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 (~), and unquote-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