本页目录

编译期元编程

程序在编译时生成或变换自身——宏(Lisp/Rust/C)、AOP 编译期编织(AspectJ)、编译期执行(constexpr/comptime)三条路线。编译器本身成了元程序的执行引擎:读写 AST,产生或修改 AST,全过程在编译期完成。

概述

程序在编译时生成或变换自身的代码——这是编译期元编程(compile-time metaprogramming)。三件事被塞进这个筐里:宏(macro——语法层面的代码生成)、AOP 编译期编织(对 AST 做横切注入)、以及现代语言的编译期执行(constexpr/comptime)。它们的共同点是编译器本身成了元程序(meta-program)的执行引擎⁠——元程序读写 AST、产生新 AST 或修改已有 AST,全过程在编译期完成,不影响运行时。这篇把三条路线分别展开:宏(生成代码)、AOP 编织(注入代码)、编译期求值(在编译时运行代码)。

宏:语法层面的代码生成

宏是最古老的编译期元编程形式,可以追溯到 Lisp 的 defmacro(1960s)。核心机制是在语法分析之后、语义分析之前,把 AST 的一个节点替换为另一个节点⁠。

Lisp macro:源码即 AST

Lisp 因为代码形式和 AST 形式一致(s-expression),macro 的机制极其简洁:

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

; 使用: (when (> x 0) (print "positive"))
; 展开: (if (> x 0) (progn (print "positive")))

宏就是一个函数,输入是 AST 子树(源码,其实也是 AST),输出是新 AST 子树。progn 是 Lisp 的多语句序列。因为宏的输入和输出都是 s-expression,展开是纯粹的 AST 改写,⁠没有复杂的语法/类型信息需要维护⁠。

这就是 Lisp 的"代码即数据"——macro 和函数共用同一组 AST 操作原语(quote/unquote/splice),上手成本极低。

C 预处理器:最弱的宏,但也最普及

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

MAX(x++, y)  →  ((x++) > (y) ? (x++) : (y))    ← 双重求值 bug

C 预处理器是纯文本替换——不接触 AST,不接触类型,不知道什么是表达式 vs 语句。结果:(1)参数可能被多次求值;(2)宏内局部变量名和调用方冲突;(3)展开后的语法错误定位到展开后的代码而非宏定义——错误信息是灾难。C 预处理器至今存在的唯一原因是它不需要编译器支持,任何语言(C、C++、Obj-C、汇编)都能用。

Rust proc macro:带类型的 AST 变换

Rust 的过程宏在编译期接收 token 流(经过词法分析的 token 序列),输出新 token 流:

#[proc_macro_derive(Serialize)]
pub fn derive_serialize(input: TokenStream) -> TokenStream {
    let ast: DeriveInput = syn::parse(input).unwrap();        ← 解析为 AST
    let expanded = generate_serialize_impl(&ast);             ← 生成 impl  token 
    expanded.into()
}

机制:编译器的 macro expansion 阶段,以 .so 的方式加载 proc macro crate,执行它,把返回的 token 流插入到原始 AST 中调用宏的位置。关键:⁠宏在编译期运行,它本身就是一段 Rust 程序⁠(可以调任何标准库),只是输入/输出是 token 流。

Rust 的宏是卫生的(hygienic):宏内部定义的变量名不会和调用方的同名变量冲突——编译器在展开时给宏内名字分配独立的语法上下文(syntax context),在名字解析阶段区分。

AOP 编译期编织:横切抽象的注入

AOP(Aspect-Oriented Programming)的核心思想:⁠横切关注点(cross-cutting concern)——日志、性能统计、权限检查——应该被单独编写,然后在编译期注入到所有需要它的地方⁠,而不是在每处都写一遍。

AspectJ:编译期编织的经典实现

// 切面定义 (Aspect):
aspect Logging {
    pointcut serviceCall():
        execution(* com.example.Service.*(..));     ← 所有 Service 方法

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

编译期,AspectJ 的编译器(Ajc)对 Java 源码做:

  1. 解析 Java 源码 → AST。
  2. 解析 Aspect 定义 → 确定每个 pointcut 匹配哪些 call site。
  3. 对每个匹配的 call site,把 advice(before() body)的 AST 注入到原 AST 中该 call site 之前。
  4. 输出修改后的 Java AST → 继续正常编译。

这本质上是AST 的精确外科手术⁠——AspectJ 的 pointcut 是"查询表达式",advice 是"注入的逻辑"。整个编织在编译期完成,运行时见到的只是编织后的类文件,不需要反射或代理。

编译期编织 vs 运行时代理

编译期编织 (AspectJ)运行时代理 (Spring AOP)
时机编译时修改 AST运行时用动态代理包装对象
性能零运行时开销 (已织入字节码)每次调用有代理链开销
覆盖所有方法调用 (包括 this.self-call)只覆盖通过代理的调用
灵活性切换切面需重新编译切换切面只需改配置

取舍和泛型的单态化 vs 擦除是同一类决定:⁠要编译期压到底(零开销但缺弹性),还是运行时留余地(弹性但耗性能)

编译期执行:把代码当参数,在编译时运行

constexpr(C++), comptime(Zig), const fn(Rust) 是另一种路线:不生成新代码,而是在编译期执行普通函数⁠,用结果替代运行时计算。

// Zig: comptime 在编译期执行这段代码
const lookup_table = comptime blk: {
    var table: [256]u32 = undefined;
    for (0..256) |i| table[i] = compute_sin(i);    ← 在编译期算出整张表
    break :blk table;
};
// 运行时 lookup_table 是已填充完毕的静态数据

编译期执行的限制:只能访问编译时已知的值(字面量、类型、编译常量),不能读文件、网络 IO、取随机数。这使它天然适合预计算⁠——表生成、类型推导、编译时常量折叠。

这和宏的区别:⁠编译期执行是"同一个函数,摆在编译期跑",宏是"从 token 流生成 token 流"——一个是对偶(函数在编译期和运行时的关系),一个是合成(程序生成程序)。

选型:哪种元编程用于什么

场景工具
避免样板代码——序列化/反序列化、derivemacro (Rust derive, Lisp defmacro)
跨多个模块/层次的横切逻辑AOP 编译期编织
预计算——查找表、数学常数constexpr / comptime / const fn
编译期类型检查 + 生成代码Rust proc macro, C++ templates
快速原型,代码不复杂C 预处理器 (低推荐,但有价值生态惯性)

参考

  • Rust Reference: Macros — proc macro 的完整规范
  • Paul Graham: "On Lisp" — Lisp macro 的哲学与实践
  • Kiczales et al.: "Aspect-Oriented Programming" (1997) — AOP 和 AspectJ 的原始论文
  • Zig: https://ziglang.org/documentation/master/#comptime — 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