编译期元编程

本页目录

元编程让程序参与生成或变换其他程序。编译期元编程把部分工作放到构建阶段,但不同机制操作的对象不同:有的处理预处理 token,有的处理语法数据,有的计算常量,还有的修改字节码。

理解时先问三个问题:元程序拿到的是语法还是已检查的值,它在什么阶段执行,产物是代码、数据还是额外行为?

先区分输入与产物:生成语法、求值、织入行为 Token / 语法数据 宏展开 新语法 → 后续检查 常量表达式 编译期求值 值 / 静态数据 程序 + 匹配规则 编织 包含额外行为的程序 变换在构建阶段发生,不代表生成的行为或数据没有运行时成本。

为什么不用普通函数就够了

普通函数适合抽象运行时计算,通常不需要知道调用方字段的名字,也不能随意生成新声明。当需求是“为结构体的每个字段生成序列化代码”或“根据类型参数构造新类型”,就需要超出普通函数调用的能力。

但元编程会增加展开、诊断和构建复杂度。若普通函数、泛型或组合能清楚表达需求,它们通常更容易调试。选择依据应是需要操作什么信息,而非哪个机制看起来更强。

C 宏:预处理 token 展开

C 预处理器按预处理 token 规则替换,不是无规则的字符替换,也不理解后续完整的表达式类型。它支持条件编译、字符串化和 token 拼接等用途。

下面的 x、y、z 语句位于函数体内;这是宏展开的局部示例。

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

int x = 3;
int y = 2;
int z = PICK_MAX(x++, y); /* x 被求值两次,最终 x=5,z=4 */

括号解决部分运算符优先级问题,却不解决重复求值:比较使用一次 x++,选中的分支再执行一次 x++。如果参数包含函数调用,也可能重复产生副作用。对于固定类型的比较,普通内联函数能提供一次参数求值和类型检查。更多展开边界见 GCC 宏说明。

用同一个调用点比较“展开两次”与“实参求值一次”

下面保存为 evaluate-once.c。两个测试分别从 x=3 开始,宏得到 x=5、结果 4;函数得到 x=4、结果 3。这个例子没有在同一个未排序表达式里多次修改 x,避免用未定义行为来解释重复求值。

#include <stdio.h>
#define PICK_MAX(a, b) ((a) > (b) ? (a) : (b))
static inline int max_int(int a, int b) { return a > b ? a : b; }
int main(void) {
    int x = 3, y = 2;
    int by_macro = PICK_MAX(x++, y);
    if (x != 5 || by_macro != 4) return 1;
    x = 3;
    int by_function = max_int(x++, y);
    if (x != 4 || by_function != 3) return 1;
    printf("macro=%d function=%d\n", by_macro, by_function);
    return 0;
}

执行 cc -Wall -Wextra evaluate-once.c -o evaluate-once,输出 macro=4 function=3。用 cc -E 查看展开也能看到 x++ 出现在哪两个分支位置。内联优化可以消除函数调用开销,但函数实参仍按语言语义求值,不会因此变成宏式重复求值。GCC 副作用重复

一次求值不等于多个参数之间有固定求值顺序。若第二个参数也读写 x,仍须遵守 C 的求值顺序规则;需要明确先后关系时先用独立语句计算临时值,再调用函数。用函数替代这个固定类型的宏,解决的是参数复制问题,不是自动为所有表达式排序。

括号都写对了,为什么 x 仍会多增加一次?

观察点不只放在返回值,还放在每一次读取 x++ 时 x 的旧值与新值。初始 x>y 时,比较读取一次、选中的分支再读取一次;x≤y 时第二个 x++ 分支不执行。函数版本始终先得到一个实参值,然后复用它。这个对照保持 C 条件运算符的顺序边界,没有借未定义行为制造差异。

正在呈现知识画面
括号都写对了,为什么 x 仍会多增加一次?

宏复制的是未求值表达式,比较之后被选中的 x++ 还会执行;函数先把实参求成值,再在函数体复用那个值。分支未选中时也不会无条件执行第二次。

Lisp 宏:输入是未求值的代码形式

Lisp 的代码形式可以作为列表等数据操作。宏接收未按普通函数参数规则求值的形式,产生新的代码形式,随后由语言处理该展开结果。

