所有权、借用与生命周期:用数据流理解编译器
课程概览 · 第 6 章
同一份任务数据可能先被查询、再被修改、最后被日志读取。所有权说明谁负责清理,借用说明访问权限,生命周期说明引用依赖谁。遇到报错时,先追踪值和引用的流向,再决定调整作用域、返回拥有值,还是确实需要一个副本。
前置知识与学习目标
本章沿用函数、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 | |
移动是语义上的所有权转移,不应理解成必须把所有堆字节复制到新位置。部分移动之后能否访问某个字段,要看该字段是否仍可用;实现 Drop 的类型还不能这样直接移出字段,因为析构需要完整值。
共享借用、可变借用与重借用
&T 允许多个共享读者;&mut T 提供独占访问。在普通借用模型下,活跃的共享借用不能与修改同一数据的可变借用重叠。内部可变性是另有约束的工具,见第 17 章,不是任意绕过借用检查。
独立示例(标准库;完整文件 borrow.rs):查询不拿走所有权,修改需要可变借用。
1 | |
可变引用还可以暂时重借用为共享引用。共享重借用使用完后,原可变引用可以继续修改;不要把短暂借出与永久移交混为一谈。
独立示例(标准库;完整文件 reborrow.rs):inspect 结束后继续通过 r 更新。
1 | |
NLL 与 drop scope:不是同一件事
非词法生命周期(NLL)让借用检查考虑引用的实际使用区间,不必把每个借用都延长到词法块末尾。下面保留真实引用绑定,而不是先复制一个长度;最后一次读取发生在 push 前,因此可以随后修改容器。
独立示例(标准库;完整文件 nll.rs):先读取 first,再修改容器。
1 | |
故意失败:编译错误(标准库;完整文件 nll_overlap.rs):仅把引用的使用移到修改后。
1 | |
first 在 push 后仍被使用,两个借用重叠,编译器拒绝可变借用。push 可能重分配,只是理解风险的例子;即使当前容量足够,借用冲突也不会因此被允许。若不需要修改后再读旧数据,优先重排读取,不必克隆。
NLL 不代表拥有值在最后一次普通使用后就自动析构。局部值通常仍按其析构作用域清理;若 Drop 会使用所持引用,该借用也必须覆盖析构。下文“深入阅读”的 Hold 展示这个区别。
生命周期签名:借用返回与拥有返回
生命周期标注描述引用之间的关系,不延长任何值的寿命。是否需要手写,应依据生命周期省略规则能否确定关系,而不是记成“仅多个输入时才需要”:结构体中的引用等场景也需要显式关系。
独立示例(标准库;完整文件 longer.rs):返回引用借用两个输入之一。
1 | |
'a 是两个输入与输出共同满足的有效范围;调用点不能在任一输入不再可借用后继续使用结果。这里比较的是字节长度。函数的签名表达依赖关系,调用方不能靠知道函数本次选了哪个分支来放宽它。
故意失败:编译错误(标准库;完整文件 local_reference.rs):返回局部拥有值的引用。
1 | |
generated 在函数返回时被释放,返回的引用不能逃出函数;增加生命周期标注无法修复。需要把新建数据交给调用者,就返回拥有值。
独立示例(标准库;完整文件 owned_return.rs):沿用函数名,但这是独立的正确版本。
1 | |
这里已有标签也会克隆,因为返回类型承诺交付 String。若希望借用已有数据、仅在缺失时拥有新数据,可以进一步学习 Cow,不必为眼前接口提前引入它。
从报错位置追踪数据流
先确认失败操作需要什么权限,再找所有权转移和仍活跃的借用,最后检查返回值是否借用了更短命的数据。函数调用之间依据签名建立借用关系;函数内部也会被检查,但调用方不能依赖实现细节缩短签名承诺的借用。
故意失败:编译错误(标准库;完整文件 moved_value.rs):移动后读取原绑定。
1 | |
这里应报告使用已移动值。只读时把 let moved = title 改为 let moved = &title;确实需要独立文本时才克隆。不要默认用 'static、泄漏内存或到处 clone 来压制报错。
深入阅读:析构仍需要借用
Hold 的析构器读取并更新它持有的目标;即使没有显式调用 hold 的方法,目标也仍被借用,直到 hold 析构。下面主动 drop 是提前消费并清理值,不是单纯给编译器一个提示。
独立示例(标准库;完整文件 drop_borrow.rs):先析构持有者,再重新访问目标。
1 | |
故意失败:编译错误(标准库;完整文件 drop_overlap.rs):同样的 Hold 定义完整列出,修改时析构仍未完成。
1 | |
这不是“最后一次读取已经过去就应该合法”的例外漏洞:析构也是引用的使用。安全借用规则约束单线程别名;跨线程还需 Send/Sync,而运行期输入是否合法仍需要业务校验。
自测
Copy的共享引用可以指向堆数据吗?clone是否总是深拷贝?- NLL 正例中把
assert_eq!(first, "docs")移到push后为什么失败? update中的共享重借用为什么不妨碍后面的*r += 1?longer的生命周期标注延长了哪个值的寿命?局部新建文本应怎样返回?Hold没有被显式读取,为什么仍不能在析构前赋值给目标?
参考答案
- 可以,引用不拥有目标;克隆语义由类型定义,既可能复制数据,也可能共享资源。
first的后续使用使共享借用与push的可变借用重叠。inspect使用完共享重借用后,不再阻挡原可变引用继续修改。- 一个也没有;标注只表达关系。局部新建文本通常返回
String转移所有权。 Drop还要访问目标,借用覆盖到析构;先drop(hold)才能重新访问。






