5.4 共享与可变
迄今为止,本书讨论的都是 Rust 如何确保不会有任何引用指向超出作用域的变量。但是还有其他方法可能引入悬空指针。下面是一个简单的例子:
let v = vec![4, 8, 19, 27, 34, 10];
let r = &v;
let aside = v; // 把向量转移给aside
r[0]; // 错误:这里所用的`v`此刻是未初始化状态
对 aside
的赋值会移动向量、让 v
回到未初始化状态,并将 r
变为悬空指针,如图 5-7 所示。
图 5-7:对已移动出去的向量的引用
尽管 v
在 r
的整个生命周期中都处于作用域内部,但这里的问题是 v
的值已经移动到别处,导致 v
成了未初始化状态,而 r
仍然在引用它。当然,Rust 会捕获错误:
error: cannot move out of `v` because it is borrowed
|
9 | let r = &v;
| - borrow of `v` occurs here
10 | let aside = v; // 把向量转移给`aside`
| ^^^^^ move out of `v` occurs here
在共享引用的整个生命周期中,它引用的目标会保持只读状态,即不能对引用目标赋值或将值移动到别处。在上述代码中,r
的生命周期内发生了移动向量的操作,Rust 当然要拒绝。如果按如下所示更改程序,就没问题了:
let v = vec![4, 8, 19, 27, 34, 10];
{
let r = &v;
r[0]; // 正确:向量仍然在那里
}
let aside = v;
在这个版本中,r
作用域范围更小,在把 v
转移给 aside
之前,r
的生命周期就结束了,因此一切正常。
下面是另一种制造混乱的方式。假设我们随手写了一个函数,它使用切片的元素来扩展某个向量:
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); // 使用另一个向量扩展`wave`
extend(&mut wave, &tail); // 使用数组扩展`wave`
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-8 所示。
extend
函数的 vec
参数借用了 wave
(由调用者拥有),而 wave
为自己分配了一个新的缓冲区,其中有 8 个元素的空间。但是 slice
仍然指向旧的 4 元素缓冲区,该缓冲区已经被丢弃了。
图 5-8:通过向量的重新分配将 slice
变成了悬空指针
这种问题并不是 Rust 独有的:在许多语言中,在指向集合的同时修改集合要加倍小心。在 C++ 中,std::vector
规范会告诫你“重新分配向量缓冲区会令指向序列中各个元素的所有引用、指针和迭代器失效”。Java 对修改 java.util.Hashtable
对象也有类似的说法。
如果在创建迭代器后的任何时间以任何方法(迭代器自身的
remove
方法除外)修改了Hashtable
的结构,那么迭代器都将抛出ConcurrentModificationException
异常。
这类错误特别难以调试,因为它只会偶尔发生。在测试中,向量可能总是恰好有足够的空间,缓冲区可能永远都不会重新分配,于是这个问题可能永远都没人发现。
笔记 这种操作在实战的场景中应尽量避开
然而,Rust 会在编译期报告调用 extend
有问题:
error: cannot borrow `wave` as immutable because it is also
borrowed as mutable
|
9 | extend(&mut wave, &wave);
| ---- ^^^^- mutable borrow ends here
| | |
| | immutable borrow occurs here
| mutable borrow occurs here
换句话说,我们既可以借用向量的可变引用,也可以借用其元素的共享引用,但这两种引用的生命周期不能重叠。在这个例子中,这两种引用的生命周期都包含着对 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; // 错误:不能赋值给`x`,因为它已被借出
let m = &mut x; // 错误:不能把`x`借入为可变引用,因为
// 它涵盖在已借出的不可变引用的生命周期内
println!("{}, {}, {}", r1, r2, m); // 这些引用是在这里使用的,所以它们
// 的生命周期至少要存续这么长
let mut y = 20;
let m1 = &mut y;
let m2 = &mut y; // 错误:不能多次借入为可变引用
let z = y; // 错误:不能使用`y`,因为它涵盖在已借出的可变引用的生命周期内
println!("{}, {}, {}", m1, m2, z); // 在这里使用这些引用
可以从共享引用中重新借入共享引用:
let mut w = (107, 109);
let r = &w;
let r0 = &r.0; // 正确:把共享引用重新借入为共享引用
let m1 = &mut r.1; // 错误:不能把共享引用重新借入为可变
println!("{}", r0); // 在这里使用r0
可以从可变引用中重新借入可变引用:
let mut v = (136, 139);
let m = &mut v;
let m0 = &mut m.0; // 正确: 从可变引用中借入可变引用
*m0 = 137;
let r1 = &m.1; // 正确: 从可变引用中借入共享引用,并且不能和m0重叠
v.1; // 错误:禁止通过其他路径访问
println!("{}", r1); // 可以在这里使用r1
这些限制非常严格。回过头来看看我们尝试调用 extend(&mut wave, &wave)
的地方,没有什么快速而简便的方法来修复代码,以使其按照我们想要的方式工作。Rust 中到处都在应用这些规则:如果要借用对 HashMap
中键的共享引用,那么在共享引用的生命周期结束之前就不能再借入对 HashMap
的可变引用。
但这么做有充分的理由:要为集合设计出“支持不受限制地在迭代期间修改”的能力是非常困难的,而且往往会导致无法简单高效地实现这些集合。Java 的 Hashtable
和 C++ 的 vector
就不支持这种访问方式,Python 的字典和 JavaScript 的对象甚至都不曾定义过这种访问方式。JavaScript 中的其他集合类型固然可以做到,不过需要更繁重的实现。C++ 的 std::map
承诺插入新条目不会让指向此映射表中其他条目的指针失效,但做出这一承诺的代价是该标准无法提供像 Rust 的 BTreeMap
这样更高效的缓存设计方案,因为后者会在树的每个节点中存储多个条目。
下面是通过上述规则捕获各种错误的另一个例子。考虑以下 C++ 代码,它用于管理文件描述符。为了简单起见,这里只展示一个构造函数和复制赋值运算符,并会省略错误处理代码:
struct File {
int descriptor;
File(int d) : descriptor(d) { }
File& operator=(const File &rhs) {
close(descriptor);
descriptor = dup(rhs.descriptor);
return *this;
}
};
这个赋值运算符很简单,但在下面这种情况下会执行失败:
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 章会对此进行讲解,刚才的定义方式仅仅是为了示范。)
如果编写使用了 File
的 Rust 代码,就会得到如下内容:
let mut f = new_file(open("foo.txt", ...));
...
clone_from(&mut f, &f);
当然,Rust 干脆拒绝编译这段代码:
error: cannot borrow `f` as immutable because it is also
borrowed as mutable
|
18 | clone_from(&mut f, &f);
| - ^- mutable borrow ends here
| | |
| | immutable borrow occurs here
| mutable borrow occurs here
以上错误看起来很熟悉。事实证明,这里的两个经典 C++ 错误(无法处理自赋值和使用无效迭代器)本质上是同一种错误。在这两种情况下,代码都以为自己正在修改一个值,同时在引用另一个值,但实际上两者是同一个值。如果你不小心让调用 memcpy
或 strcpy
的源和目标在 C 或 C++ 中重叠,则可能会带来另一种错误。通过要求可变访问必须是独占的,Rust 避免了一大类日常错误。
在编写并发代码时,共享引用和可变引用的互斥性确实证明了其价值。只有当某些值既可变又要在线程之间共享时,才可能出现数据竞争,而这正是 Rust 的引用规则所要消除的。一个用 Rust 编写的并发程序,只要避免使用 unsafe
代码,就可以在构造之初就避免产生数据竞争。第 19 章在讨论并发时会更详细地对此进行介绍。总而言之,与大多数其他语言相比,并发在 Rust 中更容易使用。
笔记 Rust从设计之初就良好的处理了数据竞争问题
Rust 的共享引用与 C 的 const
指针
乍一看,Rust 的共享引用似乎与 C 和 C++ 中指向 const
值的指针非常相似。然而,Rust 中共享引用的规则要严格得多。例如,考虑以下 C 代码:
int x = 42; // int变量,不是常量
const int *p = &x; // 指向const int的指针
assert(*p == 42);
x++; // 直接修改变量
assert(*p == 43); //“常量”指向的值发生了变化
p
是 const int *
这一事实意味着不能通过 p
本身修改它的引用目标,也就是说,禁止使用 (*p)++
。但是可以直接通过 x
获取引用目标,x
不是 const
,能以这种方式更改其值。C 家族的 const
关键字自有其用处,但与“常量”无关。
在 Rust 中,共享引用禁止对其引用目标进行任何修改,直到其生命周期结束:
let mut x = 42; // 非常量型i32变量
let p = &x; // 到i32的共享引用
assert_eq!(*p, 42);
x += 1; // 错误:不能对x赋值,因为它已被借出
assert_eq!(*p, 42); // 如果赋值成功,那么这应该是true
为了保证一个值是常量,需要追踪该值的所有可能路径,并确保它们要么不允许修改,要么根本不能使用。C 和 C++ 的指针不受限制,编译器无法对此进行检查。Rust 的引用总是与特定的生命周期相关联,因此可以在编译期检查它们。
自 20 世纪 90 年代自动内存管理兴起以来,所有程序都由大量复杂关联的对象构成,如图 5-10 所示。
图 5-10:复杂对象关系
如果你采用垃圾回收(自动内存管理)并且在开始编写程序之前不做任何设计,就会发生这种情况。我们都构建过这样的系统。
这种架构有很多从图 5-10 中无法看出的优点:初始的进展迅速;很容易添加新功能;几年以后,你将很容易确定你需要完全重写它。(让我们来一首澳大利亚摇滚乐队 AC/DC 的“通往地狱的高速公路”。2)
2意思是重写会变得迫在眉睫,但又非常困难。——译者注
当然,这种架构也有缺点。当每个部分都像这样依赖于其他部分时,必然很难测试、迭代,甚至很难单独考虑其中的任何组件。
Rust 令人着迷的地方之一就在于,其所有权模型就好像是在通向地狱的高速公路上铺设了一条减速带。在 Rust 中创建循环引用(两个值,每个值都包含指向另一个值的引用)相当困难。你必须使用智能指针类型(如 Rc
)和内部可变性(目前为止本书还未涉及这个主题)。Rust 更喜欢让指针、所有权和数据流单向通过系统,如图 5-11 所示。
图 5-11:树形对象关系
之所以现在提出这个问题,是因为在阅读本章后,你可能会很自然地想要立即编写代码并创建出大量的对象,所有对象之间使用 Rc
智能指针关联起来,最终呈现你熟悉的所有面向对象反模式。但此刻这还行不通。Rust 的所有权模型会不断给你制造麻烦。解决之道是进行一些前期设计并构建出更好的程序。
笔记 对程序良好的设计,但不要过度设计
Rust 就是要把你理解程序的痛苦从将来移到现在。它确实做到了:Rust 不仅会迫使你理解为什么自己的程序是线程安全的,甚至可能还需要你做一些高级架构设计。
笔记 如果能写出很不错Rust程序,代码简洁又优雅,这时候可能也是个不错的架构师了
欢迎大家讨论交流 Rust,如果喜欢本文章或感觉文章有用,动动你那发财的小手点个赞再走呗
^_^