How I Know I Don’t Know Enough About Lisp Macros June 19th, 2009
Patrick Stein

I have a pretty good grasp on macros in Lisp, C/C++, TeX, m4, and nroff. Lisp macros are, far and away, the most powerful in that set. In fact, Lisp macros get a bad rap by sharing a name with macros in those other languages. Still, I often hit the same wall when trying to write a Lisp macro so I know that there’s more I have to learn.

A Quick Look at Macros

First, let’s review macros for a moment to make sure we’re all on the same page. The C/C++ macros are the simplest to understand so we’ll start there. The C and C++ compilers run in two stages. First, the C preprocessor runs to expand all of your macros. Then, the compiler runs to actually compile your code. In C and C++, macros are declared using the #define command to the preprocessor. For example, you might do something like the following:

#define START_TIMER  __start_time__ = get_current_time();
#define END_TIMER    __elapsed__ = get_current_time() - __start_time__;

Then, later in you code, you might do something like:

START_TIMER
    for (ii = 0; ii < MAX_ITERATIONS; ++i) {
        do_foo();
    }
END_TIMER

The C preprocessor will replace the START_TIMER and END_TIMER with the code you declared in the #define. Your resulting code would then go into the actual compile phase looking like this:

__start_time__ = get_current_time();
    for (ii = 0; ii < MAX_ITERATIONS; ++i) {
        do_foo();
    }
__elapsed__ = get_current_time() - __start_time__;

The preprocessor did some simple string replacements on your behalf. [Actually, it is somewhat helpful to think of it as doing token replacement instead since the name of your macro has to be a valid C token and you have to jump through some ugly, implementation-specific hoops to get it to concatenate things rather than whitespace separate them.] You can also create macros with arguments like:

#define WARN(x,y)     log_message( __LOG_LEVEL_WARN__, x, y )
...
WARN( "buffer_copy()", "Output buffer is not properly aligned" );

If you look back at the START_TIMER and END_TIMER macros above, you will notice that the semicolons, which delimit statements in C and C++, are included in the macro definitions. As such, they aren’t used explicitly where the macros are invoked. So? Well, that means that your program needn’t bear any resemblance to compilable C before the preprocessor is run. Some sets of macros exploit this to make your C code look like FORTRAN or Bourne Shell or PASCAL or C++.

Lisp macros are a different beast entirely. Invoking a macro in Lisp looks just like everything else in Lisp. [To be fair, some macros (like (LOOP …)) go a little nutty and make things look like a Lisp wrapper around some non-Lisp. Most macros are much more Lispy. Additionally, Lisp has two different sorts of macros: macros and reader macros. With reader macros, you could probably make your Lisp code look something like PASCAL if you were exceedingly ambitious and exceedingly misguided. But, from there on out, when I say macro, I specifically mean just macro as distinguished from reader macro.]

For pedagogic purposes, let’s look at those same macros defined in Lisp:

