04. Ownership & Moves — 所有权与移动
所有权(ownership)是 Rust 最独特、最重要的特性。它是 Rust 在不使用垃圾回收的前提下实现内存安全的基石。本章深入所有权和移动语义——这是 Rust 学习者与编译器「搏斗」最频繁的领域,也是掌握 Rust 的分水岭。
内存管理的三难选择
先回顾编程语言管理内存的三种路线:
| 路线 | 代表语言 | 安全 | 性能 | 精细控制 |
|---|---|---|---|---|
| 手动管理 | C, C++ | 易出错 | 极快 | 完全控制 |
| 垃圾回收 | Java, Go, Python | 安全 | 有 GC 停顿 | 弱控制 |
| 编译期所有权 | Rust | 安全 | 极快 | 完全控制 |
手动管理给了你绝对的控制权,但你必须记住所有细节——悬垂指针、双重释放、use-after-free。在大项目中,依靠人类意志力保证绝对正确已被证明是不可行的。
垃圾回收让你忘记内存管理,但它接管了内存释放的唯一权限——你无法精确控制对象何时释放,也无法避免 GC 的 stop-the-world 暂停。
Rust 的第三条路是:在编译期追踪所有权,当值的所有者离开作用域时自动释放。没有 GC 运行时开销,也没有悬垂指针。
所有权规则
Rust 的所有权规则只有三条,但它们驱动了整个语言的设计:
- Rust 中的每一个值都有一个所有者(owner)。
- 同一时间,一个值只能有一个所有者。
- 当所有者离开作用域时,值被丢弃(drop)。
最简单的例子:
{
let s = String::from("hello");
// s 是 "hello" 的所有者
} // s 离开作用域,"hello" 被释放String 的值「hello」存储在堆上——它的结构是一个三词组的栈上控制块(指针、长度、容量),加上堆上的字符数组。当 s 离开作用域时,Rust 自动调用 s 的 drop 方法,释放堆上的字符数组。
对于大多数类型,这个过程是递归的:释放一个 Vec<String> 会先释放每个 String 中的堆缓冲区,再释放 Vec 本身的堆数组。
所有权树
所有权形成了一个树状结构:每个值有一个所有者,所有者可能本身就是一个拥有子值的复合值。用代码来阐释:
struct Book {
title: String, // Book 拥有 title
author: String, // Book 拥有 author
pages: u32,
}
struct Library {
name: String, // Library 拥有 name
books: Vec<Book>, // Library 拥有 books(一个 Vec)
}
{
let library = Library {
name: String::from("City Library"),
books: vec![
Book {
title: String::from("Programming Rust"),
author: String::from("Jim Blandy"),
pages: 735,
},
],
};
// 所有权树:
// library ─┬─ name ──── "City Library"(堆)
// └─ books ─── Vec<Book>
// └─ [0] ─┬─ title ── "Programming Rust"(堆)
// ├─ author ── "Jim Blandy"(堆)
// └─ pages ── 735(栈)
} // library 离开作用域,整棵树递归释放当 library 离开作用域时,Rust 首先丢弃 books(它释放了 Vec 的堆数组并丢弃其所有元素),然后丢弃 name,最后丢弃 library 自身的栈帧。整个过程是确定的、可预测的——没有 GC 扫描,没有引用计数遍历(除非显式使用 Rc)。
对于 C++ 程序员,这个行为和 C++ RAII 类似,但有编译器的强制保证;对于 Java/Go/Python 程序员,这是 GC 提供的安全性,但发生在确定的时间点(作用域结束时),而不是 GC 选择的不确定时刻。
移动语义
在 Rust 中,赋值操作的行为取决于值的类型:
// 情况 A:简单类型 —— 拷贝
let x = 42;
let y = x; // x 的值被拷贝到 y
println!("{}", x); // OK:x 仍然有效
// 情况 B:复杂类型 —— 移动
let s1 = String::from("hello");
let s2 = s1; // s1 的所有权被移动到 s2
// println!("{}", s1); // 编译错误:s1 已被移动,不能使用对于 String,赋值转移了所有权。这是因为 String 在堆上管理缓冲区——如果像整数那样按位复制,两个变量将指向同一个堆缓冲区,离开作用域时会造成双重释放。
Rust 的解决方案:赋值 = 所有权转移 + 源变量失效。此后 s1 被视为「未初始化」,编译器禁止任何对 s1 的访问。
编译器的报错直截了当:
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:20
|
3 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
4 | let s2 = s1;
| -- value moved here
5 | println!("{}", s1);
| ^^ value borrowed here after move不仅仅是直接赋值——以下所有操作都涉及移动:
let s = String::from("hello");
// 传递给函数会转移所有权
fn consume(s: String) { /* s 在这里被丢弃 */ }
consume(s);
// println!("{}", s); // 错误:s 已被移动
// 从函数返回会转移所有权
fn produce() -> String {
let s = String::from("world");
s // 所有权从 s 转移到调用者
}
let s2 = produce(); // s2 是新的所有者
// 赋值到复合数据结构
let mut v = Vec::new();
v.push(String::from("item")); // String 的所有权转移到 Vec
// 从复合数据结构取出(需要通过特殊方式)
let last = v.pop().unwrap(); // pop 返回 Option<String>,转移所有权移动与控制流
移动语义与控制流的交互需要特别关注:
let x = String::from("hello");
// if 中可以在分支间转移
if true {
let y = x; // x 移动到 y
// x 在此分支中不可用
} else {
// x 在此分支中仍可用
}
// 但之后 x 的状态不确定
// println!("{}", x); // 错误:可能已被移动在循环中移动尤为危险:
let v = vec![String::from("a"), String::from("b")];
// 这个 for 循环有效——它消费了 v
for s in v {
println!("{}", s);
}
// println!("{:?}", v); // 错误:v 已在 for 循环中被移动显式的 into_iter() 展示了其本质:for s in v 等价于 for s in v.into_iter(),后者将 v 的所有权转移给迭代器,迭代器在每个步骤中移出元素。
你不能在循环体内部移动值,除非每次迭代后重新赋值:
let mut x = String::from("hello");
loop {
let y = x; // x 移动到 y
// ... 使用 y ...
x = String::from("world"); // x 必须被重新赋值
}无法从集合中按索引移出
当你尝试从 Vec 中按索引取元素时:
let v = vec![String::from("a"), String::from("b")];
// let first = v[0]; // 编译错误:cannot move out of index of `Vec<String>`原因:v[0] 在语法上看起来像访问,但它试图将 v 的第一个元素的所有权移出——而 Rust 没有能力追踪「v 中索引 0 的位置现在为空」。如果允许这种操作,v 会处于部分初始化的不一致状态。
三种从集合中移出元素的惯用技巧:
技巧一:pop()
let mut v = vec![String::from("a"), String::from("b"), String::from("c")];
while let Some(item) = v.pop() {
println!("{}", item);
}
// v 现在是空的pop() 返回 Option<T>,当向量非空时从末尾移出一个元素。被移除的槽被缩短的 len 隐性丢弃。
技巧二:swap_remove()
let mut v = vec![
String::from("a"),
String::from("b"),
String::from("c"),
];
let second = v.swap_remove(1); // 将索引 1 与最后一个元素交换,然后弹出
// v 现在是 ["a", "c"]
println!("{}", second); // "b"swap_remove(i) 将索引 i 与最后一个元素交换,再弹出末尾。O(1) 时间,但不保留相对顺序。
技巧三:std::mem::replace()
use std::mem;
let mut v = vec![
String::from("a"),
String::from("b"),
String::from("c"),
];
// 用默认值替换,获取原值
let taken = mem::replace(&mut v[1], String::new());
println!("{}", taken); // "b"
println!("{:?}", v); // ["a", "", "c"]mem::replace(dest, src) 将 src 写入 dest 并返回 dest 的旧值。更通用的版本是 mem::take(dest),等价于 mem::replace(dest, T::default())。
let taken = mem::take(&mut v[0]);
println!("{}", taken); // "a"
println!("{:?}", v); // ["", "", "c"]Copy 类型:规则的例外
到目前为止,所有移动例子都涉及 String。但对于整数、浮点、布尔、char 等简单类型,赋值行为不同:
let x = 42;
let y = x; // x 被拷贝,不是移动
println!("{}", x); // OK这些类型实现了 Copy Trait。对于 Copy 类型,赋值执行按位复制,原值继续有效:
// 以下类型都是 Copy
let a: i32 = 1;
let b: f64 = 3.14;
let c: bool = true;
let d: char = 'A';
let e: (i32, i32) = (1, 2); // 由 Copy 类型组成的元组是 Copy
let f: [i32; 3] = [1, 2, 3]; // 由 Copy 类型组成的数组是 Copy
let g: &str = "hello"; // 不可变引用是 Copy完整的标准 Copy 类型列表:
| 类型类别 | Copy 类型 |
|---|---|
| 所有整数 | i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize |
| 浮点数 | f32 f64 |
| 布尔 | bool |
| 字符 | char |
| 共享引用 | &T(所有 &T,甚至对非 Copy 类型的引用) |
| 裸指针 | *const T *mut T |
| 函数指针 | fn(T) -> R |
| 由 Copy 类型构成的组合 | (i32, f64) [u8; 1024] Option<&T> |
哪些类型不是 Copy?
String:拥有堆缓冲区Vec<T>:拥有堆缓冲区Box<T>:拥有堆分配&mut T:可变引用不是 Copy(保证独占性)- 大多数复合结构(除非显式派生
Copy)
为自定义类型派生 Copy
#[derive(Copy, Clone)]
struct Point {
x: f64,
y: f64,
}
let p1 = Point { x: 1.0, y: 2.0 };
let p2 = p1; // p1 被拷贝
println!("{}", p1.x); // OK约束:只有当结构体的所有字段都是 Copy 时,才能派生 Copy。如果结构体包含 String 字段,就无法 Copy——这保证了 Rust 的「按位复制即安全」的前提条件。
Rc 和 Arc:共享所有权
所有权规则说一个值只能有一个所有者。但在某些场景下,我们确实需要共享所有权——比如 DOM 树中的节点被多处引用。Rust 通过引用计数提供了共享所有权:
Rc<T> —— 单线程引用计数
use std::rc::Rc;
let s = Rc::new(String::from("shared"));
assert_eq!(Rc::strong_count(&s), 1);
let t = Rc::clone(&s); // 增加引用计数,不克隆底层数据
assert_eq!(Rc::strong_count(&s), 2);
// s 和 t 都可以读取底层数据
println!("{}", s);
println!("{}", t);
// 当 s 和 t 都离开作用域时,String 被释放Rc::clone() 不会深拷贝数据——它只增加引用计数。当最后一个 Rc 指针被丢弃时,底层数据被释放。
Rc<T> 提供的引用是不可变的:
let s = Rc::new(String::from("hello"));
// s.push_str(" world"); // 错误:不能可变借用 Rc 的内容Rc 故意不支持可变性:如果允许多个位置持有的数据可变,就可能引入数据竞争或迭代器失效问题。对于需要可变的场景,通常结合 Rc<RefCell<T>> 使用(在后续章节介绍)。
Rc<T> 的内存布局
Rc::new(String::from("hello")) 分配了两块内存:
┌──────────────────────┐
│ 强引用计数 = 1 │ ← 引用计数块(堆上)
│ 弱引用计数 = 0 │
│ ┌──────────────────┐│
│ │ ptr │ len │ cap ││ ← String 控制块
│ │ ↓ ││
│ │"h""e""l""l""o" ││ ← 字符数据
│ └──────────────────┘│
└──────────────────────┘
栈上变量 s:1 word 指向引用计数块
栈上变量 t (clone):1 word 指向同一个引用计数块Arc<T> —— 多线程引用计数
use std::sync::Arc;
use std::thread;
let s = Arc::new(String::from("shared across threads"));
let mut handles = vec![];
for _ in 0..3 {
let s = Arc::clone(&s);
handles.push(thread::spawn(move || {
println!("{}", s);
}));
}
for h in handles {
h.join().unwrap();
}Arc(Atomic Reference Count)与 Rc 功能相同,但使用原子操作来更新引用计数——在多线程间安全,但有轻微的性能成本。
引用循环与 Weak<T>
use std::rc::{Rc, Weak};
use std::cell::RefCell;
struct Node {
parent: RefCell<Weak<Node>>, // 弱引用,避免循环
children: RefCell<Vec<Rc<Node>>>,
}
let leaf = Rc::new(Node {
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![]),
});
let branch = Rc::new(Node {
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
// 建立反向引用,使用 Weak 避免计数循环
*leaf.parent.borrow_mut() = Rc::downgrade(&branch);
// 验证:branch 有两个强引用(自己 + leaf.children),leaf 有一个强引用(自己)
println!("branch strong count: {}", Rc::strong_count(&branch)); // 1 或 2
println!("leaf strong count: {}", Rc::strong_count(&leaf)); // 1 或 2Weak<T> 不增加强引用计数——当所有 Rc 指针被丢弃后,数据被释放,Weak 变为无效(upgrade() 返回 None)。这在树结构、缓存等场景中至关重要。
所有权的哲学
Rust 的所有权系统初看起来像限制,但我们应该重新理解它:
- 所有权不是枷锁,而是文档。 函数签名
fn consume(s: String)明确传达了「此函数会消耗这个字符串」的信息。在 C++ 中,你只能依靠注释或文档。 - 所有权是编译器提供的安全网。 你不会忘记释放内存,因为编译器不会忘记调用
drop。你不会意外使用已释放的指针,因为编译器在编译时就阻止了这些操作。 - 所有权使并发成为可能。
Send和SyncTrait(自动派生)检查类型的所有权结构,确保跨线程传递不会引入数据竞争。 - 所有权强制了清晰的 API 设计。 当所有权规则让你感到受限时,通常意味着你的 API 设计需要改进——也许应该用借用(
&T)而不是消耗所有权(T)。
小结
- 所有权三规则:每个值一个所有者;同一时间只有一个所有者;离开作用域时释放。
- 所有权形成树状结构:释放根节点递归释放整个子树,类似于 RAII,但有编译器强制保证。
- 移动语义是默认行为:非
Copy类型的赋值转移所有权,源变量失效。这避免了双重释放和 use-after-free。 - 编译错误信息精确:
error[E0382]: borrow of moved value直接指出问题所在。 - 控制流中的移动需谨慎:
if分支中一个分支移出后,源变量在该分支外状态不确定;循环体内移出需要每次迭代后重新赋值。 - 不能从 Vec 中按索引移出:三种惯用替代——
pop()(末尾移出)、swap_remove(i)(交换后移出)、std::mem::replace()/std::mem::take()(替换后取旧值)。 Copy类型是例外:简单类型(整数、浮点、bool、char、共享引用)赋值时按位复制。Clone是显式的深拷贝。Rc<T>和Arc<T>提供引用计数式的共享所有权(不可变)。Weak<T>避免循环引用。Arc使用原子操作,适合多线程。