可变参数
当我们遇到函数的能力瓶颈时该怎么办?
比如,与 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 中宏与可见性规则共同作用的必然结果。
宏展开
此刻,我们先暂停一下,聊聊“展开”——也就是“用转换器中的内容替换宏调用”的官方术语。
虽然在我们自己脑子里模拟替换过程很有用,但有时你希望看到实际发生的代码转换。
trace_macros
为了满足这个需求,Rust 提供了一个很棒的功能:trace_macros(它本身也是一个声明式宏,可谓是“套娃”到底了)。
我在写这篇文章的时候,Rust 的最新稳定版为 1.90.0,该版本中这个功能仍处于不稳定状态,需要手动开启并使用 nightly 版本运行代码。
- 先运行
rustup install nightly。 - 然后运行
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 中工作的调试工具。