Charlie Holland: Gödel, Escher, Elisp: The Beauty of Macros

Wait 5 sec.

1. TLDRIf you are an Emacs user with a keen eye, you will have noticed that in Emacs Lisp, code is data. After all, 'Lisp' is shorthand for 'List Processing'. One of Elisp's most beautiful features is the fortuitous blur between the thing that is processing the list (the program) and the list itself (the data). The macro in Elisp is a utility that exploits this blur and allows you to leverage this dualism between program and data in many useful and fascinating ways.In this post, I want to swoon about macros, explain what "homoiconic" actually means, demonstrate their ubiquity in Elisp, depict their beauty on a detour through Hofstadter's strange loops and Escher's lithographs, and finally show off some tooling (macroexpand, emacs-lisp-macroexpand, macrostep) that enhances both comprehension and appreciation of macros.Here is the Escher imagery we'll be leaning on along the way: 2. Programs as Data, Data as Programs   emacs elisp lispThe kernel of Lisp has a crystalline purity that not only appeals to the esthetic sense, but also makes Lisp a far more flexible language than most others.— Douglas HofstadterAn important word for this post is homoiconic. A language is homoiconic when its programs are written in the language's own data structures.Many languages are homoiconic, but perhaps none more obviously so than in Emacs Lisp (Elisp). In Elisp, source code is lists, symbols, strings, and numbers. Code looks exactly the same as lists you build with cons and take apart with car and cdr.The distinction between program and data is exhibited by a specific, special character, the glorious ':;; a program: evaluates to 3(+ 1 2);; data: a list of three elements — a symbol and two numbers'(+ 1 2)The quote turns the contents of the following parentheses into a list of data elements.To emphasize that program and data are equivalent in Elisp, running eval on the quoted list (as in (eval '(+ 1 2))) will turn it into a program, where the function is addition, and its arguments are the numbers 1 and 2.That dualism lies at the heart of the language. Any piece of code is one character away from being a value you can inspect, transform, and rebuild; and any suitably-shaped value is one function call away from being a program.So in Elisp, we say Program = Data, even though that's a little too simplistic, because we saw how correctly the Lisp interpreter deciphers when a list is being represented as a program versus when it is being represented as data…. The point is the list: that's the unifying form. Maybe more appropriately, we can say the program and data take the same form, or as previously mentioned, Elisp's programs are written in Elisp's own data structures.This post was motivated by a simultaneous obsession with Douglas Hofstadter's writing and Elisp macros, so be prepared for many depictive metaphors from one of Hofstadter's favourite artists, M.C. Escher. Here's the first:Escher drew this kind of dualism as a woodcut. The ants of Möbius Strip II appear to march on both sides of a strip. The image is provocative enough at first glance, but I invite you to follow any one of them around and discover that the two sides are one continuous surface. Program and data are the two sides of Elisp's homoiconic Möbius.Figure 1: M.C. Escher, Möbius Strip II (1963). Two sides, one surface. © The M.C. Escher Company.Most languages shoehorn metaprogramming into other features that are difficult to understand and difficult to use, but in Elisp there was never a wall between code and data to tunnel through. It's the same language, and the same data structures, all the way down.3. What a Macro Actually Is   emacs elisp macrosConsider a regular function in Elisp. A function receives values and computes a value at runtime.On the other hand, a macro receives code (the raw, unevaluated forms typed at its call site) and returns new code, which is then evaluated in its place. Macros run at expansion time, before your program does. I like to think of macros as little programs that write other programs given the arbitrary forms they can accept. The complexity of that form -> program projection is essentially infinite, or at least bounded by what you can express in Elisp, which is very likely bounded by your imagination.The macro's form -> program toolkit is quasiquotation: backquote ` builds a code template, comma , splices a computed piece in, and ,@ splices in a whole list. Here's the smallest real macro I can write, a reimplementation of unless:(defmacro my-unless (condition &rest body) "Run BODY unless CONDITION is non-nil." (declare (indent 1)) `(if ,condition nil ,@body))We can actually ask Emacs what this macro will get expanded to. The first code block is the macro-expansion, the second is the expansion of the macro:(macroexpand-1 '(my-unless (file-exists-p "~/notes") (make-directory "~/notes") (message "created it")))(if (file-exists-p "~/notes") nil (make-directory "~/notes") (message "created it"))To anticipate a common question: why couldn't my-unless be a function? Function arguments are evaluated eagerly, before the function ever sees them. A function version would have already created the directory and printed the message while its arguments were being prepared. In other words, the function receives the results of the body, but the point of using a macro here is to decide whether the body runs at all. A macro receives the body as inert data, so control flow itself is up for grabs. In this way, you are extending what the language can express.4. You've Been Using Macros All Along   emacs elisp macrosMacros may seem specialist or eccentric…. I hope this surprises the Elispiens who are reading this! It certainly surprised me!when and unless are macros over if.dolist and dotimes are macros over while.push, pop, and setf are macros that rewrite themselves into the right mutation for the place you provide them with.with-current-buffer, with-temp-buffer, and ignore-errors are macros that wrap your code in the correct save-and-restore ceremony so you never have to type it.Even defun is a macro!The most justifiably famous macro in any Emacs config is use-package:(use-package magit :bind ("C-c g" . magit-status) :hook (git-commit-mode . flyspell-mode)):bind and :hook aren't Elisp, but rather keywords in a small configuration language, and the use-package macro is its 'compiler', expanding the declaration into the require calls, keymap bindings, hooks, and autoload deferrals that would otherwise need to be written out by hand in their full, verbose form.The define-minor-mode macro is similar in this way. One declaration expands into a variable, an interactive toggle command, keymap wiring, and documentation. This is what is meant by macros letting you grow a language toward the problem. With macro use, your config can read more like a declarative description of what you want, because someone built a macro for that (in use-package's case, shout out to John Wiegley).The most shocking instance in my deep dive was defun. Evaluate (macrop 'defun) and Emacs says t. Yes, defun is a macro (again, the first code block is the macro expand, the second is the expanded macro):(macroexpand-1 '(defun greet (name) "Say hi." (message "Hi, %s" name)))(defalias 'greet #'(lambda (name) "Say hi." (message "Hi, %s" name)))Defining a function turns out to mean this:build an anonymous functionalias a symbol to itMore surprises….Did you know that lambda itself is also a macro (albeit a delightfully small one that expands into a quoted version of itself)? Surely not my beloved defcustom? Yes, my fellow Emacsapien, that is also a macro.(macroexpand-1 '(defcustom chiply/favorite-lithograph "Drawing Hands" "Which Escher lithograph to contemplate while macroexpanding." :type 'string :group 'chiply))(custom-declare-variable 'chiply/favorite-lithograph '"Drawing Hands" "Which Escher lithograph to contemplate while macroexpanding." :type 'string :group 'chiply)cl-loop, that entire iteration mini-language, much overused by yours truly? That's also a macro!If you want to see all the macros, just run this.(let (names) (mapatoms (lambda (s) (when (macrop s) (push (symbol-name s) names)))) (with-temp-buffer (setq fill-column 72) (insert (mapconcat #'identity (sort names #'string