坏蛋Dan
知乎@坏蛋Dan
发布时间:2024.1.12

前言

前面一大段时间我都在搞自己的个人网站:serenesyllables,现在那边勉强可以用了,后续的文章也会同步到这里,感兴趣的可以去看下。

(之前文章都是直接发知乎上的,导致本地没备份,然后从知乎上复制下来的内容又有不兼容的写法。。。累啊)

所以时间上来说是比较空闲的。

这么一空闲,就想rust(笑)。

然后想起之前收藏好久的和macro也就是宏相关的教程。

所以接下来我们来进入到rust中宏相关的学习,这部分其实算是比较高级的内容,因为rust中有些地方的代码较难做到所谓的“复用”(以前端开发的角度来看),所以为了避免冗杂的重复性代码,宏还是很有必要学习的。


教程简介

我们要学习的教程名叫The Little Book of Rust Macros (veykril.github.io)

这本教程实际上又是另一本书Daniel Keep's Book 的延续(continuation),但这本书在16年就断更了,而little book还在同步更新中,所以我们就直接从little book开始看了。

另外这本书实际上是有中文翻译的,看了下翻译的很好:https://zjp-cn.github.io/tlborm/introduction.html

另外有空的话还是看下英文原版的,因为从英文到中文这一层中间存在抽象的行为(比如意译),这个行为会有信息的丢失。

语法拓展

第一步自然是学习语法,不过在学习之前,我们还得了解一下一些概念。

源码解析

Source Analysis

tokenization

我们需要学习语言的词语或者说是符号: tokenization,相信接触过抽象语法树相关知识的都对这个字眼不陌生,陌生也没关系,反正我们后面也会学到抽象语法树。

我们的源码最终都会变成一系列的tokens。比如:

  • Identifier:标识符,fooBambousselfwe_can_dance等,这些都是标识符,在具体一点,举个例子:let a = vec![];,这里a就是标识符。有点抽象,但是到后面就会理解了。
  • Literals:字面量,比如10hahaha等,具体例子,比如const a: &'static str = "hahah",这里的hahah就是一个字面量,这些会被硬编码进代码里的就是字面量。
  • Keywords:关键字,比如selfmatchfnyieldmacro等,这些都是rust官方定义的关键字,有了这些关键字,加上语句的语法,我们的语句就可以被编译器解析。
  • Symbols:符号,比如[]@, ?等。

还有些其他的,后面再介绍。这些都算是token。里面有一点需要注意:

  1. self,它大部分情况下是关键字,但是也有小部分情况是标识符,后面我们会介绍。
  2. yieldmacro都不是目前rust定义的关键字,但是他们也可以被编译器解析,可以算是rust的预留的关键字,将来可能会用上。
  3. 符号里面也有些是预留的,比如<-,它算是发展完善过程中的一个残留,语法中不支持它,但是词法解析的时候却又能解析。
  4. ::,大家平时写惯了,但实际上这货是一个特殊的符号,而不是两个:符号相连,这一点也需要注意。
  5. 当前rust(1.46)实际上有两个词法分析器(lexer), rustc_lexer只支持解析当个符号,而另一个lexerrustc_parse中,可以将多个符号当做一个特殊符号,比如上面的::

其它语言中比如C/C++都有自己的macro layerrust没有)。因此C/C++的宏在这一点处理上就非常有效,比如下面的例子是可以正常跑通的:


parse

接下来说一下解析,即parseparse是指在内存中将我们的源码转换成一系列token的过程,而这些一系列token组合成一个语法结构tree即是 Abstract Syntax Tree (AST)抽象语法树。

比如1 + 2这么一个表达式会被转换成下面这样:

解析出来的抽象语法树包含着我们整个程序的语法结构,不过它里面是基于词法解析信息的,所以比较抽象,难理解。另外有一点需要注意,就是上下文,对于单个语句被解析出来的tree或者说叶子来说,比如let a = b + c被解析完了之后,在这个叶子上我们知道a等于b + c,但是bc是什么解析器根本不知道。


Token trees

然后我们再来了解一下Token trees

Token trees是处于tokensAST之间的东西。

首先,几乎所有的tokens都算是token trees,更具体形象的说,它们是叶子(leaves)。还有另外一种东西也是token tree leaf,我们后面会提到。

那什么tokens不是叶子呢?那自然是树的节点(interior nodes),比如(...)[...]{...},这仨货,它们就是节点,或者说是grouping tokens,它们参与语句块的形成,但是它们自己并不属于语句。

