修复所有权错误

学习如何修复所有权错误是一项核心的 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 函数很少取得堆所有数据结构(如 VecString)的所有权。 这个版本的 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 之后,vs 都认为它们拥有 “Hello world”。在 s 被 drop 之后,“Hello world” 被释放。然后 v 被 drop,当字符串被第二次释放时,未定义行为发生。

注意: 在执行 s = *s_ref 之后,我们甚至不必使用 vs 来通过双重释放引起未定义行为。一旦我们将字符串从 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.0name.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 库中,尤其是像 Vecslice 这样的核心类型中,你经常会发现 unsafeunsafe 块允许使用“原始”指针,借用检查器不会检查其安全性。例如,我们可以使用 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 如何实现某些原本不可能的模式的方式。

总结

当修复所有权错误时,你应该问自己:我的程序实际上是不安全的吗?如果是,那么你需要理解不安全性的根本原因。如果不是,那么你需要理解借用检查器的局限性以绕过它们。

1

此保证适用于用 Rust 的“安全子集”编写的程序。如果你使用 unsafe 代码或调用不安全组件(例如调用 C 库),则必须格外小心以避免未定义行为。