6.1 编译期元编程:宏、注解处理与代码生成
当十个数据类型都需要序列化、校验和 Builder 代码时,可以让宏或生成器根据声明产出重复结构。省下的复制粘贴很实在,不过生成器也会进入构建链:它的输入、输出、错误和版本都需要管理。代码会自己写代码,不代表它会自己负责。
先问:生成发生在哪个阶段
“代码生成代码”太宽泛。更有用的分类是:
源码文本
↓ 预处理 / 宏展开
语法或语言模型
↓ 注解处理 / 派生代码
编译产物
↓ 字节码织入 / 链接
运行时
↓ 反射 / 动态代理 / 类创建
行为2
3
4
5
6
7
8
9
阶段决定工具能看到什么,也决定错误在什么时候出现:
- 文本预处理器主要看 Token 或文本片段;
- 语法宏可以匹配语言结构;
- 注解处理器看到已解析的元素和类型模型;
- 字节码工具操作编译结果;
- 运行时元编程还能看到实际对象和配置,但错误更晚暴露。
不要只按语言名称分类。同一语言可能同时提供多个阶段的机制。
C 预处理宏:强大但缺少语言类型信息
#define SQUARE(x) ((x) * (x))加括号能避免部分优先级问题,却解决不了参数被求值两次:
int i = 3;
int value = SQUARE(i++); // 未排序地修改 i 两次:C 中是未定义行为2
这里是错误示例,不应拿运行结果推断固定数值。C 中乘法的两个操作数之间没有顺序保证,展开后对 i 的两次修改构成未定义行为。普通函数可避免这个问题:
static inline int square(int x) { return x * x; }
int i = 3;
int value = square(i++); // value == 9,i == 42
3
4
这个函数仍要求乘积可由 int 表示;单次求值并没有解决有符号溢出。预处理器在 C 语义分析前工作,不知道 x 的类型,也不能像函数那样保证只求值一次。适合条件编译和少量样板时仍有价值,但错误信息、调试和作用域都需要额外谨慎。
Rust 声明式宏匹配 Token Tree
macro_rules! hashmap {
($( $key:expr => $value:expr ),* $(,)?) => {{
let mut map = ::std::collections::HashMap::new();
$( map.insert($key, $value); )*
map
}};
}
fn main() {
let ports = hashmap!["http" => 80, "https" => 443];
assert_eq!(ports["http"], 80);
println!("{}", ports.len());
}2
3
4
5
6
7
8
9
10
11
12
13
保存为 ports.rs,用 rustc --edition 2021 ports.rs 编译,运行 ./ports 得到 2。重复键会被后一次插入覆盖;空输入需要类型标注,例如 let empty: std::collections::HashMap<&str, u16> = hashmap![];。
macro_rules! 的匹配器和转录器操作 Token Tree,并用 expr、ty、pat 等片段说明期望的语法类别。把它简单说成“直接操作 AST”不准确:匹配发生在宏系统定义的 Token 与片段边界上。
Rust 声明式宏具有 mixed-site hygiene:局部变量、标签和其他符号的解析规则并不完全相同。卫生性减少意外名称捕获,但不是“宏作者再也不用考虑名称和路径”。库宏通常还要使用 $crate 或明确路径,保证从其他 crate 调用时仍能解析。
设计声明式宏时应优先:
- 让输入语法短而明确;
- 将复杂业务逻辑放进普通函数,宏只负责展开结构;
- 对不支持的形状给出尽量靠近调用点的编译错误;
- 使用展开工具检查实际结果,但不让调用方依赖内部展开细节。
Rust 过程宏接收和返回 TokenStream
过程宏分为函数式宏、自定义派生和属性宏。入口本质上是编译期执行的 Rust 函数。下面只展示 API 形状,不是可编译的完整实现;实际代码还需导入 proc_macro::TokenStream,放入 proc-macro crate,并实现解析和输出:
#[proc_macro_derive(Validate, attributes(validate))]
pub fn derive_validate(input: TokenStream) -> TokenStream {
// 解析输入 TokenStream,生成实现,再返回 TokenStream
}2
3
4
过程宏不是完全卫生的。生成代码可能受调用位置的导入影响,也可能引入名称冲突;宏作者应使用绝对路径、低冲突的内部名称,并保留合理的 span 以改善错误定位。
过程宏运行在编译期间,因此还要把它当作供应链代码:它可以消耗 CPU 和内存,也会影响构建可重复性。生成器不应偷偷访问网络、依赖当前时间或把机器路径写进产物。
Java 注解处理器操作语言模型
Java 注解处理 API 提供 Element、TypeMirror、Filer 和 Messager 等接口。下面的实验只生成类型名称常量,先把“声明 → 生成源码 → 编译”跑通,再考虑 JSON 映射等复杂逻辑。使用支持 --release 17 的 JDK,把三个源码文件放在同一个空实验目录中。
EmitInfo.java 声明注解:
package example;
import java.lang.annotation.*;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface EmitInfo {}2
3
4
5
6
7
InfoProcessor.java 检查输入并生成文件:
package example;
import java.io.IOException;
import java.util.Set;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.tools.Diagnostic;
@SupportedAnnotationTypes("example.EmitInfo")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public final class InfoProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {
if (roundEnv.processingOver()) return true;
for (Element element : roundEnv.getElementsAnnotatedWith(EmitInfo.class)) {
TypeElement type = (TypeElement) element;
if (type.getNestingKind() != NestingKind.TOP_LEVEL
|| (type.getKind() != ElementKind.CLASS
&& type.getKind() != ElementKind.RECORD)) {
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR, "top-level class or record required", type);
continue;
}
String pkg = processingEnv.getElementUtils()
.getPackageOf(type).getQualifiedName().toString();
String name = type.getSimpleName() + "Info";
String qualified = pkg.isEmpty() ? name : pkg + "." + name;
try (var writer = processingEnv.getFiler()
.createSourceFile(qualified, type).openWriter()) {
if (!pkg.isEmpty()) writer.write("package " + pkg + ";\n");
writer.write("public final class " + name + " {\n"
+ " public static final String TYPE = \""
+ type.getQualifiedName() + "\";\n}\n");
} catch (IOException error) {
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR, error.toString(), type);
}
}
return true;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
User.java 使用生成的类型:
package example;
@EmitInfo
public record User(String name) {
public static void main(String[] args) {
System.out.println(UserInfo.TYPE);
}
}2
3
4
5
6
7
8
mkdir -p processor-classes generated classes
javac --release 17 -proc:none -d processor-classes EmitInfo.java InfoProcessor.java
javac --release 17 -cp processor-classes -processorpath processor-classes -processor example.InfoProcessor -s generated -d classes User.java
java -cp classes example.User2
3
4
输出应为 example.User,并能看到 generated/example/UserInfo.java。处理器先独立编译,再通过 -processor 显式启用;JDK 23 起不能继续依赖旧式 classpath 自动发现的默认行为。--release 17 固定本实验的语言与 API 目标,不会把运行中的编译器降级成 JDK 17。
处理可能有多个 round:上一轮生成的新类型可在后续轮次继续参与处理。Filer 会管理生成位置,同一次处理运行中不能反复创建同一路径。这里生成的 UserInfo 没有 @EmitInfo,因此不会在下一轮再次生成;若用户已声明同名类型,处理器会报告文件冲突。
标准注解处理器通常生成新文件,而不是任意修改已有源码的 AST。依赖编译器内部包改写语法树会绑定具体 javac 实现;官方文档明确指出 com.sun.tools.javac 的内部类随时可能变化。
可靠的处理器应做到:
- 通过
Messager把错误关联到具体元素; - 把 originating elements 传给
Filer,帮助增量构建判断依赖; - 输出稳定、可格式化、可检查的源码;构建系统还需清理已删除输入遗留的生成文件;
- 对重复生成和轮次结束做幂等处理;
- 在多个 JDK 和构建工具版本上测试发现与执行方式。
生成源码还是生成字节码
生成源码的优点是可读、可调试、IDE 容易导航;缺点是构建步骤和文件数量增加。直接生成 class 或改写字节码更灵活,但调试门槛、兼容风险和验证责任更高。
选择时可以比较:
| 需求 | 更合适的机制 |
|---|---|
| 根据类型声明生成重复实现 | 注解处理 / derive 宏 |
| 定义紧凑的语法模式 | 声明式宏 |
| 构建时生成客户端或协议模型 | 独立代码生成器 |
| 运行时观察方法调用 | 代理 / instrumentation |
| 只想复用普通业务逻辑 | 函数、组合或泛型,通常不需要元编程 |
生成代码也需要产品级接口
生成器的公共契约包括注解、宏输入、生成类型名称和错误消息。升级时至少检查:
- 旧输入是否仍能生成兼容 API;
- 生成顺序是否确定;
- 不同机器得到的产物是否一致;
- 生成文件是否包含版本和来源信息;
- 缓存或增量编译是否可能留下陈旧文件;
- 生成器依赖是否经过与编译器插件相匹配的安全审查。
完成检查
选择一个“为数据类型生成 JSON 映射器”的需求:
- 写清输入声明和生成 API;
- 决定采用 Rust derive、Java 注解处理还是独立生成器;
- 说明错误在哪个阶段报告;
- 加入两个非法输入用例;
- 证明连续构建两次产物一致,并能清理已删除输入的旧产物。