第 7 章:借用(Borrowing)与引用(References)——“我可以看看,但不能拿走”

在上一章的结尾,我们遇到了一个难题:如果我们只是想让一个函数读取一下数据,却不得不将数据的所有权交出去,用完后再由函数返还。这个过程就像是为了让朋友开一下你的车,却需要办理一次完整的车辆过户手续,开完后再给你过户回来一样,既笨拙又低效。

// 上一章的笨拙模式
fn calculate_length(s: String) -> (String, usize) {
    let length = s.len();
    (s, length)
}

这显然不是理想的编程方式。幸运的是,Rust 提供了一个优雅得多的解决方案,它就是借用(Borrowing)

借用的概念很简单,正如本章的标题一样:你可以查看我的数据,甚至在得到许可的情况下修改它,但你不能把它拿走(即不能获得它的所有权)。

实现借用的机制,就是我们既熟悉又陌生的引用(References)

不可变引用(&T)——“只读”的借阅

在 Java 中,我们传递的几乎都是对象的“引用”。Rust 的引用概念类似,但受到了所有权系统极其严格的监管。

我们可以创建一个指向某个值的引用,而无需获取其所有权。因为不发生所有权转移,所以当引用离开其作用域时,它所指向的值也不会被丢弃(drop)。

创建一个引用的操作符是 &

现在,让我们用引用的方式,来重写那个笨拙的 calculate_length 函数:

fn main() {
    let s1 = String::from("hello");

    // 我们传递的不是 s1 本身,而是 s1 的引用
    // &s1 创建了一个指向 s1 的引用,但 s1 仍然是数据的所有者
    let len = calculate_length(&s1);

    // s1 在这里依然完全有效,因为它的所有权从未被移动
    println!("The length of '{}' is {}.", s1, len);
}

// 函数签名接收的类型是 &String,而不是 String
fn calculate_length(s: &String) -> usize { // s 是一个 String 的引用
    s.len()
} // s 在这里离开作用域。但因为它并不拥有所指向的数据,
  // 所以什么也不会发生,堆上的数据不会被释放。

看,这多么清爽!main 函数中的 s1 依然是数据的所有者,它只是将数据“借给”了 calculate_length 函数一小会儿。

我们把“创建一个引用”这个行为称为借用(borrowing)

默认情况下,借用是不可变的。就像你在图书馆借了一本珍贵的古籍,你只能阅读,不能在上面涂画。

fn change(s: &String) {
    s.push_str(", world"); // 编译错误!
}

编译器会阻止我们这么做,并提示 s 是一个不可变引用,不能修改它所借用的内容。

与 Java 的对比: 这感觉很像在 Java 里传递一个对象引用。但关键区别在于:Rust 在编译期就强制了这种不可变性。 在 Java 中,你无法在方法签名上阻止调用者执行一个 setter 方法来修改对象。而在 Rust 中,&String 这个类型签名本身就是一份“我绝不会修改数据”的契约,由编译器强制执行。

可变引用(&mut T)——“可读写”的授权

那么,如果我们确实需要一个函数来修改它借用的数据呢?Rust 当然也提供了这种可能,但这需要一个更明确的“授权”:可变引用(mutable reference)

使用 &mut 来创建一个可变引用。

