课程概览 · 第 6 章

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

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

同一份任务数据可能先被查询、再被修改、最后被日志读取。所有权说明谁负责清理,借用说明访问权限,生命周期说明引用依赖谁。遇到报错时,先追踪值和引用的流向,再决定调整作用域、返回拥有值,还是确实需要一个副本。

所有权清理责任、移动与克隆、借用权限和引用有效期
图:先看清理责任和访问权限,再分析最后一次使用与析构所需的借用。

前置知识与学习目标

本章沿用函数、String、切片和 Result;各示例独立,不需要复制其他章节的任务模型。学完后应能:

  • 区分 move、Copy 与 clone,解释部分移动。
  • 用共享借用、可变借用和重借用表达读写边界。
  • 区分 NLL 的借用结束与值的析构作用域。
  • 读懂生命周期签名,选择借用返回或拥有返回。

本章所有源码仅依赖标准库,使用 Edition 2024。标为“独立示例”的块含全部定义和 main,可各自保存为一个 .rs 文件;反例另存,不能与正常例合并。

move、Copy 与 clone

操作写法语义
移动let b = a;,a 非 Copy转移值,原绑定不能再用于该值
复制let b = a;,a: Copy隐式复制,原绑定仍可用
克隆let b = a.clone();调用该类型定义的显式克隆操作

String、Vec<T>、File 不实现 Copy;整数、布尔和共享引用等可以实现。Copy 类型不能拥有需要自身 Drop 清理的资源,但可以包含指向堆数据的共享引用。小值适合 Copy 是设计建议,不是语言的大小限制。clone 也不一律深拷贝:String 会复制文本,Rc 的克隆则增加共享计数,第 17 章再展开。

独立示例(标准库;完整文件 partial_move.rs):复制 ID、克隆标签、移动标题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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")],
};
let id = task.id;
let backup = task.labels.clone();
let title = task.title;
assert_eq!(id, task.id);
assert_eq!(title, "ship release notes");
assert_eq!(backup, task.labels);
// title 已移动;未移动的字段仍能访问,不能再整体使用 task。
}

移动是语义上的所有权转移,不应理解成必须把所有堆字节复制到新位置。部分移动之后能否访问某个字段,要看该字段是否仍可用;实现 Drop 的类型还不能这样直接移出字段,因为析构需要完整值。

共享借用、可变借用与重借用

&T 允许多个共享读者;&mut T 提供独占访问。在普通借用模型下,活跃的共享借用不能与修改同一数据的可变借用重叠。内部可变性是另有约束的工具,见第 17 章,不是任意绕过借用检查。

独立示例(标准库;完整文件 borrow.rs):查询不拿走所有权,修改需要可变借用。

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

fn mark_urgent(title: &mut String) {
if !title.starts_with("[urgent] ") {
title.insert_str(0, "[urgent] ");
}
}

fn main() {
let labels = vec![String::from("docs"), String::from("urgent")];
let first = &labels[0];
assert_eq!(count_nonempty(&labels), 2);
assert_eq!(first, "docs");
let mut title = String::from("ship release notes");
mark_urgent(&mut title);
assert_eq!(title, "[urgent] ship release notes");
}

可变引用还可以暂时重借用为共享引用。共享重借用使用完后,原可变引用可以继续修改;不要把短暂借出与永久移交混为一谈。

独立示例(标准库;完整文件 reborrow.rs):inspect 结束后继续通过 r 更新。

1
2
3
4
5
6
7
8
9
10
11
12
fn inspect(_: &i32) {}

fn update(r: &mut i32) {
inspect(&*r);
*r += 1;
}

fn main() {
let mut value = 41;
update(&mut value);
assert_eq!(value, 42);
}

NLL 与 drop scope:不是同一件事

非词法生命周期(NLL)让借用检查考虑引用的实际使用区间,不必把每个借用都延长到词法块末尾。下面保留真实引用绑定,而不是先复制一个长度;最后一次读取发生在 push 前,因此可以随后修改容器。

独立示例(标准库;完整文件 nll.rs):先读取 first,再修改容器。

1
2
3
4
5
6
7
fn main() {
let mut labels = vec![String::from("docs")];
let first = &labels[0];
assert_eq!(first, "docs");
labels.push(String::from("release"));
assert_eq!(labels.len(), 2);
}

故意失败:编译错误(标准库;完整文件 nll_overlap.rs):仅把引用的使用移到修改后。

1
2
3
4
5
6
fn main() {
let mut labels = vec![String::from("docs")];
let first = &labels[0];
labels.push(String::from("release"));
println!("{first}");
}

first 在 push 后仍被使用,两个借用重叠,编译器拒绝可变借用。push 可能重分配,只是理解风险的例子;即使当前容量足够,借用冲突也不会因此被允许。若不需要修改后再读旧数据,优先重排读取,不必克隆。

