第三课: 通过实战理解Rust宏

Posted by JoshSu Blog on July 31, 2021

大家好, 欢迎大家来参加这次公开课.

今天和大家一起探讨Rust宏, 正如PPT上的一句话 “探讨一个长期以来对我来说相当陌生的话题–宏”

为什么这么说呢? 写了10来年的程序了, 我的经历从php->ruby->java, 其实一直没有遇到宏这个概念, 大学时学C语言, 当时应该来说是学习了宏, 但是接触Rust后一点印象都没有。

所以我还能清楚的记得, 第一次开始写Rust的时候, 一个Hello world的代码. 看到这段代码, 感觉比较简单.

但是继续看println!的实现的时候, 感觉不太懂, 比如说macro_rules!不知道是什么? 还有就是有很多#[]的, 不知道这样写的目的是什么, 心里面很烦躁.

大家不知道有没有和我一样的感受, 有的话可以在群里面扣1.

还是一样的, 平时对String使用比较多, 比如这段代码. 其实理解起来没有任何难度, 但是呢, 一看源码, 总有些不太清楚的.

比如 #[derive(PartialOrd, Eq, Ord)] 不知道是什么, 不知道这样写的目的是什么, 会让人产生对Rust有一种畏惧.

再来看这段代码, 这是截的Tokio官网的一段代码, 这段对于初学者来说, 也是很有难度, #[tokio::main]是干什么用的, 在学习Rust的时候, 入口函数fn main()才是, 前面多了一个async, 能正常运行吗? 在接触Tokio前, 一直以来, Rust的入口函数都是fn main(), 入门有难度.

以上例子是我刚接触Rust时, 遇到的, 正是由于遇到这些不太明白的知识, 促使我不断的学习, 才有今天晚上我把我学到的分享给大家.

首先我来简单的介绍一下自己.

我叫苏林, 是一名从事于互联网研发的程序员, 也是一名技术爱好者, 在互联网行业有十余年, 先后效力于电商、SaaS领域, 对底层系统级开发比较感兴趣, 也才促使我学习和探索Rust语言.

今天给大家分享, 想达到的目的是, 心我自身为例, 在我刚接触Rust时, 对宏这个概念不太了解, 致使我看一些源码时, 很费劲, 正如刚刚看的那些源码, 一开始不知道是干什么的。

为了让大家能快速的入门Rust宏, 特别是在看源码能明白, 以致于不对Rust宏有所畏惧, 就达到这次公开课的目的了, 今天晚上仅仅只有一小段时间, 今天仅仅是抛砖引玉, 对宏有一个概念,, 但是在最后,我会给大家提供一个非常好的学习宏的资料, 只要大家按照提供的资料去学习, Rust过程宏(属性、派生、函数)都会有很大的收获. 废话不多说, 今天的分享按以下4部分来进行.

1、一起重温Rust整个编译过程. Rust宏是属于代码生成的一种技术, 在讲宏之前, 想重温一下Rust编译过程, 因为要了解Rust宏机制, 必须先了解Rust编译过程.

2、声明宏. 声明宏是Rust语言中最常用的宏, 也是最简单的.

3、过程宏. 这是今天晚上的重点.

4、过程宏的学习资料 只要大家肯花时间, 按照这个资料绝对在宏方面的技能会得到很大的提升.

第一部分: 通过这个图, 来重温一下Rust编译过程. 先讲一下Rust整体的编译过程. 图中间部分, 从Code Text到LLVM IR, 这部分是Rust编译器前端, 也就是Rustc, 它是一个编译器前端, 然后这下面叫编译器后端, 这是整体编译器过程, 旁边的先不要关注, 首先编译过程是从Rust代码, 文本代码会经过分词, 然后形成词条流, 一般叫做词法分析阶段, 将文本语法中的元素识别为Rust编译器有意义的词条, 那么就叫词条流Token, 那么经过词法分析以后, 再通过语法分析形成抽象语法树, 在得到抽象语法树之后会语议分析, 语言分析一般都是检查这个程序中是否符合语言的定义, 在Rust里面语义分析会持续到这个两层的中间语言, HIR它是相当于是将这个AST进一步降级, 就是简化之后形成的方便编译器去检查的这样一种高级中间语言, 这个MIR相当于是从高级中间语言进一步降级, 得到的一个中级中间语言, 这样的降级都是为了进一步方便Rust去进行一个静态分析, 检查这个类型, 因为AST相当于是它会进行一些AST到这个阶段它会把一些语法糖进行脱糖, 比如说for循环在这个阶段会被转成loop, if let会被转成match等待这样一个去糖的工作, 那么它主要是用来做类型检查, 做类型推断之类, 那么MIR相当于是中级中间语言,由MIR来生成LLVM IR, 这个相当于是LLVM这样一个中间语言, 它会由LLVM后端来处理优化. MIR做的工作比较多, 比如借用检查, 就是非词法作用域借用检查, 在这一层来做, 因为到这个时候基本上已经没有词法作用域这一说了, 它都是属于控制流图。这是Rust编译的一个过程。

