第5章 引用
# 第5章 引用
库不能带来新的限制。 ——马克·米勒(Mark Miller)
到目前为止,我们见过的所有指针类型——简单的堆指针Box<T>
,以及String
和Vec
值内部的指针——都是拥有所有权的指针:当所有者被丢弃时,被指向的对象也会随之被丢弃。Rust还有一种非拥有所有权的指针类型,称为引用(reference),它对所指向对象的生命周期没有影响。
事实上,情况恰恰相反:引用的生命周期绝不能长于它所指向的对象。你必须在代码中明确表明,任何引用都不可能比它指向的值的生命周期更长。为了强调这一点,Rust将创建对某个值的引用称为借用(borrowing)该值:你借的东西,最终必须归还给所有者。
如果你在读到“你必须在代码中明确表明”这句话时心里产生了一丝怀疑,那你并不孤单。引用本身并没有什么特别之处——在底层,它们只是地址。但确保引用安全的规则对Rust来说是全新的;在研究性语言之外,你之前从未见过类似的规则。虽然这些规则是Rust中最难掌握的部分,但它们能在编译时防止大量经典的日常错误,而且对多线程编程也有极大的帮助,不会产生运行时性能损耗。这再次体现了Rust的大胆尝试。
在本章中,我们将深入探讨引用在Rust中的工作原理;展示引用、函数和用户自定义类型如何都包含生命周期信息,以确保它们被安全使用;并举例说明这些措施在编译时就能防止的一些常见错误类型,而且不会带来运行时性能损失。
# 值的引用
例如,假设我们要构建一个文艺复兴时期杀人成性的艺术家及其著名作品的表格。Rust的标准库包含哈希表类型,所以我们可以这样定义我们的类型:
use std::collections::HashMap;
type Table = HashMap<String, Vec<String>>;
2
3
换句话说,这是一个将String
值映射到Vec<String>
值的哈希表,把艺术家的名字映射到他们作品的名字列表。你可以使用for
循环遍历HashMap
的条目,所以我们可以编写一个函数来打印Table
:
fn show(table: Table) {
for (artist, works) in table {
println!("works by {}:", artist);
for work in works {
println!(" {}", work);
}
}
}
2
3
4
5
6
7
8
构建和打印表格很简单:
fn main() {
let mut table = Table::new();
table.insert("Gesualdo".to_string(),
vec!["many madrigals".to_string(),
"Tenebrae Responsoria".to_string()]);
table.insert("Caravaggio".to_string(),
vec!["The Musicians".to_string(),
"The Calling of St. Matthew".to_string()]);
table.insert("Cellini".to_string(),
vec!["Perseus with the head of Medusa".to_string(),
"a salt cellar".to_string()]);
show(table);
}
2
3
4
5
6
7
8
9
10
11
12
13
一切都运行正常:
$ cargo run
Running `/home/jimb/rust/book/fragments/target/debug/fragments`
works by Gesualdo:
many madrigals
Tenebrae Responsoria
works by Cellini:
Perseus with the head of Medusa
a salt cellar
works by Caravaggio:
The Musicians
The Calling of St. Matthew
$
2
3
4
5
6
7
8
9
10
11
12
但是,如果你读过前一章关于移动(move)的部分,这个show
函数的定义应该会让你产生一些疑问。特别要注意的是,HashMap
不是Copy
类型——它不可能是,因为它拥有一个动态分配的表。所以当程序调用show(table)
时,整个结构会被移动到函数中,使得变量table
未初始化。(而且它遍历内容的顺序是不确定的,所以如果你得到的顺序不同,也不用担心。)如果调用代码现在试图使用table
,就会遇到问题:
...
show(table);
assert_eq!(table["Gesualdo"][0], "many madrigals");
2
3
Rust会报错说table
不可用了:
error: borrow of moved value: `table`
|
20 | let mut table = Table::new();
| --------- move occurs because `table` has type
| `HashMap<String, Vec<String>>`,
| which does not implement the `Copy` trait
...
31 | show(table);
| ----- value moved here
^^^^^ value borrowed here after move
32 | assert_eq!(table["Gesualdo"][0], "many madrigals");
2
3
4
5
6
7
8
9
10
11
实际上,如果我们查看show
函数的定义,外层的for
循环会获取哈希表的所有权并完全消耗它;内层的for
循环会对每个向量做同样的操作。(我们之前在“自由、平等、博爱”的例子中见过这种行为。)由于移动语义(move semantics),我们仅仅为了打印这个结构,就把它完全破坏了。多谢了,Rust!
正确的处理方式是使用引用。引用允许你访问一个值,而不会影响其所有权。引用有两种类型:
- 共享引用(shared reference)允许你读取但不能修改它所指向的值。不过,你可以同时拥有任意多个指向同一特定值的共享引用。表达式
&e
会生成对e
值的共享引用;如果e
的类型是T
,那么&e
的类型就是&T
,读作“ref T”。共享引用是Copy
类型。 - 如果你有一个指向某个值的可变引用(mutable reference),那么你既可以读取也可以修改这个值。然而,在同一时刻,你不能有任何其他类型的引用指向该值。表达式
&mut e
会生成对e
值的可变引用;它的类型写作&mut T
,读作“ref mute T”。可变引用不是Copy
类型。
你可以把共享引用和可变引用之间的区别,看作是一种在编译时强制实施多读者或单写者规则(multiple readers or single writer rule)的方式。实际上,这条规则不仅适用于引用;它也适用于被借用值的所有者。只要有对某个值的共享引用,即使是它的所有者也不能修改它;这个值被锁定了。在show
函数使用table
时,没有人能修改它。同样地,如果有一个可变引用指向某个值,那么它对该值拥有独占访问权;在可变引用消失之前,你根本无法使用所有者。事实证明,将共享和可变完全分开对内存安全至关重要,本章后面我们会探讨其中的原因。
在我们的示例中,打印函数不需要修改表格,只需要读取其内容。所以调用者应该能够向它传递表格的共享引用,如下所示:
show(&table);
引用是非拥有所有权的指针,所以table
变量仍然是整个结构的所有者;show
函数只是借用了它一会儿。自然地,我们需要调整show
函数的定义来匹配,不过你得仔细看才能发现其中的差异:
fn show(table: &Table) {
for (artist, works) in table {
println!("works by {}:", artist);
for work in works {
println!(" {}", work);
}
}
}
2
3
4
5
6
7
8
show
函数参数table
的类型从Table
变成了&Table
:我们现在不是按值传递表格(从而将所有权移动到函数中),而是传递一个共享引用。这是唯一的文本变化。但当我们执行函数体时,会有什么不同呢?
我们原来的外层for
循环获取了HashMap
的所有权并消耗了它,而在新版本中,它接收的是对HashMap
的共享引用。遍历对HashMap
的共享引用会生成对每个条目的键和值的共享引用:artist
从String
变成了&String
,works
从Vec<String>
变成了&Vec<String>
。
内层循环也有类似的变化。遍历对向量的共享引用会生成对其元素的共享引用,所以work
现在是&String
。在这个函数中,任何地方都没有发生所有权的转移;只是在传递非拥有所有权的引用。
现在,如果我们想编写一个函数,对每个艺术家的作品进行字母排序,共享引用就不够了,因为共享引用不允许修改。相反,排序函数需要接收表格的可变引用:
fn sort_works(table: &mut Table) {
for (_, works) in table {
works.sort();
}
}
2
3
4
5
并且我们需要传递一个可变引用:
sort_works(&mut table);
这个可变借用赋予了sort_works
函数读取和修改我们的结构的能力,这是向量的sort
方法所要求的。
当我们以将值的所有权移动到函数中的方式将值传递给函数时,我们说我们是按值传递(passed by value)。如果我们传递给函数的是值的引用,我们就说我们是按引用传递(passed by reference)。例如,我们通过将show
函数修改为按引用接收表格,而不是按值接收,从而修复了这个函数。许多语言都有这种区分,但在Rust中尤为重要,因为它明确说明了所有权是如何受到影响的。
# 使用引用
前面的示例展示了引用的一种典型用法:允许函数在不获取所有权的情况下访问或操作一个结构体。但引用的灵活性不止于此,所以让我们通过一些示例来更详细地了解其工作原理。
# Rust引用与C++引用的对比
如果你熟悉C++中的引用,它们和Rust引用确实有一些共同之处。最重要的是,在机器层面,它们本质上都是地址。但在实际使用中,Rust的引用给人的感觉大不相同。
在C++中,引用通过隐式转换创建,并且也会隐式解引用:
// C++ code!
int x = 10;
int &r = x; // initialization creates reference implicitly
assert(r == 10); // implicitly dereference r to see x's value
r = 20; // stores 20 in x, r itself still points to x
2
3
4
5
在Rust中,引用通过&
运算符显式创建,通过*
运算符显式解引用:
// Back to Rust code from this point onward.
let x = 10;
let r = &x; // &x is a shared reference to x
assert!(*r == 10); // explicitly dereference r
2
3
4
要创建可变引用,使用&mut
运算符:
let mut y = 32;
let m = &mut y; // &mut y is a mutable reference to y
*m += 32; // explicitly dereference m to set y's value
assert!(*m == 64); // and to see y's new value
2
3
4
但你可能还记得,在修复show
函数,使其按引用而非按值接收艺术家表格时,我们从未使用过*
运算符。这是为什么呢?
由于引用在Rust中使用非常广泛,在必要时,.
运算符会隐式解引用其左操作数:
struct Anime { name: &'static str, bechdel_pass: bool };
let aria = Anime { name: "Aria: The Animation", bechdel_pass: true };
let anime_ref = &aria;
assert_eq!(anime_ref.name, "Aria: The Animation");
// Equivalent to the above, but with the dereference written out:
assert_eq!((*anime_ref).name, "Aria: The Animation");
2
3
4
5
6
show
函数中使用的println!
宏展开后的代码会使用.
运算符,所以它也利用了这种隐式解引用。
在进行方法调用时,如果需要,.
运算符还可以隐式借用其左操作数的引用。例如,Vec
的sort
方法接收一个指向向量的可变引用,所以下面这两个调用是等效的:
let mut v = vec![1973, 1968];
v.sort(); // implicitly borrows a mutable reference to v
(&mut v).sort(); // equivalent, but more verbose
2
3
简而言之,C++在引用和左值(即引用内存位置的表达式)之间进行隐式转换,这些转换会在需要的任何地方出现;而在Rust中,你使用&
和*
运算符来创建和遵循引用,不过.
运算符是个例外,它会隐式借用和解引用。
# 引用赋值
将一个引用赋给一个变量,会使该变量指向新的位置:
let x = 10;
let y = 20;
let mut r = &x;
if b { r = &y; }
assert!(*r == 10 || *r == 20);
2
3
4
5
引用r
最初指向x
。但如果b
为true
,代码会使它指向y
,如图5-1所示。
图5-1 引用
r
现在指向y
而非x
这种行为可能看起来太过明显,不值一提:当然r
现在指向y
了,因为我们把&y
赋给了它。但我们指出这一点,是因为C++引用的行为截然不同:如前所述,在C++中给引用赋值,是将值存储到它所指向的对象中。一旦C++引用被初始化,就无法让它指向其他任何东西。
# 引用的引用
Rust允许存在引用的引用:
struct Point { x: i32, y: i32 }
let point = Point { x: 1000, y: 729 };
let r: &Point = &point;
let rr: &&Point = &r;
let rrr: &&&Point = &rr;
2
3
4
5
(为了清晰起见,我们写出了引用类型,但你也可以省略,这里的类型Rust都能自行推断。).
运算符会沿着所需数量的引用找到目标:
assert_eq!(rrr.y, 729);
在内存中,这些引用的排列如图5-2所示。
图5-2 引用的引用链
所以表达式rrr.y
,根据rrr
的类型,实际上要遍历三层引用才能找到Point
,然后获取其y
字段的值。
# 比较引用
和.
运算符一样,Rust的比较运算符也会“穿透”任意数量的引用:
let x = 10;
let y = 10;
let rx = &x;
let ry = &y;
let rrx = ℞
let rry = &ry;
assert!(rrx <= rry);
assert!(rrx == rry);
2
3
4
5
6
7
8
这里的最后一个断言会成功,尽管rrx
和rry
指向不同的值(即rx
和ry
),因为==
运算符会沿着所有引用,对它们最终指向的目标x
和y
进行比较。这几乎总是你想要的行为,尤其是在编写泛型函数时。如果你确实想知道两个引用是否指向同一块内存,可以使用std::ptr::eq
,它会把引用当作地址进行比较:
assert!(rx == ry); // their referents are equal
assert!( !std::ptr::eq(rx, ry)); // but occupy different addresses
2
注意,比较的操作数必须具有完全相同的类型,包括引用:
assert!(rx == rrx); // error: type mismatch: `&i32` vs `&&i32`
assert!(rx == *rrx); // this is okay
2
# 引用永远不为空
Rust引用永远不会为空。它没有与C语言的NULL
或C++的nullptr
类似的概念。引用没有默认的初始值(在变量被初始化之前,你不能使用它,无论其类型是什么),并且Rust不会将整数转换为引用(在不安全代码之外),所以你无法将零转换为引用。
C和C++代码经常使用空指针来表示值的缺失:例如,malloc
函数要么返回一个指向新内存块的指针,如果没有足够的内存来满足请求,则返回nullptr
。在Rust中,如果你需要一个既可以是指向某个东西的引用,也可以什么都不是的值,可以使用Option<&T>
类型。在机器层面,Rust将None
表示为一个空指针,而Some(r)
(其中r
是一个&T
值)表示为非零地址,所以Option<&T>
与C或C++中的可空指针一样高效,而且更安全:它的类型要求你在使用它之前检查它是否为None
。
# 借用任意表达式的引用
C和C++只允许你对特定类型的表达式使用&
运算符,而Rust允许你借用任何类型表达式的值的引用:
fn factorial(n: usize) -> usize {
(1..n + 1).product()
}
let r = &factorial(6);
// 算术运算符可以穿透一层引用。
assert_eq!(r + &1009, 1729);
2
3
4
5
6
在这种情况下,Rust只是创建一个匿名变量来保存表达式的值,并让引用指向该变量。这个匿名变量的生命周期取决于你对引用的使用方式:
- 如果你在
let
语句中立即将引用赋给一个变量(或者让它成为某个正在被立即赋值的结构体或数组的一部分),那么Rust会让这个匿名变量的生命周期与let
初始化的变量的生命周期一样长。在前面的例子中,Rust会为r
所指向的对象这样做。 - 否则,匿名变量的生命周期持续到包含它的语句结束。在我们的例子中,为保存
1009
而创建的匿名变量只持续到assert_eq!
语句结束。
如果你习惯了C或C++,这听起来可能容易出错。但请记住,Rust永远不会让你编写会产生悬空引用(dangling reference)的代码。如果引用可能在匿名变量的生命周期之外被使用,Rust总会在编译时向你报告这个问题。然后你可以修改代码,将被引用的对象保存在一个具有适当生命周期的命名变量中。
# 切片引用和特征对象引用
到目前为止,我们展示的引用都是简单的地址。然而,Rust还包括两种胖指针(fat pointer),它们是双字值,既包含某个值的地址,也包含使用该值所需的一些额外信息。
切片引用是一种胖指针,它携带切片的起始地址和长度。我们在第3章详细介绍了切片。
Rust的另一种胖指针是特征对象(trait object),它是对实现了某个特征的对象的引用。特征对象携带一个值的地址,以及一个指向适用于该值的特征实现的指针,用于调用特征的方法。我们将在“特征对象”部分详细介绍特征对象。
除了携带这些额外的数据,切片引用和特征对象引用的行为与我们在本章中展示的其他类型的引用一样:它们不拥有所指向的对象,其生命周期不能长于所指向的对象,它们可以是可变的或共享的,等等 。
# 引用安全
到目前为止,我们所介绍的引用看起来与C或C++中的普通指针非常相似。但那些指针是不安全的,Rust是如何控制其引用的呢?或许,观察这些规则实际作用的最佳方式就是尝试去打破它们。
为了阐述基本概念,我们将从最简单的情况开始,展示Rust如何确保在单个函数体内正确使用引用。然后,我们会探讨在函数之间传递引用以及将引用存储在数据结构中的情况。这需要为相关函数和数据类型赋予生命周期参数(lifetime parameter),我们将对此进行解释。最后,我们会介绍Rust提供的一些简化常见使用模式的快捷方式。在整个过程中,我们将展示Rust如何指出代码中的错误,并且通常还会给出解决方案。
# 借用局部变量
这是一个非常明显的例子。你不能借用对局部变量的引用,并将其带出该变量的作用域:
{
let r;
{
let x = 1;
r = &x;
}
assert_eq!(*r, 1); // bad: reads memory `x` used to occupy
}
2
3
4
5
6
7
8
Rust编译器会拒绝这个程序,并给出详细的错误信息:
error: `x` does not live long enough
--> references_dangling.rs:8:5
|
7 | r = &x;
| ^^ borrowed value does not live long enough
8 | }
- `x` dropped here while still borrowed
9 | assert_eq!(*r, 1); // bad: reads memory `x` used to occupy
10 | }
2
3
4
5
6
7
8
9
Rust的报错信息表明,x
的生命周期只持续到内层代码块结束,而引用的生命周期却持续到外层代码块结束,这使得该引用成为一个悬空指针(dangling pointer),这是不允许的。
虽然人类读者一眼就能看出这个程序是错误的,但了解Rust是如何得出这个结论的还是很有价值的。即使是这个简单的例子,也展示了Rust用于检查更复杂代码的逻辑方法。
Rust会尝试为程序中的每个引用类型分配一个满足其使用方式所施加约束的生命周期(lifetime)。生命周期是指程序中一段引用可以安全使用的时间段:可以是一条语句、一个表达式、某个变量的作用域等等。
生命周期完全是Rust在编译时虚构出来的概念。在运行时,引用仅仅是一个地址;它的生命周期是其类型的一部分,在运行时没有实际表示。
在这个例子中,有三个生命周期的关系需要我们梳理清楚。变量r
和x
都有各自的生命周期,从它们被初始化的位置开始,一直到编译器能够证明它们不再被使用的位置结束。第三个生命周期属于引用类型:即我们借用指向x
并存储在r
中的引用的类型。
有一个约束应该很容易理解:如果你有一个变量x
,那么对x
的引用的生命周期一定不能超过x
本身的生命周期,如图5-3所示。
图5-3
&x
允许的生命周期范围
在x
超出作用域之后,该引用就会成为一个悬空指针。我们说变量的生命周期必须包含或包围从它那里借用的引用的生命周期。
还有另一种约束:如果你将一个引用存储在变量r
中,那么这个引用的类型在变量r
的整个生命周期内(从初始化到最后一次使用)都必须是有效的,如图5-4所示。
图5-4 存储在
r
中的引用允许的生命周期范围
如果引用的生命周期至少不能和变量的生命周期一样长,那么在某个时刻r
就会成为一个悬空指针。我们说引用的生命周期必须包含或包围变量的生命周期。
第一种约束限制了引用生命周期的最大范围,而第二种约束限制了其最小范围。Rust只是尝试为每个引用找到一个满足所有这些约束的生命周期。然而,在我们的例子中,并不存在这样的生命周期,如图5-5所示。
图5-5 对生命周期有矛盾约束的引用
现在让我们来看一个能够正常运行的不同例子。我们有同样类型的约束:引用的生命周期必须包含在x
的生命周期内,但又要完全包围r
的生命周期。不过因为现在r
的生命周期更短了,所以存在一个满足这些约束的生命周期,如图5-6所示。
图5-6 生命周期包围
r
的作用域且在x
的作用域内的引用
当你借用对某个更大数据结构的一部分(比如向量中的一个元素)的引用时,这些规则同样自然适用:
let v = vec![1, 2, 3];
let r = &v[1];
2
由于v
拥有这个向量,而向量拥有其元素,所以v
的生命周期必须包围&v[1]
引用类型的生命周期。同样地,如果你将一个引用存储在某个数据结构中,该引用的生命周期必须包围这个数据结构的生命周期。例如,如果你构建一个引用向量,所有这些引用的生命周期都必须包围拥有该向量的变量的生命周期。
这就是Rust对所有代码使用的处理过程的核心。引入更多的语言特性(例如数据结构和函数调用)会带来新类型的约束,但基本原则是相同的:首先,理解程序使用引用的方式所产生的约束;然后,找到满足这些约束的生命周期。这与C和C++程序员自行遵循的过程并没有太大不同;不同之处在于,Rust了解这些规则并会强制执行。
# 函数参数接收引用
当我们将引用传递给函数时,Rust是如何确保函数安全使用它的呢?假设我们有一个函数f
,它接收一个引用并将其存储在全局变量中。我们需要对代码做一些修改,下面是初步尝试:
// 这段代码有几个问题,无法编译。
static mut STASH: &i32;
fn f(p: &i32) { STASH = p; }
2
3
Rust中与全局变量等价的是静态变量(static):它是程序启动时创建,一直持续到程序结束的值。(与其他任何声明一样,Rust的模块系统控制着静态变量的可见范围,所以它们只是在生命周期上是“全局的”,而非在可见性上。)我们会在第8章介绍静态变量,目前我们只指出上述代码未遵循的几条规则:
- 每个静态变量都必须被初始化。
- 可变静态变量本质上是线程不安全的(毕竟,任何线程在任何时候都可以访问静态变量),即使在单线程程序中,它们也可能会受到其他类型的重入问题的影响。出于这些原因,你只能在不安全块(unsafe block)内访问可变静态变量。在这个例子中,我们不关心这些特定问题,所以我们直接添加一个不安全块,继续往下进行。
修改后,代码如下:
static mut STASH: &i32 = &128;
fn f(p: &i32) { // 仍然不够好
unsafe {
STASH = p;
}
}
2
3
4
5
6
差不多了。为了发现剩余的问题,我们需要写出一些Rust贴心地让我们省略的内容。这里写的f
函数签名实际上是下面这种写法的简写:
fn f<'a>(p: &'a i32) { ... }
这里,生命周期'a
(读作“tick A”)是f
函数的生命周期参数(lifetime parameter)。你可以将<'a>
读作“对于任意生命周期'a
”,所以当我们写成fn f<'a>(p: &'a i32)
时,我们定义的是一个接收指向i32
且具有任意给定生命周期'a
的引用的函数。
由于我们必须允许'a
为任意生命周期,那么当它是可能的最短生命周期(即仅包围对f
的调用的生命周期)时,也应该能正常工作。但这样一来,下面这个赋值就会产生问题:
STASH = p;
因为STASH
的生命周期贯穿程序的整个执行过程,它所保存的引用类型的生命周期也必须与之相同;Rust将这种生命周期称为'static
生命周期。但是p
引用的生命周期是某个'a
,只要它包围对f
的调用,'a
可以是任意生命周期。所以,Rust拒绝我们的代码:
error: explicit lifetime required in the type of `p`
|
---- help: add explicit lifetime `'static`
| to the type of `p`: `&'static i32`
5 | fn f(p: &i32) { // still not good enough
6 | unsafe {
7 | STASH = p;
| ^ lifetime `'static` required
2
3
4
5
6
7
8
此时很明显,我们的函数不能接受任意引用作为参数。但正如Rust指出的,它应该能够接受具有'static
生命周期的引用:将这样的引用存储在STASH
中不会产生悬空指针。实际上,下面的代码编译完全没问题:
static mut STASH: &i32 = &10;
fn f(p: &'static i32) {
unsafe {
STASH = p;
}
}
2
3
4
5
6
这一次,f
的签名明确表示p
必须是具有'static
生命周期的引用,所以将其存储在STASH
中不再有问题。我们只能将f
应用于指向其他静态变量的引用,但无论如何,这是唯一可以确保不会让STASH
成为悬空指针的做法。所以我们可以这样写:
static WORTH_POINTING_AT: i32 = 1000;
f(&WORTH_POINTING_AT);
2
因为WORTH_POINTING_AT
是一个静态变量,&WORTH_POINTING_AT
的类型是&'static i32
,将其传递给f
是安全的。
不过,回过头来注意一下,在我们逐步修改代码使其正确的过程中,f
函数的签名发生了什么变化:最初的f(p: &i32)
最终变成了f(p: &'static i32)
。换句话说,如果不在函数签名中体现出意图,我们就无法写出一个将引用存储在全局变量中的函数。在Rust中,函数的签名总是会反映出函数体的行为。
相反,如果我们看到一个函数签名像g(p: &i32)
(或者完整写出生命周期的g<'a>(p: &'a i32)
),我们就可以判断它不会将参数p
存储在任何生命周期超过调用的地方。无需查看g
的定义,仅从签名就能知道g
对其参数能做什么、不能做什么。当你试图确定对该函数调用的安全性时,这一点非常有用。
# 向函数传递引用
既然我们已经了解了函数签名与其函数体的关系,现在来看看它与函数调用者之间的联系。假设你有以下代码:
// 这段代码可以写得更简洁:fn g(p: &i32),
// 但目前我们把生命周期写出来。
fn g<'a>(p: &'a i32) { ... }
let x = 10;
g(&x);
2
3
4
5
仅从g
的签名,Rust就知道它不会将p
存储在任何生命周期可能超过调用的地方:任何包围调用的生命周期都必须适用于'a
。所以Rust为&x
选择了尽可能短的生命周期:即对g
的调用的生命周期。这满足了所有约束条件:它不会超过x
的生命周期,并且包围了对g
的整个调用。所以这段代码是没问题的。
注意,虽然g
有一个生命周期参数'a
,但在调用g
时我们无需提及它。只有在定义函数和类型时才需要考虑生命周期参数;在使用它们时,Rust会为你推断生命周期。
如果我们尝试将&x
传递给之前那个把参数存储在静态变量中的函数f
会怎样呢?
fn f(p: &'static i32) { ... }
let x = 10;
f(&x);
2
3
这段代码无法编译:引用&x
的生命周期不能超过x
,但将它传递给f
,就限制了它的生命周期至少要和'static
一样长。这里无法满足所有要求,所以Rust会拒绝这段代码。
# 返回引用
函数接收一个指向某个数据结构的引用,然后返回指向该结构中某个部分的引用,这种情况很常见。例如,下面这个函数返回指向切片中最小元素的引用:
// v应该至少有一个元素。
fn smallest(v: &[i32]) -> &i32 {
let mut s = &v[0];
for r in &v[1..] {
if *r < *s {
s = r;
}
}
s
}
2
3
4
5
6
7
8
9
10
我们像往常一样在函数签名中省略了生命周期。当函数接收一个引用作为参数并返回一个引用时,Rust假定这两个引用的生命周期必须相同。明确写出的话,代码如下:
fn smallest<'a>(v: &'a [i32]) -> &'a i32 { ... }
假设我们这样调用smallest
:
let s;
{
let parabola = [9, 4, 1, 0, 1, 4, 9];
s = smallest(¶bola);
}
assert_eq!(*s, 0); // bad: points to element of dropped array
2
3
4
5
6
从smallest
的签名中,我们可以看到它的参数和返回值必须有相同的生命周期'a
。在我们的调用中,参数¶bola
的生命周期不能超过parabola
本身,然而smallest
的返回值的生命周期必须至少和s
一样长。不存在能同时满足这两个约束条件的生命周期'a
,所以Rust会拒绝这段代码:
error: `parabola` does not live long enough
--> references_lifetimes_propagated.rs:12:5
|
-------- borrow occurs here
^ `parabola` dropped here while still borrowed
11 | s = smallest(¶bola);
12 | }
13 | assert_eq!(*s, 0); // bad: points to element of dropped array
| - borrowed value needs to live until here
14 | }
2
3
4
5
6
7
8
9
10
将s
的位置移动,使其生命周期明显包含在parabola
的生命周期内,就能解决这个问题:
{
let parabola = [9, 4, 1, 0, 1, 4, 9];
let s = smallest(¶bola);
assert_eq!(*s, 0); // fine: parabola still alive
}
2
3
4
5
函数签名中的生命周期让Rust能够评估传递给函数的引用和函数返回的引用之间的关系,确保它们被安全使用。
# 包含引用的结构体
Rust如何处理存储在数据结构中的引用呢?下面是我们之前看过的错误代码,只是这次我们把引用放在了结构体中:
// 这段代码无法编译。
struct S {
r: &i32
}
let s;
{
let x = 10;
s = S { r: &x };
}
assert_eq!(*s.r, 10); // bad: reads from dropped `x`
2
3
4
5
6
7
8
9
10
Rust对引用设置的安全约束并不会因为我们把引用藏在结构体中就神奇地消失。不知怎的,这些约束最终也必须应用到S
上。实际上,Rust对此持怀疑态度:
error[E0106]: missing lifetime specifier
--> references_in_struct.rs:7:12
|
7 | r: &i32
| ^ expected lifetime parameter
2
3
4
5
每当引用类型出现在其他类型的定义中时,你都必须写出它的生命周期。你可以这样写:
struct S {
r: &'static i32
}
2
3
这表示r
只能指向那些生命周期与程序相同的i32
值,这相当受限。
另一种方法是给类型一个生命周期参数'a
,并将其用于r
:
struct S<'a> {
r: &'a i32
}
2
3
现在S
类型就有了一个生命周期,就像引用类型一样。你创建的每个S
类型的值都有一个新的生命周期'a
,这个生命周期会根据你使用该值的方式受到约束。存储在r
中的任何引用的生命周期最好包围'a
,并且'a
必须比存储S
的地方的生命周期长。
回到前面的代码,表达式S { r: &x }
创建了一个具有某个生命周期'a
的新的S
值。当你将&x
存储在r
字段中时,你将'a
的生命周期限制在完全在x
的生命周期内。
赋值s = S {... }
将这个S
存储在一个变量中,该变量的生命周期一直延续到示例结束,这就限制了'a
的生命周期要比s
的生命周期长。现在Rust遇到了和之前一样相互矛盾的约束:'a
的生命周期不能超过x
,但又必须至少和s
的生命周期一样长。不存在令人满意的生命周期,所以Rust拒绝这段代码。
危机解除!
当一个带有生命周期参数的类型被放在其他类型中时会怎样呢?
struct D {
s: S // not adequate
}
2
3
Rust同样持怀疑态度,就像我们之前在S
中放置引用却未指定其生命周期时一样:
error[E0106]: missing lifetime specifier
--> <source>:8:8
|
8 | s: S // not adequate
| ^ expected named lifetime parameter
2
3
4
5
这里我们不能省略S
的生命周期参数:Rust需要知道D
的生命周期与它内部S
中的引用的生命周期之间的关系,以便对D
进行和对S
以及普通引用一样的检查。
我们可以给s
赋予'static
生命周期。这样做可行:
struct D {
s: S<'static>
}
2
3
按照这个定义,s
字段只能借用那些在程序整个执行过程中都存在的值。这有点限制,但这确实意味着D
不可能借用局部变量;对D
的生命周期也没有特殊限制。
实际上,Rust的错误信息提示了另一种更通用的方法:
help: consider introducing a named lifetime parameter
|
7 | struct D<'a> {
8 | s: S<'a>
|
2
3
4
5
在这里,我们给D
自己的生命周期参数,并将其传递给S
:
struct D<'a> {
s: S<'a>
}
2
3
通过使用生命周期参数'a
并将其用于s
的类型,我们让Rust能够将D
值的生命周期与它内部S
持有的引用的生命周期联系起来。
我们之前展示了函数签名如何揭示它对我们传递的引用的操作。现在我们展示了类型也有类似的情况:类型的生命周期参数总是会揭示它是否包含具有特殊(即非'static
)生命周期的引用,以及这些生命周期可能是什么。
例如,假设我们有一个解析函数,它接收一个字节切片并返回一个包含解析结果的结构体:
fn parse_record<'i>(input: &'i [u8]) -> Record<'i> { ... }
即使完全不查看Record
类型的定义,我们也能知道,如果从parse_record
函数中得到一个Record
,它包含的任何引用都必须指向我们传入的输入缓冲区,而不会指向其他地方(可能除了'static
值)。
实际上,Rust要求包含引用的类型使用显式生命周期参数,原因就在于这种对内部行为的揭示。Rust并非不能简单地为结构体中的每个引用生成一个不同的生命周期,从而省去你书写它们的麻烦。早期版本的Rust实际上就是这样做的,但开发者们发现这很容易让人困惑:知道一个值何时从另一个值借用数据是很有帮助的,尤其是在排查错误的时候。
不只是引用和像S
这样的类型有生命周期。Rust中的每个类型都有生命周期,包括i32
和String
。大多数类型的生命周期只是'static
,这意味着这些类型的值可以根据你的需要存在任意长的时间;例如,Vec<i32>
是自包含的,无需在任何特定变量超出作用域之前被丢弃。但是像Vec<&'a i32>
这样的类型,其生命周期必须被'a
包围:它必须在其引用的对象仍然存活时被丢弃。
# 不同的生命周期参数
假设你定义了一个包含两个引用的结构体,如下所示:
struct S<'a> {
x: &'a i32,
y: &'a i32
}
2
3
4
两个引用都使用相同的生命周期'a
。如果你的代码想要这样做,可能会出现问题:
let x = 10;
let r;
{
let y = 20;
{
let s = S { x: &x, y: &y };
r = s.x;
}
}
println!("{}", r);
2
3
4
5
6
7
8
9
10
这段代码不会创建任何悬空指针。对y
的引用保存在s
中,s
在y
之前超出作用域。对x
的引用最终保存在r
中,r
的生命周期不会超过x
。
然而,如果你尝试编译这段代码,Rust会抱怨y
的生命周期不够长,即使显然它是足够的。
Rust为什么会担心呢?如果你仔细分析代码,就能理解它的逻辑:
S
的两个字段都是具有相同生命周期'a
的引用,所以Rust必须找到一个对s.x
和s.y
都适用的单一生命周期。- 我们执行
r = s.x
,这要求'a
包围r
的生命周期。 - 我们用
&y
初始化s.y
,这要求'a
不超过y
的生命周期。
这些约束条件无法同时满足:不存在比y
的作用域短但比r
的生命周期长的生命周期。Rust拒绝编译。
问题在于S
中的两个引用具有相同的生命周期'a
。修改S
的定义,让每个引用有不同的生命周期,就能解决所有问题:
struct S<'a, 'b> {
x: &'a i32,
y: &'b i32
}
2
3
4
按照这个定义,s.x
和s.y
有独立的生命周期。我们对s.x
的操作不会影响s.y
中存储的内容,所以现在很容易满足约束条件:'a
可以就是r
的生命周期,'b
可以是s
的生命周期。(y
的生命周期也可以作为'b
,但Rust会尝试选择最短的有效生命周期。)一切都没问题了。
函数签名也可能有类似的影响。假设我们有这样一个函数:
fn f<'a>(r: &'a i32, s: &'a i32) -> &'a i32 { r } // perhaps too tight
这里,两个引用参数都使用相同的生命周期'a
,这可能会像我们之前展示的那样,不必要地限制调用者。如果这是个问题,你可以让参数的生命周期独立变化:
fn f<'a, 'b>(r: &'a i32, s: &'b i32) -> &'a i32 { r } // looser
这样做的缺点是,增加生命周期会使类型和函数签名更难阅读。本书的作者倾向于先尝试最简单的定义,然后逐步放宽限制,直到代码能够编译。由于Rust不会允许不安全的代码运行,所以等待Rust指出问题所在是一种完全可行的策略。
# 省略生命周期参数
到目前为止,本书中展示了很多返回引用或接收引用作为参数的函数,但我们通常不需要明确指出每个生命周期。生命周期是存在的,只是在生命周期很明显的情况下,Rust允许我们省略它们。
在最简单的情况下,你可能永远不需要为参数写出生命周期。Rust会为每个需要生命周期的地方分配一个不同的生命周期。例如:
struct S<'a, 'b> {
x: &'a i32,
y: &'b i32
}
fn sum_xy(r: &i32, s: S) -> i32 {
r + s.x + s.y
}
2
3
4
5
6
7
8
这个函数的签名是以下写法的简写:
fn sum_xy<'a, 'b, 'c>(r: &'a i32, s: S<'b, 'c>) -> i32
如果你返回引用或其他带有生命周期参数的类型,Rust仍然会尽量简化明确的情况。如果函数参数中只出现一个生命周期,那么Rust会假定返回值中的任何生命周期都必须是这个生命周期:
fn first_third(point: &[i32; 3]) -> (&i32, &i32) {
(&point[0], &point[2])
}
2
3
完整写出所有生命周期的等效代码是:
fn first_third<'a>(point: &'a [i32; 3]) -> (&'a i32, &'a i32)
如果参数中有多个生命周期,那么对于返回值来说,没有自然的理由偏向其中某一个,Rust会要求你明确写出具体情况。
如果你的函数是某个类型的方法,并且通过引用接收self
参数,那么这就打破了这种不确定性:Rust假定self
的生命周期就是返回值中所有内容的生命周期。(self
参数指的是调用方法的那个值,它相当于C++、Java或JavaScript中的this
,或者Python中的self
。我们将在“用impl
定义方法”中介绍方法。)
例如,你可以这样写:
struct StringTable {
elements: Vec<String>,
}
impl StringTable {
fn find_by_prefix(&self, prefix: &str) -> Option<&String> {
for i in 0..self.elements.len() {
if self.elements[i].starts_with(prefix) {
return Some(&self.elements[i]);
}
}
None
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
find_by_prefix
方法的签名是以下写法的简写:
fn find_by_prefix<'a, 'b>(&'a self, prefix: &'b str) -> Option<&'a String>
Rust假定你借用的任何内容都是从self
借用的。
同样,这些只是缩写,旨在提供便利且不会带来意外情况。如果你不想要这种缩写,总是可以显式写出生命周期。
# 共享与可变
到目前为止,我们讨论了Rust如何确保引用永远不会指向已经超出作用域的变量。但是,还有其他方式会引入悬空指针。下面是一个简单的例子:
let v = vec![4, 8, 19, 27, 34, 10];
let r = &v;
let aside = v; // move vector to aside
r[0]; // bad: uses `v`, which is now uninitialized
2
3
4
对aside
的赋值移动了向量,使v
未初始化,并且将r
变成了悬空指针,如图5-7所示。
图5-7 指向已被移动走的向量的引用
虽然在r
的整个生命周期内,v
都在作用域内,但这里的问题是v
的值被移动到了其他地方,在r
仍然指向它时,v
被置为未初始化状态。自然地,Rust捕获到了这个错误:
error[E0505]: cannot move out of `v` because it is borrowed
--> references_sharing_vs_mutation_1.rs:10:9
|
9 | let r = &v;
| - borrow of `v` occurs here
^^^^^ move out of `v` occurs here
10 | let aside = v; // move vector to aside
2
3
4
5
6
7
在共享引用的整个生命周期内,它会使所指向的对象为只读状态:你不能对所指向的对象进行赋值,也不能将其值移动到其他地方。在这段代码中,r
的生命周期包含了移动向量的操作,所以Rust拒绝了这个程序。如果你像这样修改程序,就不会有问题:
let v = vec![4, 8, 19, 27, 34, 10];
{
let r = &v;
r[0]; // ok: vector is still there
}
let aside = v;
2
3
4
5
6
在这个版本中,r
更早地超出了作用域,引用的生命周期在v
被移动之前就结束了,一切正常。
还有一种不同的会引发问题的情况。假设我们有一个方便的函数,用于将切片中的元素添加到向量中:
fn extend(vec: &mut Vec<f64>, slice: &[f64]) {
for elt in slice {
vec.push(*elt);
}
}
2
3
4
5
这是标准库中向量的extend_from_slice
方法的一个灵活性较差(并且优化程度低很多)的版本。我们可以用它从其他向量或数组的切片构建一个向量:
let mut wave = Vec::new();
let head = vec![0.0, 1.0];
let tail = [0.0, -1.0];
extend(&mut wave, &head); // extend wave with another vector
extend(&mut wave, &tail); // extend wave with an array
assert_eq!(wave, vec![0.0, 1.0, 0.0, -1.0]);
2
3
4
5
6
这样我们就构建出了正弦波的一个周期。如果我们想再添加一个波动,能将向量追加到它自身吗?
extend(&mut wave, &wave);
assert_eq!(wave, vec![0.0, 1.0, 0.0, -1.0,
0.0, 1.0, 0.0, -1.0]);
2
3
乍一看,这似乎没问题。但请记住,当我们向向量中添加元素时,如果它的缓冲区已满,就必须分配一个更大的新缓冲区。假设wave
最初有容纳四个元素的空间,那么当extend
尝试添加第五个元素时,就必须分配一个更大的缓冲区。内存最终会如图5-8所示。
extend
函数的vec
参数借用了wave
(由调用者拥有),wave
为自己分配了一个能容纳八个元素的新缓冲区。但是slice
仍然指向已被丢弃的旧的四个元素的缓冲区。
图5-8 因向量重新分配内存而变成悬空指针的切片
这类问题并非Rust所独有:在很多语言中,在指向集合的同时修改集合都是需要小心处理的情况。在C++中,std::vector
规范提醒你,“[向量缓冲区的]重新分配会使指向序列中元素的所有引用、指针和迭代器失效”。类似地,Java在修改java.util.Hashtable
对象时提到:“如果在创建迭代器之后的任何时候,以迭代器自身的remove
方法之外的任何方式对Hashtable
进行结构修改,迭代器将抛出ConcurrentModificationException
异常”。
这类错误特别棘手的地方在于,它并非总是会出现。在测试时,你的向量可能总是恰好有足够的空间,缓冲区可能永远不会重新分配,问题也就永远不会暴露出来。
然而,Rust在编译时就报告了我们调用extend
时的问题:
error[E0502]: cannot borrow `wave` as immutable because it is also borrowed as mutable
--> references_sharing_vs_mutation_2.rs:9:24
|
| ^^^^- mutable borrow ends here
9 | extend(&mut wave, &wave);
| | |
| | immutable borrow occurs here
| mutable borrow occurs here
2
3
4
5
6
7
8
换句话说,我们可以借用向量的可变引用,也可以借用其元素的共享引用,但这两个引用的生命周期不能重叠。在我们的例子中,两个引用的生命周期都包含对extend
的调用,所以Rust拒绝了这段代码。
这些错误都源于违反了Rust关于可变和共享的规则:
- 共享访问是只读访问:共享引用借用的值是只读的。在共享引用的整个生命周期内,它所指向的对象,以及从该对象可达的任何内容,都不能被任何操作修改。该结构中任何内容都不存在有效的可变引用,其所有者也被视为只读,等等。它实际上是被冻结的。
- 可变访问是独占访问:通过可变引用借用的值,只能靠这个可变引用来访问。在可变引用存在的整个期间,没办法通过其他途径访问它所指向的值,也不能访问从这个值能访问到的其他值。只有从这个可变引用本身借用来的引用,其生命周期才可以和这个可变引用的生命周期有重叠,其他引用都不行。
Rust把extend
函数的例子当作违反第二条规则的情况来处理:因为我们借用了wave
的可变引用,那么这个可变引用就必须是访问向量或其元素的唯一途径。而对切片的共享引用本身就是另一种访问元素的方式,这就违反了第二条规则。
但Rust也可以把这个错误当作违反第一条规则来处理:因为我们借用了wave
元素的共享引用,那么这些元素和Vec
本身就都是只读的。你不能对只读的值借用可变引用。
每种引用都会影响我们对沿着指向被引用对象的所有权路径上的值,以及从被引用对象可达的值的操作(图5-9)。
图5-9 借用引用会影响你对同一所有权树中其他值的操作
注意,在这两种情况下,在引用的生命周期内,指向被引用对象的所有权路径都不能改变。对于共享借用,这条路径是只读的;对于可变借用,这条路径是完全不可访问的。所以程序无法做出任何会使引用无效的操作。
把这些原则用最简单的例子来展示:
let mut x = 10;
let r1 = &x;
let r2 = &x;
x += 10; // ok: multiple shared borrows permitted
let m = &mut x; // error: cannot assign to `x` because it is borrowed
// error: cannot borrow `x` as mutable because it is also borrowed as immutable
println!("{}, {}, {}", r1, r2, m); // the references are used here, so their lifetimes must last at least this long
let mut y = 20;
let m1 = &mut y;
let m2 = &mut y; // error: cannot borrow as mutable more than once
let z = y; // error: cannot use `y` because it was mutably borrowed
println!("{}, {}, {}", m1, m2, z); // references are used here
2
3
4
5
6
7
8
9
10
11
12
13
从共享引用再借用一个共享引用是可以的:
let mut w = (107, 109);
let r = &w;
let r0 = &r.0; // ok: reborrowing shared as shared
let m1 = &mut r.1; // error: can't reborrow shared as mutable
println!("{}", r0); // r0 gets used here
2
3
4
5
你也可以从可变引用进行再借用:
let mut v = (136, 139);
let m = &mut v;
let m0 = &mut m.0; // ok: reborrowing mutable from mutable
*m0 = 137;
let r1 = &m.1; // ok: reborrowing shared from mutable, and doesn't overlap with m0
v.1; // error: access through other paths still forbidden
println!("{}", r1); // r1 gets used here
2
3
4
5
6
7
8
这些限制相当严格。回到我们之前尝试调用的extend(&mut wave, &wave)
,没有简单快捷的方法能修改代码使其按我们期望的方式运行。而且Rust在所有地方都应用这些规则:比如,如果我们借用了HashMap
中某个键的共享引用,那么在这个共享引用的生命周期结束之前,我们不能借用HashMap
的可变引用。
但这样做是有充分理由的:设计支持无限制、同时进行迭代和修改的集合是很困难的,而且这通常会排除更简单、更高效的实现方式。Java的Hashtable
和C++的vector
都没有去解决这个问题,Python的字典和JavaScript的对象也没有明确定义这种访问的具体行为。JavaScript中的其他集合类型有相关定义,但结果是实现起来更复杂。C++的std::map
承诺插入新条目不会使指向其他条目的指针失效,但为了实现这个承诺,该标准排除了像Rust的BTreeMap
那样更高效利用缓存的设计,BTreeMap
在树的每个节点中存储多个条目。
下面是这些规则能够捕捉到的另一种错误示例。考虑下面这段C++代码,它用于管理文件描述符。为了简化,我们只展示构造函数和复制赋值运算符,并且省略错误处理:
struct File {
int descriptor;
File(int d) : descriptor(d) {}
File& operator=(const File &rhs) {
close(descriptor);
descriptor = dup(rhs.descriptor);
return *this;
}
};
2
3
4
5
6
7
8
9
10
赋值运算符看起来很简单,但在这种情况下会出大问题:
File f(open("foo.txt", ...));
...
f = f;
2
3
如果我们将一个File
对象赋值给自己,rhs
和*this
是同一个对象,所以operator=
在即将把文件描述符传递给dup
之前就关闭了它。我们破坏了本应复制的资源。
在Rust中,类似的代码如下:
struct File {
descriptor: i32
}
fn new_file(d: i32) -> File {
File { descriptor: d }
}
fn clone_from(this: &mut File, rhs: &File) {
close(this.descriptor);
this.descriptor = dup(rhs.descriptor);
}
2
3
4
5
6
7
8
9
10
11
12
(这不是符合Rust习惯的代码。在Rust中,有很好的方法为类型定义构造函数和方法,我们将在第9章介绍,但上述定义适用于这个例子。)
如果我们编写与使用File
对应的Rust代码,会得到:
let mut f = new_file(open("foo.txt", ...));
...
clone_from(&mut f, &f);
2
3
当然,Rust甚至不会编译这段代码:
error[E0502]: cannot borrow `f` as immutable because it is also borrowed as mutable
--> references_self_assignment.rs:18:25
|
| ^- mutable borrow ends here
18 | clone_from(&mut f, &f);
| | |
| | immutable borrow occurs here
| mutable borrow occurs here
2
3
4
5
6
7
8
这看起来应该很眼熟。事实证明,两个经典的C++错误——无法处理自赋值和使用无效迭代器,本质上是同一种错误!在这两种情况下,代码都假定在参考另一个值的同时修改一个值,但实际上它们是同一个值。如果你在C或C++中,曾不小心让memcpy
或strcpy
调用的源和目标重叠,那就是这种错误的另一种形式。通过要求可变访问是独占的,Rust避免了很多常见错误。
在编写并发代码时,共享引用和可变引用不能混合使用的规则真正体现出了价值。只有当某个值在多个线程之间既可变又共享时,才可能出现数据竞争——而这正是Rust的引用规则所避免的情况。一个避免使用不安全代码的并发Rust程序,从结构上就不会出现数据竞争。
我们将在第19章讨论并发时更详细地介绍这方面内容,但总之,在Rust中使用并发比在大多数其他语言中要容易得多。
# Rust的共享引用与C语言指向常量的指针对比
乍一看,Rust的共享引用似乎与C和C++中指向常量值的指针很相似。然而,Rust对共享引用的规则要严格得多。例如,考虑下面这段C代码:
int x = 42; // int variable, not const
const int *p = &x; // pointer to const int
assert(*p == 42);
x++; // change variable directly
assert(*p == 43); // “constant” referent's value has changed
2
3
4
5
p
是const int *
类型,这意味着你不能通过p
本身修改它所指向的值:(*p)++
是被禁止的。但是你也可以直接通过x
访问被指向的值,而x
不是常量,所以可以这样修改它的值。C语言家族中的const
关键字有它的用途,但它并不能真正保证常量性。
在Rust中,共享引用禁止在其生命周期结束前对其指向的值进行任何修改:
let mut x = 42; // non-const i32 variable
let p = &x; // shared reference to i32
assert_eq!(*p, 42);
x += 1; // error: cannot assign to x because it is borrowed
assert_eq!(*p, 42); // if you take out the assignment, this is true
2
3
4
5
为了确保一个值是常量,我们需要跟踪所有可能访问该值的路径,并确保这些路径要么不允许修改,要么根本不能使用。C和C++的指针限制太少,编译器无法进行这种检查。Rust的引用总是与特定的生命周期相关联,这使得在编译时进行检查成为可能。
# 对抗对象海洋
自20世纪90年代自动内存管理兴起以来,所有程序的默认架构就变成了“对象海洋”,如图5-10所示。 这就是在有垃圾回收机制的情况下,你在没有任何设计就开始编写程序时会出现的情况。我们都构建过类似这样的系统。
这种架构有很多图中没有体现的优点:初期进展迅速,很容易添加功能,而且几年后,你完全有理由对整个系统进行重写。(此时可以播放AC/DC的《Highway to Hell》。)
当然,它也有缺点。当所有东西都像这样相互依赖时,很难对任何一个组件进行单独测试、改进,甚至难以单独思考。
图5-10 对象海洋
Rust一个很有趣的地方在于,所有权模型给通往“地狱”(混乱的编程状态)的道路设置了减速带。在Rust中创建一个循环(即两个值,每个值都包含一个指向另一个值的引用)需要费点功夫。你需要使用智能指针类型,比如Rc
,以及内部可变性(这是我们还没讲到的内容)。Rust更倾向于让指针、所有权和数据流在系统中单向传递,如图5-11所示。
图5-11 值的树状结构
我们现在提到这个的原因是,读完本章后,你很自然地可能会想马上创建一个由Rc
智能指针连接起来的“结构体海洋”,重新创造出你熟悉的所有面向对象的反模式。但这不会马上成功。Rust的所有权模型会给你带来一些麻烦。解决办法是进行一些预先设计,构建一个更好的程序。
Rust致力于将理解程序的痛苦从未来转移到现在。它的效果出奇地好:Rust不仅能迫使你理解为什么你的程序是线程安全的,甚至还能要求你进行一定程度的高级架构设计。