Rust 所有权(Ownership)入门:从“内存去哪了”到“为什么编译器不让你写”

适合谁:0 基础 Rust,读到“所有权”突然卡住的人。 目标:把官方内容串成一条清晰的学习路径:栈/堆 → 作用域 → 三条所有权规则 → String 为什么特殊 → move / clone / Copy → 函数传参与返回的所有权流转 → 为什么需要引用(下一篇铺垫)。 注意:本文扩充只做“组织、解释、对照与练习引导”,所有概念点均来自官方材料:Rust Book 第 4 章相关段落(你给出的内容)。


目录

  1. 为什么 Rust 要讲“所有权”
  2. 栈与堆:先把内存地图画出来
  3. 三条所有权规则:背下来不如用例子理解
  4. 作用域(Scope):值在什么时候有效
  5. String:为什么它是所有权教学的主角
  6. 内存与分配:String 如何申请与归还堆内存(drop
  7. move:为什么 let s2 = s1;s1 不能用了
  8. clone:什么时候需要深拷贝(并知道它可能昂贵)
  9. Copy:为什么整数“看起来没 move”
  10. 函数与所有权:传参/返回就是 move 或 copy
  11. 走到这里你已经能解释哪些编译错误
  12. 练习:把所有权规则练成肌肉记忆
  13. 下一步:为什么引用能解决“拿走又还回去”的繁琐

1) 为什么 Rust 要讲“所有权”

所有程序运行时都要管理内存。官方对比了三条路线:

  • 有些语言用 垃圾回收(GC),运行时定期找不再使用的内存并回收
  • 有些语言要求程序员 显式分配与释放
  • Rust 走第三条:用一套 所有权规则 管理内存,由编译器检查
    • 规则违反 → 无法编译
    • 这些检查不会拖慢运行时性能(因为主要在编译期完成)

这里的关键词是:编译期保证内存安全(尤其是堆内存的使用与清理)。


2) 栈与堆:先把内存地图画出来

很多语言里你可以很少思考栈/堆,但 Rust 是系统编程语言,值在栈还是堆会影响语言行为与代码写法。

2.1 栈(Stack):后进先出(LIFO)

官方类比:一叠盘子。

  • push:把数据“推入栈顶”
  • pop:从栈顶“弹出”
  • 栈上数据必须是 编译期大小已知且固定 的值
    • “大小未知”或“运行时可能变化”的数据 → 不能放栈上,只能放堆上

栈快的原因:栈顶位置永远是下一次放置的位置,不需要搜索。

特点:

  • 后进先出(LIFO):函数调用进入、结束退出都很自然

  • 存放大小已知且固定的数据。例如:i32boolf64char、固定长度数组 [T; N]、只包含固定大小字段的结构体(前提是整体大小在编译期确定)

  • 分配/释放几乎“零成本”

  • 进入作用域/函数调用时“压栈”,离开作用域/函数结束时“出栈”

  • 不需要向分配器申请空间,也不需要你手动释放

2.2 堆(Heap):需要分配(allocate)

堆更“松散”:

  • 程序请求一块空间
  • 内存分配器找一块足够大的空位,标记为使用中
  • 返回一个 指针(地址)

指针大小固定,所以指针本身可以放在栈上;实际数据在堆上,要通过指针访问。

堆慢的原因(官方解释):分配要找空位并做簿记;访问要“跟随指针”,而 CPU 更喜欢处理内存局部性更好的数据。

特点:

  • 存放编译期大小未知或运行时可能变化的数据, 典型:StringVec<T>Box<T>HashMap<K,V>

  • 分配需要内存分配器(allocator)参与

  • 申请一块足够大的空间 → 返回一个指针(地址)

  • 访问通常需要“跟随指针”

  • 指针本身往往在栈上,真正的数据在堆上

2.3 函数调用与栈

函数调用时:

  • 传入值(也可能是指向堆数据的指针)和局部变量 → 推入栈 函数结束:
  • 这些值 → 弹出栈

官方提示:理解所有权后,你不需要频繁思考栈/堆;但知道所有权主要解决堆数据管理问题,能帮助你理解它为什么那样设计。


3) 三条所有权规则:背下来不如用例子理解

官方给出三条核心规则(后文所有例子都围绕它们):

  1. Rust 中每个值都有一个所有者(owner)。
  2. 任意时刻只能有一个所有者。
  3. 当所有者离开作用域(scope),值会被丢弃(dropped)。

你现在可以先把它当成“游戏规则”,接下来用 String 的例子把它变成直觉。


4) 作用域(Scope):值在什么时候有效

作用域是“一个东西在程序里有效的范围”。官方用字符串字面量举例:

{
let s = "hello"; // s 从这里开始有效
// 使用 s
} // 作用域结束,s 不再有效

