第4章 认识所有权
第4章 认识所有权
1. 本章核心目标
本章讲解 Rust 最关键、也最有辨识度的机制:所有权(ownership)。它的核心作用,是在不依赖垃圾回收器的前提下,依然保证内存安全。
理解本章后,应当建立下面这条主线:
- Rust 需要管理堆内存;
- 为了安全地管理堆内存,Rust 引入了所有权规则;
- 所有权规则进一步约束了赋值、传参、返回值、引用、借用和切片;
- 这些约束表面上增加了规则,实质上是在编译期消灭悬垂引用、重复释放、数据竞争等问题。
本章内容分为三部分:
- 什么是所有权
- 引用与借用
- Slice 类型
2. 为什么所有权对 Rust 如此重要
Rust 的内存安全并不是依靠运行时垃圾回收实现的,而是依靠编译器在编译期检查一组规则来实现的。程序只要违反这些规则,就无法通过编译。
这意味着:
- 错误被尽量提前到编译阶段暴露;
- 运行时不需要为垃圾回收付出额外成本;
- 程序既能保持高性能,又能尽量避免内存安全问题。
从设计思想上说,所有权并不是“附加功能”,而是 Rust 组织内存、表达资源管理、保证引用有效性的基础机制。
3. 栈与堆:理解所有权的前置知识
3.1 栈(Stack)
栈的特点:
- 按后进先出(LIFO)方式工作;
- 压栈、出栈速度快;
- 只能存储大小在编译期已知且固定的数据;
- 函数参数、局部变量通常会进入栈。
栈适合处理:
- 简单标量值;
- 固定大小的数据;
- 生命周期清晰、退出作用域即可回收的数据。
3.2 堆(Heap)
堆的特点:
- 由分配器在运行时寻找可用空间;
- 适合存放大小在编译期未知或运行中可能变化的数据;
- 访问堆数据通常要先通过指针间接访问,因此开销比栈大;
- 分配和释放都更复杂。
3.3 为什么所有权主要围绕堆数据展开
栈上数据通常复制简单、释放明确; 而堆数据会引出三类问题:
- 谁拥有这块数据;
- 何时释放这块数据;
- 多个地方同时使用这块数据时如何保证安全。
所有权系统的根本任务,就是管理堆数据。
4. 所有权的三条基本规则
Rust 的所有权规则可以压缩为三条:
- Rust 中的每一个值都有一个所有者(owner);
- 一个值在任一时刻有且只有一个所有者;
- 当所有者离开作用域,这个值将被丢弃(drop)。
这三条规则看似简单,但几乎决定了本章后面所有行为。
5. 作用域与值的有效性
作用域(scope)是一个值在程序中有效的范围。
例如:
{
let s = "hello";
// s 在这里有效
}
// s 在这里失效
对于字符串字面值这类简单值,这和很多语言很像:进入作用域后有效,离开作用域后失效。
但对堆数据来说,离开作用域不仅意味着“名字不能再用”,还意味着其底层资源可能需要被释放。
6. String 与字符串字面值的区别
6.1 字符串字面值
字符串字面值:
let s = "hello";
它的特点是:
- 内容在编译期就确定;
- 数据被直接硬编码进可执行文件;
- 因此高效;
- 但不可变,不适合表示运行时动态生成或增长的文本。
6.2 String
Rust 提供 String 类型来处理可变、可增长、运行时才能确定内容的字符串:
let s = String::from("hello");
String 的特点:
- 数据存储在堆上;
- 大小可以在运行过程中变化;
- 需要在运行时申请内存;
- 需要在合适时机释放内存。
例如:
let mut s = String::from("hello");
s.push_str(", world");
这说明 String 可以被扩展,而字符串字面值不行。
7. 内存分配与 drop
对于 String,Rust 在创建它时会向分配器申请堆内存;而当其所有者离开作用域时,Rust 会自动调用一个特殊函数:drop,释放这块内存。
例如:
{
let s = String::from("hello");
// 使用 s
}
// 这里自动调用 drop
这正是 Rust 资源管理的关键:
- 你不需要手动写
free; - 也不依赖 GC 定时回收;
- 资源释放时机由作用域和所有权决定。
这种思路与 C++ 中的 RAII 很接近: 资源的获取与对象生命周期绑定,生命周期结束时自动释放资源。
8. 移动(move):赋值并不总是复制全部数据
8.1 栈上简单值的赋值
对于整数这类简单值:
let x = 5;
let y = x;
这里会直接复制值。因为整数大小固定、位于栈上,复制成本很低,所以 x 和 y 都有效。
8.2 String 的赋值
对于 String:
let s1 = String::from("hello");
let s2 = s1;
这时发生的并不是“深拷贝整个堆数据”,而是:
- 复制栈上的那部分元数据;
- 即指针、长度、容量;
- 不会自动复制堆上的字符串内容本身。
如果此时 s1 和 s2 同时都被认为有效,那么它们离开作用域时都会尝试释放同一块堆内存,从而发生二次释放(double free)。
为避免这一问题,Rust 在 let s2 = s1; 之后,直接把 s1 视为无效。
这就是移动(move):
- 值的控制权从
s1转移到s2; s1不再可用;- 只有
s2负责最终释放资源。
因此,下面的代码会报错:
let s1 = String::from("hello");
let s2 = s1;
println!("{s1}");
因为 s1 已经被 move 走了。
8.3 为什么 Rust 选择 move 而不是自动深拷贝
如果每次赋值都自动深拷贝堆数据,那么:
- 语义上虽然方便;
- 但对大对象会带来很高运行时开销;
- 复制成本会变得不可控。
Rust 的设计选择是:
- 默认不自动深拷贝堆数据;
- 默认用 move 保证安全;
- 只有在你明确要求时,才进行真正的深拷贝。
这让性能语义更透明。
9. clone:显式深拷贝
如果确实需要复制堆上的数据,而不是转移所有权,可以显式调用 clone:
let s1 = String::from("hello");
let s2 = s1.clone();
此时:
- 栈上的元数据会复制;
- 堆上的字符串内容也会复制;
s1和s2都有效;- 它们拥有彼此独立的数据。
需要特别记住:
clone代表“我知道这里要复制真正的数据”;- 它通常比简单赋值更贵;
- 因此看到
clone就应该有性能意识。
10. Copy:适用于纯栈数据的轻量复制
Rust 中有一类类型实现了 Copy trait。对于这些类型,赋值之后原变量仍然有效。
例如:
let x = 5;
let y = x;
println!("x = {x}, y = {y}");
这里 x 不会失效,因为 i32 是 Copy 类型。
10.1 哪些类型通常是 Copy
一般来说,满足下面条件的类型可以是 Copy:
- 大小固定;
- 不需要堆分配;
- 不需要在离开作用域时执行特殊释放逻辑。
常见 Copy 类型包括:
- 所有整数类型,如
u32 - 布尔类型
bool - 所有浮点类型,如
f64 - 字符类型
char - 仅由
Copy成员组成的元组,如(i32, i32)
10.2 Copy 与 Drop 互斥
如果一个类型实现了 Drop,就不能再实现 Copy。
因为:
Copy表示赋值后两个值都独立有效;Drop表示离开作用域时要执行资源释放;- 对持有资源的类型同时允许隐式复制,会重新引出重复释放问题。
11. 赋新值时,旧值会被立即丢弃
看下面的代码:
let mut s = String::from("hello");
s = String::from("ahoy");
在第二行执行时:
- 旧的
"hello"所在堆内存不再有任何所有者; - Rust 会立刻对旧值执行
drop; s绑定到新的"ahoy"。
这说明:
- 所有权不仅和作用域结束有关;
- 也和“这个值是否还被任何所有者持有”有关。
12. 所有权与函数
12.1 传参本质上也是赋值
把值传给函数,和把值赋给变量的规则一致。
fn takes_ownership(some_string: String) {
println!("{some_string}");
}
fn makes_copy(some_integer: i32) {
println!("{some_integer}");
}
调用:
let s = String::from("hello");
takes_ownership(s);
let x = 5;
makes_copy(x);
结果:
s被 move 到函数中,因此之后不能再使用;x是Copy类型,因此调用后仍可继续使用。
12.2 返回值也会转移所有权
函数返回值时,也会发生所有权转移。
例如:
fn gives_ownership() -> String {
let some_string = String::from("yours");
some_string
}
返回的 String 会 move 给调用者。
12.3 为什么“拿进去再还出来”很别扭
如果一个函数只是想读取字符串长度,却不得不获取所有权再返回:
fn calculate_length(s: String) -> (String, usize) {
let length = s.len();
(s, length)
}
这种写法虽然可行,但很笨重:
- 调用者只想临时使用该值;
- 却被迫交出所有权;
- 然后再通过返回值拿回去。
这正是引用与借用要解决的问题。
13. 引用(reference)与借用(borrowing)
13.1 什么是引用
引用本质上是一个地址,但与普通裸指针不同,Rust 的引用有更强的安全保证:
- 在其生命周期内必须指向有效值;
- 类型必须正确;
- 借用规则必须成立。
例如:
fn calculate_length(s: &String) -> usize {
s.len()
}
调用:
let s1 = String::from("hello");
let len = calculate_length(&s1);
这里:
&s1创建了一个指向s1的引用;calculate_length只是借用该值;- 不取得所有权;
- 因此函数结束后
s1仍可继续使用。
13.2 借用的本质
“创建引用”这个动作叫做借用(borrowing)。
可以把它理解为:
- 数据仍归原所有者所有;
- 只是暂时把访问权借给别人;
- 用完后不需要显式归还,因为生命周期结束即可。
借用解决了前面那个“为了读长度还要转移所有权”的笨重问题。
14. 引用默认不可变
正如变量默认不可变,引用默认也不可变。
因此,下面的代码会报错:
fn change(some_string: &String) {
some_string.push_str(", world");
}
因为:
some_string是&String;- 这是不可变引用;
- 不能通过它修改底层数据。
这条规则非常重要:
借用并不意味着你自动拥有修改权限。
15. 可变引用(mutable reference)
如果确实需要通过引用修改值,就必须使用可变引用:
fn change(some_string: &mut String) {
some_string.push_str(", world");
}
let mut s = String::from("hello");
change(&mut s);
这里要同时满足两件事:
- 原值本身必须是可变的:
let mut s - 借用时必须显式写
&mut s
这表明:
- 原对象允许被修改;
- 当前这次借用也请求了可变访问权限。
16. 借用规则:为什么 Rust 能阻止数据竞争
Rust 对引用施加了严格限制。核心规则可以概括为:
- 在任意时刻,要么有任意多个不可变引用;
- 要么有且只有一个可变引用;
- 二者不能同时存在;
- 并且引用必须始终有效。
这几条规则是 Rust 安全引用模型的核心。
16.1 不能同时存在两个可变引用
下面的代码会报错:
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
println!("{r1}, {r2}");
原因是:
r1已经对s做了独占的可变借用;- 在
r1仍然有效时,又试图创建r2; - 这意味着同一时刻有两个写入口,Rust 不允许。
16.2 这条限制的真正价值:防止数据竞争
Rust 之所以强制这样做,是为了在编译阶段阻止数据竞争(data race)。
数据竞争出现的典型条件是:
- 两个或更多指针同时访问同一数据;
- 至少一个指针执行写操作;
- 没有同步机制。
数据竞争的可怕之处在于:
- 它往往不是稳定复现的错误;
- 很难在运行时定位;
- 经常表现为“偶发、诡异、不确定”的 bug。
Rust 的选择是:
只要代码有形成数据竞争的可能,直接拒绝编译。
16.3 作用域可以缩短借用持续时间
下面的写法是允许的:
let mut s = String::from("hello");
{
let r1 = &mut s;
}
let r2 = &mut s;
因为:
r1的作用域在内部块结束时已经结束;- 此后再创建
r2时,不再与r1重叠。
这说明 Rust 并不是“永远只允许一个可变引用”,而是:
同一时间只能有一个可变引用。
16.4 不可变引用与可变引用不能重叠
下面也会报错:
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
let r3 = &mut s;
println!("{r1}, {r2}, {r3}");
因为:
r1和r2表示“只读观察”;r3表示“独占修改”;- 观察和修改如果重叠,读到的数据可能就不稳定。
16.5 最后一次使用之后,借用就算结束
Rust 的借用结束时间并不一定等于词法作用域结束,而是可以早到“最后一次使用”之后。
例如:
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{r1} and {r2}");
let r3 = &mut s;
println!("{r3}");
这是合法的。因为:
r1和r2在println!后已不再使用;- 所以它们的借用在那里就结束了;
- 后面的
r3不再与它们重叠。
这体现出 Rust 编译器并不是机械地按大括号判断,而是会分析实际使用点。
17. 悬垂引用(dangling reference)
在很多带指针的语言中,很容易发生这种错误:
- 某块内存已经被释放;
- 但程序中仍保留着指向它的引用;
- 之后继续访问,就会读到无效数据,甚至读到别的对象的数据。
这就叫悬垂引用(或悬垂指针)。
Rust 的承诺是:
编译器保证引用不会变成悬垂引用。
例如:
fn dangle() -> &String {
let s = String::from("hello");
&s
}
这段代码不能通过编译,因为:
s是在函数内部创建的;- 函数返回时
s会被丢弃; - 返回
&s就会把一个指向已释放对象的引用交给外部。
Rust 直接在编译阶段禁止这种写法。
编译器甚至会提示:
- 要么显式说明生命周期(这里只是提示,不是正确修复方式);
- 更合理的是直接返回拥有所有权的
String。
因此正确思路通常是:
fn no_dangle() -> String {
let s = String::from("hello");
s
}
也就是返回所有权,而不是返回指向局部值的引用。
18. Slice:对集合局部区域的借用
18.1 什么是 Slice
Slice(切片)允许你:
- 引用集合中的一段连续元素;
- 而不是引用整个集合;
- 并且不取得该集合的所有权。
本质上,slice 是一种特殊的引用。
它解决的问题是:
- 我只想借用“部分数据”;
- 不想复制它;
- 也不想把“部分数据”的信息和原对象分离开。
19. 为什么“返回索引”不是好 API
本节先从一个小练习入手:
写一个函数,接收一个由空格分隔单词的字符串,返回第一个单词。
如果不用 slice,最容易想到的方式是返回空格索引:
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
这段代码的思路是:
- 把
String转成字节数组; - 用
iter()创建迭代器; - 用
enumerate()同时拿到索引和值; - 发现空格就返回其位置;
- 没发现空格就返回整个字符串长度。
这个实现本身没错,但 API 设计有问题:
- 它返回的是一个
usize索引; - 索引本身和原始字符串没有绑定关系;
- 一旦原字符串变化,这个索引就可能失效。
这说明:
“位置”不是“借用到的那一段数据”。
20. 字符串 Slice 的基本语法
20.1 区间语法
可以通过区间语法引用字符串的一部分:
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
hello 和 world 都不是新的字符串对象,而是对 s 的一部分的借用。
从实现角度看,一个字符串 slice 会记录:
- 起始位置;
- 长度。
因此它仍然与原始字符串绑定在一起。
20.2 语法简写
Rust 为区间写法提供了简写:
let slice = &s[0..2];
let slice = &s[..2];
表示从开头到索引 2 前。
let slice = &s[3..len];
let slice = &s[3..];
表示从索引 3 到结尾。
let slice = &s[0..len];
let slice = &s[..];
表示整个字符串的切片。
这些简写在日常开发中非常常见。
20.3 UTF-8 边界限制
字符串 slice 的索引必须落在有效的 UTF-8 字符边界上。
这非常重要。
因为 Rust 的字符串是 UTF-8 编码的:
- 一个字符不一定只占 1 个字节;
- 如果从某个多字节字符的中间切开;
- 就会得到无效的 UTF-8。
因此 Rust 不允许这样的 slice,并会在运行时因错误而退出。
本章为了讲清概念,示例默认按 ASCII 来理解;更完整的 UTF-8 讨论放在第八章。
21. 用 Slice 重写 first_word
有了 slice 之后,first_word 可以改写成真正返回“第一个单词本身”:
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
这里返回类型是 &str,也就是字符串 slice。
相比返回 usize:
- 它直接表达“借用到的那段字符串”;
- 它和原始数据有生命周期关系;
- 编译器可以据此检查其有效性;
- 因此更安全、更自然。
22. Slice 为什么能消灭“索引失效”问题
先看旧设计的问题:
- 先调用
first_word拿到单词结束索引; - 再修改原字符串;
- 索引仍然存在,但语义已经失效。
而使用 slice 版本后,这类错误会被提前阻止。
例如:
let mut s = String::from("hello world");
let word = first_word(&s);
s.clear();
println!("{word}");
这会编译失败。原因是:
word是对s的不可变借用;clear()需要对s进行可变借用;- Rust 不允许二者重叠。
于是编译器直接报错,而不是等到运行时再让 bug 暴露。
这正是 slice 的真正价值:
不仅 API 更自然,而且编译器可以借此更早地发现逻辑错误。
23. 字符串字面值本质上就是 Slice
现在可以更准确地理解:
let s = "Hello, world!";
这里 s 的类型其实是:
&str
也就是说,字符串字面值本质上就是一个字符串 slice:
- 它引用的是程序二进制中的一段固定字符串数据;
- 它不是拥有型字符串;
- 它天然不可变。
这也解释了为什么字符串字面值不能像 String 那样增长、修改。
24. 为什么 &str 比 &String 更好的参数类型
虽然我们刚才把 first_word 改成了:
fn first_word(s: &String) -> &str
但这还不是最通用的写法。更地道的写法是:
fn first_word(s: &str) -> &str
这样做的好处是:
&String可以传进来;String的整个 slice 可以传进来;- 字符串字面值
&str也可以直接传进来; - API 更通用;
- 不损失任何能力。
例如下面这些都可以:
let my_string = String::from("hello world");
first_word(&my_string[0..6]);
first_word(&my_string[..]);
first_word(&my_string);
let my_string_literal = "hello world";
first_word(&my_string_literal[0..6]);
first_word(&my_string_literal[..]);
first_word(my_string_literal);
所以在 Rust 中,一个很重要的 API 设计思想是:
如果函数只需要“看作字符串的一段内容”,优先把参数写成
&str,而不是&String。
这是更通用、更灵活的接口设计。
25. 其他类型的 Slice
Slice 不只适用于字符串,也适用于其他集合。
例如数组:
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
assert_eq!(slice, &[2, 3]);
这里的类型是:
&[i32]
它与字符串 slice 的工作方式相同:
- 引用底层集合的一部分;
- 不拥有数据;
- 记录起始位置和长度;
- 保持与原集合的有效性约束。
这说明 slice 是一个更一般的概念:
&str是字符串切片;&[T]是通用数组/序列切片。
26. 本章最重要的逻辑链
可以把第 4 章压缩成一条完整逻辑链:
- Rust 需要管理堆内存;
- 为了不依赖 GC 仍保证安全,Rust引入所有权;
- 所有权规定一个值同一时刻只能有一个所有者;
- 对堆数据做赋值时,默认发生 move;
- 如果要真正复制堆数据,必须显式
clone; - 为了不在读操作里频繁转移所有权,Rust提供引用与借用;
- 借用又被严格区分为不可变借用和可变借用;
- 借用规则防止数据竞争和悬垂引用;
- slice 是一种更精细的借用,它把“局部视图”和“底层数据有效性”绑定在一起;
- Rust 借此在编译期阻止大量内存与并发错误。
27. 本章易错点与高频误区
27.1 误区:赋值总是在复制值
不对。
- 对
i32这类Copy类型,赋值通常是复制; - 对
String这类堆数据类型,赋值默认是 move。
27.2 误区:借用就是把对象“复制一份”
不对。
借用只是创建引用,不复制对象,也不取得所有权。
27.3 误区:不可变引用只是“我不想改”,但别人可以改
不对。
只要某值当前存在不可变引用,Rust 就会阻止同时出现可变引用。
27.4 误区:可变引用只要写 &mut 就够了
不对。
原对象本身也必须是 mut。
27.5 误区:字符串的一部分可以用整数索引随便切
不对。
Rust 的字符串是 UTF-8 编码,切片必须位于合法字符边界。
27.6 误区:返回索引和返回切片本质一样
不对。
- 索引只是一个数字;
- 切片是和底层数据绑定的借用;
- 切片能被编译器检查有效性,索引不能。
28. 本章精华总结
28.1 所有权不是限制,而是资源管理协议
Rust 的所有权规则并不是“故意为难开发者”,而是在用更严格的静态规则换取:
- 内存安全;
- 并发安全;
- 资源释放时机确定;
- 性能可预测。
28.2 move 语义是 Rust 性能模型的一部分
Rust 不默认做昂贵深拷贝,而是默认 move。这样:
- 安全性由编译器保障;
- 复制成本由程序员显式控制;
- 性能语义更加透明。
28.3 引用的核心不在“语法”,而在“约束”
Rust 的引用之所以强大,不是因为有 & 和 &mut,而是因为:
- 它们背后有明确借用规则;
- 编译器能检查这些规则;
- 这些规则直接防止了数据竞争和悬垂引用。
28.4 Slice 是 Rust API 设计的一个典型思想
Slice 展示了 Rust 很典型的一种设计倾向:
- 尽量让接口表达真实语义;
- 尽量把错误暴露在类型层面;
- 尽量把错误检查提前到编译期。
29. 一句话总括第 4 章
第 4 章的本质,是通过所有权、借用和切片三组机制,把“谁拥有数据、谁能访问数据、访问能持续多久、局部视图是否仍然有效”这些问题全部交给编译器检查,从而在不牺牲性能的前提下获得内存安全。