第二章 所有权:Rust 世界的第一性原理

你有没有过这种经历:合租的房子里,冰箱上贴着一张便条”这瓶牛奶是谁的?“。三个人都觉得是自己的,结果没人买,或者三个人都买了。资源归属说不清,麻烦就来了。

Rust 在语言层面干了一件事:它规定,冰箱里的每一样东西,同一时刻有且只有一个明确的主人。这就是所有权(ownership)。听着像小学生纪律,却是 Rust 全部安全感的基石。这一章我们把这块基石敲实,敲到你闭着眼都能推。

先说结论,省得你后面迷路:所有权是 Rust 管理堆内存的根本机制,它让”谁负责释放内存”这件事在编译期就确定下来,从而不需要 GC,也不会有忘记释放或重复释放。

一、为什么需要”唯一主人”这件事

回到上一章的三种内存管理。Rust 不要 GC,也不要你手动 free。那”这块内存什么时候释放”由谁决定?答案是:由当前拥有这块内存的变量决定——当这个变量离开作用域(scope),Rust 就自动调用 drop 把内存还回去。

关键点来了:如果一块内存可以同时被两个变量”拥有”,那它们都离开作用域时,就会释放两次(double-free)——经典的内存安全 bug。所以 Rust 的铁律第一条:

每一个值,在任意时刻,有且仅有一个所有者(owner)。

这不是建议,是编译器强制。你写不出”两个变量同时拥有同一块堆内存”的代码,因为语言规则从根上不允许。

二、栈和堆:先把这个底子捋清

讲所有权绕不开栈(stack)和堆(heap)。别嫌墨叔啰嗦,这里不清,后面生命周期你必然晕。

栈,像一摞盘子。放东西(压栈)只能放最上面,拿也只能拿最上面。好处是极快、自动管理——函数调用结束时,它那一摞盘子整整齐齐全收走。坏处是:大小必须在编译期已知,而且你不能把盘子随便塞给另一个人长期保管。

堆,像一块大空地,你按需圈一块用,记下地址(指针)放在栈上。好处是大小随意、生命周期灵活;坏处是”这块地什么时候还”得有人管,管不好就漏(leak)或者还重了(double-free)。

Rust 里像 i32、bool、固定大小的数组这些值,直接放在栈上,离开作用域自然就没了,简单。但像 String(一个可增长的文本)、Vec(动态数组)这种大小运行时才定的东西,真正的数据在堆上,栈上只放一个”指向堆的小结构”(指针 + 长度 + 容量)。

问题就出在堆上那块数据的归属。栈上的小结构可以随便拷贝(指针值复制一下就行),但堆上那块真数据不能有两个主人。于是 Rust 发明了 move。

三、Move:赋值不是拷贝,是过户

在 C++ 或 Java 里,a = b 通常意味着”把 b 的值拷一份给 a”。Rust 里,对于拥有堆数据的类型(我们叫它”非 Copy 类型”),a = b 的含义是:把所有权从 b 过户给 a。b 从此不再是这块内存的主人,b 变成无效的、不能再被使用的变量。

let s1 = String::from("hello");
let s2 = s1;          // 所有权从 s1 移到 s2
// 下面这行如果取消注释,编译器会拒绝:
// println!("{}", s1); // error: borrow of moved value: `s1`

你品这个设计。为什么不让 s1 和 s2 共享?因为共享 = 两个主人 = 离开作用域时 double-free。Rust 选择”过户”,从根本上消灭了共享所有权导致的释放冲突。Move 不是性能优化,是安全机制。

打个比方:你把自己名下的房子卖给了朋友。过户完成后,房产证上写的是朋友的名字,你再拿着旧证去卖一次,就是诈骗(double-free)。Rust 在编译期就拦住你:“哥,证已经不是你的了,别拿它办事。”

那如果真的想要一份独立的拷贝怎么办?显式调用 .clone()。这会从堆上再申请一块内存,把数据真复制过去,产生第二个独立的主人。拷贝是你要明确”我很清楚这要花成本”时才会发生的事——Rust 不替你偷偷花这个钱。

四、作用域与 Drop:资源的自动归还

每个变量都有作用域——从它声明的地方,到最近的那个 }。变量离开作用域时,Rust 自动调用 drop 函数,把它的堆内存还回去。这就是第一章说的 RAII,在 Rust 里叫 “drop on scope exit”。

{
    let s = String::from("temporary");
    // s 在这里可用
} // ← 到这里,s 离开作用域,Rust 自动 drop,内存归还