NLL 不代表拥有值在最后一次普通使用后就自动析构。局部值通常仍按其析构作用域清理;若 Drop 会使用所持引用,该借用也必须覆盖析构。下文“深入阅读”的 Hold 展示这个区别。

生命周期签名:借用返回与拥有返回

生命周期标注描述引用之间的关系,不延长任何值的寿命。是否需要手写,应依据生命周期省略规则能否确定关系,而不是记成“仅多个输入时才需要”:结构体中的引用等场景也需要显式关系。

独立示例(标准库;完整文件 longer.rs):返回引用借用两个输入之一。

1
2
3
4
5
6
7
8
9
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 picked = longer(&labels[0], &labels[1]);
assert_eq!(picked, "release");
}

'a 是两个输入与输出共同满足的有效范围;调用点不能在任一输入不再可借用后继续使用结果。这里比较的是字节长度。函数的签名表达依赖关系,调用方不能靠知道函数本次选了哪个分支来放宽它。

故意失败:编译错误(标准库;完整文件 local_reference.rs):返回局部拥有值的引用。

1
2
3
4
5
6
fn fallback_label(labels: &[String]) -> &String {
let generated = String::from("uncategorized");
if labels.is_empty() { &generated } else { &labels[0] }
}

fn main() {}

generated 在函数返回时被释放,返回的引用不能逃出函数;增加生命周期标注无法修复。需要把新建数据交给调用者,就返回拥有值。

独立示例(标准库;完整文件 owned_return.rs):沿用函数名,但这是独立的正确版本。

1
2
3
4
5
6
7
8
9
10
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(&[]), "uncategorized");
}

这里已有标签也会克隆,因为返回类型承诺交付 String。若希望借用已有数据、仅在缺失时拥有新数据,可以进一步学习 Cow,不必为眼前接口提前引入它。

从报错位置追踪数据流

先确认失败操作需要什么权限,再找所有权转移和仍活跃的借用,最后检查返回值是否借用了更短命的数据。函数调用之间依据签名建立借用关系;函数内部也会被检查,但调用方不能依赖实现细节缩短签名承诺的借用。

故意失败:编译错误(标准库;完整文件 moved_value.rs):移动后读取原绑定。

1
2
3
4
5
fn main() {
let title = String::from("ship release notes");
let moved = title;
println!("{title}, {moved}");
}

这里应报告使用已移动值。只读时把 let moved = title 改为 let moved = &title;确实需要独立文本时才克隆。不要默认用 'static、泄漏内存或到处 clone 来压制报错。

深入阅读:析构仍需要借用

Hold 的析构器读取并更新它持有的目标;即使没有显式调用 hold 的方法,目标也仍被借用,直到 hold 析构。下面主动 drop 是提前消费并清理值,不是单纯给编译器一个提示。

独立示例(标准库;完整文件 drop_borrow.rs):先析构持有者,再重新访问目标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Hold<'a>(&'a mut i32);

impl Drop for Hold<'_> {
fn drop(&mut self) {
*self.0 += 1;
}
}

fn main() {
let mut value = 0;
let hold = Hold(&mut value);
drop(hold);
assert_eq!(value, 1);
value = 10;
assert_eq!(value, 10);
}

故意失败:编译错误(标准库;完整文件 drop_overlap.rs):同样的 Hold 定义完整列出,修改时析构仍未完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Hold<'a>(&'a mut i32);

impl Drop for Hold<'_> {
fn drop(&mut self) {
*self.0 += 1;
}
}

fn main() {
let mut value = 0;
let hold = Hold(&mut value);
value = 10;
drop(hold);
}

这不是“最后一次读取已经过去就应该合法”的例外漏洞:析构也是引用的使用。安全借用规则约束单线程别名;跨线程还需 Send/Sync,而运行期输入是否合法仍需要业务校验。

自测

  1. Copy 的共享引用可以指向堆数据吗?clone 是否总是深拷贝?
  2. NLL 正例中把 assert_eq!(first, "docs") 移到 push 后为什么失败?
  3. update 中的共享重借用为什么不妨碍后面的 *r += 1?
  4. longer 的生命周期标注延长了哪个值的寿命?局部新建文本应怎样返回?
  5. Hold 没有被显式读取,为什么仍不能在析构前赋值给目标?

参考答案

  1. 可以,引用不拥有目标;克隆语义由类型定义,既可能复制数据,也可能共享资源。
  2. first 的后续使用使共享借用与 push 的可变借用重叠。
  3. inspect 使用完共享重借用后,不再阻挡原可变引用继续修改。
  4. 一个也没有;标注只表达关系。局部新建文本通常返回 String 转移所有权。
  5. Drop 还要访问目标,借用覆盖到析构;先 drop(hold) 才能重新访问。