On this page

Compile-Time Metaprogramming

Programs generate or transform themselves at compile time—three approaches: macros (Lisp/Rust/C), AOP compile-time weaving (AspectJ), and compile-time execution (constexpr/comptime). The compiler itself becomes the execution engine for meta-programs: reading and writing ASTs, generating or modifying ASTs, with the entire process completed at compile time.

Overview

Code that generates or transforms itself at compile time—this is compile-time metaprogramming. Three things are grouped under this umbrella: macros (syntax-level code generation), AOP compile-time weaving (cross-cutting injection into the AST), and compile-time execution in modern languages (constexpr/comptime). Their common trait is that the compiler itself becomes the execution engine for meta-programs—meta-programs read and write ASTs, generate new ASTs or modify existing ones, with the entire process completed at compile time, without affecting runtime. This article expands on these three approaches: macros (generating code), AOP weaving (injecting code), and compile-time evaluation (running code at compile time).

Macros: Syntax-Level Code Generation

Macros are the oldest form of compile-time metaprogramming, dating back to Lisp's defmacro (1960s). The core mechanism is replacing one AST node with another after parsing but before semantic analysis.

Lisp Macro: Source Code as AST

Because Lisp's source code form matches its AST form (s-expressions), the macro mechanism is extremely concise:

(defmacro when (condition &body body)
  `(if ,condition (progn ,@body)))

; Usage: (when (> x 0) (print "positive"))
; Expansion: (if (> x 0) (progn (print "positive")))

A macro is a function that takes an AST subtree (source code, which is also an AST) as input and outputs a new AST subtree. progn is a Lisp multi-statement sequence. Since both the input and output of macros are s-expressions, expansion is purely AST rewriting, with no complex syntax or type information to maintain.

This is Lisp's "code is data"—macros and functions share the same set of AST operation primitives (quote/unquote/splice), resulting in a very low learning curve.

C Preprocessor: The Weakest Macro, Yet the Most Widespread

#define MAX(a, b) ((a) > (b) ? (a) : (b))

MAX(x++, y)  →  ((x++) > (y) ? (x++) : (y))    ← Double evaluation bug

The C preprocessor performs pure text substitution—it does not touch the AST, does not understand types, and does not distinguish between expressions and statements. Consequences: (1) arguments may be evaluated multiple times; (2) local variable names inside macros may conflict with those in the caller; (3) syntax errors after expansion are located in the expanded code rather than the macro definition—error messages are disastrous. The only reason the C preprocessor still exists is that it requires no compiler support; any language (C, C++, Obj-C, assembly) can use it.

Rust Proc Macro: Type-Aware AST Transformation

Rust's proc macros receive a token stream (a sequence of lexically analyzed tokens) at compile time and output a new token stream:

#[proc_macro_derive(Serialize)]
pub fn derive_serialize(input: TokenStream) -> TokenStream {
    let ast: DeriveInput = syn::parse(input).unwrap();        ← Parse into AST
    let expanded = generate_serialize_impl(&ast);             ← Generate token stream for impl
    expanded.into()
}

Mechanism: During the compiler's macro expansion phase, the proc macro crate is loaded as a .so file, executed, and the returned token stream is inserted into the original AST at the location where the macro was called. Crucially: macros run at compile time; they are themselves Rust programs (they can call any standard library), with input/output being token streams.

Rust's macros are hygienic: variable names defined inside a macro do not conflict with same-named variables in the caller—the compiler assigns independent syntax contexts to names inside macros during expansion, distinguishing them during name resolution.

AOP Compile-Time Weaving: Injection of Cross-Cutting Abstractions

The core idea of AOP (Aspect-Oriented Programming): cross-cutting concerns—logging, performance statistics, permission checks—should be written separately and injected at compile time into all places that need them, rather than being written everywhere individually.

AspectJ: The Classic Implementation of Compile-Time Weaving

// Aspect definition:
aspect Logging {
    pointcut serviceCall():
        execution(* com.example.Service.*(..));All Service methods

    before(): serviceCall() {
        System.out.println("Calling: " + thisJoinPoint.getSignature());
    }
}

At compile time, AspectJ's compiler (Ajc) performs the following on Java source code:

  1. Parse Java source code → AST.
  2. Parse aspect definitions → determine which call sites each pointcut matches.
  3. For each matched call site, inject the AST of the advice (before() body) into the original AST before that call site.
  4. Output the modified Java AST → continue normal compilation.

This is essentially precise surgery on the AST—AspectJ's pointcuts are "query expressions," and advice is "injected logic." The entire weaving is completed at compile time; what the runtime sees are only the woven class files, requiring no reflection or proxies.

Compile-Time Weaving vs. Runtime Proxies

Compile-Time Weaving (AspectJ)Runtime Proxies (Spring AOP)
TimingModifies AST at compile timeWraps objects with dynamic proxies at runtime
PerformanceZero runtime overhead (already woven into bytecode)Proxy chain overhead on every call
CoverageAll method calls (including this.self-call)Only calls made through proxies
FlexibilitySwitching aspects requires recompilationSwitching aspects only requires configuration changes

The trade-off is similar to generics monomorphization vs. erasure: push everything to compile time (zero overhead but less flexibility), or leave room at runtime (flexible but incurs performance costs).

Compile-Time Execution: Running Code as Parameters at Compile Time

constexpr (C++), comptime (Zig), and const fn (Rust) represent another approach: instead of generating new code, they execute ordinary functions at compile time, using the results to replace runtime calculations.

// Zig: comptime executes this code at compile time
const lookup_table = comptime blk: {
    var table: [256]u32 = undefined;
    for (0..256) |i| table[i] = compute_sin(i);    ← Compute the entire table at compile time
    break :blk table;
};
// At runtime, lookup_table is fully populated static data

Limitations of compile-time execution: only values known at compile time (literals, types, compile-time constants) can be accessed; reading files, network I/O, and generating random numbers are not allowed. This makes it naturally suitable for precomputation—table generation, type inference, and compile-time constant folding.

The difference from macros: compile-time execution is "the same function, run at compile time," whereas macros are "generating token streams from token streams"—one is a duality (the relationship between functions at compile time and runtime), the other is synthesis (programs generating programs).

Selection: Which Metaprogramming for What

ScenarioTool
Avoid boilerplate code—serialization/deserialization, derivemacro (Rust derive, Lisp defmacro)
Cross-cutting logic across multiple modules/layersAOP compile-time weaving
Precomputation—lookup tables, mathematical constantsconstexpr / comptime / const fn
Compile-time type checking + code generationRust proc macro, C++ templates
Rapid prototyping, simple codeC preprocessor (low recommendation, but has valuable ecosystem inertia)

References

  • Rust Reference: Macros — Complete specification for proc macros
  • Paul Graham: "On Lisp" — Philosophy and practice of Lisp macros
  • Kiczales et al.: "Aspect-Oriented Programming" (1997) — Original paper on AOP and AspectJ
  • Zig: https://ziglang.org/documentation/master/#comptime — Complete semantics of comptime

Keywords: metaprogramming, macro, Lisp defmacro, C preprocessor, Rust proc macro, hygiene, hygienic macro, syntax context, token stream, AOP, aspect, pointcut, advice, compile-time weaving, AspectJ, constexpr, comptime, const fn, compile-time evaluation, code generation, AST transformation, template metaprogramming