注意,drop 只发生在所有者身上。如果一个值被 move 走了,原变量离开作用域时啥也不做——因为它早就不拥有任何东西了。这保证了”一块内存只被释放一次”。

这里有个容易绕的点:函数传参也是 move。你把 String 传给函数,所有权就进了函数参数;函数结束时参数离开作用域,值被 drop。除非你把它 return 出来,把所有权还给你。这引出了所有权在函数间”借进借出”的麻烦——别急,第三章的”借用”就是专门解决”我不想过户、只想借一下”的需求的。

五、Copy 类型:那些”不值钱”的栈上值

你肯定要问:那 let x = 5; let y = x; 难道 x 也失效了?不会。因为 i32、f64、bool、字符、以及全是 Copy 类型的元组,属于 Copy 类型。它们的数据完全在栈上、大小固定、复制成本极低,Rust 规定它们赋值和传参时直接 bitwise 拷贝,不产生 move,原变量依然有效。

判断口诀:数据全在栈上、可以安全按位复制的,就是 Copy;碰了堆的(String/Vec/Box 等),就不是 Copy,赋值即 move。 你不用背清单,记住”堆”这个分水岭就行。

为什么这么设计?因为 Copy 类型复制一份也不会引起 double-free(它们根本没有”释放”这个概念,离开作用域栈自动回收),所以编译器大方地允许随意拷贝,不折腾你。Rust 的严格,是精准地严格——只在会出事的地方严格。

六、所有权为什么是”第一性原理”

你可能会想:规则这么多,图啥?墨叔给你捋这条因果链:

  1. 要保证内存安全又不能要 GC → 必须在编译期确定”谁负责释放”。
  2. “谁负责释放”= 明确的所有权。
  3. 明确所有权 → 一块内存只能有一个主人 → 于是有了 move,杜绝共享导致的 double-free。
  4. move 解决了”释放”,但带来了”我想借一下又不想过户”的新需求 → 引出借用(第三章)。

看到了吗?所有权不是拍脑袋定的纪律,它是”无 GC 又要安全”这个最高目标,逻辑推到底必然得到的那个解。理解了这一层,你后面学借用、生命周期,就不再是死记规则,而是”顺着这个逻辑往下走,规则自己长出来”。

所以每次编译器拦你”value moved here”,别烦躁。它在说:“哥,按咱们定的规矩,这块内存现在归别人了,你不能再碰。“它拦住的,是 tomorrow 凌晨三点的一个 double-free。

七、作用域嵌套与主动 drop:释放时机你也能做主

前面说”变量离开作用域就 drop”。但作用域是可以嵌套的——你可以在函数里随手开一个 {} 块,把某个大对象圈在里面,块一结束它就立刻被回收,不必等整个函数跑完。这在”中途产生一个很大的临时值、用完就想立刻还内存”时特别有用。

fn 处理() {
    let 小 = String::from("我活到函数结束");
    {
        let 大 = String::from("我很大,但只在这个阶段用");
        // 用 大 做点重活
    } // ← 大 在这里就 drop 了,内存立刻归还,不是等到 处理() 结束
    // 小 还活着
}

再看主动 drop:有时你想在某个明确的点释放资源(比如提前关掉一个锁 guard,好让后面代码能再拿锁),可以显式调用 std::mem::drop(大)。它本质就是”让这个值立刻离开作用域”,从而立刻触发 drop。注意一个细节:如果一个值已经被 move 走了,它在原位置就”不存在”了,drop 检查会跳过它——这正是我们说的”一块内存只被真正的主人 drop 一次”。

八、函数返回值里的 move:所有权怎么”还回来”

第二章留了个坑:函数收了 String 参数,函数结束就 drop,想留着得 return 出来。这其实揭开了一个更通用的模式——函数可以通过返回值把所有权”还回来”,更准确说,把所有权顺着 return 转移给调用方。

fn 接管并返回(s: String) -> String {
    // 这里 s 是主人,随便用
    s   // 没有分号:这是返回值,所有权从这里 move 给调用方
}
let 原 = String::from("hi");
let 新 = 接管并返回(原);   // 原 被 move 进函数,又从返回值 move 出来到 新
// 原 已失效,新 是新的主人

你发现没?return 本质也是一个 move——方向是从”函数内部”move 到”函数外部”。配合第三章的借用,真实代码里大多是”传 &T 进去、不碰所有权”,只在少数真正需要移交的场景才用 move + return。这一来一回的所有权流动,就是 Rust 函数间数据传递的全部戏法:要么借(不挪窝),要么 move 进、再 move 出。

九、检验你真懂了没有

