Rust 编译器与运行时揭秘
第2章 所有权系统:编译期内存管理的核心机制
"Ownership is Rust's most unique feature and has deep implications for the rest of the language. It enables Rust to make memory safety guarantees without needing a garbage collector, so it's important to understand how ownership works."
——《The Rust Programming Language》第 4 章 Understanding Ownership 开篇
本章要点
- 所有权模型的核心:每个值有且仅有一个所有者,所有权可以转移(move),所有者离开作用域时值被销毁
- Move 在 MIR 中表现为
Operand::Move(place),将源 place 标记为未初始化,后续访问触发编译错误 - Copy 与 Move 的区别在于 MIR 中使用
Operand::Copy还是Operand::Move,由Copytrait 判定 - Copy 和 Drop 互斥(E0184),同时实现会导致 double free
- Drop elaboration 是 MIR 的关键 pass:分析所有控制流路径,将条件性 Drop 转换为确定性 Drop,必要时插入 drop flag
- 部分移动(partial move)使结构体进入"部分初始化"状态,编译器为每个字段独立追踪
- 析构顺序严格确定:局部变量按声明逆序,结构体字段按声明正序
2.1 所有权模型:每个值有且仅有一个所有者
Rust 的所有权系统建立在三条规则之上:每个值都有一个所有者;同一时刻只能有一个所有者;当所有者离开作用域时值被丢弃。从编译器的视角看,这三条规则的核心目标只有一个:
保证每个拥有析构函数(Drop)的值在所有控制流路径上恰好被销毁一次。
不多不少。不销毁意味着资源泄漏,销毁两次意味着 double free。
fn ownership_basics() {
let s = String::from("hello"); // s 获得所有权
let t = s; // 所有权转移给 t,s 不再有效
// println!("{}", s); // 编译错误 E0382:borrow of moved value: `s`
println!("{}", t); // OK:t 是所有者
} // t 在这里被自动析构,释放堆内存
所有权不仅管理内存,也管理所有需要"清理"的资源——文件句柄、网络连接、互斥锁。这就是 Rust 版本的 RAII。与 C++ 不同的是,Rust 编译器强制执行所有权规则,违规是编译期错误而非运行期崩溃。
所有权转移发生在赋值(let t = s)、函数参数传递(foo(s))、函数返回值(return s)、模式匹配(let (a, b) = pair)等场景中。这些在编译器内部被统一表示为 MIR 中的 Operand::Move。
2.2 Move 语义在编译器层面的实现
2.2.1 MIR 中的 Move 操作
一个简单的 move 操作在 MIR 中的表示:
fn move_example() {
let s = String::from("hello");
let t = s;
println!("{}", t);
}
rustc 1.96.0 下 RUSTC_BOOTSTRAP=1 rustc -Zunpretty=mir 的实测输出(节选:省略了 println! 构造 fmt::Arguments 的几条语句、调试作用域和 cleanup 块):
fn move_example() -> () {
let mut _0: ();
let _1: std::string::String; // s
let _2: std::string::String; // t(实际声明在 scope 1 内)
...
bb0: {
_1 = <String as From<&str>>::from(const "hello") -> [return: bb1, unwind continue];
}
bb1: {
_2 = move _1; // Operand::Move —— 关键!_1 从此处起处于未初始化状态
_6 = &_2; // println! 借用 t
...
}
bb3: {
_3 = std::io::_print(move _4) -> [return: bb4, unwind: bb6];
}
bb4: {
drop(_2) -> [return: bb5, unwind continue]; // 只析构 t;_1 已被移走,没有 drop(_1)
}
bb5: {
return;
}
}
-Zunpretty=mir 打印的是跑完优化之后的 optimized_mir。不加 -O 时 RemoveStorageMarkers pass 会删掉 StorageLive / StorageDead(compiler/rustc_mir_transform/src/remove_storage_markers.rs:10-12:MIR 优化级别大于 0 且不生成 LLVM lifetime 标记时启用),所以上面看不到它们;加 -O 后它们会保留下来。想看借用检查时的版本,用 -Zdump-mir 导出较早阶段的 MIR。
在编译器源码 compiler/rustc_middle/src/mir/syntax.rs 中,Operand 的定义揭示了 Move 和 Copy 的本质区别:
// 源码:compiler/rustc_middle/src/mir/syntax.rs
pub enum Operand<'tcx> {
/// 加载 place 的值。drop elaboration 之前,place 的类型必须是 Copy。
Copy(Place<'tcx>),
/// 加载 place 的值,并*可能*将 place 覆写为 uninit。
Move(Place<'tcx>),
/// 常量值。
Constant(Box<ConstOperand<'tcx>>),
/// 查询当前 crate 的编译期开关(值可能跨 crate 不同,所以不是常量)。
RuntimeChecks(RuntimeChecks),
}
关键信息:Operand::Copy 在 drop elaboration 之前只能用于 Copy 类型;Operand::Move 会将源 place 标记为未初始化。
2.2.2 Move 的物理本质:memcpy
一个常见误解是"move 比 copy 更高效"。实际上,从机器码层面看,两者执行的操作完全相同——都是一次 memcpy。区别仅在编译器的静态分析层面:Copy 后源变量仍有效,Move 后源变量被禁止访问。
graph LR
subgraph "Move 之前"
S1["_1 (s)<br/>ptr → heap: 'hello'<br/>len: 5, cap: 5"]
end
subgraph "Move 之后"
S2["_1 (s)<br/>未初始化(禁止访问)"]
S3["_2 (t)<br/>ptr → heap: 'hello'<br/>len: 5, cap: 5"]
end
S1 -->|"memcpy + 标记未初始化"| S3
style S1 fill:#2563eb,color:#fff,stroke:none
style S2 fill:#dc2626,color:#fff,stroke:none
style S3 fill:#10b981,color:#333,stroke:none
这就是所有权系统"零开销"的本质——所有权转移完全是编译器跟踪的静态信息,没有引用计数的原子操作,没有 GC 暂停。
2.2.3 MoveData:编译器的移动追踪基础设施
编译器通过 MoveData 结构追踪所有 move 操作,定义在 compiler/rustc_mir_dataflow/src/move_paths/mod.rs 中:
// 源码:compiler/rustc_mir_dataflow/src/move_paths/mod.rs
pub struct MoveData<'tcx> {
pub move_paths: IndexVec<MovePathIndex, MovePath<'tcx>>,
pub moves: IndexVec<MoveOutIndex, MoveOut>,
pub loc_map: LocationMap<SmallVec<[MoveOutIndex; 4]>>,
pub path_map: IndexVec<MovePathIndex, SmallVec<[MoveOutIndex; 4]>>,
pub rev_lookup: MovePathLookup<'tcx>,
pub inits: IndexVec<InitIndex, Init>,
// ...
}
MoveData 的核心是 MovePath 的树形结构。每个 MovePath 代表一个可能被移动的路径(place),例如 x、x.field、x.field.subfield:
pub struct MovePath<'tcx> {
pub next_sibling: Option<MovePathIndex>,
pub first_child: Option<MovePathIndex>,
pub parent: Option<MovePathIndex>,
pub place: Place<'tcx>,
}
这种树形结构使编译器能精确追踪部分移动。例如对 struct Pair { first: String, second: String },MovePath 树为:
mp0: p (整体)
├── mp1: p.first
└── mp2: p.second
2.2.4 初始化状态的数据流分析
编译器使用两个互补的数据流分析追踪每个 MovePath 的初始化状态:
- MaybeInitializedPlaces:到达某个程序点时,沿至少一条控制流路径可能已被初始化的所有 place
- MaybeUninitializedPlaces:到达某个程序点时,沿至少一条控制流路径可能仍未初始化的所有 place(被移走的、或从未赋值的)
(两者不是互补关系:一个 place 可以同时出现在两个集合里——那正是需要 drop flag 的情形。)
// 源码:compiler/rustc_mir_transform/src/elaborate_drops.rs(节选,省略了中间的配置方法调用)
let move_data = MoveData::gather_moves(body, tcx, |ty| ty.needs_drop(tcx, typing_env));
let mut inits = MaybeInitializedPlaces::new(tcx, body, &env.move_data)
// ... .exclude_inactive_in_otherwise().skipping_unreachable_unwind()
.iterate_to_fixpoint(tcx, body, Some("elaborate_drops"))
.into_results_cursor(body);
let uninits = MaybeUninitializedPlaces::new(tcx, body, &env.move_data)
// ... .include_inactive_in_otherwise() 等
.iterate_to_fixpoint(tcx, body, Some("elaborate_drops"))
.into_results_cursor(body);
注意过滤条件 |ty| ty.needs_drop(tcx, typing_env)——drop elaboration 只为需要析构的类型构建 MovePath,i32、bool 等不需要析构的类型在这里不追踪移动状态。(借用检查器调用 gather_moves 时传的过滤器是 |_| true,见 compiler/rustc_borrowck/src/lib.rs:336,所有类型都追踪。)
两个分析结果的组合决定了 Drop 策略:
maybe_init |
maybe_uninit |
DropStyle |
|---|---|---|
| false | - | Dead —— 不需要 Drop |
| true | false | Static —— 无条件 Drop |
| true | true | Conditional —— 需要 drop flag(若该路径有多个子路径参与,则细分为 Open,见 §2.4.3) |
2.3 Copy vs Move:编译器如何决策
2.3.1 Copy trait 的判定
flowchart TD
A["类型 T"] --> B{"T 实现了<br/>Copy trait?"}
B -->|"是"| C["MIR: Operand::Copy<br/>源变量仍有效"]
B -->|"否"| D["MIR: Operand::Move<br/>源变量失效"]
E["自动 Copy"] --> F["i32, f64, bool, char<br/>&T, 元组/数组(若所有元素 Copy)"]
G["永远 Move"] --> H["String, Vec, Box<br/>&mut T, 实现了 Drop 的类型"]
I["关键约束:Copy 和 Drop 互斥<br/>编译错误 E0184"] -.-> B
style C fill:#10b981,color:#333,stroke:none
style D fill:#f59e0b,color:#333,stroke:none
style I fill:#dc2626,color:#fff,stroke:none
Copy 类型在 MIR 中使用 Operand::Copy:
// Copy 类型的 MIR
_2 = _1; // Operand::Copy —— 没有 move 关键字
// _1 仍然有效
// Move 类型的 MIR
_2 = move _1; // Operand::Move
// _1 变为未初始化
2.3.2 Copy 和 Drop 互斥(E0184)
#[derive(Copy, Clone)]
struct Foo;
impl Drop for Foo { fn drop(&mut self) {} }
// error[E0184]: the trait `Copy` cannot be implemented for this type;
// the type has a destructor
原因直观:如果允许同时实现,let b = a; 执行 Copy 后,a 和 b 都有效,函数结束时两者都执行 Drop——double free。
2.3.3 needs_drop:编译器的类型分析
Ty::needs_drop() 在 compiler/rustc_middle/src/ty/util.rs 中递归分析类型是否需要析构:
// 源码:compiler/rustc_middle/src/ty/util.rs(节选,省略了部分分支)
pub fn needs_drop_components_with_async<'tcx>(
tcx: TyCtxt<'tcx>, ty: Ty<'tcx>, asyncness: Asyncness,
) -> Result<SmallVec<[Ty<'tcx>; 2]>, AlwaysRequiresDrop> {
match *ty.kind() {
// 基本类型永远不需要 Drop
ty::Bool | ty::Int(_) | ty::Uint(_) | ty::Float(_)
| ty::FnPtr(..) | ty::Char | ty::RawPtr(..) | ty::Ref(..) => Ok(SmallVec::new()),
// trait object:同步 drop 下一律保守认为需要 Drop
ty::Dynamic(..) | ty::Error(_) => {
if asyncness.is_async() { Ok(SmallVec::new()) } else { Err(AlwaysRequiresDrop) }
}
// 数组:元素需要 Drop 且长度非零 → 需要 Drop
ty::Array(elem_ty, size) => { /* 递归检查 */ },
// 元组:任何字段需要 Drop → 整体需要 Drop
ty::Tuple(fields) => { /* 递归检查 */ },
// ADT、泛型等:需要进一步查询
ty::Adt(..) | ty::Param(_) | ty::Closure(..) => Ok(smallvec![ty]),
}
}
这个分析的结果直接决定编译器是否为某个类型构建 MovePath、是否插入 Drop 终止符。
2.4 Drop 析构:编译器何时、如何插入析构代码
2.4.1 MIR 阶段的语义变化
在 MirPhase::Analysis(drop elaboration 之前),Drop 终止符是条件性的——表示"如果这个值已初始化就析构"。在 MirPhase::Runtime(之后),所有 Drop 变为无条件的。
编译器源码中的注释明确说明了这一点:
"In analysis MIR,
Dropterminators represent conditional drops... In runtime MIR, the drops are unconditional; when aDropterminator is reached, if the type has drop glue that drop glue is always executed."
2.4.2 Drop Elaboration Pass
Drop elaboration 定义在 compiler/rustc_mir_transform/src/elaborate_drops.rs 中:
// 源码:compiler/rustc_mir_transform/src/elaborate_drops.rs
/// At a high level, this pass refines Drop to only run the destructor if the
/// target is initialized. The way this is achieved is by inserting drop flags
/// for every variable that may be dropped, and then using those flags to
/// determine whether a destructor should run.
pub(super) struct ElaborateDrops;
执行流程:
ElaborateDrops::run_pass()
├── MoveData::gather_moves() 构建 MovePath 树
├── MaybeInitialized/Uninit 分析 数据流不动点计算
├── compute_dead_unwinds() 识别不可达 unwind 边
└── ElaborateDropsCtxt::elaborate()
├── collect_drop_flags() 收集需要 drop flag 的路径
├── elaborate_drops() 精化每个 Drop 终止符
└── drop_flags_on_init / _for_fn_rets / _for_args / _for_locs
设置 drop flag 初始值和更新逻辑
2.4.3 四种 DropStyle
编译器为每个 Drop 确定一种风格,定义在 compiler/rustc_mir_transform/src/elaborate_drop.rs 中:
pub(crate) enum DropStyle {
Dead, // 所有路径上都未初始化 → 不执行 Drop
Static, // 所有路径上都已初始化 → 无条件 Drop
Conditional, // 状态取决于控制流 → 需要 drop flag
Open, // 部分移动 → 只 Drop 仍初始化的子字段
}
对应的判定逻辑:
// compiler/rustc_mir_transform/src/elaborate_drops.rs:170
fn drop_style(&self, path: Self::Path, mode: DropFlagMode) -> DropStyle {
// ...
match (maybe_init, maybe_uninit, multipart) {
(false, _, _) => DropStyle::Dead,
(true, false, _) => DropStyle::Static,
(true, true, false) => DropStyle::Conditional,
(true, true, true) => DropStyle::Open,
}
}
Dead 和 Static 是最常见情况——编译期完全确定,无需运行时判断。
2.4.4 Drop Elaboration 完整示例
fn conditional_drop(condition: bool) {
let s = String::from("hello");
if condition {
drop(s); // 显式 drop
}
// s 是否已被 drop?取决于 condition
}
注意源码里的显式 drop(s) 不是 Drop 终止符,而是把 s move 进 std::mem::drop 的一次函数调用。Drop elaboration 之前,函数出口(bb5)有一个 drop(_2),若照字面无条件执行,condition=true 时就会 double free。数据流分析发现 bb5 处 s 同时 maybe_init 和 maybe_uninit,于是确定为 Conditional 风格。
elaboration 之后的 MIR(Rust 1.96.0 下 RUSTC_BOOTSTRAP=1 rustc -Zdump-mir=conditional_drop 导出的 ElaborateDrops.after,当日 nightly 输出相同;节选,省略了 Storage 语句、_0 = const () 和 cleanup 块):
bb0: {
_6 = const false; // 函数入口:drop flag 先置 false
_6 = const true; // s 即将初始化:flag 置 true
_2 = <String as From<&str>>::from(const "hello") -> [return: bb1, unwind continue];
}
bb1: {
_3 = copy _1;
switchInt(move _3) -> [0: bb4, otherwise: bb2];
}
bb2: { // condition == true
_6 = const false; // s 被移走:flag 置 false
_5 = move _2;
_4 = std::mem::drop::<String>(move _5) -> [return: bb3, unwind: bb7];
}
bb3: { goto -> bb5; }
bb4: { goto -> bb5; }
bb5: { goto -> bb11; }
bb11: {
switchInt(copy _6) -> [0: bb6, otherwise: bb10]; // 检查 drop flag
}
bb10: { drop(_2) -> [return: bb6, unwind: bb9]; } // Conditional drop
bb6: { _6 = const false; return; }
2.5 Drop Flag:运行时追踪移动状态
2.5.1 创建条件
Drop flag 只在数据流分析表明某路径同时 maybe_init 和 maybe_uninit 时创建:
// 源码:compiler/rustc_mir_transform/src/elaborate_drops.rs
fn collect_drop_flags(&mut self) {
for (bb, data) in self.body.basic_blocks.iter_enumerated() {
// ... 对每个 Drop 终止符
on_all_children_bits(self.move_data(), path, |child| {
let (maybe_init, maybe_uninit) = self.init_data.maybe_init_uninit(child);
if maybe_init && maybe_uninit {
self.create_drop_flag(child, terminator.source_info.span)
}
});
}
}
fn create_drop_flag(&mut self, index: MovePathIndex, span: Span) {
let patch = &mut self.patch;
self.drop_flags[index]
.get_or_insert_with(|| patch.new_temp(self.tcx.types.bool, span)); // 创建 bool 局部变量
}
2.5.2 生命周期
- 函数入口:所有 drop flag 初始化为
false - 变量初始化时:设为
true - 值被移走时:设为
false - Drop 点:检查 flag,为
true则执行 Drop
2.5.3 优化
直线代码里根本不会创建 drop flag——不存在 maybe_init 与 maybe_uninit 同时为真的 Drop 点;即便创建了,只要其值在编译期可知,LLVM 的常量传播和死代码消除也会将其移除。只有条件分支中的 drop(if cond { drop(x); })、循环中的条件移动等场景才需要保留运行时 drop flag。
保留时的代价极小:1 字节栈空间 + 一次条件跳转,与 C++ unique_ptr 析构时的 null 检查本质相同。
2.5.4 elaborate_drops 的三条快捷路径和一个 panic 恢复矩阵
上面讲了 drop flag 什么时候被创建。但 collect_drop_flags 的对应函数 elaborate_drops(compiler/rustc_mir_transform/src/elaborate_drops.rs:335)才是把 TerminatorKind::Drop 最终变成实际执行代码的地方——这段代码比表面上复杂得多:
快捷路径一:needs_drop 直接删除(line 348-355):
if !place.ty(&self.body.local_decls, self.tcx)
.ty.needs_drop(self.tcx, self.typing_env()) {
self.patch.patch_terminator(bb, TerminatorKind::Goto { target });
continue;
}
如果被 drop 的值的类型不需要 drop,TerminatorKind::Drop 直接换成 TerminatorKind::Goto——drop 调用彻底从 MIR 消失,在 MIR 阶段就砍掉,LLVM 根本不会看到这些无用 drop。MIR 构建时(compiler/rustc_mir_build/src/builder/scope.rs)本来就不会给 i32、&T 这类类型安排 drop;走到这条快捷路径的,是构建时判断不了、到 drop elaboration 用 post-analysis 类型环境才确定无需析构的情形——例如 fn mk() -> impl Sized { 1i32 } 的返回值:1.96.0 实测它在 ElaborateDrops.before 里还有 drop(_1),之后变成了 goto。
panic 恢复矩阵(line 360-375)——drop 本身可能 panic,要给它预备好"这次 drop 挂了接下来去哪"的路由:
let unwind = match unwind {
_ if data.is_cleanup => Unwind::InCleanup,
UnwindAction::Cleanup(cleanup) => Unwind::To(cleanup),
UnwindAction::Continue => Unwind::To(self.patch.resume_block()),
UnwindAction::Unreachable => Unwind::To(self.patch.unreachable_cleanup_block()),
UnwindAction::Terminate(reason) => {
debug_assert_ne!(reason, UnwindTerminateReason::InCleanup,
"we are not in a cleanup block, InCleanup reason should be impossible");
Unwind::To(self.patch.terminate_block(reason))
}
};
五种 unwind 路由 对应"drop 自己 panic 时"的五种处理策略:
InCleanup——当前本身就是 cleanup block(另一个 drop 已经在处理异常),再 panic 直接进 abort(不能再 unwind、否则 double panic)To(cleanup)——普通情况,跳到用户函数已有的清理块继续 unwindresume_block——当前函数没本地清理,直接 resume 到调用者继续 unwindunreachable_cleanup_block——编译器确信这里不会 panic,如果真 panic 了触发 unreachableterminate_block(reason)——按 ABI 本就不允许 unwind 的场景(UnwindTerminateReason::Abi),要求 panic 时立即 abort 而不是 unwind
最后那个 debug_assert_ne! 很有意思——它声明"非 cleanup block 里不应该出现 InCleanup 原因",如果真出现是编译器 bug。这种在不变式本应成立的地方加 assertion 捕获 rustc 自己的 bug 是 rustc 源码的常见防御性编程。
LookupResult::Parent(Some(_)) 处理(line 389-399)——被 drop 的 place 不是直接 tracked 而是通过父路径 tracked(比如 array[i] 这种按运行时下标索引的位置——Index 投影不建 move path,见 compiler/rustc_mir_dataflow/src/move_paths/mod.rs:427;Box 的解引用 *box 则有自己的 move path,不走这条分支)。这种情况只在 replace=true 时允许("drop 前重新赋值" 场景)、否则直接 span_bug(表示 rustc bug 不是用户代码错)。借用检查器会保证这种位置在赋值前已经初始化、所以可以无条件 drop 不需要 flag。这解释了为什么 arr[i] = new_value 不会因为"旧值是否已 drop"而需要运行时检查。
async_fut: _(line 339)——TerminatorKind::Drop 的 async_fut 字段(连同 drop 字段)服务于 async drop。async Drop 至今仍是 nightly 的 incomplete 特性(#![feature(async_drop)],自 1.88.0 起,tracking issue rust-lang/rust#126482)。这里用 _ 忽略它,是因为此刻它还是 None:MIR 构建产生的 Drop 一律不带 async_fut,正是 drop elaboration 自己在生成异步析构时(compiler/rustc_mir_transform/src/elaborate_drop.rs 的 build_async_drop)把它填成 future 所在的局部变量,再交给 coroutine.rs 里的 StateTransform 展开;drop 字段则作为 dropline 原样传给了 elaborate_drop。
读完 elaborate_drops 你会发现所谓 "Drop 很简单" 是错觉——简单的是用户视角的语义(离开作用域就析构);难的是编译器视角的实现(和 unwind 协议、异步、type-level 需求分析、MoveData 索引、cleanup block 网络错综交织)。elaborate_drops.rs 这 518 行代码是 Rust 异常安全的基石——每一行都在回答一个"如果 panic 了怎么办"的具体问题。
2.6 析构顺序:编译器的确定性保证
2.6.1 规则
| 类别 | 析构顺序 |
|---|---|
| 局部变量 | 声明逆序 |
| 结构体字段 | 声明正序 |
| 元组/数组元素 | 索引正序 |
graph TD
A["函数入口"] --> B["StorageLive(_1) // first"]
B --> C["StorageLive(_2) // second"]
C --> D["StorageLive(_3) // third"]
D --> E["... 函数体 ..."]
E --> F["drop(_3) // third 最先析构"]
F --> G["drop(_2) // second 其次"]
G --> H["drop(_1) // first 最后"]
H --> I["return"]
style F fill:#dc2626,color:#fff,stroke:none
style G fill:#f59e0b,color:#333,stroke:none
style H fill:#2563eb,color:#fff,stroke:none
2.6.2 为什么逆序很重要
逆序析构保证后声明的变量(可能引用先声明的变量)先被析构,避免悬垂引用:
fn why_reverse_order() {
let data = vec![1, 2, 3]; // 先声明
let reference = &data; // 后声明,引用 data
// 逆序析构:reference 先释放,data 后释放 → 安全
// 如果正序:data 先释放,reference 变成悬垂引用 → 不安全
}
对锁守卫尤为关键:
use std::sync::Mutex;
fn lock_order(mutex_a: &Mutex<i32>, mutex_b: &Mutex<i32>) {
let guard_a = mutex_a.lock().unwrap(); // 先获取
let guard_b = mutex_b.lock().unwrap(); // 后获取
// 析构:guard_b → guard_a(先释放后获取的锁,与获取顺序相反)
// 锁按嵌套顺序释放,释放时机完全可预期
}
2.7 部分移动:结构体的所有权碎片化
2.7.1 机制
struct Pair { first: String, second: String }
fn partial_move() {
let p = Pair { first: "hello".into(), second: "world".into() };
let f = p.first; // 部分移动
// p.first → 不可用(已移出)
// p.second → 可用
// p 整体 → 不可用(部分初始化)
println!("{}", p.second); // OK
}
编译器为每个字段独立追踪初始化状态。函数结束时只对 p.second 调用 Drop,这就是 DropStyle::Open 的用途——"打开"结构体,只 Drop 仍初始化的字段。
2.7.2 限制
实现了 Drop 的类型不允许部分移动——因为 drop(&mut self) 需要访问完整结构体,如果某字段已被移出,析构函数就会访问未初始化内存。引用背后的值也不允许部分移动,因为引用不拥有所有权。
2.8 借用检查器与所有权的关系
借用检查器建立在所有权系统之上。所有权回答"谁负责析构",借用检查器回答"谁可以在什么时候访问"。两者共享同一套 MoveData 基础设施:
// 源码:compiler/rustc_borrowck/src/lib.rs
use rustc_mir_dataflow::impls::{EverInitializedPlaces, MaybeUninitializedPlaces};
use rustc_mir_dataflow::move_paths::{
InitIndex, InitLocation, LookupResult, MoveData, MovePathIndex,
};
借用检查器借助 MoveData 检测 use after move 和 use of uninitialized value;move while borrowed 则还要结合借用集合 BorrowSet 判断(见 §2.17)。这种复用体现了编译器的设计哲学——所有权和借用是同一问题的两个面。
2.9 所有权的 MIR 表示:完整视图
2.9.1 MIR 阶段与所有权语义
graph LR
A["MirPhase::Built"] --> B["MirPhase::Analysis"]
B --> C["MirPhase::Runtime"]
A1["Drop 是条件性的<br/>Copy 仅限 Copy 类型"] --> A
B1["借用检查在此执行<br/>Move 错误在此报告"] --> B
C1["Drop elaboration 完成<br/>Drop 变为无条件的<br/>drop flag 已插入"] --> C
style A fill:#2563eb,color:#fff,stroke:none
style B fill:#f59e0b,color:#333,stroke:none
style C fill:#10b981,color:#333,stroke:none
到了 MirPhase::Runtime,Operand::Copy 不再受 Copy trait 限制——因为所有析构决策已固化为显式 Drop 和 drop flag。
2.9.2 StorageLive/StorageDead vs Drop
StorageLive/StorageDead 标记局部变量栈空间的存活区间(不一定对应实际的分配指令,LLVM 可据此复用栈槽),Drop 管理值的析构(如释放堆内存)。两者是不同层次:
StorageLive(s) → 标记 s 的栈槽从此处开始使用(64 位目标上 String 占 24 字节:指针、容量、长度)
s = String::from("hello") → 堆上分配 5 字节
drop(s) → 释放堆上的 5 字节
StorageDead(s) → 标记 s 的栈槽从此处失效
2.9.3 DropFlagMode:浅层 vs 深层
// 源码:compiler/rustc_mir_transform/src/elaborate_drop.rs
pub(crate) enum DropFlagMode {
Shallow, // 只影响顶层 drop flag
Deep, // 影响所有嵌套子字段的 drop flag
}
Deep 连同所有子路径一起看——判定整个值的 DropStyle、析构整个值后清除全部 drop flag 时用它;Shallow 只测试或清除顶层的"主" drop flag,例如打开式析构(open drop)逐字段析构完毕后清除主 flag(drop_ladder_bottom),以及 drop_flag_test_block 里的 flag 检查。
2.10 与其他内存模型的对比
graph TB
subgraph "手动管理 - C"
C1["malloc/free<br/>⚠️ 泄漏/double free/UAF"]
end
subgraph "垃圾回收 - Java/Go"
G1["运行时 GC<br/>⚠️ 暂停、内存占用高"]
end
subgraph "引用计数 - Swift ARC"
A1["retain/release<br/>⚠️ 原子操作开销、循环引用"]
end
subgraph "所有权 - Rust"
R1["编译期追踪<br/>零运行时开销、编译期安全"]
end
style C1 fill:#dc2626,color:#fff,stroke:none
style G1 fill:#f59e0b,color:#333,stroke:none
style A1 fill:#f59e0b,color:#333,stroke:none
style R1 fill:#10b981,color:#333,stroke:none
| 维度 | C (手动) | Java/Go (GC) | Swift (ARC) | Rust (所有权) |
|---|---|---|---|---|
| 安全保证 | 无 | 运行时 | 部分 | 编译期 |
| 运行时开销 | 零(但易出错) | GC 暂停 | 原子引用计数 | 零 |
| 析构时机 | 手动 | 不确定 | 确定性 | 确定性 |
| 并发安全 | 程序员负责 | GC 处理 | 原子操作 | 编译期 Send/Sync |
| 适用场景 | 嵌入式/底层 | 应用层服务 | iOS/macOS | 系统编程/高性能 |
C 的手动管理:编译器不提供任何安全网。char *t = s; free(s); printf("%s", t); 是合法的 C 代码,但会导致 use after free。Rust 的所有权系统在编译期阻止所有此类错误。
Java/Go 的 GC:消除了手动管理错误,但析构时机不确定(finalize() 可能永远不被调用),且 GC 暂停对实时系统不可接受。Rust 既有确定性析构又无 GC 暂停。
Swift 的 ARC:有确定性析构,但引用计数的原子操作有性能开销,循环引用会泄漏。Rust 的 Rc<T>/Arc<T> 是可选的,大多数代码用纯所有权模型,无引用计数开销。
2.11 常见所有权模式
2.11.1 Builder 模式
利用所有权转移实现类型安全的方法链:
struct QueryBuilder { table: String, conditions: Vec<String>, limit: Option<usize> }
impl QueryBuilder {
fn new(table: &str) -> Self {
QueryBuilder { table: table.into(), conditions: vec![], limit: None }
}
fn where_clause(mut self, cond: &str) -> Self { // 消费 self
self.conditions.push(cond.into()); self
}
fn limit(mut self, n: usize) -> Self { self.limit = Some(n); self }
fn build(self) -> String { // 消费 self → 之后不可再用
format!("SELECT * FROM {} WHERE {} LIMIT {:?}",
self.table, self.conditions.join(" AND "), self.limit)
}
}
每个方法调用在 MIR 中都是 Operand::Move——self 移入方法,返回值移回调用者。build() 消费 self 后,编译器保证不会被误用。
2.11.2 RAII 模式
所有权绑定资源生命周期——即使 panic 也能正确清理:
struct Transaction { committed: bool, /* ... */ }
impl Transaction {
fn commit(mut self) -> Result<(), Error> { // 消费 self
self.committed = true;
Ok(())
}
}
impl Drop for Transaction {
fn drop(&mut self) {
if !self.committed {
eprintln!("Transaction not committed, rolling back");
}
}
}
commit 消费 self——提交后不能再操作(编译期保证)。未提交时 Drop 自动回滚。
2.11.3 类型状态模式
用所有权转移编码状态机,使非法状态转换成为编译错误:
use std::io;
use std::net::TcpStream;
struct Disconnected;
struct Connected { stream: TcpStream }
struct Authenticated { stream: TcpStream, token: String }
impl Disconnected {
fn connect(self, addr: &str) -> Result<Connected, io::Error> {
Ok(Connected { stream: TcpStream::connect(addr)? })
} // Disconnected 被消费,不能再使用
}
impl Connected {
fn authenticate(self, creds: &str) -> Result<Authenticated, Connected> {
// 验证成功:所有权从 Connected 转移到 Authenticated
// 验证失败:所有权返回 Connected
todo!()
}
}
impl Authenticated {
fn send_data(&mut self, data: &[u8]) -> Result<(), io::Error> {
// 只有认证后才能发送——编译期保证
todo!()
}
}
2.11.4 Newtype 模式
所有权封装创建类型安全抽象,零运行时开销:
struct UserId(String);
struct Email(String);
// 编译错误:UserId 不是 Email,类型不匹配
// send_email(user_id);
// 实际布局与裸 String 相同;要在 ABI 层面得到保证,需显式标注 #[repr(transparent)]
// 所有权操作零额外开销
2.12 本章小结
本章从编译器源码层面剖析了所有权系统的实现:
- Move 语义:MIR 中的
Operand::Move,物理上是 memcpy,语义上标记源为未初始化。通过MoveData和MovePath树追踪。 - Copy vs Move:由
Copytrait 决定。Copy 和 Drop 互斥(E0184)。needs_drop()递归判断类型是否需要析构。 - Drop elaboration:MIR 关键 pass,使用 MaybeInitialized / MaybeUninitialized 两个前向数据流分析,将条件性 Drop 转为四种确定性风格(Dead/Static/Conditional/Open)。
- Drop flag:仅在控制流歧义时插入,是一个
bool局部变量,大多数情况被 LLVM 优化掉。 - 析构顺序:变量逆序、字段正序。逆序保证引用在被引用物之前失效。
- 内存模型对比:Rust 将安全成本从运行时转移到编译时——零开销、确定性析构、编译期保证。
下一章我们将深入借用检查器——它建立在本章的 MIR 和初始化状态追踪之上,用 NLL 算法判断引用的合法性。
2.13 源码实证:compiler/rustc_mir_dataflow/src/move_paths/mod.rs 442 行
本章讲了 MovePath——打开 compiler/rustc_mir_dataflow/src/move_paths/mod.rs:58-63、你会看到 MovePath 的实际定义:
#[derive(Clone)]
pub struct MovePath<'tcx> {
pub next_sibling: Option<MovePathIndex>,
pub first_child: Option<MovePathIndex>,
pub parent: Option<MovePathIndex>,
pub place: Place<'tcx>,
}
四个字段讲一个故事——
parent+first_child+next_sibling= 兄弟链 + 父子链的双向图place: Place<'tcx>= MIR 层面的地址表达式(如_1.0代表x.m)'tcx=TyCtxt的生命周期参数(rustc 的核心上下文)
为什么用 Option<MovePathIndex> 而不是 Vec<MovePathIndex>——index-based 的兄弟链比 Vec<Index> 省内存——三个 Option<MovePathIndex> 各 4 字节(MovePathIndex 由 newtype_index! 生成,底层是带 niche 的 u32,所以 Option 不额外占位),Place 是 Local(u32)+ 一个 &'tcx List<..> 指针,对齐后整个结构体 32 字节——1M 个 MovePath 只占 32 MB——rustc 的内存预算紧。
对比 naive 实现——Vec<Index> 动态分配 + heap 碎片——每个节点都要单独的堆分配,大型 crate 上内存与分配开销明显更高。
2.14 MovePath 为什么用 index 不用指针
MovePathIndex 本质是 u32 索引——不是裸指针、不是 &MovePath——三个好处:
好处 1——克隆便宜——MovePathIndex 是 Copy(一个 u32)、到处传递零成本;而且节点之间用索引而不是引用,整棵树里没有 &/Rc 构成的环,MovePath 本身可以安全地 #[derive(Clone)]。
好处 2——borrow checker 友好——&move_paths[mpi] 是每次 lookup 即借即还——同一 MoveData 可以在多个位置被读取、不会触发借用冲突。
好处 3——序列化友好——索引与内存地址无关、不涉及指针修复——所以 rustc 里标了 #[encodable] 的索引类型(如 Local、BasicBlock)能随 MIR 一起写进增量缓存和 crate 元数据。MovePathIndex 本身没有标 #[encodable]:MoveData 在借用检查、drop elaboration 时按需现建,并不进缓存。
Rust 编译器内部大量使用这种"IndexVec + Index newtype"模式——Local、BasicBlock、FieldIdx、NodeId、RegionVid 全是 newtype_index! 生成的 u32 包装(LocalDefId 也是——它包着同为 u32 的 DefIndex)——这是 rustc 的 idiom。
读者写自己的 compiler-like 系统时——learn this pattern——比 Arc<Mutex<HashMap<NodeId, Arc<Node>>>> 这类结构少一层哈希、少一次原子操作、也少一次堆分配。
2.15 find_descendant 的遍历实现——工程细节
compiler/rustc_mir_dataflow/src/move_paths/mod.rs:98-124 的 find_descendant——沿 first_child / next_sibling 两条链找满足条件的第一个后代:
pub fn find_descendant(
&self,
move_paths: &IndexSlice<MovePathIndex, MovePath<'_>>,
f: impl Fn(MovePathIndex) -> bool,
) -> Option<MovePathIndex> {
let Some(child) = self.first_child else { return None };
let mut todo = vec![child];
while let Some(mpi) = todo.pop() {
if f(mpi) { return Some(mpi); }
let move_path = &move_paths[mpi];
if let Some(child) = move_path.first_child { todo.push(child); }
if let Some(sibling) = move_path.next_sibling { todo.push(sibling); }
}
None
}
几个值得学的技巧——
let Some(x) = ... else { return }的 let-else 语法(Rust 1.65+)——比if let扁平Vec + pop()做栈——注意这是后进先出,所以这是一次深度优先遍历而不是 BFS;换成VecDeque + pop_front()才是 BFS。这里只要"找到第一个满足条件的后代",顺序无关紧要,于是选了开销更低的Vecimpl Fn参数——支持闭包、函数指针、任意 callable——零成本多态
2.16 builder.rs 593 行:从 MIR 构造 MovePath 树
compiler/rustc_mir_dataflow/src/move_paths/builder.rs 593 行——借用检查和 drop elaboration 都要调用的 MoveData 构造器(gather_moves):
核心流程——
- 遍历 MIR 的每个
Statement/Terminator - 对每个
Operand::Move(place)/ 赋值——生成或查找对应 MovePath - 维护place 到 MovePathIndex 的双向 map
- 解析嵌套 place(
_1.0.1→ 先建_1、再建_1.0、再建_1.0.1)
593 行看似多——但每一行都有明确用途——MIR 的所有 Statement / Terminator 类型都要处理(Assign / StorageLive / StorageDead / Call / Drop / ...)——没偷懒。
对本专栏读者的启发——如果你写静态分析器、IR 解析器、"path tree + place lookup"是成熟范式——不要自己发明新轮子。
2.17 BorrowSet 和 MoveData 的分工
本章讲 MoveData——rustc 还有一个孪生的 BorrowSet(compiler/rustc_borrowck/src/borrow_set.rs):
MoveData——追踪所有权移动(谁被 move、谁被 drop)BorrowSet——追踪所有借用(每个&x/&mut x都是一个 Borrow、有 index)
两者配合——
- 借用检查需要同时知道"这个 place 是否已被 move"(查 MoveData)和"这个 place 是否有活跃借用"(查 BorrowSet)
- 冲突情景——"place 被 move 时还有一个活跃的借用"(借用在 move 之后仍被使用) → 报错 E0505
这是"两个数据结构 + 一套数据流分析"的协作——rustc 把"所有权" 和 "借用" 解耦——各自可读、组合可验。
2.18 Rust 相比 C++ 的"静态析构"优势
本章 §2.10 对比过内存模型——这里再深挖"静态析构" 一条:
C++——
shared_ptr析构在 dtor 里原子减引用计数 + 可能调用 deallocate——运行时代价unique_ptr稍好、但仍然是运行时检查 + pointer 解引用- 析构顺序——标准同样规定构造逆序(这一点与 Rust 一致)
Rust——
- 析构点完全在编译期确定——MIR 的 Drop elaboration pass
- 析构顺序——语言规范强制:变量逆序、字段正序
- drop flag——仅在条件性 drop 场景插入——被 LLVM 优化——几乎无 runtime cost
工程后果——
- Rust 程序可以没有 GC;只有显式使用
Rc/Arc时才可能出现引用循环(见 §2.25 第 9 条)——内存释放"在编译时就知道" - 实时系统里相对 GC 语言占优——因为"GC pause" 和 "unpredictable dtor time" 都不是问题
这是 Rust 的"杀手锏"——不是"比 C++ 安全"(C++ 用好了也安全),而是"安全 + 可预测"——两者兼得、稀缺。
2.19 needs_drop() 函数——编译器的"懒惰判断"
rustc 有个内部方法 Ty::needs_drop(tcx, typing_env)(compiler/rustc_middle/src/ty/util.rs:1317,标准库对外暴露为 std::mem::needs_drop::<T>())——判断一个类型是否需要析构。
判断规则——
- 原始类型 (
i32,bool,f64,char)——不需要 - Pure data composites (
(i32, bool),[i32; 10])——递归判断——全原始就不需要 - 实现
Droptrait——需要 - 含
!Copy的堆分配类型 (String,Vec,Box)——需要 - 泛型 T——编译时未知、保守认为"可能需要"——生成 drop 代码(但大部分被 monomorphize 后优化掉)
优化意义——
needs_drop::<i32>() == false→ 变量离开 scope 不生成Drop调用——零指令开销needs_drop::<String>() == true→ 生成String的 drop glue(String自身没有Drop实现,由内部Vec<u8>的Drop::drop释放 heap buffer)
实测——一个 struct Point { x: f64, y: f64 } 的变量——离开 scope 时生成 0 条指令——因为 needs_drop::<Point>() == false。
这是 Rust "零开销抽象" 的具体落地——抽象层次高、但生成的代码跟手写 C 一样紧。
2.20 Rust 2024 edition 的"生命周期推导改进"
Rust 2024 edition 随 Rust 1.85.0(2025-02-20) 稳定,对所有权和临时值生命周期做了几个小但重要的改进——
改进 1:match ergonomics 收紧——默认绑定模式被改写之后再显式写 & / ref / mut 的那些歧义写法,在 2024 里成了错误;rustc 用 rust_2024_incompatible_pat lint(compiler/rustc_lint_defs/src/builtin.rs)帮你在迁移前先发现它们。
改进 2:if let 临时值提前析构(rescope)——2024 之前,if let 被匹配表达式里产生的临时值要活到整条 if let 语句结束(包括 else 分支);2024 起它们在进入 else 之前就被 drop。rustc 的 if_let_rescope lint(compiler/rustc_lint/src/if_let_rescope.rs)负责提示行为变化——if let Some(_) = m.lock().unwrap().get(&k) { } else { ... } 这类"在 else 里再次上锁就死锁"的坑由此消失。
改进 3:尾表达式临时值的析构顺序调整——函数/块尾表达式里的临时值,现在在局部变量之前被 drop,析构顺序更符合直觉;对应 tail_expr_drop_order lint。
(历史对照)闭包的精确捕获——以前 move || x.field 会 move 整个 x、现在只捕获 x.field——这条不是 2024 的改动,而是 Rust 2021 edition 引入的 RFC 2229,同样是为了解决"只用一个字段却拿全所有权"的历史坑。
这几条改动——看似小、但去除了无数"我的代码应该能编但不能"、以及"锁没按我以为的时间释放"的历史疑难杂症。
2.21 所有权 × 生命周期 × 借用——三角关系
本章讲所有权——但 Rust 的安全其实是"所有权 + 借用 + 生命周期" 三位一体:
- 所有权——谁拥有资源、谁负责释放
- 借用——非所有者如何"临时访问"资源
- 生命周期——借用必须在被借物存在期间有效
三者缺一不可——
- 只有所有权、没借用——函数传参极不方便(每次都 move)
- 只有所有权 + 借用、没生命周期——无法验证 dangling reference
- 只有借用 + 生命周期、没所有权——说不清谁负责释放资源、何时析构
Rust 的做法——三者分工明确、彼此配合,共同构成它的内存安全模型。
Rust 由 Graydon Hoare 于 2006 年作为个人项目起步、2009 年起由 Mozilla 赞助、2010 年首次公开——2015 年 5 月发布 1.0——所有权 / 借用 / 生命周期这套模型正是在这段时间里逐步定型的(NLL 要到 2018 edition 才落地)。
2.22 rustc_borrowck 的模块结构
本章多次呼应下章——这里先给读者一张地图:
compiler/rustc_borrowck/src/ 目录里的关键文件——
| 文件 | 职责 |
|---|---|
lib.rs |
入口:mir_borrowck 主函数 |
borrow_set.rs |
BorrowSet 数据结构 |
nll.rs |
Non-Lexical Lifetimes 算法 |
places_conflict.rs |
判断两个 place 是否冲突 |
constraints/ |
生命周期约束图 |
polonius/ |
新一代借用检查(实验) |
diagnostics/ |
借用错误的诊断信息 |
dataflow.rs |
借用状态流分析 |
21 个子模块、34,458 行代码(1.96.0 tag 实测,与 §1.18、ch04 §4.12 同口径)——借用检查是 rustc 最复杂的模块之一。
本章讲所有权(rustc_mir_dataflow 模块)——下章讲借用检查(rustc_borrowck)——两章之间紧密咬合。
2.23 Polonius 的未来——更精确的借用检查
Rust 团队自 2018 年起在开发 Polonius——下一代借用检查器。
Polonius 相比 NLL 的优势——
- 更精确——能处理 NLL 仍然报错的合法代码(典型如 NLL "problem case #3":在一个分支里返回借用、另一个分支里再次可变借用同一个值)
- 更声明式——最初的原型用 Datalog 规则描述借用规则——易于形式化证明
- 可能更可解释——Datalog 规则让"为什么报错"有机会追溯到具体推导步骤——这是笔者的推断,1.96.0 的 Polonius 诊断并没有公开提供这种能力
但 Polonius 更贵——基于 polonius-engine 的 Datalog 求解在大 crate 上明显慢于 NLL——这也是它迟迟没进 stable 的主要原因之一。
1.96 的状态——默认关闭(Polonius::Off);-Zpolonius/-Zpolonius=legacy 打开基于外部 polonius-engine 的旧实现,-Zpolonius=next 打开直接长在 NLL 基础设施上的 in-tree 原型(不再走 Datalog 引擎,而是把贷款传播表述为约束图上的可达性问题)。定义见 compiler/rustc_session/src/config.rs 的 enum Polonius { Off, Legacy, Next }。何时替代 NLL 目前没有公开时间表。
读者为何要关心——Polonius 代表 Rust 借用检查的未来——你写今天的代码、未来可能被 Polonius 重新检查——按设计,Polonius 接受 NLL 接受的程序,它的意义在于让今天被 NLL 拒绝的一部分合法写法将来能通过。
2.24 所有权 + unsafe 的"边界"
Rust 的安全边界由所有权 + 借用 + 生命周期构成——但 unsafe block 是"逃生出口":
unsafe 能做什么——
- 解引用裸指针 (
*const T/*mut T) - 调用 unsafe 函数(
extern "C"FFI / 标准库内部) - 访问
static mut变量 - 实现 unsafe trait
- 读写 union 字段
unsafe 不能做什么——
- 不能绕过所有权规则——unsafe 里仍然得小心 move、编译器不会自动"忽略所有权检查"
- 不能让程序变安全——unsafe 只是"告诉编译器:我承担这部分的不变式"
编写 unsafe 的黄金法则——
- 封装不变式——把 unsafe 代码限制在一个小 module、外部接口全 safe
- 文档——每个 unsafe 操作旁边写一行
// SAFETY: ...注释说明"why this is safe"——Clippy 有clippy::undocumented_unsafe_blocks可以把这条约定变成强制检查(rustc 本身不检查注释;rustc 管的是unsafe_op_in_unsafe_fn这类边界 lint) - 测试 + Miri——Miri 是 MIR 层的解释器、能检测 undefined behavior——unsafe 代码"必须" 跑
cargo +nightly miri test
这条"safe + unsafe 两层"设计——是 Rust 工程实用主义的体现——100% safe 做不出 OS kernel / 高性能运行时——留个 unsafe 口子、但严格管控。
2.25 所有权的"常见新手坑"十条
即便学完本章理论——实操时仍然会栽——十条典型坑:
- for 循环里消费 Vec 还想继续用——
for x in v { ... }之后 v 已被 move——用&v或v.iter()借用 - 把
&mut self暴露给两个地方——不会编译——一处借用时另一处必须等 - struct 里持有
&T字段——需要生命周期参数struct Foo<'a> { data: &'a T }——省不掉 - 闭包意外 move 外部变量——用
move ||强制 move 或"引用 capture" 保留借用 - 递归数据结构——
struct Node { next: Node }编译不过(无限大小)——用Box<Node> - Vec + push 后想借用之前的元素——push 可能扩容导致旧引用失效——先借用再 push 不行
- String +
&str——&str是借用、String 是所有——函数参数通常用&str(接受两者) - Option::take / mem::replace——从
&mut T里"取走" 值——标准技巧、不是 hack - Rc/Arc 循环——Rust 不检测——用
Weak打破循环、否则 memory leak - HashMap entry API——
map.entry(key).or_insert_with(|| ...)比if !contains + insert更符合借用规则
这 10 条——每条都是社区问答区的常客——预先知道能省下大量 debug 时间。
2.26 所有权的"哲学映射"
Rust 的所有权本质是什么——三个映射:
映射 1:到 C++ 的 RAII——Rust 把 C++ 的 RAII 原则变成"语言规则"——编译器强制而非开发者纪律。
映射 2:到线性 / 仿射类型(linear / affine types)——线性类型要求每个值恰好用一次,仿射类型要求至多用一次——Rust 的所有权 + move 语义更接近仿射类型的工业落地(值可以不被使用、直接被 drop)。
映射 3:到"资源治理"——除了内存、文件句柄、锁、数据库连接"所有权"都适用——Rust 的 RAII 覆盖一切资源。
三个映射合起来——Rust 的所有权"既不是新发明、也不是老调重弹"、是把多个学术成果"工业化落地"的产物——这就是"伟大语言"的共性。
2.27 Copy trait 的"内部实现"——一行代码的大秘密
Copy 是 Rust 里最简单的 trait——trait body 完全为空:
pub trait Copy: Clone {}
空的!——没有方法、没有关联类型、没有 trait objects。
但它对 rustc 意义重大——
- 所有"bit-wise copy" 的行为、rustc 根据
T: Copy标记自动生成 Copy隐含Clone(Clone是显式的.clone()、Copy是隐式的=赋值)- 可以手动写
impl Copy for T {}(trait 体本来就是空的),#[derive(Copy)]只是省掉这一行并顺带给泛型参数加上T: Copy约束——无论哪种写法,rustc 都会 check 所有字段都是 Copy、且类型没有实现Drop
空 trait 的用法——"marker trait"——不提供行为、只提供"类型 tag"——rustc 编译时查这个 tag 做特殊处理。
marker trait 在 Rust 里还有——Send、Sync、Sized、Unpin、Freeze——每一个都是"语言级能力" 的开关。
这种"空 trait + 编译器特殊处理"的设计——比"大量关键字" 更灵活——语言层面只有一个"trait" 概念、各种标记都复用它。
2.28 Drop trait 的"反直觉约束"
Drop 是 Rust 里最特殊的 trait——三个反直觉约束:
约束 1:不能手动调用 .drop()——用户写 x.drop() 会被 rustc 拒绝——只能让 rustc 在 scope 结束时自动调。
为什么——如果用户手动调完、rustc 在 scope 结束时又自动调一次 → double free——rustc 强制"只能自动调用"。
约束 2:Drop 和 Copy 互斥(E0184)——一个类型不能同时 impl Drop 和 impl Copy。
为什么——Copy 意味着"bit-wise 复制、无副作用"、Drop 意味着"析构时有副作用"——两者逻辑冲突。
约束 3:Drop::drop(&mut self)——接 &mut self 而不是 self——drop 里还有 self、但之后整个 self 被回收——&mut self 给"最后一次修改机会"。
经典用法——MutexGuard::drop 里释放锁、OwnedFd::drop 里关闭 file descriptor(Unix 上 File 内部持有的就是 OwnedFd)——都用 &mut self。
2.29 ManuallyDrop 的用途——"禁用自动析构"
Rust 标准库有个 std::mem::ManuallyDrop<T>——包一层、阻止自动析构:
use std::mem::ManuallyDrop;
let x: ManuallyDrop<String> = ManuallyDrop::new(String::from("hi"));
// x 离开 scope 时,里面包着的 String 不会被 drop
为什么需要这个——
- FFI 场景——C 代码接管了 Rust 对象的内存、Rust 不应该 drop
- union 字段——union 不知道该 drop 哪个字段、非
Copy的字段必须包成 ManuallyDrop - 手动析构顺序——想精确控制 drop 顺序、用
unsafe { ManuallyDrop::drop(&mut x) }手动触发
危险——如果不手动调 ManuallyDrop::drop、就是"内存泄漏"——不 unsafe、但逻辑错误。
应用——Vec::into_raw_parts / vec::IntoIter 内部用它把 Vec 拆成裸部件而不触发析构;MaybeUninit<T> 本身就是 union { uninit: (), value: ManuallyDrop<T> }——所有"手动控制生命周期"的底层原语都绕不开它。
2.30 MaybeUninit<T>——"未初始化"的类型
Rust 不允许"读一个未初始化变量"——这是安全的根基——但底层代码有时需要:
use std::mem::MaybeUninit;
let mut x: MaybeUninit<String> = MaybeUninit::uninit();
// x 里现在是垃圾、读 x.assume_init() 是 UB
unsafe { x.as_mut_ptr().write(String::from("hello")); }
let s: String = unsafe { x.assume_init() }; // 现在合法
使用场景——
- 大 struct 就地初始化——不想在栈上复制一份后再赋值
- 数组初始化——
let arr: [MaybeUninit<T>; 1024]、再逐个 write、最后 transmute 成[T; 1024] - FFI——C 函数填充 struct、Rust 只保留空 slot
MaybeUninit 是 Rust 1.36 新增——替代了"mem::uninitialized()" 的老 API(老 API 立即 UB、新 API 类型级别明确可能未初始化)——类型系统进步的典范。
2.31 Pin<T> 与自引用 struct——所有权的"固定版"
所有权一般假设"可移动"——但 async / self-referential struct 要求"不可移动"——Rust 用 Pin<T> 解决:
use std::pin::Pin;
use std::marker::PhantomPinned;
struct SelfRef {
data: String,
ptr: *const String, // 指向自己的 data
_pin: PhantomPinned,
}
Pin<P> 的本质——"保证 P 指向的数据不会被 move"——拿着 Pin<&mut T> 也得不到 &mut T(除非 T: Unpin),也就没法 mem::swap。
为什么需要——
- async fn 生成的 future 内部可能有"指向自己变量的指针"——如果 future 被 move、内部指针就悬挂
- Pin 就是编译时标记"这个对象从 pin 起不能再 move"
使用 Pin 的两种方式——
- 栈上 pin:
pin!(value)宏(Rust 1.68+) - 堆上 pin:
Box::pin(value)→Pin<Box<T>>
Pin 是 Rust 所有权的"高阶扩展"——本章未深入、但值得读者知道存在——写 async 库必须理解它。
2.32 Send 和 Sync 的跨线程所有权
所有权规则在单线程内讨论过——跨线程场景由 Send / Sync 补全:
Send——类型 T 可以"move 到另一个线程"Sync——类型 T 的&T可以"共享到多个线程"
Rust 的跨线程安全"完全编译时保证"——不是"运行时 lock 判断"。
几个典型类型——
Arc<T>是Send + Sync(ifT: Send + Sync)——能跨线程 shareRc<T>不是Send——只能单线程引用计数、避免原子开销Cell<T>是Send(ifT: Send)、不是Sync——单线程内部可变、跨线程不行Mutex<T>是Send + Sync(ifT: Send)——跨线程锁
这就是 Rust 的 "fearless concurrency"——类型系统强制"不 Send 的东西别跨线程"——编译期杜绝数据竞争。
2.33 实战:**5 个"重写为 Rust 式所有权"的真实练习
读者最好的检验方式——做这 5 个练习:
练习 1——把一段 C++ unique_ptr<Foo> 代码翻译成 Rust Box<Foo>——对比"所有权转移时的语法"。
练习 2——用 Rust 实现 LinkedList<T>——先写不出来(会卡 borrow checker)、查资料了解"Option<Box<Node>> + Option<Rc<RefCell<Node>>>" 两种解法。
练习 3——把一个"回调模式"(如事件总线)重写——用 Box<dyn Fn> + Vec<Box<dyn Fn>>——理解 trait object 的所有权。
练习 4——写一个"带析构日志"的 struct——在 impl Drop 里 println!——观察析构顺序(§2.6 讲过逆序)。
练习 5——读一段 std::sync::Arc 源码——看 clone() 的原子 fetch_add、drop() 的 fetch_sub——理解 reference counting 的内部。
5 个练习做完——你对所有权不再是"书本知识"、是"肌肉记忆"。
2.34 附录 A:10 个"不写代码就能记住的"所有权速查
- 一人一物 —— 一个值只能有一个 owner
- 移动即失效 ——
=后左边拿到所有权、右边变失效 - 借用不改所有权 ——
&x/&mut x临时用、原 owner 不变 - 借用三原则 —— 可变借用只能一个、不可变可多个、两者互斥
- 作用域定生死 —— 离开
{}就 drop - 逆序析构 —— 后 let 的先 drop
- Copy 跳过 move ——
Copy类型 = 不触发 move 语义 - Drop 最后一次机会 —— 自动 drop 前做清理
- Clone 显式拷贝 ——
.clone()不是隐式 - Send/Sync 跨线程保护 —— 类型系统管并发
10 条背下来——你写 Rust 不再卡借用检查。
2.35 附录 B:三个相关章节的呼应
- 本专栏第 3 章(借用检查器:编译器如何证明内存安全)——所有权 + 借用 = 完整模型
- 本专栏第 7 章(Trait 静态分发:零成本抽象的编译器实现)——Copy/Drop/Send/Sync 都是 trait
- 本专栏第 15 章(MIR 优化:编译器的中间表示与优化管线)——Drop elaboration 所在的 pass 流水线在那章详讲
2.36 所有权相关的 8 个 std 工具
trick 1——mem::take(&mut x)——"拿走值、留个默认值"——要求 T: Default、不需要 T: Copy——常用于"从 struct 里 take 一个字段但不想 consume struct"。
trick 2——Option::take(&mut self)——专门为 Option 设计、Some(x) 变 None 并返回 x——替代 mem::take 针对 Option 场景。
trick 3——std::mem::swap(&mut a, &mut b)——两个值互换所有权——不需要复制、不需要 clone。
trick 4——Box::leak(b)——"故意泄漏"得到 &'static T——用于"构造一次用永远不 drop" 的全局资源。
trick 5——Cow<'a, T>(Clone-on-Write)——"借用或拥有"二选一的智能类型——适合"大多数情况只借用、偶尔需要修改才 clone" 的场景。
trick 6——Pin<Box<T>> + PhantomPinned——§2.31 讲过、构造自引用 struct。
trick 7——drop(x)——标准库里它就是 pub fn drop<T>(_x: T) {}(见 core::mem::drop 的文档:"This function is not magic")——靠"把值 move 进函数、函数体为空、离开即析构"实现。注意别和 let _ = x; 混淆:_ 不绑定任何东西,既不 move x 也不会提前析构它。
trick 8——impl Trait 返回类型——不用写 box、不用写 dyn,编译器把它固定成一个具体的匿名类型(不是 dyn 分发,也不会为它生成多份代码)——"静态多态" 的语法糖。
这 8 个工具(最后一个是语言特性,不是 std API)覆盖了"所有权操纵"的主要入口——读 tokio / hyper / serde 源码时会反复看到。
2.37 所有权视角的四句话总结
"所有权不是规则、是思考方式"——用所有权的视角看代码、你会发现:
- 每个变量都是资源持有者
- 每次函数调用都是"借用或转移"的选择
- 每个
}都是"集体清理"的时刻 - 每个错误都是"对抗语言" vs "顺应语言" 的结果
这不是 Rust 独有——其他语言的好代码也在做同样的事、只是Rust 把"好代码的习惯"变成了"语言规则"。
2.38 "Clippy lint 和所有权"的日常工作流
写 Rust 代码不用手工查所有权问题——cargo clippy 替你查。
和所有权相关的 Clippy lint——
clippy::redundant_clone——检测"多余的 clone"——改成借用clippy::large_enum_variant——检测"enum 里某个 variant 过大"——建议 Box 包起来clippy::box_collection——检测"Box<Vec/String>"——Vec/String 本身已经是堆、Box 包着是多余clippy::redundant_allocation——Box<&T>/Rc<Box<T>>等重复分配clippy::single_call_fn——只被调用一次的函数——可能 inline 掉省所有权传递开销clippy::unnecessary_to_owned——.to_string()/.to_vec()用多了——很多场景&str/&[T]够用
日常工作流——
- 写代码
cargo clippy --all-targets --all-features- 修 warning
- 再 commit
Clippy 的价值——把本章讲的"理论知识"变成"自动化检查"——读者省几年"踩坑摸索"。
默认行为——Clippy 默认启用 correctness / suspicious / style / complexity / perf 五组 lint;pedantic / nursery / restriction 需要手动打开(上面的 redundant_clone 属于 nursery,single_call_fn 属于 restriction,1.96.0 自带的 Clippy 实测默认都不报)。
2.39 最后附:一张"所有权生命周期"全景图
把本章所有概念压缩成一张"ASCII 流程图"——
let mut x = String::from("hi") // [x: OWNED]
let y = &x // [x: OWNED + borrowed immutably]
drop(y) // [x: OWNED]
let z = &mut x // [x: OWNED + borrowed mutably]
*z = String::from("hi2") // [x: still OWNED, z modifies]
drop(z) // [x: OWNED]
let w = x // [x: MOVED] [w: OWNED]
println!("{}", x) // ERROR: x has been moved
// w goes out of scope → String dropped
每一行都对应本章讨论的一种状态转换——
- 第 1 行 —— 初始 ownership
- 第 2 行 —— 不可变借用
- 第 3 行 —— 借用结束
- 第 4 行 —— 可变借用
- 第 5 行 —— 借用期间修改
- 第 6 行 —— 可变借用结束
- 第 7 行 —— 所有权转移
- 第 8 行 —— 编译错误(access moved value)
- 第 9 行 —— scope 结束、自动 drop
这 9 行 = 所有权一生的全部——能背下来、能讲出每一步的 rustc 内部状态——你就"懂了"。