(defmacro unless-demo (condition &body body)
  `(if (not ,condition) (progn ,@body)))

这里 condition 和 body 被放入生成的 if 形式;它们表达的运行时动作并不是在宏展开时就执行。准引用、反引用和拼接帮助构造代码,但宏作者仍要考虑变量捕获、求值次数、展开环境与错误位置。代码是数据不意味着作用域和语义问题消失。

Rust 过程宏:TokenStream 不等于带类型 AST

Rust 过程宏在编译时消费并产生 TokenStream。宏可以借助 syn 等库解析为语法结构,但这不意味着获得 rustc 完成类型推导后的内部 AST,也不能普遍查询某个表达式推导出的类型。

过程宏主要有 function-like、derive 和 attribute 三类,展开的插入或替换规则不同。例如 derive 生成附加条目,常用来生成 impl;attribute 可以替换所标注的条目。接口与规则见 Rust Reference。

假设为 struct Record<T> { value: T } 生成序列化实现,宏必须保留泛型参数与 where 约束,生成合法路径和边界,然后由后续编译阶段检查实现是否类型正确。“宏知道字段语法”与“宏证明字段类型满足全部约束”不同。

卫生性:展开后名字指向谁

卫生性解决生成名字与调用点名字的意外捕获。Rust macro_rules! 有其混合位置卫生规则,过程宏则不能概括为“天然完全卫生”。过程宏作者需要处理名称冲突和路径解析,例如使用适当的绝对路径、保留 span,以及避免生成容易冲突的辅助名字。

卫生也不自动保证求值一次、借用正确或错误信息良好。宏生成代码的这些性质仍要单独设计。

生成的 tmp,会不会抓走调用方的 tmp?

相同拼写的两个 tmp 可能来自不同的声明环境。下图保留调用方声明 C 与宏生成声明 M,分别追踪插入表达式和辅助引用。关闭卫生策略时,仅在调用方也叫 tmp 的情况下出现捕获;改名能避开这个例子的碰撞,却不是保证所有宏调用安全的通用方案。卫生规则应该维护绑定来源,而不只是寻找一个看起来冷门的变量名。

正在呈现知识画面
生成的 tmp,会不会抓走调用方的 tmp?

卫生性保留插入表达式原有的声明身份,同时让生成的辅助引用指向生成声明。仅凭展开后文本做最近名字查找,可能把调用方变量误绑到辅助变量。

编译期求值:计算一个值

Rust 的 const fn 可以在满足条件的常量上下文中执行;在普通调用位置也可以运行时执行,不能仅凭函数声明里的 const 就断言每次调用都在编译期完成。

const fn square_table() -> [u32; 4] {
    let mut out = [0; 4];
    let mut i = 0;
    while i < 4 {
        out[i] = (i * i) as u32;
        i += 1;
    }
    out
}

const SQUARES: [u32; 4] = square_table();

fn main() {
    assert_eq!(SQUARES, [0, 1, 4, 9]);
}

常量上下文要求得到合法常量结果,语言会限制可执行的操作。数组越界等在必须求值时成为编译错误;允许的语法和引用行为按语言与版本规定。Rust 常量求值

C++ constexpr、consteval 与 Zig comptime 的具体能力不同,不应统一描述成同一种解释器。也不要把 const 求值与构建脚本、过程宏的权限混为一谈:后两者可能访问构建环境和文件系统。

预计算把计算时间转移到构建阶段,并可能把结果放入产物。它还可能增加二进制体积、编译内存或缓存压力;“编译期执行”不等于所有成本归零。

同一个 const fn,调用上下文决定错误在哪一阶段暴露

下面把合法 payload 上限定义为 1024,固定头部占 8 字节。常量 PACKET_SIZE 的初始化必须在编译期求值;main 中来自 args 的长度则属于普通运行时输入。

const fn packet_size(payload: usize) -> usize {
    assert!(payload <= 1024, "payload too large");
    payload + 8
}

const PACKET_SIZE: usize = packet_size(16);

fn main() {
    assert_eq!(PACKET_SIZE, 24);
    let runtime_input = std::env::args().count();
    assert_eq!(packet_size(runtime_input), runtime_input + 8);
}

直接编译并无额外参数运行会通过。如果把常量初始化改成 packet_size(1025),编译器必须拒绝这个常量;若仅在 main 普通路径传入 1025,程序会在执行到断言时 panic。const fn 的函数体并没有变成两种逻辑,变化的是当前上下文是否要求合法常量结果。常量上下文

使用位置阶段义务不能据此推断
const 初始化必须能在常量求值规则下完成数据没有产物空间成本
普通运行时调用按普通执行语义产生值或失败编译器完全不会做常量折叠
构建脚本读取文件构建时运行独立程序文件内容自动成为已声明的依赖
过程宏生成表达式产生待编译语法表达式已经完成类型检查

同一个 const fn,错误会在哪个阶段出现?

阶段义务与优化机会应分开。const 初始化要求编译阶段完成合法求值;普通路径则按是否真正执行决定运行时结果。下面保留同一个 packet_size 函数体,只改变调用上下文和输入,便于观察错误从哪里产生。普通调用即使被优化器预计算,也不能因此无条件改成常量上下文的诊断规则。

正在呈现知识画面
同一个 const fn,错误会在哪个阶段出现?

const 初始化必须得到合法常量;普通调用不因函数名带 const 就成为必须编译期求值。普通路径没执行时,也不会凭空发生该调用的运行时 panic。

模板与泛型:参数也可以决定程序结构

泛型的类型参数参与检查;某些语言还支持值参数、类型级计算或按参数实例化代码。单态化可以让不同实例获得专用实现,但会增加实例数量与编译成本。类型擦除与字典传递则采用不同的运行时表示和调用方式。

这些与宏可以组合:宏先生成带泛型的实现,编译器再检查并按策略生成代码。把所有泛型都叫宏,或把所有元编程都叫 AST 替换,会掩盖阶段差异。

AOP 编织:选择程序中的连接点并加入行为

AspectJ 通过 pointcut 描述连接点集合,通过 advice 指定要执行的行为。call 与 execution 匹配的对象不同,不能把方法执行连接点一律画成调用点前插入 AST。

AspectJ 支持源代码编译以及对已有类文件的编织等路径,产物可为织入行为的字节码;也存在加载时编织。它不局限于“编译器只修改 Java AST”。AspectJ 编译器与编织器

提前织入可以避免某些代理边界,但 advice 里的日志、计时和检查仍在运行时执行,动态连接点条件也可能有成本。自调用是否被拦截取决于机制、匹配规则与编织范围,不能用“覆盖所有调用、零运行时开销”作通用结论。

生成结果能否重现,取决于所有影响输出的输入 显式输入 schema + 生成器版本 + 选项 确定性生成 固定字段顺序与命名规则 生成代码 后续解析、类型检查、编译 隐式输入:当前目录、环境变量、时间或无序遍历 若会改变输出,就要纳入构建模型,或从生成逻辑中消除 生成成功和产物正确是两步;编译通过后还要验证生成接口的实际语义。

构建缓存必须能看见生成器的真实依赖

例如由 schema 生成 Record 的序列化实现,字段顺序来自 schema,命名规则来自生成器版本,输出模式来自配置开关。只把生成器源文件列为依赖,修改 schema 后就可能沿用旧输出;只把 schema 列为依赖,升级命名规则也可能不重建。

相反,若生成器在每次运行时无条件加入当前时间,即使所有业务输入不变,生成结果仍会变化,使缓存与代码审查充满噪声。应先确定时间是否属于产物语义;若只是日志,留在构建日志中即可。

变化合理期望需要核对
新增字段对应生成声明变化输入依赖是否使生成器重跑
升级生成器规则相同 schema 按新规则生成工具版本是否进入缓存键
无业务输入变化生成内容保持稳定顺序、随机数与隐式环境
生成出非法类型组合编译诊断能指向领域输入源位置或生成清单是否保留

编译期计算也有规模边界:N 个泛型实例每个生成 B 字节,代码空间可能随实例数增加,但链接器折叠、优化和共享策略会改变最终大小。应查看产物与构建资源,不能只根据源码少写了多少行就认定编译期元编程一定节省总成本。

工程评审速查

需求可考虑的机制要检查
少量重复计算函数、泛型是否已经足够表达
按字段生成实现derive / 过程宏泛型、路径、错误 span、生成代码可检查性
预生成常量表常量求值允许操作、编译成本、产物大小
按类型和值生成专用结构模板或类型级机制实例膨胀、约束诊断
横切行为编织或代理连接点语义、覆盖范围、运行时副作用

验证不仅要看正常展开,还要看错误输入、同名变量、带副作用的参数、泛型边界,以及修改后增量构建是否失效正确。生成结果应有可追溯的位置和稳定顺序,避免构建环境的隐式输入造成难以复现的产物。

参考

Keywords: metaprogramming, macro, TokenStream, hygiene, const fn, constexpr, templates, AspectJ, weaving