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 要管内存

栈和堆都是运行时内存,但结构完全不同:

  • :后进先出(像一叠盘子,只能从顶部加/取)。所有数据必须大小已知且固定。入栈极快——位置永远在栈顶,分配器无需搜索;
  • :缺乏组织。放数据时向分配器申请空间,它在堆里找一块够大的空位,返回指针。因为指针大小固定,指针本身放栈上,真数据在堆上,访问要顺着指针跳——现代处理器内存跳转越少越快,所以访问堆比访问栈慢

函数调用时,参数和局部变量压栈,函数结束出栈。那堆上数据谁来清?这正是所有权存在的意义:跟踪堆数据的使用、减少重复、及时清理,避免耗尽空间。

三条所有权规则

铭记在心,后文全是它们的展开:

  1. Rust 中每一个值都有一个所有者(owner)变量
  2. 值在任一时刻有且只有一个所有者
  3. 当所有者离开作用域,这个值将被丢弃(自动调用 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 只复制了栈上的三件套,堆内容没有复制——两个指针指向同一块堆内存。如果任其发展,s1s2 离开作用域时会两次释放同一块内存,这就是著名的二次释放(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 = xx 还能用?因为像整型这类编译时大小已知、整个放栈上的类型,复制实际值本身极快,没有深浅之分,没必要让 x 失效。

这类类型实现了 Copy trait:赋值后旧变量仍可用。通用规则:任何简单标量的组合都能实现 Copy(如 u32boolf64char(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)与借用——用值而不拿所有权,才是日常写法。

实践建议与总结

  1. 用”谁是所有者”的眼光读代码:任何 let/传参/返回,先问值在哪、谁拥有、何时 drop——这是 Rust 程序员的核心思维;
  2. 报错时看”move occurs”:E0382 等错误信息会精确指出值在哪里被移动、哪里被借用,照编译器说的调整即可;
  3. 区分三类”复制”:整型赋值(Copy/免费)、String 赋值(移动/免费但旧值失效)、clone(深拷贝/昂贵)——性能心智模型就此建立。

本身上篇可以浓缩成一句话:值有且只有一个所有者,赋值即移交,离开作用域即释放,需要副本就用 clone(栈类型除外)。理解了”为什么 Rust 要这么设计”(管理堆、杜绝 double free、编译期消灭整类 bug),下篇的引用、借用与切片就是水到渠成。