所有权、借用与生命周期
📚 Rust 课程系列
所有权(ownership)、借用(borrowing)与生命周期(lifetime)是 Rust 最核心、也最与众不同的特性。它们让 Rust 无需垃圾回收与手动 free,在编译期保证内存安全与无数据竞争,也是理解后续智能指针、并发、异步等章节的基础。
所有权与借用
所有权的基本规则
| 规则 | 说明 | 示例 |
|---|---|---|
| 唯一所有者 | 每个值有且仅有一个所有者,离开作用域时自动释放 | let s = String::from("hi"); |
| 移动语义 | 赋值/传参时所有权转移,原变量失效 | let s2 = s1; —— s1 不再可用 |
| 复制语义 | Copy 类型赋值时按位复制,原变量仍有效 | let y = x;(x: i32) |
1 | |
💡 提示:为什么
String赋值后原变量失效?String在栈上存放指针/长度/容量,堆上存放实际数据。let s2 = s1;只复制了栈上部分——若两者同时有效,就会指向同一块堆内存,离开作用域时 double free。Rust 用移动语义在编译期杜绝此问题,零运行时开销。
🔄 对比:Rust 的移动语义 ≈ C++ 的
std::unique_ptr语义,但 Rust 编译器强制执行——你无法意外复制。Go/Java 的赋值总是复制引用(或值),不存在"移动"概念。
💡 提示:判断能否
Copy的简单规则——若类型销毁时需要特殊清理(如String要释放堆内存),就不能Copy;标量、不可变引用、全是Copy字段的结构体可以Copy。
移动语义的内存模型
理解移动,要看清 String 这类类型的内存布局:栈上存「指针、长度、容量」三元组,堆上存实际字符数据。
flowchart LR
subgraph STACK["栈"]
s1["s1: ptr / len / cap"]
s2["s2: ptr / len / cap"]
end
subgraph HEAP["堆"]
data["h e l l o"]
end
s1 -. "移动后失效" .-> data
s2 --> datalet s2 = s1; 只复制栈上的三元组并让 s1 作废,堆数据纹丝不动——移动是 O(1) 的。代价是编译器必须静态跟踪「谁还活着」,这正是所有权系统的工作。
Copy 与 Clone
复制行为由两个 trait 控制,语义与开销完全不同:
| trait | 语义 | 开销 | 触发方式 |
|---|---|---|---|
Copy | 按位复制,原变量仍可用 | 零开销 | 赋值/传参时隐式发生 |
Clone | 通常是深拷贝 | 可能分配堆内存 | 显式调用 .clone() |
1 | |
实现 Copy 的常见类型:整数、浮点、bool、char、不可变引用 &T、函数指针、元素全 Copy 的元组与数组。自定义类型可用 derive 获得:
1 | |
⚠️ 注意:
Copy与Drop互斥——需要自定义清理逻辑的类型不能Copy,否则按位复制会产生两个都要drop的副本,回到 double free 的老问题。
Drop:自动释放
所有者离开作用域时,Rust 自动调用其 Drop::drop——类似 C++ 析构函数,但全自动、不可遗忘:
1 | |
释放顺序规则:局部变量逆声明序释放;结构体字段按声明序释放。需要提前释放时用 std::mem::drop:
1 | |
💡 提示:RAII 模式(锁守卫、文件句柄、临时目录)都建立在
Drop之上。MutexGuard离开作用域自动解锁,正是《并发与异步编程》会反复见到的模式。
函数与所有权
传参和返回同样遵循移动/复制语义,让「谁负责释放」始终清晰:
1 | |
💡 提示:如果函数只是需要「读一下」而不想拿走所有权,每次先拿走再返回很笨重——这就是借用的意义。
部分移动
移动可以精确到字段粒度——只移出结构体的部分字段,其余字段仍可使用:
1 | |
⚠️ 注意:实现了
Drop的类型不能部分移动——drop需要拿到完整的值。若确实要取走字段,可用Option::take/std::mem::take留下占位值。
借用
使用 & 创建不可变引用,&mut 创建可变引用。引用不拥有数据,只是「指向」某个值:
1 | |
要修改借用的值,用 &mut T,但原变量本身也得是 mut:
1 | |
🔄 对比:
&T≈ C++ 的const T&,&mut T≈T&。但 Rust 编译器额外保证:同一时间要么有多个&T,要么有且仅有一个&mut T,从源头杜绝数据竞争。
解引用强制转换与再借用
函数参数常声明为 &str 而非 &String:前者通过**解引用强制转换(deref coercion)**同时接受 &String、&str 和字符串字面量,API 更通用:
1 | |
同理 &Vec<T> 可转为 &[T]。经验法则:只读参数尽量写切片形式(&str、&[T],详见《切片(Slice)》),调用方传什么都方便。
可变引用还支持再借用(reborrow):对 &mut T 再取 &mut,新引用生效期间原引用被「冻结」:
1 | |
🔬 进阶:方法调用
r1.push_str(...)实际隐含一次(&mut *r1).push_str(...)再借用——这正是可以把&mut &mut T用在需要&mut T的位置的原因。
借用检查器规则
规则总结:
- 任何借用必须在其引用的值的生命周期内有效
- 可以同时存在多个不可变借用(
&T) - 不能同时存在可变借用(
&mut T)和任何其他借用
1 | |
💡 提示:上面
r1、r2在println!之后就「不再使用」了,因此r3 = &mut s可以通过。这说明 Rust 的借用并非严格按词法作用域结束,而是采用 非词法生命周期(NLL):引用的「活跃区间」只到它最后一次被使用为止。
⚠️ 注意:在持有不可变借用时试图修改数据是典型报错场景:
1 | |
原因:push 可能触发扩容并把堆数据搬到新地址,此时 first 指向的旧地址就悬空了。修复方式是把不可变借用的使用提前到可变操作之前:
1 | |
可变引用还有一个限制:同一时间只能有一个可变引用:
1 | |
⚠️ 注意:遍历容器同时修改(如
for item in &vec { vec.push(...); })会被编译器拒绝——这是借用冲突最常见的场景之一。优先把修改操作收集到遍历之后,或用索引遍历。
💡 提示:函数参数设计原则——不需要修改用
&T,需要修改用&mut T,需要获得所有权(如构建返回值)传T。对大型结构频繁clone()有性能开销,优先考虑借用或Rc/Arc。
拆分借用
借用检查器能识别互不重叠的借用。同一结构体的不同字段可以同时被可变借用:
1 | |
但通过索引访问容器时,编译器无法证明两次索引不重叠,标准库为此提供显式拆分 API:
1 | |
🔬 进阶:
split_at_mut内部用unsafe指针运算实现——「不重叠」由库作者用unsafe担保,对使用者仍是安全 API。详见《Unsafe Rust 与常用 trait 详解》。
非词法生命周期(进阶)
NLL 自 Rust 2018 edition 起成为默认行为。在旧版词法生命周期(lexical lifetimes)下,引用的存活区间等于整个作用域——即使引用在作用域中途就不再使用,借用检查器也会认为它一直活跃到作用域末尾。NLL 将引用的存活区间缩短到最后一次使用点,使很多在词法模型下被拒的代码合法化:
1 | |
🔬 进阶:NLL 的实现基于「生命周期即约束」模型——编译器先收集所有约束(
'a: 'b表示'a至少和'b一样长),再求解最小可行生命周期,而非简单地将引用绑定到词法作用域。
生命周期
为什么需要生命周期
引用必须保证它指向的数据在引用使用期间一直有效,否则就是「悬垂引用」。大多数时候编译器能自动推断,但当函数返回引用时,编译器无法仅凭签名判断返回引用与哪个参数关联——这时就需要生命周期注解。
生命周期注解用 'a 表示,它不改变引用的实际存活时间,只是描述多个引用之间的约束关系:「返回的引用至少和入参 x、y 中较短的那个一样长」。
1 | |
⚠️ 注意:不要返回对局部变量的引用——编译器会直接拒绝:
1 | |
正确做法是直接返回所有权:fn no_dangle() -> String { String::from("hello") }。
💡 提示:当生命周期签名复杂时,考虑返回拥有所有权的类型(如
String)或使用Arc/Cow来简化 API。接口设计中尽量让调用方持有所有权或明确借用关系,减少不必要的生命周期注解暴露。
生命周期省略规则
Rust 编译器会自动应用三条规则来省略生命周期注解:
| 规则 | 说明 |
|---|---|
| 规则1 | 每个引用参数都有自己的生命周期参数 |
| 规则2 | 如果只有一个输入生命周期参数,它被赋予所有输出生命周期参数 |
| 规则3 | 如果有多个输入生命周期参数,但其中一个是 &self 或 &mut self,则 self 的生命周期被赋予所有输出生命周期参数 |
1 | |
按规则 1,s: &str 被看作 s: &'a str;按规则 2,唯一的输入生命周期 'a 被赋给输出,于是签名等价于 fn first_word<'a>(s: &'a str) -> &'a str,无需手写。当三条规则都无法确定输出生命周期时,编译器才要求显式标注(如 longest 有两个输入引用却没有 self,规则 3 不适用,故必须手写)。
多个生命周期参数与约束
省略规则不够用时,需显式写出生命周期参数及其关系。多个参数各自独立命名:
1 | |
'a: 'b 读作「'a 至少和 'b 活得一样久」,用于把长生命周期缩短为短的:
1 | |
生命周期也可作泛型约束:T: 'a 表示 T 内部所有引用都至少活到 'a;T: 'static 表示 T 不含任何非 'static 引用(thread::spawn 的闭包约束即来源于此)。
Rust 2018 起还可用匿名生命周期 '_,不想起名时交给编译器推断:
1 | |
结构体中的生命周期
结构体若持有引用,必须标注生命周期,表明该引用的数据必须比结构体活得久:
1 | |
🔄 对比:C++ 中类持有引用不需要显式标注生命周期,但可能产生悬垂引用(运行时 UB);Rust 在编译期就强制你声明约束关系。
方法中的生命周期
在 impl 块中,结构体的生命周期参数要在 impl 后声明、类型名后使用;方法内部仍享受省略规则(规则 3 以 &self 为准):
1 | |
announce_and_return 的完整签名等价于 fn announce_and_return<'b, 'c>(&'b self, note: &'c str) -> &'b str——调用方传入的 note 不需要和 self 活得一样久。
静态生命周期
1 | |
'static 表示引用在程序整个运行期间都有效。所有字符串字面量都是 &'static str,因为它们直接被编译进二进制的只读数据段。'static 是所有生命周期的「上界」:'static 引用可以满足任意 'a 的约束,因此常出现在需要「任意生命周期」的地方。
⚠️ 注意:不要为了消除编译错误而随手给参数标
'static,这往往掩盖了真正的设计问题(本该返回拥有所有权的String却返回了引用)。真正需要'static的场景包括:全局常量、std::thread::spawn闭包的默认约束等。
生命周期与型变(variance)、'static 约束的更深入讨论,见《类型系统》一章。
常见错误速查
| 编译器报错 | 根因 | 修复方向 |
|---|---|---|
use of moved value | 移动后继续使用原变量 | 改用借用、.clone(),或接收返回值 |
cannot borrow as mutable | 与已有借用冲突 | 调整顺序,让旧借用先结束(NLL) |
does not live long enough | 数据先于引用释放 | 扩大数据作用域,或改返回所有权类型 |
missing lifetime specifier | 省略规则无法确定输出来源 | 显式标注,或重签名为所有权类型 |
cannot return reference to local | 返回局部变量的引用 | 返回 String/Vec 等所有权类型 |
cannot move out of borrowed | 试图从 &T 后移出值 | .clone()、改返回引用、或 mem::take |
小结
- 所有权:一个值一个所有者;移动是 O(1) 的栈上复制加原变量作废;
Copy隐式复制、Clone显式深拷贝、Drop自动清理。 - 借用:任意多个
&T或唯一一个&mut T;NLL 让借用在最后使用后即结束;字段级拆分借用与split_at_mut解决重叠问题。 - 生命周期:注解描述引用间的约束关系而非改变实际存活;三条省略规则覆盖多数场景,复杂签名用显式参数、
'a: 'b约束或'_。



