那么我们讲宏, 要理解宏的话你必须理解编译过程, 尤其是分词, 首先我们知道Rust有两类宏, 一个是声明宏, 一个是过程宏, 过程宏可能就是比这个声明宏要更加复杂一点, 我们看右边这个图, 表示声明宏的一个过程, 声明宏的一个过程它实际上就是在Rust解析文本代码的时候, 比如说代码里面碰到println!这个宏, 实际上会由另外一个叫Rust宏解析器来解析这个宏, 因为它要把这个宏就是展开为有意义的普通的Rust代码. 因为这个宏实际上是一个替换,替换发生在哪个层面, 不是在文本层面, 而是在Token层面, 比如说 println!(“hello, world”), 它会把这句话解析为TokenStream, 然后在TokenStream层面上展开, 然后混入到普通的TokenStream, 然后这样来构建整个的编译流程, 所以声明宏因为词条流它就相当于是一个正则表达式, 它的替换规则你可以理解为和正则表达式是一样的, 它没有什么具体的类型信息, 所以这种声明宏的替换它是没有基本上是不支持做任何计算。

那么过程宏其实它的工作机制也是类似, 它也是基于TokenStream, 但是它在这个TokenStream上面又相当于又构建了一个自己的AST, 它是通过第三方库Syn, Syn里面定义了AST类型, 定义AST类型就是更加方便去通过类型信息进行一些更强大的计算, 但这个AST和内部的AST不太一样, 这个是我们为了更稳定, 通过第三方库来构建的, Syn它是依赖于proc_macro2这样一个库, Rust官方团队提供了一个库叫proc_macro, 这个库就是专门为了支持过程宏, 然后proc_macro2它是在这个基础上进行一层包装, 它更加的通用, 不仅仅是过程宏, Syn是基于它来构建的AST, 那么这个Quote它的作用相当于将AST转成TokenStream, Syn构建了AST, 那么Quote会把AST进一步转换为TokenStream, 合到正常的编译流里面去, 这个是它的这样一个过程, 因为过程宏比声明宏多了一层AST, 所以它能进行强大的类型计算, 它只能是token, 因为Token上面没有类型信息, 这就是它们的区别, 所以在理解宏的时候你应该理解这整个过程. 如果你不理解这个过程, 那么Rust宏你是很难理解的.

花了很大一部分精力来讲解Rust编译过程 以及 Rust宏解释器, 目的只有一个, 让大家看到过程宏相关源码至少能知道为什么过程宏相关代码要这样写.

接下来, 我们先来看一个简单的例子, 通过例子来结合着刚才讲的编译过程, 再来理解一下.

第二部分: 声明宏 声明宏, 这里就不着重讲了, 声明宏有很多教程.

第三部分: 实战过程宏 如果想用Rust去开发大型项目,特别是框架级别的项目,那么Rust的过程宏(proc-macro)机制肯定是一个必须掌握的技能。

首先从如何搭建一个Rust过程宏的开发调试环境入手.

新建一个文件夹,我们要在其中建立两个嵌套的crate。我这里就己经创建好了.

我们创建了一个嵌套的层级结构, 其实这就是为了便于管理, 如果你把course-3文件夹和macro_define_crate文件夹平级放置, 或者把他俩放到不同的磁盘分区上去,都是没问题的。

这里要和大家探讨一个问题, 为什么过程宏必须定义在一个独立的crate中。不能在一个crate中既定义过程宏,又使用过程宏。

原理:考虑过程宏是在编译一个crate之前,对crate的代码进行加工的一段程序,这段程序也是需要编译后执行的。如果定义过程宏和使用过程宏的代码写在一个crate中,那就陷入了死锁:

