Rust 学习笔记(4/21):认识所有权(上)——让编译器替你管内存
本系列基于官方《Rust 程序设计语言》(The Rust Programming Language,TRPL)逐章学习。
开篇:谁来打扫内存?
想象一间共享自习室:垃圾回收(GC)像”每小时自动清扫的保洁阿姨”——程序跑着跑着,GC 定时扫一遍内存,把没人用的东西清掉,方便但会占用运行时;手动管理像”用完自己收拾”——C/C++ 的做法,自由但全靠自觉,忘收、早收、重复收都出事故;而 所有权(ownership) 是第三种方案:进门时领一张”物品归属卡”,每样东西记在谁名下,离开房间时 Rust 自动回收——全程由编译器在编译期检查,运行时零开销。
这是 Rust 最与众不同的特性:不靠 GC 也能保证内存安全。本章上篇讲清”所有权是什么”,下篇再讲借用与切片。
三种内存管理方式:先看清战场
| 方式 | 代表语言 | 原理 | 代价 |
|---|---|---|---|
| 垃圾回收 | Java/Go/Python | 运行时自动扫描回收 | GC 暂停、性能损耗 |
| 手动分配/释放 | C/C++ | malloc/free 自己配对 |
忘释放=泄漏;过早/重复释放=未定义行为 |
| 所有权 | Rust | 编译期规则检查,离开作用域自动 drop |
需要学习”所有权规则”,编写时受约束 |
栈与堆:为什么 Rust 要管内存
栈和堆都是运行时内存,但结构完全不同:
- 栈:后进先出(像一叠盘子,只能从顶部加/取)。所有数据必须大小已知且固定。入栈极快——位置永远在栈顶,分配器无需搜索;
- 堆:缺乏组织。放数据时向分配器申请空间,它在堆里找一块够大的空位,返回指针。因为指针大小固定,指针本身放栈上,真数据在堆上,访问要顺着指针跳——现代处理器内存跳转越少越快,所以访问堆比访问栈慢。
函数调用时,参数和局部变量压栈,函数结束出栈。那堆上数据谁来清?这正是所有权存在的意义:跟踪堆数据的使用、减少重复、及时清理,避免耗尽空间。
三条所有权规则
铭记在心,后文全是它们的展开:
- Rust 中每一个值都有一个所有者(owner)变量;
- 值在任一时刻有且只有一个所有者;
- 当所有者离开作用域,这个值将被丢弃(自动调用
drop释放内存)。
作用域与 String:从简单到”需要管理”
作用域即项在程序中有效的范围:let s = "hello" 从声明处到所在块结束 } 都有效——这与其他语言类似。
真正的分水岭是 String。字符串字面量 "hello" 编译时就知道内容,直接硬编码进可执行文件,所以它快速高效,但也不可变、不能存编译时未知的文本。String 不同——它把内容放在堆上,支持可变、可增长:
1
2
3
let mut s = String::from("hello"); // :: 把 from 放在 String 的命名空间下
s.push_str(", world!");
println!("{}", s); // 打印 hello, world!
需要运行时才能知道大小的文本、用户输入,都靠 String。
内存与分配:drop 与”RAII”
String::from 负责向分配器请求内存(第一步所有语言都一样),难点在第二步——何时归还?
- 有 GC:不用管;
- 无 GC:
free必须精确配对malloc。忘配=内存浪费;早配=无效变量;重复配=bug。C 程序员最痛的账本; - Rust:内存在拥有它的变量离开作用域时自动释放。Rust 在
}处自动调用drop函数(String的作者在drop里写释放逻辑)。
熟悉 C++ 的读者会发现这很像 RAII(资源获取即初始化)。确实如此——Rust 的
drop就是 RAII 的 Rust 版。
移动:赋值不是复制(Rust 最关键的一课)
看整数,直觉正确:
1
2
let x = 5;
let y = x; // 两个 5 都放栈上,各自独立
但换成 String,直觉就错了:
1
2
let s1 = String::from("hello");
let s2 = s1; // 不是复制!是"移动"
String 在内存里是”三件套”:指向堆内容的指针 + 长度 + 容量(这三者放栈上),内容本身在堆上。let s2 = s1 只复制了栈上的三件套,堆内容没有复制——两个指针指向同一块堆内存。如果任其发展,s1、s2 离开作用域时会两次释放同一块内存,这就是著名的二次释放(double free)漏洞。
Rust 的解法:让旧变量直接失效。let s2 = s1; 之后 s1 被视为已移动(move)到 s2,再使用 s1 会编译报错:
1
2
3
4
error[E0382]: borrow of moved value: `s1`
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
| let s2 = s1;
| -- value moved here
所以 Rust 的赋值是”移动”而非浅拷贝:拷贝了指针,但同时让原变量失效,从根上杜绝了 double free——只有 s2 有效,它离开作用域时释放内存,完毕。
隐含设计哲学:Rust 永远不会自动深拷贝堆数据,任何”自动”复制都开销极小,性能可预期。
克隆:需要真复制时,显式说出来
真要把堆数据也复制一份?调用 clone:
1
2
3
let s1 = String::from("hello");
let s2 = s1.clone(); // 堆内容真的被复制了一份
println!("s1 = {}, s2 = {}", s1, s2); // 两个都能用
clone 出现在代码里就是”昂贵操作”的醒目信号——Rust 刻意不隐藏成本。
Copy trait:只在栈上的类型直接”拷贝”
那为什么 let y = x 后 x 还能用?因为像整型这类编译时大小已知、整个放栈上的类型,复制实际值本身极快,没有深浅之分,没必要让 x 失效。
这类类型实现了 Copy trait:赋值后旧变量仍可用。通用规则:任何简单标量的组合都能实现 Copy(如 u32、bool、f64、char、(i32, i32)),任何需要分配内存或持有资源的类型不能(如 String)。注意:实现了 Drop(需特殊清理)的类型不允许实现 Copy,否则会编译错误。
判断口诀:栈类型 = 赋值即拷贝;堆类型 = 赋值即移动,要复制就 clone。
所有权与函数:传参与赋值同一套规则
把值传给函数,和赋值给变量语义相同——会移动或复制:
1
2
3
4
5
6
7
8
9
fn main() {
let s = String::from("hello");
takes_ownership(s); // s 被移动到函数里,此后 s 失效
let x = 5;
makes_copy(x); // i32 是 Copy 的,x 之后还能用
}
fn takes_ownership(some_string: String) { /* ... */ } // 结束时 drop
fn makes_copy(some_integer: i32) { /* ... */ }
同样,返回值也会转移所有权:gives_ownership() 把内部创建的 String 移给调用者;takes_and_gives_back 把参数原样返回(即”借出后归还”)。模式恒成立:赋值移动它;离开作用域 drop 它——除非已被移给新主人。
但”进函数一趟再传回来”实在太啰嗦。一个 calculate_length 为了算个长度,得用元组返回 (String, usize) 把所有权还回来……这是第 4 章下篇的引子:引用(references)与借用——用值而不拿所有权,才是日常写法。
实践建议与总结
- 用”谁是所有者”的眼光读代码:任何
let/传参/返回,先问值在哪、谁拥有、何时 drop——这是 Rust 程序员的核心思维; - 报错时看”move occurs”:E0382 等错误信息会精确指出值在哪里被移动、哪里被借用,照编译器说的调整即可;
- 区分三类”复制”:整型赋值(Copy/免费)、String 赋值(移动/免费但旧值失效)、
clone(深拷贝/昂贵)——性能心智模型就此建立。
本身上篇可以浓缩成一句话:值有且只有一个所有者,赋值即移交,离开作用域即释放,需要副本就用 clone(栈类型除外)。理解了”为什么 Rust 要这么设计”(管理堆、杜绝 double free、编译期消灭整类 bug),下篇的引用、借用与切片就是水到渠成。