Rust常见问题:所有权和可变性


使 Rust 成为如此出色的语言的相同概念可能会给新手带来问题:所有权、生命周期等。
并不是这些概念天生就很难;只是它们提供了其他语言没有的安全检查;这些安全检查可能会成为熟悉其他更宽容语言的人的障碍。

Rust 编译器严重依赖静态代码分析来查找内存错误。而其他语言(如 C 或 Java)允许开发人员编写可能导致不希望的状态(例如NullPointerException堆栈溢出)的代码,然后需要在运行时通过错误处理和异常来处理,Rust 不允许这样的代码编译。

在这篇文章中,我们将专注于初学者在开始他们的 Rust 世界之旅时会发现的常见问题。

试图修改不可变的变量
在下面的例子中,我们创建了一个名为Book的结构实例,并试图为其标题title属性分配不同的值。

struct Book {
    title: String
}
fn main() {
    let book = Book {
        title: String::from("The Rust Programming Language")
    };
    book.title = String::from(
"Another book")
}

如果我们试图编译这段代码,我们将得到以下错误。

error[E0594]: cannot assign to `book.title`, as `book` is not declared as mutable

解决方案
这第一个问题很容易发现。默认情况下,所有的Rust变量都被声明为不可变的(只读/恒定)。这一决定的目的是为了让开发者在期望变量改变数值时能够明确。

如果我们想让book变得可变,我们必须将其声明为let mut book。

struct Book {
    title: String
}
fn main() {
    let mut book = Book {
        title: String::from("The Rust Programming Language")
    };
    book.title = String::from(
"Another book")
}

在下面的例子中可以发现这种相同错误的另一种味道:

