声明宏实战:实现一个vec!宏的简化版本

声明宏,说白了就是 Rust 里的「代码生成器」。你写一个模式,它帮你展开成真正的代码。今天我们就手写一个简化版的 vec! 宏,把元变量和转译器这两个核心概念彻底搞明白。

我个人习惯,学宏编程一定要动手。光看文档,你永远不知道那些 $()* 符号到底在干什么。好,我们直接开干。

先看目标:我们要实现什么?

标准库的 vec! 宏可以这样用:

let v = vec![1, 2, 3];
let v2 = vec![0; 5];  // 重复5个0

我们的简化版本,只实现第一种:vec![1, 2, 3] 这种逗号分隔的列表形式。至于重复语法 [0; 5],嗯,那是进阶内容,我们后面再聊。

元变量:宏里的「占位符」

元变量(metavariable)是声明宏的灵魂。你可以把它理解成「带类型的占位符」。在宏定义里,我们用 $变量名:类型 来声明。

常见的元变量类型有:

类型标识 匹配内容 我在项目中遇到的坑
expr 表达式 最常用,但注意不能匹配 if
ty 类型 写泛型宏时必用
ident 标识符 变量名、函数名都用它
tt Token 树 最灵活,但也是最容易出错的

为什么要有类型?因为 Rust 需要在编译时知道你要匹配什么。你想想看,如果 $x 既能匹配 42 又能匹配 fn foo(),那展开的时候编译器就懵了。

转译器:把模式变成代码

转译器(transcriber)就是宏定义中 => 后面的部分。它告诉编译器:「当你匹配到这个模式时,就生成这段代码」。

这里有个关键点:转译器里可以使用元变量,但必须遵守「卫生性」规则。说白了,宏里生成的变量名不会和外面的变量冲突。这一点 Rust 做得比 C 的宏好太多了。

手写简化版 vec! 宏

好,我们直接上代码。这是我们的第一个版本:

macro_rules! my_vec {
    // 匹配一个或多个表达式,用逗号分隔
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}

fn main() {
    let v = my_vec![1, 2, 3];
    println!("{:?}", v);  // 输出 [1, 2, 3]
}

我们来拆解一下这个宏:

  • $( $x:expr ),* —— 匹配零个或多个 expr,用逗号分隔。这里的 * 表示重复零次或多次。
  • $()* —— 在转译器里,这个结构表示「对每个匹配到的元素,重复执行里面的代码」。
  • temp_vec.push($x) —— 每次重复时,把当前匹配到的表达式 $x 推入 vector。

注意看,我们用了两层花括号 { { ... } }。外层花括号是宏展开的边界,内层花括号创建了一个块作用域。这样 temp_vec 这个变量就不会泄露到外面。

关键点$()* 里的代码会为每个匹配到的元素执行一次。这是声明宏实现「重复」的核心机制。

避坑指南:我曾经踩过的雷

我曾经在写一个日志宏时,忘记处理空列表的情况。你想想看,如果用户写 my_vec![],我们的宏会匹配到 $( $x:expr ),* 里的 *(零次重复),然后生成一个空的 Vec。这没问题。

但如果你写的是 $( $x:expr ),++ 表示一次或多次),那 my_vec![] 就会编译报错。所以,一定要想清楚你的宏是否允许空输入

警告*+ 的区别很大。前者允许零次,后者至少一次。选错了,用户会骂你的。

再进一步:支持尾逗号

Rust 标准库的 vec! 支持尾逗号:vec![1, 2, 3,]。我们怎么实现?

很简单,再加一个匹配分支:

macro_rules! my_vec {
    // 带尾逗号
    ( $( $x:expr ),+ , ) => {
        my_vec!( $( $x ),+ )
    };
    // 不带尾逗号
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}

这里用了一个技巧:带尾逗号的分支直接递归调用自身,去掉尾逗号后再匹配第二个分支。这种「委托」模式在宏编程里很常见。

我个人习惯,写宏时总是先处理最特殊的模式,再处理通用模式。因为 Rust 的宏匹配是「先匹配先服务」的,顺序很重要。

用 SVG 画一张核心逻辑图

下面这张图展示了我们的宏从匹配到展开的完整流程:

输入: my_vec![1, 2, 3] 匹配阶段 模式: $( $x:expr ),* 展开阶段(转译器) let mut temp_vec = Vec::new(); temp_vec.push(1); temp_vec.push(2); temp_vec.push(3); 输出: Vec<i32>

这张图很直观:输入经过模式匹配,提取出元变量 $x 的值列表,然后转译器把这些值展开成实际的 Rust 代码。

测试我们的宏

写完了,总得跑一跑。我建议你至少测试这三种情况:

fn main() {
    // 测试1:正常列表
    let v1 = my_vec![1, 2, 3];
    assert_eq!(v1, vec![1, 2, 3]);
    
    // 测试2:空列表
    let v2: Vec<i32> = my_vec![];
    assert!(v2.is_empty());
    
    // 测试3:带尾逗号
    let v3 = my_vec![1, 2, 3,];
    assert_eq!(v3, vec![1, 2, 3]);
    
    println!("所有测试通过!");
}

小提示:测试空列表时,记得给变量标注类型。因为编译器无法从 my_vec![] 推断出元素类型。

元变量与转译器的配合细节

这里有个容易忽略的点:元变量在转译器里使用时,它的「类型」决定了你能对它做什么。比如 $x:expr 在转译器里可以直接作为表达式使用,但你不能把它当作类型名。

为什么会这样?因为 Rust 的宏展开发生在类型检查之前。宏只做「文本替换」,但它保留了类型信息,确保替换后的代码是合法的。

我记得有一次,我想在宏里生成一个结构体字段,用了 $field:ident 而不是 $field:expr。如果用了 expr,生成的代码里字段名会变成表达式,编译器直接报错。嗯,这种细节,只有踩过坑才会记得牢。

总结一下我们学到了什么

  • 元变量是带类型的占位符,用 $名称:类型 声明
  • 转译器=> 后面的代码模板,负责生成实际代码
  • 重复模式 $()*$() + 控制代码生成的次数
  • 匹配顺序很重要,特殊模式放前面,通用模式放后面

声明宏看起来语法有点怪,但一旦你理解了「匹配-展开」这个模型,就会发现它其实很优雅。说白了,它就是一套编译期的模式匹配引擎。

好,这一章就到这里。代码都在上面了,建议你打开编辑器,亲手敲一遍。遇到问题不要紧,宏的报错信息虽然难看懂,但多试几次就有感觉了。


专注资料整理