-> 要编译的代码首先需要运行过程宏来展开,否则代码是不完整的,没法编译crate.

-> 不能编译crate,crate中的过程宏代码就没法执行,就不能展开被过程宏装饰的代码.

正是由于这个原理, 我们在学习开源项目时, 都能很容易找到定义宏的源码.

接下来,我们来看看两个项目的Cargo.toml,配置我们的开发环境.

首先在 macro_define_crate/Cargo.toml 中添加[lib]节点, 并在下面增加proc-macro = true,表示这个crate是一个proc-macro,增加这个配置以后,这个crate的特性就会发生一些变化,

例如,这个crate将只能对外导出内部定义的过程宏,而不能导出内部定义的其他内容。

接下来, 我们开始写一个最简单的过程宏.

入参为TokenStream的类型,刚刚己经介绍过了,直白一点, 实际上,这个类型表示的就是输入的源码文件,经过词法分析后的结果。我们在后面会给大家演示里面保存的数据是什么样子的。保证给大家一个清晰直观的认识。

#[proc_macro_attribute]是在告诉编译器我们在定义一个”属性式”的过程宏

它还有两个兄弟:#[proc_macro]和#[proc_macro_derive],分别用于定义”函数式”和”派生式”两种类型的过程宏

接下来的函数名称,就是我们的过程宏的名称.

函数输入有两个参数,分别是attr和item,别紧张,后面看例子就清楚了

我们这里在打印的时候使用了eprintln!,这是因为cargo在调用rustc进行编译的时候,stdout的输出会被cargo吞掉,而stderr上的输出会在控制台被打印出来,所以我们要把输出打印到stderr上。

Rust过程宏的本质就是一个编译环节的过滤器,或者说是一个中间件,它接收一段用户编写的源代码,做一通酷炫的操作,然后返回给编译器一段经过修改的代码。

函数的返回值直接返回了item,表示的含义是,我什么都不修改,输入代码是什么样,还原封不动给回去。当然实际工程中, 就是编写更复杂的逻辑, 返回一个加工后的item给编译器用.

这段过程宏定义的代码是写完了,但是现在还不能直接用,我们没法直接运行它,因为它是由编译器在编译其他代码的过程中调用的,所以我们现在回到外层crate中,使用我们编写的过程宏,这样,在编译外层crate的时候,我们写的过程宏代码就会被执行啦。

运行cargo check命令,check的过程就会将宏进行展开。cargo check的输出结果如下所示,比较长,做简单的解释.

一定要对照着用户输入的源代码来阅读attr和item这两个TokenStream类型数据的内容,我们在过程宏的定义中先后打印了attr变量和item变量的值,

下面这一行用户原始代码,被编译器解析为TokenStream后,作为attr变量,传递给我们的过程宏。

这里的结果我就不再深入分析了,相信通过上面的例子,大家会发现,我们的代码也被转换成了一种数据,也就是一种代码即数据的思想。

有了上面的铺垫,我们现在就很容易理解了:所谓的Rust过程宏,就是我们可以自己修改上面的item变量中的值,从而等价于加工原始输入代码,最后将加工后的代码返回给编译器即可。

通过上面的实验,我们就会认识到,TokenStream还是一种比较低级的表达形式,手工去写这样的树形结构,也一定会让人发疯,因此,我们就要借助syn和quote这两个Rust库来提供更友好的开发体验。

我们把之前的过程宏定义代码修改成下面这个样子,

以上程序有一个共同的特点, 我们写的很简单, 一旦看源码, 方法上各种 #[] 让人不太好明白, 今天公开课的目的, 就是和大家一起, 基于这些点和大家一起探讨探讨, 让大家对Rust宏不再畏惧, 看源码没有压力.

如果想用Rust去开发大型项目,或者学习大型项目源代码, 特别是框架级别的项目,那么Rust的宏机制肯定是一个必须掌握的技能。

Rust语言最强大的一个特点就是可以创建和利用宏/Macro。不过创建Rust宏看起来挺复杂,常常令刚接触Rust的开发者心生畏惧。

接下来的公开课帮助你理解Rust Macro的基本运作原理,学习如何创建自己的Rust宏, 以及查看源码学习宏的实现.

什么是Rust宏

Rust宏运行原理

如何创建Rust过程宏

阅读datafuse项目源码, 学习项目中宏的实现