卷 II · 所有权CH 04深度 04/24

移动:let b = a 之后,a 去哪了

这是几乎每个人被 Rust 弹回来的第一下:let b = a; 之后,你再用 a,编译器说它「被移动了」。你觉得荒谬 —— 我又没删它。但只要你看一眼内存里到底发生了什么,就会明白:Rust 不是在删你的变量,它是在回答一个别的语言不敢正面回答的问题 —— 这块内存,最后该由谁来释放?

移动语义栈与堆Copyclone

先看现象

这段代码,在 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 拷贝的是栈上那三件套(快,就仨机器字)。它不会去深拷贝堆上的字符 —— 那太贵了。于是拷完这一瞬间,ab 的指针指向了堆上同一块字符

   栈                          堆
 a: ptr ─┐
    len 9│                  ┌─> "一本书" 的字节
    cap 9│                  │
 b: ptr ─┴──────────────────┘   ← a 和 b 指向同一块!
    len 9
    cap 9

问题来了。ab 都离开作用域时,Rust 会各自去释放它们指向的堆内存。可它俩指的是同一块 —— 于是这块内存被释放两次。这就是 C/C++ 里臭名昭著的 double free:轻则崩溃,重则被攻击者利用成安全漏洞。

◆ 三条路,Rust 选了第三条

面对「两个变量指向同一块堆内存」,语言设计者只有三条路:

一、每次赋值都深拷贝堆数据。安全,但巨慢 —— 你每写一次 let b = a 都在复制整个缓冲区。(C++ 的默认拷贝构造大致是这条,也因此被诟病。)

二、让它们共享,用引用计数或 GC 管什么时候释放。安全,但需要一个运行时在背后数引用 / 扫垃圾。(Java、Python、Go 走这条,代价是 GC 停顿和内存开销。)

三、拷贝栈上的三件套,但作废原来的变量于是任一时刻只有一个变量「拥有」这块堆内存,就只会被释放一次。零拷贝、零运行时、零 double free。这就是移动。

所以 let b = a; 之后,Rust 把 a 标记为失效 —— 不是删了它,是转移了它的所有权。堆上那块字符现在归 b 管,a 这个名字被吊销了使用权。等 b 离开作用域,释放一次,干干净净。这就是为什么再用 a 会报 E0382

▶ 在账页上看移动

下面这台登记簿是真的。选不同的例子,看每次赋值后账上谁「持有」、谁「已移出」(划掉的那行)。第四个例子会当场报 E0382 —— 那个报错,是本书的借用检查器引擎跑出来的,不是我写的。

为什么整数不移动:Copy

i32boolcharf64,还有全由这些组成的元组 —— 它们整个就住在栈上,没有堆的部分。复制它们只是复制几个字节,便宜到「作废原变量」根本不划算。于是 Rust 对这类类型的处理是:直接拷一份,两个都留着。

这类「拷贝就完事」的类型,实现了一个叫 Copy 的标记 trait。判断标准很简单:一个值如果不管理任何堆资源(或别的需要清理的东西),它就能是 Copy。所以 i32 是 Copy,String 不是(它管着堆)。let b = a 对 Copy 类型是「复制」,对非 Copy 类型是「移动」—— 同一个语法,两种行为,分水岭就是「有没有堆资源要独占」。

想两个都留着?三个办法

如果你确实需要 a 移动之后还能用,按需选一个:

  • 真的想要两份独立的数据 → .clone()。它深拷贝堆内容,给你一块全新的、ab 各自拥有、互不影响。代价是那次拷贝的开销,所以别滥用(回想第 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 类型太便宜,所以直接拷;其余的,默认搬走。

下一章,我们看这个「唯一的主人」在故事结尾的样子 —— 它离开作用域时,那本书是怎么、什么时候被还回去的。