关于 macro 的 正式说明
同系列传送门:
前一节,我们大概清楚了macro的结构, 现在让我们再来简单复习一遍:
macro_rules!(这是特点语法)
来创建一个macrorule(s)(匹配分支,以 "参数=>展开代码" 的形式)
放在最外层的花括号内println!(some_str)
现在稍微深入一点:
一个 rule
可以被这样抽象地表示: (matcher)=>(transcriber), 它由三个重要的部分组成:
matcher (匹配器)
: 用来匹配传入的参数metavariable/literal (元变量/字面量)
: 绑定传入的代码片段,出现于 matcher
transcriber (转录器)
: 用来在宏匹配成功后,进行代码替换以println!
为例子,我们向其传入了一些参数,它给我们打印到标准输出上
因为原本的println!
涉及到卫生性
,重复语法
,路径作用域
等,不适合新鸟阅读,所以我抽象了它:
macro_rules! println {
(/* 空参匹配 */) => (/* `换行` 的代码 */);
(/* 有参匹配 */) => (/* `打印参数 + 换行` 的代码 */)
}
println
由两个rule
组成,每个的形式都是: matcher=>transcriber
, 从上往下,每个rule的matcher
会与传入参数尝试匹配, 匹配时括号具有多样性
下面是具体阐述:
匹配规则:
transcriber
里面的代码matcher
都无法与传入参数
进行匹配,则编译报错括号多样性:
matcher
与transcriber
的括号,可以是(),[],{}三种之一[pattern]=>{expansion}
vec![0, 1,2,3]
与 pritnln!("xx")
而metavariable/literal
则出现在matcher
中,用于匹配并捕获代码片段
在对应的transcriber
中,则可以操控这些元值
,即操控捕获到的代码片段
稍微有点蒙? 我也没让你背哟, 多看多用就会了
是时候引入一些新的东西了, 顺便加深下你的印象
元变量, 即Metavariable, 让我们来看个例子:
macro_rules! my_macro {
($a: expr) => {
$a
};
($a: expr, $b: expr) => {
$a + $b
}
}
fn main() {
my_macro!(1);
my_macro!(1, 2);
}
/* 宏全部展开后, 可以理解为下面:
fn main() {
1;
1 + 2;
}
*/
你会注意到,每个rule之间的参数, 其声明都有点奇怪:
比如: $a:expr
-> $+参数标识符+类型
$
这个前缀是干嘛的?
这显式说明, 它是 metavariable
, 可以匹配并捕获代码片段, 这里会捕获表达式
至于为何要特意加$前缀来说明, 像函数参数那样直接用不行吗? 下面就要讲到啦, 别着急~
先看看 expr
类型, 其全称为 expression(表达式)
第一次宏调用时, 传入了 1(一个字面量)
, 这当然是个expr, 与第一个rule成功匹配
这时, my_macro!(1)
这个部分, 就会被替换为相应的展开代码`
(matcher(匹配器),metavariable(元变量),transcriber(转录器)都用到了呢)
简单来讲, macro的本质, 就是匹配捕获传入参数, 将调用部分替换/展开为相应代码
macro就像个code generator: 一段用来生成code的code
注意: macro实际上, 是将传入部分解析为一个AST节点, 然后将调用部分, 替换为一个新的AST节点
在本节下面会更详细地讲讲
元字面量, 即Metaliteral
为了加深印象与引出 metaliteral
, 我们来玩个对应游戏:
($a: expr) => {$a}; // the first rule
| |
| |
( 1 ) 1 // pass && expand
($a: expr, $b: expr) => { $a + $b}; // the second rule
| | | | |
| | | | |
( 1 , 2 ) => 1 + 2 // pass && expand
我使用 |
来联系 形参 -- 实参
你会惊讶的发现, 传参时的对应关系们, 除了 $a -- 1
与 $b -- 2
, 还有个 , -- ,
对于第二个rule, 它的定义中, 分割两个expr的逗号本身, 也是形参
这种固定的参数, 如同token中的字面量一样
我姑且称它为 Literal Token(字面量标记)
, 或者 Metaliteral (元字面量)
(因为我也不知道有什么对应术语, 所以用了 "姑且", 知道的麻烦告诉我)
假若 rule 中的参数没有 $前缀 进行区分:
macro_rules! fuck {
(a:expr) => {
};
}
fn main() {
fuck!(1); // No
fuck!(a:expr); // Yes ~~(Oh~)~~
}
对于定义中的 "a:expr"(它是 MetaLiteral
), 传入固定形式的 "a:expr" 时才可发生匹配
因此, 切记住当你想绑定一段代码片段, 在参数名前, 必须加上$进行修饰
假设有这么个宏 map!
(联想一下vec!
)
它能根据 =>
判断出 Key/Value
, 然后生成一个 HashMap
如下:
fn main() {
let m = map![
"吉良吉影" => 33,
"空条承太郎" => 41,
]
// `m` 的类型为: std::collections::HashMap<&str, i32>
}
我们来写一个这样的宏吧!
不过我们还未学习 重复语法
, 无法写出接收可变参数的 map!
, 所以现在只讨论简陋版:
macro_rules! map {
($key:expr => $val:expr) => {{
let mut m = std::collections::HashMap::<_,_>::new();
m.insert($key, $val);
m
}};
}
fn main() {
let m = map!("普通上班族" => 33);
println!("{:?}",m);
}
/* 展开后, 可以看作是:
fn main() {
let m = {
let mut m = std::collections::HashMap::<_,_>::new();
m.insert("普通上班族", 33);
m
};
println!("{:?}",m);
}
*/
这就是一个简陋的DSL, 你定义了创建HashMap的新语法: Key=>Value
来看看两种宏参数在宏中, 发挥着怎样的作用:
Metavariable
: 将捕获的传入的代码片段绑定到自身, 并在 Transcriber
中被使用, 最后展开为新代码
Metaliteral
: 限定了匹配, 传入的代码片段, 必须以 => 分割, 才能成功匹配 若改成 map!("普通上班族", 33)
, 则每个rule的 Matcher
都无法匹配上
(这里只有一个rule, 空匹配的懒得放里面了)
其实说了这么多, 简单讲就是一个对应游戏, 对应上的话, 就会替换为一些代码
考虑的事: 匹配的语法美不美观, 简不简单, 然后将复杂的代码隐藏, 就这样而已
之后会考虑讲几个小例子: 比如我在B站上已经录过的递推序列生成器教程, 源自宏小册
(博客的话, 我周六放学回来慢慢更吧......) (潜台词是随时会鸽子)
macro的本质, 是生成一个AST节点, 可以配套看看我在B站上宏视频, 视频讲得很浅, 容易理解
假若由你来设计一个Rust编译器:
首先, 不同的人写代码的风格不同, 那么你如何分析不同源码, 并生成目标码?
若我们将每遍扫描并做点事情的过程, 称为pass
, pass一次就生成了, 对于很大的源码来说, 这不现实吧
那我们就pass多次, 将 从源码编译为目标码
这个大问题, 分解为一大堆小问题, 每一次pass都解决一个小问题, 那不就Ok了吗
这种 中间表示
, 就称为 IR (Intermediate Representation )
我们可以先把源码抽象为AST(抽象语法树, Abstract Syntax Tree), 那是一种代码被抽象后的树状结构, 比如我们用Rust的enum表示一下:
// 该枚举: 一个AST节点可以是Int表达式或二元运算表达式
enum ASTNode {
Int(i32),
BinaryExpr {
op: Op,
lhs: Box<ASTNode>,
rhs: Box<ASTNode>,
}
}
// 二元运算的符号: 这里只抽象了加法
enum Op {
Plus,
}
你只需明白AST是对源码封装的一层抽象产物就可以了
对于很少的源码, 已经可以直接转换为目标码了, 毕竟这时候AST也小, 但是, 如果源码很大呢? AST已经很复杂了
那就再抽象一层吧, 向目标语言逐渐靠拢, 比如向汇编靠拢, 提高性能
基于AST, 我们可以再来一些IR
, 层层递进, 以达目的, 注意, 是一些,而非一个, 因为一次可能还是不够嘛
AST
很重要, 是生成目标码的关键, 是代码的骨架, 而另外的IR, 也是有必要的, 这也增强了可维护性, 编译器在AST的基础上, 最终生成了目标码
Q: 生成AST需要点啥? 或者说, 它由什么组成?
A: 由 expr(表达式), stmt(语句), ;(标点符号) 等组成, 这些都叫做 token
在这些小玩意的基础上, 组成一个更加庞大复杂的整体结构, 它将token们联系起来, 表达了代码的骨架
这个庞然大物便是 AST 了
生成AST需要Token协助, macro 中, 其参数的类型, 便是token类型
macro要操控传入的token (或AST节点, 等会讲), 我们总得知道token类型吧, 不然怎知如何操控?
只有规定宏参数的类型, 才能保证macro达到我们想要的目的 (这里指 Metavariable
)
所以, 辛苦你将下面的记一下, 稍微记下, 有印象即可, 多用几下保证你熟悉得不行:
名称 | 对应 |
---|---|
ident | 标识符, 如函数名, 变量名, 关键字 |
expr | expression, 表达式,如x 与1_i32 |
literal | literal expression, 即字面量表达式, 是expr的子集 |
pat | pattern, 比如在match表达式下的 (pattern) => todo!() |
path | 路径, 注意这里不指文件路径, 而是类似 std::io::stdin 的 |
ty | type, 如 i32, u32, String, Option<T>等 |
tt | token tree, 之后我会单独再讲解下它的 |
meta | 元条目/项, 比如 #[allow(unsued)] , meta 就对应 allow(unuse) |
vis | visibility, 可见性, 比如pub, pub(crate)等, 也可能为空 |
lifetime | 生命周期 |
item | 条目/项, 例如函数定义 |
block | 代码块 |
stmt | statemen, 语句 |
这里有个比较特殊的类型 tt (Token Tree)
从字面上的意思来讲, 就是由Token组成的树(数据结构上的树)呗(废话)
tt
,可以捕获Single Token
,或由(),[],{}及括号包裹起来的东西
作为 Token Tree 的根节点, 先让我们来点例子:
macro_rules! aa {
($a:tt) => {
println!("{}", stringify!($a));
};
}
fn main() {
// Single Token
aa!(123); // Yes: 123
aa!(FuckYou); // Yes: FuckYou
// aa!(Fuck You); // No
// aa!(123 + 11); // No
// (), [], {}
aa!([123]); // Yes: [123]
aa!({123 + 123}); // Yes: { 123 + 123 }
}
上面的代码, 展示了 tt
可以匹配捕获哪些东西
请看以下内容, 以理解它, 我们以<<xxx>>
, 来表示 xxx
是一颗 Token Tree
// 你的眼中:
1 + 2 + (3 + 4)
// tt的眼中:
<<1>> <<+>> <<2>> <<+>> <<( )>>
|
|
<<3>> <<+>> <<4>>
这段代码的Token Tree共有5个:
对于前面4个, 都是Single Token, 也就是只有根节点自己的Tree
对于第5个, 因为有()包裹, <<(...)>> 作为根节点, 它还有三个子节点(这里正好同层)
有没有对 Token后面跟着Tree
更加理解?
macro 会将传入的token, 解析为对应类型的AST节点(除了少量token类型, 等下会讲)
比如 map!
中, $key:value
与 $val:expr
, 都会被解析为expr类型的AST节点:
macro_rules! map {
($key:expr => $val:expr) => {{
let mut m = std::collections::HashMap::<_,_>::new();
m.insert($key, $val);
m
}};
}
fn main() {
let m = map!("普通上班族" => 33);
println!("{:?}",m);
}
/* 展开后, 可以看作是:
fn main() {
let m = {
let mut m = std::collections::HashMap::<_,_>::new();
m.insert("普通上班族", 33);
m
};
println!("{:?}",m);
}
*/
我们通过使用 macro , 站在了更抽象的视角上
操控传入的token(或解析token后形成的AST节点), 组成新AST节点(生成新代码)
这可以大量简化手写量, 如std中向宏传入特定类型, 自动生成为它们实现trait的代码
值得注意的是, 宏将传入参数给AST节点化时, 有时意味着会产生不期望的结果, 我直接用 宏小册 上面的代码了:
macro_rules! capture_then_match_tokens {
($e:expr) => {match_tokens!($e)};
}
macro_rules! match_tokens {
($a:tt + $b:tt) => {"got an addition"};
(($i:ident)) => {"got an identifier"};
($($other:tt)*) => {"got something else"};
}
fn main() {
println!("{}\n{}\n{}\n",
match_tokens!((caravan)),
match_tokens!(3 + 6),
match_tokens!(5)
);
println!("{}\n{}\n{}",
capture_then_match_tokens!((caravan)),
capture_then_match_tokens!(3 + 6),
capture_then_match_tokens!(5)
);
}
输出结果会是:
got an identifier
got an addition
got something else
got something else
got something else
got something else
比如这里, match_tokens
捕获token, 然后将参数解析为一个expr类型的AST节点, 它不再是token, 而是个AST节点了!
比如 5 + 7
, 原本是可以与 $a:tt + $b:tt
相匹配, 也可以与 $a: expr
匹配
但经二次传入后(向 capture_then_match_tokens
传入的参数又传给 match_tokens
)
5 + 7
变成AST表达式节点, 只能与 $a: expr
, 而不能与 $a:tt + $b:tt
匹配
只有 tt
, ident
, lifetime
能免遭 AST节点化, 可以好好理解下这块
总结:
宏将一些捕获的token给AST节点化, 随后又展开为一个新AST节点
这个新的AST节点, 会用来替换宏调用部分的AST节点, 因此你可以在很多地方调用宏 (只要宏展开的AST节点正确即可 :D)
宏展开的结果是个某类型的AST节点, 这相比于C语言的 #define
宏, 有什么好处?
最直接的好处, 如下 (相当于自动给你加上了括号):
// C语言: 简单的文本替换
#define SUM(a,b) a+b
int main(void) {
SUM(2, 2); // 2 * 2
5 * SUM(2, 2); // 5 * 2 + 2
return 0;
}
// Rust语言: 根据上下文推测宏调用的AST节点应被展开为expr类型的AST节点
macro_rules! sum {
($a:expr,$b:expr) => {$a + $b};
}
fn main() {
sum!(2,2); // 2 + 2
5 * sum!(2,2); // 5 * (2 + 2)
}
在传参时有两个地方要注意, 一是个很常见的误解, 二是些为了以后宏的发展而有的限制, 有时间的也可以去看看 宏小册
来看看下面一段代码:
macro_rules! aa {
($a: expr) => {};
($a: ident +) => {}
}
fn main() {
aa!(a); // Yes
aa!(a+); // No
}
按照你的直觉, aa!(a+)
应该会与第二个 rule 相匹配, 但是实际上会报这么一个错误:
expected expression, found end of macro arguments
// 期望表达式, 却发现宏参数结束了
你会发现实际上都是在与第一个rule尝试着进行匹配:
a (lhs, left hand side)
能被第一个rule匹配
而 + (二元加)
因为可以尾随表达式, 也可以被第一个rule匹配 但由于缺少 rhs
, 此时会直接报错, 而不是去尝试匹配下一个rule
这避免了某些情况下, 发生不期望的匹配, 但你却不知, 因此rule的顺序很重要
由于一些歧义, 为了向后兼容性与不破坏代码, 当前对 Metavariable
后面可以跟的内容有所限制, 详情可见 Rust-Reference: 限制
这里只需稍微看看, 留个印象, 实际使用时, 若在此方面报错, 则根据编译器的提示来改即可
(别问我为什么 xxx 类型后面, 加 yyy 这个符号不允许, 因为我也没全搞懂, 反正这样不允许就对了)
当然, 随着Rust声明宏的逐渐完善, 限制会逐渐减少(虽然也不多)
上一篇: p2~> 从println!开始
下一篇: p4~> 重复