课程概览 · 第 6 章

上一篇:函数与 I/O:把所有权、错误和资源边界写进接口

下一篇:结构体:组织数据、封装状态与编写方法

同一段任务数据,往往要被查询函数读取、被编辑函数修改、再被日志函数打印。如果"谁负责释放"和"谁正在改它"只靠人为约定,悬垂指针和数据竞争就藏在这些约定里。Rust 把约定升级为编译期规则:所有权规定清理责任,借用规定读写权限。遇到报错时先画出值的流向,通常比背规则更快。

所有权清理责任、move 与 clone、借用读写权限,以及生命周期引用关系的作用域模型
图:先画数据流,再判断谁负责清理、谁可读写、借用何时结束;生命周期标注只描述依赖关系。

学习目标与默认选择

学完本章你应当能够:

  • 区分 move、Copy、显式 clone、不可变借用、可变借用的语义与成本。
  • 解释"多读或一写"如何从根源上排除悬垂引用与数据竞争。
  • 遇到借用报错时按固定顺序定位数据流向,而不是随手加 clone
  • 读懂最常见的生命周期标注形态 fn longer<'a>(...) -> &'a str,并说出它做什么。

默认选择:函数优先借用数据(&T / &mut T),而不是拿走所有权;只有确实需要独立副本时才显式 clone()。生命周期标注绝大多数时候由编译器推导,手写标注只在返回引用可能来自多个输入时出现。

三种数据流:move、Copy 与显式 clone

操作写法结果
移动let b = a;a 不再可用(非 Copy 类型)
复制let b = a;a 仍可用(Copy 类型,隐式复制)
深复制let b = a.clone();两份独立数据,成本显式可见
不可变借用let r = &a;可同时存在多个读者
可变借用let r = &mut a;同一时刻只能有一个写者

StringVec<T>File 等拥有资源的类型赋值时移动;u64bool 等小值实现了 Copy,赋值时复制。深复制必须显式调用 clone(),这是一次有意识的成本决定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#[derive(Debug)]
struct Task {
id: u64,
title: String,
labels: Vec<String>,
}

fn main() {
let task = Task {
id: 7,
title: String::from("ship release notes"),
labels: vec![String::from("docs"), String::from("release")],
};

// id 是 u64,实现了 Copy:复制之后 task 仍完整可用
let id_copy = task.id;
assert_eq!(id_copy, task.id);

// labels 是 Vec<String>:clone 是显式深复制,堆数据被复制一份
let labels_backup = task.labels.clone();
assert_eq!(labels_backup.len(), 2);

// title 是 String:赋值是移动,字段不再属于 task
let title = task.title;
assert_eq!(title, "ship release notes");

// 未移动的字段仍可单独使用;但 task 已不完整,不能再整体打印
assert_eq!(task.labels.len(), 2);
}

借用:多读或一写

借用是在不转移所有权的情况下访问数据。规则只有一条:任意时刻,要么存在多个不可变借用,要么存在恰好一个可变借用,两者不能重叠。

多个只读借用可以并存:

1
2
3
4
5
6
7
8
9
10
11
fn count_nonempty(labels: &[String]) -> usize {
labels.iter().filter(|label| !label.is_empty()).count()
}

fn main() {
let labels = vec![String::from("docs"), String::from("urgent")];
let first = &labels[0]; // 只读借用一
let total = count_nonempty(&labels); // 只读借用二:多读并存
assert_eq!(first, "docs");
assert_eq!(total, 2);
}

可变借用要求独占——正在写入时不存在任何别名,读到的值就不可能是"改了一半"的:

1
2
3
4
5
6
7
8
9
10
11
fn mark_urgent(title: &mut String) {
if !title.starts_with("[urgent] ") {
title.insert_str(0, "[urgent] ");
}
}

fn main() {
let mut title = String::from("ship release notes");
mark_urgent(&mut title);
assert_eq!(title, "[urgent] ship release notes");
}

借用在最后一次使用后结束

借用的存活区间不是"到作用域结束",而是"到最后一次使用"。这就是非词法生命周期(NLL):只要读操作都发生在写操作之前完成,它们就不算重叠。

1
2
3
4
5
6
7
8
9
fn main() {
let mut labels = vec![String::from("docs")];

let first_len = labels[0].len(); // 只读借用在这里最后一次使用
labels.push(String::from("release")); // 从这里起只剩可变借用,二者不重叠

assert_eq!(first_len, 4);
assert_eq!(labels.len(), 2);
}

如果 first_len 的读取发生在 push 之后,两个借用就会重叠,编译器会拒绝(见下节)。先完成所有读取、再开始写入,是拆解大多数借用冲突的思路。

被拒绝的写法与等价修正

move 之后继续使用

1
2
3
4
5
6
// 无法编译:值被移动后继续使用
fn main() {
let title = String::from("ship release notes");
let moved = title; // String 的所有权移动到 moved
println!("{title}, {moved}"); // 错误:borrow of moved value: `title`
}

编译器拒绝的原因:String 没有实现 Copylet moved = title; 之后 title 已经失效,再次使用等于允许两个绑定都认为自己拥有同一块堆内存。修正:只需要读取时用借用,确实需要两份时才 clone

1
2
3
4
5
6
7
fn main() {
let title = String::from("ship release notes");

let summary = &title; // 借用:所有权留在 title
println!("{title}, {summary}"); // 两者都可用
assert_eq!(summary, "ship release notes");
}

重叠的读与写

1
2
3
4
5
6
7
// 无法编译:不可变借用未结束就发起可变借用
fn main() {
let mut labels = vec![String::from("docs")];
let first = &labels[0]; // 只读借用开始
labels.push(String::from("release")); // 错误:cannot borrow `labels` as mutable
println!("{first}"); // 因为 `first` 在 push 之后仍被使用
}

