阶段二 · 所有权系统

借用,引用与切片

一句话总结

借用(Borrow)让不夺走所有权地访问数据成为可能,通过引用 &T(只读)和 &mut T(可写)实现.
借用检查器有一条铁律:共享不可变,可变不共享.它在编译期消灭了数据竞争与悬垂引用.
切片(&[T],&str)则是借用集合一部分的"窗口".

解决所有权的"繁琐"

上一篇结尾的痛点:只想读一下数据,却要把所有权传进函数再传回来.借用就是为此而生--它让函数能访问数据而不拥有数据.

fn main() {
let s = String::from("hello");
let len = calculate_length(&s); // 传入引用,而非 s 本身
println!("'{s}' 的长度是 {len}"); // s 依然有效!
}

fn calculate_length(text: &String) -> usize {
text.len()
} // text 只是引用,离开作用域不会 drop 底层数据

&s 创建一个指向 s 的引用,函数借用了 s 但不拥有它;函数结束,引用消失,s 安然无恙.

所有权是把书送人,借用是把书借给人看.
借阅者能读,读完归还,书还是原来的.Rust 的 & 就是那张借书凭证.

可变借用

默认引用是只读的.想通过引用修改数据,需要可变引用 &mut,且原变量本身必须是 mut:

fn main() {
let mut s = String::from("hello");
change(&mut s);
println!("{s}"); // hello, world
}

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

借用规则:全篇核心

可变引用能力更强,Rust 因此施加一条核心约束.这是本系列最该刻进肌肉记忆的规则:

借用检查器的铁律

在任意给定的作用域内,对同一份数据,要么:

  • 存在任意多个不可变引用 &T(共享只读),
  • 要么存在恰好一个可变引用 &mut T(独占读写).

两者不能同时存在.一句话:共享不可变,可变不共享.

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

let r1 = &s; // 第一个不可变引用,OK
let r2 = &s; // 第二个不可变引用,OK(多个只读没问题)
println!("{r1} {r2}");

let r3 = &mut s; // 可变引用,此时上面的 r1/r2 已不再使用,OK
r3.push_str("!");

但下面这样就会被拒绝:

let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // 编译错误:同时存在两个可变借用
println!("{r1} {r2}");

这条规则在编译期消灭了数据竞争(data race).回想 Go:多个 goroutine 同时读写一个 map 会 panic 或未定义行为,得自己加 sync.Mutex 或靠 race detector 在运行时发现.Rust 把这类错误提前到了编译期.

把数据想象成共享文档:可以很多人同时阅读(多个 &T),但一旦有人要编辑(&mut T),就必须独占,其他人连读都不行.
这样永远不会出现"边改边读"的脏数据.

NLL:借用在"最后一次使用"后就结束

有个让规则更人性化的细节,叫非词法生命周期(NLL,Non-Lexical Lifetimes):一个引用的"借用期"不是持续到作用域大括号结束,而是到它最后一次被使用为止.

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

let r1 = &s;
println!("{r1}"); // r1 最后一次使用,借用到此结束

let r2 = &mut s; // OK!因为 r1 的借用已经结束,不再与之冲突
r2.push_str("!");

为什么要知道 NLL

很多新手会困惑"明明先用完了不可变引用,为什么报错"--其实多数情况下 NLL 已经放行.

理解"借用到最后一次使用为止",能帮助看懂为什么有些看似冲突的代码其实合法,也能指导把引用的使用尽量集中,尽早用完来避免借用冲突.

借用规则的副产品:杜绝悬垂引用

借用检查器还顺手解决了另一类经典 bug.C 里可能返回指向局部变量的指针,函数结束后该内存失效,指针悬空.Rust 直接拒绝编译:

fn dangle() -> &String {  // 编译错误:missing lifetime specifier
let s = String::from("hello");
&s // 返回 s 的引用,但 s 即将被 drop,引用会悬空
}

编译器发现:返回的引用指向的数据会在函数结束时释放,于是直接报错.正确做法是返回 String 本身(移交所有权),让数据活得足够久.

这里埋了一颗种子:生命周期

报错提到 "lifetime"(生命周期).引用必须活得不比它指向的数据长--编译器如何精确推断这一点,就是生命周期要解决的问题.

本系列先建立"引用不能比数据活得久"的直觉,深入机制留到「Rust 进阶 · 生命周期与并发」分类.

切片:借用集合的一部分

切片(slice)是一种特殊的引用:它借用集合中连续的一段,而不拥有数据.类型写作 &[T](数组/Vec 切片)和 &str(字符串切片).

let v = vec![10, 20, 30, 40, 50];
let slice: &[i32] = &v[1..4]; // 借用索引 1..4 的部分:[20, 30, 40]

let s = String::from("hello world");
let word: &str = &s[0..5]; // 字符串切片:"hello"

切片像给集合开了一扇:能透过窗看到,操作其中一段数据,但窗本身不持有房子.
窗(切片)的存在期间,房子(原集合)不能被拆(被移动或修改到使切片失效),这同样由借用规则保证.

最佳实践:函数参数优先用切片

一个非常地道的 Rust 习惯:函数接收字符串参数时,优先用 &str 而非 &String;接收序列时优先用 &[T] 而非 &Vec<T>.因为切片更通用--它能同时接受多种来源:

// 推荐:&str 既能接受 String,也能接受字符串字面量
fn greet(name: &str) {
println!("Hello, {name}");
}

let owned = String::from("Alice");
greet(&owned); // String 自动借用为 &str
greet("Bob"); // 字面量本就是 &str
greet(&owned[..]); // 显式整体切片也行

为什么这是最佳实践

&String 只能接受 String 的引用;而 &str 能接受 String,字面量,切片等所有"字符串视图".用更通用的切片类型做参数,函数适用面更广,调用方更省心.

同理,序列参数用 &[T] 而非 &Vec<T>.这是 Clippy 也会提示的地道写法.

快速回顾

  • 借用:用 &T(只读)/&mut T(可写)访问数据而不取得所有权,解决所有权往返的繁琐.
  • 借用铁律:同一数据,要么多个 &T,要么单个 &mut T,二者不并存--编译期消灭数据竞争.
  • NLL:借用到"最后一次使用"为止,而非作用域末尾;尽早用完引用可避免冲突.
  • 悬垂引用:返回指向局部变量的引用会被编译器拒绝(引出生命周期概念).
  • 切片:&[T]/&str 借用集合连续的一段,是一种不拥有数据的"窗口".
  • 最佳实践:函数参数优先用 &str/&[T] 而非 &String/&Vec<T>,更通用.

动手练习

  1. 可变借用:写一个 fn append_suffix(s: &mut String) 在字符串末尾追加内容,在 main 中调用并打印.
  2. 规则冲突:在同一作用域同时创建一个 &mut 和一个 & 引用,记录报错,并解释这条规则如何防止数据竞争.
  3. NLL 验证:构造一个能体现 NLL 的例子--先用完不可变引用,再创建可变引用,验证它能编译通过.
  4. 悬垂引用修复:写一个返回局部 String 引用的函数,观察 "lifetime" 报错,再改成返回 String 修复.
  5. 切片通用性:写一个函数,参数用 &str,分别用 String 和字符串字面量调用它,体会切片参数的通用性.