所有权、借用与生命周期:用数据流理解编译器
课程概览 · 第 6 章
同一段任务数据,往往要被查询函数读取、被编辑函数修改、再被日志函数打印。如果"谁负责释放"和"谁正在改它"只靠人为约定,悬垂指针和数据竞争就藏在这些约定里。Rust 把约定升级为编译期规则:所有权规定清理责任,借用规定读写权限。遇到报错时先画出值的流向,通常比背规则更快。
学习目标与默认选择
学完本章你应当能够:
- 区分 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; | 同一时刻只能有一个写者 |
String、Vec<T>、File 等拥有资源的类型赋值时移动;u64、bool 等小值实现了 Copy,赋值时复制。深复制必须显式调用 clone(),这是一次有意识的成本决定。
1 | |
借用:多读或一写
借用是在不转移所有权的情况下访问数据。规则只有一条:任意时刻,要么存在多个不可变借用,要么存在恰好一个可变借用,两者不能重叠。
多个只读借用可以并存:
1 | |
可变借用要求独占——正在写入时不存在任何别名,读到的值就不可能是"改了一半"的:
1 | |
借用在最后一次使用后结束
借用的存活区间不是"到作用域结束",而是"到最后一次使用"。这就是非词法生命周期(NLL):只要读操作都发生在写操作之前完成,它们就不算重叠。
1 | |
如果 first_len 的读取发生在 push 之后,两个借用就会重叠,编译器会拒绝(见下节)。先完成所有读取、再开始写入,是拆解大多数借用冲突的思路。
被拒绝的写法与等价修正
move 之后继续使用
1 | |
编译器拒绝的原因:String 没有实现 Copy,let moved = title; 之后 title 已经失效,再次使用等于允许两个绑定都认为自己拥有同一块堆内存。修正:只需要读取时用借用,确实需要两份时才 clone。
1 | |
重叠的读与写
1 | |
编译器拒绝的原因:first 是指向 labels[0] 的只读借用,它一直活到最后的 println!;push 可能使 Vec 重新分配内存,届时 first 会指向已释放的旧缓冲区——在其他语言里这是运行时的迭代器失效,Rust 在编译期就拦下。修正:先把读操作做完,再修改。
1 | |
生命周期标注:描述关系,不是延长寿命
生命周期描述"引用有效的范围"。多数函数不需要写标注,因为编译器能从签名推导。唯一需要手写的常见形态是:返回的引用可能来自多个输入引用,编译器无法确定跟随哪一个。
1 | |
'a 的含义是"返回引用的有效期不超过两个输入中较短的那个"。它是对关系的描述:编译器据此在调用点检查调用方是否会让引用活过头。它不延长任何值的生命周期,也不能让局部数据的引用逃出函数。
返回局部变量引用是典型的失败案例:
1 | |
编译器拒绝的原因:generated 在函数返回时执行 Drop,任何指向它的引用都会悬垂;标注无法改变这一点。修正:把数据所有权交出去。
1 | |
边界与失败场景
- move 之后:原绑定整体不可用,但未被移动的字段可以单独访问(部分移动,第 7 章展开)。
- 借用跨函数:借用检查不读函数体,只看签名。
&mut String的承诺是"独占写权限",函数内部怎么用是它自己的事。 - 读取与修改的顺序:把所有读操作集中在修改之前,多数"莫名其妙"的借用冲突会直接消失。
- 引用不能指向更短命的数据:
longer返回的引用若被存进活得更久的结构,编译器会在调用点报错,这是设计提醒而非障碍。
为什么可行
以 String 为例:栈上是指针、长度、容量三个字,堆上是文本。如果赋值只复制这三个字,两个绑定都会在离开作用域时释放同一块堆内存(双重释放)。所以 Rust 的默认赋值是"所有权转移":旧绑定失效,只有新绑定负责析构。实现了 Copy 的类型没有堆资源、也没有析构副作用,复制是安全的。
借用规则"多读或一写"本质上是一个别名约束:存在写入时禁止任何别名,读取时禁止写入。悬垂引用(指向已释放内存)和数据竞争(一读一写并发交错)都要求"别名 + 可变性"同时成立,这条规则在编译期就拆掉了它们的组合条件。NLL 则把借用的存活区间精确到"最后一次使用",避免过于保守的拒绝。
常见误区
- 用
clone压制报错。 先问"这个函数需要所有权吗",多数答案是否;需要副本时再 clone,成本才可见。 - 以为生命周期标注能延长寿命。 标注只描述输入与输出引用的关系,从不改变任何值的存活时间。
- 用
'static或Box::leak"修复"生命周期错误。 它们把设计问题变成内存长期占用,错误只是被推迟。 - 同时持有借用并修改容器。 迭代或索引期间
push/remove在其他语言是运行时未定义行为,这里是无法编译的确定错误。 - 把"多读或一写"当成并发规则。 它先在单线程里排除别名缺陷;线程间的数据竞争由
Send/Sync(第 16 章)继续约束。
自测
let b = a;之后a何时仍可用?
答案方向:a的类型实现了Copy(如u64、bool)时隐式复制、仍可用;String、Vec等被移动后不可用。let first = &labels[0]; labels.push(...); println!("{first}");为什么被拒绝?如何修正?
答案方向: 只读借用first活到println!,与push的可变借用重叠;先完成读取(或 clone 出值)再修改。longer<'a>中的'a延长了哪个值的生命周期?
答案方向: 一个也没有;它只声明"返回引用的有效期不超过两个输入中较短者",供调用点检查。- 函数内新建的
String想交给调用方,返回String还是&String?
答案方向: 返回String(所有权转移);返回&String会引用局部变量,无法编译。 - 为什么说"多读或一写"防住了迭代器失效?
答案方向: 迭代器持有只读借用期间,任何可变借用(包括push触发的重分配)都无法成立,别名与修改不能同时存在。
下一篇我们给这份数据建模:结构体:组织数据、封装状态与编写方法。