编译器拒绝的原因:first 是指向 labels[0] 的只读借用,它一直活到最后的 println!push 可能使 Vec 重新分配内存,届时 first 会指向已释放的旧缓冲区——在其他语言里这是运行时的迭代器失效,Rust 在编译期就拦下。修正:先把读操作做完,再修改。

1
2
3
4
5
6
7
8
9
fn main() {
let mut labels = vec![String::from("docs")];

let first = labels[0].clone(); // 读取完成,只读借用结束
labels.push(String::from("release")); // 此时没有活跃借用,可以修改

assert_eq!(first, "docs");
assert_eq!(labels.len(), 2);
}

生命周期标注:描述关系,不是延长寿命

生命周期描述"引用有效的范围"。多数函数不需要写标注,因为编译器能从签名推导。唯一需要手写的常见形态是:返回的引用可能来自多个输入引用,编译器无法确定跟随哪一个。

1
2
3
4
5
6
7
8
9
10
fn longer<'a>(left: &'a str, right: &'a str) -> &'a str {
if left.len() >= right.len() { left } else { right }
}

fn main() {
let labels = [String::from("docs"), String::from("release")];
let pick = longer(&labels[0], &labels[1]);
assert_eq!(pick, "release");
// pick 借用的是 labels 里的数据:labels 活多久,pick 才能用多久
}

'a 的含义是"返回引用的有效期不超过两个输入中较短的那个"。它是对关系的描述:编译器据此在调用点检查调用方是否会让引用活过头。它不延长任何值的生命周期,也不能让局部数据的引用逃出函数。

返回局部变量引用是典型的失败案例:

1
2
3
4
5
6
7
8
// 无法编译:返回了局部变量的引用
fn fallback_label(labels: &[String]) -> &String {
let generated = String::from("uncategorized");
if labels.is_empty() { &generated } else { &labels[0] }
// 错误:`generated` 在函数返回时被释放,返回其引用会悬垂
}

fn main() {}

编译器拒绝的原因:generated 在函数返回时执行 Drop,任何指向它的引用都会悬垂;标注无法改变这一点。修正:把数据所有权交出去。

1
2
3
4
5
6
7
8
9
10
11
12
fn fallback_label(labels: &[String]) -> String {
labels
.first()
.cloned()
.unwrap_or_else(|| String::from("uncategorized"))
}

fn main() {
let labels = vec![String::from("docs")];
assert_eq!(fallback_label(&labels), "docs");
assert_eq!(fallback_label(&Vec::new()), "uncategorized");
}

边界与失败场景

  • move 之后:原绑定整体不可用,但未被移动的字段可以单独访问(部分移动,第 7 章展开)。
  • 借用跨函数:借用检查不读函数体,只看签名。&mut String 的承诺是"独占写权限",函数内部怎么用是它自己的事。
  • 读取与修改的顺序:把所有读操作集中在修改之前,多数"莫名其妙"的借用冲突会直接消失。
  • 引用不能指向更短命的数据longer 返回的引用若被存进活得更久的结构,编译器会在调用点报错,这是设计提醒而非障碍。

为什么可行

String 为例:栈上是指针、长度、容量三个字,堆上是文本。如果赋值只复制这三个字,两个绑定都会在离开作用域时释放同一块堆内存(双重释放)。所以 Rust 的默认赋值是"所有权转移":旧绑定失效,只有新绑定负责析构。实现了 Copy 的类型没有堆资源、也没有析构副作用,复制是安全的。

借用规则"多读或一写"本质上是一个别名约束:存在写入时禁止任何别名,读取时禁止写入。悬垂引用(指向已释放内存)和数据竞争(一读一写并发交错)都要求"别名 + 可变性"同时成立,这条规则在编译期就拆掉了它们的组合条件。NLL 则把借用的存活区间精确到"最后一次使用",避免过于保守的拒绝。

常见误区

  1. clone 压制报错。 先问"这个函数需要所有权吗",多数答案是否;需要副本时再 clone,成本才可见。
  2. 以为生命周期标注能延长寿命。 标注只描述输入与输出引用的关系,从不改变任何值的存活时间。
  3. 'staticBox::leak "修复"生命周期错误。 它们把设计问题变成内存长期占用,错误只是被推迟。
  4. 同时持有借用并修改容器。 迭代或索引期间 push/remove 在其他语言是运行时未定义行为,这里是无法编译的确定错误。
  5. 把"多读或一写"当成并发规则。 它先在单线程里排除别名缺陷;线程间的数据竞争由 Send/Sync(第 16 章)继续约束。

自测

  1. let b = a; 之后 a 何时仍可用?
    答案方向: a 的类型实现了 Copy(如 u64bool)时隐式复制、仍可用;StringVec 等被移动后不可用。
  2. let first = &labels[0]; labels.push(...); println!("{first}"); 为什么被拒绝?如何修正?
    答案方向: 只读借用 first 活到 println!,与 push 的可变借用重叠;先完成读取(或 clone 出值)再修改。
  3. longer<'a> 中的 'a 延长了哪个值的生命周期?
    答案方向: 一个也没有;它只声明"返回引用的有效期不超过两个输入中较短者",供调用点检查。
  4. 函数内新建的 String 想交给调用方,返回 String 还是 &String
    答案方向: 返回 String(所有权转移);返回 &String 会引用局部变量,无法编译。
  5. 为什么说"多读或一写"防住了迭代器失效?
    答案方向: 迭代器持有只读借用期间,任何可变借用(包括 push 触发的重分配)都无法成立,别名与修改不能同时存在。

下一篇我们给这份数据建模:结构体:组织数据、封装状态与编写方法