这篇文章将介绍 Rust 中的一些高级特性,这些特性在一些特定的场景中非常有用,这些特性包括:
- 不安全的 Rust:舍弃 Rust 的某些安全保障并负责手动维护相关规则
- 高级 trait:关联类型、默认类型参数、完全限定语法、超 trait 以及与 trait 相关的 newtype 模式
- 高级类型:更多关于 newtype 模式的内容、类型别名、never 类型和动态大小类型
- 高级函数和闭包:函数指针与返回闭包
- 宏:在编译器生成更多代码的方法
不安全的 Rust
目前我们所有代码都拥有编译器强制实施的内存安全保障,但是 Rust 内部还隐藏了一种不会强制实施内存安全保障的语言:不安全的 Rust(unsafe Rust)。它和普通 Rust 代码没有区别,但是会给我们一些额外的超能力。
不安全 Rust 之所以存在是因为静态分析本质上是保守的。当编译器在判断一段代码是否拥有某种安全保障时,它总是宁可错杀一些合法的程序也不会接受可能非法的代码。通过使用不安全的代码来告知编译器:相信我,我知道自己在干什么
,而缺点则是你需要为自己的行为负责。
另一个需要不安全 Rust 的原因在于底层计算机硬件固有的不安全性。Rust 作为一门系统语言需要能够进行底层编程,它允许你直接与操作系统打交道甚至编写自己的操作系统。
不安全的超能力
可以在代码块前使用关键字 unsafe 来切换到不安全模式,并在被标记后的代码块中使用不安全的代码。不安全 Rust 允许你执行 4 种在安全 Rust 中不被允许的操作,它们就是所谓的 不安全超能力(unsafe superpower)
,这些能力包括:
- 解引用裸指针
- 调用不安全的函数或方法
- 访问或修改可变的静态变量
- 实现不安全的 trait
注意,unsafe 关键字仅仅让你可以访问这 4 种不会被编译器进行内存安全检查的特性,因此即使是身处不安全的代码块中,仍然可以获得一定程度的安全性。而且 unsafe 并不意味着代码一定是危险或者会导致内存安全问题,它仅仅是将责任转移到了程序员的肩上,需要手动确定 unsafe 块的内的代码会以合法的方式访问内存。
为了尽可能隔离不安全代码,可以将不安全代码封装在一个安全的抽象上并提供一套安全 API。这种技术可以有效防止 unsafe 代码泄漏到任何调用它的地方,因为使用安全抽象总会是安全的。
解引用裸指针
裸指针(raw pointer)是一种类似于引用的新指针类型,与引用类似,裸指针要么是可变的,要么是不可变的,它们分别写为 *const T
和 *mut T
。这里星号是类型名的一部分而不是解引用操作。裸指针与引用、智能指针的区别在于:
- 允许忽略借用规则,可以同时拥有指向同一个内存地址的可变和不可变指针,或者拥有指向同一个地址的多个可变指针
- 不能保证自己总是指向了有效的内存地址
- 允许为空
- 没有实现任何自动清理机制
如下代码从一个引用中创建出不可变和可变的裸指针:
1 | fn main() { |
可以在安全代码内合法地创建裸指针,但不能在 unsafe 代码块外解引用裸指针。这里使用了 as 来分别将不可变引用和可变引用强制转换为对应的裸指针类型。创建一个指针并不会产生任何危害,只有当我们试图访问它指向的值才可能因为无效的值而导致程序异常。
裸指针一个主要用途便是与 C 代码接口进行交互,另外还可以被用来构造一些借用检查器无法理解的安全抽象。
调用不安全函数或方法
第二种需要使用不安全代码块的操作便是调用不安全函数(unsafe function)。除了在定义前面加上标记 unsafe,不安全函数或方法看上去与正常的函数或方法几乎一摸一样。unsafe 关键字意味着我们需要在调用函数时手动满足并维护一些先决条件,因为 Rust 无法对这些条件进行验证。通过在 unsafe 代码块中调用不安全函数,我们向 Rust 表明自己确实理解并实现了相关约定。
1 | unsafe fn dangerous() { |
我们必须在单独的 unsafe 代码块中调用 dangerous 函数。这其实也是一种确认,通过插入 unsafe 向 Rust 表明我们自己已经阅读了函数的文档,能够理解正确使用它的方式,并确认满足了它所要求的约定。
函数中包含不安全代码并不意味着我们需要将整个函数都标记为不安全的,实际上将不安全代码封装到安全函数中是一种十分常见的抽象。
如下是一个示例,如下函数接收一个切片并从给定的索引参数处将其分割为两个切片:
1 | fn split_at_mut(slice: &mut[i32], mid: usize) -> (&mut[i32], &mut[i32]) { |
这段代码无法编译通过,因为它只知道我们可变借用了两次同一个切片,虽然我们是借用同一个切片的不同部分,这两个切片没有交叉的地方,但 Rust 编译器不能理解信息。当我们能够确定某段代码的正确性而 Rust 却不能时,可以使用 不安全代码
。
如下使用 unsafe 代码块、裸指针以及一些不安全函数来实现 split_at_mut
:
1 | fn split_at_mut(slice: &mut[i32], mid: usize) -> (&mut[i32], &mut[i32]) { |
切片由一个指向数据的指针与切片长度组成,我们可以通过 as_mut_ptr
方法获得切片包含的裸指针。而 slice::from_raw_parts_mut
接受一个裸指针和长度来创建一个切片。我们的 split_at_mut
并没有标记为 unsafe,因为我们对不安全代码创建了一个安全抽象,并在实现时以安全的方式使用 unsafe 代码块,因为它仅仅创建了指向访问数据的有效指针。
有时候 Rust 代码可能需要与另外一种语言编写的代码交互。Rust 提供了 extern
关键字来简化创建和使用外部函数接口(Foreign Function Interface,FFI)的过程。
如下示例,集成了 C 标准库的 abs 函数。任何在 extern 块中声明的函数都是不安全的。因为其他语言不会执行 Rust 的规则,而 Rust 也无法对它们进行检查。所以调用外部函数的过程,需要开发者保证安全。
1 | extern "C" { |
这段代码在 extern “C” 中列出我们想要调用的外部函数名称和签名,其中 “C” 指明了外部函数使用的二进制接口(Application Binary Interface,ABI):它被用来定义函数在汇编层面的调用方式。”C” ABI 正式 C 语言的 ABI,也是最常见的 ABI 格式之一。
同样可以使用 extern
来创建一个允许其他语言调用 Rust 函数的接口。但是不同于使用 extern 标注的代码块,需要将 extern 关键字及对应的 ABI 添加到函数签名的 fn 关键字之前,并且为函数添加 #[no_mangle]
注解来避免 Rust 在编译时改变它的名称。这一类型的 extern 功能不需要使用 unsafe。如下是一个例子:
1 |
|
访问或修改一个可变静态变量
Rust 其实是支持全局变量的,但是在使用它们的过程中可能会因为 Rust 的所有权机制而产生某些问题。如果两个现成同时访问一个可变的全局变量,那么就会造成数据竞争。
在 Rust 中,全局变量也被称为静态(static)变量。如下定义并使用一个不可变的静态变量:
1 | static HELLO_WORLD: &str = "Hello, world!"; |
静态变量通常写为 SCREAMING_SNAKE_CASE
形式,必须要标注变量的类型。静态变量只能存储拥有 'static
生命周期的引用,这意味着 Rust 编译器可以自己计算出它的生命周期而无需手动标注。访问一个不可变静态变量是安全的。
访问和修改一个可变静态变量都是不安全的,这些代码都必须放在 unsafe
代码块中。在拥有可全局访问的可变数据时,我们很难保证没有数据竞争发生,这也是 Rust 会将可变静态变量当做不安全的原因。
1 | static mut COUNTER: u32 = 0; |
实现不安全 trait
最后一个只能在 unsafe 中执行的操作是实现某个不安全的 trait。当某个 trait 中存在至少一个方法拥有编译器无法校验的不安全因素时,就称该 trait 为不安全的。可以在 trait 定义的前面加上 unsafe 关键字来声明一个不安全 trait,同时该 trait 也只能在 unsafe 代码块中实现。
1 | unsafe trait Foo { |
之前介绍过,当我们的类型完全由实现了 Send 与 Sync 的类型组成时,编译器会自动为它实现 Send 与 Sync。如果我们的类型包含了没有实现 Send 或 Sync 的字段,而又希望把这个类型标记为 Send 与 Sync,就必须使用 unsafe。Rust 无法验证我们的类型是否能够安全地跨线程传递,或者安全地从多个线程访问。因此需要手动执行这些审查并使用 unsafe 关键字来实现这些 trait。
使用不安全代码的时机
在上述操作中使用 unsafe 并没有什么问题,但是由于它们缺少编译器提供的强制内存安全保障,所以要保持 unsafe 代码的正确性也并不是一件简单的事。当出现问题时,显式标记的 unsafe 关键字也可以比较轻松地定位到这些代码。
高级 trait
接下来再介绍一些 trait 的高级特性。
在 trait 的定义中使用关联类型指定占位类型
关联类型是 trait 中的类型占位符,它可以被用于 trait 的方法签名中。trait 的实现者需要根据特定的场景来为关联类型指定具体的类型。通过这一技术,我们可以定义出包含某些类型的 trait,而无需在实现前确定它们的具体类型是什么。
标准库的 Iterator 就是一个带有关联类型的 trait 示例,它拥有一个名为 Item 的关联类型,并使用该类型来替代迭代中出现的值类型。
1 | pub trait Iterator{ |
这里为什么不直接使用泛型来定义 Iterator trait 呢?例如如下所示:
1 | pub trait Iterator<T> { |
这其中的区别在于,如果使用泛型版本,那么就需要在每次实现该 trait 的过程中标注类型。因为我们既可以实现 Iterator
默认泛型参数和运算符重载
在使用泛型参数时可以为泛型指定一个默认的具体类型。当使用默认类型就能工作时,该 trait 的实现者可以不用在指定另外具体的类型。可以在定义泛型时通过 <PlaceholderType=ConcreteType>
来为泛型指定默认类型。
该技术常用于运算符重载中,在 Rust 中,可以实现 std::ops
中列出的那些 triat 来重载一部分相应的运算符。
1 | use std::ops::Add; |
这里的 Add trait 使用了默认泛型参数,它的定义如下:
1 | trait Add(RHS=Self) { |
RUSH=Self
就是默认类型参数,泛型参数 RHS 定义了 add 方法中 rhs 参数的类型。在上面的例子中,因为的确是将两个 Point 实例相加,所以在 Point 实现 Add 时使用了默认的 RHS。而下面的例子则有所不同:
1 | use std::ops::Add; |
为了能够将 Millimeters 和 Meters 的值加起来,我们指定 impl Add<Meters>
来设置 RHS 类型参数的值,而没有使用默认的 Self。
默认类型参数主要被用于以下两种情景:
- 扩展一个类型而不破坏现有的代码
- 允许在大部分用户都不需要的特定场合进行自定义
用于消除歧义的完全限定语法:调用相同名称的方法
Rust 既不会阻止两个 trait 拥有相同名称的方法,也不会阻止你为同一个类型实现这样的两个 trait。你甚至可以在这个类型上直接实现与 trait 方法同名的方法。当你需要调用这些方法时,你需要明确地告诉 Rust 你期望调用的具体对象。
如下是一个实例:
1 | trait Pilot { |
- 当我们在 Human 的实例上调用 fly 时,编译器会默认调用直接实现在类型上的方法
- 为了调用 trait 中的 fly 方法,需要使用更加显式的语法来指定具体的 fly 方法。在方法名的前面指定 trait 名称向 Rust 表明我们需要调用哪个 fly 实现
- 直接使用
Human::fly(&person)
也是可以的,它等价于person.fly()
当拥有两种实现了同一个 trait 的类型时,对于 fly 等需要接收 self 作为参数的方法,Rust 可以自动地根据 self 的类型推导出具体的 trait 实现。但是 trait 中的关联函数没有 self 参数,所以当同一个作用域下有两个实现了这种 trait 的类型时,Rust 无法推导出你究竟想要调用哪一个具体类型,除非使用完全限定语法(Fully qualified syntax)。
1 | trait Animal { |
这段代码的输出如下:
1 | A baby dog is called a Spot |
如果我们想调用在 Dog 上实现的 Animal trait 的 baby_name 函数,直接使用 Animal::baby_name()
是会有编译错误的,因为该函数是一个没有 self 参数的关联函数而不是方法,所以 Rust 无法推断出我们想要调用哪一个 Animal::baby_name
的实现。为了消除歧义并指示 Rust 使用 Dog 为 Animal trait 实现的 baby_name 函数,需要使用完全限定语法:
1 | println!("A baby dog is called a {}", <Dog as Animal>::baby_name()); |
完全限定语法的形式如下:
1 | <Type as Trait>::function(receiver_if_method, next_arg, ...); |
可以在任何调用函数或方法的地方使用完全限定语法,而 Rust 允许你忽略那些能够从其他上下文信息中推导出来的部分。
用于在 trait 中附带另外一个 trait 功能的超 trait
有时需要在一个 trait 中使用另外一个 trait 的功能,在这种情况下,需要使当前 trait 的功能依赖于另外一个同时被实现的 trait。这个被依赖的 trait 也是当前 trait 的 超 trait
(supertrait)。
如下是一个示例,OutlinePrint trait
拥有一个方法 outline_print
,它的默认实现使用 Display trait 的功能,所以 OutlinePrint
必须注明自己只能用于那些提供了 Display 功能的类型。可以在定义 trait 时指定 OutlinePrint: Display
来完成该声明,这有些类似于为泛型添加 trait 约束。
1 | use std::fmt; |
因为我们注明了 OutlinePrint 依赖于 Display trait,所以能够在随后的方法中使用 to_string
函数。
使用 newtype 模式在外部类型上实现外部 trait
之前介绍过,只有当类型和对应的 trait 的任意一个定义在本包内时,我们才能够为该类型实现这一 trait。实际上,我们可以通过 newtype 模式来绕过这个限制,它会利用元组结构体创建一个新的类型,这个元组结构体只有一个字段,是我们想要实现 trait 类型的(thin wrapper),由于封装后的类型位于本地包内,所以可以为该类型实现对应的 trait。使用这一模式不会导致任何额外的运行时开销,封装后的类型会在编译过程中被优化掉。
如下是一个示例:
1 | use std::fmt; |
这种技术有个缺点:由于 Wrapper 是一个新的类型,所以它没有自己内部值的方法。为了让 Wrapper 的行为与 Vec<T>
完全一致,需要在 Wrapper 中实现所有的 Vec<T>
的方法,并将这些方法委托给 self.0。如果我们希望新类型具备内部类型的所有方法,我们也可以为 Wrapper 实现 Deref trait 来直接返回内部类型。
高级类型
使用 newtype 模式实现类型安全与抽象
newtype 模式还有一些用途,它可以被用来静态地保证各种值之间不会被混淆及表明值使用的单位。newtype 模式另外一个用途是为类型的某些细节提供抽象能力,newtype 模式还可以被用来隐藏内部实现,它可以通过轻量级的封装隐藏实现细节。
使用类型别名创建同义类型
除了 newtype 模式,Rust 还提供了创建类型别名(type alias)的功能,它可以为现有的类型生成另外的名称。这一特性需要使用 type 关键字。例如:
1 | type Kilometers = i32; |
不同于 newtype 模式,这里 Kilometers 和 i32 其实是同一种类型,所以可以直接将这两个类型的值相加,甚至可以将 Kilometers 类型的值传递给 i32 类型作为参数的函数。而缺点则是无法享有 newtype 模式带来的类型检查便利性。
类型别名主要用途是减少代码字符重复。例如:
1 | type Thunk = Box<dyn Fn() + Send + 'static>; |
永远不返回的 Never 类型
Rust 有一个名为 !
的特殊类型,它在类型系统中的术语为空类型(empty type),因为它没有任何的值,倾向于叫它 never 类型,因为它在从不返回的函数中充当返回值的类型。
1 | fn bar() -> ! { |
不会返回值的函数也被称为 发散函数
,不能创建出类型为 !
的值来让 bar 返回。那 never
类型到底有什么用处呢?如下是拥有一个 continue 结尾的分支的 match 语句:
1 | let guess: u32 = match guess.trim().parse() { |
match 有一个限制,即 match 的所有分支都必须返回相同的类型。continue 的返回类型是 !
,因为 !
无法产生一个可供返回的值,所以在上面的例子中,Rust 直接采用 u32 作为 guess 的类型。
或者还有一种更为正式的说法,类型 ! 的表达式可以被强制转换为其他任意类型。我们之所以能够使用 continue 来结束 match 分支,是因为 continue 永远不会返回值,相反它会将程序的控制流转移到上层循环,因此这段代码在输入值为 Err 的情况下不会对 guess 进行赋值。
panic!
宏同样使用了 never 类型,还有一个以 !
作为返回类型的表达式是 loop,由于 loop 循环永远不会结束,所以这个表达式以 !
作为自己的返回类型。
1 | print!("forever"); |
动态大小类型和 Sized trait
Rust 需要在编译时获取一些特定的信息来完成自己的工作,例如需要为一个特定的类型的值分配多少空间等。但 Rust 的类型系统中也存在 动态大小类型
(Dynamically Sized Type,DST)的概念,有时也被称为 不确定大小类型
(unsized type),这些类型使我们在编写代码时使用只有在运行时才能确定大小的值。
str 就是动态大小类型(大小无法确定的类型),这也意味着我们无法创建一个 str 类型的变量,或者使用 str 类型来作为函数的参数。如下代码无法正确工作:
1 | let s1: str = "hello there"; |
因为 Rust 需要在编译时确定某个特定类型的值究竟会占据多少内存,而同一个类型的所有值都需要使用等量的内存。而字符串确实具有不同的长度,所以它的内存空间是无法确定的,所以我们就无法创建出动态大小类型变量。
所以我们都是使用 &str
来处理字符串,&str
实际上是由两个值组成的:str 的地址与它的长度。这使得我们可以在编译时确定 &str
值的大小:其长度为 uszie 的两倍。所以无论指向什么样的字符串,总是能够知道 &str
的大小。这就是 Rust 使用动态大小类型的通用方式:他们会附带一些额外的元数据来存储动态信息的大小,我们在使用动态大小类型时总是会把它的值放在某种指针后面。
可以将 &str
可以与所有种类的指针组合起来,例如 Box<str>
或 Rc<str>
等。trait 也是动态大小类型,每一个 trait 都是一个可以通过其名称来进行引用的动态大小类型,为了把 trait 用作 trait 对象,必须将它防止在某种指针之后,例如 &dyn Trait
、Box<dyn Trait>
。
为了处理动态大小类型,Rust 提供了一个特殊的 Sized trait 来确定一个类型的大小在编译时是否可知。编译时可以计算出大小的类型会自动实现这一 trait,另外 Rust 还会为每个泛型函数隐式添加 Sized 约束:
1 | fn generic<T>(t: T) { |
会被隐式转换为:
1 | fn generic<T: Sized>(t: T) { |
在默认情况下,泛型函数只能被用于在编译时已经知道大小的类型,但是可以通过如下语法来解除这一限制:
1 | fn generic<T: ?Sized>(t: &T) { |
这里 ?Size trait
约束表达了与 Sized 相反的含义,可以将它读做 T 可能是也可能不是 Sized 的
。这个语法只能用于 Sized 上,而不能用于其他 trait。另外将 t 参数的类型由 T 修改为 &T,因为类型可能不是 Sized,所以需要将它放置在某种指针后面。
高级函数与闭包
函数指针
之前介绍过如何将闭包传递给函数,但是同样可以将普通函数传递给其他函数。函数会在传递的过程中被强制转换为 fn 类型。注意 fn 与 Fn 的区别,Fn 是闭包 trait,而 fn 是类型,即函数指针。将参数声明为函数指针与闭包语法类似:
1 | fn add_one(x: i32) -> i32 { |
与闭包不同,fn 是一个类型而不是一个 trait,因此可以直接指定 fn 为参数类型,而不用声明一个以 Fn trait 为约束的泛型参数。
由于函数指针实现了全部三种闭包 trait(Fn、FnMut 以及 FnOnce),所以总是可以把函数指针用作参数传递给一个接收闭包的函数。所以我们倾向于搭配闭包 trait 的泛型来编写函数,这样函数可以同时处理闭包和普通函数。当然某些情况下的确可能只想接收 fn 而不想接收闭包(例如与某种不支持闭包的外部代码进行交互,C 函数可以接受函数作为参数,但是却没有闭包)。
如下是一个例子:
1 | let list_of_numbers = vec![1, 2, 3]; |
1 | let list_of_numbers = vec![1, 2, 3]; |
由于元组结构体、元组结构枚举变体这些类型的初始化语法与调用函数相似(它们的构造器的确也被实现为了函数),因此可以将构造器视为实现了闭包 trait 的函数指针,并在那些接收闭包的方法中使用它们。
1 | enum Status { |
返回闭包
由于闭包使用了 trait 来进行表达,所以无法在函数中直接返回一个闭包。在大多数希望返回 trait 的情形下,可以将一个实现了该 trait 的具体类型作为返回值。但是无法对闭包执行同样的操作,因为闭包没有一个可供返回的具体类型,无法将函数指针 fn 用作返回类型。
如下代码尝试返回一个闭包,但是无法通过编译:
1 | fn returns_closure() -> Fn(i32) -> i32 { |
可以使用 trait 对象来解决这一问题:
1 | fn returns_closure() -> Box<dyn Fn(i32) -> i32> { |
宏
宏是 Rust 中一组相关功能的集合称谓,其中包括使用 macro_rules!
构造的声明宏以及另外 3 种过程宏:
- 用于结构体或枚举的自定义
#[derive]
宏,它可以指定随 derive 属性自动添加的代码 - 用于为任意条目添加自定义属性的属性宏
- 看起来类似于函数的函数宏,它可以接收并处理一段标记(token)序列
宏与函数的区别
从根本上来讲,宏是一种用于编写其他代码的代码的编写方式,即所谓的元编程。元编程可以极大程度减少你需要编写和维护的代码数量,虽然这也是函数的作用之一,但是宏却有一些函数所不具备的能力。
函数在定义签名时需要声明自己参数的个数与类型,但是宏却具有一些函数所不具备的能力。例如函数在定义签名时必须声明自己参数的个数与类型,而宏则能够处理可变数量的参数。另外由于编译器会在解释代码之前展开宏,所以宏可以被用来执行比较特殊的任务,例如为类型实现 trait。函数无法做到这一点,因为 trait 需要在编译时实现,而函数则是运行时调用执行的。
宏的定义比函数定义复杂得多,因为我们编写的是用于生成 Rust 代码的 Rust 代码,正是这种间接性,宏定义通常要比函数定义更加难以阅读、理解和维护。
另外宏和函数还有一个重要区别:在某个文件中调用宏时,需要提前定义宏或者将宏引入当前作用域中,而函数则可以在任意位置定义并在任意位置使用。
用于通用元编程的 macro_rules! 声明宏
声明宏有时也被称为模版宏(macros by example)”macro_rules!” 宏,或者直接称为 宏
。从核心形式上来讲,声明宏要求你编写出类似于 match 表达式的东西。声明宏将输入的值与带有相关执行代码的模式进行比较:
- 这里的值是传递给宏的字面 Rust 源代码
- 这里的模式则是可以用来匹配这些源代码的结构
当某个模式匹配成功时,该分支下的代码就会被用来替换传入宏的代码。所有这一切都会发生在编译时期。为了定义一个宏,需要用到 macro_rules!
。
如下展示了一个简化后的 vec!
宏定义:
1 |
|
#[macro_export]
标注意味着这个宏会在它所处的包被引入作用域后可用,缺少该标注的宏不能被引入作用域- 使用
macro_rules!
以及不带感叹号的名称来开始定义宏,宏名称后面则是一对包含了宏定义体的花括号 - 之后则是一个模式为
( $($x:expr),* )
的分支,模式后紧跟着的是=>
及对应的代码块,这些关联代码会在模式匹配成功时执行
宏定义中的模式匹配语法与一般的 Rust 模式匹配不同,因为宏模式匹配的是 Rust 代码结构,而不是值。如下简单解释了这里的匹配语法,完整地宏模式匹配语法,参考 Rust 手册,另外也可以参考 The Little Book of Rust Macros
。
- 首先使用圆括号将整个模式包裹起来,接着是一个 $ 符号,以及另外一对包裹着匹配模式的圆括号,这些被匹配并捕获的值最终会被用于生成替换代码
$x:expr
可以匹配任意的 Rust 表达式,并将其命名为 $x$()
之后的逗号意味着一个可能的字面逗号分隔符会出现在捕获代码的后面,而逗号后的 * 则意味着这个模式可以匹配 0 个或多个 * 之前的东西- 而在匹配分支代码,它会为模式中匹配到的每一个
$()
生成$()*
中的代码,这一展开过程会重复 0 或多次,取决于匹配成功的表达式数量,而$x
则会被每个匹配到的表达式所替代
基于属性创建代码的过程宏
这种形式的宏更像是函数(某种形式的过程)一些,因此被称为 过程宏
。过程宏会接受并操作输入的 Rust 代码,并生成另外一些 Rust 代码作为结果,这与声明宏根据模式匹配来替换代码的行为有所不同。虽然过程宏有 3 种不同的类型(自定义派生宏、属性宏、以及函数宏),但是它们都具有某种相似的工作机制。
创建过程宏时,宏的定义必须单独放置在自己的包中,并使用特殊的包类型(未来可能会消除该限制)。如下是过程宏的实例:
1 | use proc_macro; |
该定义了过程宏的函数接收一个 TokenStream 作为输入,并产生一个 TokenStream 作为输出。TokenStream 类型在 proc_macro
包中定义,表示一段标记序列。这也说明了过程宏的核心机制:需要被宏处理的源代码组成了输入的 TokenStream,而宏生成的代码则组成了输出的 TokenStream。
编写一个自定义 derive 宏
这里我们将实现了一个 自定义 derive 宏
。在 hello_macro
包中定义了一个拥有关联函数 hello_macro
的 HelloMacro trait
中。为了避免让用户在他们的每一个类型上逐一实现 HelloMacro trait,会提供一个能够自动实现 trait 的过程宏。因此用户可以在它们的类型上标注 #[derive(HelloMacro)]
,从而得到 hello_macro
函数的默认实现。
hello_macro
函数用于输出类型的名称,而 Rust 没有提供反射的功能,所以无法再运行时查找类型的名称。因此需要的一个能够在编译时生成代码的宏。
由于过程宏需要单独放置在自己的包内(未来可能会去除该限制),目前组织主包和宏包的惯例是,对于一个名为 foo
的包,会生成一个用于放置自定义派生过程宏的包 foo_derive
。
首先创建代码包:
1 | cargo new hello_macro --lib |
定义 HelloMacro trait
1 | pub trait HelloMacro { |
接下来创建 hello_macro_derive
包:
1 | # cargo new hello_macro_derive --lib |
修改 hello_macro_derive/Cargo.toml
文件,声明它是一个拥有过程宏的包,同时添加依赖:
1 | [lib] |
接下来定义过程宏,首先将如下代码放入 hello_macro_derive
包的 src/lib.rs
中:
1 | extern crate proc_macro; |
- 将负责解析 TokenStream 的代码放入
hello_macro_derive
函数中 impl_hello_macro
函数则只负责语法树- 我们可以借助
proc_macro
包提供的编译器接口在代码中读取和操作 Rust 代码,由于它已经被内置在 Rust 中,因此不需要添加到 Cargo.toml 依赖中,syn 包用来将 Rust 代码从字符串转换为可供我们进一步操作的数据结构体,quote 包则将 syn 包产生的数据结构重新转换为 Rust 代码。这些工具可以让解析 Rust 代码更加轻松 - 当包的用户在某个类型标注
[#derive(HelloMacro)]
时,hello_macro_derive
函数就会被自动调用。这是因为我们在这个函数上标注了proc_macro_derive
,并在该属性中指定了可以匹配到的 trait 名称HelloMacro
。这也是大多数过程宏需要遵循的编写惯例 syn
的 parse 函数接收一个 TokenStream 作为输入,并返回一个 DeviceInput 结构体作为结果,它表示解析后的 Rust 代码impl_hello_macro
函数的产出物是一个 TokenStream,它会被添加到使用这个宏的用户代码中,并使用户在编译自己的包时获得额外的功能quote!
宏允许我们的定义那些希望返回的 Rust 代码,quote!
宏的返回结果还需要通过into()
方法将其转换为 TokenStream 类型quote!
宏也提供了一套模版机制,例如这里使用#name
替换为变量name
中的值- 这里我们编写的过程宏,就是为用户标注的类型生成一份
HelloMacro trait
的实现,而类型的名称可以通过#name
得到 stringify!
宏接收一个 Rust 表达式并在编译时将其转换为字符串字面量
接下来我们在一个新的项目中使用该宏:
1 | use hello_macro::HelloMacro; |
运行结果如下:
1 | Hello, Macro! My name is Pancackes |
属性宏
属性宏与自定义派生宏类似,它允许你创建新的属性,而不是为 derive 属性生成代码。属性宏也更加灵活,因为 derive
宏只能用于结构体和枚举,而属性则可以用于其他条目,例如函数。如下是一个示例:
1 | #[route(GET, "/")] |
这个 #[route]
属性是由框架本身作为一个过程宏来定义的,这个宏定义的函数签名如下:
1 | #[proc_macro_attribute] |
- 这里 attr 是属性本身的内容,即这里的
GET, "/"
- item 则是该属性所所附件的条目,即这里的
fn index() {}
函数宏
函数宏用于定义出类似于函数调用的宏,但比普通函数更加灵活。不同于 macro_rules
只能使用类似于 match 的语法来进行定义。函数宏可以接受一个 TokenStream 作为参数,并与另外两种过程宏一样在定义中使用 Rust 代码来操作 TokenStream。
例如如下调用一个名为 sql!
的函数宏:
1 | let sql = sql!(SELECT * FROM posts WHERE id=1); |
它会解析圆括号内的 SQL 语句语法的正确性,这要比 macro_rules
宏所做的事情复杂的多。它的定义应该是这个样子:
1 |
|