Less Macros More Functions

Preface

This article mainly talks about my experiences of using macros.

Prerequisite knowledge

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:

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:

(new-macro
    (exist-user? user)

    (channel-is-full? channel)

    (add-member channel-id user-id)
  
    (ok))
(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

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.

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

Updated on: Sat February 24, 2024