跳到内容

第 9 卷 / 第 6 章 / 第 1 课

6.1 编译期元编程:宏、注解处理与代码生成 ​

当十个数据类型都需要序列化、校验和 Builder 代码时,可以让宏或生成器根据声明产出重复结构。省下的复制粘贴很实在,不过生成器也会进入构建链:它的输入、输出、错误和版本都需要管理。代码会自己写代码,不代表它会自己负责。

先问:生成发生在哪个阶段 ​

“代码生成代码”太宽泛。更有用的分类是:

text
源码文本
  ↓ 预处理 / 宏展开
语法或语言模型
  ↓ 注解处理 / 派生代码
编译产物
  ↓ 字节码织入 / 链接
运行时
  ↓ 反射 / 动态代理 / 类创建
行为

阶段决定工具能看到什么,也决定错误在什么时候出现:

  • 文本预处理器主要看 Token 或文本片段;
  • 语法宏可以匹配语言结构;
  • 注解处理器看到已解析的元素和类型模型;
  • 字节码工具操作编译结果;
  • 运行时元编程还能看到实际对象和配置,但错误更晚暴露。

不要只按语言名称分类。同一语言可能同时提供多个阶段的机制。

C 预处理宏:强大但缺少语言类型信息 ​

c
#define SQUARE(x) ((x) * (x))

加括号能避免部分优先级问题,却解决不了参数被求值两次:

c
int i = 3;
int value = SQUARE(i++); // 未排序地修改 i 两次:C 中是未定义行为

这里是错误示例,不应拿运行结果推断固定数值。C 中乘法的两个操作数之间没有顺序保证,展开后对 i 的两次修改构成未定义行为。普通函数可避免这个问题:

c
static inline int square(int x) { return x * x; }

int i = 3;
int value = square(i++); // value == 9,i == 4

这个函数仍要求乘积可由 int 表示;单次求值并没有解决有符号溢出。预处理器在 C 语义分析前工作,不知道 x 的类型,也不能像函数那样保证只求值一次。适合条件编译和少量样板时仍有价值,但错误信息、调试和作用域都需要额外谨慎。

Rust 声明式宏匹配 Token Tree ​

rust
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());
}

保存为 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,并实现解析和输出:

rust
#[proc_macro_derive(Validate, attributes(validate))]
pub fn derive_validate(input: TokenStream) -> TokenStream {
    // 解析输入 TokenStream,生成实现,再返回 TokenStream
}

过程宏不是完全卫生的。生成代码可能受调用位置的导入影响,也可能引入名称冲突;宏作者应使用绝对路径、低冲突的内部名称,并保留合理的 span 以改善错误定位。

过程宏运行在编译期间,因此还要把它当作供应链代码:它可以消耗 CPU 和内存,也会影响构建可重复性。生成器不应偷偷访问网络、依赖当前时间或把机器路径写进产物。

Java 注解处理器操作语言模型 ​

Java 注解处理 API 提供 Element、TypeMirror、Filer 和 Messager 等接口。下面的实验只生成类型名称常量,先把“声明 → 生成源码 → 编译”跑通,再考虑 JSON 映射等复杂逻辑。使用支持 --release 17 的 JDK,把三个源码文件放在同一个空实验目录中。

EmitInfo.java 声明注解:

java
package example;

import java.lang.annotation.*;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface EmitInfo {}

InfoProcessor.java 检查输入并生成文件:

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;
    }
}

User.java 使用生成的类型:

java
package example;

@EmitInfo
public record User(String name) {
    public static void main(String[] args) {
        System.out.println(UserInfo.TYPE);
    }
}
bash
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.User

输出应为 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 映射器”的需求:

  1. 写清输入声明和生成 API;
  2. 决定采用 Rust derive、Java 注解处理还是独立生成器;
  3. 说明错误在哪个阶段报告;
  4. 加入两个非法输入用例;
  5. 证明连续构建两次产物一致,并能清理已删除输入的旧产物。

参考资料 ​

Built with VitePress | Software Systems Atlas