十三、函数式语言特性:迭代器和闭包
Rust的设计灵感来自于许多现有的语言和技术,其中一个重要影响是函数式编程( functional programming)。函数式风格的编程通常包括将函数作为值使用,方法是将它们传递到参数中,从其他函数返回它们,将它们赋值给变量以便稍后执行,等等。
在本章中,我们不讨论函数式编程是什么或不是什么,而是讨论Rust的一些特性,这些特性类似于许多语言中通常称为函数式的特性。
更具体地说,我们将介绍:
- 闭包
Closures
,一个类似函数的构造,可以存储在变量中 - 迭代器
Iterators
,一种处理一系列元素的方法 - 如何使用闭包和迭代器来改进第12章的I/O项目
- 闭包和迭代器的性能(剧透:它们比你想象的要快!)
因为掌握闭包和迭代器是编写地道的、快速的Rust代码的重要部分,所以我们将用整章的时间来讨论它们。
13.1 闭包:捕获其环境的匿名函数
Rust的闭包是匿名函数,可以保存在变量中,也可以作为参数传递给其他函数。您可以在一个地方创建闭包,然后在其他地方调用闭包,以在不同的上下文中计算它。与函数不同,闭包可以从定义它们的作用域捕获值。我们将演示这些闭包特性如何支持代码重用和行为我自定义。
13.1.1 使用闭包捕获环境
我们将首先研究如何使用闭包从定义闭包的环境中捕获值,以供以后使用。场景是这样的:每隔一段时间,我们的t恤公司就会向我们邮件列表上的某个人赠送一件独家限量版衬衫作为促销。邮件列表上的用户可以选择将自己喜欢的颜色添加到个人资料中。如果被选为免费衬衫的人有他们最喜欢的颜色,他们就会得到那种颜色的衬衫。如果这个人没有指定最喜欢的颜色,他们就得到公司目前最多的颜色。
有许多方法可以实现这一点。对于本例,我们将使用一个名为ShirtColor
的枚举,它具有变体Red
和Blue
(为简单起见,限制了可用颜色的数量)。我们用一个inventory
结构体表示公司的库存,该结构体有一个名为shirts
的字段,该字段包含Vec<ShirtColor>
,表示当前库存的衬衫颜色。在Inventory
中定义的方法giveaway
获得免费衬衫获胜者的可选衬衫颜色偏好,并返回该人将获得的衬衫颜色。这个设置如示例13-1所示:
#[derive(Debug, PartialEq, Copy, Clone)]
enum ShirtColor {
Red,
Blue,
}
struct Inventory {
shirts: Vec<ShirtColor>,
}
impl Inventory {
fn giveaway(&self, user_preference: Option<ShirtColor>) -> ShirtColor {
user_preference.unwrap_or_else(|| self.most_stocked())
}
fn most_stocked(&self) -> ShirtColor {
let mut num_red = 0;
let mut num_blue = 0;
for color in &self.shirts {
match color {
ShirtColor::Red => num_red += 1,
ShirtColor::Blue => num_blue += 1,
}
}
if num_red > num_blue {
ShirtColor::Red
} else {
ShirtColor::Blue
}
}
}
fn main() {
let store = Inventory {
shirts: vec![ShirtColor::Blue, ShirtColor::Red, ShirtColor::Blue],
};
let user_pref1 = Some(ShirtColor::Red);
let giveaway1 = store.giveaway(user_pref1);
println!(
"The user with preference {:?} gets {:?}",
user_pref1, giveaway1
);
let user_pref2 = None;
let giveaway2 = store.giveaway(user_pref2);
println!(
"The user with preference {:?} gets {:?}",
user_pref2, giveaway2
);
}
main
定义的store
有两件蓝色衬衫和一件红色衬衫,以分发本次限量版促销。对于一个喜欢穿红衬衫的用户和一个不喜欢穿红衬衫的用户,我们调用giveaway
方法。
同样,这段代码可以通过多种方式实现,在这里,为了关注闭包,除了giveaway
使用闭包的方法之外,我们一直坚持您已经学过的概念。在giveaway
方法中,我们获得用户首选项作为类型Option<ShirtColor>
并在user_preference
上调用unwrap_or_else
方法(Option<T>
由标准库定义)。它有一个参数:一个没有任何参数的闭包,返回值T
(与Option<T>
的Some变体中存储的类型相同,在本例中是ShirtColor
)。如果Option<T>
是Some变量,unwrap_or_else从Some变量中返回值。如果Option<T>
是None变量,unwrap_or_else调用闭包并返回由闭包返回的值。
我们指定闭包表达式|| self. most_stored()
作为unwrap_or_else
的参数。这是一个闭包,它本身没有参数(如果闭包有参数,它们将出现在两个竖线之间)。闭包的主体调用self. most_stocking()
。我们在这里定义闭包,如果需要结果,unwrap_or_else
的实现稍后将计算闭包。
Running this code prints:
$ cargo run
Compiling shirt-company v0.1.0 (file:///projects/shirt-company)
Finished dev [unoptimized + debuginfo] target(s) in 0.27s
Running `target/debug/shirt-company`
The user with preference Some(Red) gets Red
The user with preference None gets Blue
这里一个有趣的方面是,我们在当前Inventory
实例上传递了一个调用self. most_stocking()
的闭包。标准库不需要知道我们定义的Inventory
或ShirtColor
类型的任何信息,也不需要知道我们想在这个场景中使用的逻辑。闭包捕获对self
Inventory
实例的不可变引用,并将其与我们指定的代码一起传递给unwrap_or_else
方法。另一方面,函数不能以这种方式捕获它们的环境。
13.1.2 闭包类型推断和注释
函数和闭包之间有更多的区别。闭包通常不需要像fn
函数那样注释形参或返回值的类型。函数上需要类型注释,因为类型是向用户公开的显式接口的一部分。严格定义这个接口对于确保所有人都同意函数使用和返回的值类型非常重要。另一方面,闭包不会在这样的公开接口中使用:它们存储在变量中,在使用时不给它们命名,也不向库的用户公开它们。
闭包通常很短,只在狭窄的上下文中相关,而不是在任何任意的场景中。在这些有限的上下文中,编译器可以推断形参的类型和返回类型,类似于推断大多数变量的类型(在极少数情况下编译器也需要闭包类型注释)。
与变量一样,如果我们想增加显式性和清晰度,那么我们可以添加类型注释,代价是过于冗长。对闭包类型进行注释的定义如示例13-2所示。在本例中,我们定义了一个闭包并将其存储在一个变量中,而不是像示例13-1中所做的那样,将闭包作为参数传递到指定位置。
13-2
let expensive_closure = |num: u32| -> u32 {
println!("calculating slowly...");
thread::sleep(Duration::from_secs(2));
num
};
添加了类型注释后,闭包的语法看起来更类似于函数的语法。这里,我们定义了一个函数,它的形参加上1,并定义了一个具有相同行为的闭包,以便进行比较。我们添加了一些空格来排列相关部分。这说明了闭包语法与函数语法的相似之处,除了使用管道和可选语法的数量之外:
fn add_one_v1 (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x| { x + 1 };
let add_one_v4 = |x| x + 1 ;
第一行显示函数定义,第二行显示完全注释的闭包定义。在第三行中,我们从闭包定义中删除类型注释。在第四行中,我们删除大括号,这是可选的,因为闭包体只有一个表达式。这些都是有效的定义,当它们被调用时将产生相同的行为。add_one_v3
和add_one_v4
行要求计算闭包以能够编译,因为类型将从它们的使用推断出来。这类似于let v = Vec::new();
需要在Vec
中插入类型注释或某种类型的值,以便Rust能够推断类型。
对于闭包定义,编译器将为它们的每个形参及其返回值推断出一种具体类型。例如,示例13-3显示了一个简短闭包的定义,它只返回作为参数接收的值。除了本例的目的之外,这个闭包没什么用处。注意,我们没有向定义中添加任何类型注释。因为没有类型注释,所以可以用任何类型调用闭包,我们第一次用String
就这样做了。如果我们尝试用一个整数调用example_closure
,就会得到一个错误。
13-3:
let example_closure = |x| x;
let s = example_closure(String::from("hello"));
let n = example_closure(5);
The compiler gives us this error:
$ cargo run
Compiling closure-example v0.1.0 (file:///projects/closure-example)
error[E0308]: mismatched types
--> src/main.rs:5:29
|
5 | let n = example_closure(5);
| ^- help: try using a conversion method: `.to_string()`
| |
| expected struct `String`, found integer
For more information about this error, try `rustc --explain E0308`.
error: could not compile `closure-example` due to previous error
13.1.3 捕获引用或移动所有权
闭包可以通过三种方式从其环境中捕获值,它们直接映射到函数接受参数的三种方式:不可变借用(borrowing immutably)、可变借用(borrowing mutably)和获得所有权(taking ownership)。闭包将根据函数体对捕获值的处理来决定使用哪一种。
在示例13-4中,我们定义了一个闭包,它捕获了一个对名为list
的向量的不可变引用,因为它只需要一个不可变引用来打印值:
13-4
fn main() {
let list = vec![1, 2, 3];
println!("Before defining closure: {:?}", list);
let only_borrows = || println!("From closure: {:?}", list);
println!("Before calling closure: {:?}", list);
only_borrows();
println!("After calling closure: {:?}", list);
}
这个例子还说明了变量可以绑定到闭包定义,并且我们可以稍后通过使用变量名和圆括号调用闭包,就像变量名是函数名一样。
因为可以同时对list
有多个不可变引用,所以list
仍然可以从闭包定义之前的代码中访问,在闭包定义之后但在调用闭包之前,以及在调用闭包之后。这段代码编译、运行并打印:
$ cargo run
Compiling closure-example v0.1.0 (file:///projects/closure-example)
Finished dev [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
Before calling closure: [1, 2, 3]
From closure: [1, 2, 3]
After calling closure: [1, 2, 3]
接下来,在示例13-5中,我们更改闭包体,使其向列表向量添加一个元素。闭包现在捕获一个可变引用:
fn main() {
let mut list = vec![1, 2, 3];
println!("Before defining closure: {:?}", list);
let mut borrows_mutably = || list.push(7);
borrows_mutably();
println!("After calling closure: {:?}", list);
}
This code compiles, runs, and prints:
$ cargo run
Compiling closure-example v0.1.0 (file:///projects/closure-example)
Finished dev [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
After calling closure: [1, 2, 3, 7]
注意,在定义和调用borrows_mutable
闭包之间不再有println!
:当borrows_mutable
被定义时,它捕获一个对list
的可变引用。在调用闭包之后,我们不再使用这个闭包,因此可变的borrow
结束了。在闭包定义和闭包调用之间,不允许进行不可变的借用以打印,因为当存在可变借用时,不允许进行其他借用。尝试添加一个println!
在那里看你得到什么错误消息!
如果希望强制闭包获得它在环境中使用的值的所有权,即使闭包的主体并不严格需要所有权,也可以在参数列表之前使用move
关键字。
这种技术在将闭包传递给新线程以移动数据以便数据归新线程所有时非常有用。我们将在第十六章讨论并发时详细讨论线程以及为什么要使用线程,但现在,让我们简单地探讨一下使用需要move
关键字的闭包生成一个新线程。示例13-6显示了修改后的示例13-4,以便在新线程而不是主线程中打印vector
:
13-6:
use std::thread;
fn main() {
let list = vec![1, 2, 3];
println!("Before defining closure: {:?}", list);
thread::spawn(move || println!("From thread: {:?}", list))
.join()
.unwrap();
}
我们生成一个新线程,给线程一个闭包作为参数运行。闭包主体打印出列表。在示例13-4中,闭包只使用不可变引用捕获list
,因为这是打印list
所需的最少访问量。在本例中,尽管闭包体仍然只需要一个不可变引用,但我们需要通过在闭包定义的开头放置 move
关键字来指定应该将list
移动到闭包中。新线程可能在主线程的其他部分完成之前完成,或者主线程可能先完成。如果主线程保持list
的所有权,但在新线程完成并丢弃list
之前结束,则线程中的不可变引用将无效。因此,编译器要求将该列表移动到给新线程的闭包中,以便引用有效。尝试删除move
关键字,或者在闭包定义后在主线程中使用list
,看看会得到什么编译错误!
13.1.4 将捕获值移出闭包和Fn traits
一旦闭包从定义闭包的环境中捕获了一个引用或一个值的所有权(因此会影响什么(如果有的话)移动到闭包中),闭包主体中的代码就会定义在以后计算闭包时引用或值会发生什么(因此会影响什么(如果有的话)移出闭包)。闭包体可以执行以下任一操作:将捕获的值移出闭包,更改捕获的值,既不移动也不更改值,或者从一开始就不从环境中捕获任何东西。
闭包从环境中捕获和处理值的方式会影响闭包实现哪些traits ,而traits 是函数和结构如何指定它们可以使用的闭包类型。闭包将自动实现一个、两个或所有三个Fn traits
,以附加的方式,这取决于闭包体如何处理这些值:
FnOnce
应用于可调用一次的闭包。所有闭包都至少实现了这个特性,因为所有闭包都可以被调用。将捕获值移出其体的闭包只会实现FnOnce
,而不会实现其他Fn traits
,因为它只能被调用一次。FnMut
应用于不将捕获值移出其闭包体,但可能会改变捕获值的闭包。这些闭包可以被多次调用。Fn
适用于不将捕获值移出其闭包体且不改变捕获值的闭包,以及不捕获任何内容的闭包。可以多次调用这些闭包,而不会改变它们的环境,这在并发调用闭包多次等情况下非常重要。
让我们看看示例13-1中使用的Option<T>
上的unwrap_or_else
方法的定义:
impl<T> Option<T> {
pub fn unwrap_or_else<F>(self, f: F) -> T
where
F: FnOnce() -> T
{
match self {
Some(x) => x,
None => f(),
}
}
}
回想一下,T
是泛型类型,表示Option
的Some
变体中的值的类型。该类型T
也是unwrap_or_else
函数的返回类型:例如,在Option<String>
上调用unwrap_or_else
的代码将得到一个String
。
接下来,注意unwrap_or_else
函数有附加的泛型类型参数F
。F
类型是名为F
的参数的类型,F
是我们在调用unwrap_or_else
时提供的闭包。
泛型类型F
上指定的 trait bound 是FnOnce() -> T
,这意味着F
必须能够被调用一次,不接受参数,并返回一个T
。在 trait bound 中使用FnOnce
表示unwrap_or_else
最多只调用f
一次的约束。在 unwrap_or_else
函数体中,我们可以看到,如果Option
是Some
, f
将不会被调用。如果Option
为None
, f
将被调用一次。因为所有闭包都实现了FnOnce
,所以unwrap_or_else
可以接受最多不同类型的闭包,并且尽可能灵活。
注意:函数也可以实现
Fn
的所有三个特征。如果我们想要做的事情不需要从环境中捕获值,那么我们可以使用函数的名称,而不是需要实现Fn traits
之一的闭包。例如,在Option<Vec<T>>
值上,如果值为None
,我们可以调用unwrap_or_else(Vec::new)
来获得一个新的空向量。
现在让我们看看在切片上定义的标准库方法sort_by_key
,看看它与unwrap_or_else
有何不同,以及sort_by_key
为什么使用FnMut
而不是FnOnce
作为特征边界。闭包以对正在考虑的切片中的当前项的引用的形式获得一个参数,并返回一个可排序的K
类型的值。当您希望按每个项的特定属性对片进行排序时,此函数非常有用。在示例13-7中,我们有一个矩形实例的列表,我们使用sort_by_key
根据它们的宽度属性从低到高对它们排序:
13-7:
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let mut list = [
Rectangle { width: 10, height: 1 },
Rectangle { width: 3, height: 5 },
Rectangle { width: 7, height: 12 },
];
list.sort_by_key(|r| r.width);
println!("{:#?}", list);
}
This code prints:
$ cargo run
Compiling rectangles v0.1.0 (file:///projects/rectangles)
Finished dev [unoptimized + debuginfo] target(s) in 0.41s
Running `target/debug/rectangles`
[
Rectangle {
width: 3,
height: 5,
},
Rectangle {
width: 7,
height: 12,
},
Rectangle {
width: 10,
height: 1,
},
]
sort_by_key
被定义为接受FnMut
闭包的原因是它多次调用闭包:对片中的每个项都调用一次。闭包|r| r.width
不会捕获、突变或从它的环境中移出任何东西,因此它满足trait bound的要求。
相比之下,示例13-8显示了一个只实现FnOnce
特征的闭包示例,因为它将一个值移出环境。编译器不允许我们使用sort_by_key
闭包:
13-8
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let mut list = [
Rectangle { width: 10, height: 1 },
Rectangle { width: 3, height: 5 },
Rectangle { width: 7, height: 12 },
];
let mut sort_operations = vec![];
let value = String::from("by key called");
list.sort_by_key(|r| {
sort_operations.push(value);
r.width
});
println!("{:#?}", list);
}
这是一种人工的、复杂的方法(它不起作用)来尝试计数排序list
时调用sort_by_key
的次数。这段代码试图通过将value
-一个来自闭包环境的String
推入sort_operations
向量来进行计数。闭包捕获值,然后通过将值的所有权转移到sort_operations
向量,将值移出闭包。这个闭包可以调用一次;尝试第二次调用它将不起作用,因为value
将不再存在于环境中,不再被推入sort_operations
!因此,这个闭包只实现了FnOnce
。当我们试图编译这段代码时,我们会得到这样的错误:值不能移出闭包,因为闭包必须实现FnMut
:
$ cargo run
Compiling rectangles v0.1.0 (file:///projects/rectangles)
error[E0507]: cannot move out of `value`, a captured variable in an `FnMut` closure
--> src/main.rs:18:30
|
15 | let value = String::from("by key called");
| ----- captured outer variable
16 |
17 | list.sort_by_key(|r| {
| ______________________-
18 | | sort_operations.push(value);
| | ^^^^^ move occurs because `value` has type `String`, which does not implement the `Copy` trait
19 | | r.width
20 | | });
| |_____- captured by this `FnMut` closure
For more information about this error, try `rustc --explain E0507`.
error: could not compile `rectangles` due to previous error
错误指向闭包体中将value
移出环境的行。要解决这个问题,我们需要更改闭包体,以便它不会将值移出环境。要计算sort_by_key
被调用的次数,在环境中保留一个计数器并在闭包体中增加它的值是一种更直接的计算方法。示例13-9中的闭包与sort_by_key
一起工作,因为它只捕获对num_sort_operations
计数器的一个可变引用,因此可以多次调用:
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let mut list = [
Rectangle { width: 10, height: 1 },
Rectangle { width: 3, height: 5 },
Rectangle { width: 7, height: 12 },
];
let mut num_sort_operations = 0;
list.sort_by_key(|r| {
num_sort_operations += 1;
r.width
});
println!("{:#?}, sorted in {num_sort_operations} operations", list);
}
在定义或使用使用闭包的函数或类型时,Fn
特征非常重要。在下一节中,我们将讨论迭代器。许多迭代器方法都接受闭包参数,因此在继续讨论时请记住这些闭包细节!
13.2 用迭代器处理一系列项
迭代器模式允许您对一系列项依次执行某些任务。迭代器负责迭代每个项的逻辑,并确定序列何时结束。当您使用迭代器时,不必自己重新实现该逻辑。
在Rust中,迭代器是惰性(lazy)的,这意味着除非调用消耗迭代器的方法来耗尽迭代器,否则迭代器不会起作用。例如,示例13-10中的代码通过调用Vec<T>
上定义的iter
方法,在向量v1
中的项上创建一个迭代器。这段代码本身没有做任何有用的事情。
13-10
let v1 = vec![1, 2, 3];
let v1_iter = v1.iter();
迭代器存储在变量v1_iter
中。一旦创建了迭代器,就可以以各种方式使用它。在第三章的示例3-5中,我们使用for
循环遍历一个数组,在数组的每个项上执行一些代码。在底层,它隐式地创建并使用了一个迭代器,但到目前为止,我们一直忽略了它的具体工作原理。
在示例13-11的示例中,我们将迭代器的创建与for
循环中迭代器的使用分离开来。当使用v1_iter
中的迭代器调用for
循环时,迭代器中的每个元素都将在循环的一次迭代中使用,这将打印出每个值。
13-11
let v1 = vec![1, 2, 3];
let v1_iter = v1.iter();
for val in v1_iter {
println!("Got: {}", val);
}
在标准库中没有提供迭代器的时,您可能会通过以下方法编写相同的功能:从索引0开始一个变量,使用该变量索引到向量中获得一个值,然后在循环中递增变量值,直到达到向量中项的总数。
迭代器为你处理所有的逻辑,减少你可能搞砸的重复代码。迭代器为您提供了更大的灵活性,可以对许多不同类型的序列使用相同的逻辑,而不仅仅是可以索引的数据结构,比如向量。让我们看看迭代器是如何做到这一点的。
13.2.1 Iterator
Trait和 next
方法
所有迭代器都实现了一个在标准库中定义的名为Iterator
的trait。这个trait的定义如下:
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// methods with default implementations elided
}
注意,这个定义使用了一些新的语法:type Item
和Self::Item
,它们定义了一个与该trait相关的类型。我们将在第19章深入讨论关联类型(associated type
)。现在,您所需要知道的是,这段代码表示实现Iterator特性还需要定义Item
类型,而该Item
类型将在下一个方法的返回类型中使用。换句话说,Item
类型将是迭代器返回的类型。
Iterator
trait只需要实现者定义一个方法:next
方法,它每次返回迭代器中的一项,包装在Some
中,迭代结束时返回None
。
我们可以直接在迭代器上调用next
方法;示例13-12演示了从vector
创建的迭代器上重复调用next
返回的值。
13-12
#[test]
fn iterator_demonstration() {
let v1 = vec![1, 2, 3];
let mut v1_iter = v1.iter();
assert_eq!(v1_iter.next(), Some(&1));
assert_eq!(v1_iter.next(), Some(&2));
assert_eq!(v1_iter.next(), Some(&3));
assert_eq!(v1_iter.next(), None);
}
注意,我们需要使v1_iter
可变:在迭代器上调用next
方法会改变迭代器用来跟踪它在序列中的位置的内部状态。换句话说,这段代码消耗或用完迭代器。每次调用next
都会消耗迭代器中的一项。当我们使用for
循环时,我们不需要使v1_iter
可变,因为循环拥有v1_iter
的所有权,并在幕后使其可变。
还要注意,从对next
的调用中获得的值是对vector中
的值的不可变引用。iter
方法在不可变引用上生成迭代器。如果我们想创建一个迭代器来获得v1
的所有权并返回拥有的值,我们可以调用into_iter
而不是iter
。类似地,如果我们想迭代可变引用,我们可以调用iter_mut
而不是iter
。
13.2.2 消费迭代器的方法
Iterator
trait 有许多不同的方法,标准库提供了默认实现;你可以通过查看Iterator
trait 的标准库API文档来找到这些方法。其中一些方法调用它们定义中的next
方法,这就是为什么在实现Iterator
trait 时需要实现next
方法的原因。
调用next
的方法称为消费适配器(consuming adaptors),因为调用它们会耗尽迭代器。一个例子是sum
方法,它获得迭代器的所有权,并通过重复调用next
来遍历项,从而使用迭代器。在遍历迭代过程中,它将每个项目添加到运行总和中,并在迭代完成时返回总和。示例13-13演示了sum
方法的用法:
13-13
#[test]
fn iterator_sum() {
let v1 = vec![1, 2, 3];
let v1_iter = v1.iter();
let total: i32 = v1_iter.sum();
assert_eq!(total, 6);
}
在调用sum
之后不允许使用v1_iter
,因为sum
会获取调用它的迭代器的所有权。
13.2.3 产生其他迭代器的方法
迭代器适配器(Iterator adaptors
)是在Iterator
trait 上定义的不消费迭代器的方法。相反,它们通过改变原始迭代器的某些方面来生成不同的迭代器。
示例13-17显示了一个调用迭代器适配器方法map
的示例,它接受一个闭包,在遍历项时调用每个项。map
方法返回一个生成修改项的新迭代器。这里的闭包创建了一个新的迭代器,其中vector
中的每一项都将加1
:
13-14:
let v1: Vec<i32> = vec![1, 2, 3];
v1.iter().map(|x| x + 1);
However, this code produces a warning:
$ cargo run
Compiling iterators v0.1.0 (file:///projects/iterators)
warning: unused `Map` that must be used
--> src/main.rs:4:5
|
4 | v1.iter().map(|x| x + 1);
| ^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_must_use)]` on by default
= note: iterators are lazy and do nothing unless consumed
warning: `iterators` (bin "iterators") generated 1 warning
Finished dev [unoptimized + debuginfo] target(s) in 0.47s
Running `target/debug/iterators`
示例13-14中的代码不做任何事情;我们指定的闭包永远不会被调用。这个警告提醒了我们原因:迭代器适配器是懒惰的,我们需要在这里使用迭代器。
要修复这个警告并使用迭代器,我们将使用collect
方法,我们在示例12-1的第12章中使用过该方法。此方法使用迭代器并将结果值收集到集合数据类型中。
在示例13-15中,我们收集迭代器的结果,迭代器从映射到vector的调用中返回。这个向量最终将包含原始向量中每一项加1。
13-15
let v1: Vec<i32> = vec![1, 2, 3];
let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();
assert_eq!(v2, vec![2, 3, 4]);
因为map
接受闭包,所以可以指定想要对每个项执行的任何操作。这是一个很好的例子,说明了闭包如何让您自定义一些行为,同时重用Iterator
trait 提供的迭代行为。
您可以将多个调用链接到迭代器适配器,以可读的方式执行复杂的操作。但是因为所有的迭代器都是惰性的,所以必须调用一个消费适配器方法才能从迭代器适配器调用中获得结果。
13.2.4 使用捕获其环境的闭包
许多迭代器适配器将闭包作为参数,通常我们将指定为迭代器适配器参数的闭包将是捕获其环境的闭包。
对于本例,我们将使用接受闭包的filter
方法。闭包从迭代器中获取一个项并返回bool
值。如果闭包返回true
,则该值将包含在filter
生成的迭代中。如果闭包返回false
,则不包含该值。
在示例13-16中,我们使用filter
和一个闭包,该闭包从其环境中捕获shoe_size
变量,以遍历Shoe
结构实例的集合。它将只返回指定尺寸的鞋子。
#[derive(PartialEq, Debug)]
struct Shoe {
size: u32,
style: String,
}
fn shoes_in_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> {
shoes.into_iter().filter(|s| s.size == shoe_size).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filters_by_size() {
let shoes = vec![
Shoe {
size: 10,
style: String::from("sneaker"),
},
Shoe {
size: 13,
style: String::from("sandal"),
},
Shoe {
size: 10,
style: String::from("boot"),
},
];
let in_my_size = shoes_in_size(shoes, 10);
assert_eq!(
in_my_size,
vec![
Shoe {
size: 10,
style: String::from("sneaker")
},
Shoe {
size: 10,
style: String::from("boot")
},
]
);
}
}
shoes_in_size
函数将鞋子向量的所有权和鞋子尺寸作为参数。它返回一个只包含指定尺寸鞋子的向量。
在shoes_in_size
函数体中,调用into_iter
来创建一个迭代器,该迭代器获得vector
的所有权。然后调用filter
将该迭代器调整为只包含闭包返回true
的元素的新迭代器。
闭包从环境中捕获shoe_size
参数,并将该值与每只鞋子的尺寸进行比较,只保留指定尺寸的鞋子。最后,调用collect
将调整后的迭代器返回的值收集到函数返回的vector
中。
测试表明,当调用shoes_in_size
时,我们只返回与指定值相同大小的鞋子。
13.3 比较性能:循环和迭代器
要确定是使用循环还是使用迭代器,您需要知道哪个实现更快:search
函数选用带有显式for
循环的函数版本还是带有迭代器的版本。
我们运行了一个基准测试,将阿The Adventures of Sherlock Holmes by Sir Arthur Conan Doyle
的全部内容加载到一个String
中,并在内容中查找单词the
。下面是使用for
循环的搜索版本和使用迭代器的搜索版本的基准测试结果:
test bench_search_for ... bench: 19,620,300 ns/iter (+/- 915,700)
test bench_search_iter ... bench: 19,234,900 ns/iter (+/- 657,200)
迭代器版本稍微快一点!我们在这里不解释基准测试代码,因为重点不是要证明这两个版本是等价的,而是要大致了解这两个实现在性能方面是如何比较的。
对于更全面的基准测试,应该使用各种大小的文本作为内容,使用不同的单词和不同长度的单词作为query
,以及使用各种其他变体进行检查。关键是:迭代器虽然是高级抽象,但会被编译成与您自己编写的低级代码大致相同的代码。迭代器是Rust的零成本抽象之一,也就是说,使用该抽象不会带来额外的运行时开销。这类似于Bjarne Stroustrup, c++的最初设计者和实实者,在“c++的基础”(2012)中定义的零开销zero-overhead
:
一般来说,c++实现遵循零开销原则:不用的东西就不用花钱。更进一步说:你所使用的,你不可能手工编码得更好。
作为另一个例子,下面的代码取自音频解码器。该译码算法采用线性预测数学运算,基于之前样本的线性函数估计未来值。这段代码使用迭代器链对作用域中的三个变量进行计算:数据的切片buffer
、12个系数的数组coefficients
以及在qlp_shift中移动数据的量。我们在这个例子中声明了变量,但没有给它们任何值;尽管这段代码在其上下文之外没有太多意义,但它仍然是Rust如何将高级想法转换为低级代码的一个简明的、真实的示例。
let buffer: &mut [i32];
let coefficients: [i64; 12];
let qlp_shift: i16;
for i in 12..buffer.len() {
let prediction = coefficients.iter()
.zip(&buffer[i - 12..i])
.map(|(&c, &s)| c * s as i64)
.sum::<i64>() >> qlp_shift;
let delta = buffer[i];
buffer[i] = prediction as i32 + delta;
}
为了计算prediction
值,该代码遍历coefficients
中的12个值,并使用zip
方法将系数值与缓冲区中的前12个值配对。然后,对于每一对,我们将值相乘,对所有结果求和,并将总和qlp_shift
位中的位向右移动。
在音频解码器等应用程序中的计算通常将性能放在最优先的位置。在这里,我们创建一个迭代器,使用两个适配器,然后使用值。Rust代码将编译成什么汇编代码?在编写本文时,它将编译为您手工编写的相同程序集。没有任何循环对应于coefficients
值上的迭代:Rust知道有12个迭代,所以它“展开”了循环。展开是一种优化,它消除了循环控制代码的开销,而是为循环的每次迭代生成重复的代码。
所有的系数都存储在寄存器中,这意味着访问值非常快。在运行时对数组访问没有边界检查。Rust能够应用的所有这些优化使得生成的代码非常高效。现在您知道了这一点,您就可以放心地使用迭代器和闭包了!它们使代码看起来更高级,但不会因此造成运行时性能损失。
十五、智能指针
指针是在内存中包含地址的变量的一般概念。该地址指向或“指向”一些其他数据。Rust中最常见的一种指针是引用,这是您在第4章中了解到的。引用由&
符号表示,并借用(borrow )它们所指向的值。除了引用数据之外,它们没有任何特殊功能,也没有任何开销。
另一方面,智能指针是类似于指针的数据结构,但也具有额外的元数据和功能。智能指针的概念并不是Rust独有的:智能指针起源于c++,也存在于其他语言中。Rust在标准库中定义了各种智能指针,这些指针提供的功能超出了引用提供的功能。为了探究一般概念,我们将看几个智能指针的不同示例,包括引用计数(reference counting
)智能指针类型。该指针允许数据具有多个所有者,通过跟踪所有者的数量,以及在没有所有者时清理数据。
Rust具有所有权和借用的概念,它在引用和智能指针之间还有一个额外的区别:虽然引用只借用数据,但在许多情况下,智能指针拥有它们所指向的数据。
虽然我们当时没有这样称呼它们,但我们已经在本书中遇到了一些智能指针,包括第8章中的String
和Vec<T>
。这两种类型都可以算作智能指针,因为它们拥有一些内存,并允许您对其进行操作。它们还具有元数据和额外的功能或保证。例如,String
将其容量存储为元数据,并具有确保其数据始终为有效UTF-8的额外能力。
智能指针通常使用结构实现。与普通结构不同,智能指针实现了Deref
和Drop
trait 。 Deref
trait 允许智能指针结构的实例像引用一样工作,因此您可以编写代码来使用引用或智能指针。Drop
trait 允许您自定义当智能指针的实例超出作用域时运行的代码。在本章中,我们将讨论这两个特性,并演示为什么它们对智能指针很重要。
鉴于智能指针模式是Rust中经常使用的通用设计模式,本章不会涵盖所有现有的智能指针。许多库都有自己的智能指针,您甚至可以编写自己的指针。我们将介绍标准库中最常见的智能指针:
Box<T>
用于在堆上分配值Rc<T>
,一个支持多重所有权的引用计数类型Ref<T>
和RefMut<T>
,通过RefCell<T>
访问,这是在运行时而不是编译时强制借用规则的类型
此外,我们还将介绍内部可变模式(interior mutability pattern
),其中不可变类型公开用于更改内部值的API。我们还将讨论引用周期(reference cycles
):它们如何泄漏内存以及如何防止泄漏。
让我们开始吧!
15.1 使用Box<T>
指向堆上的数据
最直接的智能指针是一个 box,其类型为box <T>
。box 允许将数据存储在堆上,而不是堆栈上。栈上剩下的是指向堆数据的指针。参考第4章来回顾堆栈和堆之间的区别。
box 没有性能开销,只是将数据存储在堆上而不是堆栈上。但它们也没有很多额外的功能。你会经常在以下情况下使用它们:
- 当您有一个在编译时无法知道其大小的类型,并且希望在需要精确大小的上下文中使用该类型的值时
- 当您有大量数据,并且想要转移所有权,但要确保在这样做时数据不会被复制时
- 当您想拥有一个值,并且只关心它是实现特定 trait 的类型而不是特定类型时
我们将在“用box开启递归类型”一节中演示第一种情况。在第二种情况下,传输大量数据的所有权可能需要很长时间,因为数据在栈上到处复制。为了在这种情况下提高性能,我们可以将堆上的大量数据存储在一个box中。然后,只在堆栈上复制少量指针数据,而它引用的数据则停留在堆上的一个位置。第三种情况被称为trait对象 (trait object
),第17章专门用了一整节“使用允许不同类型值的trait对象”来讨论这个主题。所以,你在这里学到的东西将在第17章再次应用!
15.1.1 使用Box<T>
将数据存储在堆上
在讨论Box<T>
的堆存储用例之前,我们将介绍语法以及如何与Box中存储的值进行交互。
示例15-1显示了如何使用一个box 在堆上存储i32
值:
15-1
fn main() {
let b = Box::new(5);
println!("b = {}", b);
}
我们将变量b
定义为Box
的值,该Box
指向在堆上分配的值5。这个程序将输出b = 5
;在本例中,我们可以像访问栈上的数据那样访问 box 中的数据。就像任何拥有的值一样,当一个 box 超出作用域时,比如的b
到达main
末尾,它将被释放。释放发生在 box (存储在栈上)和它所指向的数据(存储在堆上)。
将单个值放在堆上不是很有用,因此不会经常以这种方式单独使用 box。在栈上有一个i32
这样的值(它们默认存储在栈中)在大多数情况下更合适。让我们来看一个例子,在这个例子中,box允许我们定义如果没有box就不能定义的类型。
15.1.2 用box启用递归类型
递归类型的值可以有另一个相同类型的值作为其本身的一部分。递归类型造成了一个问题,因为在编译时Rust需要知道一个类型占用了多少空间。然而,理论上递归类型的嵌套值可以无限地继续,因此Rust无法知道值需要多少空间。因为box的大小是已知的,所以可以通过在递归类型定义中插入box来启用递归类型。
作为递归类型的一个示例,让我们研究一下它的cons list
。这是一种在函数式编程语言中常见的数据类型。我们将定义的cons列表类型很简单,除了递归;因此,当您遇到涉及递归类型的更复杂的情况时,我们将使用的示例中的概念将非常有用。
关于cons list
的更多信息
cons list
是一种数据结构,它来自Lisp编程语言及其方言,由嵌套对组成,是链表的Lisp版本。它的名字来自Lisp中的cons
函数(“构造函数( “construct function”)”的缩写),该函数从它的两个参数构造一个新的pair。通过对由一个值和另一个值组成的对调用cons
,我们可以构造由递归对组成的 cons list
。
例如,下面是一个cons列表的伪代码表示,其中包含列表1,2,3,每对都在括号中:
(1, (2, (3, Nil)))
cons list
中的每一项都包含两个元素:当前项的值和下一项的值**。列表中的最后一项只包含一个名为Nil
的值,没有下一项。通过递归调用cons
函数生成一个 cons list
。表示递归基本情况的规范名称是Nil
。注意,这与第6章中的“null
”或“nil
”概念不同,后者是无效或不存在的值。
cons list
不是Rust中常用的数据结构。大多数时候,当你在Rust中有一个项目列表时,Vec<T>
是一个更好的选择。其他更复杂的递归数据类型在各种情况下都很有用,但是从本章的cons list
开始,我们可以探索box如何让我们定义递归数据类型而不受太多干扰。
示例15-2包含一个cons list
的枚举定义。注意,这段代码还不能编译,因为List
类型没有已知的大小,我们将对此进行演示。
15-2
enum List {
Cons(i32, List),
Nil,
}
注意:对于本例的目的,我们实现了一个仅保存
i32
值的cons list
。我们本可以使用泛型来实现它,就像我们在第10章中讨论的那样,来定义一个cons list
类型,该类型可以存储任何类型的值。
使用List类型存储列表1,2,3的代码如示例15-3所示:
15-3
use crate::List::{Cons, Nil};
fn main() {
let list = Cons(1, Cons(2, Cons(3, Nil)));
}
第一个Cons
值为1,另一个为List
值。这个List
值是另一个Cons
值,包含2
和另一个List
值。这个List
值是另一个Cons
值,包含3
和一个List
值,最后是Nil
,这是非递归变量,表示列表结束。
如果我们尝试编译示例15-3中的代码,我们会得到如下所示的错误:
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0072]: recursive type `List` has infinite size
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^ recursive type has infinite size
2 | Cons(i32, List),
| ---- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to make `List` representable
|
2 | Cons(i32, Box<List>),
| ++++ +
error[E0391]: cycle detected when computing drop-check constraints for `List`
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^
|
= note: ...which immediately requires computing drop-check constraints for `List` again
= note: cycle used when computing dropck types for `Canonical { max_universe: U0, variables: [], value: ParamEnvAnd { param_env: ParamEnv { caller_bounds: [], reveal: UserFacing, constness: NotConst }, value: List } }`
Some errors have detailed explanations: E0072, E0391.
For more information about an error, try `rustc --explain E0072`.
error: could not compile `cons-list` due to 2 previous errors
错误显示此类型“具有无限大小”。原因是我们用递归的变量定义了List
:它直接保存自身的另一个值。因此,Rust无法计算出需要多少空间来存储List
值。让我们分析一下为什么会得到这个错误。首先,我们将了解Rust如何决定需要多少空间来存储非递归类型的值。
计算非递归类型的大小
回想一下我们在第6章讨论enum
定义时在示例6-2中定义的Message enum
:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
为了确定为Message
值分配多少空间,Rust将遍历每个变量,以查看哪个变量需要最多的空间。Rust看到Message::Quit
不需要任何空间,Message::Move
需要足够的空间来存储两个i32
值,以此类推。因为只使用一个变量,所以Message
值所需的最大空间是存储其最大变量所需的空间。
与此相比,当Rust试图确定像示例15-2中的List
enum这样的递归类型需要多少空间时,会发生什么情况。编译器首先查看Cons
变体,它包含一个类型为i32
的值和一个类型为List
的值。因此,Cons
需要的空间量等于i32
的大小加上List
的大小。为了计算List类型需要多少内存,编译器会查看变体,从Cons
变体开始。Cons
变量拥有一个类型为i32
的值和一个类型为List
的值,这个过程无限地继续下去,如图15-1所示。
使用Box<T>
获得已知大小的递归类型
因为Rust无法计算出要为递归定义的类型分配多少空间,编译器给出了一个错误,并给出了以下有用的建议:
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to make `List` representable
|
2 | Cons(i32, Box<List>),
| ^^^^ ^
在这个建议中,“间接(indirection)”意味着不直接存储值,而是通过存储指向值的指针来改变数据结构来间接存储值。
因为Box<T>
是一个指针,Rust总是知道Box<T>
需要多少空间:指针的大小不会根据它所指向的数据量而改变。这意味着我们可以在Cons
变体中放入Box<T>
,而不是直接放入另一个List
值。Box<T>
将指向将位于堆上而不是Cons
变量内部的下一个List
值。从概念上讲,我们仍然有一个列表,它是用包含其他列表的列表创建的,但这个实现现在更像是将项放在另一个项旁边,而不是放在另一个项里面。
我们可以将示例15-2中List
枚举的定义和示例15-3
中List的用法更改为示例15-5
中的代码,它将可以编译:
enum List {
Cons(i32, Box<List>),
Nil,
}
use crate::List::{Cons, Nil};
fn main() {
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}
Cons
变体需要i32
的大小加上存储 box 指针数据的空间。Nil
变体不存储任何值,因此它需要的空间比Cons
变体少。现在我们知道,任何List
值都将占用i32
的大小加上 box 指针数据的大小。通过使用box ,我们打破了无限递归链,因此编译器可以计算出存储List
值所需的大小。图15-2显示了Cons变体现在的样子。
box 只提供间接和堆分配;它们没有任何其他特殊功能,不像我们将看到的其他智能指针类型那样。它们也没有这些特殊功能所带来的性能开销,所以在像cons list
这样的情况下,间接是我们唯一需要的特性,它们是有用的。我们还将在第17章中看到更多关于 box 的用例。
Box<T>
类型是一个智能指针,因为它实现了Deref
trait,这允许Box<T>
值被视为引用。当Box<T>
值超出作用域时,由于Drop
trait实现,box 所指向的堆数据也会被清除。这两个trait对于我们将在本章其余部分讨论的其他智能指针类型所提供的功能将更加重要。让我们更详细地探讨这两个trait。
15.2 把智能指针当作具有Deref
Trait 的常规引用
实现Deref
特性允许您自定义解引用操作符*
的行为(不要与乘法或glob
操作符混淆)。通过以一种将智能指针视为常规引用的方式实现Deref
,您可以编写对引用进行操作的代码,并将该代码与智能指针一起使用。
让我们首先看看解引用操作符如何处理常规引用。然后,我们将尝试定义一个行为类似于Box<T>
的自定义类型,并了解为什么解引用操作符在新定义的类型上不能像引用那样工作。我们将探讨如何实现Deref
trait 使智能指针以类似于引用的方式工作成为可能。然后,我们将了解Rust的deref coercion
功能,以及它如何让我们使用引用或智能指针。
注意:我们将要构建的
MyBox<T>
类型和真正的Box<T>
之间有一个很大的区别:我们的版本不会将其数据存储在堆上。我们将本例的重点放在Deref
上,因此数据实际存储在哪里没有类似指针的行为重要。
15.2.1 Following the Pointer to the Value
常规引用是指针的一种类型,一种方法是将指针视为指向存储在其他地方的值的箭头。在示例15-6中,我们创建了一个对i32
值的引用,然后使用解引用操作符紧跟对该值的引用:
fn main() {
let x = 5;
let y = &x;
assert_eq!(5, x);
assert_eq!(5, *y);
}
变量x
的值为i32
类型5。我们设y
等于x
的引用,我们可以断言x
等于5。然而,如果我们想对y
中的值进行断言,我们必须使用*y
紧跟它所指向的值的引用(因此是解引用(dereference)),以便编译器可以比较实际值。一旦我们解引用y
,我们就可以访问y
所指向的整数值,我们可以与5进行比较。
如果我们尝试写assert_eq!(5, y);
相反,我们会得到这样的编译错误:
$ cargo run
Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0277]: can't compare `{integer}` with `&{integer}`
--> src/main.rs:6:5
|
6 | assert_eq!(5, y);
| ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
|
= help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
= help: the following other types implement trait `PartialEq<Rhs>`:
f32
f64
i128
i16
i32
i64
i8
isize
and 6 others
= note: this error originates in the macro `assert_eq` (in Nightly builds, run with -Z macro-backtrace for more info)
For more information about this error, try `rustc --explain E0277`.
error: could not compile `deref-example` due to previous error
不允许比较数字和对数字的引用,因为它们是不同的类型。必须使用解引用操作符来跟随引用到它所指向的值。
15.2.2 像引用一样使用Box<T>
我们可以重写示例15-6中的代码,使用Box<T>
来代替引用;示例15-7中Box<T>
上使用的解引用操作符的功能与示例15-6中引用上使用的解引用操作符的功能相同:
15-7
fn main() {
let x = 5;
let y = Box::new(x);
assert_eq!(5, x);
assert_eq!(5, *y);
}
示例15-7和示例15-6的主要区别在于,这里我们将y
设置为一个指向x
的复制值的 box 实例,而不是一个指向x值的引用。在最后一个断言中,我们可以使用解引用操作符来跟随 box 的指针,方法与当y
是引用时相同。接下来,我们将探索Box<T>
的特殊之处,它使我们能够通过定义自己的 Box 类型来使用解引用操作符。
15.2.3 定义自己的智能指针
让我们构建一个类似于标准库提供的Box<T>
类型的智能指针,以体验智能指针在默认情况下与引用的不同行为。然后,我们将了解如何添加使用解引用操作符的功能。
Box<T>
类型最终被定义为具有一个元素的元组结构体,因此示例15-8以同样的方式定义了MyBox<T>
类型。我们还将定义一个new
函数来匹配Box<T>
上定义的new
函数。
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
我们定义了一个名为MyBox
的结构,并声明了一个泛型参数T
,因为我们希望我们的类型保存任何类型的值。MyBox
类型是一个元组结构,包含一个类型为T
的元素。MyBox::new
函数接受一个类型为T
的形参,并返回一个保存传入值的MyBox
实例。
让我们试着将示例15-7中的main
函数添加到示例15-8中,并将其更改为使用我们定义的MyBox<T>
类型,而不是Box<T>
。示例15-9中的代码无法编译,因为Rust不知道如何解引用MyBox
。
我们的MyBox<T>
类型不能被解引用,因为我们还没有在我们的类型上实现该能力。为了使用*
操作符实现解引用,我们实现了Deref
trait。
15.2.4 通过实现Deref
Trait来像引用一样对待类型
正如第10章“在类型上实现Trait”一节所讨论的那样,要实现 trait,我们需要为 trait 所需的方法提供实现。标准库提供的Deref
trait要求我们实现一个名为deref
的方法,该方法借用self
并返回对内部数据的引用。示例15-10包含要添加到MyBox
定义中的Deref
实现:
use std::ops::Deref;
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
类型Target = T;
语法为要使用的Deref
特征定义了一个关联类型( associated type
)。关联类型是声明泛型参数的一种稍微不同的方式,但您现在不需要担心它们;我们将在第19章更详细地介绍它们。
我们用&self.0
填充deref
方法的主体。所以deref
返回一个我们想用*
操作符访问的值的引用;回想第5章“使用无命名字段的元组结构创建不同的类型”一节,.0
访问元组结构中的第一个值。示例15-9中在MyBox<T>
值上调用*
的main
函数现在开始编译,断言传递!
如果没有Deref
特性,编译器只能解引用&
引用。deref
方法使编译器能够接受实现了Deref
的任何类型的值,并调用deref
方法来获得它知道如何解引用的&
引用。
当我们在示例15-9中输入*y
时,Rust实际上在幕后运行了以下代码:
*(y.deref())
Rust用一个对deref
方法的调用替换了*
操作符的对象,然后就是一个普通的解引用,因此我们不必考虑是否需要调用deref
方法。Rust特性允许我们编写功能相同的代码,无论我们使用的是常规引用还是实现Deref
的类型。
deref
方法返回一个值的引用,并且*(y.deref())
中括号外的普通解引用仍然是必要的,这与所有权系统有关。如果deref
方法直接返回值而不是对该值的引用,则该值将从self
中移出。在本例中或在使用解引用操作符的大多数情况下,我们不想获得MyBox<T>
内部值的所有权。
注意,当我们在代码中使用*
时,*
操作符的对象被替换为对deref
方法的调用,然后只调用一次*
操作符。因为*
操作符的替换不会无限递归,所以我们最终得到类型为i32
的数据,它与assert_eq!
匹配。
15.2.5 函数和方法的隐式解引用强制转换
Deref强制转换 (Deref coercion
)将对实现Deref
trait 的类型的引用转换为对另一类型的引用。例如,deref强制转换可以将&String
转换为&str
,因为String
实现了Deref
trait ,因此它返回&str
。Deref
强制转换是Rust在函数和方法的参数上执行的一种方便方法,并且只对实现Deref
特征的类型有效。当将传递给函数或方法的对特定类型值的引用作为实参与函数或方法定义中的形参类型不匹配时,就会自动发生这种情况。对deref
方法的一系列调用将我们提供的类型转换为参数所需的类型。
Rust中添加了Deref coercion
,这样程序员编写函数和方法调用时就不需要使用&
和*
添加那么多显式引用和解引用。Deref coercion
功能还允许我们编写更多可同时用于引用或智能指针的代码。
要查看deref强制转换的效果,让我们使用示例15-8中定义的MyBox<T>
类型以及示例15-10中添加的deref
实现。示例15-11显示了一个具有字符串slice
形参的函数的定义:
fn hello(name: &str) {
println!("Hello, {name}!");
}
我们可以用字符串切片作为参数调用hello
函数,例如hello("Rust");
为例。Deref强制转换使得使用MyBox<String>
类型值的引用调用hello
成为可能,如示例15-12所示:
fn main() {
let m = MyBox::new(String::from("Rust"));
hello(&m);
}
这里我们用参数&m
调用hello
函数,它是对MyBox<String>
值的引用。因为我们在示例15-10中实现了MyBox<T>
上的Deref
特性,Rust可以通过调用Deref
将&MyBox<String>
转换为&String
。标准库提供了一个 在String
上Deref
的实现,它返回一个字符串片,这在Deref
的API文档中。Rust再次调用deref
将&String
转换为&str
,这与hello函数的定义相匹配。
如果Rust没有实现deref强制转换,我们将不得不编写示例15-13中的代码而不是示例15-12中的代码使用具有类型为&MyBox<String>
的值调用hello
。
fn main() {
let m = MyBox::new(String::from("Rust"));
hello(&(*m)[..]);
}
(*m)
将MyBox<String>
解引用为字符串。然后是&
和[..]
取等于整个String
的字符串切片来匹配hello
的签名。没有deref强制转换的代码在包含所有这些符号的情况下更难读、写和理解。Deref强制转换允许Rust为我们自动处理这些转换。
当为涉及的类型定义Deref trait 时,Rust将分析类型并根据需要多次使用Deref::deref
,以获得与形参类型匹配的引用。Deref::deref
需要插入的次数在编译时解析,因此利用Deref强制转换不会造成运行时损失!
15.2.6 Deref强制转换如何与可变性相互作用
类似于如何使用Deref trait 覆盖不可变引用上的*
运算符,您可以使用DerefMut
trait 覆盖可变引用上的*
运算符。
当发现类型和trait实现时,Rust在三种情况下, 执行deref强制转换:
- 从
&T
到&U
当T
:Deref<Target=U>
- 从
&mut T
到&mut U
当T: DerefMut<Target=U>
- 从
&mut T
到&U
当T: Deref<Target=U>
前两种情况彼此相同,只是第二种实现了可变性。第一个例子说明,如果您有一个&T
,而T
实现了Deref
到某种类型U
,您可以透明地获得一个&U
。第二种情况表明,对于可变引用也会发生相同的deref强制转换。
第三种情况更为棘手:Rust还会将一个可变引用强制为一个不可变引用。但反过来是不可能的:不可变引用永远不会强制为可变引用。由于借用规则,如果您有一个可变引用,该可变引用必须是对该数据的唯一引用(否则,程序将无法编译)。将一个可变引用转换为一个不可变引用永远不会违反借用规则。将不可变引用转换为可变引用需要初始不可变引用是对该数据的唯一不可变引用,但借用规则不能保证这一点。因此,Rust不能假设将不可变引用转换为可变引用是可能的。
15.3 使用Drop
Trait运行清理代码
智能指针模式的第二个重要 trait 是Drop
,它允许您自定义当值即将超出作用域时发生的情况。您可以为任何类型的Drop
trait 提供实现,该代码可用于释放文件或网络连接等资源。
我们在智能指针的上下文中引入Drop
是因为Drop
trait的功能几乎总是在实现智能指针时使用。例如,当一个Box<T>
被丢弃时,它将释放该 box 所指向的堆上的空间。
在某些语言中,对于某些类型,程序员每次使用完这些类型的实例时都必须调用代码来释放内存或资源。例子包括文件句柄、套接字或锁。如果他们忘记了,系统可能会过载并崩溃。在Rust中,您可以指定当值超出作用域时运行特定的代码位,编译器将自动插入此代码。因此,在完成了特定类型的实例的程序中,您不需要小心地将清除代码放置在任何地方—您仍然不会泄漏资源!
当值超出作用域时,可以通过实现Drop
trait指定要运行的代码。Drop
trait要求您实现一个名为drop
的方法,它接受一个对self
的可变引用。为了查看Rust何时调用drop
,让我们用println!
实现drop
。
示例15-14显示了一个CustomSmartPointer
结构,它唯一的自定义功能是当实例超出作用域时,打印Dropping CustomSmartPointer!
,显示Rust何时运行drop
函数。
struct CustomSmartPointer {
data: String,
}
impl Drop for CustomSmartPointer {
fn drop(&mut self) {
println!("Dropping CustomSmartPointer with data `{}`!", self.data);
}
}
fn main() {
let c = CustomSmartPointer {
data: String::from("my stuff"),
};
let d = CustomSmartPointer {
data: String::from("other stuff"),
};
println!("CustomSmartPointers created.");
}
Drop
trait 包含在prelude
中,所以我们不需要把它带入范围。我们在CustomSmartPointer
上实现了Drop
trait,并通过调用println!
,为drop
方法提供了一个实现。drop
函数的主体是放置在当类型的实例超出作用域时想要运行的任何逻辑的地方。我们在这里打印了一些文本,以直观地演示Rust何时调用drop
。
在main
中,我们创建了两个CustomSmartPointer
实例,然后打印创建的CustomSmartPointer
。在main
结束时,我们的CustomSmartPointer
实例将超出作用域,Rust将调用我们放入drop
方法中的代码,打印最后的消息。注意,我们不需要显式地调用drop
方法。
When we run this program, we’ll see the following output:
$ cargo run
Compiling drop-example v0.1.0 (file:///projects/drop-example)
Finished dev [unoptimized + debuginfo] target(s) in 0.60s
Running `target/debug/drop-example`
CustomSmartPointers created.
Dropping CustomSmartPointer with data `other stuff`!
Dropping CustomSmartPointer with data `my stuff`!
当实例超出作用域时,Rust自动为我们调用drop
,调用我们指定的代码。变量按其创建顺序的相反顺序被删除,因此d
在c
之前被删除。这个示例的目的是让您直观地了解删除方法是如何工作的;通常,您需要指定类型需要运行的清理代码,而不是打印消息。
使用std::mem::drop
提前删除一个值
不幸的是,要禁用自动删除功能并不容易。禁用drop
通常不是必要的;Drop
trait的关键在于它被自动处理了。但是,有时您可能希望尽早清理值。一个例子是在使用管理锁的智能指针时:您可能想强制释放锁的drop
方法,以便相同作用域中的其他代码可以获得锁。Rust不允许你手动调用Drop
trait的drop
方法;相反,如果您想强制一个值在其作用域结束之前被删除,则必须调用标准库提供的std::mem::drop
函数。
如果我们尝试通过修改示例15-14中的main
函数来手动调用Drop
trait的drop
方法,如示例15-15所示,我们将得到一个编译器错误:
fn main() {
let c = CustomSmartPointer {
data: String::from("some data"),
};
println!("CustomSmartPointer created.");
c.drop();
println!("CustomSmartPointer dropped before the end of main.");
}
When we try to compile this code, we’ll get this error:
$ cargo run
Compiling drop-example v0.1.0 (file:///projects/drop-example)
error[E0040]: explicit use of destructor method
--> src/main.rs:16:7
|
16 | c.drop();
| --^^^^--
| | |
| | explicit destructor calls not allowed
| help: consider using `drop` function: `drop(c)`
For more information about this error, try `rustc --explain E0040`.
error: could not compile `drop-example` due to previous error
此错误消息声明不允许显式调用drop
。错误消息使用术语析构函数(destructor
),这是用于清理实例的函数的通用编程术语。析构函数类似于构造函数(constructor
),它创建一个实例。Rust中的drop
函数是一个特殊的析构函数。
Rust不允许显式调用drop
,因为Rust仍然会自动调用main
末尾的值上drop
。这将导致双重释放(double free
)错误,因为Rust将尝试两次清除相同的值。
当值超出作用域时,不能禁用drop
的自动插入,也不能显式调用drop
方法。因此,如果需要强制提前清理一个值,可以使用std::mem::drop
函数。
std::mem::drop
函数不同于Drop
trait中的drop
方法。我们通过传递想要强制删除的值作为参数来调用它。函数在prelude
中,因此我们可以修改示例15-15中的main
来调用drop
函数,如示例15-16所示:
15-16
fn main() {
let c = CustomSmartPointer {
data: String::from("some data"),
};
println!("CustomSmartPointer created.");
drop(c);
println!("CustomSmartPointer dropped before the end of main.");
}
Running this code will print the following:
$ cargo run
Compiling drop-example v0.1.0 (file:///projects/drop-example)
Finished dev [unoptimized + debuginfo] target(s) in 0.73s
Running `target/debug/drop-example`
CustomSmartPointer created.
Dropping CustomSmartPointer with data `some data`!
CustomSmartPointer dropped before the end of main.
文本drop CustomSmartPointer with data ' some data ' !
在 CustomSmartPointer
创建后和CustomSmartPointer dropped before the end of main.
文本之间打印。CustomSmartPointer
在main
结束前被删除。显示在该点删除c
将调用drop
方法代码。
您可以使用Drop
trait实现中指定的代码,以多种方式使清理变得方便和安全:例如,您可以使用它创建您自己的内存分配器!有了Drop
trait和Rust的所有权系统,你就不需要记得清理了,因为Rust会自动清理。
您也不必担心意外清理仍在使用的值所导致的问题:确保引用始终有效的所有权系统也确保drop
只在值不再使用时被调用一次。
现在我们已经研究了Box<T>
和智能指针的一些特征,让我们看看标准库中定义的其他一些智能指针。
15.4 Rc<T>
,引用计数智能指针
在大多数情况下,所有权是明确的:您确切地知道哪个变量拥有给定的值。但是,在某些情况下,一个值可能有多个所有者。例如,在图数据结构中,多条边可能指向同一个节点,而该节点在概念上属于所有指向它的边。一个节点不应该被清理,除非它没有任何边指向它,也就是没有所有者。
您必须使用Rust类型Rc<T>
显式启用多个所有权,这是引用计数(reference counting
)的缩写。Rc<T>
类型跟踪一个值的引用数量,以确定该值是否仍在使用中。如果对某个值的引用为零,则可以清除该值,而不会使任何引用失效。
把Rc<T>
想象成家庭娱乐室里的一台电视机。如果有人进来看电视,他们就会打开电视。其他人可以进房间看电视。当最后一个人离开房间时,他们会关掉电视,因为它不再被使用了。如果有人在别人还在看电视的时候关掉了电视,剩下的电视观众就会哗然了!
当我们想在堆上为程序的多个部分分配一些数据以便读取,并且在编译时无法确定哪个部分最后使用数据时,我们使用Rc<T>
类型。如果我们知道哪个部分会最后完成,我们就可以让这个部分成为数据的所有者,在编译时执行的正常所有权规则就会生效。
注意:Rc<T>
仅用于单线程场景。当我们在第16章讨论并发性时,我们将介绍如何在多线程程序中进行引用计数。
15.4.1 使用Rc<T>
共享数据
让我们回到示例15-5中的cons list
示例。回想一下,我们用Box<T>
来定义它。这一次,我们将创建两个列表,它们都共享第三个列表的所有权。从概念上看,这与图15-3类似:
我们将创建一个包含5
和10
的列表a
。然后我们再做两个表:以3
开头的b
和以4
开头的c
。然后,b
和c
列表将继续到第一个包含5
和10
的列表。换句话说,两个列表将共享包含5和10的第一个列表。
尝试使用Box<T>
定义我们的List
来实现这个场景是行不通的,如示例15-17所示:
enum List {
Cons(i32, Box<List>),
Nil,
}
use crate::List::{Cons, Nil};
fn main() {
let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
let b = Cons(3, Box::new(a));
let c = Cons(4, Box::new(a));
}
When we compile this code, we get this error:
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0382]: use of moved value: `a`
--> src/main.rs:11:30
|
9 | let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
| - move occurs because `a` has type `List`, which does not implement the `Copy` trait
10 | let b = Cons(3, Box::new(a));
| - value moved here
11 | let c = Cons(4, Box::new(a));
| ^ value used here after move
For more information about this error, try `rustc --explain E0382`.
error: could not compile `cons-list` due to previous error
Cons
变体拥有它们所持有的数据,因此当我们创建b
列表时,a
被移动到b
中,b
拥有a
。然后,当我们试图在创建c
时再次使用a
时,我们不允许这样做,因为a
已经被移动了。
我们可以更改Cons
的定义以保存引用,但这样就必须指定生存期参数。通过指定生存期参数,我们将指定列表中的每个元素的生存时间至少与整个列表一样长。示例15-17中的元素和列表就是这种情况,但并不是在所有场景中都是如此。
相反,我们将更改List
的定义,使用Rc<T>
代替Box<T>
,如示例15-18所示。每个Cons
变量现在都有一个值和一个Rc<T>
指向一个List。当我们创建b
时,我们将克隆a
所持有的Rc<List>
,而不是获得a
的所有权,从而将引用的数量从一个增加到两个,并让a
和b
共享Rc<List>
中的数据的所有权。我们还将在创建c
时克隆a
,将引用的数量从2个增加到3个。每次调用Rc::clone
时,对Rc<List>
中的数据的引用计数将增加,而数据不会被清除,除非对它的引用为零。
15-18
enum List {
Cons(i32, Rc<List>),
Nil,
}
use crate::List::{Cons, Nil};
use std::rc::Rc;
fn main() {
let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
let b = Cons(3, Rc::clone(&a));
let c = Cons(4, Rc::clone(&a));
}
我们需要添加一个use
语句来将Rc<T>
带入作用域,因为它不在prelude
中。在main
中,我们创建了包含5和10的列表,并将其存储在a
中的一个新的Rc< list >
中。然后,当我们创建b
和c
时,调用Rc::clone
函数并将给a
中的Rc< list >
的引用作为参数。
我们本可以调用a.clone()
而不是Rc::clone(&a)
,但Rust的惯例是在这种情况下使用Rc::clone
。Rc::clone
的实现不像大多数类型的clone
实现那样对所有数据进行深度复制。对Rc::clone
的调用只会增加引用计数,这并不需要太多时间。数据的深度拷贝会花费大量时间。通过使用Rc::clone
进行引用计数,我们可以直观地区分深度复制类型的克隆和增加引用计数的克隆。当在代码中寻找性能问题时,我们只需要考虑深度复制克隆,可以忽略对Rc::clone
的调用。
15.4.2 克隆Rc<T>
会增加引用计数
让我们更改示例15-18中的工作示例,以便在创建和删除对a
中的Rc<List>
的引用时可以看到引用计数的变化。
在示例15-19中,我们将更改main
,使它在列表c
周围有一个内部作用域;然后我们可以看到当c
超出作用域时引用计数的变化。
fn main() {
let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
println!("count after creating a = {}", Rc::strong_count(&a));
let b = Cons(3, Rc::clone(&a));
println!("count after creating b = {}", Rc::strong_count(&a));
{
let c = Cons(4, Rc::clone(&a));
println!("count after creating c = {}", Rc::strong_count(&a));
}
println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}
在程序中引用计数发生变化的每一点,我们都打印引用计数,这是通过调用Rc::strong_count
函数获得的。这个函数被命名为strong_count
而不是count
,因为Rc<T>
类型也有一个weak_count
;我们将在“防止引用循环:将Rc<T>
转换为Weak<T>
”一节中看到weak_count
的用途。
This code prints the following:
我们可以看到,a
中的Rc<List>
的初始引用计数为1;然后每次调用clone
,计数就增加1。当c
超出作用域时,计数减少1。我们不需要像调用Rc::clone
来增加引用计数那样调用一个函数来减少引用计数:当Rc<T>
值超出作用域时,Drop
trait 的实现会自动减少引用计数。
我们在这个例子中看不到的是,当b
和a
在main
的末尾超出作用域时,计数为0
,Rc<List>
被完全清除。使用Rc<T>
允许一个值具有多个所有者,并且计数确保只要任何所有者仍然存在,该值就保持有效。
通过不可变引用,Rc<T>
允许您在程序的多个部分之间共享数据,只能读。如果Rc<T>
也允许您有多个可变引用,那么您可能会违反第4章讨论的借用规则之一:多个可变借用到同一位置会导致数据竞争和不一致。但是能够改变数据是非常有用的!在下一节中,我们将讨论内部可变模式和RefCell<T>
类型,您可以将其与Rc<T>
结合使用,以处理这种不可变限制。
15.5 RefCell<T>
和内部可变性模式
内部可变性是Rust中的一种设计模式,它允许你改变数据,即使数据有不可变的引用;通常情况下,借款规则不允许这种行为。为了改变数据,该模式在数据结构中使用不安全(unsafe
)的代码来改变Rust管理改变和借用的常规规则。不安全代码向编译器表明,我们正在手动检查规则,而不是依赖编译器为我们检查;我们将在第19章详细讨论不安全代码。
只有当我们能够确保在运行时遵循借用规则时,我们才能使用使用内部可变模式的类型,即使编译器不能保证这一点。然后将涉及的不安全代码包装在安全API中,而外部类型仍然是不可变的。
让我们通过观察遵循内部可变模式的RefCell<T>
类型来探索这个概念。
15.5.1 通过 RefCell 在运行时检查借用规则
与Rc<T>
不同,RefCell<T>
类型表示对其持有的数据的单一所有权。那么,是什么使RefCell<T>
不同于Box<T>
这样的类型呢?回想一下你在第四章学过的借阅规则:
- 在任何给定的时间,您都可以拥有(但不能同时拥有)一个可变引用或任意数量的不可变引用。
- 引用必须总是有效的。
对于引用和Box<T>
,在编译时强制执行借用规则的不可变性。对于RefCell<T>
,这些不可变性在运行时强制执行。对于引用,如果违反了这些规则,就会出现编译错误。对于RefCell<T>
,如果您违反了这些规则,您的程序将陷入恐慌并退出。
在编译时检查借用规则的好处是,在开发过程中可以更快地发现错误,并且不会对运行时性能产生影响,因为所有的分析都是预先完成的。出于这些原因,在大多数情况下,在编译时检查借用规则是最佳选择,这就是为什么这是Rust的默认值。
相反,在运行时检查借用规则的好处是,在某些内存安全的场景下,它们会被编译时检查禁止。静态分析,像Rust编译器一样,本质上是保守的。代码的一些属性是无法通过分析代码来检测的:最著名的例子是中止问题,这超出了本书的范围,但却是一个值得研究的有趣主题。
因为有些分析是不可能的,如果Rust编译器不能确定代码符合所有权规则,它可能会拒绝正确的程序;从这个角度来说,它是保守的。如果Rust接受了一个错误的程序,用户就不能信任Rust做出的保证。然而,如果Rust拒绝正确的程序,程序员将会感到不便,但不会发生灾难性的事情。当你确定你的代码遵循了借用规则,但编译器无法理解和保证这一点时,RefCell<T>
类型是有用的。
与Rc<T>
类似,RefCell<T>
仅用于单线程场景,如果您尝试在多线程上下文中使用它,则会给您一个编译时错误。我们将在第16章讨论如何在多线程程序中获得RefCell<T>
的功能。
以下是选择Box<T>
, Rc<T>
或RefCell<T>
的原因:
Rc<T>
允许相同数据的多个所有者;Box<T>
和RefCell<T>
只有一个所有者。Box<T>
允许在编译时检查不可变或可变借用;Rc<T>
只允许在编译时检查不可变借;RefCell<T>
允许在运行时检查不可变或可变借用。- 因为
RefCell<T>
允许在运行时检查可变借位,所以即使RefCell<T>
是不可变的,您也可以更改RefCell<T>
内部的值。
在不可变值内改变值是内部可变模式(interior mutability pattern
)。让我们来看一种情况,在这种情况下内部变异是有用的,并检查它是如何实现的。
15.5.2 内部可变:对不可变值的可变借用
借用规则的一个结果是,当你有一个不可变的值时,你不能以可变的方式借用它。例如,这段代码不能编译:
fn main() {
let x = 5;
let y = &mut x;
}
If you tried to compile this code, you’d get the following error:
$ cargo run
Compiling borrowing v0.1.0 (file:///projects/borrowing)
error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable
--> src/main.rs:3:13
|
2 | let x = 5;
| - help: consider changing this to be mutable: `mut x`
3 | let y = &mut x;
| ^^^^^^ cannot borrow as mutable
For more information about this error, try `rustc --explain E0596`.
error: could not compile `borrowing` due to previous error
然而,在某些情况下,值在其方法中改变自己,但对其他代码来说是不可变的,这是很有用的。该值的方法之外的代码将无法更改该值。使用RefCell<T>
是获得内部可变性能力的一种方法,但RefCell<T>
并不能完全绕过借用规则:编译器中的借用检查器允许这种内部可变性,而在运行时检查借用规则。如果你违反了规则,你会受到panic!
而不是编译错误。
让我们研究一个实际的例子,在这个例子中,我们可以使用RefCell<T>
来改变一个不可变的值,并看看这为什么有用。
15.5.3 内部可变性的用例:模拟对象(Mock Objects)
有时在测试过程中,程序员会使用一种类型来代替另一种类型,以观察特定的行为并断言其实现正确。这种占位符类型称为test double
。可以把它想象成电影制作中的“特技替身”,即由一个人代替演员来完成一个特别棘手的场景。在运行测试时,测试双精度代表其他类型。模拟对象是test double
的一个特定类型,它记录测试期间发生的事情,因此您可以断言发生了正确的操作。
Rust不像其他语言拥有对象那样拥有对象,Rust也不像其他一些语言那样在标准库中内置模拟对象功能。但是,您完全可以创建一个与模拟对象具有相同用途的结构。
下面是我们将要测试的场景:我们将创建一个库,它根据最大值跟踪一个值,并根据当前值与最大值的接近程度发送消息。例如,这个库可以用来跟踪用户允许进行的API调用数量的配额。
我们的库只提供跟踪一个值与最大值的接近程度以及消息在什么时间应该是什么样子的功能。使用我们的库的应用程序需要提供发送消息的机制:应用程序可以在应用程序中放入消息、发送电子邮件、发送文本消息或其他内容。库不需要知道这些细节。它所需要的只是实现一个我们将提供的称为Messenger
的特性。示例15-20显示了库代码:
pub trait Messenger {
fn send(&self, msg: &str);
}
pub struct LimitTracker<'a, T: Messenger> {
messenger: &'a T,
value: usize,
max: usize,
}
impl<'a, T> LimitTracker<'a, T>
where
T: Messenger,
{
pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
LimitTracker {
messenger,
value: 0,
max,
}
}
pub fn set_value(&mut self, value: usize) {
self.value = value;
let percentage_of_max = self.value as f64 / self.max as f64;
if percentage_of_max >= 1.0 {
self.messenger.send("Error: You are over your quota!");
} else if percentage_of_max >= 0.9 {
self.messenger
.send("Urgent warning: You've used up over 90% of your quota!");
} else if percentage_of_max >= 0.75 {
self.messenger
.send("Warning: You've used up over 75% of your quota!");
}
}
}
这段代码的一个重要部分是,Messenger
trait 有一个名为send
的方法,它接受对self
和消息文本的不可变引用。这个 trait 是我们的模拟对象需要实现的接口,这样模拟对象才能像实际对象一样被使用。另一个重要部分是,我们希望测试LimitTracker上
的set_value
方法的行为。我们可以更改为value
形参传递的内容,但是set_value
并不返回任何可以进行断言的内容。我们希望能够这样说:如果我们创建一个LimitTracker
,其中包含实现了Messenger
trait 和max
的特定值,当我们传递不同的值时,messenger 被告知发送适当的消息。
我们需要一个模拟对象,它在调用send
时不发送电子邮件或文本消息,而是只跟踪被告知要发送的消息。我们可以创建一个模拟对象的新实例,创建一个使用模拟对象的LimitTracker
,调用LimitTracker
上的set_value
方法,然后检查模拟对象是否有我们期望的消息。示例15-21显示了一个实现模拟对象的尝试,但借款检查器不允许这样做:
pub trait Messenger {
fn send(&self, msg: &str);
}
pub struct LimitTracker<'a, T: Messenger> {
messenger: &'a T,
value: usize,
max: usize,
}
impl<'a, T> LimitTracker<'a, T>
where
T: Messenger,
{
pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
LimitTracker {
messenger,
value: 0,
max,
}
}
pub fn set_value(&mut self, value: usize) {
self.value = value;
let percentage_of_max = self.value as f64 / self.max as f64;
if percentage_of_max >= 1.0 {
self.messenger.send("Error: You are over your quota!");
} else if percentage_of_max >= 0.9 {
self.messenger
.send("Urgent warning: You've used up over 90% of your quota!");
} else if percentage_of_max >= 0.75 {
self.messenger
.send("Warning: You've used up over 75% of your quota!");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockMessenger {
sent_messages: Vec<String>,
}
impl MockMessenger {
fn new() -> MockMessenger {
MockMessenger {
sent_messages: vec![],
}
}
}
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
self.sent_messages.push(String::from(message));
}
}
#[test]
fn it_sends_an_over_75_percent_warning_message() {
let mock_messenger = MockMessenger::new();
let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);
limit_tracker.set_value(80);
assert_eq!(mock_messenger.sent_messages.len(), 1);
}
}
这段测试代码定义了一个MockMessenger
结构,它有一个sent_messages
字段,其中Vec
为String
值,用于跟踪它被告知要发送的消息。我们还定义了一个关联函数new
,以方便创建以空消息列表开始的新的MockMessenger
值。然后我们为MockMessenger
实现信使特性,这样我们就可以将MockMessenger
提供给LimitTracker
。在send
方法的定义中,我们将传递进来的消息作为参数,并将其存储在sent_messages
的MockMessenger
列表中。
在测试中,我们测试当LimitTracker
被告知将value
设置为大于最大值75%
的值时会发生什么。首先,我们创建一个新的MockMessenger
,它将从一个空消息列表开始。然后我们创建一个新的LimitTracker
并给它一个新的MockMessenger
的引用和一个最大值为100
的值。我们在LimitTracker
上调用set_value
方法,其值为80
,大于100
的75%
。然后,我们断言MockMessenger
正在跟踪的消息列表现在应该包含一条消息。
However, there’s one problem with this test, as shown here:
$ cargo test
Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
error[E0596]: cannot borrow `self.sent_messages` as mutable, as it is behind a `&` reference
--> src/lib.rs:58:13
|
2 | fn send(&self, msg: &str);
| ----- help: consider changing that to be a mutable reference: `&mut self`
...
58 | self.sent_messages.push(String::from(message));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be borrowed as mutable
For more information about this error, try `rustc --explain E0596`.
error: could not compile `limit-tracker` due to previous error
warning: build failed, waiting for other jobs to finish...
我们不能修改MockMessenger
来跟踪消息,因为send
方法接受一个不可变的self
引用。我们也不能从错误文本中接受使用&mut self
的建议,因为这样send
的签名将不匹配Messenger
trait 定义中的签名(请随意尝试并查看您得到的错误消息)。
在这种情况下,内部的可变性会有所帮助!我们将把sent_messages
存储在RefCell<T>
中,然后send
方法将能够修改sent_messages
来存储我们看到的消息。示例15-22显示了l
15-22
pub trait Messenger {
fn send(&self, msg: &str);
}
pub struct LimitTracker<'a, T: Messenger> {
messenger: &'a T,
value: usize,
max: usize,
}
impl<'a, T> LimitTracker<'a, T>
where
T: Messenger,
{
pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
LimitTracker {
messenger,
value: 0,
max,
}
}
pub fn set_value(&mut self, value: usize) {
self.value = value;
let percentage_of_max = self.value as f64 / self.max as f64;
if percentage_of_max >= 1.0 {
self.messenger.send("Error: You are over your quota!");
} else if percentage_of_max >= 0.9 {
self.messenger
.send("Urgent warning: You've used up over 90% of your quota!");
} else if percentage_of_max >= 0.75 {
self.messenger
.send("Warning: You've used up over 75% of your quota!");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct MockMessenger {
sent_messages: RefCell<Vec<String>>,
}
impl MockMessenger {
fn new() -> MockMessenger {
MockMessenger {
sent_messages: RefCell::new(vec![]),
}
}
}
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
self.sent_messages.borrow_mut().push(String::from(message));
}
}
#[test]
fn it_sends_an_over_75_percent_warning_message() {
// --snip--
let mock_messenger = MockMessenger::new();
let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);
limit_tracker.set_value(80);
assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
}
}
sent_messages
字段的类型现在是RefCell<Vec<String>>
,而不是Vec<String>
。在新函数中,我们围绕空向量创建了一个新的RefCell<Vec<String>>
实例。
对于send
方法的实现,第一个参数仍然是self
的不可变借用,这与trait
定义相匹配。我们在self.sent_messages
中的RefCell<Vec<String>>
上调用borrow_mut
获取在RefCell<Vec<String>>
中的值的一个可变引用。然后,我们可以对vector的可变引用调用push
,以跟踪测试期间发送的消息。
我们必须做的最后一个更改是在断言中:为了查看内部向量中有多少项,我们在RefCell<Vec<String>>
上调用borrow
以获得对该向量的不可变引用。
现在您已经了解了如何使用RefCell<T>
,让我们深入了解它是如何工作的!
15.5.4 使用RefCell<T>
在运行时跟踪借用
在创建不可变和可变引用时,我们分别使用&
和&mut
语法。对于RefCell<T>
,我们使用borrow
和borrow_mut
方法,它们是属于RefCell<T>
的安全API的一部分。borrow
方法返回智能指针类型Ref<T>
, borrow_mut
返回智能指针类型RefMut<T>
。这两种类型都实现了Deref
,因此我们可以将它们视为常规引用。
RefCell<T>
跟踪当前活动的Ref<T>
和RefMut<T>
智能指针的数量。每次调用borrow
时,RefCell<T>
将增加活动的不可变借数的计数。当Ref<T>
值超出作用域时,不可变借数减少1
。就像编译时借用规则一样,RefCell<T>
允许我们在任何时间点有许多不可变借用或一个可变借用。
如果我们试图违反这些规则,而不是像引用那样得到编译器错误,那么RefCell<T>
的实现将在运行时出现panic 。示例15-23显示了对示例15-22中send
实现的修改。为了说明RefCell<T>
阻止我们在运行时这样做,我们故意尝试为同一个作用域创建两个活动的可变借用。
15-23
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
let mut one_borrow = self.sent_messages.borrow_mut();
let mut two_borrow = self.sent_messages.borrow_mut();
one_borrow.push(String::from(message));
two_borrow.push(String::from(message));
}
}
我们为从borrow_mut
返回的RefMut<T>
智能指针创建一个变量one_borrow
。然后在变量two_borrow
中以同样的方式创建另一个可变借用。这使得两个可变引用在同一个作用域中,这是不允许的。当我们为我们的库运行测试时,示例15-23中的代码将在编译时没有任何错误,但测试将失败:
$ cargo test
Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
Finished test [unoptimized + debuginfo] target(s) in 0.91s
Running unittests src/lib.rs (target/debug/deps/limit_tracker-e599811fa246dbde)
running 1 test
test tests::it_sends_an_over_75_percent_warning_message ... FAILED
failures:
---- tests::it_sends_an_over_75_percent_warning_message stdout ----
thread 'main' panicked at 'already borrowed: BorrowMutError', src/lib.rs:60:53
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::it_sends_an_over_75_percent_warning_message
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass '--lib'
请注意,代码panicked,提示already borrowed: BorrowMutError
。这就是RefCell<T>
在运行时处理违反借用规则的结果。
选择在运行时而不是编译时捕获借用错误,就像我们在这里所做的那样,意味着您可能会在开发过程的后期发现代码中的错误:可能直到将代码部署到生产环境中才会发现。此外,由于在运行时而不是编译时跟踪借位,您的代码会导致运行时性能下降。但是,使用RefCell<T>
可以编写一个模拟对象,该对象可以修改自身以跟踪在只允许不可变值的上下文中使用它时所看到的消息。您可以使用RefCell<T>
来获得比常规引用提供的更多的功能。
15.5.5 结合Rc<T>
和RefCell<T>
拥有多个可变数据所有者
使用RefCell<T>
的常用方法是与Rc<T>
结合使用。回想一下,Rc<T>
允许您拥有一些数据的多个所有者,但它只提供对该数据的不可变访问。如果你有一个Rc<T>
持有一个RefCell<T>
,你可以得到一个可以有多个所有者的值,你可以改变它!
例如,回想一下示例15-18中的cons list
示例,其中我们使用Rc<T>
允许多个列表共享另一个列表的所有权。因为Rc<T>
只保存不可变的值,所以一旦创建了列表中的任何值,我们就不能更改它们。让我们添加RefCell<T>
来获得更改列表中的值的能力。示例15-24显示,通过在cons list
中使用RefCell<T>
,我们可以修改存储在所有列表中的值:
#[derive(Debug)]
enum List {
Cons(Rc<RefCell<i32>>, Rc<List>),
Nil,
}
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let value = Rc::new(RefCell::new(5));
let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));
let b = Cons(Rc::new(RefCell::new(3)), Rc::clone(&a));
let c = Cons(Rc::new(RefCell::new(4)), Rc::clone(&a));
*value.borrow_mut() += 10;
println!("a after = {:?}", a);
println!("b after = {:?}", b);
println!("c after = {:?}", c);
}
我们创建一个值,它是Rc<RefCell<i32>>
的实例,并将它存储在一个名为value
的变量中,以便稍后可以直接访问它。然后,我们在包含Cons
变量的a
中创建一个List
,用于保存值。我们需要克隆value
,因此a
和value
都拥有内部5
个value
的所有权,而不是将所有权从value
转移到a
或让a
从value
中借用。
我们将列表a
包装在Rc<T>
中,因此当我们创建列表b
和c
时,它们都可以引用a
,这是我们在示例15-18中所做的。
在创建了a
、b
和c
中的列表之后,我们想给valu
e中的值加上10
。为此,我们调用borrow_mut
on value
,它使用我们在第5章中讨论过的自动解引用特性(参见“->
操作符在哪里?”一节),将Rc<T>
解引用到内部RefCell<T>
值。borrow_mut
方法返回一个RefMut<T>
智能指针,我们对它使用解引用操作符并更改内部值。
When we print a
,b,
and c
, we can see that they all have the modified value of 15 rather than 5:
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
Finished dev [unoptimized + debuginfo] target(s) in 0.63s
Running `target/debug/cons-list`
a after = Cons(RefCell { value: 15 }, Nil)
b after = Cons(RefCell { value: 3 }, Cons(RefCell { value: 15 }, Nil))
c after = Cons(RefCell { value: 4 }, Cons(RefCell { value: 15 }, Nil))
这个技巧非常巧妙!通过使用RefCell<T>
,我们得到了一个外部不可变的List
值。但是我们可以使用RefCell<T>
上的方法,这些方法提供了对其内部可变性的访问,因此我们可以在需要时修改数据。借用规则的运行时检查保护我们免受数据竞争,有时为了数据结构中的这种灵活性而牺牲一点速度是值得的。注意,RefCell<T>
不适用于多线程代码!Mutex<T>
是RefCell<T>
的线程安全版本,我们将在第16章讨论Mutex<T>
。
15.6 引用循环与内存泄漏
Rust的内存安全保证使得意外地创建从未清理的内存(称为内存泄漏)变得很困难,但并非不可能。完全防止内存泄漏并不是Rust的保证之一,这意味着内存泄漏在Rust中是内存安全的。我们可以看到Rust通过使用Rc<T>
和RefCell<T>
允许内存泄漏:可以在循环中创建项目相互引用的引用。这将造成内存泄漏,因为循环中每个项的引用计数永远不会达到0,而且这些值永远不会被删除。
15.6.1 创建引用循环
让我们从示例15-25中List
枚举的定义和tail
方法开始,看看引用循环是如何发生的以及如何防止它:
15-25
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
enum List {
Cons(i32, RefCell<Rc<List>>),
Nil,
}
impl List {
fn tail(&self) -> Option<&RefCell<Rc<List>>> {
match self {
Cons(_, item) => Some(item),
Nil => None,
}
}
}
fn main() {}
我们使用示例15-5中的List
定义的另一个变体。Cons
变体中的第二个元素现在是RefCell<Rc<List>>
,这意味着我们不像在示例15-24中那样能够修改i32值,而是希望修改Cons变体所指向的List
值。我们还添加了一个tail
方法,以便在有Cons
变体的情况下方便地访问第二项。
在示例15-26中,我们添加了一个主函数,该函数使用示例15-25中的定义。这段代码在a
中创建一个列表,在b
中创建一个指向a
中的列表的列表。然后修改a
中的列表以指向b
,创建一个引用循环。有println!
语句,以显示该过程中各个点的引用计数。
15-26
fn main() {
let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));
println!("a initial rc count = {}", Rc::strong_count(&a));
println!("a next item = {:?}", a.tail());
let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));
println!("a rc count after b creation = {}", Rc::strong_count(&a));
println!("b initial rc count = {}", Rc::strong_count(&b));
println!("b next item = {:?}", b.tail());
if let Some(link) = a.tail() {
*link.borrow_mut() = Rc::clone(&b);
}
println!("b rc count after changing a = {}", Rc::strong_count(&b));
println!("a rc count after changing a = {}", Rc::strong_count(&a));
// Uncomment the next line to see that we have a cycle;
// it will overflow the stack
// println!("a next item = {:?}", a.tail());
}
我们创建了一个Rc<List>
实例,该实例在变量a
中包含一个List
值,初始列表为5
,Nil
。然后,我们创建一个Rc<List>
实例,其中包含变量b
中的另一个List
值,该值包含值10
,并指向a
中的列表。
我们修改a
,让它指向b
而不是Nil
,创造了一个循环。为此,我们使用tail
方法获取a
中的RefCell<Rc<List>>
的引用,并将其放入变量link
中。然后,我们在RefCell<Rc<List>>
上使用borrow_mut
方法将内部的值从持有Nil
值的Rc<List>
更改为b
中的Rc<List>
。
当我们运行这段代码时,最后的println!
暂时注释掉,我们将得到这个输出:
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
Finished dev [unoptimized + debuginfo] target(s) in 0.53s
Running `target/debug/cons-list`
a initial rc count = 1
a next item = Some(RefCell { value: Nil })
a rc count after b creation = 2
b initial rc count = 1
b next item = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
b rc count after changing a = 2
a rc count after changing a = 2
在我们将a
中的列表更改为指向b
之后,a
和b
中的Rc<List>
实例的引用计数都是2
。在main
的末尾,Rust
删除变量b
,这将使b
Rc<List>
实例的引用计数从2
减少到1
。此时Rc<List>
在堆上的内存不会被删除,因为它的引用计数是1
,而不是0
。然后Rust删除a
,这也将Rc<List>
实例的引用计数从2
减少到1
。这个实例的内存也不能被删除,因为另一个Rc<List>
实例仍然引用它。分配给列表的内存将永远保持未收集状态。为了可视化这个参考循环,我们创建了一个图15-4所示的图。
如果您取消注释最后的println!
然后运行程序,Rust会尝试打印这个循环,a
指向b
指向a
,以此类推,直到栈溢出。
与现实世界的程序相比,在本例中创建引用循环的结果并不是非常可怕:就在我们创建引用循环之后,程序结束了。然而,如果一个更复杂的程序在一个周期中分配了大量内存,并长时间使用它,那么该程序使用的内存将超过所需的内存,并可能使系统过载,导致可用内存耗尽。
创建引用循环并不容易,但也不是不可能。如果你有RefCell<T>
值,包含Rc<T>
值或类似的嵌套组合类型与内部可变和引用计数,你必须确保你不创建循环;你不能指望Rust去抓他们。创建引用循环可能是程序中的一个逻辑错误,您应该使用自动测试、代码检查和其他软件开发实践来最小化该错误。
避免引用循环的另一个解决方案是重新组织数据结构,使一些引用表达所有权,而一些引用不表达所有权。因此,您可以拥有由一些所有权关系和一些非所有权关系组成的循环,并且只有所有权关系影响是否可以删除值。在示例15-25中,我们总是希望Cons
变量拥有它们的列表,因此不可能重新组织数据结构。让我们看一个示例,该示例使用由父节点和子节点组成的图来查看何时是非所有权关系是合适的