Less macros more functions
1. Preface
This article shares my experiences working with macros in Clojure.
2. Prerequisite knowledge
- Familiarity with basic Clojure syntax and macros, and the ability to write running Clojure code.
- Understanding of
defmacro
,quote (`)
,unquote (~)
, andunquote-splicing (~@)
. - Awareness of the evaluation differences between macros and functions.
3. When to use macros?
Macros are notoriously difficult to reason about, so as a rule of thumb, we should minimize their use.
In my experience, if a code pattern repeats more than three times, I first try to abstract it as a function. Only when a function cannot achieve the desired behavior do I consider implementing a macro, and even then, after careful thought.
Following this principle, most macros I write end up serving real and practical use cases.
You might consider writing a macro under these circumstances:
- The functionality is hard or impossible to implement as a function.
- You need to alter the default evaluation behavior (introducing new syntax).
4. A few classic macros
Let’s review some classic macros. Encountering these patterns often signals the need for a macro.
4.1. Creating context
Macros can manipulate lexical environments. For example, we can implement our own let
:
(defmacro our-let [binds & body] `((fn ~(vec (map first (partition-all 2 binds))) ~@body) ~@(map second (partition-all 2 binds)))) (our-let [a 1 b 2] (+ a b))
Q: The number of parameters bound to
let
is limited. How can we expand it?
Another scenario is embedding calculations in a specific context. For example, measuring execution time:
(defmacro cal-time [& body] `(let [start# (System/currentTimeMillis) result# (do ~@body) end# (System/currentTimeMillis)] {:time-ms (- end# start#) :result result#})) (cal-time (Thread/sleep 5000))
4.2. Resource management macros
For example, with-open
automatically prepares and cleans up resources to prevent leaks:
(defmacro with-open [bindings & body] (cond (= (count bindings) 0) `(do ~@body) (symbol? (bindings 0)) `(let ~(subvec bindings 0 2) (try (with-open ~(subvec bindings 2) ~@body) (finally (. ~(bindings 0) close)))) :else (throw (IllegalArgumentException. "with-open only allows Symbols in bindings"))))
4.3. Conditional evaluation
Macros can defer evaluation. For example, when
in clojure.core
:
(defmacro when [test & body] (if test (do ~@body) nil)) (when false (prn 123))
Q: Is there another way to prevent macro parameters from being evaluated?
4.4. Repeated evaluation
(defmacro while [test & body] `(loop [] (when ~test ~@body (recur)))) (def a (atom 10)) (while (pos? @a) (do (println @a) (swap! a dec)))
4.5. Compile-time computation
Values known at compile-time can be calculated early, improving runtime performance:
(defmacro params-number [& body] `(let [n# ~(count body)] n#)) (params-number 1 2 3)
5. How I write a macro
Here’s my workflow when writing a complex macro. The following example is not my original code :):
(let [rt1 (exist-user? user)] (if (fail? rt1) rt1 (let [rt2 (channel-is-full? channel)] (if (fail? rt2) rt2 (let [rt3 (add-member channel-id user-id)] rt3 (ok))))))
Three main steps:
- Step 1: Express the macro logic in a generic way and identify the pattern.
- Step 2: Write the syntax template you prefer:
(new-macro (exist-user? user) (channel-is-full? channel) (add-member channel-id user-id) (ok))
- Step 3: Translate Step 2 into Step 1 implementation:
(defmacro new-macro [first-clause & clauses] (let [g (gensym)] `(let [~g ~first-clause] (if (fail? (first ~g) ~g ~(if clauses `(new-macro ~@clauses) g)))))
6. Miscellaneous
6.1. Avoid macros unless necessary
6.1.1. For the author
Writing macros is mentally demanding. Macros involve compile-time computation, so you must consider whether code runs during compilation or runtime, which is error-prone.
6.1.2. For the reviewer
Reading macros is harder than writing them. Macros are a black box. The evaluation order of macro parameters is not guaranteed (unlike functions). Expanding the macro is often required to understand behavior.
6.2. Poor composability
Functions compose well—they can be passed as arguments, returned, or combined with comp
or apply
. Macros, in contrast, are less orthogonal. Favor functions whenever possible.
6.3. Macros as thin wrappers
Macros should only wrap the part that requires altered evaluation. Many macros can be simplified by extracting helper functions.
;;;; Tag the result (defmacro tag-result [group tag date & body] `(let [result# ~body group# (str ~group "-addition-group") tag# (str ~tag "-addition-group") date# (t/plus ~date (t/days 5))] {:group group# :tag tag# :date date# :result result#})) (tag-result "group" "tag" (t/now) (+ 4 5 6 7 8)) ;;;; Cleaner version using a helper function (defn tag-result-f [group tag date result] (let [group (str group "-addition-group") tag (str tag "-addition-group") date (t/plus date (t/days 5))] {:group group :tag tag :date date :result result})) (defmacro another-tag-result [group tag date & body] `(let [result# ~(reverse body)] (tag-result-f ~group ~tag ~date result#))) (another-tag-result "group" "tag" (t/now) (+ 4 5 6 7 8))
6.4. Parameter validation
Always validate parameters. Fail fast when arguments are invalid. Debugging macros is expensive, so defensive checks are critical.
6.5. Watch out for repeated evaluation
Re-evaluating parameters can easily introduce subtle bugs.
(defmacro transform-http-result [& body] `(try (log/debugf "http-result: %s" ~@body) (transform ~@body)))
6.6. Destructive parameter patterns
(defmacro test-a [[& opts] & body] (prn (class opts)) (prn (class body))) => #'user/test-a (test-a [4 5 6] 1 2 3) clojure.lang.PersistentVector$ChunkedSeq clojure.lang.PersistentList
Note that the two &
sequences produce different types. Use (vec opts)
to normalize them.
7. References
clojure.core
source code- On Lisp - Paul Graham
- Desensitization code at work