移动:let b = a 之后,a 去哪了
这是几乎每个人被 Rust 弹回来的第一下:let b = a; 之后,你再用 a,编译器说它「被移动了」。你觉得荒谬 —— 我又没删它。但只要你看一眼内存里到底发生了什么,就会明白:Rust 不是在删你的变量,它是在回答一个别的语言不敢正面回答的问题 —— 这块内存,最后该由谁来释放?
先看现象
这段代码,在 Java、Python、Go、JS 里都毫无问题,在 Rust 里编译不过:
let a = String::from("一本书");
let b = a; // a 的所有权「移动」给了 b
println!("{}", a); // ✗ error[E0382]: borrow of moved value: `a`
但换成整数,又完全没事:
let a = 5;
let b = a; // 这是「复制」,不是移动
println!("{}", a); // ✓ a 还好好的
同样是 let b = a,为什么 String 就作废、i32 就没事?答案全藏在内存里。
栈与堆:一个 String 到底长什么样
一个 String 其实分两半住:
- 栈上:一个固定大小的「三件套」—— 一个指向堆的指针、一个长度 len、一个容量 cap。
- 堆上:真正的那串字符字节。
当你写 let b = a;,Rust 拷贝的是栈上那三件套(快,就仨机器字)。它不会去深拷贝堆上的字符 —— 那太贵了。于是拷完这一瞬间,a 和 b 的指针指向了堆上同一块字符:
栈 堆
a: ptr ─┐
len 9│ ┌─> "一本书" 的字节
cap 9│ │
b: ptr ─┴──────────────────┘ ← a 和 b 指向同一块!
len 9
cap 9
问题来了。a 和 b 都离开作用域时,Rust 会各自去释放它们指向的堆内存。可它俩指的是同一块 —— 于是这块内存被释放两次。这就是 C/C++ 里臭名昭著的 double free:轻则崩溃,重则被攻击者利用成安全漏洞。
面对「两个变量指向同一块堆内存」,语言设计者只有三条路:
一、每次赋值都深拷贝堆数据。安全,但巨慢 —— 你每写一次 let b = a 都在复制整个缓冲区。(C++ 的默认拷贝构造大致是这条,也因此被诟病。)
二、让它们共享,用引用计数或 GC 管什么时候释放。安全,但需要一个运行时在背后数引用 / 扫垃圾。(Java、Python、Go 走这条,代价是 GC 停顿和内存开销。)
三、拷贝栈上的三件套,但作废原来的变量。于是任一时刻只有一个变量「拥有」这块堆内存,就只会被释放一次。零拷贝、零运行时、零 double free。这就是移动。
所以 let b = a; 之后,Rust 把 a 标记为失效 —— 不是删了它,是转移了它的所有权。堆上那块字符现在归 b 管,a 这个名字被吊销了使用权。等 b 离开作用域,释放一次,干干净净。这就是为什么再用 a 会报 E0382。
下面这台登记簿是真的。选不同的例子,看每次赋值后账上谁「持有」、谁「已移出」(划掉的那行)。第四个例子会当场报 E0382 —— 那个报错,是本书的借用检查器引擎跑出来的,不是我写的。
为什么整数不移动:Copy
i32、bool、char、f64,还有全由这些组成的元组 —— 它们整个就住在栈上,没有堆的部分。复制它们只是复制几个字节,便宜到「作废原变量」根本不划算。于是 Rust 对这类类型的处理是:直接拷一份,两个都留着。
这类「拷贝就完事」的类型,实现了一个叫 Copy 的标记 trait。判断标准很简单:一个值如果不管理任何堆资源(或别的需要清理的东西),它就能是 Copy。所以 i32 是 Copy,String 不是(它管着堆)。let b = a 对 Copy 类型是「复制」,对非 Copy 类型是「移动」—— 同一个语法,两种行为,分水岭就是「有没有堆资源要独占」。
想两个都留着?三个办法
如果你确实需要 a 移动之后还能用,按需选一个:
- 真的想要两份独立的数据 →
.clone()。它深拷贝堆内容,给你一块全新的、a和b各自拥有、互不影响。代价是那次拷贝的开销,所以别滥用(回想第 2 章:别拿 clone 当消音器)。 - 只是想「借看一眼」,不需要拥有 →
&a(借用)。这是下一节的主角,也是绝大多数情况下的正解。你会发现很多「移动后不能用」的报错,真正的修法不是 clone,而是「你本来就只想借,那就借」。 - 这个值本来就该只有一份、传给别人后自己不再用 → 什么都不做,接受移动。这才是移动最常见的正确用法:所有权本就该交出去。
函数调用也是移动
把一个值传进函数,和 let b = a 是一回事 —— 所有权移进了函数:
fn consume(s: String) { // s 拿走了所有权
println!("我拥有了:{}", s);
} // s 在这里被 drop
fn main() {
let book = String::from("一本书");
consume(book); // book 的所有权移进 consume
// println!("{}", book); // ✗ 用不了了,所有权已经交出去
}
函数返回值,则是把所有权移出来。所以你会看到 Rust 里一种常见模式:函数「拿走再还回」——
fn borrow_and_return(s: String) -> String {
println!("看一眼:{}", s);
s // 把所有权还回去(没有分号 = 返回它)
}
let book = borrow_and_return(book); // 拿回来,重新绑给 book
不过你八成已经想到了:为了「读一下就还回来」还得这样倒手,太笨了。正是为了解决这个笨拙,才有了借用(下一节起讲)—— 借用让你「看一眼」而不必交出所有权。这条「移动 → 太笨 → 引入借用」的动机线,请记着,它是卷 II 的主线。
移动可以「移动一部分」。如果你把一个结构体里的某个非 Copy 字段移出去,整个结构体就变成「部分被移出」,之后你不能再整体使用它:
struct Book { title: String, pages: u32 }
let b = Book { title: String::from("借还"), pages: 200 };
let t = b.title; // 把 title 字段移出去
// println!("{:?}", b); // ✗ b 被部分移出,不能整体用了
println!("{}", b.pages); // ✓ 但还能单独用没被移走的 Copy 字段 pages
这个规则很符合直觉 —— 你把书名撕下来带走了,这本书就不完整了 —— 但第一次撞上会愣一下。记住:移动的粒度可以细到字段。
回流
Java / Kotlin / Python / JS:b = a 之后两个变量指向同一个对象(引用语义),谁都能改,改了另一个也变 —— 这就是上面说的「路二」,用 GC 来决定何时释放。方便,但也正是无数「别名 bug」的来源:你以为你改的是自己那份,其实动了别人的。Rust 的移动,从根上让这种「不知不觉的共享」不可能发生。
C++:移动语义 Rust 是从 C++11 借来的(std::move)。但在 C++ 里它是可选的、你得手动触发,而且移动后原对象处于一个「合法但未指定」的僵尸状态 —— 你还是能用它,只是行为诡异,编译器不拦。Rust 把移动设成默认,并且移动后彻底作废、编译器拦死。同一个想法,Rust 把它做成了安全的。
下次你在别的语言里写 b = a,可以顺手问自己一句:「现在这块数据,到底归谁?他俩是不是在偷偷共享?」—— 这个问题你一旦开始问,就已经在用 Rust 的脑子写代码了。
这一章的一句话
移动不是删除,是所有权的转移:一个值任一时刻只有一个主人,于是只被释放一次 —— 没有 GC,也没有 double free。Copy 类型太便宜,所以直接拷;其余的,默认搬走。
下一章,我们看这个「唯一的主人」在故事结尾的样子 —— 它离开作用域时,那本书是怎么、什么时候被还回去的。