本页目录
编译期元编程
程序在编译时生成或变换自身——宏(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 的机制极其简洁:
; 使用: (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 流:
机制:编译器的 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
编译期,AspectJ 的编译器(Ajc)对 Java 源码做:
- 解析 Java 源码 → AST。
- 解析 Aspect 定义 → 确定每个 pointcut 匹配哪些 call site。
- 对每个匹配的 call site,把 advice(
before()body)的 AST 注入到原 AST 中该 call site 之前。 - 输出修改后的 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 流"——一个是对偶(函数在编译期和运行时的关系),一个是合成(程序生成程序)。
选型:哪种元编程用于什么
| 场景 | 工具 |
|---|---|
| 避免样板代码——序列化/反序列化、derive | macro (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