所有权
本章是整个教程的学习曲线顶点:把「谁来释放内存」变成编译期可判定的问题。 前置知识:环境搭建与工具链、 变量与流程控制。 基准环境:
rustc 1.98.1/cargo 1.98.1,Rust 2024 edition,Windows 11 + PowerShell。
本章目标
- 能说清所有权(ownership)要解决的到底是哪个问题,以及它凭什么是「零运行时开销」的。
- 能背下三条所有权规则,并画出
String在栈/堆上的ptr/len/capacity布局。 - 能解释
let b = a;对String是 move(移动)、对i32是 Copy(复制),并说明Copy与Drop为什么互斥。 - 能识别并读懂
E0382、E0502、E0499、E0505、E0515、E0716、E0106七类核心报错,每类至少会一种修法。 - 能写带生命周期参数(lifetime parameter)的函数与结构体,理解
'a描述的是引用之间的关系而不是「活多久」。 - 能主动选择
&str而不是&String、&[T]而不是&Vec<T>作为参数类型。 - 能掌握一套可复用的借用检查器调试流程,而不是靠瞎加
clone()硬凑。
为什么需要所有权
内存的两块地盘:栈与堆
任何语言都要回答两个问题:值放在哪、谁负责回收。先看地基。
栈(stack) 堆(heap)
┌──────────────────────┐ ┌───────────────────────────────┐
│ 固定大小、编译期已知 │ │ 大小可变、运行期向分配器申请 │
│ 后进先出(LIFO) │ │ 无序、靠地址(指针)访问 │
│ 分配 = 移动栈指针 │ ptr ──► │ 分配 = 找空闲块(可能系统调用)│
│ 释放 = 移动栈指针 │ │ 释放 = 归还给分配器 │
│ 极快、不可能失败 │ │ 较慢、可能失败(OOM) │
└──────────────────────┘ └───────────────────────────────┘关键差别不是「快慢」,而是栈上的值在编译期就知道何时消失(离开作用域即消失), 堆上的值不行 —— 堆块的生命周期是运行期决定的,因此必须有某种机制来回收它。
🧠 原理:Rust 的所有权系统做的唯一一件事,就是把「堆块何时释放」这个运行期问题, 变成「哪个栈上变量持有它」这个编译期问题。一旦能静态确定持有者,释放时机就静态确定了。
值语义(value semantics)与引用语义(reference semantics)
值语义:变量里装的就是数据本身 引用语义:变量里装的是「通往数据的地址」
┌──────────────┐ ┌──────────────┐
│ a = 42 │ a 就是 42 │ r ──────────┼──► ┌──────────┐
└──────────────┘ └──────────────┘ │ 对象/堆块 │
b = a ⇒ b 也有一份独立的 42 └──────────┘
b = r ⇒ b 和 r 指向同一块数据- 值语义(C 的
struct、C++ 的int、Go 的数组、Rust 的默认行为):赋值即拷贝内容。 - 引用语义(Java 的对象、Python 的一切、JS 的对象、C# 的
class):赋值只拷贝地址。
Java/Python 的问题恰恰出在引用语义上:别名(aliasing)。你手上有两个名字, 不知道还有谁也在改同一块数据,于是出现「改了 A 结果 B 也变了」的幽灵 bug。 Java 试图用 final、不可变类(String、record)、Collections.unmodifiableList 来缓解, 但这些都是约定和运行期包装,编译器不会强制你。
Rust 的答案非常激进:值语义是默认的,要共享就显式写借用(&), 而且共享(&T)与修改(&mut T)在编译期互为排他。
💡 对照:Python 的
b = a对list是共享,对int是共享但不可变(看起来像拷贝)。 Rust 里let b = a;对Vec<T>是移动(移动后a不可用),对i32是拷贝。 两者都由类型特征(Copy)在编译期决定,没有「小整数碰巧不可变」这种巧合。
C++ 的三条路:RAII、悬垂、双重释放
C++ 用 RAII(Resource Acquisition Is Initialization,资源获取即初始化)把释放绑到析构函数上, 这是伟大的发明,Rust 的 Drop 直接继承了这个思想。但 C++ 留给你三个必须自己盯住的坑:
cpp
// C++ 伪代码,仅用于说明问题,不是本章要跑的代码
void bad() {
std::string* p = new std::string("hi");
delete p;
std::cout << *p; // 悬垂指针(dangling pointer):读已释放内存,UB
}
void worse() {
std::string* p = new std::string("hi");
std::string* q = p;
delete p;
delete q; // 双重释放(double free):堆元数据被破坏
}
// 提前 return / 抛异常时忘记 delete ⇒ 内存泄漏(leak)C++ 的补救手段是智能指针:std::unique_ptr(独占)、std::shared_ptr(引用计数)、 std::weak_ptr(打破循环)。但这些都是库类型,你可以选择不用; 而且 shared_ptr 有运行期计数开销,循环引用还会泄漏。
Java / Python 的路:GC 与共享可变状态
Java 的 GC(Garbage Collector,垃圾回收器)和 Python 的引用计数 + 分代 GC 彻底解决了悬垂与双重释放 —— 它们把「谁还活着」推迟到运行期用图可达性(reachability)计算。
代价是:
- 运行期成本:GC 线程、写屏障(write barrier)、STW(Stop-The-World)停顿; Python 的引用计数还要在每个对象头上加计数器,并处理循环引用。
- 内存放大:每个对象都有对象头(object header),值类型被装箱(boxing)。
- 共享可变状态(shared mutable state)依然无解:GC 只管「回收」, 不管「谁能改」。两个线程拿着同一个
ArrayList互相踩,GC 一声不吭。
Rust 的选择是:用编译期所有权替换 GC,也替换手动 free。
手动管理 GC 所有权(Rust)
┌────────────┐ ┌────────────┐ ┌────────────────────┐
│ C / C++ │ │ Java/Python│ │ 编译期证明唯一持有 │
│ free/delete│ │ 运行期追踪 │ │ 离开作用域自动 drop │
├────────────┤ ├────────────┤ ├────────────────────┤
│ 悬垂/双释/ │ │ 运行期开销 │ │ 编译期开销(变慢) │
│ 泄漏 │ │ 停顿 │ │ 运行期 0 开销 │
└────────────┘ └────────────┘ └────────────────────┘🧠 原理:「零开销」指的是运行期零开销:没有引用计数增减、没有 GC 扫描、 没有看门狗线程。代价挪到了编译期 —— 编译变慢,且你必须写编译器能证明的代码。 这是 Rust 最核心的取舍(trade-off),后面所有「借用检查器不让我过」的痛苦都源于此。
🚀 进阶:
Rc<T>/Arc<T>提供了引用计数(〈智能指针与闭包〉一章), 相当于「在需要时用一小块 GC 换灵活性」。Rust 不是没有 GC,而是默认没有。
三条所有权规则
规则正文
规则 1:Rust 中每一个值(value)都有一个所有者(owner)。
规则 2:同一时刻只能有一个所有者。
规则 3:当所有者离开作用域(scope),这个值就被丢弃(drop)。三条规则合起来就得到一个推论:值在唯一确定的位置、唯一确定的时间被释放。 编译器不需要运行任何东西就能算出这个位置和时间。
String 在内存里到底长什么样
String 是最适合讲所有权的类型,因为它在堆上有数据,而 &str / i32 讲不出这点。
rust
fn main() {
let s = String::from("hello");
println!("{s}");
} 变量 s(栈上,24 字节固定) 堆上(分配器给的缓冲区)
┌──────────────┬──────────┬───────────┐ ┌───┬───┬───┬───┬───┐
│ ptr ─────────┼──────────┼───────────┼──►│ h │ e │ l │ l │ o │
│ 0x7ffd_1a20 │ len = 5 │ cap = 5 │ └───┴───┴───┴───┴───┘
└──────────────┴──────────┴───────────┘ ^ 这 5 字节是堆内存,归 s 所有
指针(8B) 长度(8B) 容量(8B)| 字段 | 含义 | 变化时机 |
|---|---|---|
ptr | 指向堆缓冲区的地址 | 重新分配(reallocate)时变化 |
len | 当前实际内容长度(字节) | push_str 时增长 |
capacity | 已向分配器申请的总容量(字节) | len > capacity 时翻倍增长 |
rust
fn main() {
let mut s = String::from("hello");
println!("len={} cap={}", s.len(), s.capacity()); // 输出:len=5 cap=5
s.push_str(" world");
println!("len={} cap={}", s.len(), s.capacity()); // 输出:len=11 cap=11
s.push('!');
println!("len={} cap={}", s.len(), s.capacity()); // 输出:len=12 cap=22
}输出:
len=5 cap=5→len=11 cap=11→len=12 cap=22。 第三次len超过capacity,Rust 申请了 22 字节(近似翻倍),把旧数据搬过去,再释放旧缓冲区。 这正是为什么 push 之后旧引用会失效:ptr变了。
⚠️ 陷阱:
capacity的增长倍率是实现细节,官方文档不保证。不要写依赖cap == 22的逻辑。
💡 对照:Java 的
String不可变,StringBuilder有类似的capacity概念; Python 的str不可变,list有类似「多留一点空间」的append优化。 Rust 的String是Vec<u8>的薄包装,把「缓冲区扩容」这件事暴露给你看。
规则 3 的实证:作用域结束就 drop
rust
fn main() {
{
let s = String::from("block-local");
println!("块内可用:{s}");
} // 这一行的右花括号处调用 drop(s),堆缓冲区返回给分配器
// println!("{s}"); // E0425: cannot find value `s` in this scope
}输出:
块内可用:block-local。之后s在名字层面就消失了, 连「访问已释放内存」的机会都不存在 —— 这就是所有权相对裸指针的本质优势。
move 语义:let b = a; 到底发生了什么
String:移动,不复制堆数据
rust
fn main() {
let a = String::from("hello");
let b = a; // 所有权从 a 转移到 b(move)
println!("{b}"); // 合法:b 是现在的所有者
// println!("{a}"); // E0382: borrow of moved value: `a`
}输出:
hello。注释掉的那行会编译失败 —— 这正是本节要讲的错误。
move 之前 move 之后
a ──┐ a(栈槽还在,标记为「已移动」)
│ ptr ──►┌─h─e─l─l─o─┐ ╳ 不可再使用
│ len=5 └──────────┘ b ──┐
│ cap=5 │ ptr ──►┌─h─e─l─l─o─┐
│ len=5 └──────────┘
b(尚未初始化) │ cap=5机器码上发生了什么:只有栈上那 24 字节被逐字节复制(memcpy), 堆上的 hello 一个字节都没动。也不会调用 free。所谓「移动」就是一次浅拷贝(shallow copy) 加上编译期把旧名字作废。
i32:复制,两个变量都可用
rust
fn main() {
let x = 5;
let y = x; // i32 实现了 Copy,这里是复制而非 move
println!("x={x}, y={y}"); // 两个都合法
}输出:
x=5, y=5。
为什么 i32 不移动?因为 i32 完全在栈上,复制它就是复制全部数据, 不存在「两个变量共享同一个堆块,谁该释放」的问题。既然没有所有权问题, 让旧变量失效就是纯粹的麻烦,所以 Rust 直接复制。
Copy trait:谁能复制
Copy 是一个标记 trait(marker trait,没有方法的 trait), 它向编译器承诺:该类型的值可以按位复制(bitwise copy),且复制后新旧值都有效。
实现 Copy 的类型(全部在栈上、无堆所有权):
| 类别 | 例子 |
|---|---|
| 整数 / 浮点 | i32、u8、usize、f64 |
| 布尔与字符 | bool、char |
| 不可变引用 | &T(注意:&mut T 不是 Copy) |
| 函数指针 | fn(i32) -> i32 |
元组 / 数组(元素全是 Copy) | (i32, bool)、[u8; 4] |
你手动 derive 的结构体 / 枚举(字段全是 Copy) | #[derive(Copy, Clone)] struct P { x: i32 } |
Option<T> / Result<T, E>(泛型参数全是 Copy) | Option<i32> |
不是 Copy 的类型(拥有堆资源或有析构逻辑):
| 类型 | 为什么不能 Copy |
|---|---|
String、Vec<T>、Box<T> | 拥有堆缓冲区,复制会导致双重释放 |
&mut T | 独占借用必须唯一,复制会破坏别名排他性 |
File、TcpStream、MutexGuard | 拥有操作系统资源,复制会双重关闭句柄 |
任何实现了 Drop 的类型 | Drop 与 Copy 互斥(见下) |
Copy 与 Drop 互斥:
rust
// 编译失败示例 A(E0204):字段不 Copy,所以整体不能 Copy
#[derive(Copy, Clone)]
struct Bad {
name: String,
}
// error[E0204]: the trait `Copy` cannot be implemented for this type
// | struct Bad { name: String }
// | ^^^ ------------ this field does not implement `Copy`
// 编译失败示例 B(E0184):有析构函数就不能 Copy
struct AlsoBad;
impl Drop for AlsoBad {
fn drop(&mut self) {}
}
impl Copy for AlsoBad {}
// error[E0184]: the trait `Copy` cannot be implemented for this type;
// the type has a destructor🧠 原理:
Copy意味着「一份数据变两份,各自独立」。 若类型有Drop,那两份数据都会调用析构函数 —— 对String就是双重释放。 编译器用「互斥」这一条规则,把 C++ 里最容易出的 double free 直接变成编译错误。
⚠️ 陷阱:
Clone不等于Copy。Clone是普通的 trait 方法fn clone(&self) -> Self, 可以很昂贵(深拷贝堆数据);Copy是编译器内建的按位复制语义。 所有Copy类型都应当实现Clone(Copy: Clone是 supertrait 约束),但反之不成立。
move 之后使用旧变量:E0382 完整解读
rust
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{}", s1);
}rustc --edition 2024 的原始输出(已用 1.98.1 实测):
error[E0382]: borrow of moved value: `s1`
--> src\main.rs:4:20
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let s2 = s1;
| -- value moved here
4 | println!("{}", s1);
| ^^ value borrowed here after move
|
help: consider cloning the value if the performance cost is acceptable
|
3 | let s2 = s1.clone();
| ++++++++逐行读这段错误信息:
| 行 | 含义 |
|---|---|
borrow of moved value: 's1' | 你使用了一个已经失去所有权的变量 |
move occurs because 's1' has type 'String', which does not implement the 'Copy' trait | 根因:String 不 Copy,所以把它赋给别人就是 move |
value moved here | 罪案现场:第 3 行 |
value borrowed here after move | 报案现场:第 4 行 |
help: consider cloning | 编译器给的一种修法(不是唯一,也不总是最优) |
三种修法:
rust
// 修法 A:clone —— 真的复制堆数据,两个变量都可用。代价:O(n) 内存与时间
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();
println!("{s1} / {s2}"); // 输出:hello / hello
}rust
// 修法 B:传引用 —— 双方都不获得所有权,零拷贝。多数场景的最优选
fn main() {
let s1 = String::from("hello");
let s2 = &s1; // s2 是 &String,只借用不夺取
println!("{s1} / {s2}"); // 输出:hello / hello
}rust
// 修法 C:移出作用域 —— 让旧变量在 move 前就用完,之后不再引用它
fn main() {
let s1 = String::from("hello");
println!("{s1}"); // 先用完 s1
let s2 = s1; // 再移动;此后 s1 作废
println!("{s2}"); // 输出:hello
}🚀 进阶:还有第四种「修法」,是在函数末尾用
s1就用不上时干脆把 move 当成优化: 把String交给别的函数比clone()更省,因为 heap 数据原地不动。 判据是:以后还需要s1的内容吗?需要 →clone;只是用一下 →&;不再需要 → 直接 move。
函数与所有权
传参即 move(或 Copy)
rust
fn main() {
let s = String::from("hello");
takes_ownership(s); // s 的所有权移入函数
// println!("{s}"); // E0382: borrow of moved value: `s`
let n = 5;
makes_copy(n); // i32 是 Copy,n 依然有效
println!("{n}"); // 输出:5
}
fn takes_ownership(some_string: String) {
println!("{some_string}"); // 输出:hello
} // 此处 some_string 离开作用域,drop 掉堆缓冲区
fn makes_copy(some_integer: i32) {
println!("{some_integer}"); // 输出:5
}输出:
hello→5→5。
返回值即转移所有权
rust
fn main() {
let s1 = gives_ownership(); // 所有权从函数内移出到 s1
let s2 = String::from("hello");
let s3 = takes_and_gives_back(s2); // s2 move 进函数,返回值 move 给 s3
println!("{s1} / {s3}"); // 输出:yours / hello
// println!("{s2}"); // E0382: borrow of moved value: `s2`
}
fn gives_ownership() -> String {
String::from("yours")
} // 返回值被移出函数,因此这里不会 drop
fn takes_and_gives_back(a_string: String) -> String {
a_string // 原样返回,所有权回到调用方
}所有权流转全景图
main 的栈帧 函数 foo 的栈帧
┌────────────────────────┐ ┌──────────────────────────┐
│ let s = String::from() │ │ │
│ s ──[ptr|5|5]────────┼───┐ │ │
└────────────────────────┘ │ │ │
│ │ │
调用 foo(s) ── move ──────┼────►│ fn foo(t: String) │
│ │ t ──[ptr|5|5]──────────┼──┐
│ │ println!("{t}") │ │
│ │ } ← t 离开作用域,drop │ │
│ └──────────────────────────┘ │
│ ▼
│ ┌─h─e─l─l─o─┐
│ └──────────┘
│ 若 foo 改为返回 String:
│ let s = foo(s);
└── 所有权沿返回值移回 main,堆块始终没动过要点:整张图里堆上的 hello 一次都没有被复制,也没有被提前释放。 只是「谁在栈上拿着那 24 字节描述符」在编译期不断易主。
为什么 C++ 的拷贝构造 / 移动构造在 Rust 里是「显式的」
cpp
// C++:编译器会悄悄帮你调用拷贝构造 / 移动构造,你只能事后分析
std::string f() {
std::string s = "hi";
return s; // 可能触发 NRVO,也可能触发移动构造
}
std::string a = f();
std::string b = a; // 拷贝构造:a 仍可用,堆上多了一份 "hi"
std::string c = std::move(a); // 移动构造:a 处于「有效但未指定」状态Rust 的对照:
| C++ 行为 | Rust 等价物 | 显式性 |
|---|---|---|
| 隐式拷贝构造 | b = a.clone() | 必须写 .clone(),编译器绝不偷偷深拷贝 |
隐式移动构造 / std::move | let b = a; | 默认行为,不需要 std::move |
移动后 a 处于未指定状态 | a 编译期直接作废 | 静态保证,不会误用 |
const T& 参数 | &T 参数 | 语法不同,语义相近 |
T& 参数(可改) | &mut T 参数 | Rust 额外要求调用方声明 let mut |
💡 对照:Rust 里没有对应的
std::move。因为「移动」是默认语义且发生在编译期, 是一个位置(place)的重新解释,不需要运行期函数。 你唯一需要显式决定的是:这次要 move、要 borrow,还是要深拷贝.clone()。
⚠️ 陷阱:C++ 程序员最容易在 Rust 里到处写
.clone()来「让编译器闭嘴」。clone()常常是正确的,但先问一句:我是不是可以传&s? 一个&s通常是零成本,一次s.clone()是 O(n)。
延伸阅读
- 同一概念的第二种讲法(官方书中文版、Rust 圣经的逐章映射),见 附录 E · 对照阅读与组合学习法。
- 官方文档、中文资料、书单与工具的完整索引,见 附录 D · 学习资源与文档索引。