集句(cento)(发音为"cento",来自拉丁语的"patchwork(拼凑)")是一首完全由引用另一位诗人的诗行组成的诗. --Matt Madden 你的引用在这里. --Bjarne Stroustrup
原文
A cento (pronounced "cento," from the Latin for "patchwork") is a poem made up entirely of lines quoted from another poet. --Matt Madden Your quote here. --Bjarne Stroustrup
Rust支持 宏(macros) ,这种扩展语言的方式超出了单独使用函数所能提供的功能.例如,我们已经见过的assert_eq!
宏,这对测试很方便:
assert_eq!(gcd(6, 10), 2);
这本可以写成泛型函数,但是assert_eq!
宏做了几件函数无法做到的事情.一个是当断言失败时,assert_eq!
生成包含断言的文件名和行号的错误消息.函数无法获取该信息.宏可以,因为它们的工作方式完全不同.
宏是一种简写.在编译期间,在检查类型之前以及在生成任何机器代码之前很久,每个宏调用都会被 扩展(expanded) --也就是说,它被替换为一些Rust代码.前面的宏调用扩展为:
match (&gcd(6, 10), &2) {
(left_val, right_val) => {
if !(*left_val == *right_val) {
panic!("assertion failed: `(left == right)`, \
(left: `{:?}`, right: `{:?}`)", left_val, right_val);
}
}
}
panic!
也是一个宏,所以它扩展为更多的Rust代码.该代码使用另外两个宏,即file!()
和line!()
.一旦crate中的每个宏调用完全展开,Rust就会进入下一个编译阶段.
在运行时,断言失败看起来像这样(并且会指示gcd()
函数中的bug,因为2
是正确的答案):
thread 'main' panicked at 'assertion failed: `(left == right)`, (left: `17`,
right: `2`)', gcd.rs:7
如果你来自C++,你可能会遇到一些糟糕的宏经历.Rust宏采用不同的方法,类似于Scheme的syntax-rules
. 与C++宏相比,Rust宏可以更好地与语言的其余部分集成,因此不易出错.宏调用始终标有感叹号,因此当你阅读代码时它们会突出显示,并且当你打算调用函数时,它们不会被意外调用.Rust宏永远不会插入不匹配的括号(brackets)或圆括号(parentheses).而Rust宏带有模式匹配,可以更容易地编写既可维护又易于使用的宏.
在本章中,我们将使用几个示例展示如何编写宏.然后我们将深入研究宏如何工作,因为像Rust一样,这个工具需要深入理解.最后,我们将看到,当简单模式匹配不够时我们可以做些什么.
图20-1显示了assert_eq!
宏的部分源代码.
图20-1. assert_eq!宏.
macro_rules!
是在Rust中定义宏的主要方法.请注意,在这个宏定义中的assert_eq
之后没有!
:!
仅在调用宏时包含,而不是在定义宏时.
并非所有宏都以这种方式定义:少数,就像file!
,line!
和macro_rules!
本身一样是内置于编译器的,我们将在本章的最后讨论另一种方法,称为过程宏(procedural macros).但在大多数情况下,我们将专注于macro_rules!
,这是(到目前为止)编写自己的宏的最简单方法.
用macro_rules!
定义的宏完全通过模式匹配工作.宏的主体只是一系列规则:
(pattern1) => (template1);
(pattern2) => (template2);
...
在图20-1中的assert_eq!
的版本只有一个模式和一个模板.
顺便说一句,你可以使用方括号或花括号代替模式或模板周围的括号;它对Rust没什么影响.同样,当你调用一个宏时,这些都是等价的:
assert_eq!(gcd(6, 10), 2);
assert_eq![gcd(6, 10), 2];
assert_eq!{gcd(6, 10), 2}
唯一的区别是花括号后分号通常是可选的.按照惯例,我们在调用assert_eq!
时使用括号,vec!
时使用方括号和macro_rules!
时使用花括号;但这只是一个惯例.
Rust在编译期间很早就展开了宏.编译器从头到尾读取源代码,定义和展开宏.在定义之前不能调用宏,因为Rust在查看程序的其余部分之前会展开每个宏调用.(相比之下,函数和其他项不必具有任何特定的顺序.可以调用直到稍后才在crate中定义的函数.)
当Rust展开assert_eq!
宏调用时,发生的事情很像计算match
表达式.Rust首先将参数与模式匹配,如图20-2所示.
图20-2. 展开一个宏,第1部分:参数上的模式匹配.
宏模式是Rust中的一种迷你语言.它们基本上是用于匹配代码的正则表达.但是正则表达式对字符进行操作,而模式对 标记(tokens) 进行操作--数字,名称,标点符号等等是Rust程序的构建块.这意味着你可以在宏模式中自由使用注释和空格,以使它们尽可能可读.注释和空格不是标记,因此它们不会影响匹配.
正则表达式和宏模式之间的另一个重要区别是括号,括号和大括号总是出现在Rust中的匹配对中.在展开宏之前检查这一点,不仅在宏模式中,而且在整个语言中.
在这个例子中,我们的模式包含$left:expr
,它告诉Rust匹配一个表达式(在本例中为,gcd(6, 10)
)并为其指定名称$left
.然后Rust将模式中的逗号与gcd
参数后面的逗号匹配.就像正则表达式一样,模式只有少数特殊字符可以触发有趣的匹配行为;其他一切,比如这个逗号,必须逐字匹配或匹配失败.最后,Rust匹配表达式2
并将其命名为$right
.
这种模式中的两个代码片段都是expr
类型:它们期望表达式.我们将在第510页的"片段类型(Fragment Types)"中看到其他类型的代码片段.
由于此模式匹配所有参数,Rust会展开相应的 模板(template) (图20-3).
图20-3. 展开一个宏,第2部分:填写模板.
Rust使用匹配期间找到的代码片段替换$left
和$right
.
在输出模板中包含片段类型是一个常见的错误:编写$left:expr
而不是$left
.Rust不会立即检测到这种错误.它将$left
视为替换,然后它将expr
视为模板中的其他内容:将标记包含在宏的输出中.所以在你 调用(call) 宏之前不会发生错误;然后它会生成无法编译的虚假输出.如果你在使用新宏时,收到类似于expected type, found `:`
的错误消息,检查它是否有这个错误.(第508页的"调试宏(Debugging Macros)"为这种情况提供了更一般性的建议.)
宏模板与Web编程中常用的十几种模板语言中的任何一种都没有太大区别.唯一的区别--它是一个重要的区别--输出是Rust代码.
将代码片段插入模板与使用值的常规代码略有不同.这些差异一开始并不总是显而易见的.我们一直在关注的宏,assert_eq!
,包含一些稍微奇怪的代码,原因很多,关于宏编程.让我们来看看两个有趣的部分.
首先,为什么这个宏创建变量left_val
和right_val
?是否有一些原因我们无法简化模板看起来像这样?
if !($left == $right) {
panic!("assertion failed: `(left == right)` \
(left: `{:?}`, right: `{:?}`)", $left, $right)
}
要回答这个问题,请尝试在心理上展开宏调用assert_eq!(letters.pop(), Some('z'))
.输出会是什么?当然,Rust会将匹配的表达式插入到多个位置的模板中.但是,在构建错误消息时,再次计算表达式似乎是一个坏主意,而不仅仅是因为它需要两倍的时间:因为letters.pop()
从向量中移除一个值,第二次调用时,它将产生一个不同的值!这就是为什么真正的宏只计算$left
和$right
一次并存储它们的值.
继续讨论第二个问题:为什么这个宏借用$left
和$right
的值的引用?为什么不将值存储在变量中,像这样?
macro_rules! bad_assert_eq {
($left:expr, $right:expr) => ({
match ($left, $right) {
(left_val, right_val) => {
if !(left_val == right_val) {
panic!("assertion failed"/* ... */);
}
}
}
});
}
对于我们一直在考虑的特殊情况,宏参数是整数,这样可以正常工作.但是如果调用者传递一个String
变量为$left
或$right
,则此代码会将值移出变量!
fn main() {
let s = "a rose".to_string();
bad_assert_eq!(s, "a rose");
println!("confirmed: {} is a rose", s); // error: use of moved value "s"
}
由于我们不希望断言移动值,因此宏借用了引用.
(你可能想知道为什么宏使用match
而不是let
定义变量.我们也想知道.事实证明这没有特别的原因.let
相同等效的.)
简而言之,宏可以做出令人惊讶的事情.如果你编写的宏周围发生奇怪的事情,那么宏应该受到责备.
你 不会(won't) 看到的一个bug是这个经典的C++宏bug:
// buggy C++ macro to add 1 to a number
#define ADD_ONE(n) n + 1
由于大多数C++程序员熟悉的原因,并且不值得在此完全解释,ADD_ONE(1) * 10
或ADD_ONE(1 << 4)
等不起眼的代码会对此宏产生非常令人惊讶的结果.要修复它,你需要为宏定义添加更多括号.这在Rust中不是必需的,因为Rust宏可以更好地与语言集成.Rust知道它何时处理表达式,因此只要将一个表达式粘贴到另一个表达式中,它就会有效地添加括号.
标准的vec!
宏有两种形式:
// Repeat a value N times
let buffer = vec![0_u8; 1000];
// A list of values, separated by commas
let numbers = vec!["udon", "ramen", "soba"];
它可以像这样实现:
macro_rules! vec {
($elem:expr ; $n:expr) => {
::std::vec::from_elem($elem, $n)
};
( $( $x:expr ),* ) => {
<[_]>::into_vec(Box::new([ $( $x ),* ]))
};
( $( $x:expr ),+ ,) => {
vec![ $( $x ),* ]
};
}
这里有三条规则.我们将解释多个规则如何工作,然后依次查看每个规则.
当Rust展开像vec![1,2,3]
这样的宏调用时,它首先尝试将参数1
,2
,3
与第一条规则的模式匹配,在本例中为$elem:expr ; $n:expr
.这无法匹配:1
是一个表达式,但之后模式需要一个分号,而我们没有一个分号.那么Rust随后转向第二条规则,依此类推.如果没有规则匹配,那就是错误.
第一条规则处理像vec![0u8; 1000]
这样使用.碰巧有一个标准函数std::vec::from_elem
,它完全符合这里的要求,所以这个规则很简单.
第二条规则处理vec!["udon", "ramen", "soba"]
.模式$( $x:expr ),*
,使用我们以前从未见过的功能:重复.它匹配0个或更多表达式,以逗号分隔.更一般地,语法$( PATTERN ),*
用于匹配任何逗号分隔列表,其中列表中的每个项目与PATTERN
匹配.
这里的*
与正则表达式("0或更多(0 or more)")中的含义相同,尽管必须承认正则表达式(regexps)没有特殊的*
重复器.你也可以使用+
来要求至少一个匹配.没有?
语法.下表给出了完整的重复模式:
模式 | 含义 |
---|---|
$( ... )* |
匹配0次或更多次没有分隔符 |
$( ... ),* |
匹配0次或更多次,以逗号分隔 |
$( ... );* |
匹配0次或更多次,以分号分隔 |
$( ... )+ |
匹配1次或更多次没有分隔符 |
$( ... ),+ |
匹配1次或更多次,以逗号分隔 |
$( ... );+ |
匹配1次或更多次,以分号分隔 |
代码片段$x
不仅仅是单个表达式,而是一个表达式列表.此规则的模板也使用重复语法:
<[_]>::into_vec(Box::new([ $( $x ),* ]))
同样,有一些标准方法可以完全满足我们的需要.此代码创建一个装箱数组(boxed array),然后使用[T]::into_vec
方法将装箱数组转换为向量.
第一位<[_]>
,是一种不寻常的方式来编写类型"某东西的切片(slice of something)",同时期望Rust推断元素类型.名称为纯标识符的类型可以在表达式中使用而不会有任何问题,但fn()
,&str
或[_]
等类型必须包含在尖括号中.
重复出现在模板的末尾,那里有$($x),*
.这个$(...),*
与我们在模式中看到的语法相同.它遍历我们为$x
匹配的表达式列表,并将它们全部插入到模板中,以逗号分隔.
在这种情况下,重复输出看起来就像输入一样.但事实并非如此.我们本可以写下这样的规则:
( $( $x:expr ),* ) => {
{
let mut v = Vec::new();
$( v.push($x); )*
v
}
};
这里,模板中读取$( v.push($x); )*
的部分为$x
中的每个表达式插入对v.push()
的调用.
与Rust的其余部分不同,使用$( ... ),*
的模式不会自动支持可选的尾随逗号.但是,通过添加额外规则来支持尾随逗号有一个标准技巧.这就是我们vec!
宏的第三条规则的做法:
( $( $x:expr ),+ ,) => { // if trailing comma is present,
vec![ $( $x ),* ] // retry without it
};
我们使用$( ... ),+
来匹配带有额外逗号的列表.然后,在模板中,我们递归地调用vec!
,留下额外的逗号.这时第二条规则将匹配.
Rust编译器提供了几个在定义自己的宏时很有用的宏.这些都不能单独使用macro_rules!
实现.他们硬编码在rustc
中:
file!()
扩展为字符串字面量:当前文件名.line!()
和column!()
扩展为u32
字面量,给出当前行(从1开始计数)和列(从0开始计数).
如果一个宏调用另一个宏,它调用另一个,所有调用在不同的文件中,最后一个宏调用file!()
,line!()
,或column!()
,它将展开以指示 第一个(first) 宏调用的位置.
stringify!(... tokens ...)
展开为包含给定标记的字符串字面量.assert!
宏使用它来生成包含断言代码的错误消息.
参数中的宏调用 不(not) 展开:stringify!(line!())
展开为字符串"line!()"
.
Rust从标记构造字符串,因此字符串中没有换行符或注释.
concat!(str0,str1,...)
展开为通过连接其参数而生成的单个字符串字面量.
Rust还定义了这些用于查询构建环境的宏:
cfg!(...)
展开为布尔常量,如果当前构建配置与括号中的条件匹配,则为true
.例如,如果你正在启用调试断言进行编译,则cfg!(debug_assertions)
为true.
此宏支持的语法与第175页的"属性(Attributes)"中描述的#[cfg(...)]
属性完全相同,但不是条件编译,你得到一个真或假的答案.
env!("VAR_NAME")
展开为字符串:编译时指定的环境变量的值.如果变量不存在,那就是编译错误.
除了Cargo在编译crate时设置几个有趣的环境变量外,这将没什么价值.例如,要获取crate的当前版本字符串,可以编写:
let version = env!("CARGO_PKG_VERSION");
Cargo文档中包含这些环境变量的完整列表.
option_env!("VAR_NAME")
与env!
相同,只是它返回一个Option<&'static str>
,如果未设置指定的变量,则为None
.
另外三个内置宏可以让你从另一个文件中引入代码或数据.
-
include!("file.rs")
展开为指定文件的内容,该文件必须是有效的Rust代码--表达式或项目序列. -
include_str!("file.txt")
展开为包含指定文件文本的&'static str
.你可以像这样使用它:
const COMPOSITOR_SHADER: &str =
include_str!("../resources/compositor.glsl");
如果文件不存在,或者UTF-8无效,则会出现编译错误.
include_bytes!("file.dat")
是相同的,只是将文件视为二进制数据,而不是UTF-8文本.结果是&'static [u8]
.
像所有宏一样,这些宏在编译时处理.如果文件不存在或无法读取,则编译失败.他们不能在运行时失败.在所有情况下,如果文件名是相对路径,则解析相对于包含当前文件的目录.
调试任意宏可能具有挑战性.最大的问题是缺乏对宏展开过程的可见性.Rust通常会展开所有宏,发现某种错误,然后打印一条错误消息,但不显示包含错误的完全展开代码!
以下是帮助解决宏问题的三种工具.(这些功能都是不稳定的,但由于它们的设计目的是在开发过程中使用,而不是在你检查的代码中使用,因此在实践中这不是一个大问题.)
首先,最简单的,你可以要求rustc
在展开所有宏后显示代码的样子.使用cargo build --verbose
来了解Cargo如何调用rustc
.复制rustc
命令行并添加-Z unstable-options --pretty expand
作为选项.完全展开的代码将转储到你的终端.不幸的是,这仅在你的代码没有语法错误时才有效.
其次,Rust提供了一个log_syntax!()
宏,它只是在编译时将其参数打印到终端.你可以将它用于println!
式调试.该宏需要#![feature(log_syntax)]
功能标志.
第三,你可以要求Rust编译器将所有宏调用记录到终端.插入trace_macros!(true)
;在你代码中的某个地方.从那时起,每次Rust展开一个宏时,它都会打印宏名称和参数.例如,这个程序:
#![feature(trace_macros)]
fn main() {
trace_macros!(true);
let numbers = vec![1, 2, 3];
trace_macros!(false);
println!("total: {}", numbers.iter().sum::<u64>());
}
产生这个输出:
$ rustup override set nightly
...
$ rustc trace_example.rs
note: trace_macro
--> trace_example.rs:5:19
|
5 | let numbers = vec![1, 2, 3];
| ^^^^^^^^^^^^^
|
= note: expanding `vec! { 1 , 2 , 3 }`
= note: to `< [ _ ] > :: into_vec ( box [ 1 , 2 , 3 ] )`
编译器在展开之前和之后显示每个宏调用的代码.行trace_macros!(false)
;再次关闭跟踪,因此不会跟踪对println!()
的调用.
我们现在讨论了macro_rules!
的核心功能.在本节中,我们将逐步开发用于构建JSON数据的宏.我们将使用这个例子来展示开发宏的方式,展示macro_rules!
的一些剩余部分,并提供一些建议,以确保你的宏行为符合要求.
回到第10章,我们提出了这个用于表示JSON数据的枚举:
#[derive(Clone, PartialEq, Debug)]
enum Json {
Null,
Boolean(bool),
Number(f64),
String(String),
Array(Vec<Json>),
Object(Box<HashMap<String, Json>>)
}
不幸的是,写出Json
值的语法相当冗长:
let students = Json::Array(vec![
Json::Object(Box::new(vec![
("name".to_string(), Json::String("Jim Blandy".to_string())),
("class_of".to_string(), Json::Number(1926.0)),
("major".to_string(), Json::String("Tibetan throat singing".to_string()))
].into_iter().collect())),
Json::Object(Box::new(vec![
("name".to_string(), Json::String("Jason Orendorff".to_string())),
("class_of".to_string(), Json::Number(1702.0)),
("major".to_string(), Json::String("Knots".to_string()))
].into_iter().collect()))
]);
我们希望能够使用更类似JSON的语法来编写它:
let students = json!([
{
"name": "Jim Blandy",
"class_of": 1926,
"major": "Tibetan throat singing"
},
{
"name": "Jason Orendorff",
"class_of": 1702,
"major": "Knots"
}
]);
我们想要的是一个json!
宏,它接受JSON值作为参数.并展开为类似于前一个示例中的Rust表达式.
编写任何复杂宏的第一项工作是弄清楚如何匹配或 解析(parse) 所需的输入.
我们已经可以看到宏将有几个规则,因为JSON数据中有几种不同的东西:对象,数组,数字等等.事实上,我们可能会猜测每个JSON类型都有一个规则:
macro_rules! json {
(null) => { Json::Null };
([ ... ]) => { Json::Array(...) };
({ ... }) => { Json::Object(...) };
(???) => { Json::Boolean(...) };
(???) => { Json::Number(...) };
(???) => { Json::String(...) };
}
这不太正确,因为宏模式无法分开最后三种情况;但是我们稍后会看到如何处理.前三个案例至少从不同的标记开始,所以让我们从那些开始.
第一条规则已经奏效:
acro_rules! json {
(null) => {
Json::Null
}
}
#[test]
fn json_null() {
assert_eq!(json!(null), Json::Null); // passes!
}
要添加对JSON数组的支持,我们可能会尝试将元素匹配为expr
:
macro_rules! json {
(null) => {
Json::Null
};
([ $( $element:expr ),* ]) => {
Json::Array(vec![ $( $element ),* ])
};
}
不幸的是,这不匹配所有的JSON数组.这是一个说明问题的测试:
#[test]
fn json_array_with_json_element() {
let macro_generated_value = json!(
[
// valid JSON that doesn't match `$element:expr`
{
"pitch": 440.0
}
]
);
let hand_coded_value =
Json::Array(vec![
Json::Object(Box::new(vec![
("pitch".to_string(), Json::Number(440.0))
].collect()))
]);
assert_eq!(macro_generated_value, hand_coded_value);
}
模式$( $element:expr),*
表示"以逗号分隔的Rust表达式列表(a comma-separated list of Rust expressions)."但是许多JSON值,特别是对象,都不是有效的Rust表达式.他们不会匹配.
由于并非你想要匹配的每一段代码都是表达式,因此Rust支持其他几种片段类型,如表20-1所示.
表20-1. macro_rules!支持的片段类型.
片段类型 | 匹配(示例) | 后面可以跟着... |
---|---|---|
expr |
表达式:2 + 2 ,"udon" ,x.len() |
=> , ; |
stmt |
表达式或声明,不包括任何尾随分号(难以使用;尝试使用expr 或block 代替) |
=> , ; |
ty |
类型:String ,Vec<u8> ,(&str, bool) |
`=> , ; = |
path |
路径(在第167页讨论):ferns ,::std::sync::mpsc |
`=> , ; = |
pat |
模式(在第221页讨论):_ ,Some(ref x) |
`=> , = |
item |
项(在第126页讨论):struct Point { x: f64, y: f64 } ,mod ferns; |
任何东西 |
block |
块(在第124页讨论):{ s += "ok\n"; true } |
任何东西 |
meta |
属性的主体(在第175页讨论):inline ,derive(Copy, Clone) ,doc="3D models." |
任何东西 |
ident |
标识符:std ,Json ,longish_variable_name |
任何东西 |
tt |
标记树(见文本):; ,>= ,{} ,[0 1 (+ 0 1)] |
任何东西 |
此表中的大多数选项都严格执行Rust语法.expr
类型仅匹配Rust表达式(不是JSON值),ty
匹配Rust类型,依此类推.它们不可扩展:没有办法定义新算术运算符或expr
可识别的新关键字.我们将无法使任何这些匹配任意JSON数据.
最后两个,ident
和tt
,支持匹配不像Rust代码的宏参数.ident
匹配任何标识符.tt
匹配单个 标记树(token tree) :正确匹配的一对括号,(...) [...]
或{...}
,以及它们之间的所有内容,包括嵌套的标记树;或者不是括号的单个标记,例如1926
或"Knots"
.
标记树正是我们的json!
宏所需要的.每个JSON值都是一个单个的标记树:数字,字符串,布尔值和null
都是单个标记;对象和数组被括起来.所以我们可以写这样的模式:
macro_rules! json {
(null) => {
Json::Null
};
([ $( $element:tt ),* ]) => {
Json::Array(...)
};
({ $( $key:tt : $value:tt ),* }) => {
Json::Object(...)
};
($other:tt) => {
... // TODO: Return Number, String, or Boolean
};
}
这个版本的json!
宏可以匹配所有JSON数据.现在我们只需要生成正确的Rust代码.
为了确保Rust在将来能够获得新的语法功能而不破坏你今天编写的任何宏,Rust会限制在片段之后出现在模式中的标记.表20-1中的"可以跟随...(Can befollowed by...)"列显示允许哪些标记.例如,模式$x:expr ~ $y:expr
是一个错误,因为在expr
之后不允许〜
.模式$varspat : $t:ty
是可以的,因为$vars:pat
后跟冒号,是用于pat
的允许的标记之一;并且$t:ty
后面没有任何内容,这总是允许的.
你已经看到一个宏调用自己的一个微不足道的案例:我们的vec!
实现使用递归来支持尾随逗号.在这里我们可以展示一个更重要的例子:json!
需要递归地调用自己.
我们可能会尝试不使用递归来支持JSON数组,如下所示:
([ $( $element:tt ),* ]) => {
Json::Array(vec![ $( $element ),* ])
};
但这不起作用.我们将JSON数据($element
标记树)粘贴到Rust表达式中.他们是两种不同的语言.
我们需要将数组的每个元素从JSON格式转换为Rust.幸运的是,有一个宏可以做到这一点:我们正在写的那个!
([ $($element:tt),* ]) => {
Json::Array(vec![ $( json!($element) ),* ])
};
可以以相同的方式支持对象:
({ $($key:tt : $value:tt),* }) => {
Json::Object(Box::new(vec![
$( ($key.to_string(), json!($value)) ),*
].into_iter().collect()))
};
默认情况下,编译器对宏施加递归限制:64次调用.这对于json!
的正常使用来说已经足够了,但复杂的递归宏有时会达到极限.你可以通过在使用宏的包的顶部添加此属性来调整它:
#![recursion_limit = "256"]
我们的json!
宏几近完成.剩下的就是支持布尔值,数字和字符串值.
编写复杂的宏总是会带来谜题.重要的是要记住,宏本身并不是你可以使用的唯一解谜工具.
在这里,我们需要支持json!(true)
,json!(1.0)
和json!("yes")
,将值(无论它是什么)转换为适当类型的Json
值.但宏并不擅长区分类型.我们可以想象编写:
macro_rules! json {
(true) => {
Json::Boolean(true)
};
(false) => {
Json::Boolean(false)
};
...
}
这种方法马上崩溃了.只有两个布尔值,但有更多的数字,甚至更多的字符串.
幸运的是,有一种标准方法可以将各种类型的值转换为一种指定类型:From
trait,如第297页所述.我们只需要为几种类型实现这个特性:
impl From<bool> for Json {
fn from(b: bool) -> Json {
Json::Boolean(b)
}
}
impl From<i32> for Json {
fn from(i: i32) -> Json {
Json::Number(i as f64)
}
}
impl From<String> for Json {
fn from(s: String) -> Json {
Json::String(s)
}
}
impl<'a> From<&'a str> for Json {
fn from(s: &'a str) -> Json {
Json::String(s.to_string())
}
}
...
实际上,所有12种数字类型应该具有非常相似的实现,因此编写宏可能是有意义的,只是为了避免复制和粘贴:
macro_rules! impl_from_num_for_json {
( $( $t:ident )* ) => {
$(
impl From<$t> for Json {
fn from(n: $t) -> Json {
Json::Number(n as f64)
}
}
)*
};
}
impl_from_num_for_json!(u8 i8 u16 i16 u32 i32 u64 i64 usize isize f32 f64);
现在我们可以使用Json::from(value)
将任何支持的类型的value
转换为Json
.在我们的宏中,它看起来像这样:
($other:tt) => {
Json::from($other) // Handle Boolean/number/string
};
将此规则添加到我们的json!
宏使它通过我们迄今为止所写的所有测试.将所有部分组合在一起,它目前看起来像这样:
macro_rules! json {
(null) => {
Json::Null
};
([ $($element:tt),* ]) => {
Json::Array(vec![ $( json!($element) ),* ])
};
({ $($key:tt : $value:tt),* }) => {
Json::Object(Box::new(vec![
$( ($key.to_string(), json!($value)) ),*
].into_iter().collect()))
};
($other:tt) => {
Json::from($other) // Handle Boolean/number/string
};
}
事实证明,宏意外地支持在JSON数据中使用变量甚至任意Rust表达式,这是一个方便的额外功能:
let width = 4.0;
let desc =
json!({
"width": width,
"height": (width * 9.0 / 4.0)
});
因为(width * 9.0 / 4.0)
是带括号的,所以它是单个标记树,因此宏在解析对象时成功地将它与$value:tt
匹配.
编写宏的一个令人惊讶的棘手方面是它们涉及将来自不同作用域的代码粘贴在一起.接下来的几页将介绍Rust处理作用域的两种方式:局部变量和参数的一种方式,以及其他所有方式.
为了说明为什么这很重要,让我们重写我们解析JSON对象的规则(前面显示的json!
中的第三个规则)来消除临时向量.我们可以这样写:
({ $($key:tt : $value:tt),* }) => {
{
let mut fields = Box::new(HashMap::new());
$( fields.insert($key.to_string(), json!($value)); )*
Json::Object(fields)
}
};
现在我们不是通过使用collect()
来填充HashMap
,而是通过重复调用.insert()
方法.这意味着我们需要将映射存储在临时变量中,我们称之为fields
.
但是如果调用json!
的代码碰巧使用自己的变量(也命名为fields
),会发生什么呢?
let fields = "Fields, W.C.";
let role = json!({
"name": "Larson E. Whipsnade",
"actor": fields
});
展开宏会将两位代码粘贴在一起,两者都使用名称fields
来表示不同的东西!
let fields = "Fields, W.C.";
let role = {
let mut fields = Box::new(HashMap::new());
fields.insert("name".to_string(), Json::from("Larson E. Whipsnade"));
fields.insert("actor".to_string(), Json::from(fields));
Json::Object(fields)
};
当宏使用临时变量时,这似乎是一个不可避免的陷阱,你可能已经在考虑可能的修复.也许我们应该重命名变量,json!
宏的定义了其调用者不可能传入的内容:我们可以将其称为__json$fields
而不是fields
.
这里的惊喜是 宏的工作原理(the macro works as is) .Rust为你重命名变量!这个功能首先在Scheme宏中实现,称为 卫生(hygiene) ,因此说Rust有 卫生宏(hygienic macros) .
理解宏卫生的最简单方法是想象每次展开宏时,来自宏本身的展开部分都会被涂成不同的颜色.
然后,将不同颜色的变量视为具有不同的名称:
let fields = "Fields, W.C.";
let role = {
let mut fields = Box::new(HashMap::new());
fields.insert("name".to_string(), Json::from("Larson E. Whipsnade"));
fields.insert("actor".to_string(), Json::from(fields));
Json::Object(fields)
}
;
请注意,由宏调用者传入并粘贴到输出中的代码位(例如"name"
和"actor"
)保持其原始颜色(黑色).仅绘制源自宏模板的标记.
现在有一个名为fields
的变量(在调用者中声明)和一个名为fields
的单独变量(由宏引入).由于名称是不同的颜色,这两个变量不会混淆.
如果宏确实需要引用调用者作用域中的变量,则调用者必须将变量的名称传递给宏.
(绘制颜色比喻并不是对卫生工作方式的准确描述.真正的机制甚至比这更聪明,认识到两个标识符是相同的,无论:"绘制(paint)",如果它们指的是一个共同的变量,在宏和它的调用者的作用域内.但这样的情况在Rust中很少见.如果你理解前面的例子,你就知道使用卫生宏了.)
你可能已经注意到,随着宏的展开,许多其他标识符被绘制了一种或多种颜色:例如Box
,HashMap
和Json
.尽管有绘制,Rust还是没有识别这些类型的名字.这是因为Rust中的卫生仅限于局部变量和参数.当谈到常量,类型,方法,模块和宏名称时,Rust是"色盲(colorblind)".
这意味着如果我们的json!
宏在Box
,HashMap
或Json
不在作用域内的模块中使用,宏将无法工作.我们将在下一节中介绍如何避免此问题.
首先,我们将考虑一个Rust严格的卫生习惯阻碍的情况.我们需要解决它.假设我们有许多包含这行代码的函数:
let req = ServerRequest::new(server_socket.session());
复制和粘贴该行很痛苦.我们可以使用宏吗?
macro_rules! setup_req {
() => {
let req = ServerRequest::new(server_socket.session());
}
}
fn handle_http_request(server_socket: &ServerSocket) {
setup_req!(); // declares `req`, uses `server_socket`
... // code that uses `req`
}
如上所述,这不起作用.它需要宏中的名称server_socket
引用函数中声明的局部server_socket
,反之请求变量req
.但是卫生会阻止宏中的名称与其他作用域中的名称"碰撞(colliding)"--即使在这样的情况下,这就是你想要的.
解决方案是将任何你计划在宏代码内部和外部的使用的标识符传递给宏:
macro_rules! setup_req {
($req:ident, $server_socket:ident) => {
let $req = ServerRequest::new($server_socket.session());
}
}
fn handle_http_request(server_socket: &ServerSocket) {
setup_req!(req, server_socket);
... // code that uses `req`
}
由于req
和server_socket
现在由函数提供,因此它们是该作用域的正确"颜色(color)".
卫生使这个宏使用起来有点啰嗦,但这是一个特性,而不是一个bug:它更容易推理卫生宏知道他们不能弄乱你背后的局部变量.如果在函数中搜索server_socket
之类的标识符,你将找到所有使用它的位置,包括宏调用.
由于宏在编译的早期扩展,在Rust知道项目的完整模块结构之前,它们不会以通常的方式导入和导出.
在单个crate里面:
-
在一个模块中可见的宏在其子模块中自动可见.
-
要将宏从模块"向上(upward)"导出到其父模块,请使用
#[macro_use]
属性.例如,假设我们的 lib.rs 看起来像这样:
#[macro_use]mod macros;
mod client;
mod server;
macros
模块中定义的所有宏都将导入到 lib.rs 中,因此可以在整个crate中可见,包括在client
和server
中.
使用多个crates时:
-
要从另一个crate中导入宏,请在
extern crate
声明上使用#[macro_use]
. -
要从你的crate中导出宏,请使用
#[macro_export]
标记每个公有宏.
当然,实际上做这些事情意味着你的宏可以在其他模块中调用.因此,导出的宏不应该依赖于作用域内的任何东西 --不知道在使用它的作用域内会是什么.即使是标准前置的功能也可以被遮蔽.
相反,宏应该使用它使用的任何名称的绝对路径.macro_rules!
提供特殊片段$crate
来帮助解决这个问题.它的作用类似于定义宏的crate的根模块的绝对路径.我们可以编写$crate::Json
,而不是Json
,这样即使没有导入Json
也可以工作.HashMap
可以更改为::std::collections::HashMap
或$crate::macros::HashMap
.在后一种情况下,我们必须重新导出HashMap
,因为$crate
不能用于访问crate的私有功能.它真的只是展开为类似::jsonlib
的东西,一个普通的路径.可见性规则不受影响.
将宏移动到自己的模块macros
并将其修改为使用$crate
后,它看起来像这样.这是最终版本.
// macros.rs
pub use std::collections::HashMap;
pub use std::boxed::Box;
pub use std::string::ToString;
#[macro_export]
macro_rules! json {
(null) => {
$crate::Json::Null
};
([ $( $element:tt ),* ]) => {
$crate::Json::Array(vec![ $( json!($element) ),* ])
};
({ $( $key:tt : $value:tt ),* }) => {
{
let mut fields = $crate::macros::Box::new(
$crate::macros::HashMap::new());
$( fields.insert($crate::ToString::to_string($key), json!($value)); )*
$crate::Json::Object(fields)
}
};
($other:tt) => {
$crate::Json::from($other)
};
}
由于.to_string()
方法是标准ToString
trait的一部分,我们使用$crate
来引用它,使用我们在第252页的"完全限定的方法调用(Fully Qualified Method Calls)"中介绍的语法:$crate::ToString::to_string($key)
.在我们的例子中,这对于使宏工作并不是绝对必要的,因为ToString
在标准前置中.但是如果你在调用宏时调用可能不在作用域内的trait的方法,那么完全限定的方法调用是最好的方式.
以下宏似乎是合理的,但它给Rust带来了一些麻烦:
macro_rules! complain {
($msg:expr) => {
println!("Complaint filed: {}", $msg);
};
(user : $userid:tt , $msg:expr) => {
println!("Complaint from user {}: {}", $userid, $msg);
};
}
假设我们这样调用它:
complain!(user: "jimb", "the AI lab's chatbots keep picking on me");
对于人眼来说,这显然与第二种模式相匹配.但Rust首先尝试第一个规则,尝试将所有输入与$msg:expr
匹配.这就是事情开始变得糟糕的地方.user: "jimb"
当然不是表达式,所以我们得到语法错误.Rust拒绝扫除毯子下的语法错误--宏已经足够难以调试了.相反,它会立即报告并且编译停止.
如果一个模式中的任何其他标记无法匹配,Rust将继续执行下一个规则.只有语法错误是致命的,它们只在尝试匹配片段时才会发生.
这里的问题并不难理解:我们试图在错误的规则中匹配片段$msg:expr
.它不会匹配,因为我们甚至不应该在这里.调用者想要另一条规则.有两种简单的方法可以避免这种情况.
第一种,避免混淆规则.例如,我们可以更改宏,以便每个模式都以不同的标识符开头:
macro_rules! complain {
(msg : $msg:expr) => {
println!("Complaint filed: {}", $msg);
};
(user : $userid:tt , msg : $msg:expr) => {
println!("Complaint from user {}: {}", $userid, $msg);
};
}
当宏参数以msg
开头时,我们将获得规则1.当他们以user
开头时,我们将获得规则2.无论哪种方式,我们都知道在尝试匹配片段之前我们已经有了正确的规则.
避免虚假语法错误的另一种方法是将更具体的规则放在第一个.将user:
规则放在第一个解决complain!
的问题,因为永远不会达到导致语法错误的规则.
宏模式可以解析比JSON更复杂的输入,但我们发现复杂性很快就会失控.
由Daniel Keep等人撰写的<Rust宏入门>(The Little Book of Rust Macros)是一本优秀的高级macro_rules!
编程手册.这本书清晰而巧妙,它比我们在这里更详细地描述了宏展开的各个方面.它还提供了一些非常聪明的技术来处理macro_rules!
模式作为一种深奥的编程语言服务,以解析复杂的输入.我们对此并不那么热衷.请小心使用.
Rust 1.15引入了一种称为 过程宏(procedural macros) 的独立机制.此功能支持扩展#[derive]
属性以处理自定义traits,如图20-4所示.
图20-4. 通过#[derive]属性调用假想的IntoJson过程宏.
没有IntoJson
trait,但没关系:过程宏可以使用这个钩子插入它想要的任何代码(在这种情况下,可能是impl From<Money> for Json { ... }
).
过程宏"过程化(procedural)"的原因在于它是作为Rust函数实现的,而不是声明式规则集.在撰写本文时,过程宏仍然是新的,并且有望继续发展,因此我们将向你介绍在线文档.
也许,读过这一切之后,你已经决定讨厌宏了.然后怎样呢?另一种方法是使用构建脚本生成Rust代码.Cargo文档显示了如何逐步做这件事.它涉及编写一个生成所需Rust代码的程序,在 Cargo.toml 中添加一行以在构建过程中运行该程序,并使用include!
将生成的代码放入你的crate中.