搞个例子具象化:

可以看到(...)[...]是这个语句块中的一员,但是它本身不算在这个语句中,只是参与塑形。

然后上面的例子抽象成语法树会是下面的结构

可以看到(...)[...]这俩货并不在这个tree语句中,但是结构上他俩还在。


抽象语法树中的宏

Macros in the AST

宏的处理实际发生在抽象语法树生成之后,即是处理在抽象语法树上的。同样的,用来调用宏的语法也得是符合这个语言语法的。而在rust中,存在一些语法拓展结构,当然,它们也算是rust的部分语法。尤其是下面四货:

  1. # [ $arg ];:比如#[derive(Clone)]或者#[no_mangle]等;
  2. # ! [ $arg ];:比如#![allow(dead_code)]或者#![crate_name="blang"]等;
  3. $name ! $arg;:比如println!("hi !")或者concat!("a", "b")等;
  4. $name ! $arg0 $arg1;:比如macro_rules! dumy { () => {}; }等。

前面两种是属性 attributes ,用来声明项(items)、表达式(expressions)和语句(statements)。它们可以被归纳为不同的类型, built-in attributes, proc-macro attributes 以及 derive attributes。即内置属性、过程宏属性以及派生属性。其中过程宏属性和派生属性可以用来实现第二种类型的宏( procedural macros过程宏),(过往的学习文章中有浅浅的了解过:rust基础学习--day37 - 知乎 (zhihu.com))。而另一个角度来说,built-in attributes算是编译器的一部分,是编译器实现的。

第三种语法或者说是形状,像是函数的宏,从例子里看也可以看得出来。这个形状可以用于macro_rules!定义的宏,但不是局限于这一种,这货算是一种泛型语法,也是一种过程宏。举个例子:常用的format!是由macro_rules!定义的,但是它是由format_args!实现的,而format_args!则是一个内置属性。

有点晕,简单地说就是结构够泛,可塑性高。

第四种形状本质上是一个宏不能使用的(not available)变体(variation),实际上它仅用于macro_rules!结构自身中。

看到这里是不是很晕,我们再具体点。先看下第三种形状,解析器是怎么知道$arg代表的是什么语法拓展结构的?实际上它不需要知道,相反,对它来说,我们的这个形状中$arg只能是一个单语法treesingle token tree),更具体点,是单个词语,不是叶子。来看个例子:

这里面涉及到了好几个宏的调用,有点复杂,我们先把语句给抽掉再看下:

对解析器来说,(...)或者[...]又或者{...}这里面是什么东西它不需要知道,这些都是定义这个宏的人需要考虑的,而对于解析器来说,它只会把这些个语句全部当做是一个$arg并传给开发者定义的宏。

这个道理并不是完全适用于1, 2, 4这另外三种形状。对于前面两种形状来说,它们的$arg并不直接是一个token tree,而是一个简单的path,后面跟着=以及字面量表达式或者token tree。这一点我们后面会深层次去探讨,现在记住即可(太抽象了)。而第四种形状则是一种非常特殊的形状,它接收特殊的语法(也是由token tree组成),现在也是不探讨,后面会遇到。

总结下:

  • rust中有很多不同类型的语法拓展;
  • 仅仅只是看$name! $arg这么一个形状,是没办法知道它的语法拓展是啥的,它可能是macro_rules!,也可能是proc-macro,甚至是内置属性;
  • 对于!的宏调用传入的内容来说,对于解析器来说都是一个单独的非叶语法树。
  • 语法拓展也会被解析成抽象语法树的一部分。

最后一点是非常重要的,有特殊意义,因为由于语法拓展也会被解析成抽象语法树,所以通过这几种形状实现的宏,只能用于明确能支持的地方(比如没有引入的地方就无法使用)。

它们可用于替代下面的场景:

  • Patterns:模式
  • Statements:语句
  • Expressions:表达式
  • Items(this includes impl items):项
  • Types:类型

但是并不能用于替代下面这些:

  • Identifiers:标识符
  • Match arms:匹配的arms
  • Struct fields:结构体的字段

平铺展开

Expansion

如字面上所说,就是展开,构造抽象语法树之后的某个时间点,在编译器开始构造理解程序的语义之前,语法拓展会被展开。这个调用会经历(traversing)整个抽象语法树,定位调用宏(语法拓展)的地方并将它替换展开。

