This article covers Chapter 10, Other Macro Pitfalls. The macro examples are kept simple to focus on specific concepts, and in some cases do not deserve to be macros at all. They could just as well be functions. That said, the concepts in this article are still valid for larger, more complex, deserving-of-macroness macros.
Number of Evaluations
Macros can evaluate an argument more than once, which in some cases will violate caller expectations and lead to subtle bugs. Here is a
m-e-for macro that evaluates the
stop argument more than once:
(defmacro m-e-for [[idx start stop] & body] `(loop [~idx ~start] (if (< ~idx ~stop) (do ~@body (recur (inc ~idx))))))
In a sensible implementation, the
stop expression would be evaluated once, but instead it is evaluated each time through the loop. For a simple
stop expression, this won't matter. For example, the following
m-e-for loop correctly prints the numbers from 0 to 10:
(m-e-for [i 1 10] (print i)) 123456789
But if the
stop expression has side effects, the end of the loop becomes a moving target. The following example uses an expression involving a mutable Clojure atom:
(let [a (atom 10)] (m-e-for [i 0 (swap! a dec)] (print i))) 01234
Because the atom swaps in a decremented value each time around, only half the desired values print. If the
stop expression had incremented the atom, the consequences would be even more severe. (Try it.)
The solution to the problem is simple: don't write macros that evaluate arguments multiple times, unless that is the specific purpose of the macro. The previous article in this series includes a
good-for macro that does it right, evaluating the
stop only once and storing the result in a lexical binding.
Clojure is no different from other Lisps in this problem, or in how you should handle it. However, a buggy Clojure macro that evaluates arguments multiple times is less likely to show symptoms in execution. Most Clojure code is side-effect-free. If a multiply-evaluated form has no side effects, you may see performance degrade, but you will not see incorrect behavior.
Order of Evaluation
Similar to multiple evaluation is out-of-order evaluation. Callers expect that arguments will be evaluated from left-to-right, and sometimes write code that relies on this assumption.
b-o-for ("bad order for") macro solves the multiple evaluation problem by assigning the value of
limit#. But it evaluates
stop out of order, before the other arguments:
(defmacro b-o-for [[idx start stop] & body] `(loop [limit# ~stop ~idx ~start] (if (< ~idx limit#) (do ~@body (recur limit# (inc ~idx))))))
Again, this macro works fine with simple arguments:
(b-o-for [i 1 10] (print i)) 1
But you can construct arguments that expose the flaw. The following code should print
1, but prints nothing:
(let [a (atom 1)] (b-o-for [i @a (swap! a inc)] (print i)))
The problem is that the increment (
inc) happens before the dereference (
@a), so the start state reads a value that has already been incremented past the end state.
The solution to the problem is also simple: don't write macros that evaluate arguments out of order, unless that is the specific purpose of the macro. The discussion of multiple evaluation above applies here as well.
Common Lisp expects macro expanders to be pure functions. You should not rely on the compiler expanding a macro only once.
The Clojure documentation does not address this issue. Since Clojure is "more functional" than the average Lisp, it makes sense to follow this rule wherever possible. I have started a thread on the Clojure mailing list to get more specific advice.
Macros manipulate source code, not runtime data. This means that recursive functions cannot, in general, be trivially converted into macros. If the recursion depends on runtime data, that data is not available at macro-expansion time.
OL uses an
nth function to make this example. The recursive definition of this function is:
(defn nth-a [n coll] (if (= n 0) (first coll) (nth-a (dec n) (rest coll))))
nth-a works fine:
(nth-a 2 [:a :b :c :d :e]) :c
But a simplistic conversion of
nth-a to a macro is disastrous:
(defmacro nth-b [n coll] `(if (= ~n 0) (first ~coll) (nth-b (dec ~n) (rest ~coll))))
If you try to call
(nth-b 2 [:a :b :c :d]), your REPL will hang. The problem is that the recursion happens at macro-expansion time. The expanded form
(dec ~n) is not evaluated, it is just another form. Likewise
(rest ~coll). Rather than being evaluated, and getting smaller towards terminating the recursion, the forms are just expanded, and get bigger. The recursion runs forever, generating larger and larger forms.
The solution to this problem is to write the macro so that it is not recursive during expansion. It can still generate an expansion that is recursive at runtime.
In Clojure, you could use the idiomatic
(defmacro nth-c [n coll] `(loop [n# ~n coll# ~coll] (if (= n# 0) (first coll#) (recur (dec n#) (rest coll#)))))
Or, the macro could expand into an anonymous recursive function:
(defmacro nth-e [n coll] `((fn [n# coll#] (if (= n# 0) (first coll#) (recur (dec n#) (rest coll#)))) ~n ~coll))
nth-e version is a little more verbose than the Common Lisp version, in that it uses the auto-gensym suffix (
#) to generate unique local names for the function every time the macro is expanded. The OL version does not use gensyms at all, and in fact they are not really necessary in this example.
Even though the gensyms are not necessary in
nth-e, I like that Clojure requires them. Auto-gensyms are simple enough that I prefer to use them for all "local" symbols inside syntax-quotes, and never worry about when I can get away with shadowing a lexical binding.
In the previous article, I argued that Clojure makes simple macros easier and safer to write. But "easier" and "simpler" does not equal "easy" and "safe." In this chapter, we have looked at a set of macro pitfalls where the Clojure approach is more similar to other Lisps:
- number of evaluations
- out of order evaluation
- non-functional expanders
For these specific pitfalls, use the same strategies in Clojure that you would in other Lisps.
- The sample code is available at http://github.com/stuarthalloway/onlisp-clojure.
If you find this series helpful, you might also like:
- 2008/12/20: initial version