C++ template macroprogramming versus Lisp macros

Following on from Lisp macros versus Rust macros, I also want to compare C++ templates to Lisp macros.

Templates in C++ were designed as a way of generating typed versions of classes. The template declares some type variables that can be used as placeholders within a class declaration. When the template is instanciated and provided with actual type names, these are substituted for the type variables and the class is expanded. (It used to literally happen like this, so each use generated a completely new class. Modern compilers are smart enough to avoid the code repetition.) A classic example is a typed singly-linked list:

  template<typename A>
  struct List<A> {
    A value;
    struct List<A> next;
  };

However, the template system also allows values to be used in templates instead of (or as well as) type names. When these are encountered they are expanded at compile-time, and may cause further templates to be expanded. A classic example of this is to pre-compute some factorials:

  template<unsigned n>
  struct factorial {
    enum { value = n * factorial<n - 1>::value };
  };

  template <>
  struct factorial<0> {
    enum { value = 1 };
  };

In this code the first clause defines a template that defines the usual recursive factorial calculation. The second clause bottoms-out this recursion by defining a specialised template that directly provides the factorial of zero. This can then be used in code such as:

  template<unsigned n>
  struct factorial {
    enum { value = n * factorial<n - 1>::value };
  };

  template <>
  struct factorial<0> {
    enum { value = 1 };
  };

  int main() {
    std::cout << factorial<7>::value << std::endl;
  }
5040

which outputs the factorial of 7 as one might expect – but with the factorial having been computed at compile-time and inserted into the code as a literal, so the calculation introduces no run-time calculation.

There are some stringent limitations on the ways in which templates can be expanded. They can’t have mutable variables for a start (that’s why we needed to use the recursive factorial algorithm). Weirdly this makes the template language a functional programming sub-set of C++. Having said that, as with Lisp macros, it allows calculations that can be statically performed forward to be brought forward to compile-time. This makes it useful for building read-only tables, unrolling loops, and the like.

It’s claimed that templates are now so akin to “normal” C++ that they incur less of a readability penalty. That’s a subjective statement that may be true. But the template language isn’t C++. While one can write programs in it, they’re nothing like the C++ one would normally write. The template language is Turing complete, but that just means one can encode any computation, not that one can encode any particular program – and most template programs will require massive re-writing from the code one would write normally for execution at run-time. Template macroprogramming is therefore a non-trivial programming task to undertake.

Again as with Rust versus Lisp, C++ templates are an extension to the language rather than a core part of it, although they’re now used quite extensively in the standard library for generic typing. Also as with Rust, use of templates is semantically and syntactically distinct from “normal” C++ code or syntax, and it’s this that causes the programming load.

A Lisp macro for the factorial computation, by contrast, looks almost exactly like a normal factorial function that can access the entire language, both when defined and when used:

  (defmacro factorial (n)
    (labels ((fact (m)
               (if (= m 0)
                   1
                   (* m (fact (1- m))))))
      `,(fact n)))

  (princ (factorial 7))
5040

The choice of macro or function (defmacro or defun) has no further syntactic implications for the rest of the program, and no restrictions on the code that can be used within the definition; we could re-write the to use iteration, mutable variables, or any other code, and it would simply be executed at compile-time. The whole language is there, all the time. We can show this by taking a factorial function written in “normal” Lisp and macro-ifying it to be computed at compile-time:

  (defun fact (m)
    "Compute the factorial of M."
    (if (= m 0)
        1
        (* m (fact (1- m)))))

  (defmacro factorial (n)
    `,(fact n))

  (princ (factorial 7))
5040

More importantly, Lisp (and indeed Rust) macro can abstract over syntax as well as classes and values, and so allow the language to be extended with new first-class-at-compile-time structures. Templates are restricted to instanciating templates written with a fixed syntax; in Lisp the syntax has to be “Lisp-like”, although that’s a very light restriction; and in Rust a macro can use any syntax that Rust can tokenise.

While C++ templates are sometimes described as macroprogramming (or metaprogramming), they’re addressing a substantially different use case to that addressed by Lisp or Rust macros, and doing so within a more restricted computational and syntactic envelope.