精通 Rust 宏 — 实现可变参数与宏展开

0 阅读6分钟

可变参数

0.png

当我们遇到函数的能力瓶颈时该怎么办?

比如,与 Java 不同,Rust 并不支持函数的可变参数。

可能是因为可变参数会增加编译器的实现难度,也可能是因为它并非一个足够重要的特性。

当然以后怎么说还犹未可知,至少目前 Rust 是不支持可变参数的。
关于是否要在 Rust 中加入可变参数的讨论由来已久,而且争议极大(默认参数也是如此!)

那么,如果你确实需要可变参数怎么办呢?

宏总是一个解决方案。

如果看了上一篇文章,我们的 my_vec 宏就实现了这一点:你可以传入任意数量的参数,Rust 会生成对应的处理代码。

如果你是从支持函数重载或默认参数的语言转到 Rust,宏同样能满足你的需求。

比如,我有一个打招呼的函数,希望它默认使用 “Hello”,同时也支持自定义问候语。

我们当然可以创建两个名字稍有不同的函数来处理这两种情况,但功能相同却要起不同的名字,这多少有点麻烦,再者说,如果参数变多了,你岂不是要取好几个这样的函数?

所以,我们来写一个greeting宏:

pub fn base_greeting_fn(name: &str, greeting: &str) -> String {
    format!("{}, {}!", greeting, name)
}

macro_rules! greeting {
    ($name:literal) => {
        base_greeting_fn($name, "Hello")
    };
    ($name:literal, $greeting:literal) => {
        base_greeting_fn($name, $greeting)
    };
}

稍等一下,这次我们优化一下代码——把实现代码和主函数分开存放。

这个宏被放在单独的greeting.rs文件中。

如果要在定义宏的文件之外使用它,我们需要在main中添加的模块声明上方加上#[macro_use]

main.rs 中使用 greeting 宏:

use crate::greeting::base_greeting_fn;    // #1

#[macro_use]
mod greeting;    // #2

fn main() {
    let greet = greeting!("Sam", "Heya");
    println!("{}", greet);    // #3
    let greet_with_default = greeting!("Sam");
    println!("{}", greet_with_default);    // #4
}

#1 导入基础函数 base_greeting_fn
#2 导入包含宏的模块。通过#[macro_use]注解,我们告诉 Rust 希望导入该文件中定义的宏
#3 输出:Heya, Sam!
#4 输出:Hello, Sam!

在更复杂的项目结构中(比如通过 mod.rs 导入和重导出模块),你需要在“根文件”(比如main.rs)和所有负责重导出的mod.rs中都添加这个注解。

不过别担心:Rust 会一直提示你“是否在模块/导入上添加了 #[macro_use]?”,直到你把所有需要的地方都补全。

这有时可能有点繁琐,但 Rust 就是这样——除非显式公开否则默认私有,这种设计确实会让你更注重信息隐藏的合理性。

相反,看看 Kotlin 的默认公开,也被诟病不少!

需要注意的是,我们必须把 base_greeting_fn 设为公共函数(并导入到 main.rs 中)。

原因很简单:声明式宏是在 main 函数中展开的,展开后会变成这个函数调用,所以这个函数也必须导入到 main.rs 中。

我们可以在脑海中把宏调用替换成宏定义里的代码。

在这个例子中,greeting!("Sam", "Heya") 会被替换为 base_greeting_fn("Sam", "Heya")

如果 base_greeting_fn 不是公共的,你就会调用一个未定义的函数。

你可能会觉得这样实在是太麻烦了,但,这就是 Rust 中宏与可见性规则共同作用的必然结果。

宏展开

1.png

此刻,我们先暂停一下,聊聊“展开”——也就是“用转换器中的内容替换宏调用”的官方术语。

虽然在我们自己脑子里模拟替换过程很有用,但有时你希望看到实际发生的代码转换。

trace_macros

为了满足这个需求,Rust 提供了一个很棒的功能:trace_macros(它本身也是一个声明式宏,可谓是“套娃”到底了)。

我在写这篇文章的时候,Rust 的最新稳定版为 1.90.0,该版本中这个功能仍处于不稳定状态,需要手动开启并使用 nightly 版本运行代码。

  1. 先运行 rustup install nightly
  2. 然后运行 cargo +nightly run