此时的感觉和其他语言差不多:变量从声明起有效,到所在作用域结束为止。

但当我们引入 堆分配 的类型时,“作用域结束会发生什么”就变得关键了。


5) String:为什么它是所有权教学的主角

官方指出:第 3 章介绍的很多类型(整数、布尔、浮点、char、部分元组等):

  • 大小固定
  • 能放栈上
  • 作用域结束后被弹出栈
  • 复制起来廉价(可快速得到独立副本)

而所有权真正要解决的麻烦,主要出在:

  • 堆上的数据
  • 多个变量想“同时用同一块堆数据”

String 恰好是一个经典例子:

  • 字符串字面量 "hello":内容编译期已知、写进二进制、不可变
  • String::from("hello"):堆上分配,可变、可增长,适合存放编译期未知或运行时会变化的文本(如用户输入)

创建与修改(官方示例):

let mut s = String::from("hello");
s.push_str(", world!");
println!("{s}");

6) 内存与分配:String 如何申请与归还堆内存(drop

6.1 为什么字面量快但不能随便改

字面量内容编译期就确定,直接硬编码进最终可执行文件,效率很高。 但它的这些优点来自“不可变”与“大小固定已知”。

6.2 String 为了可变可增长必须做两件事

官方列出两点:

  1. 运行时向分配器 申请 一块堆内存存内容
  2. 用完后要把内存 归还 给分配器

第一点普遍存在(很多语言都得申请内存);难点在第二点:

  • GC 语言:GC 负责回收
  • 无 GC 语言:程序员负责 free(容易忘、早释放、重复释放 → bug)

Rust 的路径是:当拥有者变量离开作用域时,自动归还内存

{
let s = String::from("hello");
// 使用 s
} // 作用域结束,Rust 自动调用 drop,释放 s 对应的堆内存

官方强调:

  • Rust 会在 } 处自动调用一个特殊函数 drop
  • drop 中包含释放资源的逻辑(String 的作者实现)

这类“生命周期结束时释放资源”的模式,在 C++ 中常称为 RAII(官方也提到)。


7) move:为什么 let s2 = s1;s1 不能用了

现在进入所有权最容易“震惊新手”的点:赋值看起来一样,但行为不同

7.1 整数赋值:会复制

let x = 5;
let y = x;

官方解释:

  • 整数大小固定、全在栈上
  • 复制实际值很快 所以 xy 都有效,都等于 5。

7.2 String 赋值:不会复制堆数据

let s1 = String::from("hello");
let s2 = s1;

官方解释 String 的内存结构:

  • 栈上存:指针 + 长度 + 容量(这三者大小固定)
  • 堆上存:真正的字符串内容(字节序列)

当你写 let s2 = s1;

  • Rust 复制的是栈上的三元组(指针、len、capacity)
  • 不会复制堆上的字符串内容

如果这时 s1s2 都认为自己拥有那块堆内存,那么作用域结束时两者都会 drop,导致同一块内存被释放两次:

  • 这就是官方说的 double free(双重释放)
  • 可能导致内存损坏甚至安全漏洞

7.3 Rust 的解决方式:让旧变量失效(move)

为了避免 double free,Rust 的选择是:

  • let s2 = s1; 之后,s1 不再有效
  • 所有权从 s1 移动(move)s2

因此下面代码会报错(官方示例):

let s1 = String::from("hello");
let s2 = s1;
println!("{s1}, world!");

错误信息关键点是:borrow of moved value(对已移动的值进行借用/使用)。

官方还补充:这不叫浅拷贝(shallow copy),因为 Rust 同时“使原变量失效”,所以用术语 move

7.4 一个重要的性能结论(官方原话意思)

Rust 不会自动做深拷贝。 因此“自动发生的复制”通常可以认为开销很小(因为只复制栈上的固定数据)。


8) clone:什么时候需要深拷贝(并知道它可能昂贵)

如果你确实想复制堆数据,官方给的方式是调用 clone

let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {s1}, s2 = {s2}");

含义很明确:

  • clone 会把堆上的字符串内容也复制一份
  • 于是 s1s2 各自拥有自己的堆数据,离开作用域时各自 drop 各自的内存

官方特别提醒了一个“读代码的信号”:

  • 看到 clone,你就知道执行了“可能昂贵”的复制操作
  • 它是一个非常明显的提示:这里发生了非平凡的事情

9) Copy:为什么整数“看起来没 move”

你可能会觉得:

  • String 会 move
  • 为什么 i32 不会 move?

官方给出的原因是:像整数这样的类型:

  • 编译期大小已知
  • 完全在栈上
  • 复制便宜且不会引发资源管理问题 所以 Rust 不需要让原变量失效

