Built-in Macros
In this chapter, we'll explore a few of the macros built in to GameLisp's standard library.
While reading through, bear in mind that none of these macros are doing anything which requires special support from the runtime - if they didn't exist, it would be possible for you to implement all of them yourself, using the information you learned in the previous chapter.
Control Flow
The when
and unless
macros were introduced previously.
while
, loop
, until
while
and loop
are similar to their Rust counterparts:
(let i 0)
(while (< i 5)
(inc! i)
(pr i " ")) ; prints 1 2 3 4
(loop
(prn "around and around we go"))
until
is like while
, but it terminates the loop when its condition is
"truthy", rather than stopping when it's
"falsy". We could rewrite the above while
loop as:
(let i 0)
(until (>= i 5)
(inc! i)
(pr i " ")) ; prints 1 2 3 4
The break
and continue
macros work as they do in Rust.
By default, all looping constructs return #n
, but break-with-value is supported:
(prn (loop
(break 10))) ; prints 10
and
, or
The and
and or
macros provide lazy boolean evaluation, just like
Rust's &&
and ||
operators.
and
evaluates each of its arguments from left to right. If one of them returns a
"falsy" value, the and
form returns that value and does not
evaluate any more arguments. Otherwise, it returns the result of evaluating its rightmost
argument.
Similarly, or
evaluates each of its arguments from left to right, stopping as soon as it
produces a "truthy" value.
cond
The cond
macro is reminiscent of Rust's match
, but it's simpler. (GameLisp does
also have a full fledged match
macro, which we'll discuss in a future
chapter.)
cond
receives a series of clauses, where the first form in each clause is a
condition. It checks each condition in turn, evaluating the body of the first clause whose
condition is "truthy", and returning the evaluation result of the
last form in that body.
(cond
(condition-0 ; if this condition is truthy...
then-0) ; this form is evaluated and returned
(condition-1 ; otherwise, if this condition is truthy...
then-1a ; this form is evaluated, then...
then-1b) ; this form is evaluated and returned
(condition-2) ; otherwise, if this condition is truthy, it's returned
(else ; otherwise...
then-e)) ; this form is evaluated and returned
The final clause's condition can be the symbol else
, in which case the condition always
passes. If there is no else
clause and none of the conditions are
"truthy", the cond
form returns #n
.
If a clause only contains a condition, without any other forms, then that clause's return value is the result of evaluating the condition itself.
When a branching expression is long enough that it needs to be split across multiple lines,
and it has both a "then" branch and an "else" branch, it's good practice to use cond
rather than if
:
; can be confusing
(if (eq? emotion 'angry)
(draw 0 0 w h 'crimson)
(draw 3 3 (- w 3) (- h 3) 'cerulean))
; more verbose, but easier to edit and less confusing
(cond
((eq? emotion 'angry)
(draw 0 0 w h 'crimson))
(else
(draw 3 3 (- w 3) (- h 3) 'cerulean)))
Defining Globals
We introduced bind-global!
in the Evaluation chapter.
bind-global!
is such a common operation that we provide a macro shorthand for it,
def
.
(def clip:door-open (load-clip "door-open.mp3"))
; ...is equivalent to...
(bind-global! 'clip:door-open (load-clip "door-open.mp3"))
Bear in mind that bind-global!
will trigger an error if its global is already bound, so you
can't necessarily use def
where you would use let
. It's generally only used at the toplevel.
def
's syntax is otherwise identical to let
: it can accept three or more arguments
(equivalent to multiple consecutive def
forms), and it performs destructuring
on its left-hand side.
defn
is a similar shorthand for defining global functions:
(defn brew (cauldron)
(push! cauldron 'eye-of-newt))
; ...is equivalent to...
(bind-global! 'brew (fn &name brew (cauldron)
(push! cauldron 'eye-of-newt)))
And we have defmacro
for global macros:
(defmacro with-pointy-hat (..body)
`(do
(put-on-hat)
~..body
(remove-hat)))
; ...is equivalent to...
(bind-macro! 'with-pointy-hat (fn &name with-pointy-hat (..body)
`(do
(put-on-hat)
~..body
(remove-hat))))
In both cases, a &name
clause is used to assign a symbol as the function's name for debugging
purposes. This means that defn
and defmacro
generally lead to a nicer debugging experience,
compared to using bind-global!
and bind-macro!
directly.
Recursive Local Functions
Defining a local function which calls itself recursively can be awkward:
(let recurse (fn (n)
(cond
((> n 5)
(recurse (- n 1)))
(else
(prn n)))))
(recurse 10.5) ; error: unbound symbol 'recurse'
The outer call to recurse
works as expected, but the let
binding is not in scope for the
recursive call, so it attempts to access a global variable named recurse
instead.
The let-fn
macro solves this problem:
(let-fn recurse (n)
(cond
((> n 5)
(recurse (- n 1)))
(else
(prn n))))
(recurse 10.5) ; prints 4.5
This works by initializing the local variable to #n
first, and then immediately assigning the
function to it, so that the local binding is in scope throughout the function's body. It's
equivalent to:
(let recurse)
(= recurse (fn (n) ...))
Assignment
In GameLisp, all variables and collections are mutable by default.
The =
macro can be used to assign a new value to a place. A place might be a
global or local variable, an array element, a table value, or any number of other things.
Simple use of =
looks like this:
(let a 10, b 20, c 30)
(= a 40)
(= b 50, c 60)
(prn (+ a b c)) ; prints 150
When =
's first argument is a function call, it will inspect the name of the function, then
replace the entire =
form with a call to the corresponding "setter" function.
(= (macro 'brew) (fn () #n))
(= (global 'cursed?) #t)
; ...is equivalent to...
(macro= 'brew (fn () #n))
(global= 'cursed? #t)
Naming Conventions
Functions which assign a value to some memory location are suffixed with =
, while functions
which perform other kinds of mutation are suffixed with !
.
; reads as "set the global cursed? to #t"
(global= 'cursed? #t)
; reads as "push the new-cat to the arr-of-cats"
(push! arr-of-cats new-cat)
; reads as "increment the cauldron-weight"
(inc! cauldron-weight)
Where possible, it's good style to use the =
macro, rather than calling a setter function
directly. The =
macro is easier to spot when visually scanning through the source code.
In-Place Mutation
It's a very common operation to read the value currently stored in a place, pass it to a function (perhaps with some other arguments), and then assign the function's return value to that same place. We provide a number of macros to make this easier.
(let n 0)
(inc! n 5) ; add 5 to n
(mul! n 2 2 2) ; multiply n by 2, three times
(prn n) ; prints 40
inc!
, dec!
, mul!
,
div!
, div-euclid!
, rem!
,
rem-euclid!
, abs!
and clamp!
can be used to perform basic arithmetic in-place.
The (swap! a b)
macro swaps the values stored in any two places.
; the two places can be different from one another
(swap! my-local-var (global 'my-global-var))
(swap! [my-arr 0] [my-arr -1])
Arrows
The ->
and ->>
macros perform a simple syntax
transformation which can make deeply-nested function calls easier to read.
The ->
macro accepts a form followed by any number of arrays or symbols. The form is evaluated
as normal, and it's then passed through a chain of function calls, where the return value of
each call acts as the first argument to the next call.
(fourth (third (second (first a) b) c))
; ...could be refactored into...
(-> (first a) (second b) (third c) fourth)
; ...which is equivalent to...
(do
(let tmp-1 (first a))
(let tmp-2 (second tmp-1 b))
(let tmp-3 (third tmp-2 c))
(fourth tmp-3))
The ->>
macro is similar, except that it passes in each form's result as the last argument
to the following call, rather than its first argument.
(fourth (third c (second b (first a))))
; ...could be refactored into...
(->> (first a) (second b) (third c) fourth)
; ...which is equivalent to...
(do
(let tmp-1 (first a))
(let tmp-2 (second b tmp-1))
(let tmp-3 (third c tmp-2))
(fourth tmp-3))
When you have a several function calls deeply nested inside one other, it's usually possible to
refactor them with either ->
or ->>
. Having data flow from start to finish can be much
easier to follow, compared to data which travels up and down a call-tree.
(+ ..(map (fn (n) (* n 2)) (filter even? (arr 1 2 3 4))))
; ...could be refactored into...
(->>
(arr 1 2 3 4)
(filter even?)
(map (fn (n) (* n 2)))
..+)
It's a little like a chain of method calls. In Rust, the above would be written as:
#![allow(unused_variables)] fn main() { [1, 2, 3, 4] .filter(is_even) .map(|n| n * 2) .sum() }