阶段二 · 所有权系统

所有权与移动

一句话总结

所有权(Ownership)是 Rust 在编译期管理内存的规则,替代了 Go 的 GC:每块内存有且只有一个所有者,所有者离开作用域时内存自动释放.
堆类型赋值/传参会移动(Move) 所有权使原变量失效;栈上的简单类型则因实现了 Copy 而按位复制.这是整个 Rust 的立身之本.

从一个 Go 程序的困惑出发

在 Go 里这段代码再普通不过:

s1 := "hello"
s2 := s1
fmt.Println(s1, s2) // hello hello,毫无问题

"翻译"成 Rust,却会被编译器拦下:

let s1 = String::from("hello");
let s2 = s1;
println!("{s1}"); // 编译错误:borrow of moved value: `s1`

同样是赋值,为什么 Rust 报错?答案就是所有权.理解了这个差异,就推开了 Rust 的大门.

Go 的赋值像复印文件:复印件和原件各自独立,GC 之后统一清理.
Rust 的 let s2 = s1移交唯一的纸质合同:合同只有一份,交给 s2 后,s1 手里就空了,再去看 s1 自然不行.

所有权三条规则

整个系统建立在三条规则上,先记住,后面所有现象都由此推导:

  1. Rust 中每个值都有一个所有者(owner).
  2. 同一时刻,值有且只有一个所有者.
  3. 当所有者离开作用域(scope),值被自动释放(drop).

第三条最关键:

{
let s = String::from("hello"); // s 进入作用域,成为字符串的所有者
// 这里可以使用 s
} // s 离开作用域,Rust 自动调用 drop,释放堆内存

没有 GC,也没有手动 free.释放点由作用域在编译期确定,运行时零额外开销--这是 Rust "零成本抽象"的第一个体现.

drop 像自动的 defer Close

它有点像 Go 的 defer file.Close():离开作用域自动清理.区别在于--Go 要手动写 defer,且只针对显式声明的资源;Rust 对所有拥有堆资源的值自动插入 drop,无需操心.

需要自定义清理逻辑时,可以实现 Drop trait(后续会遇到).

栈,堆与移动的本质

要理解为什么 String 赋值会"移动",得看清它的内存布局.String 分两部分:

栈(Stack)              堆(Heap)
┌─────────────┐ ┌───────────────┐
s1 │ ptr ──────┼───────▶│ h e l l o │
│ len = 5 │ └───────────────┘
│ cap = 5 │
└─────────────┘

栈上是一个"胖指针"(指向堆的指针 + 长度 + 容量),真正的字符内容在堆上.let s2 = s1 时,Rust 只复制栈上那三个字段,不复制堆数据(否则大字符串赋值会很昂贵).

于是 s1 和 s2 的指针指向同一块堆内存.若两者各自在离开作用域时 drop 一次,同一块内存就被释放两次(double free)--经典内存漏洞.Rust 的解法干脆:让 s1 失效,只有 s2 拥有这块数据.这个过程叫移动(Move),不是浅拷贝.

移动语义保证"任何时刻,一块堆内存只有一个所有者负责释放它".
这正是 Rust 在没有 GC 的前提下,杜绝二次释放与悬垂指针的核心机制.

Copy:为什么整数不会被移动

立刻试探:整数赋值是不是也会失效?

let x = 5;
let y = x;
println!("{x} {y}"); // 5 5,完全正常

整数没被移动.因为 i32 这类类型完全在栈上,大小固定,复制廉价,没有堆数据要管理.Rust 为它们实现了 Copy trait:赋值时按位复制一份,原值依然有效.

常见的 Copy 类型:

  • 所有整数,浮点,布尔,字符(char)
  • 仅由 Copy 类型组成的元组,如 (i32, bool)
  • 不可变引用 &T(下一篇详述)

String,Vec<T> 这类管理堆内存的类型,刻意没有实现 Copy,因此走移动.

判断口诀与 Go 的微妙差异

值是否"纯栈上,可廉价复制",决定它走 Copy 还是 Move.

对照 Go:Go 的 int 赋值是值拷贝(类似 Copy),但 Go 的 slice,map 赋值是共享底层(既非 Copy 也非 Move,而是共享 + GC 兜底).Rust 不允许这种"悄悄共享"--要么独占所有权(Move),要么显式借用(下一篇).

Clone:需要副本时显式深拷贝

如果确实想要两份独立的堆数据,用 .clone() 显式深拷贝.Rust 强制深拷贝必须显式,正是为了让"这里有一次昂贵的内存复制"在代码里清晰可见:

let s1 = String::from("hello");
let s2 = s1.clone(); // 深拷贝:堆数据也复制一份
println!("{s1} {s2}"); // 两者都有效,各自拥有独立数据

最佳实践:不要用 clone 来消灭借用报错

新手遇到所有权报错时,常无脑 .clone() 绕过--这能编译通过,但可能掩盖了本该用借用解决的问题,带来不必要的内存复制.

正确顺序是:先想"能不能借用(下一篇)",借用解决不了再考虑 clone.把 clone 当成"我确实需要一份独立副本"的明确表达,而非"编译器闭嘴"的万能药.

函数调用中的移动

移动不只发生在赋值,把值传给函数同样移交所有权:

fn main() {
let s = String::from("hello");
takes_ownership(s); // s 的所有权移入函数
// 这之后再用 s 会编译错误:s 已被移走
}

fn takes_ownership(text: String) {
println!("{text}");
} // text 离开作用域,drop 被调用,内存释放

函数也能通过返回值把所有权还回来:

fn main() {
let s1 = String::from("hello");
let s2 = takes_and_gives_back(s1); // s1 移入,返回值移给 s2
println!("{s2}");
}

fn takes_and_gives_back(text: String) -> String {
text // 把所有权返回给调用方
}

这套"传进去,再传回来"很快会让人厌烦--只想读一下数据,却要交还所有权.这正是借用登场的理由,也是下一篇的主题.

与 Go 的整体对照

维度 Go Rust
内存回收 运行时 GC,自动但有停顿 所有权 + drop,编译期确定,零运行时开销
赋值 String 共享底层字节,GC 兜底 移动所有权,原变量失效
赋值 int 值拷贝 Copy,值拷贝(行为一致)
深拷贝 需手动逐字段复制 显式 .clone()
传参 默认值拷贝;传指针则共享 默认移动;借用见下一篇

快速回顾

  • 三规则:一值一主,唯一所有者,所有者离开作用域即 drop(编译期确定,零开销).
  • 移动(Move):堆类型赋值/传参转移所有权,原变量失效,杜绝二次释放.
  • Copy:纯栈上,可廉价复制的类型(整数等)赋值不失效;堆类型刻意不实现 Copy.
  • Clone:显式深拷贝;不要用它来掩盖本该借用的问题.
  • 函数传参:传值即移动所有权,可通过返回值交还--繁琐之处正由借用解决.

动手练习

  1. 移动报错:创建一个 String,赋值给另一变量后尝试使用原变量,完整读懂编译器的 "moved value" 报错.
  2. Copy 验证:把上题的 String 换成 i32,确认不再报错,并解释 Copy 与 Move 的区别.
  3. 函数移动:写一个接收 String(取得所有权)的函数,在 main 中调用后尝试再用该变量,观察报错.
  4. Clone 实验:用 .clone() 让上题的变量在传参后仍可用,并思考这次 clone 是否真的必要.
  5. 所有权往返:写一个"传入 String,处理后再返回"的函数,体会这种所有权往返的繁琐(为下一篇借用做铺垫).