修复所有权错误
学习如何修复所有权错误是一项核心的 Rust 技能。当借用检查器拒绝你的代码时,你该如何应对?在本节中,我们将讨论几个常见所有权错误的案例研究。每个案例研究都将展示一个被编译器拒绝的函数。然后我们将解释 Rust 拒绝该函数的原因,并展示几种修复它的方法。
一个常见的主题是理解一个函数是真正安全还是不安全的。Rust 总是会拒绝不安全的程序1。但有时,Rust 也会拒绝安全的程序。这些案例研究将展示如何在两种情况下应对错误。
修复不安全程序:返回栈上数据的引用
我们的第一个案例研究是关于返回栈上数据的引用,就像我们在上一节 “数据必须比它的所有引用都存在更久” 中讨论的那样。这是我们看过的函数
fn return_a_string() -> &String {
let s = String::from("Hello world");
&s
}
当思考如何修复这个函数时,我们需要问:为什么这个程序是不安全的? 这里,问题在于被引用数据的生命周期。如果你想传递一个字符串的引用,你必须确保底层的字符串存活足够长的时间。
根据具体情况,这里有四种方法可以延长字符串的生命周期。一种是将字符串的所有权移出函数,将 &String
更改为 String
#![allow(unused)] fn main() { fn return_a_string() -> String { let s = String::from("Hello world"); s } }
另一种可能性是返回字符串字面量,它永远存在(用 'static
表示)。如果我们从不打算更改字符串,那么这种解决方案适用,并且堆分配是不必要的
#![allow(unused)] fn main() { fn return_a_string() -> &'static str { "Hello world" } }
另一种可能性是通过使用垃圾回收将借用检查推迟到运行时。例如,你可以使用 引用计数指针
#![allow(unused)] fn main() { use std::rc::Rc; fn return_a_string() -> Rc<String> { let s = Rc::new(String::from("Hello world")); Rc::clone(&s) } }
我们将在第 15.4 节 “Rc<T>
,引用计数智能指针” 中更详细地讨论引用计数。简而言之,Rc::clone
仅克隆指向 s
的指针,而不是数据本身。在运行时,Rc
检查何时指向数据的最后一个 Rc
被 drop,然后释放数据。
还有一种可能性是让调用者提供一个“槽”来放置字符串,使用可变引用
#![allow(unused)] fn main() { fn return_a_string(output: &mut String) { output.replace_range(.., "Hello world"); } }
通过这种策略,调用者负责为字符串创建空间。这种风格可能很冗长,但如果调用者需要仔细控制何时进行分配,那么它也可能更节省内存。
哪种策略最合适将取决于你的应用程序。但关键思想是认识到表面所有权错误背后的根本问题。我的字符串应该存活多久?谁应该负责释放它?一旦你对这些问题有了明确的答案,那么剩下的就是更改你的 API 以匹配。
修复不安全程序:权限不足
另一个常见问题是尝试修改只读数据,或尝试 drop 引用后面的数据。例如,假设我们尝试编写一个函数 stringify_name_with_title
。此函数应从名称部分的 vector 创建一个人的全名,包括额外的职称。
此程序被借用检查器拒绝,因为 name
是不可变引用,但 name.push(..)
需要 W 权限。此程序是不安全的,因为 push
可能会使 stringify_name_with_title
之外的其他 name
引用无效,例如这样
在此示例中,在调用 stringify_name_with_title
之前创建了对 name[0]
的引用 first
。函数 name.push(..)
重新分配了 name
的内容,这使 first
无效,导致 println
读取已释放的内存。
那么我们如何修复这个 API 呢?一种直接的解决方案是将 name 的类型从 &Vec<String>
更改为 &mut Vec<String>
fn stringify_name_with_title(name: &mut Vec<String>) -> String {
name.push(String::from("Esq."));
let full = name.join(" ");
full
}
但这不是一个好的解决方案!如果调用者不期望,函数不应修改其输入。 调用 stringify_name_with_title
的人可能不希望他们的 vector 被此函数修改。另一个函数(如 add_title_to_name
)可能会被期望修改其输入,但我们的函数不是。
另一种选择是取得 name 的所有权,通过将 &Vec<String>
更改为 Vec<String>
fn stringify_name_with_title(mut name: Vec<String>) -> String {
name.push(String::from("Esq."));
let full = name.join(" ");
full
}
但这也不是一个好的解决方案!Rust 函数很少取得堆所有数据结构(如 Vec
和 String
)的所有权。 这个版本的 stringify_name_with_title
会使输入 name
无法使用,这对调用者来说非常烦人,正如我们在 “引用与借用” 的开头讨论的那样。
因此,选择 &Vec
实际上是一个不错的选择,我们不想更改它。相反,我们可以更改函数的主体。有很多可能的修复方法,它们在内存使用量方面有所不同。一种可能性是克隆输入 name
fn stringify_name_with_title(name: &Vec<String>) -> String {
let mut name_clone = name.clone();
name_clone.push(String::from("Esq."));
let full = name_clone.join(" ");
full
}
通过克隆 name
,我们被允许修改 vector 的本地副本。但是,克隆会复制输入中的每个字符串。我们可以通过稍后添加后缀来避免不必要的复制
fn stringify_name_with_title(name: &Vec<String>) -> String {
let mut full = name.join(" ");
full.push_str(" Esq.");
full
}
这个解决方案有效,因为 slice::join
已经将 name
中的数据复制到字符串 full
中。
一般来说,编写 Rust 函数是在请求正确的权限级别之间仔细权衡。对于此示例,最符合习惯的做法是仅期望对 name
具有读取权限。
修复不安全程序:别名和修改数据结构
另一个不安全的操作是使用对堆数据的引用,该堆数据被另一个别名释放。例如,这是一个函数,它获取对 vector 中最大字符串的引用,然后在修改 vector 时使用它
注意: 此示例使用 迭代器 和 闭包 来简洁地找到对最大字符串的引用。我们将在后面的章节中讨论这些特性,现在我们将提供对这些特性如何工作的直观理解。
此程序被借用检查器拒绝,因为 let largest = ..
移除了 dst
上的 W 权限。但是,dst.push(..)
需要 W 权限。同样,我们应该问:为什么这个程序是不安全的? 因为 dst.push(..)
可能会释放 dst
的内容,使引用 largest
无效。
要修复程序,关键的见解是我们需要缩短 largest
的生命周期,使其不与 dst.push(..)
重叠。一种可能性是克隆 largest
#![allow(unused)] fn main() { fn add_big_strings(dst: &mut Vec<String>, src: &[String]) { let largest: String = dst.iter().max_by_key(|s| s.len()).unwrap().clone(); for s in src { if s.len() > largest.len() { dst.push(s.clone()); } } } }
但是,这可能会导致分配和复制字符串数据的性能损失。
另一种可能性是首先执行所有长度比较,然后在之后修改 dst
#![allow(unused)] fn main() { fn add_big_strings(dst: &mut Vec<String>, src: &[String]) { let largest: &String = dst.iter().max_by_key(|s| s.len()).unwrap(); let to_add: Vec<String> = src.iter().filter(|s| s.len() > largest.len()).cloned().collect(); dst.extend(to_add); } }
但是,这也导致分配 vector to_add
的性能损失。
最后一种可能性是复制出 largest
的长度,因为我们实际上不需要 largest
的内容,只需要它的长度。这种解决方案可以说是最符合习惯且性能最高的
#![allow(unused)] fn main() { fn add_big_strings(dst: &mut Vec<String>, src: &[String]) { let largest_len: usize = dst.iter().max_by_key(|s| s.len()).unwrap().len(); for s in src { if s.len() > largest_len { dst.push(s.clone()); } } } }
这些解决方案都共同具有一个关键思想:缩短对 dst
的借用的生命周期,使其不与对 dst
的修改重叠。
修复不安全程序:复制与移出集合
Rust 学习者常见的困惑发生在从集合(如 vector)复制数据出来时。例如,这是一个安全的程序,它从 vector 中复制一个数字
解引用操作 *n_ref
仅需要 R 权限,路径 *n_ref
具有该权限。但是,如果我们将 vector 中元素的类型从 i32
更改为 String
会发生什么?然后结果证明我们不再具有必要的权限
第一个程序将编译,但第二个程序将不会编译。Rust 给出以下错误消息
error[E0507]: cannot move out of `*s_ref` which is behind a shared reference
--> test.rs:4:9
|
4 | let s = *s_ref;
| ^^^^^^
| |
| move occurs because `*s_ref` has type `String`, which does not implement the `Copy` trait
问题在于 vector v
拥有字符串 “Hello world”。当我们解引用 s_ref
时,它试图从 vector 中取得字符串的所有权。但是引用是非所有权指针——我们不能通过引用取得所有权。因此,Rust 抱怨我们“无法移出 […] 共享引用”。
但是为什么这是不安全的呢?我们可以通过模拟被拒绝的程序来说明问题
这里发生的是双重释放。 在执行 let s = *s_ref
之后,v
和 s
都认为它们拥有 “Hello world”。在 s
被 drop 之后,“Hello world” 被释放。然后 v
被 drop,当字符串被第二次释放时,未定义行为发生。
注意: 在执行
s = *s_ref
之后,我们甚至不必使用v
或s
来通过双重释放引起未定义行为。一旦我们将字符串从s_ref
中移出,未定义行为将在元素被 drop 时发生。
但是,当 vector 包含 i32
元素时,这种未定义行为不会发生。区别在于复制 String
会复制指向堆数据的指针。复制 i32
则不会。用技术术语来说,Rust 说类型 i32
实现了 Copy
trait,而 String
没有实现 Copy
(我们将在后面的章节中讨论 trait)。
总而言之,如果一个值不拥有堆数据,那么它可以被复制而无需移动。 例如
i32
不拥有堆数据,所以它可以被复制而无需移动。String
确实拥有堆数据,所以它不能被复制而无需移动。&String
不拥有堆数据,所以它可以被复制而无需移动。
注意: 此规则的一个例外是可变引用。例如,
&mut i32
不是可复制类型。所以如果你做类似的事情let mut n = 0; let a = &mut n; let b = a;
那么在赋值给
b
之后,a
不能再使用。这防止了同时使用对同一数据的两个可变引用。
因此,如果我们有一个非 Copy
类型(如 String
)的 vector,那么我们如何安全地访问 vector 的元素呢?这里有几种安全的方法。首先,你可以避免取得字符串的所有权,而只使用不可变引用
fn main() {
let v: Vec<String> = vec![String::from("Hello world")];
let s_ref: &String = &v[0];
println!("{s_ref}!");
}
其次,如果你想取得字符串的所有权,同时保持 vector 不变,你可以克隆数据
fn main() {
let v: Vec<String> = vec![String::from("Hello world")];
let mut s: String = v[0].clone();
s.push('!');
println!("{s}");
}
最后,你可以使用像 Vec::remove
这样的方法将字符串移出 vector
fn main() {
let mut v: Vec<String> = vec![String::from("Hello world")];
let mut s: String = v.remove(0);
s.push('!');
println!("{s}");
assert!(v.len() == 0);
}
修复安全程序:修改不同的元组字段
以上示例是不安全程序的情况。Rust 也可能拒绝安全程序。一个常见的问题是 Rust 尝试在细粒度级别跟踪权限。但是,Rust 可能会将两个不同的位置混淆为同一个位置。
让我们首先看一个细粒度权限跟踪的示例,该示例通过了借用检查器。此程序显示了如何借用元组的一个字段,并写入同一元组的不同字段
语句 let first = &name.0
借用了 name.0
。此借用从 name.0
中移除了 WO 权限。它还从 name
中移除了 WO 权限。(例如,不能将 name
传递给以 (String, String)
类型的值作为输入的函数。)但 name.1
仍然保留 W 权限,因此执行 name.1.push_str(...)
是有效操作。
但是,Rust 可能会丢失对哪些位置被借用的确切跟踪。例如,假设我们将表达式 &name.0
重构为一个函数 get_first
。请注意,在调用 get_first(&name)
之后,Rust 现在移除了 name.1
上的 W 权限
现在我们不能执行 name.1.push_str(..)
了!Rust 将返回此错误
error[E0502]: cannot borrow `name.1` as mutable because it is also borrowed as immutable
--> test.rs:11:5
|
10 | let first = get_first(&name);
| ----- immutable borrow occurs here
11 | name.1.push_str(", Esq.");
| ^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
12 | println!("{first} {}", name.1);
| ----- immutable borrow later used here
这很奇怪,因为程序在我们编辑之前是安全的。我们所做的编辑并没有实际改变运行时行为。那么为什么我们将 &name.0
放入函数中会有影响呢?
问题在于,当决定 get_first(&name)
应该借用什么时,Rust 不会查看 get_first
的实现。Rust 只查看类型签名,类型签名只是说“输入中的某个 String
被借用”。然后 Rust 保守地决定 name.0
和 name.1
都被借用,并消除了两者的写入和所有权权限。
请记住,关键思想是上面的程序是安全的。 它没有未定义行为!未来版本的 Rust 可能会足够智能以使其编译,但就今天而言,它被拒绝了。那么我们今天应该如何绕过借用检查器呢?一种可能性是像原始程序中那样内联表达式 &name.0
。另一种可能性是使用 cells 将借用检查推迟到运行时,我们将在以后的章节中讨论它。
修复安全程序:修改不同的数组元素
当我们借用数组元素时,也会出现类似的问题。例如,观察当我们获取数组的可变引用时,哪些位置被借用
Rust 的借用检查器不包含 a[0]
、a[1]
等的不同位置。它使用单个位置 a[_]
来表示 a
的所有索引。Rust 这样做是因为它并非总是可以确定索引的值。例如,想象一下更复杂的场景,例如这样
let idx = a_complex_function();
let x = &mut a[idx];
idx
的值是多少?Rust 不会猜测,因此它假设 idx
可能是任何值。例如,假设我们尝试从一个数组索引读取,同时写入另一个数组索引
但是,Rust 会拒绝此程序,因为 a
将其读取权限授予了 x
。编译器的错误消息也说明了同样的事情
error[E0502]: cannot borrow `a[_]` as immutable because it is also borrowed as mutable
--> test.rs:4:9
|
3 | let x = &mut a[1];
| --------- mutable borrow occurs here
4 | let y = &a[2];
| ^^^^^ immutable borrow occurs here
5 | *x += *y;
| -------- mutable borrow later used here
同样,这个程序是安全的。 对于这种情况,Rust 通常在标准库中提供一个可以绕过借用检查器的函数。例如,我们可以使用 slice::split_at_mut
fn main() {
let mut a = [0, 1, 2, 3];
let (a_l, a_r) = a.split_at_mut(2);
let x = &mut a_l[1];
let y = &a_r[0];
*x += *y;
}
你可能想知道,但是 split_at_mut
是如何实现的?在某些 Rust 库中,尤其是像 Vec
或 slice
这样的核心类型中,你经常会发现 unsafe
块。unsafe
块允许使用“原始”指针,借用检查器不会检查其安全性。例如,我们可以使用 unsafe 块来完成我们的任务
fn main() {
let mut a = [0, 1, 2, 3];
let x = &mut a[1] as *mut i32;
let y = &a[2] as *const i32;
unsafe { *x += *y; } // DO NOT DO THIS unless you know what you're doing!
}
不安全代码有时是必要的,以绕过借用检查器的限制。作为一个通用策略,假设借用检查器拒绝了你认为实际上是安全的程序。那么你应该寻找标准库函数(如 split_at_mut
),这些函数包含解决你的问题的 unsafe
块。我们将在 第 20 章 中进一步讨论不安全代码。现在,请注意,不安全代码是 Rust 如何实现某些原本不可能的模式的方式。
总结
当修复所有权错误时,你应该问自己:我的程序实际上是不安全的吗?如果是,那么你需要理解不安全性的根本原因。如果不是,那么你需要理解借用检查器的局限性以绕过它们。
此保证适用于用 Rust 的“安全子集”编写的程序。如果你使用 unsafe
代码或调用不安全组件(例如调用 C 库),则必须格外小心以避免未定义行为。