rust-decl-macro-p3~> 声明与使用
<~~ 发表日期:2021-09-20 | 本文词数:3725 | 预计阅读时间:19分钟 ~~>

关于 macro 的 正式说明

同系列传送门:

框架建立

前一节,我们大概清楚了macro的结构, 现在让我们再来简单复习一遍:

  • macro_rules!(这是特点语法)来创建一个macro
  • rule(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都无法与传入参数进行匹配,则编译报错

括号多样性:

  • 概念: matchertranscriber的括号,可以是(),[],{}三种之一
  • 定义时: 比如, rule可写成 [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 了


回到Macro

Token类型表

生成AST需要Token协助, macro 中, 其参数的类型, 便是token类型

macro要操控传入的token (或AST节点, 等会讲), 我们总得知道token类型吧, 不然怎知如何操控?
只有规定宏参数的类型, 才能保证macro达到我们想要的目的 (这里指 Metavariable)

所以, 辛苦你将下面的记一下, 稍微记下, 有印象即可, 多用几下保证你熟悉得不行:

TT

这里有个比较特殊的类型 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个:

  1. <<1>>
  2. <<+>>
  3. <<2>>
  4. <<+>>
  5. <<(3 + 4)>>

对于前面4个, 都是Single Token, 也就是只有根节点自己的Tree
对于第5个, 因为有()包裹, <<(...)>> 作为根节点, 它还有三个子节点(这里正好同层)

有没有对 Token后面跟着Tree 更加理解?

AST节点

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声明宏的逐渐完善, 限制会逐渐减少(虽然也不多)


名称对应
ident标识符, 如函数名, 变量名, 关键字
exprexpression, 表达式,如x1_i32
literalliteral expression, 即字面量表达式, 是expr的子集
patpattern, 比如在match表达式下的 (pattern) => todo!()
path路径, 注意这里不指文件路径, 而是类似 std::io::stdin 的
tytype, 如 i32, u32, String, Option
tttoken tree, 之后我会单独再讲解下它的
meta元条目/项, 比如 #[allow(unsued)], meta 就对应 allow(unuse)
visvisibility, 可见性, 比如pub, pub(crate)等, 也可能为空
lifetime生命周期
item条目/项, 例如函数定义
block代码块
stmtstatemen, 语句