声明式宏还有一个重要用途:减少样板代码与重复代码。为了说明这一点,我们先引入 newtype 这个概念。
newtype 来自函数式编程领域,本质上就是给一个已有值再包一层壳,借助类型系统帮我们避免犯错。
实际就是为现有类型创建一个新类型,为了就是做更好的区分。
假设我们有一个用于计算加薪的函数。这个函数的问题在于:它不仅要接收四个参数,而且前两个参数和后两个参数的类型还分别完全一致。这样一来,调用时就很容易犯一些低级错误。
容易犯错的代码:
fn calculate_raise(first_name: String,
_last_name: String,
_age: i32,
current_pay: i32) -> i32 {
if first_name == "Sam" { // #1
current_pay + 1000
} else {
current_pay
}
}
fn main() {
let first_raise = calculate_raise(
"Smith".to_string(), // #2
"Sam".to_string(),
20,
1000
);
println!("{}", first_raise); // #3
let second_raise = calculate_raise(
"Sam".to_string(),
"Smith".to_string(),
1000, // #4
20
);
println!("{}", second_raise); // #5
}
- #1:只有名字为
"Sam"的人才会加薪。 - #2:糟糕,我把名字和姓氏传反了,所以拿不到加薪。
- #3:输出
"1000"。 - #4:这次名字顺序总算对了,我也确实“加薪”了;但因为把年龄和当前薪资弄反了,结果成了一个活到
1000岁的员工只涨20美元,显然是个 bug。 - #5:输出
"1020"。
如果我们分别为这些参数创建独立的包装类型,比如 FirstName、LastName、Age 和 CurrentPay,那么类型系统就能在编译阶段帮我们拦住这类错误。
当然,代码也会更易读,因为参数的语义被直接编码进了类型里。
正因如此,newtype 这种模式在“整洁代码”和“领域驱动设计”中都很受欢迎。
把“真实值”藏在内部,再为领域建模出更有意义的类型,也让 newtype 成为设计公共 API 的理想工具:更清晰,也更容易演进。
题外话
Rust 其实提供了另一种方式——类型别名,用来给某个既有类型起一个替代名称。
例如,如果我们想给 FirstName 起个别名,可以写成 type FirstName = String;。这样一来,其他开发者一眼就能看出,这里需要的是一种具有特定语义的 String,也就是“名字”。
类型别名经常用于那些本身较复杂、希望变得更易读、更易使用的类型。
很多 crate 都会有一个在全局频繁使用的自定义错误类型,例如 syn crate 中的 syn::Error。这时,再为 Result 定义一个预先绑定该错误类型的别名就很方便,例如:
type Result<T> = std::result::Result<T, syn::Error>;
这样,crate 内部的代码就可以直接使用 Result<T> 作为返回类型。
不过要注意,类型别名并不会让类型系统变得更“严格”:即使函数参数写成 FirstName 这个别名,我仍然可以直接传入一个普通的 String。
正式开始
在下面的代码中,你可以看到 FirstName 这个 newtype 的示例。我把它内部包裹的真实值设为私有,只对外提供一个 new 构造函数。
这个函数可以顺便完成额外校验,确保传入的是合法值;如果不合法,就返回错误。
为什么要这样做?
因为这会让后续使用这些类型的代码更轻松。只要某个值能被构造成这个类型,我们就可以默认它已经通过了所有必要的验证。
以 FirstName 为例,我们现在就可以确信它不是空字符串。
对 Age 而言,则可以约定构造逻辑保证它是一个小于 150 的正数。即便你暂时不做这类校验,newtype 依然有价值,因为它能让类型系统表达更多语义,并迫使你认真思考自己到底在传什么值。
如果某个函数需要的是 FirstName,而你必须显式地把一个 String 包装成 FirstName,那你就更不容易误把一个名为 last_name 的字符串传进去。
NOTE
从实际应用角度来讲,即使不使用 newtype,也有办法缓解示例里“年龄”和“薪资”混淆的问题,例如把age的类型改成u8。这样至少可以天然保证它是一个小于256的正整数。
除了构造函数之外,我们还暴露了一个 get_value 方法,用来返回内部值的不可变引用,供其他代码安全读取。
当然了,我们也可以继续补上一些便利性实现,比如 AsRef 和 AsRefMut trait,不过这已经超出了当前示例的范围。
来,让我看看 FirstName newtype 的示例代码:
struct FirstName {
value: String,
}
impl FirstName {
pub fn new(name: &str) -> Result<FirstName, String> {
if name.len() < 2 {
Err("Name should be at least two chars long".to_string())
} else {
Ok(FirstName {
value: name.to_string(),
})
}
}
pub fn get_value(&self) -> &String {
&self.value
}
}
// Code for the other three newtypes omitted.
fn calculate_raise(first_name: FirstName, // #1
_last_name: LastName,
_age: Age,
current_pay: Pay) -> Pay {
// ...
}
- #1:这里使用的是
newtype参数,而不是String类型。
编程从来都是权衡。采用 newtype 的一个明显代价就是:代码会变得更臃肿。
仅仅为了定义一个 newtype,我们就多写了十几行样板代码。接下来,我们就借助宏来压缩这部分重复劳动。第一步,从 FirstName 结构体里的 get_value 方法开始。
现在,我们为 newtype 生成 get_value 方法的宏
struct FirstName {
value: String,
}
struct LastName {
value: String,
}
macro_rules! generate_get_value {
($struct_type:ident) => { // #1
impl $struct_type {
pub fn get_value(&self) -> &String { // #2
&self.value
}
}
}
}
generate_get_value!(FirstName); // #3
generate_get_value!(LastName);
- #1:宏接收一个输入参数,也就是一个标识符。
- #2:我们使用这个参数,为指定结构体生成
get_value方法。 - #3:现在,
FirstName和LastName这两个结构体都会自动拥有这个方法。
读到这里,这段代码应该已经不难理解了。
我们使用 macro_rules! 定义了一个新宏,并给出了一个匹配器与转写器组合。
它唯一需要的输入就是结构体名称,因此 ident(标识符)是最合适的匹配类型。
除了这个名称之外,转写器中的其余部分都是写死的。
还要注意,一个结构体完全可以拥有多个 impl 块。
不过,事情还没完。
当我们把同一个宏用到 Age 和 Pay 这两个结构体上时,编译器会报错:mismatched types: expected reference &String found reference &i32。
这个错误不言而喻,前面的实现为了简化讨论,默认返回类型总是 String,但现实里并不是所有 newtype 都包装 String。
一种解决办法是:让宏再接收一个参数,用来覆盖返回类型。虽然这里也可以继续使用 ident,但既然表达的是“类型”,那么 ty 显然更准确。
这样一来,如果只传一个标识符,就默认返回 String;如果额外传入第二个参数,就可以把这个宏用到 Age 和 Pay 上。
现在,我们改动一下宏的实现,让它适用于非 String 类型
struct Age {
value: i32,
}
struct Pay {
value: i32,
}
macro_rules! generate_get_value {
($struct_type:ident) => {
impl $struct_type {
pub fn get_value(&self) -> &String {
&self.value
}
}
};
($struct_type:ident, $return_type:ty) => { // #1
impl $struct_type {
pub fn get_value(&self) -> &$return_type {
&self.value
}
}
}
}
generate_get_value!(FirstName);
generate_get_value!(LastName);
generate_get_value!(Age, i32);
generate_get_value!(Pay, i32);
- #1:这是新增的匹配分支。它额外接收一个类型参数(
ty),从而在get_value的方法签名里生成正确的返回类型。
如果你还记得这个系列的第一篇文章的话,那里在介绍元变量类型的时候,有提到过 ty(表明一个类型,例如 String,i32)。
做到这里,我们似乎只是把原来的十几行样板代码换成了另一种写法,看起来并没有节省多少。
这篇文章写完的时候,我想到还有另一种方法可以更好的避免犯错,例如:Age 的 get 方法应该是 get_age,LastName 的 get 方法应该是 get_last_name。
自己调用自己
但宏还有一个非常实用的能力,我们前面为了简洁一直没有展开讲,那就是:宏可以调用自己。
如果你仔细看,会发现这两个分支之间存在大量重复代码。
既然“返回类型是 String”只是“返回任意类型”这个更通用分支的一个特例,那就完全可以让特例分支去复用通用分支。
做法是:在第一个分支里再次调用宏自己,把原来的输入参数传进去,同时补上第二个参数 String。Rust 在看到两个参数后,就会自动匹配到第二个分支。
现在,放出最终版 get_value 宏:
macro_rules! generate_get_value {
($struct_type:ident) => {
generate_get_value!($struct_type, String); // #1
};
($struct_type:ident, $return_type:ty) => { // #2
impl $struct_type {
pub fn get_value(&self) -> &$return_type {
&self.value
}
}
}
}
- #1:在第一个转写器里,我们改为用两个参数再次调用同一个宏。
- #2:因此,真正完成代码生成的是这个接收两个参数的匹配分支。
上图展示了当我们只用一个标识符调用 generate_get_value 时,宏在内部是如何展开的。
第一条流程从 generate_get_value!(FirstName) 开始,进入标有 “One ident (FirstName)” 的方框,在这里 FirstName 会被绑定到 $struct_type,随后宏会继续调用 generate_get_value!(FirstName, String)。
第二条流程则从标有 “Two idents (FirstName, String)” 的方框出发,把 FirstName 绑定到 $struct_type、把 String 绑定到 $return_type,最终展开成 impl FirstName { pub fn get_value(&self) -> &String { &self.value } }。
现在,我们终于真正减少了样板代码:所有生成 get_value 方法的逻辑都被集中在一个地方;使用的 newtype 越多,这个宏带来的收益就越明显。
在更大的项目里,我们甚至还可以继续编写其他宏,去生成更多便捷方法,或者再写一个上层宏,把这些宏统一串起来。
聪明的你,肯定已经开始这么做了吧!
NOTE
在你开始手写一大堆类似代码之前,很值得先看看 derive_more 这个 crate。它可以自动为这类包装类型实现一些常见trait。哦,不过提前提醒一下,它使用的是过程宏,而不是声明式宏,过程宏,我们后面会讲到。
在结束本节之前,还必须提到 Rust 中一个特别重要的 newtype 使用场景:孤儿规则(orphan rules)。
在 Rust 里,如果你想为某个 trait 编写实现,那么这个 trait、这个类型(例如某个结构体),或者两者都必须来自你的本地代码。这样做是为了避免冲突和歧义。
举个例子,如果我决定重新为 String 实现 Clone,那 Rust 到底应该使用哪一个实现?
在应用程序层面,也许优先本地实现还能说得过去;但如果所有第三方库都能各自为 String 提供自己的 Clone 实现,编译器又该选择标准库版本,还是某个依赖库 A、B 乃至 X 的版本呢?
当然,这条规则有时也会阻止你做一些非常有用的事。
常见的规避办法,就是把这个“非本地类型”(例如 String)包进一个本地 newtype(例如 MyString)里。
这样一来,MyString 就成了你自己的类型,因此你可以自由地为它实现 trait。
无论是内建宏还是自定义宏,都能帮助你减轻随之而来的样板负担;既然如此,再用声明式宏继续压缩重复代码,自然也是顺理成章的做法。
更重要的是,newtype 还是一种零成本抽象:它不会引入额外的运行时开销,编译器会把这层包装优化掉。
最后,既然我们刚刚看到宏可以调用自己,就有必要强调一点:声明式宏的递归,并不总是等价于函数或方法的递归。 看看下面这段代码:
macro_rules! count {
($val: expr) => {
if $val == 1 {
1
} else {
count!($val - 1)
}
}
}
如果这是一段函数代码,你大概率会以为无论调用 count!(1) 还是 count!(5),最终都应该返回 1。
但实际上,编译阶段你会得到一个 recursion limit reached(递归深度超出限制)的错误。
如果再打开一些 trace 宏,输出会非常有意思。前几行日志大概是这样:
= note: expanding `count! { 5 }`
= note: to `if 5 == 1 { 1 } else { count!(5 - 1) }`
= note: expanding `count! { 5 - 1 }`
= note: to `if 5 - 1 == 1 { 1 } else { count!(5 - 1 - 1) }`
问题在于,这里的 $val - 1 并没有先被求值,所以宏永远也到不了真正的终止条件。
这并不是说“带有不定参数数量的递归”就一定无法实现;关于如何把这件事做对,关注这个系列,后续会讲到。