let x = 1;
x = 2;
| error[E0384]: cannot assign twice to immutable variable `x

同样的解决方案也适用。使用let mut x = 1来允许未来对x进行分配。


试图使用一个 "已被移动 moved"的变量
按照前面的例子,我们现在定义两个函数,接收一个Book的实例并尝试对它做一些事情(例如打印它的标题)。

fn print_book(book: Book) {
    println!("Book: {}", book.title);
}
fn do_something_else(book: Book) {
    println!(
"Again: {}", book.title);
}
fn main() {
    let book = Book {
        title: String::from(
"The Rust Programming Language")
    };
    print_book(book);
    do_something_else(book);
}

编译器错误:

error[E0382]: use of moved value: `book`
  --> src/main.rs:19:23
   |
14 |     let book = Book {
   |         ---- move occurs because `book` has type `Book`,
   |               which does not implement the `Copy` trait
...
18 |     print_book(book);
   |                ---- value moved here
19 |     do_something_else(book);
   |                       ^^^^ value used here after move


解决方案
这个问题是由Rust围绕数据引用的所有权规则造成的。

Rust没有像Java那样的垃圾收集器,也不要求开发者手动 "销毁 "变量引用以释放分配的内存。相反,它使用所有权来定义何时清除变量引用的内存。当执行到一个函数的末端时,该函数所拥有的所有变量将超出范围,其内存将被释放。

  1. 当程序开始时,主函数拥有book变量的所有权。
  2. 然后,当我们调用print_book(book)时,函数print_book现在拥有book。
  3. 当执行到print_book的结束时,book将超出范围,其数据被清除。

这方面有多种解决方案:

  1. 改变print_book,把书book的所有权还给main。
  2. 将print_book改为接收借来的book的引用。

1、把书book的所有权还给main
下面是第一种方式:

fn print_book(book: Book) -> Book {
    println!("Book: {}", book.title);
    book
}
fn do_something_else(book: Book) {
    println!(
"Again: {}", book.title);
}
fn main() {
    let mut book = Book {
        title: String::from(
"The Rust Programming Language")
    };
    book = print_book(book);
    do_something_else(book);
}

注意,我们改变了book的声明,使其成为可变的,所以我们可以重新分配其值。另外,我们还更新了print_book,使其在完成后返回Book。

我不太喜欢这种方法,因为我发现它很啰嗦,而且如果print_book被期望返回其他东西的话,就很难处理了。

2、使用借来的引用
以下代码将正确编译:

fn print_book(book: &Book) {
    println!("Book: {}", book.title);
}
fn do_something_else(book: Book) {
    println!(
"Again: {}", book.title);
}
fn main() {
    let book = Book {
        title: String::from(
"The Rust Programming Language")
    };
    print_book(&book);
    do_something_else(book);
}

注意我们更新了print_book来接收一个&Book的实例,它是对Book实例的一个不可变的引用。这就是所谓的借用。我们还将print_book(book)改为print_book(&book),以向函数发送一个book的引用。

现在,print_book不会得到book的所有权。它将收到只读的引用。当执行到print_book结束时,book不会超出范围,因为main仍然拥有它。

请记住,在这个例子中,do_something_else确实获得了book的所有权,这是完全合法的。

另一个解决方案是听从编译器的建议,为Book实现Copy属性,这样在传递给其他函数时就会创建book的副本。但这样我们就会使用不同的book实例,这有一些缺点:

  • 额外的内存分配。如果book很大,而我们每次把它传给一个函数时都会复制它,我们就会不必要地消耗内存。
  • 不能很好地发挥变化的作用。如果其中一个函数要改变book的内容,那么我们必须返回副本,以便下一个函数可以访问book的更新值。这导致了与返回所有权相同的流程,但有额外的步骤。

试图在同一作用域内借用为可变和不可变的
现在,我们将用一个新的函数来修改书的标题,并用另一个函数来打印它。下面的代码将正确编译。

fn rename_book(book: &mut Book) {
    book.title = String::from("Something else");
}
fn print_title(book: &Book) {
    println!(
"Book: {}", book.title);
}
fn main() {
    let mut book = Book {
        title: String::from(
"The Rust Programming Language")
    };
    rename_book(&mut book);
    print_title(&book);
}

然而,你会发现,做下面的事情--似乎实际上是在做同样的事情--并不能编译。

fn rename_book(book: &mut Book) {
    book.title = String::from("Something else");
}
fn print_title(book: &Book) {
    println!(
"Book: {}", book.title);
}
fn main() {
    let mut book = Book {
        title: String::from(
"The Rust Programming Language")
    };
    let mut book1 = &mut book;
    let book2 = &book;
    rename_book(&mut book1);
    print_title(&book2);
}
error[E0502]: cannot borrow `book` as immutable because it is also borrowed as mutable
  --> src/main.rs:20:17
   |
19 |     let mut book1 = &mut book;
   |                     --------- mutable borrow occurs here
20 |     let book2 = &book;
   |                 ^^^^^ immutable borrow occurs here
21 |
22 |     rename_book(&mut book1);
   |                 ---------- mutable borrow later used here

解决方案
Rust不允许我们在同一作用域内创建一个对象的可变和不可变的引用。

在第一个例子中,实际的借用发生在执行到每个rename_book和print_title函数时,所以每次只有一个借用的引用。

虽然这个问题最明显的解决方案是使用第一个例子,在这个例子中一切都能正确编译,但让我们探索一个更有创意的解决方案,这将对所有权规则有一些启发。

fn main() {
    let mut book = Book {
        title: String::from("The Rust Programming Language")
    };
    {
        let mut book1 = &mut book;
        rename_book(&mut book1);
    }
    let book2 = &book;
    print_title(&book2);
}

这个小小的变通方法使我们的代码又可以编译了。
当我们用一个块{}包裹book1和rename_book的借入实例时,我们为它们创建了一个新的作用域。
一旦执行到该块的末尾,在该块内声明的任何变量都将超出作用域范围。那么,当执行到let book2的时候,book1就不存在了,我们就只有一个借用。

试图保留对一个结构值的引用
当我们从其他语言转过来,我们试图为一个对象的属性保留一个引用是很常见的。

fn main() {
    let mut book = Book {
        title: String::from("The Rust Programming Language")
    };
    let title = book.title;
    rename_book(&mut book);
    println!(
"Title: {}", title);
}

虽然这在其他语言中是正确的,但Rust会抱怨。

error[E0382]: borrow of partially moved value: `book`
  --> src/main.rs:20:17
   |
18 |     let title = book.title;
   |                 ---------- value partially moved here
19 |
20 |     rename_book(&mut book);
   |                 ^^^^^^^^^ value borrowed here after partial move


解决办法
当我们做 let title = book.title 时,我们将标题title属性的所有权从 book 传递给了主函数,这意味着我们不能在 rename_book 中使用 book。我们可以将title的所有权交还给book,但这将使我们无法单独使用title变量。

为了解决这个问题,我们可以克隆标题,如let title = book.title.clone()。然而,这种方法只有在以下情况下才有效:第一,title实现了Clone特性(字符串默认是这样的);第二,无论我们用title做什么,都不希望得到book.title的实际值。

fn rename_book(book: &mut Book) {
    book.title = String::from("Something else");
}
fn print_title(book: &Book) {
    println!(
"Title: {}", book.title);
}
fn main() {
    let mut book = Book {
        title: String::from(
"The Rust Programming Language")
    };
    let title = book.title.clone();
    rename_book(&mut book);
    println!(
"Title: {}", title);
    print_title(&book);
}
///////////
Title: The Rust Programming Language
Title: Something else

如果我们想保留对book.title的真实引用,我们必须借用它的引用,注意不要在同一作用域范围内创建不可变和可变的借用。


fn main() {
    let mut book = Book {
        title: String::from("The Rust Programming Language")
    };
    {
        rename_book(&mut book);
    }
    let title = &book.title;
    println!(
"Title: {}", title);
    print_title(&book);
}
/////////
Title: Something else
Title: Something else

当然,这只是一个非常自足的例子,在其他情况下是行不通的。例如,如果我们想保留对book.title的引用--与book无关--并从一个函数中返回它,该怎么办?

fn create_book() -> (Book, &String) {
    let book = Book {
        title: String::from("The Rust Programming Language")
    };
    return (book, &book.title);
}
fn main() {
    let (mut book, title) = create_book();
    rename_book(&mut book);
    println!(
"{}", &title);
}

错误:
error[E0106]: missing lifetime specifier
  --> src/main.rs:16:28
   |
16 | fn create_book() -> (Book, &String) {
   |                            ^ expected named lifetime parameter
   |
   = help: this function's return type contains a borrowed value,
    but there is no value for it to be borrowed from
help: consider using the `'static` lifetime

