所有权
约 3642 字大约 12 分钟
2026-02-04
适合谁:0 基础 Rust,读到“所有权”突然卡住的人。 目标:把官方内容串成一条清晰的学习路径:栈/堆 → 作用域 → 三条所有权规则 → String 为什么特殊 → move / clone / Copy → 函数传参与返回的所有权流转 → 为什么需要引用(下一篇铺垫)。 注意:本文扩充只做“组织、解释、对照与练习引导”,所有概念点均来自官方材料:Rust Book 第 4 章相关段落(你给出的内容)。
目录
- 为什么 Rust 要讲“所有权”
- 栈与堆:先把内存地图画出来
- 三条所有权规则:背下来不如用例子理解
- 作用域(Scope):值在什么时候有效
String:为什么它是所有权教学的主角- 内存与分配:
String如何申请与归还堆内存(drop) move:为什么let s2 = s1;后s1不能用了clone:什么时候需要深拷贝(并知道它可能昂贵)Copy:为什么整数“看起来没 move”- 函数与所有权:传参/返回就是 move 或 copy
- 走到这里你已经能解释哪些编译错误
- 练习:把所有权规则练成肌肉记忆
- 下一步:为什么引用能解决“拿走又还回去”的繁琐
1) 为什么 Rust 要讲“所有权”
所有程序运行时都要管理内存。官方对比了三条路线:
- 有些语言用 垃圾回收(GC),运行时定期找不再使用的内存并回收
- 有些语言要求程序员 显式分配与释放
- Rust 走第三条:用一套 所有权规则 管理内存,由编译器检查
- 规则违反 → 无法编译
- 这些检查不会拖慢运行时性能(因为主要在编译期完成)
这里的关键词是:编译期保证内存安全(尤其是堆内存的使用与清理)。
2) 栈与堆:先把内存地图画出来
很多语言里你可以很少思考栈/堆,但 Rust 是系统编程语言,值在栈还是堆会影响语言行为与代码写法。
2.1 栈(Stack):后进先出(LIFO)
官方类比:一叠盘子。
- push:把数据“推入栈顶”
- pop:从栈顶“弹出”
- 栈上数据必须是 编译期大小已知且固定 的值
- “大小未知”或“运行时可能变化”的数据 → 不能放栈上,只能放堆上
栈快的原因:栈顶位置永远是下一次放置的位置,不需要搜索。
特点:
后进先出(LIFO):函数调用进入、结束退出都很自然
存放大小已知且固定的数据。例如:
i32、bool、f64、char、固定长度数组[T; N]、只包含固定大小字段的结构体(前提是整体大小在编译期确定)分配/释放几乎“零成本”
进入作用域/函数调用时“压栈”,离开作用域/函数结束时“出栈”
不需要向分配器申请空间,也不需要你手动释放
2.2 堆(Heap):需要分配(allocate)
堆更“松散”:
- 程序请求一块空间
- 内存分配器找一块足够大的空位,标记为使用中
- 返回一个 指针(地址)
指针大小固定,所以指针本身可以放在栈上;实际数据在堆上,要通过指针访问。
堆慢的原因(官方解释):分配要找空位并做簿记;访问要“跟随指针”,而 CPU 更喜欢处理内存局部性更好的数据。
特点:
存放编译期大小未知或运行时可能变化的数据, 典型:
String、Vec<T>、Box<T>、HashMap<K,V>等分配需要内存分配器(allocator)参与
申请一块足够大的空间 → 返回一个指针(地址)
访问通常需要“跟随指针”
指针本身往往在栈上,真正的数据在堆上
2.3 函数调用与栈
函数调用时:
- 传入值(也可能是指向堆数据的指针)和局部变量 → 推入栈 函数结束:
- 这些值 → 弹出栈
官方提示:理解所有权后,你不需要频繁思考栈/堆;但知道所有权主要解决堆数据管理问题,能帮助你理解它为什么那样设计。
3) 三条所有权规则:背下来不如用例子理解
官方给出三条核心规则(后文所有例子都围绕它们):
- Rust 中每个值都有一个所有者(owner)。
- 任意时刻只能有一个所有者。
- 当所有者离开作用域(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 为了可变可增长必须做两件事
官方列出两点:
- 运行时向分配器 申请 一块堆内存存内容
- 用完后要把内存 归还 给分配器
第一点普遍存在(很多语言都得申请内存);难点在第二点:
- 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;官方解释:
- 整数大小固定、全在栈上
- 复制实际值很快 所以
x和y都有效,都等于 5。
7.2 String 赋值:不会复制堆数据
let s1 = String::from("hello");
let s2 = s1;官方解释 String 的内存结构:
- 栈上存:指针 + 长度 + 容量(这三者大小固定)
- 堆上存:真正的字符串内容(字节序列)
当你写 let s2 = s1;:
- Rust 复制的是栈上的三元组(指针、len、capacity)
- 不会复制堆上的字符串内容
如果这时 s1 和 s2 都认为自己拥有那块堆内存,那么作用域结束时两者都会 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会把堆上的字符串内容也复制一份- 于是
s1和s2各自拥有自己的堆数据,离开作用域时各自 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赋给s2后s1不能用了?” → 因为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):让函数“使用值”但不取得所有权。
下一篇只要把“引用 & 借用”讲清楚,你对所有权就会从“硬背规则”变成“自然写对”。
贡献者
flycodeu
版权所有
版权归属:flycodeu
