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

前言

昨天我们简单的了解了下声明宏2.0的一些特性和改动,那么声明宏的部分就告一段落了。

今天我们来开始过程宏(procedural macro)的学习旅程。


过程宏(Procedural Macros)

注意:过程宏文章有部分内容还处于TODO的状态

和声明宏的学习流程一样,接下来我们也准备分两部分:理论 + 实践(TODO。。。)。

有很多知识点都是来自于 rust reference的,所以最好是去那边再看一遍。。。

理论介绍

其实我们之前学习rust基础时有学习过声明宏和过程宏,不过当时的教程中并没有提供声明宏的例子,所以当时我们只学习了如何编写过程宏,如果你忘了,可以去这里回顾:rust基础学习--day37 - 知乎 (zhihu.com)

我们之前有了解过,过程宏有三种,分别是函数宏(Function-like,其实叫函数式过程宏会好一点,但是太长了,所以后面同一去掉中间一段,另外两个宏同理),属性宏(Attribute)以及派生宏(Derive)。

所以接下来分三小章节来介绍下它们。

不过在介绍之前,我们还需要了解下过程宏的一些点。

和声明宏不同的是,过程宏使用函数的形式接收输入的token流(input token stream),(或者两个),然后输出也是token流(output token stream),相信写过的都不陌生。

过程宏的核心是从crate导出的函数并且定义了proc-macrocrate type

如果我们有多个过程宏,那么我们可以把它们都放到同一个crate里面。

我们之前有配置过的,基于cargo项目是需要声明这个包里面包含过程宏的:

过程宏声明隐式的指向编译器提供的 proc_macro ,它包含了我们开发过程宏所需要的所有东西。

这里面有两个最重要的类型:

  • TokenStream:它是我们熟知的token trees的过程宏变体(variant)
  • Span:它表示源码的一部分,主要用于错误报告(error reporting)和卫生性,具体可以看这里: Hygiene and Spans

因此过程宏是存在于crate里的函数,它们可以和其他rust项目中的项一样被引用(be addressed)。引入它们唯一需要做的就是在项目的依赖图(dependency graph)中引入它们的crate(就是将它所在的crate接入项目中)确保在同一作用域中。

注意:调用过程宏与编译器展开成声明宏是在同一阶段运行,只是过程宏是编译器编译、运行、最后替换或追加的独立的(standalone) Rust 程序。


过程宏中的类型

