Less Macros More Functions
Preface
This article mainly talks about my experiences of using macros.
Prerequisite knowledge
- Understand the basic grammar of Clojure and the primary usage of macros, and have written some running Clojure code.
- Know the meaning of defmacro, quote (`), unquote(~), unquote-splicing(~@).
- Know the differences of evaluation between macros and functions.
When to use macros?
One big problem with macros is that they are hard to understand. So, principally, we should use macros as little as possible.
In my experience, if only a pattern is repeated more than three times, I will have a try to abstract a function; And only if I cannot achieve a function at all would I implement it as a macro after prudent consideration.
After following this principle, I found that, in most cases, I can finally build a macro that has real usage scenarios.
Technically, you may consider making macros under these conditions:
- Hardly implement with a function;
- Need to change the default evaluation behavior (involving the addition of new syntax).
A few classic macros
Let’s recall some classic macros. When encountering these similar scenes, we may realize a need to write a macro here.
Creating context
Referring to the lexical environment.
For example, We can use macros to create our 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 to expand it?.
Another case refers to various states around the calculation.
An Example: calculating time. Insert code snippets into a specific context.
(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))
With-macros
For example, the macro with-open
.It will prepare and clean up some resources automatically to avoid potential memory leakage.
(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"))))
Conditional evaluation
The parameters are evaluated only under certain circumstances.
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 protect parameters from being evaluated?
Repeat the evaluation of the parameters.
(defmacro while
[test & body]
`(loop []
(when ~test
~@body
(recur))))
(def a (atom 10))
(while (pos? @a) (do (println @a) (swap! a dec)))
Compile time calculation
If the values are determined at compile-time, we can calculate them directly at compile time. The results can be used directly at runtime to speed up calculations.
(defmacro params-number [& body]
`(let [n# ~(count body)]
n#))
(params-number 1 2 3)
How I write a macro
I would like to share what I will do when writing a complex macro. The macro below is not written by me : ).
Consider we encounter a condition that requires writing deep nested if-else-if-else expression; And if the resulting success continues, otherwise return immediately.
(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))))))
There are three main steps:
-
step1: Use a general way to express the macro’s logic and think about the pattern.
-
step2: Write out the syntax template you prefer to use.
(new-macro
(exist-user? user)
(channel-is-full? channel)
(add-member channel-id user-id)
(ok))
- step3: Translate the code of step2 into step1.
(defmacro new-macro
[first-clause & clauses]
(let [g (gensym)]
`(let [~g ~first-clause]
(if (fail? (first ~g)
~g
~(if clauses
`(new-macro ~@clauses)
g)))))
Misc
Don’t make a macro unless you have no choice
- Writer
Macro is complicated for the writer.
Macros involve compile-time calculations. You always have to consider whether the code runs during compilation or runtime, which significantly increases the mental burden and is very error-prone.
- Reader
Macro is more difficult for the reader than the writer. It’s a black box for the reader.
You cannot determine the evaluation order of macro parameters (We know that the evaluation of a function is always evaluated from left to right, and macros do not have this guarantee). Also, you have to expand the macro code to confirm the behavior manually.
Macros provide poorly composability
Functions have good composability. It can be used as a parameter or as a return. It can also be combined with comp
or apply
. However, macros can’t. Macro’s orthogonality is very poor. We should use functions more often.
Macro only encapsulates a thin layer over function
Macros should only involve in the specific part which needs to change the evaluation behavior;
Many macros that I have read can be simplified by just extracting a helper function that doesn’t require changing the default evaluation behavior.
;;;; 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))
;;;; A better implement. We can extract the process of tagging.
(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))
Parameter check
This is the same rule as for functions. We should check the parameter in runtime and even in compile time.
A simple general tip is that only allowing the correct parameters and exit immediately when the check fails - fail fast.
The cost of debugging a macro is very high, so we have to do more strict defensive checks than functions.
Be wary of repeated evaluations
It’s easy to write buggy macros caused by repeated evaluation, and some of them are not easy to debug sometimes.
(defmacro transform-http-result
[& body]
`(try
(log/debugf "http-result: %s" ~@body)
(transform ~@body)))
Destructive parameter
(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
We can see that the data structures of the two &
are different.
To convert them into data structures, you need to convert them with (vec opts)
.
Reference
- clojure.core source code
- on lisp - Paul Graham
- Desensitization code at work
Updated on: Sat February 24, 2024