You may have heard of the famous “Macro” system in Common Lisp and many other Lisp dialects. You cannot realize its magic if you’ve never touched Lisp, as you cannot compare its features to those of competitors elsewhere. This is because of its unique features and smart design under the hood.
The first feature is “compile-time function execution”. Let’s look at the macro in C. It looks like #define name[(args)] [expr]
. Even if it can be long, has multiple lines and expressions,
printf("%d", num);\
printf(" is");\
printf(" %s number", str);\
printf("\n");\
})
An example from https://www.geeksforgeeks.org/multiline-macros-in-c/
But the idea behind is very simple: just search and replace, no logic computation can be taken during the macro being expanded. So that you cannot do something like generate multiple expressions based on the number of arguments.
(defmacro reset (&rest list-of-vars)
(cons 'progn
(loop for var in list-of-vars
collect `(setq ,var nil))))
; (reset foo bar baz) => (progn (setq foo nil) (setq bar nil) (setq baz nil))
C: ???
A similar feature can be found in Rust’s Macros, and that’s why Rust’s macros get such high praise from its users. But Rust’s macros only support limited operators during expansion — compared with the next unique feature of Lisp macros.
Yeah, some languages allow you to do some logic during macro expansion (actually before or in compilation), but what if your requirements go beyond the predefined operators? Can we employ more operators, functions, and even runtime features to yield the expansion?
If you’re sticking to static programming languages, you will say “No” as it’s impossible. Fortunately, Lisp is not a static language and has a powerful dynamic environment. You can use any function, even if you defined one line before, inside the macro definition.
(defmacro slot-value-> (object &rest slots)
(reduce (lambda (exp slot) `(slot-value ,exp ,slot)) (cdr slots)
:initial-value `(slot-value ,object ,(first slots))))
; (slot-value-> object 'slot1 'slot2) => (slot-value (slot-value object 'slot1) 'slot2)
One of the macros we wrote and quite often used. It employs a standard Common Lisp function
REDUCE
, and returns its result immediately during compilation.
How can it be done?
Different from languages like C, Rust or Python, which have separate environments between compilation and execution, modern Lisp implementations have a compiler which is running inside its Lisp environment. It means that the environment shares its symbols, variables and functions with the compiler. The compiler can use all of them during compilation, and anything compiled will be returned and loaded into the environment instantly. This unique feature is the basis of the powerful Lisp macros.
The compilation environment inherits from the evaluation environment, and the compilation environment and evaluation environment might be identical. The evaluation environment inherits from the startup environment, and the startup environment and evaluation environment might be identical. — Compiler Terminology, Common Lisp HyperSpec
Notes: Lisp’s environment is very flexible. You can retrieve and modify the environment using the
&environment
argument andaugment-environment
, define specialcompiler-macro
, establish variable bindings during compile time usingcompiler-let
and so on. See the HyperSpec and Common Lisp the Language for more information.
Behind its name, Lisp macro is just a special type of function which works with literal arguments instead of values. Anything you can do with a function can also be done with a macro, and any existing functions can become macros, too. You can pack it into a closure, remove it with unintern
, or change it dynamically with #'(setf macro-function)
. Thanks to the wisdom of Lisp pioneers, you can do whatever you want, even inside the compiler. Nothing can suppress the true freedom.
Sometimes, Lispers will use macros instead of inline functions, as they’re more straightforward.
It has been 40 years now (since the publication of Common Lisp the Language), and it is still unique, nothing can be compared with it.
Lisp’s macro offers incredible power to extend the language easily, with efficiency remaining after expansion. The most famous (notorious) one may be the LOOP
:
(loop for i from 0 below 10 collect i)
=>
(block nil
(macrolet ((loop-finish () '(go )))
(let ((i 0)
( 10)
( 1))
(let (( (list nil)))
(declare (type list ))
(let (( ))
(tagbody (progn (if (>= i ) (go ) nil))
nil
(setq (loop::loop-set-cdr (the cons ) (list i)))
(progn (let (( (+ i ))) (setq i )) (if (>= i ) (go ) nil))
(go )
(return-from nil (cdr (the cons )))))))))
Yes… it’s also a macro. I cannot write it longer, or your screen will be blown up by the expansion. Temporary symbols (starting with “#:”) are simplified for reading. (Implementation: Lispworks)
As you can see, there are only basic operators after expansion, which can be transferred to nearly equal amounts of assembly. No run-time compensation. That’s why people say that Lisp is good for “making a new language”.
Notes: In Common Lisp, there’s also Reader Macro that can define how Lisp reads the source code. You can introduce new syntax to Lisp with it.
Also, Lisp-style macros are potent to break the barrier between syntactic expressions and function calls — But it’s meaningless for Lisp itself, since Lisp’s source code (S-expressions) is already a valid data structure in Lisp’s runtime.
Besides, is there anything better than making yourself work less and more comfortable?
But macro can also become a barrier sometimes. It makes things difficult for those who rely on “stability”.
One is static code analysis. Macro makes it impossible to analyze Lisp code statically. As we mentioned before, Lisp macros are functions, and you cannot get their results without executing them.
This yields some consequences. For example, Lisp language support is difficult to integrate into modern IDEs, as it’s difficult for Lisp to cope with the Language Server Protocol (LSP). You need additional real-time communication between the source code and the Lisp image running the LSP server. Many helping features cannot be implemented as well, like type inference and code refactoring.
But this doesn’t mean there’s nothing to use. There’s some great works like alive-lsp and scheme-langserver. There’re many things we can do.
Second, too much flexibility can corrupt generative AI, and make it difficult to help with coding. During your development in a certain project, you’ll fund a large set of helper macros and functions, import certain libraries, and finally build a subset language that is specially for your needs. For generative AI, it’s difficult to cope with those new facilities, and you may need special prompts & better models for ideal results.
Besides, complex macros can easily confuse readers who are new to Lisp, as understanding the logic of expansion and all other prerequisites is costly. This is also a reason for the division of Lisp dialects.
Many Common Lisp users will stick to the ANSI standard and only use those widespread utility libraries if possible, especially for some open-source projects. That reduces the difficulty for other members to join in development, making it more durable.