十九、高级特性
到目前为止,您已经学习了Rust编程语言最常用的部分。在我们开始第20章的下一个项目之前,我们先来看一下你可能偶尔会碰到,但不是每天都在使用的语言的一些方面。当你遇到任何未知的情况时,你可以使用这一章作为参考。这里介绍的特性在非常特定的情况下非常有用。尽管您可能不经常使用它们,但我们希望确保您掌握Rust提供的所有功能。
在本章中,我们将学习:
- 不安全的Rust(
Unsafe Rust
):如何选择退出Rust的某些保证,并负责手动维护这些保证 - 高级traits(
Advanced traits
):关联类型、默认类型参数、完全限定语法、supertraits 以及与 traits 相关的新类型模式 - 高级类型:更多关于newtype模式、类型别名、never类型和动态大小的类型
- 高级函数和闭包(
Advanced functions and closures
):函数指针和返回闭包 - 宏(
Macros
):定义在编译时定义更多代码的代码的方法
19.1 Unsafe Rus
到目前为止,我们讨论的所有代码都在编译时强制执行Rust的内存安全保证。然而,Rust内部隐藏了第二种语言,它不强制执行这些内存安全保证:它被称为不安全Rust(unsafe Rust
),工作起来就像普通Rust一样,但给了我们额外的超能力。
不安全Rust的存在是因为静态分析本质上是保守的。当编译器试图确定代码是否支持保证时,它最好拒绝一些有效的程序,而不是接受一些无效的程序。虽然代码可能没问题,但如果Rust编译器没有足够的信息,它就会拒绝代码。在这些情况下,您可以使用不安全的代码告诉编译器:“相信我,我知道我在做什么。”然而,要注意的是,使用不安全的Rust要自担风险:如果不正确地使用不安全的代码,可能会因为内存不安全而出现问题,比如空指针解引用。
Rust具有不安全另一面的另一个原因是底层计算机硬件本身就不安全。如果Rust不让你做不安全的操作,你就不能做某些任务。Rust需要允许您进行低级的系统编程,例如直接与操作系统交互,甚至编写自己的操作系统。使用底层系统编程是该语言的目标之一。让我们来探索一下我们可以用不安全的Rust做什么以及如何做。
19.1.1 不安全的超能力
要切换到不安全的Rust,使用unsafe
关键字,然后启动一个包含不安全代码的新块。你可以在不安全的Rust中执行五个在安全Rust中无法执行的操作,我们称之为不安全的超能力(unsafe superpowers
)。这些超能力包括:
- 取消对原始指针的引用
- 调用不安全的函数或方法
- 访问或修改可变静态变量
- 实现一个不安全的 trait
- 访问
union
字段
重要的是要理解unsafe
不会关闭借用检查器或禁用Rust的任何其他安全检查:如果你在不安全的代码中使用引用,它仍然会被检查。unsafe
关键字只允许您访问这五个特性,然后编译器不会检查这些特性的内存安全性。在一个不安全的区域内,你仍然会得到一定程度的安全。
此外,unsafe
并不意味着块中的代码一定是危险的,或者它一定会有内存安全问题:作为程序员,您的目的是确保unsafe
块中的代码将以有效的方式访问内存。
人是容易犯错的,错误是会发生的,但是通过要求这5个不安全操作位于带unsafe
注解的块中,您就会知道与内存安全相关的任何错误都必须位于unsafe
块中。保持unsafe
块较小;稍后当您研究内存错误时,您将会感激不尽。
为了尽可能地隔离不安全的代码,最好将不安全的代码封装在一个安全的抽象中,并提供一个安全的API,我们将在本章后面讨论不安全的函数和方法。标准库的部分实现为经过审计的不安全代码之上的安全抽象。将不安全的代码包装在安全抽象中,可以防止unsafe
使用泄漏到您或您的用户可能希望使用unsafe
代码实现的功能的所有地方,因为使用安全抽象是安全的。
让我们依次来看看这五个不安全的超能力。我们还将介绍一些为不安全代码提供安全接口的抽象。
19.1.2 解引用原始指针
在第4章“悬空引用”一节中,我们提到编译器确保引用总是有效的。不安全Rust有两种新类型,称为原始指针(raw pointers
),类似于引用。与引用一样,原始指针可以是不可变的,也可以是可变的,并且分别被写成*const T
和*mut T
。星号不是解引用操作符;它是类型名的一部分。在原始指针的上下文中,不可变意味着指针被解引用后不能直接赋值给它。
与引用和智能指针不同,原始指针:
- 是否允许通过同时拥有不可变指针和可变指针或指向同一位置的多个可变指针来忽略借位规则
- 不能保证指向有效内存
- 是否允许为空
- 没有实现任何自动清理
通过选择不让Rust强制执行这些保证,您可以放弃安全保证,以换取更好的性能或或能够与另一种语言或硬件(Rust的担保不适用的地方)进行交互。
示例19-1展示了如何从引用创建不可变和可变原始指针。
19-1
let mut num = 5;
let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;
注意,我们没有在这段代码中包含unsafe
关键字。我们可以在安全代码中创建原始指针;我们只是不能在不安全的块外解引用原始指针,正如您稍后将看到的那样。
通过使用as
将不可变引用和可变引用转换为它们对应的原始指针类型,我们创建了原始指针。因为我们直接从保证有效的引用中创建了它们,所以我们知道这些特定的原始指针是有效的,但我们不能对任何原始指针都做出这样的假设。
为了证明这一点,接下来我们将创建一个原始指针,我们不能确定它的有效性。示例19-2展示了如何创建指向内存中任意位置的原始指针。尝试使用任意内存是未定义的:可能在该地址有数据,也可能没有,编译器可能会优化代码,因此没有内存访问,或者程序可能会因分割错误而出错。通常,没有很好的理由编写这样的代码,但这是可能的。
let address = 0x012345usize;
let r = address as *const i32;
回想一下,我们可以在安全代码中创建原始指针,但不能解引用原始指针并读取所指向的数据。在示例19-3中,我们在需要unsafe
块的原始指针上使用了解引用操作符*
。
19-3
let mut num = 5;
let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;
unsafe {
println!("r1 is: {}", *r1);
println!("r2 is: {}", *r2);
}
创建指针没有害处;只有当我们试图访问它所指向的值时,我们才可能最终处理一个无效的值。
还要注意,在示例19-1和19-3中,我们创建了*const i32
和*mut i32
原始指针,它们都指向相同的内存位置,也就是num
存储的位置。如果我们试图创建一个对num
的不可变引用和一个可变引用,代码将无法编译,因为Rust的所有权规则不允许可变引用与任何不可变引用同时存在。使用原始指针,我们可以创建指向同一位置的可变指针和不可变指针,并通过可变指针更改数据,这可能会导致数据竞争。小心!
有了这些危险,为什么还要使用原始指针呢?一个主要的用例是与C代码的接口,正如您将在下一节“调用不安全的函数或方法”中看到的那样。另一种情况是在构建安全抽象时借用检查器无法理解。我们将介绍不安全的函数,然后看一个使用不安全代码的安全抽象示例。
19.1.3 调用不安全的函数或方法
在不安全块中可以执行的第二种操作是调用不安全函数。不安全函数和方法看起来和常规函数和方法完全一样,但是在定义的其余部分之前,它们有一个额外unsafe
。在这个上下文中,unsafe
关键字表示函数有我们在调用这个函数时需要维护的需求,因为Rust不能保证我们已经满足了这些需求。通过在unsafe
的块中调用不安全的函数,我们说我们已经阅读了这个函数的文档,并负责维护函数的契约。
下面是一个名为dangerous
的不安全函数,它在它的主体中没有做任何事情:
unsafe fn dangerous() {}
unsafe {
dangerous();
}
我们必须在单独的unsafe
块中调用dangerous
函数。如果我们试图不在unsafe
块的情况下调用dangerous
,我们会得到一个错误:
$ cargo run
Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0133]: call to unsafe function is unsafe and requires unsafe function or block
--> src/main.rs:4:5
|
4 | dangerous();
| ^^^^^^^^^^^ call to unsafe function
|
= note: consult the function's documentation for information on how to avoid undefined behavior
For more information about this error, try `rustc --explain E0133`.
error: could not compile `unsafe-example` due to previous error
对于unsafe
块,我们向Rust断言我们已经阅读了函数的文档,我们了解如何正确使用它,并且我们已经验证了我们正在履行函数的契约。
不安全函数体实际上是unsafe
块,因此要在不安全函数中执行其他不安全操作,我们不需要添加另一个unsafe
块。
在不安全代码之上创建安全抽象
仅仅因为函数包含不安全代码并不意味着我们需要将整个函数标记为不安全。事实上,在安全函数中封装不安全代码是一种常见的抽象。作为一个例子,让我们研究一下标准库中的split_at_mut
函数,它需要一些不安全的代码。我们将探讨如何实现它。这个安全的方法定义在可变切片上:它接受一个切片,并通过在作为参数给定的索引处分割切片将其变成两个。示例19-4展示了如何使用split_at_mut
。
19-4:
let mut v = vec![1, 2, 3, 4, 5, 6];
let r = &mut v[..];
let (a, b) = r.split_at_mut(3);
assert_eq!(a, &mut [1, 2, 3]);
assert_eq!(b, &mut [4, 5, 6]);
我们不能只使用安全Rust来实现这个函数。一个尝试可能类似于示例19-5,它无法编译。为简单起见,我们将split_at_mut
实现为一个函数而不是一个方法,并且只用于i32
值的切片,而不是泛型类型T
。
fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = values.len();
assert!(mid <= len);
(&mut values[..mid], &mut values[mid..])
}
这个函数首先获取切片的总长度。然后,它通过检查索引是否小于或等于长度来断言作为参数给出的索引是否在切片内。该断言意味着,如果传递的索引大于分割切片的长度,则函数在尝试使用该索引之前将会panic。
然后在元组中返回两个可变切片:一个是从原始切片的开始到mid
索引,另一个是从mid
到切片的结束。
When we try to compile the code in Listing 19-5, we’ll get an error.
Rust的借用检查器无法理解我们正在借用slice的不同部分;它只知道我们从同一个切片上借了两次。借用一个切片的不同部分基本上是可以的,因为两个切片并不重叠,但Rust不够聪明,不知道这一点。当我们知道代码没问题,但Rust不行时,就该去找不安全的代码了。
示例19-6展示了如何使用一个unsafe
块、一个原始指针和一些对不安全函数的调用来实现split_at_mut
。
use std::slice;
fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = values.len();
let ptr = values.as_mut_ptr();
assert!(mid <= len);
unsafe {
(
slice::from_raw_parts_mut(ptr, mid),
slice::from_raw_parts_mut(ptr.add(mid), len - mid),
)
}
}
回想一下在第4章的“切片类型”一节中,切片是指向一些数据和切片长度的指针。我们使用len
方法获取片的长度,使用as_mut_ptr
方法访问片的原始指针。在本例中,因为我们有一个可变的i32值切片,所以as_mut_ptr
返回一个类型为*mut i32
的原始指针,我们将其存储在变量ptr
中。
我们保留mid
索引在片内的断言。然后我们看到不安全的代码:slice::from_raw_parts_mut
函数接受一个原始指针和一个长度,并创建一个片。我们使用这个函数来创建一个从ptr
开始的切片,它的长度为mid
个项。然后我们以mid为参数调用ptr
上的add
方法,以获得一个从mid
开始的原始指针,并使用该指针和mid
之后的剩余项数作为长度创建一个切片。
函数slice::from_raw_parts_mut
是不安全的,因为它接受一个原始指针,并且必须相信这个指针是有效的。原始指针上的add
方法也是不安全的,因为它必须相信偏移位置也是有效指针。因此,我们必须在slice::from_raw_parts_mut
和add
的调用周围放置一个unsafe
块,这样我们才能调用它们。通过查看代码并添加mid
必须小于或等于len
的断言,我们可以知道unsafe
块中使用的所有原始指针都是指向片中的数据的有效指针。这是一个可接受的和适当的使用unsafe
。
注意,我们不需要将结果split_at_mut
函数标记为unsafe
,我们可以从安全的Rust中调用这个函数。我们创建了对不安全代码的安全抽象,并实现了以安全方式使用unsafe
代码的函数,因为它只从该函数可以访问的数据中创建有效指针。
相反,示例19-7中使用slice::from_raw_parts_mut
可能会在使用切片时崩溃。这段代码使用任意内存位置并创建一个10,000个条目的片。
19-7
use std::slice;
let address = 0x01234usize;
let r = address as *mut i32;
let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
我们不拥有这个任意位置的内存,也不能保证这段代码创建的片包含有效的i32
值。试图像使用有效切片一样使用values
会导致未定义的行为。
使用extern
函数调用外部代码
有时,Rust代码可能需要与用另一种语言编写的代码交互。为此,Rust有关键字extern
,它可以帮助创建和使用外部函数接口(Foreign Function Interface, FFI)。FFI是一种编程语言定义函数并允许不同(外部)编程语言调用这些函数的方法。
示例19-8演示了如何设置与C标准库中的abs函数的集成。在extern
块中声明的函数从Rust代码中调用总是不安全的。原因是其他语言不执行Rust的规则和保证,Rust也不能检查它们,所以确保安全的责任落在了程序员身上。
extern "C" {
fn abs(input: i32) -> i32;
}
fn main() {
unsafe {
println!("Absolute value of -3 according to C: {}", abs(-3));
}
}
在extern "C"
块中,我们列出了来自我们想调用的另一种语言的外部函数的名称和签名。"C"
部分定义外部函数使用哪个应用程序二进制接口(application binary interface,ABI): ABI定义如何在程序汇编级别调用函数。"C"
ABI是最常见的,它遵循C编程语言的ABI。
从其他语言调用Rust函数
我们还可以使用extern
创建一个允许其他语言调用Rust函数的接口。我们没有创建整个extern
块,而是添加了extern
关键字,并在相关函数的fn
关键字之前指定要使用的ABI。我们还需要添加一个#[no_mangle]
注解来告诉Rust编译器不要破坏这个函数的名称。Mangling是指编译器更改我们给函数的名称为另一个名称,该名称包含更多信息供编译过程的其他部分使用,但不太适合人类阅读。每种编程语言的编译器对名称的处理略有不同,因此为了让Rust函数可以被其他语言命名,我们必须禁用Rust编译器的名称处理。
在下面的例子中,我们让call_from_c
函数在C
代码中可访问,在它被编译为共享库并从C中链接之后:
#[no_mangle]
pub extern "C" fn call_from_c() {
println!("Just called a Rust function from C!");
}
extern的这种用法并不要求unsafe
。
19.1.4 访问或修改可变静态变量
在本书中,我们还没有讨论全局变量(global variables
),Rust确实支持全局变量,但Rust的所有权规则可能会带来问题。如果两个线程正在访问相同的可变全局变量,则可能导致数据竞争。
在Rust中,全局变量被称为静态变量(static variables
)。示例19-9显示了一个以字符串片作为值的静态变量的声明和使用示例。
19-9
static HELLO_WORLD: &str = "Hello, world!";
fn main() {
println!("name is: {}", HELLO_WORLD);
}
静态变量类似于常量,我们在第3章的“变量和常量的区别”一节中讨论过。静态变量的名称按照约定在SCREAMING_SNAKE_CASE
中**。静态变量只能存储带有'static
生命周期的引用**,这意味着Rust编译器可以计算出生命周期,我们不需要显式地注解它。访问不可变静态变量是安全的。
常量和不可变静态变量之间的细微区别是静态变量中的值在内存中有一个固定的地址。使用该值将始终访问相同的数据。另一方面,常量可以在使用时复制它们的数据。另一个区别是静态变量可以是可变的。访问和修改可变静态变量是不安全的。示例19-10显示了如何声明、访问和修改名为COUNTER
的可变静态变量。
19-10
static mut COUNTER: u32 = 0;
fn add_to_count(inc: u32) {
unsafe {
COUNTER += inc;
}
}
fn main() {
add_to_count(3);
unsafe {
println!("COUNTER: {}", COUNTER);
}
}
与常规变量一样,我们使用mut
关键字指定可变性。任何从COUNTER
读取或写入的代码都必须位于不安全的块中。这段代码编译并输出COUNTER: 3
,因为它是单线程的。使用多个线程访问COUNTER
可能会导致数据竞争。
对于全局可访问的可变数据,很难确保没有数据竞争,这就是Rust认为可变静态变量不安全的原因。在可能的情况下,最好使用我们在第16章中讨论的并发技术和线程安全智能指针,这样编译器就可以检查从不同线程访问的数据是否安全。
19.1.5 实现一个Unsafe
Trait
我们可以使用unsafe
来实现一个unsafe trait。当trait至少有一个方法具有编译器无法验证的不变量时,它就是不安全的。我们通过在trait之前添加unsafe
关键字并将trait的实现标记为unsafe
来声明trait是unsafe
,如示例19-11所示。
19-11
unsafe trait Foo {
// methods go here
}
unsafe impl Foo for i32 {
// method implementations go here
}
fn main() {}
通过使用不安全的impl
,我们承诺将维护编译器无法验证的不变量。
举个例子,回顾一下我们在第16章“Extensible Concurrency with the Sync and Send Traits”一节中讨论的Sync
和Send
标记traits :如果我们的类型完全由Sync
和Send
类型组成,编译器会自动实现这些特征。如果实现的类型包含不是发送或同步的类型,例如原始指针,并且希望将该类型标记为发送或同步,则必须使用unsafe
。Rust无法验证我们的类型是否保证了它可以安全地跨线程发送或从多个线程访问;因此,我们需要手动进行这些检查,并指示unsafe
。
19.1.6 访问Union
的字段
最后一个只适用于unsafe
的操作是访问联合union
的字段。联合类似于结构体,但在特定实例中一次只使用一个声明的字段。联合主要用于在C代码中与联合进行交互。访问联合字段是不安全的,因为Rust不能保证当前存储在联合实例中的数据类型。你可以在Rust参考中了解更多关于联合的信息。
19.1.7 何时使用unsafe
代码
使用unsafe
的方法来采取上述五种行动之一(超能力)并不是错误的,甚至是不受欢迎的。但是要使unsafe
代码正确是比较棘手的,因为编译器不能帮助维护内存安全。当您有理由使用unsafe
代码时,您可以这样做,并且具有显式的不安全注解可以更容易地在问题发生时跟踪问题的根源。
19.2 高级 Traits
我们在第10章的“特征:定义共享行为”一节中首先讨论了特征,但我们没有讨论更高级的细节。现在你对Rust有了更多的了解,我们可以深入了解真相了。
19.2.1 在Trait定义中用关联类型指定占位符类型
关联类型(Associated types
)将类型占位符与trait连接起来,这样trait方法定义就可以在它们的签名中使用这些占位符类型。对于特定的实现,trait的实现者将指定要使用的具体类型,而不是占位符类型。这样,我们就可以定义一个使用某些类型的trait,而不需要知道这些类型到底是什么,直到该trait被实现。
我们在本章中描述的大多数高级特性都是很少用到的。关联类型介于两者之间:它们比本书其余部分解释的特性使用得更少,但比本章讨论的许多其他特性使用得更普遍。
带有关联类型的trait的一个例子是标准库提供的Iterator
trait。关联的类型名为Item
,代表实现Iterato
r特征的类型迭代遍历的值的类型。Iterator
trait的定义如示例19-12所示。
19-12:
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
类型Item
是一个占位符,next
方法的定义表明它将返回类型为Option<Self::Item>
的值。Iterato
r trait的实现者将为Item
指定具体类型,而next
方法将返回一个包含该具体类型值的Option
。
关联类型可能看起来与泛型类似,因为后者允许我们定义函数而不指定它可以处理什么类型。为了检验这两个概念之间的区别,我们将看看Iterator
trait在名为Counter
的类型上的实现,该类型指定Item
类型为u32
:
impl Iterator for Counter {
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
// --snip--
这种语法似乎可以与泛型的语法相比较。那么为什么不直接用泛型定义Iterator
trait,如示例19-13所示?
19-13:
pub trait Iterator<T> {
fn next(&mut self) -> Option<T>;
}
区别在于,当使用泛型时,如示例19-13所示,我们必须在每个实现中注解类型;因为我们也可以为Counter
实现Iterator<String> for Counter
或任何其他类型,所以我们可以有多个用于Counter
的Iterator
实现。换句话说,当一个trait有一个泛型参数时,它可以为一个类型实现多次,每次都改变泛型类型参数的具体类型。当我们在Counter
上使用next
方法时,我们必须提供类型注解来指出我们想要使用哪个迭代器实现。
对于关联类型,我们不需要注解类型,因为我们不能在类型上多次实现trait。在示例19-12中使用关联类型的定义中,我们只能选择Item
的类型一次,因为Counter
只能有一个impl Iterator for Counter.
。我们接下来在Counter
上调用的所有地方,不需要指定我们想要一个u32
值的迭代器。
关联类型也成为trait契约的一部分:trait的实现者必须提供一个类型来代替关联的类型占位符。关联类型通常有一个描述如何使用该类型的名称,在API文档中记录关联类型是很好的实践。
19.2.2 默认泛型类型参数和操作符重载
使用泛型类型参数(generic type parameters)时,可以为泛型类型指定默认的具体类型。如果默认类型有效,trait的实现者就不需要指定具体类型了。在使用<PlaceholderType=ConcreteType>
语法声明泛型类型时指定默认类型。
这种技术很有用的一个例子是操作符重载,在这种情况下,您可以在特定的情况下自定义操作符(例如+
)的行为。
Rust不允许您创建自己的操作符或重载任意操作符。但是,您可以通过实现与操作符相关的traits 来重载std::ops
中列出的操作和相应的traits 。例如,在示例19-14中,我们重载了+
操作符来将两个Point
实例加在一起。我们通过在Point
结构体上实现Add
traits来做到这一点:
19-14:
use std::ops::Add;
#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
x: i32,
y: i32,
}
impl Add for Point {
type Output = Point;
fn add(self, other: Point) -> Point {
Point {
x: self.x + other.x,
y: self.y + other.y,
}
}
}
fn main() {
assert_eq!(
Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
Point { x: 3, y: 3 }
);
}
add
方法将两个Point
实例的x
值和两个Point
实例的y
值相加,以创建一个新的Point
。Add
trait有一个名为Output
的关联类型,该类型决定了从Add
方法返回的类型。
这段代码中的默认泛型类型位于Add
trait中。下面是它的定义:
trait Add<Rhs=Self> {
type Output;
fn add(self, rhs: Rhs) -> Self::Output;
}
这段代码看起来应该很熟悉:一个trait有一个方法和一个关联的类型。新的部分是Rhs=Self
:此语法称为默认类型参数
。Rhs
(“right hand side”的简称)泛型类型参数定义了add
方法中Rhs
参数的类型。如果我们在实现Add
trait时没有为Rhs
指定具体类型,Rhs
的类型将默认为Self
,这将是我们实现Add
的类型。
当我们实现 Point
实现Add
时,我们使用Rhs
的默认值,因为我们想添加两个Point
实例。让我们看一个实现Add
trait 的示例,其中我们希望自定义Rhs
类型,而不是使用默认类型。
我们有两个结构,Millimeters
和Meters
,保存不同单位的值。这种在另一个结构中对现有类型的薄包装被称为newtype
模式,我们将在“使用newtype
模式在外部类型上实现外部特征”一节中详细描述。我们希望将以毫米为单位的值添加到以米为单位的值,并让Add
的实现正确地进行转换。我们可以使用Meters
作为 Rhs
为Millimeters
实现的Add
,如示例19-15所示。
19-15:
use std::ops::Add;
struct Millimeters(u32);
struct Meters(u32);
impl Add<Meters> for Millimeters {
type Output = Millimeters;
fn add(self, other: Meters) -> Millimeters {
Millimeters(self.0 + (other.0 * 1000))
}
}
要相加Millimeters
和Meters
,我们指定impl add <Meters>
来设置Rhs
类型参数的值,而不是使用默认的Self
。
使用默认类型参数主要有两种方式:
- 在不破坏现有代码的情况下扩展类型
- 允许在大多数用户不需要的特定情况下进行定制
标准库的Add
trait是第二个目的的一个例子:通常,如果您将相加两个类似的类型,Add
trait提供了自定义的能力。在Add
trait定义中使用默认类型参数意味着在大多数情况下您不必指定额外的参数。换句话说,一点实现样板文件是不需要的,这使得trait更容易使用。
第一个目的与第二个相似,但相反:如果您想向现有的trait添加一个类型参数,您可以给它一个默认值,以允许扩展trait的功能,而不破坏现有的实现代码。
19.2.3 消除歧义的完全限定语法:调用同名方法
Rust中不会阻止一个trait的方法与另一个trait的方法同名,也不会阻止你在一个类型上实现两个trait。也可以直接在类型上实现一个方法,这个方法与实现trait中的方法同名。
当调用同名的方法时,你需要告诉Rust你想使用哪个方法。考虑示例19-16中的代码,其中我们定义了两个trait, Pilot
和Wizard
,它们都有一个名为fly
的方法。然后,我们在已经实现了名为fly的方法的Human
类型上实现这两个特征。每种fly
方法都有不同的功能。
19-16
trait Pilot {
fn fly(&self);
}
trait Wizard {
fn fly(&self);
}
struct Human;
impl Pilot for Human {
fn fly(&self) {
println!("This is your captain speaking.");
}
}
impl Wizard for Human {
fn fly(&self) {
println!("Up!");
}
}
impl Human {
fn fly(&self) {
println!("*waving arms furiously*");
}
}
当我们在Human
实例上调用fly
时,编译器默认调用直接在该类型上实现的方法,如示例19-17所示。
fn main() {
let person = Human;
person.fly();
}
运行这段代码将打印*waving arms furiously*
,显示Rust调用了直接在Human
上实现的fly
方法。
要从Pilot
trait或Wizard
trait中调用fly方法,我们需要使用更显式的语法来指定我们指的是哪个fly方法。示例19-18演示了这种语法。
19-18
fn main() {
let person = Human;
Pilot::fly(&person);
Wizard::fly(&person);
person.fly();
}
在方法名之前指定trait名称向Rust阐明我们想调用哪个fly
。我们也可以编写Human::fly(&person)
,它等价于我们在示例19-18中使用的person.fly()
,但是如果我们不需要消除歧义,那么写这个就有点长了。
Running this code prints the following:
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
Finished dev [unoptimized + debuginfo] target(s) in 0.46s
Running `target/debug/traits-example`
This is your captain speaking.
Up!
*waving arms furiously*
因为fly
方法有一个self
参数,如果我们有两个都实现了一个trait的类型,Rust可以根据self
的类型确定要使用哪个 trait 的实现。
但是,不是方法的关联函数(associated functions
)没有self
形参。当有多个类型或 traits 定义了具有相同函数名的非方法函数时,Rust并不总是知道你指的是哪一种类型,除非你使用完全限定语法(fully qualified syntax
)。例如,在示例19-19中,我们为一个动物收容所创建了一个trait,该trait希望将所有的幼犬命名为Spot
。我们用一个相关联的非方法函数baby_name
来创建Animal
trait。Animal
trait 是为结构Dog
实现的,我们还在该结构Dog
上直接提供了一个相关的非方法函数baby_name
。
19-19:
trait Animal {
fn baby_name() -> String;
}
struct Dog;
impl Dog {
fn baby_name() -> String {
String::from("Spot")
}
}
impl Animal for Dog {
fn baby_name() -> String {
String::from("puppy")
}
}
fn main() {
println!("A baby dog is called a {}", Dog::baby_name());
}
在main
中,我们调用Dog::baby_name
函数,该函数直接调用Dog
上定义的相关函数。这段代码输出如下内容:
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
Finished dev [unoptimized + debuginfo] target(s) in 0.54s
Running `target/debug/traits-example`
A baby dog is called a Spot
这个输出不是我们想要的。我们想要调用baby_name
函数,它是我们在Dog
上实现的Animal
trait的一部分,因此代码打印A baby dog is called a puppy
。我们在示例19-18中使用的指定trait名称的技术在这里没有帮助;如果我们将main
改为示例19-20中的代码,我们将得到一个编译错误。
fn main() {
println!("A baby dog is called a {}", Animal::baby_name());
}
因为Animal::baby_name
没有自我参数,而且可能有其他类型实现了Animal
特征,Rust无法找出我们想要的Animal::baby_name
的哪个实现。我们会得到这样的编译错误:
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0283]: type annotations needed
--> src/main.rs:20:43
|
20 | println!("A baby dog is called a {}", Animal::baby_name());
| ^^^^^^^^^^^^^^^^^ cannot infer type
|
= note: cannot satisfy `_: Animal`
For more information about this error, try `rustc --explain E0283`.
error: could not compile `traits-example` due to previous error
为了消除歧义,并告诉Rust我们想为Dog
使用Animal
的实现,而不是为其他类型使用Animal
的实现,我们需要使用完全限定语法。示例19-21演示了如何使用完全限定语法。
19-21
fn main() {
println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
}
我们在尖括号内为Rust提供了一个类型注解,这表明我们希望调用在Dog
上实现的Animal
trait 的 baby_name
方法,也就是说,我们希望在这个函数调用中将Dog
类型视为Animal
。这段代码现在将打印我们想要的内容:
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
Finished dev [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/traits-example`
A baby dog is called a puppy
一般来说,完全限定语法的定义如下:
<Type as Trait>::function(receiver_if_method, next_arg, ...);
对于不是方法的关联函数,将不会有接收者(receiver
):只有其他参数的列表。在调用函数或方法的任何地方都可以使用完全限定语法。但是,您可以省略Rust可以从程序中的其他信息中找出的语法的任何部分。您只需要在多个实现使用相同名称的情况下使用这种更详细的语法,并且Rust需要帮助来确定您想调用哪个实现。
19.2.4 父 trait 用于在另一个 trait 中使用某 trait 的功能
有时,您可能会编写依赖于另一个 trait 的 trait 定义:对于实现第一个 trait 的类型,您希望要求该类型也实现第二个 trait 。这样做是为了让你的 trait 定义可以使用第二个 trait 的相关项。你的 trait 定义所依赖的 trait 被称为你 trait 的父 trait (supertrait
)。
例如,我们想要使用outline_print
方法创建一个OutlinePrint
trait,该方法将打印一个给定的值,格式化后用星号框起来。也就是说,给定一个Point
结构体,它实现了标准库trait Display
以打印(x, y)
,当我们在一个x
为1 y
为3的Point
实例上调用outline_print
时,它应该打印以下内容:
**********
* *
* (1, 3) *
* *
**********
在outline_print
方法的实现中,我们希望使用Display
trait 的功能。因此,我们需要指定OutlinePrint
trait 只适用于实现Display
并提供OutlinePrint
所需功能的类型。我们可以在trait定义中通过指定OutlinePrint: Display
来做到这一点。这种技术类似于将一个 trait 绑定到 trait 上。示例19-22显示了OutlinePrint trait的实现。
19-22
use std::fmt;
trait OutlinePrint: fmt::Display {
fn outline_print(&self) {
let output = self.to_string();
let len = output.len();
println!("{}", "*".repeat(len + 4));
println!("*{}*", " ".repeat(len + 2));
println!("* {} *", output);
println!("*{}*", " ".repeat(len + 2));
println!("{}", "*".repeat(len + 4));
}
}
因为我们已经指定了OutlinePrint
需要Display
特征,所以我们可以使用to_string
函数,该函数为实现Display
的任何类型自动实现。如果我们试图使用to_string
而不添加冒号并在 trait 名称后指定Display
trait,我们会得到一个错误,说在当前范围内没有为类型&Self
找到名为to_string
的方法。
让我们看看当我们尝试在一个没有实现Display
的类型上实现OutlinePrint
时会发生什么,比如Point
结构体:
struct Point {
x: i32,
y: i32,
}
impl OutlinePrint for Point {}
We get an error saying that Display
is required but not implemented:
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0277]: `Point` doesn't implement `std::fmt::Display`
--> src/main.rs:20:6
|
20 | impl OutlinePrint for Point {}
| ^^^^^^^^^^^^ `Point` cannot be formatted with the default formatter
|
= help: the trait `std::fmt::Display` is not implemented for `Point`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `OutlinePrint`
--> src/main.rs:3:21
|
3 | trait OutlinePrint: fmt::Display {
| ^^^^^^^^^^^^ required by this bound in `OutlinePrint`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `traits-example` due to previous error
为了解决这个问题,我们在Point
实现了Display
,并满足OutlinePrint
要求的约束,如下所示:
use std::fmt;
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
然后,在Point
上实现OutlinePrint
trait将成功编译,我们可以在Point
实例上调用outline_print
以在星号的轮廓中显示它。
19.2.5 用newtype
模式在外部类型上实现外部 trait
在第10章“在类型上实现Trait”一节中,我们提到了孤儿规则(orphan rule
),即只有当Trait或类型是我们crate的本地时,我们才允许在类型上实现Trait。使用newtype模式(newtype pattern
)可以绕过这个限制,这涉及到在元组结构体中创建一个新类型。(我们在第5章的“使用无命名字段的元组结构来创建不同类型”一节中介绍了元组结构。)tuple结构体只有一个字段,是我们想要为其实现trait的类型的精简包装器。然后包装器类型是我们crate的本地类型,我们可以在包装器上实现trait。Newtype是一个起源于Haskell编程语言的术语。使用此模式不会影响运行时性能,并且在编译时省略了包装器类型。
例如,假设我们想在Vec<T>
上实现Display
,孤立规则阻止我们直接这样做,因为Display
trait 和Vec<T>
类型定义在我们的crate之外。我们可以创建一个Wrapper
结构体,它包含Vec<T>
;然后我们可以在Wrapper
上实现Display
,并使用Vec<T>
值,如示例19-23所示。
19-23
use std::fmt;
struct Wrapper(Vec<String>);
impl fmt::Display for Wrapper {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}
fn main() {
let w = Wrapper(vec![String::from("hello"), String::from("world")]);
println!("w = {}", w);
}
Display
的实现使用self
。访问Vec<T>
,因为Wrapper
是一个元组结构体,Vec<T>
是元组中索引0
的项。然后我们可以在Wrapper
上使用Display
类型的功能。
使用这种技术的缺点是Wrapper
是一种新类型,因此它没有它所持有的值的方法。我们必须直接在Wrapper
上实现Vec<T>
的所有方法,以便这些方法委托给self.0
,这将允许我们完全像对待Vec<T>
一样对待Wrapper
。如果我们希望新类型拥有内部类型拥有的所有方法,那么在包装器上实现Deref
trait(在第15章“像对待常规引用一样对待智能指针”一节中讨论)来返回内部类型将是一个解决方案。如果我们不希望Wrapper
类型拥有内部类型的所有方法——例如,为了限制Wrapper
类型的行为——我们将不得不手动实现我们想要的方法。
这种 newtype pattern 即使在不涉及 traits 的情况下也很有用。让我们转换一下关注点,看看与Rust的类型系统交互的一些高级方法。
19.3 高级类型
Rust类型系统有一些我们已经提到过但还没有讨论过的特性。我们将首先讨论一般的新类型(newtypes),我们将研究为什么新类型作为类型有用。然后我们将转向类型别名( type aliases),这是一个类似于newtypes但语义略有不同的特性。我们还将讨论!
类型和动态大小的类型。
19.3.1 使用新类型模式(newtype pattern
)实现类型安全和抽象
newtype模式对于我们目前讨论的以外的任务也很有用,包括静态地强制值永远不会混淆和指示值的单位。您在示例19-15中看到了使用newtypes来指示单位的示例:回想一下,Millimeters
和Meters
结构体将u32
值包装在一个新类型中。如果我们编写了一个参数类型为Millimeters
的函数,我们就不能编译一个无意中试图调用值类型为Meters
或普通u32
的函数的程序。
我们还可以使用newtype模式抽象出类型的一些实现细节:新类型可以公开与私有内部类型的API不同的公共API。
新类型也可以隐藏内部实现。例如,我们可以提供一个People
类型来包装HashMap<i32, String>
,它存储人的ID和姓名。使用People
的代码将只与我们提供的公共API交互,例如将名字字符串添加到People
集合的方法;代码不需要知道我们在内部为名称分配了i32
ID。newtype模式是实现封装以隐藏实现细节的轻量级方式,我们在第17章的“隐藏实现细节的封装”一节中讨论过。
19.3.2 使用类型别名创建类型同义词
Rust提供了声明类型别名以给现有类型另一个名称的能力。为此,我们使用type
关键字。例如,我们可以创建别名km
到i32
,如下所示:
type Kilometers = i32;
现在,别名Kilometers
是i32
的同义词;与示例19-15中创建的Millimeters
和Meters
类型不同,Kilometers
并不是一个独立的新类型。Kilometers
类型的值将被视为i32
类型的值:
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
因为Kilometers
和i32
是同一类型,所以可以同时添加两种类型的值,并且可以将Kilometers
的值传递给接受i32
参数的函数。然而,使用这种方法,我们无法获得从前面讨论的newtype模式中获得的类型检查好处。换句话说,如果我们在某处混淆了Kilometers
和i32
的值,编译器不会给我们一个错误。
类型同义词的主要用例是减少重复。例如,我们可能有一个像这样的长类型:
Box<dyn Fn() + Send + 'static>
将这种冗长的类型写在函数签名中,并作为类型注解写在整个代码中,会很累人,而且容易出错。想象一下,有一个充满示例19-24所示代码的项目。
let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));
fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
// --snip--
}
fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
// --snip--
}
类型别名通过减少重复使代码更易于管理。在示例19-25中,我们为verbose类型引入了一个名为Thunk
的别名,可以用更短的别名Thunk
替换该类型的所有使用。
type Thunk = Box<dyn Fn() + Send + 'static>;
let f: Thunk = Box::new(|| println!("hi"));
fn takes_long_type(f: Thunk) {
// --snip--
}
fn returns_long_type() -> Thunk {
// --snip--
}
这段代码更容易阅读和编写!为类型别名选择一个有意义的名称也可以帮助传达您的意图(thunk是一个用于稍后评估的代码的单词,因此它是存储的闭包的合适名称)。
类型别名也通常与Result<T, E>
类型一起使用,以减少重复。考虑标准库中的std::io
模块。I/O操作通常返回Result<T, E>
来处理操作失败的情况。这个库有一个std::io::Error
结构体,它表示所有可能的I/O错误。std::io
中的许多函数将返回Result<T, E>
,其中E为std::io::Error
,例如Write
trait中的这些函数:
use std::fmt;
use std::io::Error;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
fn flush(&mut self) -> Result<(), Error>;
fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}
Result<..., Error>
重复了很多次。因此,std::io
有这样的类型别名声明:
type Result<T> = std::result::Result<T, std::io::Error>;
因为这个声明是在std::io
模块中,所以我们可以使用完全限定别名std::io::Result<T>
;也就是说,一个Result<T, E>
,其中E
被填充为std::io::Error
。Write
trait函数签名最终看起来像这样:
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;
fn write_all(&mut self, buf: &[u8]) -> Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}
类型别名在两个方面提供了帮助:它使代码更容易编写,并且在所有std::io
中提供了一致的接口。因为它是一个别名,它只是另一个Result<T, E>
,这意味着我们可以使用任何对Result<T, E>
有效的方法,像?
操作符。
19.3.3 永不返回的Never类型
Rust有一个特殊的类型,名为!
,这在类型理论术语中被称为空类型(empty type
),因为它没有值。我们更喜欢称其为never类型
,因为当函数永远不会返回时,它位于返回类型的位置。这里有一个例子:
fn bar() -> ! {
// --snip--
}
这段代码被解读为“函数bar
永不返回”。不返回的函数称为发散函数(diverging functions
)。我们不能创建该类型!
的值所以bar
永远不可能回来。
但一个你永远不能创建值的类型有什么用呢?回想一下示例2-5中的代码,这是数字猜谜游戏的一部分;我们在示例19-26中复制了其中的一部分。
19-26
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
当时,我们跳过了这段代码中的一些细节。在第6章“匹配控制流操作符”一节中,我们讨论了匹配 arm 必须返回相同的类型。例如,下面的代码不能工作:
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};
这段代码中的guess
类型必须是整数和字符串,Rust要求guess
只有一种类型。那么continue
返回什么呢?在示例19-26中,如何允许我们从一个 arm 返回一个u32
,而另一个 arm 以continue
结束?
正如您可能已经猜到的那样,continue
有一个!
值。也就是说,当Rust计算guess
类型时,它会查看两个匹配 arm ,前者的值为u32
,后者的值为 !
值。因为!
不能有值,Rust决定guess
的类型是u32。
描述这种行为的正式方式是type !
可以被强转成任何其他类型。我们允许用continue
结束这个match
arm 因为continue
不返回值;相反,它将控制移回循环的顶部,因此在Err
情况下,我们从不给guess
赋值。
never类型在panic!
中也很有用。回想一下我们在Option<T>
值上调用的unwrap
函数,用下面的定义产生一个值或panic
:
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("called `Option::unwrap()` on a `None` value"),
}
}
}
在这段代码中,发生了与示例19-26中的match
相同的事情:Rust看到val
具有类型T
和panic!
有类型!
,所以整体匹配表达式的结果是T
。这段代码工作,因为panic!
不会产生值;它结束了程序。在None
情况下,我们不会从unwrap
返回值,因此这段代码是有效的。
最后一个具有类型!
的表达式是一个loop
:
print!("forever ");
loop {
print!("and ever ");
}
在这里,循环永远不会结束,所以表达式的值是!
。然而,如果我们包含一个break
,这就不成立了,因为循环会在到达break
时终止。
19.2.4 动态大小类型和 Sized trait
Rust需要知道关于其类型的某些细节,比如为特定类型的值分配多少空间。这使得类型系统的一个角落一开始有点令人困惑:动态大小类型(dynamically sized types)的概念。有时也称为 DSTs
或unsized类型
,这些类型允许我们使用只有在运行时才能知道其大小的值来编写代码。
让我们深入研究称为str
的动态大小类型的细节,我们在本书中一直在使用它。没错,不是&str
,而是str
本身是DST
。在运行时之前,我们无法知道字符串的长度,这意味着我们不能创建类型为str
的变量,也不能接受类型为str
的参数。考虑下面的代码,它不起作用:
let s1: str = "Hello there!";
let s2: str = "How's it going?";
Rust需要知道为特定类型的任何值分配多少内存,并且同一类型的所有值必须使用相同数量的内存。如果Rust允许我们编写这段代码,这两个str值将需要占用相同的空间。但是它们的长度不同:s1
需要12字节的存储空间,s2
需要15字节的存储空间。这就是为什么不可能创建一个包含动态大小类型的变量。
那么我们该怎么办呢?在这种情况下,你已经知道答案了:我们将s1
和s2
的类型设为&str
而不是str
。回想一下第四章的“字符串切片”部分,切片数据结构只存储了切片的起始位置和长度。因此,尽管&T
是存储T
所在内存地址的单个值,但&str
是两个值:str
的地址和它的长度。因此,我们可以在编译时知道&str
值的大小:它是usize
长度的两倍。也就是说,我们总是知道&str
的大小,不管它引用的字符串有多长。通常,这是Rust中使用动态大小类型的方式:它们有一个额外的元数据,用于存储动态信息的大小。动态大小类型的黄金法则是,必须始终将动态大小类型的值放在某种指针的后面。
我们可以将str
与各种指针结合使用:例如,Box<str>
或Rc<str>
。事实上,您之前已经看到过这种情况,但使用的是不同的动态大小类型:trait
。每个 trait 都是一个动态大小的类型,我们可以使用trait的名称来引用它。在第17章“使用允许不同类型值的Trait对象”一节中,我们提到要使用Trait作为Trait对象,我们必须将它们放在指针后面,例如&dyn Trait
或Box<dyn Trait>
(Rc<dyn Trait>
也可以)。
为了使用 DSTs, Rust提供了Sized
trait来确定类型的大小在编译时是否已知。对于在编译时已知大小的所有内容,将自动实现此特征。此外,Rust还隐式地为每个泛型函数添加了Sized
上的绑定。也就是说,像这样的泛型函数定义:
fn generic<T>(t: T) {
// --snip--
}
实际上就像我们写的那样:
fn generic<T: Sized>(t: T) {
// --snip--
}
默认情况下,泛型函数只适用于在编译时具有已知大小的类型。但是,你可以使用下面的特殊语法来放松这个限制:
fn generic<T: ?Sized>(t: &T) {
// --snip--
}
一个trait 绑定在?Sized
上意味着“T
可能是已知大小,也可能是未知大小”,这个注解覆盖了泛型类型在编译时必须有一个已知大小的默认值。具有此含义的 ?Trait
语法仅适用于Sized
,而不适用于任何其他Trait。
还要注意,我们将t
参数的类型从T
切换为&T
。因为类型可能不是Sized
,所以我们需要在某种指针后面使用它。在这种情况下,我们选择了一个引用。
19.4 高级函数和闭包
本节探讨与函数和闭包相关的一些高级特性,包括函数指针和返回闭包。
19.4.1 函数指针
我们已经讨论过如何将闭包传递给函数;您还可以将常规函数传递给函数!当您想要传递一个已经定义的函数而不是定义一个新的闭包时,这种技术非常有用。函数强制转换为fn
类型(带有小写f),不要与Fn
闭包 trait 混淆。fn
类型称为函数指针(function pointer)。传递带有函数指针的函数将允许您将函数用作其他函数的参数。
将形参指定为函数指针的语法类似于闭包,如示例19-27所示,其中我们定义了一个函数add_one
,将1添加到其形参中。函数do_twice
接受两个形参:一个是指向任何接受i32
形参并返回i32
的函数的函数指针,另一个是i32
值。do_twice
函数调用函数f
两次,将参数值传递给它,然后将两个函数调用结果相加。main
函数使用参数add_one
和5调用do_twice
。
19-27
fn add_one(x: i32) -> i32 {
x + 1
}
fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
f(arg) + f(arg)
}
fn main() {
let answer = do_twice(add_one, 5);
println!("The answer is: {}", answer);
}
这段代码输出的结果是:12。我们指定do_twice
中的形参f
是一个fn
,它接受一个类型为i32
的形参并返回i32
。然后我们可以在do_twice
体中调用f
两次。在main
中,我们可以将函数名add_one
作为do_twice
的第一个参数传递。
与闭包不同,fn
是一个类型而不是一个trait,因此我们直接将fn
指定为参数类型,而不是声明一个泛型类型参数,其中一个Fn
trait 作为trait bound。
函数指针实现了闭包的所有三个特征(Fn
、FnMut
和FnOnce
),这意味着您总是可以将函数指针作为期望闭包的函数的参数传递。最好使用泛型类型和闭包 traits 之一来编写函数,这样你的函数就可以接受函数或闭包。
也就是说,当与没有闭包的外部代码交互时,您希望只接受fn
而不接受闭包的一个例子是:C函数可以接受函数作为参数,但C没有闭包。
作为一个可以使用内联定义的闭包或命名函数的示例,让我们看看标准库中Iterator
trait 提供的map
方法的使用。要使用map
函数将数字向量转换为字符串向量,我们可以使用闭包,如下所示:
let list_of_numbers = vec![1, 2, 3];
let list_of_strings: Vec<String> =
list_of_numbers.iter().map(|i| i.to_string()).collect();
或者我们可以将一个函数命名为map
的参数,而不是闭包,如下所示:
let list_of_numbers = vec![1, 2, 3];
let list_of_strings: Vec<String> =
list_of_numbers.iter().map(ToString::to_string).collect();
注意,我们必须使用我们在前面的“高级特征”一节中讨论过的完全限定语法,因为有多个名为to_string
的函数可用。这里,我们使用的是ToString
特征中定义的to_string
函数,标准库已经为实现Display
的任何类型实现了该函数。
回想一下第6章的“枚举值”一节,我们定义的每个枚举变量的名称也变成了初始化函数。我们可以使用这些初始化函数作为实现闭包 traits 的函数指针,这意味着我们可以将初始化函数指定为接受闭包的方法的参数,如下所示:
enum Status {
Value(u32),
Stop,
}
let list_of_statuses: Vec<Status> = (0u32..20).map(Status::Value).collect();
在这里,我们使用使用Status::Value
的初始化函数调用map
的范围内的每个u32
值创建Status::Value
实例。有些人喜欢这种风格,有些人喜欢使用闭包。它们编译为相同的代码,所以使用对您更清楚的样式。
19.4.2 返回闭包
闭包由 traits 表示,这意味着不能直接返回闭包。在大多数情况下,在可能想要返回一个trait的地方,您可以使用实现 trait 的具体类型作为函数的返回值。但是,闭包不能这样做,因为它们没有可返回的具体类型;例如,你不允许使用函数指针fn
作为返回类型。
下面的代码尝试直接返回一个闭包,但它不能编译:
fn returns_closure() -> dyn Fn(i32) -> i32 {
|x| x + 1
}
The compiler error is as follows:
$ cargo build
Compiling functions-example v0.1.0 (file:///projects/functions-example)
error[E0746]: return type cannot have an unboxed trait object
--> src/lib.rs:1:25
|
1 | fn returns_closure() -> dyn Fn(i32) -> i32 {
| ^^^^^^^^^^^^^^^^^^ doesn't have a size known at compile-time
|
= note: for information on `impl Trait`, see <https://doc.rust-lang.org/book/ch10-02-traits.html#returning-types-that-implement-traits>
help: use `impl Fn(i32) -> i32` as the return type, as all return paths are of type `[closure@src/lib.rs:2:5: 2:14]`, which implements `Fn(i32) -> i32`
|
1 | fn returns_closure() -> impl Fn(i32) -> i32 {
| ~~~~~~~~~~~~~~~~~~~
For more information about this error, try `rustc --explain E0746`.
error: could not compile `functions-example` due to previous error
错误再次引用Sized
trait ! Rust不知道它需要多少空间来存储闭包。我们之前看到了这个问题的解决方案。我们可以使用trait对象:
fn returns_closure() -> Box<dyn Fn(i32) -> i32> {
Box::new(|x| x + 1)
}
19.5 宏 (Macros
)
我们使用过println!
在本书中,我们并没有完全探讨宏是什么以及它是如何工作的。术语宏
指的是Rust中的一系列特性:带有macro_rules
的声明(declarative
)宏!还有三种过程(procedural
)宏:
- 自定义
#[derive]
宏,用于在结构体和枚举上指定添加了derive
属性的代码 - 类属性宏,定义可用于任何项的自定义属性
- 类函数宏,看起来像函数调用,但对指定为参数的令牌进行操作
我们将依次讨论这些,但首先,让我们看看为什么在已经有函数的情况下还需要宏。
15.5.1 宏和函数的区别
从根本上讲,宏是一种编写用于编写其他代码的代码的方式,即所谓的元编程(metaprogramming
)。在附录C中,我们讨论了derive
属性,它为您生成各种 trait 的实现。我们还使用了println!
和 vec !
宏贯穿全书。所有这些宏都会展开以生成比手动编写的代码更多的代码。
元编程对于减少必须编写和维护的代码量非常有用,这也是函数的作用之一。然而,宏有一些函数没有的额外功能。
函数签名必须声明函数具有的参数的数量和类型。另一方面,宏可以接受可变数量的参数:我们可以用一个参数调用println!("hello")
或println!("hello {}", name)
有两个参数。此外,宏在编译器解释代码含义之前被展开,因此宏可以,例如,在给定类型上实现 trait。函数不能,因为它在运行时被调用,而 trait 需要在编译时实现。
实现宏而不是函数的缺点是宏定义比函数定义更复杂,因为您编写的是写Rust代码的Rust代码。由于这种间接关系,宏定义通常比函数定义更难阅读、理解和维护。
宏和函数之间的另一个重要区别是,在文件中调用宏之前,必须定义宏或将它们带入作用域,而函数可以在任何地方定义和调用。
15.5.2 带有macro_rules!
的声明宏用于通用元编程
Rust中使用最广泛的宏形式是声明宏。这些有时也被称为“宏示例”、“macro_rules!
宏”,或者只是简单的“宏”。在其核心,声明式宏允许您编写类似Rust match
表达式的东西。如第6章所述,match
表达式是一种控制结构,它接受表达式,将表达式的结果值与模式进行比较,然后运行与匹配模式相关的代码。宏也可以将值与与特定代码相关联的模式进行比较:在这种情况下,值是传递给宏的文本 Rust源代码;模式与源代码的结构进行比较;与每个模式相关联的代码在匹配时替换传递给宏的代码。这一切都发生在编译期间。
要定义宏,可以使用macro_rules!
构造。让我们通过观察vec!
定义宏来探索一下如何使用macro_rules!
。第8章介绍了如何使用vec!
宏创建一个具有特定值的新向量。例如,下面的宏创建了一个包含三个整数的新向量:
let v: Vec<u32> = vec![1, 2, 3];
我们也可以用vec!
宏来生成两个整数的向量或五个字符串切片的向量。我们不能使用函数来做同样的事情,因为我们不知道前面值的数量或类型。
示例19-28给出了稍微简化的vec!宏。
19-28
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
注:实际定义的
vec!
宏在标准库中包含预先分配正确内存数量的代码。该代码是一种优化,我们在这里没有包括它,以使示例更简单。
#[macro_export]
注解指出,当定义宏的crate 被引入作用域时,这个宏应该可用。如果没有这个注解,宏就不能被引入作用域。
然后我们使用macro_rules!
开始宏定义以及我们定义的宏的名称,不带感叹号。名字(在本例中是vec
)后面跟着花括号,表示宏定义的主体。
vec!
主体的结构正文类似于match
表达式的结构。在这里,我们有一个 arm 的模式( $( $x:expr ),* )
,后面是=>
和与此模式相关的代码块。如果模式匹配,将执行相关的代码块。假设这是这个宏中唯一的模式,那么只有一种有效的匹配方式;任何其他模式都会导致错误。更复杂的宏将有多个 arm 。
宏定义中的有效模式语法不同于第18章中介绍的模式语法,因为宏模式(macro patterns
)是根据Rust代码结构而不是值来匹配的。让我们看看示例19-28中的模式片段意味着什么;有关完整的宏模式语法,请参阅Rust。
首先,我们使用一组括号来包含整个模式。我们使用美元符号($
)在宏系统中声明一个变量,该变量将包含与模式匹配的Rust代码。美元符号表明这是一个宏变量,而不是常规Rust变量。接下来是一组括号,用于捕获与括号内模式匹配的值,以便在替换代码中使用。在$()
中是$x:expr
,它匹配任何Rust表达式,并为表达式命名为$x
。
$()
后面的逗号表示字面逗号分隔符可以有选择地出现在与$()
中代码匹配的代码之后。*
指定模式匹配0个或多个在*
之前的值。
当我们用vec![1,2,3];
, $x
模式与表达式1
、2
和3
匹配三次。
现在让我们看看与此 arm 相关的代码主体中的模式:$()*
内的temp_vec.push()
会为模式中与$()
匹配0次或多次的每个部分生成,这取决于模式匹配的次数。$x
被每个匹配的表达式替换。当我们用vec![1,2,3];
,替换此宏调用的生成代码如下所示:
{
let mut temp_vec = Vec::new();
temp_vec.push(1);
temp_vec.push(2);
temp_vec.push(3);
temp_vec
}
我们已经定义了一个宏,它可以接受任意数量的任意类型的参数,并可以生成代码来创建包含指定元素的向量。
要了解关于如何编写宏的更多信息,请参考在线文档或其他资源,例如Daniel Keep开始并由Lukas Wirth继续的“Rust宏小书”。
15.5.3 用于从属性生成代码的过程宏
宏的第二种形式是过程宏(procedural macro
),它的作用更像一个函数(也是一种过程)。过程式宏接受一些代码作为输入,对这些代码进行操作,并生成一些代码作为输出,而不是像声明宏那样根据模式匹配并用其他代码替换代码。这三种过程宏是自定义派生( custom derive
)、类属性(attribute-like
)和类函数(function-like
),它们都以类似的方式工作。
创建过程宏时,定义必须驻留在具有特殊crate 类型的自己的crate 中。这是由于复杂的技术原因,我们希望在未来消除这种情况。在示例19-29中,我们展示了如何定义一个过程宏,其中some_attribute
是用于使用特定宏种类的占位符。
19-29
use proc_macro;
#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
定义过程宏的函数以TokenStream
作为输入,并生成TokenStream
作为输出。TokenStream
类型是由包含在Rust中的proc_macro
crate 定义的,它表示一系列的令牌。这是宏的核心:宏操作的源代码构成输入TokenStream
,宏生成的代码是输出TokenStream
。该函数还附加了一个属性,用于指定我们要创建的过程宏的类型。我们可以在同一个crate中有多种程序宏。
让我们看看不同种类的过程宏。我们将从一个自定义派生宏开始,然后解释使其他形式不同的小差异。
如何编写一个自定义派生宏
让我们创建一个名为hello_macro的crate ,它定义了一个名为HelloMacro
的trait和一个名为hello_macro
的关联函数。我们不会让用户为他们的每个类型实现HelloMacro
特性,而是提供一个过程宏,这样用户就可以用#[derive(HelloMacro)]
注解他们的类型,以获得hello_macro
函数的默认实现。默认实现将打印Hello, Macro! My name is TypeName!
,其中TypeName
是定义该trait的类型的名称。换句话说,我们将编写一个crate ,使其他程序员能够使用我们的板条箱编写类似示例19-30的代码。
19-30
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;
#[derive(HelloMacro)]
struct Pancakes;
fn main() {
Pancakes::hello_macro();
}
这段代码将打印Hello, Macro! My name is Pancakes!
,当我们完成的时候。第一步是制作一个新的库板条箱,就像这样:
$ cargo new hello_macro --lib
接下来,我们将定义HelloMacro
trait及其相关函数:
pub trait HelloMacro {
fn hello_macro();
}
我们有一个trait 和它的函数。在这一点上,我们的 crate 用户可以实现 trait 来实现所需的功能,如下所示:
use hello_macro::HelloMacro;
struct Pancakes;
impl HelloMacro for Pancakes {
fn hello_macro() {
println!("Hello, Macro! My name is Pancakes!");
}
}
fn main() {
Pancakes::hello_macro();
}
然而,他们需要为他们想要使用hello_macro
的每个类型编写实现块;我们想让他们不必做这项工作。
此外,我们还不能为hello_macro
函数提供默认实现,该实现将打印 trait 实现的类型的名称:Rust没有反射功能,因此它不能在运行时查找类型的名称。我们需要一个宏来在编译时生成代码。
下一步是定义过程宏。在撰写本文时,过程宏需要在它们自己的crate中。最终,这一限制可能会被取消。构造crate和宏crate的约定如下:对于名为foo
的crate ,自定义派生过程宏 crate 称为foo_derived
。让我们在hello_macro
项目中启动一个名为hello_macro_derived
的新crate :
$ cargo new hello_macro_derive --lib
这两个crate 是紧密相关的,因此我们在hello_macrocrate 的目录中创建过程宏crate 。如果我们改变了hello_macro
中的trait定义,我们也必须改变hello_macro_derive
中过程宏的实现。这两个crate 需要分别发布,使用这些crate 的程序员需要将两者作为依赖项添加,并将它们都带入作用域。我们可以让hello_macro
crate 使用 hello_macro_derive
作为依赖项,并重新导出过程宏代码。然而,我们构建项目的方式使得程序员即使不需要derive
功能也可以使用hello_macro
。
我们需要将hello_macro_derive
crate 声明为过程宏crate 。稍后您将看到,我们还需要来自syn和quote箱的功能,因此我们需要将它们作为依赖项添加。将以下内容添加到hello_macro_derive
的Cargo.toml
文件:
[lib]
proc-macro = true
[dependencies]
syn = "1.0"
quote = "1.0"
要开始定义过程宏,请将示例19-31中的代码放入hello_macro_derive
crate src/lib.rs
文件中。注意,在为impl_hello_macro
函数添加定义之前,这段代码不能编译。
19-31
use proc_macro::TokenStream;
use quote::quote;
use syn;
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// Construct a representation of Rust code as a syntax tree
// that we can manipulate
let ast = syn::parse(input).unwrap();
// Build the trait implementation
impl_hello_macro(&ast)
}
注意,我们将代码分成hello_macro_derive
函数(负责解析TokenStream
)和impl_hello_macro
函数(负责转换语法树):这使得编写过程式宏更加方便。外部函数(在本例中为hello_macro_derive
)中的代码对于您看到或创建的几乎每个过程式宏板条箱都是相同的。在内部函数体中指定的代码(在本例中为impl_hello_macro
)将根据过程宏的目的而有所不同。
我们引入了三个新的crates: proc_macro
、syn和 quote。proc_macro
crate是Rust自带的,所以我们不需要将其添加到Cargo.toml
中的依赖项中。proc_macro
crate是编译器的API,它允许我们从代码中读取和操作Rust代码。
syn
crate将Rust代码从字符串解析为我们可以执行操作的数据结构。quote
crate 将syn
数据结构转换回Rust代码。这些crates 使得我们可以更简单地解析任何类型的Rust代码:为Rust代码编写一个完整的解析器不是一件简单的任务。
当我们库的用户在一个类型上指定#[derive(HelloMacro)]
时,将调用hello_macro_derive
函数。这是可能的,因为我们在这里用proc_macro_derive
标注了hello_macro_derive
函数,并指定了名称HelloMacro
,这与我们的 trait 名称相匹配;这是大多数程序宏遵循的约定。
hello_macro_derive
函数首先将输入从TokenStream
转换为数据结构,然后我们可以对其进行解释和执行操作。这就是syn
发挥作用的地方。syn
中的解析函数接受一个TokenStream
并返回一个表示已解析Rust代码的DeriveInput
结构。示例19-32显示了通过解析struct Pancakes;
字符串得到的DeriveInput
结构的相关部分:
DeriveInput {
// --snip--
ident: Ident {
ident: "Pancakes",
span: #0 bytes(95..103)
},
data: Struct(
DataStruct {
struct_token: Struct,
fields: Unit,
semi_token: Some(
Semi
)
}
)
}
这个结构体的字段表明我们解析的Rust代码是一个带有Pancakes
标识符(标识符,意思是名称)的单元结构体。在这个结构体上有更多的字段用于描述各种Rust代码;有关更多信息,请查看syn文档中的DeriveInput。
很快,我们将定义impl_hello_macro
函数,我们将在其中构建希望包含的新Rust代码。但在此之前,请注意派生宏(derive macro
)的输出也是一个TokenStream
。返回的TokenStream
被添加到我们的板条箱用户编写的代码中,因此当他们编译他们的板条箱时,他们将获得我们在修改后的TokenStream
中提供的额外功能。
您可能已经注意到,如果在这里对syn::parse
函数的调用失败,则调用unwrap
会导致hello_macro_derive
函数panic。我们的过程性宏有必要对错误感到恐慌,因为proc_macro_derive
函数必须返回TokenStream
而不是Result
以符合过程宏API。我们通过使用unwrap
简化了这个例子;在生产代码中,您应该使用panic来提供更具体的错误消息,说明哪里出了panic!
或expect
。
现在我们已经有了将带注解的Rust代码从TokenStream
转换为DeriveInput
实例的代码,让我们生成实现HelloMacro
trait 的类型 的代码,如示例19-33所示。
19-33
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let gen = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
gen.into()
}
我们使用ast.ident
获得一个Ident
结构实例,其中包含带注解类型的名称(标识符)。示例19-32中的结构体表明,当我们对示例19-30中的代码运行impl_hello_macro
函数时,我们得到的ident
将具有ident
字段的值为"Pancakes"
。因此,示例19-33中的name
变量将包含一个Ident
结构实例,当打印时,该实例将是字符串"Pancakes"
,即示例19-30中的结构名称。
quote!
宏允许我们定义想要返回的Rust代码。编译器期望的结果与引用quote!
宏执行的直接结果有一些不同,所以我们需要把它转换成一个TokenStream
。我们通过调用into
方法来实现这一点,该方法使用这个中间表示,并返回所需的TokenStream
类型的值。
quote!
Macro还提供了一些非常酷的模板机制:我们可以输入#name
,然后quote!
将其替换为变量name
中的值。您甚至可以做一些类似于常规宏工作方式的重复操作。检查the quote crate’s docs为一个详细的介绍。
我们希望我们的过程宏为用户注解的类型生成HelloMacro
trait的实现,我们可以使用#name
来获得。trait实现有一个hello_macro
函数,它的主体包含了我们想要提供的功能:打印Hello, Macro! My name is
,然后是带注解的类型的名字。
这里使用的stringify!
宏是内置在Rust中的。它接受一个Rust表达式,例如1 + 2
,并在编译时将表达式转换为字符串文字,例如"1 + 2"
。这与format!
或println!
不同,这些宏计算表达式,然后将结果转换为字符串。#name
输入有可能是一个要按字面输出的表达式,因此我们使用stringify!
, 使用把stringify!
还可以通过在编译时将#name
转换为字符串字面值来节省分配。
此时,cargo build
应该在hello_macro
和hello_macro_derived
中成功完成。让我们将这些crates与示例19-30中的代码连接起来,看看过程宏的作用!使用cargo new pancake
在你项目目录中创建一个新的二进制项目。我们需要在pancake
crate的Cargo.toml
中添加hello_macro
和hello_macro_derived
作为依赖项。如果您正在将hello_macro
和hello_macro_derived
版本发布到crates.io,它们是正常依赖关系;如果不是,你可以将它们指定为路径依赖项,如下所示:
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }
将示例19-30中的代码放入src/main.rs
,并运行cargo run
:它应该打印Hello, Macro! My name is Pancakes!
过程宏中HelloMacro
trait 的实现被包括在内,而不需要pancakes
crate 来实现它;#[derive(HelloMacro)]
添加trait实现。
接下来,让我们探讨其他类型的过程宏与自定义派生宏的区别。
类属性的宏
类属性宏类似于自定义派生宏,但它们不像derive
属性生成代码,而是允许您创建新属性。它们也更加灵活:derive
只适用于结构体和枚举;属性也可以应用于其他项,比如函数。下面是一个使用类属性宏的例子:假设你有一个名为route
的属性,它在使用web应用框架时注解函数:
#[route(GET, "/")]
fn index() {
这个#[route]
属性将被框架定义为一个过程宏。宏定义函数的签名是这样的:
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
在这里,我们有两个TokenStream
类型的参数。第一个是属性的内容:GET, "/"
部分。第二个是附加属性的项的主体:在本例中是fn index(){}
和函数主体的其余部分。
除此之外,类属性宏的工作方式与自定义派生宏相同:使用proc-macro
crate类型创建一个crate,并实现一个生成所需代码的函数!
类函数宏
类函数宏定义了看起来像函数调用的宏。类似于macro_rules!
宏,它们比函数更灵活;例如,它们可以接受未知数量的参数。然而,macro_rules !宏只能使用我们在 Declarative Macros with macro_rules! for General Metaprogramming 一节中讨论过的类似匹配的语法来定义。类函数宏接受一个TokenStream
参数,它们的定义使用Rust代码操作该TokenStream
,就像其他两种过程型宏一样。类函数宏的一个例子是sql!
宏,可以这样命名:
let sql = sql!(SELECT * FROM posts WHERE id=1);
这个宏将解析其中的SQL语句并检查其语法是否正确,这比macro_rules!
宏可以做的要复杂得多。sql !Macro的定义如下:
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
这个定义类似于自定义派生宏的签名:我们接收圆括号内的标记,并返回我们想要生成的代码。