Rust 用一个特殊的 trait 表示“这个类型可以按位复制且复制后仍然安全”——Copy trait(官方提到会在后面章节讲 trait)。

规则(官方要点):

  • 实现了 Copy 的类型:赋值/传参不会 move,而是“简单复制”,原变量仍有效
  • 如果一个类型实现了 Drop,Rust 不允许它再实现 Copy(否则会和自动释放的语义冲突)

官方列了一些常见 Copy 类型(你给的列表):

  • 所有整数类型(如 u32
  • bool
  • 所有浮点类型(如 f64
  • char
  • 仅由 Copy 类型组成的元组:如 (i32, i32)Copy,但 (i32, String) 不是

这条规则在读代码时非常有用:看到某个值被赋给另一个变量后还能继续用,往往说明它是 Copy(或你显式 clone 了)。


10) 函数与所有权:传参/返回就是 move 或 copy

官方强调:把值传给函数,在所有权层面和“赋值给变量”非常像——会发生 move 或 copy。

10.1 传参示例(官方 Listing 4-3)

fn main() {
let s = String::from("hello");
takes_ownership(s); // s moved,后面不能再用 s
let x = 5;
makes_copy(x); // i32 是 Copy,x 仍然可用
}
fn takes_ownership(some_string: String) {
println!("{some_string}");
} // some_string 出作用域,drop 释放内存
fn makes_copy(some_integer: i32) {
println!("{some_integer}");
} // i32 无需特殊释放

读法:

  • String 进函数:所有权移入参数 some_string
  • 函数结束:some_string 出作用域 → drop → 释放堆数据
  • i32 进函数:发生复制,不影响外面的 x

10.2 返回值也会转移所有权(官方 Listing 4-4)

fn main() {
let s1 = gives_ownership(); // 返回值 move 给 s1
let s2 = String::from("hello");
let s3 = takes_and_gives_back(s2); // s2 move 进函数,返回值 move 给 s3
}
fn gives_ownership() -> String {
let some_string = String::from("yours");
some_string // move 出去
}
fn takes_and_gives_back(a_string: String) -> String {
a_string // move 回去
}

官方总结规律(非常建议背下来):

  • 把值赋给另一个变量 → move(除非是 Copy)
  • 含堆数据的变量出作用域 → drop 清理(除非所有权已经 moved)

11) 走到这里你已经能解释哪些编译错误

读到这里,你应该能解释这类典型报错的原因:

  • “为什么 s1 赋给 s2s1 不能用了?” → 因为 String 不实现 Copy,发生 move,防止 double free。
  • “为什么整数赋值后原变量还能用?” → 因为整数实现 Copy,赋值是简单复制。
  • “为什么函数把 String 作为参数时,调用后外部变量就不能用了?” → 因为传参等价于赋值,发生 move,所有权进入函数参数,函数结束 drop 释放。
  • “为什么 clone 一出现就要小心性能?” → 因为它深拷贝堆数据,可能昂贵(官方强调它是一个明显信号)。

12) 练习:把所有权规则练成肌肉记忆(全部来自本文讲过的点)

建议每个练习都用 cargo run 试一次,再故意改出一个编译错误,观察报错信息。

练习 1:验证 move

fn main() {
let s1 = String::from("hello");
let s2 = s1;
// 取消注释看看错误:
// println!("{s1}");
println!("{s2}");
}

练习 2:验证 clone

fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1={s1}, s2={s2}");
}

练习 3:验证 Copy

fn main() {
let x = 5;
let y = x;
println!("x={x}, y={y}");
}

练习 4:验证函数传参的所有权转移

fn main() {
let s = String::from("hello");
takes_ownership(s);
// 取消注释看看错误:
// println!("{s}");
}
fn takes_ownership(s: String) {
println!("{s}");
}

练习 5:返回值把所有权带回来

fn main() {
let s1 = gives_ownership();
println!("{s1}");
}
fn gives_ownership() -> String {
let s = String::from("yours");
s
}

练习 6:用元组“把值还回来”(官方 Listing 4-5 的动机)

fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
println!("The length of '{s2}' is {len}.");
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len();
(s, length)
}

做完你会强烈感受到官方说的那句话: “把所有权拿走再还回来”很繁琐——这正是下一节“引用(references)”要解决的问题。


13) 下一步:为什么引用能解决“拿走又还回去”的繁琐

官方在这一段做了一个很自然的铺垫: 如果每次函数想“用一下 String”都得把它拿走(move),然后再用返回值或元组还回来,代码会变得很累赘。 因此 Rust 提供了 引用(references):让函数“使用值”但不取得所有权。

下一篇只要把“引用 & 借用”讲清楚,你对所有权就会从“硬背规则”变成“自然写对”。