墨叔扔个思想实验给你收尾本节:假设你有个 Vec<String>(一个装着多个字符串的数组),你把它 move 给函数。函数结束时,这整串字符串的堆内存,是一次性释放,还是逐个释放?答案是:Vec 自己那块”指针 + 长度 + 容量”的小结构被 drop,它内部的 Drop 实现会负责把它指向的整块元素内存也递归释放掉。所以”一个主人 drop,它名下所有堆资源连带清理”是 Rust 的默认行为——这叫”所有权树”的级联释放。理解了这点,你就明白为什么 Rust 不需要 GC 也不会漏:释放是结构化、递归、确定发生的,不是靠后台扫。

十、预告:当数据要”自己指自己”

最后留个钩子,免得你后面遇到递归结构时懵。假设你想定义一个链表节点:节点 { 值: i32, 下一个: 节点 }——这里”下一个”字段的类型,又是一个”节点”本身,无限嵌套,编译器根本算不出它占多大内存,也绝不会让你这么写。又或者,你想让两个变量”共享”同一份大数据(而不是 move 来 move 去),可所有权铁律说”不行,只能一个主人”。

这两种需求——“递归自指”和”受限的共享”——单靠今天讲的所有权是解不了的。Rust 的解法藏在第十章(集合与智能指针):用 Box 给递归数据一个确定大小的”盒子”,用 Rc 实现”多个句柄共享同一份数据、引用计数归零才释放”。它们是所有权的”官方例外通道”,专为这些绕不开的场景而设。先记着这个钩子,到时候我们拆。

【思考题】

  1. 你觉得 Rust 的 move 语义,本质上是在用”写代码时的不方便”去换”运行时的哪个具体好处”?除了 double-free,还有没有别的 bug 是被 move 顺手一起防住的?提示:想想”悬垂引用”和”数据被偷偷改了”这两类问题。

  2. 既然 Copy 类型(如 i32)可以随意拷贝,那为什么 Rust 不干脆把所有类型都做成 Copy、省得大家记 move 规则?如果 String 也被设计成 Copy,会发生什么?

  3. 函数传参是 move 这件事,初学时特别容易踩坑:把 String 传进函数就没了,还得 return 回来。你觉得这个设计带来的”麻烦”,和它在并发场景下(第十三章会讲)带来的”红利”,是不是同一件事的两面?试着用一句话把这两面讲通。

【参考答案】

  1. move 换来的核心好处是”释放责任的唯一性”,从而从根上消灭 double-free。但它顺手还防了两类问题:第一,悬垂引用——如果允许任意共享,一个所有者 drop 了堆内存,另一个还拿着指针指向已释放的区域,这就是 use-after-free;move 让”非所有者”根本不再合法存在,自然没有悬垂。第二,隐式数据竞争的前提——共享可变状态是并发 bug 的温床,move 把”默认不可共享”焊死,后面讲并发你会发现这正是 Rust 敢说”消灭数据竞争”的地基。所以 move 不是孤立的刁难,是一石多鸟的安全闸门。

  2. 如果 String 是 Copy,意味着赋值/传参时要把整段堆上文本 bitwise 复制一份。后果有三:一是性能灾难——传个大字符串就全量拷贝,远比 move(只移动栈上的小结构)贵;二是语义荒谬——你”拷”了一份文本,改一份另一份不变,这跟”可变字符串”的直觉冲突,反而更容易出逻辑 bug;三是 Rust 直接放弃了”唯一主人”这条铁律,那 double-free、悬垂全回来了,无 GC 安全的根基就没了。Copy 只能给”复制零风险且零成本”的纯栈值用,碰堆的绝不配享受。

  3. 是的,同一件事的两面。正面说:move 让函数调用变成”所有权必须显式交接”,你就没法在不知情的情况下让一块内存同时被调用者和被调用者持有。到了并发场景,这个”无法隐式共享”的特性直接升级成:你根本没法把一个值同时塞给两个线程去改。要共享,必须走 Arc 这种显式、带引用计数的通道(第十三章)。所以单线程里你觉得”传参还得 return 真麻烦”,多线程里恰恰变成”编译器逼你把共享摆到台面上、数据竞争无从藏身”。麻烦即红利,这就是 Rust 的设计美学。

小结

所有权只有一条铁律——每个值同一时刻有且仅有一个主人。它靠 move(而非拷贝)杜绝共享,靠作用域结束自动 drop 保证释放且仅释放一次。栈上的 Copy 类型例外放行,因为复制它零风险。所有权不是纪律,是”无 GC 又安全”这个最高目标逻辑推到底的必然解。