(defmacro START_TIMER () '(setf *start-time* (get-current-time)))
(defmacro END_TIMER () '(setf *elapsed* (- (get-current-time) *start-time*)))
(defmacro WARN (x y) `(log_message +log-level-warn+ ,x ,y))

The first two macros are straight substitutions: wherever you find the Lisp form (START_TIMER), substitute the form (setf *start-time* (get-current-time)), and similarly for the (END_TIMER) form. The single quote in front of the (SETF …) forms in those first two macros indicates that the following form, while parsed, is not to be evaluated at the time the macro is expanded. In the (WARN …) macro, things are a bit more complicated. The backquote before the (LOG_MESSAGE …) form indicates that the form itself is not to be evaluated at expansion time, but that some portions within it (those demarcated with commas) will need to be substituted. As you would expect:

(WARN "square-root" "Not interested in handling negative arguments today")

would expand into:

(log_message +log-level-warning+ "square-root" "Not interested in handling negative arguments today")

What makes Lisp macros so great?

From what I’ve shown you so far, you might be thinking: What make Lisp macros so great? Everything you’ve done with Lisp macros, I can do with the C preprocessor. Further, I can do things with the C preprocessor that are syntactically nothing at all like C. You already said you can’t do Lisp macros that are syntactically nothing like Lisp.

There is, of course, an aesthetic argument about why it would be better if the C preprocessor enforced some semblance of C on you. I wouldn’t respect that argument much either if someone were poking at my language. Aesthetics is good. Aesthetics is important. Should the compiler enforce them? Only if it’s going to give me something much better in return.

What do Lisp macros give you? They give you Lisp! What more could you possible want?

Let’s say that you’ve written code in Assembly Language or BASIC so long that the code you’re writing today screams for a Jump table or computed GOTO. Heck, the code you wrote yesterday used a jump table, too? Maybe you could factor out jump tables. You could do this in C by creating a function:

void do_jump_table( int choice, void (*array_of_functions[])( void ) )
{
    (*(array_of_functions[choice]))();
}
...
void (*my_jump_table[])( void ) = { foo, bar, baz, cat, dog, horse, tick };
do_jump_table( choice, my_jump_table );

That will work for you as long it’s okay that all of your functions have the same signature, you don’t mind the overhead of the extra function call to do_jump_table(), you don’t mind making a separate function for each different jump table option, and your choice never stumbles past the end of the jump table. You can do it this way in Lisp if you like, too (without having to write a different do_jump_table for every different return-type you might like from your jump tables.

(defun do-jump-table (choice array-of-functions)
  (funcall (aref array-of-functions choice)))
...
(do-jump-table choice #< #'foo, #'bar, #'baz, #'cat, #'dog, #'horse, #'tick >)

What if you do care about the overhead of the extra function call to do_jump_table(), you do mind making a separate function for each different jump table option, or you are concerned about doing something graceful when your choice stumbles past the end of the jump table? You could address that last concern with more error checking and, in the C case, more parameters to the function. The other concerns are tougher though.

If we don’t want to have to write separate foo, bar, baz, etc. functions, then we want to try this with macros. Rather than taking an array of function pointers, we’re going to let it take some variable number of statements—one for each case in the jump table. Let’s start with the Lisp version:

(defmacro do-jump-table ( choice &rest options )
  `(case ,choice
     ,@(loop for ii from 0
            for oo in options
            collecting (list ii oo))
     (otherwise (do-something-graceful))))

Then, we could use it with some code like this:

(do-jump-table op-code
               (write "NOOP")
               (write "SYNC")
               (when (>= +protocol-version+ 1)
                 (write "QUIT")))

This would expand to:

(case op-code
  (0 (write "NOOP"))
  (1 (write "SYNC"))
  (2 (when (>= +protocol-version+ 1)
       (write "QUIT")))
  (otherwise (do-something-graceful)))

I challenge you to make such a macro in C, C++, TeX, m4, or nroff. I’ve written lots of custom C programs in the past to preprocess my code before sending it to the C preprocessor and the C compiler. In Lisp, it’s all right there. I can use all of Lisp’s magic to turn my code into the code I want it to become. No temporary files. No fancy Makefile tricks to get everything squared away. It’s just all there.

What don’t I know?

The do-jump-table macro we just defined expands to one statement (one lisp form): (CASE …). There are many times when I want my macro to expand to more than one form, however. This isn’t legit. I still end up wanting to do it. For example, I might think that since I’m doing things with macros, that I might want the whole (CASE …) construct to be in the main code and just have the macro expand the different cases:

(case op-code
    (do-jump-table-cases (write "NOOP")
                         (write "SYNC")
                         (when (>= +protocol-version+ 1)
                             (write "QUIT")))
    (otherwise (do-something-especially-graceful)))

Lisp says no. I can understand why it says that. In this case, it’s easy enough to work around. But, in other cases, I end up flailing.

I feel that once I understand Lisp macros more completely, I won’t keep trying to jam that gorgeous, round peg into all of these square holes in my logic.

l