下面的代码展示了如何开启和关闭trace_macros功能:

#![feature(trace_macros)]    // #1

use crate::greeting::base_greeting_fn;

#[macro_use]
mod greeting;

fn main() {
    trace_macros!(true);    // #2
    let _greet = greeting!("Sam", "Heya");
    let _greet_with_default = greeting!("Sam");
    trace_macros!(false);    // #3
}

#1 启用 trace_macros 功能
#2 开启宏追踪
#3 关闭宏追踪

这是我们之前的代码,只是去掉了 println! 语句并添加了 trace_macros! 调用。

传入 true 时开启追踪,传入 false 时关闭。

在这个例子中,其实关闭并非必须,因为程序运行到这里就结束了。

运行这段代码会输出类似这样的内容:

note: trace_macro
  --> src\main.rs:10:17
   |
10 |     let greet = greeting!("Sam", "Heya");
   |                 ^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: expanding `greeting! { "Sam", "Heya" }`
   = note: to `base_greeting_fn("Sam", "Heya")`

note: trace_macro
  --> src\main.rs:11:30
   |
11 |     let greet_with_default = greeting!("Sam");
   |                              ^^^^^^^^^^^^^^^^
   |
   = note: expanding `greeting! { "Sam" }`
   = note: to `base_greeting_fn("Sam", "Hello")`

日志清晰展示了默认值的工作原理:greeting!("Sam") 被转换成了 base_greeting_fn("Sam", "Hello")

是不是很巧妙?trace_macros 现在给我们展示了其宏的替换工作,省去了不少我们自己思考替换过程的脑力。

log_syntax

另一个有用的工具是 log_syntax! 宏(在 1.90.0 中同样不稳定),它允许你在编译期记录日志。

如果你之前没写过宏,可能不会意识到这有什么重要性。

作为一个简单的演示,我们可以给 greeting 宏添加第三个分支。

这个分支会用 log_syntax 记录收到的参数,用 println! 提示返回了默认问候语,然后再调用 base_greeting_fn

所有这些代码都被包裹在额外的一对大括号里,这是因为宏必须返回一个单独的表达式,才能绑定到 let 变量上。

通过添加这层括号,我们把两条语句和一个表达式封装成了一个整体。

使用 log_syntax

macro_rules! greeting {
    ($name:literal) => {
        base_greeting_fn($name, "Hello")
    };
    ($name:literal, $greeting:literal) => {
        base_greeting_fn($name, $greeting)
    };
    (test $name:literal) => {{    // #1
        log_syntax!("The name passed to test is: {}", $name);    // #2
        println!("Returning default greeting");
        base_greeting_fn($name, "Hello")
    }};
}

#1 使用双层大括号,让生成的代码被{}包裹,从而确保输出是一个单独的表达式
#2 使用log_syntax!记录输入参数

在主文件中,代码变化不大,只是多启用了一个功能,并添加了对宏第三个分支的调用:

#![feature(trace_macros)]
#![feature(log_syntax)]    // #1

use crate::greeting::base_greeting_fn;

#[macro_use]
mod greeting;

fn main() {
    trace_macros!(true);
    let _greet = greeting!("Sam", "Heya");
    let _greet_with_default = greeting!("Sam");
    let _greet_with_default_test = greeting!(test "Sam");    // #2
    trace_macros!(false);
}

#1 log_syntax是不稳定功能,需要通过feature启用
#2 调用宏的新分支

现在,如果你用 cargo +nightly check 运行代码,会看到 log_syntax! 的输出 The name passed to test is: Sam,因为它是在编译期执行的。

只有当你用 cargo +nightly run 运行时(注意这里是 run),才会同时看到 log_syntax 的输出和宏里 println! 语句的输出。

这种区别对于调试编译期行为非常重要。

借助这些工具,你可以追踪宏到展开后 Rust 代码的完整过程。它虽然没有真正调试器那么强大,但聊胜于无。唯一可惜的是,这些功能目前只能在 nightly 版本中使用。

后面我们会介绍一些能在稳定版 Rust 中工作的调试工具。

未完待续...