fn main() {
    // 首先,变量本身必须是可变的
    let mut s = String::from("hello");

    // 传递一个可变引用
    change(&mut s);

    println!("{}", s); // 输出 "hello, world"
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

这里有两点需要注意:

  1. 变量 s 本身必须用 let mut 声明为可变的。
  2. 创建引用时必须使用 &mut

这再次体现了 Rust 的**明确性(explicitness)**原则。你必须在两个地方都清楚地表明你的意图,才能修改数据。

借用规则——编译器执行的“图书馆法则”

借用虽然强大,但也必须遵守一套严格的规则。正是这套规则,让 Rust 能够在没有 GC 的情况下,从源头上杜绝数据竞争(Data Races)。数据竞争通常发生在多线程环境下:

  • 两个或更多的指针(引用)同时访问同一块数据。
  • 其中至少有一个指针在写数据。
  • 没有使用任何同步机制。

Rust 的借用规则在编译期就彻底防止了这种情况的发生。

借用第一定律:在任何给定的作用域内,对于一块数据,你只能拥有以下两种情况之一:

  • 一个可变引用 (&mut T)。
  • 任意数量的不可变引用 (&T)。

借用第二定律:引用必须始终有效。

让我们看看违反规则时会发生什么:

违反定律一:一个可变引用 vs 任意数量的不可变引用
  • 你不能同时拥有多个可变引用:

    let mut s = String::from("hello");
    
    let r1 = &mut s;
    let r2 = &mut s; // 编译错误!
    
    println!("{}, {}", r1, r2);

    为什么? 如果 r1r2 都能同时修改 s,那么修改的顺序和结果将变得不可预测,这就是数据竞争的温床。Rust 在编译期就禁止了这种可能。

  • 你不能在拥有不可变引用的同时,再创建一个可变引用:

    let mut s = String::from("hello");
    
    let r1 = &s; // 不可变借用
    let r2 = &s; // 不可变借用,OK
    let r3 = &mut s; // 编译错误!
    
    println!("{}, {}, and {}", r1, r2, r3);

    为什么? 如果你已经把书借给 r1r2 去“只读”,你就得保证书的内容不会在他们阅读期间被篡改。r3 的存在打破了这个承诺。这保证了数据的使用者在持有不可变引用期间,数据是稳定不变的。

一个重要的细节:引用的作用域

引用的作用域从它被创建开始,一直持续到它最后一次被使用的地方。这意味着,如果引用的作用域不重叠,你可以创建多个引用:

let mut s = String::from("hello");

let r1 = &s; // r1 的借用开始
let r2 = &s; // r2 的借用开始
println!("{} and {}", r1, r2); // r1 和 r2 最后一次被使用,它们的借用作用域到此结束

let r3 = &mut s; // 现在是合法的!因为 r1 和 r2 的生命已经结束
r3.push_str(", world");
println!("{}", r3);
违反定律二:悬垂引用(Dangling References)

引用必须指向一块有效的内存。Rust 编译器同样会保证你不会意外地创建一个指向无效内存的“悬垂引用”。

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String { // dangle 函数返回一个 String 的引用
    let s = String::from("hello"); // s 是一个局部变量

    &s // 返回 s 的引用
} // s 在这里离开作用域,它拥有的 String 被 drop,内存被释放!

dangle 函数的结尾,s 的生命结束了,它占用的内存被释放。那么函数返回的引用将指向一块已经被回收的、无效的内存。在 C/C++ 中,这是一个能导致程序崩溃或安全漏洞的严重 bug。

而 Rust 编译器绝不会让这样的代码通过编译。它会提示你,返回的引用指向的数据生命周期不够长。正确的做法是直接返回 String 本身,将所有权移出函数:

fn gives_ownership() -> String {
    let some_string = String::from("hello");
    some_string // 所有权被移动到调用者
}

本章小结

我们今天掌握了所有权系统的另一半——借用。这个机制让 Rust 的代码既安全又高效。

  • 借用允许我们在不转移所有权的情况下使用值。
  • 引用是实现借用的工具,分为不可变引用(&T)和可变引用(&mut T
  • 借用规则(一个可变 vs. 多个不可变)是 Rust 在编译期防止数据竞争的核心武器,也是其“无畏并发”(Fearless Concurrency)的基石。
  • 编译器会防止我们创建悬垂引用,保证所有引用都指向有效数据。

我们现在已经学习了 Rust 安全性的两大支柱:所有权和借用。你可能已经感觉到,编译器有时就像一个喋喋不休的老师,不断地检查你的“作业”。但它的每一次唠叨,都是在为你消除一个潜在的、致命的运行时 bug。

但是,当代码变得更复杂,比如在一个结构体里存放一个引用时,编译器可能无法自动推断出引用的生命周期是否有效。此时,我们就需要一种方式,来手动地告诉编译器各个引用之间的“存活关系”。

在下一章,我们将挑战这个主题的“最终 Boss”——(《生命周期(Lifetimes)——与编译器做个“约定”》)[https://silentstormic.top/post/from_java_to_rust/08/]。掌握它,你将真正成为一名合格的 Rustacean。