一旦编译器执行了这个语法拓展(后面直接就叫宏算了),那么这个宏的展开产物一定要是一个可基于上下文被解析的语法元素集合,否则会直接报错。举个例子,如果你在一个module的作用域内调用一个宏,编译器会将它展开的产物编入抽象语法树中作为节点表示这个树的一个项。如果你调用的宏在一个表达式中,那么编译器会把它展开的产物作为表达式抽象语法树的节点。

简单的说就是你调用宏的地方会影响到最终编译器对宏展开产物的解析。比如:

  • 一个表达式
  • 一个模式
  • 一个类型
  • 0或者多个项
  • 0或者多个语句

你调用宏的地方最终会变成你宏展开的产物。

来看个例子:

这个语句会变成下面这样的抽象语法树(还没有宏展开之前):

然后根据上下文,four!()需要被展开成一个表达式(初始程序只能是一个表达式),然后不管是怎么个表达式,它会被解析成一个完整的表达式。我们的这个例子中假设four!展开之后是1 + 3这么一个表达式,那么最终编译的结果会变成下面这样:

而最终的抽象语法树则是这样:

这就是一个完整的宏展开过程。

不过这里有一点需要注意,我们给展开的产物1 + 3做了(...)包裹处理,这么做是因为编译器会宏的展开表达式当做是一个完整的抽象语法树节点,而不是一些token序列。

这么做也有下面两个的原因:

  1. 除了限定可以使用宏的地方之外,宏展开的类型也必须和解析器期望的一样。
  2. 作为展开的结果,宏绝对不能在未完成或者语法违规的构造中展开。

对于展开来说,还有一点更深远的点需要说下,如果一个宏产物展开在包含其它宏展开产物的表达式中,说人话就是可能其中一个宏是依赖于另一个宏实现的,那会怎么样呢?

来看个例子:

这里的four!是基于three!实现的一个宏,那么它的第一次展开会变成

然后它在展开变成

按我们前面的理解,编译器解析的时候是需要宏展开产物符合“要求”的,所以如果这个时候不符合,它就会去尝试再展开一次,直到这个解析结果符合“要求”。不过这是有上限的,在达到128次递归之后,如果表达式中依旧还有宏在,那么这个时候编译器就会取消对这个宏的展开,并且报错。

当然,也是可以通过#![recursion_limit="..."]来调整的。


卫生

Hygiene

这个名字稍微有点抽象,实际上就是类似前端中提及的pure function也就是纯函数这么一个概念。

卫生是宏中一个比较重要的概念,如果一个宏在它的上下文中执行中,它的展开产物不会影响到外部环境或者它展开所引用的不会被外界影响,那么这个宏就可以被看做是一个干净又卫生的宏。

简单地说,就是createuse的区别。

先来看个create的例子,假设现在我们有个宏make_local!,这个宏展开会变成let local = 0。这表示这个宏创建(creates)了一个标识符local

然后我们使用assert_eq!去比较这个local标识符和0,那么这里make_local!这个宏的展开产物就被上下文环境引用了,所以这个make_local!是一个不卫生的宏。

然后我们再来看use的例子:

我们的这个use_local!展开后会变成local = 42;,那么这里上下文中的local就被引用了,所以这个use_local!也会被看做是不卫生的。

这个先简单的介绍到这里,后面我们会深入的探讨。


调试

Debugging

rustc提供了一些工具可以调试宏,里面包含了一些专门针对于过程宏和声明宏的调试工具。

我们可以通过-Zunpretty=expanded去查看展开的产物,不过目前这个功能还是unstable的,需要切换到nightly

至于如何切换nightly,可以看之前的文章:rust基础学习--day38 - 知乎 (zhihu.com)

比如下面这个例子:

可以使用指令

最终展开产物变成:

不过这里有个问题,实际上我们更需要的是查看展开过程中是否有问题,这个时候这种方案就不行了,我个人推荐的方式是使用dbg!去打log,这是可行的(个人的方案),另外也可以打断点,不过需要花点时间去理解才能看懂过程产物是什么。

也可以使用 cargo-expand插件,效果和使用-Zunpretty=expanded是差不多的(实际上就是这个命令包裹了一层)。

另外也可以借助 playground,使用TOOLS选项查看展开产物。

简单过一下即可,我们后面会大量使用到的,到时通过实际使用来深刻理解。


总结

今天我们简单的理解了宏里面涉及的一些概念,以及宏的展开原理等。

接下来我们会接触到声明宏相关的内容。