我们不会深入讨论生命周期寿命的话题,但Rust的意思是,由于我们要返回一个借来的对String的引用,所以开发者需要在返回类型声明中添加一个生命周期lifetime寿命,以让Rust知道这个引用预计能活多久。

我们可以尝试--剧透一下,不成功--听从Rust编译器的建议,用一个 "静态寿命static lifetime "来注释&String。

fn create_book() -> (Book, &'static String) {
    let book = Book {
        title: String::from("The Rust Programming Language")
    };
    return (book, &book.title);
}
error[E0382]: borrow of moved value: `book`
  --> src/main.rs:21:19
   |
17 |     let book = Book {
   |         ---- move occurs because `book` has type `Book`, which does not implement the `Copy` trait
...
21 |     return (book, &book.title);
   |             ----  ^^^^^^^^^^^ value borrowed here after move
   |             |
   |             value moved here

在create_book函数结束时,book被移出了函数,所有权被传递给了调用者(main());正因为如此,我们无法借用book.title(这与我们在本帖第一个例子中发现的问题相同)。

即使我们试图存储书book的title的借用引用,我们也不能直接返回book.title的借用引用。

fn create_book() -> (Book, &'static String) {
    let book = Book {
        title: String::from("The Rust Programming Language")
    };
    let title = &book.title;
    return (book, title);
}
error[E0515]: cannot return value referencing local data `book.title`
  --> src/main.rs:23:12
   |
21 |     let title = &book.title;
   |                 ----------- `book.title` is borrowed here
22 |
23 |     return (book, title);
   |            ^^^^^^^^^^^^^ returns a value referencing data owned by the current function

即使看起来我们把title的所有权传给了main(),实际上,我们是想返回一个由create_book拥有的变量的引用;这样的变量也会在create_book结束时超出作用域范围。

我们在这里有什么选择?

使用Rc来创建不可变的引用
基本的所有权规则在较简单的应用程序中运行良好。然而,更大、更复杂的代码将不可避免地需要获得一个对象的多个引用,并在各函数间传递。对于这些情况,std::rc::Rc-引用计数器-是一个方便的工具。


引用计数器允许我们为一个对象创建多个不可变的引用。这些引用可以安全地从一个函数传递到另一个函数。

use std::rc::Rc;
struct Book {
    title: Rc<String>
}
fn create_book() -> (Book, Rc<String>) {
    let book = Book {
        title: Rc::new(String::from("The Rust Programming Language"))
    };
    let title = Rc::clone(&book.title);
    return (book, title);
}
fn main() {
    let (book, title) = create_book();
    println!(
"{}", book.title);
    println!(
"{}", title);
}
////////////
The Rust Programming Language
The Rust Programming Language

在引擎盖下,Rc对已经创建的引用的数量进行统计。一旦所有的引用超出了作用域范围,它们所指向的对象就会从内存中被删除。在某种程度上,Rc是一个非常简化的垃圾收集器

这个例子有点误导,因为看起来我们是在克隆book.title的值,其实我们是在克隆它的一个引用。如果我们更新book.title的值,这个区别就更清楚了。

使用Rc和RefCell来创建可变的引用
默认情况下,引用计数是不可变的。如果我们想更新book.title,我们必须引入RefCell来使title成为可变的引用。

use std::rc::Rc;
use std::cell::RefCell;
struct Book {
    title: Rc<RefCell<String>>
}
fn create_book() -> (Book, Rc<RefCell<String>>) {
    let book = Book {
        title: Rc::new(RefCell::new(String::from("The Rust Programming Language")))
    };
    let title = Rc::clone(&book.title);
    return (book, title);
}
fn rename_book(book: &Book) {
    book.title.replace(String::from(
"Something else"));
}
fn main() {
    let (book, title) = create_book();
    rename_book(&book);
    println!(
"{}", book.title.borrow());
    println!(
"{}", title.borrow());
}
输出:

Something else
Something else

通过这个例子,我们展示了两点:

  1. Rc::clone(&book.title)返回对book.title引用的克隆,而不是对book.title值的克隆。否则,println! ("{}", title.borrow())会打印出旧的值 "The Rust Programming Language"。
  2. 为book.title使用Rc<RefCell<String>>变量类型,允许我们创建其引用的副本,并改变其值。

关于Rc和RefCell的警告
虽然Rc和RefCell允许我们创建一个更复杂的工作流程,但它们也增加了你的应用程序的复杂性。仅仅从人机工程学的角度来看,用<Rc<RefCell<T>>来传递变量很快就会变得非常冗长。

另外,在不需要的时候使用引用计数器会增加内存泄漏的风险。如果我们把一个引用副本保留太久,它所指向的底层内存可能不会被释放。在我们使用Rust实现HTTP服务器等东西的情况下,我们可能会积累不使用的对象的引用副本,增加消耗的内存量。这在Java等语言中是一个常见的问题,保留对未使用的变量的引用也会导致内存泄漏,而垃圾收集器无法修复。

在可能的情况下,我们必须避免过度使用这些工具,并尽量按照所有权的基本规则来工作。

结论
Rust的所有权和可变性规则与其他语言执行内存安全的方式完全不同。虽然这些工具会让新人在学习过程中感到困难,但它们也是Rust如此安全和轻量级的原因。

不仅所有权允许Rust在没有垃圾收集器等工具的情况下运行,而且它还迫使开发人员更深入地思考在他们的应用程序中如何分配、使用和释放内存。像C和C++这样的语言也提供了这种程度的控制,但却没有Rust的编译器所执行的保障措施。

虽然看起来Rust编译器抛出了太多的错误,但这是为了我们自己好。在编译过程中抓住这些问题比在执行时试图调试它们要好得多,在生产环境中很多时候都是如此。