Libraries cannot provide new inabilities. --Mark Miller
到目前为止,我们看到的所有指针类型--简单的Box<T>
堆指针,以及指向String
和Vec
值内部的指针--都是拥有指针:当所有者被删除时,引用对象也随之删除.Rust还有被称为 引用(references) 的非拥有指针类型,这种指针对其引用的对象的生命周期没有影响.
事实上,它恰恰相反:引用必须永远不会超过它们引用的对象.你必须在代码中明确,任何引用都不可能超过它指向的值.为了强调这一点,Rust指的是创建对某些值的引用作为 借用(borrowing) 值:你借入的内容,最终必须返回给其所有者.
如果你在阅读"你必须在代码中明确"这句话时感到有点怀疑,那么你在优秀的公司里,引用本身并不特别--在底层,它们只是地址.但Rust保证他们安全的规则是新奇的;除了研究语言之外,你以前不会看到任何类似的东西.虽然这些规则是Rust的一部分,需要最大的努力来掌握,但它们预防的经典的,绝对日常的错误的广度是惊人的,它们对多线程编程的影响是解放性的.这又是Rust的激进赌注.
举个例子,让我们假设我们要建立一个表格,里面有很多凶残的文艺复兴时期艺术家以及他们的作品.Rust的标准库包含一个哈希表类型,所以我们可以这样定义我们的类型:
use std::collections::HashMap;
type Table = HashMap<String, Vec<String>>;
换句话说,这是一个哈希表,它将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);
}
}
}
构建和打印表格很简单:
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);
}
一切正常:
$ cargo run
Running `/home/jimb/rust/book/fragments/target/debug/fragments`
works by Gesualdo:
Tenebrae Responsoria
many madrigals
works by Cellini:
Perseus with the head of Medusa
a salt cellar
works by Caravaggio:
The Musicians
The Calling of St. Matthew
$
但是如果你已经阅读了前一章关于移动的章节,那么show
的这个定义应该会引起一些问题.特别是,HashMap
不是Copy
--它也不能是,因为它拥有一个动态分配的表.因此,当程序调用show(table)
时,整个结构将移动到函数,使变量table
成为未初始化状态.如果调用代码现在尝试使用table
,它将遇到麻烦:
...
show(table);
assert_eq!(table["Gesualdo"][0], "many madrigals");
Rust抱怨table
不再可用:
error[E0382]: use of moved value: `table`
--> references_show_moves_table.rs:29:16
|
28 | show(table);
| ----- value moved here
29 | assert_eq!(table["Gesualdo"][0], "many madrigals");
| ^^^^^ value used here after move
|
= note: move occurs because `table` has type `HashMap<String, Vec<String>>`,
which does not implement the `Copy` trait
实际上,如果我们研究show
的定义,外部for
循环将获取哈希表的所有权并完全消耗它;并且内部for
循环对每个向量执行相同的操作.(我们之前在"liberté, égalité, fraternité"示例中看到了这种行为.)由于移动语义,我们只是通过尝试将其打印出来就完全破坏了整个结构.谢谢,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) 或 单个写入器(single writer) 规则的方法.实际上,这条规则不仅适用于引用, 它也包括借来的值的所有者.只要存在对值的共享引用,即使其所有者也不可以修改它;该值被锁定.当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);
}
}
}
show
的参数table
的类型已从Table
更改为了&Table
:我们现在传递共享引用,而不是按值传递表(因此将所有权移动到函数中).这是唯一的文字变化.但是,当我们通过函数体时,这是如何发挥作用的?
虽然我们的原来的外部for
循环取得了HashMap
的所有权并使用了它,但在我们的新版本中它接收了对HashMap
的共享引用.迭代对HashMap
的共享引用被定义为生成对每个条目的键和值的共享引用:artist
已从String
变为&String
,work
从Vec<String>
变为&Vec<String>
.
内部循环也发生了类似的变化.迭代对向量的共享引用被定义为生成对其元素的共享引用,因此work
现在是&String
.在此函数的任何地方都没有所有权转手;它只是传递非拥有的引用.
现在,如果我们想编写一个函数来按字母顺序分析每个艺术家的作品,那么共享引用是不够的,因为共享引用不允许修改.相反,排序函数需要接收对表的可变引用:
fn sort_works(table: &mut Table) {
for (_artist, works) in table {
works.sort();
}
}
我们需要传递一个:
sort_works(&mut table);
这种可变借用赋予sort_works
读取和修改结构的能力,也是向量sort
方法所需要的.
当我们以一种将值的所有权移动到函数的方式将值传递给函数时,我们说我们已经 通过值(by value) 传递了它.如果我们将对值的引用传递给函数,我们说我们通过引用(by reference) 传递了值.例如,我们修改了show
函数,将其更改为通过引用而不是通过值接受表.许多语言都有这种区别,但它在Rust中尤其重要,因为它阐明了所有权如何受到影响.
前面的示例显示了一个非常典型的引用用法:允许函数访问或操作结构而不占用所有权.但是引用比这更灵活,所以让我们看一些例子来更详细地了解正在发生的事情.
如果你熟悉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
在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
要创建可变引用,请使用&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
但是你可能还记得,当我们修改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");
用在show
函数中的println!
宏展开的代码使用.
运算符,因此它也利用了这种隐式解引用.
如果需要进行方法调用,.
操作符还可以隐式借用其左操作数的引用.例如,Vec
的sort
方法接收对向量的可变引用,因此这里显示的两个调用是等价的:
let mut v = vec![1973, 1968];
v.sort(); // implicitly borrows a mutable reference to v
(&mut v).sort(); // equivalent; much uglier
简而言之,C++在引用和左值(lvalues)(即引用内存中位置的表达式)之间隐式转换,这些转换出现在任何需要的位置,在Rust中,你使用&
和*
运算符来创建和跟踪引用,.
运算符例外,它隐式地借用和解引用.
给Rust引用赋值使它指向一个新值:
let x = 10;
let y = 20;
let mut r = &x;
if b { r = &y; }
assert!(*r == 10 || *r == 20);
引用r
最初指向x
.但如果b
为真,则代码将其指向y
,如图5-1所示.
图5-1. 引用r,现在指向y而不是x.
这与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;
(为了清楚起见,我们已经写出了引用类型,但是你可以省略它们:这里没有任何东西是Rust无法推断自己出来的).
运算符跟随同引用一样多来找到它的目标:
assert_eq!(rrr.y, 729);
在内存中,引用的排列如图5-2所示.
图5-2. 引用的引用链.
因此,由rrr
类型引导的表达式rrr.y
,实际上在获取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);
这里的最终断言成功,即使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
Rust引用永远不会为空.没有和C的NULL
或C++的nullptr
类似的东西;引用没有默认初始值(在初始化之前不能使用任何变量,无论其类型如何);和Rust不会将整数转换为引用(unsafe
代码除外),因此你无法将零转换为引用.
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).fold(1, |a, b| a * b)
}
let r = &factorial(6);
assert_eq!(r + &1009, 1729)
在这种情况下,Rust只是创建一个匿名变量来保存表达式的值,并使引用指向该值.这个匿名变量的生命周期取决于你对引用的处理方式:
-
如果你立即在
let
语句中给引用赋值为变量(或使它成为一些立即赋值的结构或数组的一部分),那么Rust就会使匿名变量活得和let
初始化的变量一样长.在前面的例子中,Rust会为r
的引用对象执行此操作. -
否则,匿名变量活到封闭语句的结束.在我们的示例中,为保存
1009
而创建的匿名变量仅持续到assert_eq!
语句的结束.
如果你习惯使用C或C++,这可能听起来容易出错.但请记住,Rust永远不会让你编写会产生悬空引用的代码.如果引用可以在匿名变量的生命周期之外使用,Rust将始终在编译时向你报告该问题.然后,你可以修复代码,将引用对象保存在具有适当生命周期的命名变量中.
到目前为止,我们展示的引用都是简单的地址.然而.Rust还包含两种 胖指针(fat pointers) ,两个字长的值带有一些值的地址,以及便于使用该值的必要信息.
对切片的引用是胖指针,其携带切片的起始地址及其长度.我们在第3章中详细描述了切片.
Rust的另一种胖指针是一个 trait object ,它是对实现特定trait的值的引用.trait对象携带一个值的地址和一个指向与该值相应的trait实现的指针,用于调用trait的方法.我们将在第238页的"Trait Object"中详细介绍trait对象.
除了携带这些额外的数据之外,切片和trait对象引用的行为就像我们在本章中到目前为止所展示的其他类型的引用一样:它们不拥有它们的引用对象;它们不能比它们的引用对象活得更久,它们可能是可变的或共享的;等等.
正如我们到目前为止所介绍的,引用看起来非常像C或C++中的普通指针.但那些是不安全的;Rust如何控制其引用?也许了解规则的最佳方式是试图打破它们.我们将从最简单的示例开始,然后添加有趣的复杂性并解释它们是如何工作的.
这是一个非常明显的例子.你不能借用对局部变量的引用并将其从变量的作用域中取出:
{
let r;
{
let x = 1;
r = &x;
}
assert_eq!(*r, 1); // bad: reads memory `x` used to occupy
}
Rust编译器拒绝此程序,并带有详细的错误消息:
error: `x` does not live long enough
--> references_dangling.rs:8:5
|
7 | r = &x;
| - borrow occurs here
8 | }
| ^ `x` dropped here while still borrowed
9 | assert_eq!(*r, 1); // bad: reads memory `x` used to occupy
10 | }
| - borrowed value needs to live until here
Rust抱怨,x
只活到内部块结束,而引用则活到外部块结束.这使其成为一个悬空指针,这是禁止的.
虽然对于一个人类读者来说很明显,这个程序是坏的.但是值得一看的是Rust自己是如何得出这个结论的.即使这个简单的例子也显示了Rust用来检查更复杂代码的逻辑工具.
Rust尝试为程序中的每个引用类型分配一个 生命周期(lifetime) ,该生命周期满足使用方式所施加的约束.生命周期是程序的一些范围,可以安全地使用引用:词法块,语句,表达式,某些变量的作用域等.生命周期完全是Rust的编译时想象力的虚构.在运行时,引用只不过是一个地址;它的生命周期是其类型的一部分,没有运行时表示.
在这个例子中,有三个生命周期,我们需要理清它们之间的关系.变量r
和x
各有一个生命周期,从它们初始化的点延伸到它们超出作用域的点.第三个生命周期是引用类型的生命周期:我们借用到&x
的引用类型,并存储在r
中.
这里的一个约束看起来非常明显:如果你有一个变量x
,那么对x
的引用不能活得比x
本身还久,如图5-3所示.
图5-3. &X允许的生命周期.
越过x
超出作用域的点,引用将是一个悬空指针.我们说变量的生命周期必须 包含(contain) 或 包围(enclose) 从其借用的引用的生命周期.
这是另一种约束:如果在变量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];
由于v
拥有向量,向量拥有其元素,因此v
的生命周期必须包围引用类型&v[1]
的生命周期.同样,如果你将引用存储在某个数据结构中,则其生命周期必须包围数据结构的生命周期.如果你构建一个引用的向量,也就是说,它们都必须具有生命周期,该生命周期包围拥有向量的变量的生命周期.
这是Rust用于所有代码的过程的本质.将更多的语言特性引入到图像--数据结构和函数调用中,也就是说--引入了新的约束条件,但原理仍然是相同的:首先,理解从程序使用引用的方式所产生的约束;然后,找到满足他们的生命周期.这与C和C++程序员强加于自己的过程没有什么不同;区别在于Rust了解规则并强制执行.
当我们传递引用给函数时,Rust如何确保函数安全地使用它?假设我们有一个函数f
,它接受一个引用并将它存储在一个全局变量中.我们需要对此进行一些修改,但这是第一次修改:
// This code has several problems, and doesn't compile.
static mut STASH: &i32;
fn f(p: &i32) { STASH = p; }
Rust的与全局变量等效的称为静态(static) :它是一个在程序启动时创建并持续到终止的值.(像任何其他声明一样,Rust的模块系统控制静态可见的位置,因此它们只是生命周期"全局(global)";而不是它们的可见性全局).我们将在第8章介绍静态,但是现在我们只需要指出一些刚刚展示的代码没有遵循的规则:
-
必须初始化每个静态.
-
可变静态本质上不是线程安全的(毕竟,任何线程都可以随时访问静态),甚至在单线程程序中,它们也可能成为其他类型的可重入问题的牺牲品.由于这些原因,你只能在
unsafe
块中访问可变静态.在这个例子中,我们并不关心那些特定的问题,所以我们就引入一个unsafe
块然后继续前进.
通过这些修订,我们现在有以下内容:
static mut STASH: &i32 = &128;
fn f(p: &i32) { // still not good enough
unsafe {
STASH = p;
}
}
我们差不多完成了.要查看剩下的问题,我们需要写出Rust帮助我们省略的一些内容.这里写的f
的签名实际上是以下的简写:
fn f<'a>(p: &'a i32) { ... }
这里,生命周期'a
(发音为"tick A")是f
的 生命周期参数(lifetime parameter) .你可以将<'a>
读作"for any lifetime 'a
",所以当我们写fn f <'a>(p:&'a i32)
时,我们定义了一个函数,它接受具有任何给定生命周期'a
的i32
的引用.
因为我们必须允许'a
成为任何生命周期,所以如果它是它是可能的最小生命周期:一个只包围f
的调用的声明周期,那么事情就更好解决了.赋值继而成为争论的焦点:
STASH = p;
由于STASH
存活在程序的整个执行过程,它所持有的引用类型必须具有相同长度的生命周期;Rust称之为 'static
生命周期(lifetime) .但是p
的引用的生命周期是某个'a
,它可以是任何生命周期,只要它包围对f
的调用.所以,Rust拒绝我们的代码:
error[E0312]: lifetime of reference outlives lifetime of borrowed content...
--> references_static.rs:6:17
|
6 | STASH = p;
| ^
|
= note: ...the reference is valid for the static lifetime...
note: ...but the borrowed content is only valid for the anonymous lifetime #1
defined on the function body at 4:0
--> references_static.rs:4:1
|
4 | / fn f(p: &i32) { // still not good enough
5 | | unsafe {
6 | | STASH = p;
7 | | }
8 | | }
| |_^
在这一点上,很明显我们的函数不能接受只是随便一种引用作为参数.但是它应该能够接受具有'static
生命周期的引用:在STASH
中存储这样的引用不会创建悬空指针.事实上,下面的代码编译得很好:
static mut STASH: &i32 = &10;
fn f(p: &'statici32) {
unsafe {
STASH = p;
}
}
这次,f
的签名表明p
必须是一个具有生命周期'static
的引用,因此在STASH
中存储它不再有任何问题.我们只能将f
应用于其他静态的引用,但这是唯一不会让STASH
悬空的东西.所以我们可以写:
static WORTH_POINTING_AT: i32 = 1000;
f(&WORTH_POINTING_AT);
因为WORTH_POINTING_AT
是静态,&WORTH_POINTING_AT
的类型是&'static
,它可以安全地传给f
.
然而,退后一步,注意一下,当我们修正到正确时,f
的签名发生了什么:原来的f(p:&i32)
最终为f(p:&'static i32)
.换句话说,我们无法编写一个函数来将引用存储在全局变量中而不在函数的签名中反映该意图.在Rust中,函数的签名总是暴露函数体的行为.
相反,如果我们确实看到一个带有像g(p:&i32)
(或者写出了生命周期,g<'a>(p:&'a i32)
)这样的签名的函数,我们可以看出它 不会(does not) 将参数p
存储在任何比调用活得更久的地方.没有必要研究g
的定义;签名本身就告诉我们g
能用参数做什么和不能做什么.当你尝试建立对函数调用的安全性时,这一事实最终会非常有用.
现在我们已经展示了函数的签名与其函数体的关系,让我们来看看它与函数调用者的关系.假设你有以下代码:
// This could be written more briefly: fn g(p: &i32),
// but let's write out the lifetimes for now.
fn g<'a>(p: &'a i32) { ... }
let x = 10;
g(&x);
仅从g
的签名中,Rust知道它不会在可能比调用活得更长的任何地方保存p
:任何包围调用的生命周期都必须适用于'a
.因此Rust选择&x
的最小的可能的生命周期:调用g
的生命周期.这符合所有约束条件:它不会比x
活得久,并且包围整个对g
的调用.所以这段代码是合格的.
请注意,尽管g
需要一个生命周期参数'a
,但在调用g
时我们不需要提及它.你只需要在定义函数和类型时担心生命周期参数;使用它们时,Rust会为你推断生命周期.
如果我们试图将&x
传递给我们之前的函数f
,并将其参数存储在静态中,会怎么样?
fn f(p: &'statici32) { ... }
let x = 10;
f(&x);
这无法编译:引用&x
不能活得比x
久,但是通过将它传递给f
,我们将它限制为至少与'static
一样长. 没有办法满足这里的每个人,所以Rust拒绝了代码.
函数接收某些数据结构的引用,然后返回该结构的某个部分的引用是很常见的.例如,这是一个返回对切片的最小元素的引用的函数:
// v should have at least one element.
fn smallest(v: &[i32]) -> &i32 {
let mut s = &v[0];
for r in &v[1..] {
if *r < *s { s = r; }
}
s
}
按照通常的方式,我们省略了该函数签名中的生命周期.当函数将单个引用作为参数并返回单个引用时,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
从smallest
的签名,我们可以看到它的参数和返回值必须具有相同的生命周期,'a
.在我们的调用中,参数¶bola
不能活得比parabola
本身更久;然而,smallest
的返回值必须至少与s
一样长.没有可能的生命周期'a
可以满足同时两个约束,所以Rust拒绝代码:
error: `parabola` does not live long enough
--> references_lifetimes_propagated.rs:12:5
|
11 | s = smallest(¶bola);
| -------- borrow occurs here
12 | }
| ^ `parabola` dropped here while still borrowed
13 | assert_eq!(*s, 0); // bad: points to element of dropped array
14 | }
| - borrowed value needs to live until here
移动s
使其生命周期明确包含在parabola
中解决问题:
{
let parabola = [9, 4, 1, 0, 1, 4, 9];
let s = smallest(¶bola);
assert_eq!(*s, 0); // fine: parabola still alive
}
函数签名中的生命周期让Rust评估你传递给函数的引用与函数返回的引用之间的关系,并确保它们被安全地使用.
Rust如何处理存储在数据结构中的引用?这是和我们之前看到的相同的错误程序,除了我们将引用放在结构中:
// This does not compile.
struct S {
r: &i32
}
let s;
{
let x = 10;
s = S { r: &x };
}
assert_eq!(*s.r, 10); // bad: reads from dropped `x`
Rust对引用的安全约束不会只是因为我们将引用隐藏在结构中就神奇地消失.无论如何,这些限制最终也必须应用于S
.事实上,Rust持怀疑态度:
error[E0106]: missing lifetime specifier
--> references_in_struct.rs:7:12
|
7 | r: &i32
| ^ expected lifetime parameter
每当引用类型出现在另一个类型的定义中时,你就必须写出其生命周期.你可以这样写:
structS {
r: &'static i32
}
这表示r
只能引用将在程序生命周期内持续存在的i32
值,这有点受限制.另一种方法是给类型一个生命周期参数'a
,并将其用于r
:
struct S<'a> {
r: &'a i32
}
现在,S
类型有一个生命周期,就是和引用类型一样. 你创建的每个S
类型的值都会获得一个新的生命周期'a
,这会受到你使用该值的方式的限制.你存储在r
中的任何引用的生命周期最好包含'a
,并且'a
必须比存储S
的任何地方的生命周期更长.
回到前面的代码,表达式S { r:&x }
创建一个带有生命周期'a
的新S
值.当你在r
字段中存储&x
时,你约束'a
完全位于x
的生命周期内.
赋值s = S { ... }
将此S
存储在一个变量中,该变量的生命周期延伸到示例的末尾,约束'a
比s
的生命周期更长.现在,Rust已经达到了与之前相同的矛盾限制:'a
一定不能活得比x
久,但必须至少和s
一样长. 没有令人满意的生命周期,Rust拒绝此代码.避免灾难!
当把具有生命周期参数的类型放在其他类型中时它是如何表现的?
struct T {
s: S // not adequate
}
Rust是持怀疑态度的,就像我们尝试在S
中放置引用而不指定其生命周期一样:
error[E0106]: missing lifetime specifier
--> references_in_nested_struct.rs:8:8
|
8 | s: S // not adequate
| ^ expected lifetime parameter
我们不能在这里省略S
的生命周期参数:Rust需要知道T
的生命周期如何与其S
中的引用的生命周期相关联,以便对T
应用与S
和普通引用相同的检查.
我们可以给s
以'static
生命周期.这是有效的:
struct T {
s: S<'static>
}
通过给定一个生命周期参数'a
并在s
的类型中使用它,我们允许Rust将T
值的生命周期与其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
}
两个引用使用相同的生命周期'a
.如果你的代码想要执行以下操作,这可能是一个问题:
let x = 10;
let r;
{
let y = 20;
{
let s = S { x: &x, y: &y };
r = s.x;
}
}
此代码不会创建任何悬空指针.对y的引用保留在s中,它在y
之前超出作用域.对x
的引用结束在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
的定义以使每个引用具有不同的生命周期可以修复所有内容:
structS<'a, 'b> {
x: &'a i32,
y: &'b i32
}
根据这个定义,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就会给每个需要的点分配不同的生命周期.例如:
struct S<'a, 'b> {
x: &'a i32,
y: &'b i32
}
fn sum_r_xy(r: &i32, s: S) -> i32 {
r + s.x + s.y
}
这个函数的签名是以下的简写:
fn sum_r_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])
}
写出所有的生命周期,相当于:
fn first_third<'a>(point: &'a [i32; 3]) -> (&'a i32, &'a i32)
如果你的参数中有多个生命周期,那么没有理由为返回值选择一个而不是另一个,所以Rust会让你写出正在发生的事情.
但作为一个最后的简写,如果你的函数是某种类型上的方法并通过引用接受其self
参数,那么这就打破了联系:Rust假定self
的生命周期是返回值中所有内容的生命周期.(self
参数指的是正在调用该方法的值,Rust的self
等价于C++,Java或JavaScript中的this
,或Python中的self
.我们将在第198页的"使用impl定义方法(Defining Methods with 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
}
}
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
aside
的赋值移动了向量,使v
未初始化,将r
转为悬空指针,如图所图5-7示.
图5-7. 一个对已经移走了的向量的引用.
虽然v
在r
的整个生命周期都保持在作用域内.但这里的问题是v
的值被移动到其他地方,使v
未初始化而r
仍然引用它.当然,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
10 | let aside = v; // move vector to aside
| ^^^^^ move out of `v` occurs here
在其生命周期中,共享引用使其引用对象只读:你不能赋值给引用对象或将其值移动到其他地方.在这段代码中,r
的生命周期包含移动向量的尝试,因此Rust拒绝该程序.如果你将程序更改为此处显示的,则没有问题:
let v = vec![4, 8, 19, 27, 34, 10];
{
let r = &v;
r[0]; // ok: vector is still there
}
let aside = v;
在这个版本中,r
更早超出作用域,引用的生命周期在v
被移到aside
之前结束,一切都很好.
这是造成严重破坏的另一种方式.假设我们有一个方便的函数来用切片的元素扩展向量:
fn extend(vec: &mut Vec<f64>, slice: &[f64]){
for elt in slice {
vec.push(*elt);
}
}
这是标准库的在向量上的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]);
所以我们在这里建立了一个周期的正弦波.如果我们想要添加另一个波动,我们可以将向量附加到自身上吗?
extend(&mut wave, &wave);
assert_eq!(wave, vec![0.0, 1.0, 0.0, -1.0,
0.0, 1.0, 0.0, -1.0]);
这在不经意检查中可能看起来很好.但请记住,当我们向向量添加元素时,如果其缓冲区已满,则必须分配一个具有更多空间的新缓冲区.假设wave
以4个元素的空间开始,因此当extend
尝试添加第5个元素时,必须分配一个更大的缓冲区.内存最终如图5-8所示.
图5-8. 由于向量重新分配,切片变成悬空指针.
extend
函数的vec
参数借用wave
(由调用者拥有),它为自己分配了一个带有8个元素空间的新缓冲区.但slice
继续指向旧的4元素缓冲区,该缓冲区已被删除.
这种问题并不是Rust所独有的:在许多语言中,在指向集合的同时修改集合是一个微妙的领域.在C++中,std::vector
规范提醒你"重新分配[向量的缓冲区]使引用序列中元素的所有引用,指针和迭代器无效."类似地,Java说,修改java.util.Hashtable
对象:
如果在创建迭代器之后的任何时候对Hashtable进行结构上的修改,除了通过迭代器自己的remove方法之外,迭代器将抛出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
|
9 | extend(&mut wave, &wave);
| ---- ^^^^- mutable borrow ends here
| | |
| | immutable borrow occurs here
| mutable borrow occurs here
换句话说,我们可以借用对向量的可变引用,也可以借用对其元素的共享引用,但是这两个引用的生命周期不能重叠.在我们的例子中,两个引用的生命周期都包含对extend
的调用,因此Rust拒绝了代码.
这些错误都源于违反Rust的可变和共享规则:
-
共享访问是只读访问(Shared access is read-only access) .共享引用所借用的值是只读的.在共享引用的整个生命周期中, 任何东西(anything) 都不能改变它的引用对象,也不能改变从它的引用对象那里到达的任何东西.该结构中的任何内容都不存在活的可变引用;它的所有者是只读的;等等.它真的冻结了.
-
可变访问是独占访问(Mutable access is exclusive access) .可变引用借用的值只能通过该引用访问.在可变引用的整个生命周期中,没有其他可用的路径(访问)它的引用,或着(访问)从那里可以到达的任何值.唯一的生命周期可能与可变引用重叠的引用,是你从可变引用本身借用的引用.
Rust报告extend
示例违反了第二条规则:因为我们已经借用了一个对wave
的可变引用,所以该可变引用必须是到达向量或其元素的唯一方法.对切片的共享引用是另一种到达元素的方法,违反了第二条规则.
但Rust也可以将我们的bug视为违反第一条规则:因为我们借用了wave
元素的共享引用,元素和Vec
本身都是只读的.你不能借用一个对只读值的可变引用.
每种引用都会影响我们如何处理沿着拥有路径到引用对象上的值,以及从引用对象可以到达的值(图5-9).
图5-9. 借用引用会影响你可以对在同一所有权树中的其他值执行的操作.
请注意,在这两种情况下,所有权到引用对象的路径都无法在引用的生命周期内更改.对于共享借用,路径是只读的;对于可变借用,它是完全无法访问的.所以程序没有办法做任何会使引用无效的事情.
将这些原则归结为最简单的例子:
let mut x = 10;
let r1 = &x;
let r2 = &x; // ok: multiple shared borrows permitted
x += 10; // error: cannot assign to `x` because it is borrowed
let m = &mut x; // error: cannot borrow `x` as mutable because it is
// also borrowed as immutable
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
从共享引用重新借用共享引用是可以的:
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
你可以从可变引用中重新借用:
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
这些限制非常严格.回到我们的尝试调用extend(&mut wave, &wave)
,没有快速简便的方法来修复代码使其以按照我们的想要方式工作.Rust在任何地方都应用这些规则:如果我们借用HashMap
中对键的共享引用,我们就不能借用对HashMap
的可变引用,直到共享引用的生命周期结束.
但是这样做是有充分理由的:设计集合以支持不受限制的同时迭代和修改是很困难的,并且通常会排除更简单,更高效的实现.Java的Hashtable
和C++的vector
并不麻烦,Python字典和JavaScript对象都没有精确定义这种访问的行为.JavaScript中的其他集合类型可以,但结果需要更多的实现.C++的std::map
承诺插入新条目不会使指向map中其他条目的指针失效,但通过做出这个承诺,该标准排除了更缓存高效的设计,如Rust的BTreeMap
,它在树的每个节点中存储多个条目.
这是这些规则捕获的那种错误的另一个例子.考虑以下用于管理文件描述符的C++代码.为了简单起见,我们只展示构造函数和复制赋值运算符(copying assignment operator),我们将省略错误处理:
struct File {
int descriptor;
File(int d) : descriptor(d) { }
File& operator=(const File &rhs) {
close(descriptor);
descriptor = dup(rhs.descriptor
);
}
};
赋值运算符很简单,但是在这样的情况下会严重失败:
File f(open("foo.txt", ...));
...
f = f;
如果我们为自己赋值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);
}
(这不是惯用的Rust.有很好的方法可以为Rust类型提供他们自己的构造函数和方法,我们在第9章中对此进行描述,但前面的定义适用于这个例子.)
如果我们编写相对应的Rust代码去使用File
,我们得到:
let mut f = new_file(open("foo.txt", ...));
...
clone_from(&mut f, &f);
当然,Rust拒绝编译这段代码:
error[E0502]: cannot borrow `f` as immutable because it is also
borrowed as mutable
--> references_self_assignment.rs:18:25
|
18 | clone_from(&mut f, &f);
| - ^- mutable borrow ends here
| | |
| | immutable borrow occurs here
| mutable borrow occurs here
这应该看起来很熟悉.事实证明,两个经典的C++错误--无法应对自我赋值,和使用无效的迭代器--是同样的底层错误!在这两种情况下,代码都假设它正在修改一个值,同时咨询另一个值,而实际上它们都是相同的值.如果你曾经不小心让调用memcpy
或strcpy
的调用的源和目标在C或C++中重叠,那么这就是此bug的另一种出现形式.通过要求可变访问是独占的,Rust已经抵御了一大类日常错误.
共享引用和可变引用的不混溶性在编写并发代码时确实证明了它的价值.只有当某些值是可变的并且在线程之间共享时,数据竞争才有可能发生--这正是Rust的引用规则所消除的.避免unsafe
代码的并发的Rust程序可以 通过构造(by construction) 避免数据竞争.在第19章讨论并发时,我们将更详细地介绍这一方面,但总的来说,并发在Rust中比在大多数其他语言中使用起来要容易得多.
Rust的共享引用对比C的指向常量的指针(Rust's Shared References Versus C's Pointers to const).
在第一次检查时,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
p
是const int *
的事实意味着你不能通过p
本身修改它的引用对象:(*p)++
是禁止的.但是你也可以直接将对象作为x
,而不是const
,并以这种方式改变它的值.C系列的const
关键字有其用途,但不是恒定的.
在Rust中,共享引用禁止对其引用对象的所有修改,直到其生命周期结束:
let mut x = 42; // nonconst 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
为确保值恒定,我们需要跟踪该值的所有可能路径,并确保它们要么不允许修改,要么根本不能使用.C和C++指针太不受限制,编译器无法检查这一点.Rust的引用始终与特定生命周期相关联,因此可以在编译时检查它们.
自20世纪90年代自动内存管理兴起以来,所有程序的默认架构都是 对象之海(sea of objects) ,如图5-10所示.
如果你有垃圾收集并且你开始编写程序而没有设计任何东西会发生这种情况.我们都建立了这样的系统.
这种架构有很多在图中没有显示的优点:初始进展很快,很容易把事情搞糟,几年后,你可以毫无困难地证明需要完全重写.(提示AC/DC的"地狱公路".)
图5-10. 对象之海.
当然,也有缺点.当一切都依赖于这样的一切时,很难单独测试,发展甚至考虑任何组件.
关于Rust的一个有趣的事情是,所有权模型在通往地狱的高速公路上放了减速带.在Rust中创建一个循环需要花费一些精力---这样的两个值,每个值都包含一个指向另一个的引用.你必须使用智能指针类型,如Rc
,以及内部可变性(interior mutability)--我们甚至尚未涉及的主题.Rust更倾向于指针,所有权和数据流在一个方向上通过系统,如图5-11所示.
图5-11. 值的树.
我们现在提出这个问题的原因是,在读完这一章之后,很自然地,我们会想要直接运行并创建一个"结构之海(sea of structs)",所有这些都用Rc
智能指针绑定在一起,并重新创建所有你熟悉的面向对象的反模式.这对你没用.Rust的所有权模型会给你带来一些麻烦.解决方法是做一些前期设计并建立一个更好的程序.
Rust就是将理解你的程序的痛苦从未来转移到现在.它的工作得不合理地好:它不仅会迫使你理解为什么你的程序是线程安全的,它甚至还要求一些高级架构设计.