Search code examples
clojuremacros

How Clojure Cond macro works


I am fairly comfortable with Clojure but have always shied away from macros. In an attempt to remedy that, I'm reading through "Mastering Clojure Macros" and also looking at some Clojure core macros generally.

While reading over the cond macro, I got a little tripped up as to what is actually being evaluated when. Assuming clauses is not nil and that the initial when test passes, we then evaluate the list call. List is a function, so it must first evaluate all of it's arguments before entering it's body. The first argument is just the symbol 'if, the next argument is then (first claues) which evaluates to the first test, but then the part I found a bit confusing is what happens with the next (3rd) argument. It looks like the entire form:

(if (next clauses)
    (second clauses)
    (throw (IllegalArgumentException.
            "cond requires an even number of forms")))


is actually evaluated before the final macroexpansion is returned for evaluation. If this is correct, does that mean that the test for an even number of forms occurs before the macro is actually expanded, and therefore can bail with an exception before the macro has actually generated a list for evaluation at runtime?

(defmacro cond
  "Takes a set of test/expr pairs. It evaluates each test one at a
  time.  If a test returns logical true, cond evaluates and returns
  the value of the corresponding expr and doesn't evaluate any of the
  other tests or exprs. (cond) returns nil."
  {:added "1.0"}
  [& clauses]
    (when clauses
      (list 'if (first clauses)
            (if (next clauses)
                (second clauses)
                (throw (IllegalArgumentException.
                         "cond requires an even number of forms")))
            (cons 'clojure.core/cond (next (next clauses))))))

Solution

  • The easiest way to see how a macro works is to check it with clojure.core/macroexpand-1 or clojure.walk/macroexpand-all.

    We can for example see how the following form will be expanded:

    (cond
      (pos? 1) :positive
      (neg? -1) :negative)
    

    with macroexpand-1:

    (macroexpand-1
      '(cond
        (pos? 1) :positive
        (neg? -1) :negative))
    
    ;; => (if (pos? 1) :positive (clojure.core/cond (neg? -1) :negative))
    

    We can see that when that form is expanded, clauses is bound to the sequence of these expressions: (pos? 1), :positive, (neg? -1) and :negative.

    (first clauses) will evaluate to (pos? 1) and its value will be used as the test expression for the emitted if. Then the macro checks if the first predicate has its required result expression by checking if it has more than one clause: (next clauses) will evaluate to (:positive (neg? -1) :negative) which is truthy and the true branch of the emitted if will get the value of (second clauses) which is :positive.

    The else branch of the emitted if will get (clojure.core/cond (neg? -1) :negative). As the emitted code will again include a call to cond macro, it will be called again and expanded again.

    To see the fully expanded code we can use clojure.walk/macroexpand-all:

    (require 'clojure.walk)
    
    (clojure.walk/macroexpand-all
          '(cond
            (pos? 1) :positive
            (neg? -1) :negative))
    ;; => (if (pos? 1) :positive (if (neg? -1) :negative nil))
    

    To expand on the topic if the forms included in clauses are evaluated during macro expansion, we can inject some side effects into the code:

    (clojure.walk/macroexpand-all
      '(cond
         (do
           (println "(pos? 1) evaluated!")
           (pos? 1))
         (do
           (println ":positive evaluated1")
           :positive)
    
         (do
           (println "(neg? -1) evaluated!")
           (neg? -1))
         (do
           (println ":negative evaluated!")
           :negative)))
    =>
    (if
     (do (println "(pos? 1) evaluated!") (pos? 1))
     (do (println ":positive evaluated1") :positive)
     (if (do (println "(neg? -1) evaluated!") (neg? -1)) (do (println ":negative evaluated!") :negative) nil))
    

    As we can see no side effects were executed because none of the clauses were evaluated during macro expansion.

    We can also check if the call to throw is evaluated during macro expansion by providing clauses that will cause the else branch of the (if (next clauses) ... to be called:

    (macroexpand-1 '(cond (pos? 1)))
    java.lang.IllegalArgumentException: cond requires an even number of forms
    

    Here we can see that the exception was thrown and macro expansion of the cond macro didn't complete normally by returning the macro expanded code. The reason the throw form is evaluated during macro expansion is it is not quoted (e.g. ``(throw ...)`).