---
title: Compile-Time Metaprogramming
url: https://doc.liz6.com/en/compilers/08-compilation-technology-applications/03-compile-time-metaprogramming
locale: en
area: compilers
tags:
- compilers
- compilation-technology-applications
date: 2026-06-30
modified: 2026-07-16
description: '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.'
---

# 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:

```lisp
(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:

```rust
#[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

```java
// 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) |
|---|---|---|
| Timing | Modifies AST at compile time | Wraps objects with dynamic proxies at runtime |
| Performance | Zero runtime overhead (already woven into bytecode) | Proxy chain overhead on every call |
| Coverage | All method calls (including `this.self-call`) | Only calls made through proxies |
| Flexibility | Switching aspects requires recompilation | Switching 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
// 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

| Scenario | Tool |
|------|------|
| Avoid boilerplate code—serialization/deserialization, derive | macro (Rust derive, Lisp defmacro) |
| Cross-cutting logic across multiple modules/layers | AOP compile-time weaving |
| Precomputation—lookup tables, mathematical constants | constexpr / comptime / const fn |
| Compile-time type checking + code generation | Rust proc macro, C++ templates |
| Rapid prototyping, simple code | C 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*