前面说过过程宏有三种类型:

  • 函数宏一般是用来实现$name : $arg的可调用宏
  • 属性宏一般用来实现#[$arg]属性
  • 派生宏一般是用来实现一个派生(derive,比如#[derive(Debug)]等)

它们基本上都是一样的,不过由于它们函数的定义,在输入和输出会有一些不同点。

简单地说,过程宏就是一个函数处理的token stream。我们来先简单的看下它们的定义和区别。

函数宏

使用#[proc_macro]属性定义

属性宏

使用#[proc_macro_attribute]属性定义

派生宏

使用#[proc_macro_derive(Target)]属性定义

可以看到写法确实都是函数,并且接收token stream,最终也是输出token stream。区别在于如何定义以及如何处理token和输出token

注意,返回的类型必须是TokenStream(需要注意的是必须也是proc_macro公开的,后面接触到quote再说),而不能是其它类型。

过程宏也是有错误报告的:

  1. 直接panic
  2. 调用 compile_error!上报(emit)错误

如果一个过程宏panic了,编译器会从宏调用中捕获这个错误并且将它作为错误上报。

另外,编译器会将一个无限循环(endless loops)的过程宏挂起,这会导致过程宏所在的crate也被挂起。


函数宏(Function-like)

函数宏和声明宏一样的调用格式:macro!(...)

函数宏算是过程宏中最简单的(simplest),也是唯一一个无法在调用的时候从表面和声明宏区分开来的宏。

函数宏的骨架如下:

就像它的名字一样,和函数的定义几乎一模一样,不过需要使用#[proc_macro]来声明这是一个宏。

可以看到它就是将一个TokenStream映射(mapping)成另一个TokenStream

input是调用的分隔符,比如foo!(bar)bar就是输入的token stream,它会被看作是一个整体,bar token。

输出的token stream会作为宏调用的展开产物。

声明宏的替换/展开规则适用于函数宏,也就是函数宏必须在调用的位置展开一个正确的token stream。不过和声明宏不同的是,函数宏不会对输入的token stream做明确的限制,也就是说声明宏中提到的片段限定符(fragment specifier)不会在函数宏中使用,因为过程宏是直接作用于token的,而不是基于片段限定符匹配等。

很明显,函数宏比声明宏要强大的多,因为它可以修改输入的token tree,将它变成自己期望的output token stream

调用的方式:

正如前面提到的,调用这一块从表面看是无法和声明宏区分的。


属性宏

属性宏可以定义一个新的外部(outer)属性并将它和其它的项关联。属性宏可以通过#[attr]或者#[attr(...)]调用,...表示任意的token tree

它的骨架如下:

需要借助proc_macro_attribute声明。

和另外两个过程宏不同的是,属性宏有两个参数:

  • 第一个参数是一个不做限制的token tree,跟在属性名之后,不包含分隔符()。它可能为空,因为属性不一定会写,比如#[attr],只有一个名字,后面什么都没有。
  • 第二个参数是属性要关联的那个项(item),但不包含过程宏定义的属性。因为这是一个 active属性,所以在传递给过程宏之前,它会被从项剥离(be stripped from)。

属性宏展开的产物会完全替换注释的(annotated)项。注意展开的内容不一定是一个单独的项,也可以是0个或者多个。

调用的例子:

你可能有些迷惑:什么是关联的项(item),看着例子,这里的foobar就是被关联的项。


派生宏(Derive)

派生宏可以给derive定义一个新的输入。简单地说,派生宏是属性宏针对于derive的一种变体,用法:#[derive(Target)]

它的骨架如下:

它的定义比较特殊,由于是针对于derive属性的,所以需要定义宏在derive属性的标识符,比如例子中的TlbormDerive,而这个标识符则作为调用时传递给derive的宏名字。

输入的token stream指向关联的项,前面说过关联的项指向使用这个宏的项,因此它只能是enumstruct又或者元组,因为derive属性的使用范围就只有这些。

和属性宏不同的是,派生宏的展开产物不会完全替代原来的属性,而是添加(append)到derive属性里,因为derive里可以同时使用多个,比如derive(Debug, Eq, Clone)等。

调用格式如下:

我把定义宏的代码也放到一起,这样方便理解,可以看到定义宏时传递给proc_macro_derive的标识符TlbormDerive在调用宏的时候是作为宏的名字传递给derive属性的。

辅助属性(Helper Attributes)

派生宏还有一点特殊的地方,它关联的项可以使用额外的属性(只能在这个项中使用),这些额外属性就是派生宏辅助属性(derive macro helper attributes),并且它们是惰性的(inert)。它们存在的目的是提供派生宏针对于每个字段(field)或者变体(variant)额外的自定义能力,这样可以对项的字段或者变体进行注释并且不会影响到它们自己。另外因为它们是惰性的,所以它们不会被剥离,所以可以在所有的宏中可见。

它们可以通过attributes(helper0, helper1, ...)作为参数添加到proc_macro_derive属性中,和派生宏的标识符一样,这些标识符将作为属性在调用时的名字。

它的基础定义和调用如下:

这就是辅助属性的全部内容。在过程宏中使用(或者说消耗)辅助属性,得检查字段和成员的属性,来判断它们是否具有相应的辅助属性。

如果条目使用了所有 derive 宏都未定义的辅助属性,那么会出现错误,因为编译器会尝试将这个辅助属性解析为普通属性(而且这个属性并不存在)。


总结

今天我们熟悉了过程宏的三种类型,接下来本来是打算实战,但是这一大章节是处于TODO的状态,所以接下来是学习第三方的crate。。。