Search This Blog

Clojure macros for beginners

This article will guide you step-by-step (or even character-by-character) through the process of writing macros in Clojure. I will focus on fundamental macro characteristics while explaining what happens behind the scenes.

Cool, but imagine this test failing on CI server or seeing this in your terminal. There is no context, maybe you’ll get test name if you’re lucky. “Expected 1 but was 2” tells us nothing about the nature or root cause of the problem. Wouldn’t it be great to see:

AssertionError Expected '(count (filter even? primes))' to be 1 but was 2

You see this? Assertion error now gives us full expression that yielded incorrect result. We can see from the very first second what the issue can be. However, there is a problem. Big one. By the time we are throwing AssertionError, original expression is lost. We got actualvalue as an argument and we have no idea where did that value came from. It could have been a constant, result of expression like (count (filter even? primes)) or even a random value. Function arguments are computed eagerly and there is no way to access code that produced these arguments.

Entering macros

Macros and functions in Clojure are not independent or orthogonal. In fact, they are almost the same:

Functions execute at run time, they take and produce data (values). Conceptually one can replace every (pure) function invocation with its value.

Macros execute at compile time, they take and produce code. Conceptually one can replace (expand) every occurrence of macro with its value.

Not that much different? Moreover since Clojure is homoiconic, Clojure code can be represented as Clojure data structures. In other words both functions and macros accept data, but in case of macros it’s more often to see Clojure source represented using data structures like lists.

What does it all mean and how can it help us? Let’s jump straight into writing our first (incorrect) macro and improve it step-by-step to finally achieve desired result. To keep samples focused I skip throwing an AssertionError and leave only equality condition:

1 + 2 is definitely equal to 3, yet it returns false. In order to appreciate this behaviour and call it “feature” rather than “bug” we must deeply understand what just happened. Remember, macros are executed at compile time, right? And they are almost ordinary functions. So, the compiler executes assert-equals. However during compilation it can’t possibly know the values of variables like x, therefore it can’t eagerly evaluate macro arguments. We don’t even want that, as you see later.

Instead the compiler passes Clojure code, literally. The actual parameter is (inc 5) - literally, Clojure list holding two elements: inc symbol and 5 number. That’s all there is to it. expected is just a number. This means that inside macro we have full access to Clojure source code enclosed by that macro.

So maybe you can now guess what happens. Clojure compiler executes macro definition, that is (= expected actual). As far as the compiler is concerned, actual is a list (inc 5) while expected is a number 6. List can never possibly be equal to a number. Thus macro returns false, just like any other function can return it. Later on Clojure compiler replaces (assert-equals (inc 5) 6) expression with the outcome of macro, which happens to be… false. We said before that macro should return valid Clojure code (represented using Clojure data structures). falseis valid Clojure code!

Now we know that instead of evaluating (= expected actual) by the compiler (after all, we don’t want the compiler to run our assertions, we only want to compile them!) we simply want to return code that represents this assertion. It’s not that hard!

(defmacro assert-equals [actual expected] (list '= expected actual))

Now our macro returns result of evaluating (list '= expected actual) expression. The result happens to be… (= expected actual). That’s right, it looks like valid Clojure code, again. Extra quote ('=) was added so that = is interpreted as raw symbol rather than a function reference. Let’s take it for a test drive:

macroexpand and macroexpand-1 are your weapons of choice when debugging macros. Here you see that (assert-equals (inc 5) 6) is actually being replaced by (= 6 (inc 5)). This process happens at compile time, macros don’t exist at runtime. In your compiled code you are left with (= 6 (inc 5)). OK, so let’s restore the full functionality of throwing AssertionError. As you know by now, our macro should return Clojure code that includes equality check and throwing an exception. This becomes a bit unwieldy:

Notice how every single symbol has to be escaped ('when-not, 'throw, 'AssertionError., …), otherwise compiler will try to evaluate it at compile time. Moreover list in Clojure denotes function call so we must proceed every list literal with (list ...) function call. If you are not that familiar with Clojure: (list 1 2) returns list of (1 2) while (1 2) will throw an exception since 1 number is not a function.

We barely reproduced what original assert-equals function was doing and the first commandment of writing macros is: don’t write macros if function is sufficient. But before we go further, let us clean up what we have so far. Typical macro definition consists of lots of Clojure code that has to be escaped and not that much live values like actual and expected in our case. So there is a smart default - instead of quoting everything except few items, quote everything upfront and selectively unquote things. This is called syntax-quoting (using ` character) and unquoting is done via ~ operator. Look carefully: we syntax quote whole result and selectively unquote what was previously not quoted:

This is equivalent to previous definition but looks much better, almost entirely like valid Clojure code. Let’s employ macroexpand-1 to see how our macro is expanded during compilation. macroexpand would work as well, but since when-not is also a macro (!) it would be recursively expanded, cluttering output:

It’s like templating language embedded within that language! Notice how (inc 5) piece of code was inserted instead of ~actual twice. Keep that in mind. Also experiment by removing unquote (~) symbol here or there. Use macroexpand-1 to figure out what is going on.

Remember, our ultimate goal was to show actual expression in its full glory, not only its value.

(AssertionError.
(str "Expected '???' to be " ~expected " but was " actual-value#))))))

What should we put in place of ??? to print “(inc 5)” string. We know that value of actual is not6 but a list with two items: (inc 5). Can we somehow quote that list again so that it no longer evaluates at run-time but instead is treated as a data structure? Of course, we know how to quote things!

(answer question) appears twice (not counting quoted one), once during comparison and second time when we generate assertion message. This is rarely desired, especially when function under test has side effects. The solution is simple: precompute (answer question) once, store it somewhere and reference when needed. But there is a twist: declaring let bindings inside macros is tricky. Sometimes you might hit unexpected name shadowing and overriding when names of variables inside macro collide with the ones used in user code. Not going into much detail, using (gensym) or convenient # suffix is enough to keep our macros safe. In both cases Clojure compiler will produce unique names making sure they don’t collide. Our final solution looks like this:

Extra suffix replacing # symbol makes sure actual-value is not colliding with any other symbol.

Summary

Our assert-equals macro is not the most comprehensive one, just like this tutorial. But it gives you some impression of what macros can do and how they work. If you need further resources, check out this great macro tutorial (part 2 and 3). If you like the idea of enhanced assertions, Power Assertions in Groovy are even more comprehensive. But I bet this behaviour can be reproduced in Clojure macros!

Get link

Facebook

Twitter

Pinterest

Email

Other Apps

Labels

Comments

Thank you for writing this. I believe this is the best introduction to macros I've ever read, and the reasoning and syntax is finally starting to make sense. I definitely the time and effort it took to complete this. Thanks again.