第3章:所有权 —— 借用、切片与内存模型
约 9 分钟 · 更新于 2026-09-01
第3章:所有权 —— 借用、切片与内存模型
本章把所有权当作一套“资源账本”来学习:创建值意味着登记负责人,move 意味着转交责任,借用意味着临时访问,作用域结束意味着结算。沿着这条主线,再理解 Copy、Clone、切片和字符串会更自然。
一、先建立资源账本
每一份需要清理的资源都必须回答两个问题:谁负责释放,以及其他代码如何临时使用它。手动内存管理把答案交给程序员,垃圾回收把答案延迟到运行时;Rust 则让编译器在编译阶段核对一份资源账本。
可以用四个动作理解本章:
| 动作 | 账本变化 |
|---|
| 创建拥有型值 | 登记一个所有者 |
| move | 把清理责任转给新所有者 |
| 借用 | 临时授予读或写权限,不改变所有者 |
| 离开作用域 | 所有者执行清理 |
这套模型覆盖的不只是堆内存。文件句柄、锁守卫、网络连接等资源同样可以在所有者离开作用域时确定释放。所有权因此既是内存规则,也是通用的资源管理协议。
遇到借用错误先画关系
写出所有者、引用创建点、最后使用点和 move 点。很多看似复杂的生命周期问题,画成时间线后只是“权限重叠”或“资源已转交”。
二、栈、堆与 String
栈上的值通常大小已知。函数调用时参数和局部变量进入栈帧,函数返回时栈帧退出,分配速度很快。
rust
let x: i32 = 42;
let enabled: bool = true;
let point: (i32, i32) = (3, 4);
运行时大小可能变化的数据通常需要堆分配,例如:
rust
let mut message = String::from("hello");
message.push_str(", Rust");
可把 String 简化理解为栈上的三个字段:指向堆数据的指针、当前长度、已分配容量。真正的 UTF-8 字节存放在堆上,栈上的描述信息大小固定。
栈与堆只是入门模型
Rust 不承诺一个类型永远位于某个区域。值可能被优化、嵌入其他结构或装箱。本章用常见布局建立直觉,不把它当作绝对位置保证。
三、所有权三条规则
- 每个值都有一个所有者
- 同一时刻,一个值通常只有一个所有者
- 所有者离开作用域时,值被丢弃并执行清理
rust
fn main() {
{
let text = String::from("scope");
println!("{text}");
} // text 被 drop
}
资源释放发生在确定的作用域边界,因此时机可预测。
四、Copy:简单值按位复制
rust
fn main() {
let x = 5;
let y = x;
println!("x={x}, y={y}");
}
i32 实现了 Copy,赋值时复制值,两个绑定都有效。常见 Copy 类型包括整数、浮点数、bool、char、元素全部为 Copy 的元组与数组,以及不可变引用 &T。
含 String 的元组通常不是 Copy。是否 Copy 由 trait 实现决定,不是简单由“看起来在栈上”决定。实现了资源清理语义的类型通常不能 Copy,否则多个副本可能清理同一资源。
五、move:转移清理责任
rust
fn main() {
let first = String::from("hello");
let second = first;
println!("{second}");
// println!("{first}"); // first 已移动
}
赋值时,指针、长度和容量被复制到 second,堆上的文本没有深拷贝。为避免两个变量二次释放同一内存,Rust 让 first 失效,这叫 move。
move 首先是语义规则:清理资源的责任从旧绑定转移给新绑定,不必理解成真的把所有字节搬了位置。
遇到 borrow of moved value,先问:旧变量是否还需要;函数是否只需借用;是否真的要独立副本;能否让最后一次使用发生在 move 之前。
六、Clone:显式创建副本
确实需要两份独立堆数据时使用 clone():
rust
fn main() {
let first = String::from("hello");
let second = first.clone();
println!("first={first}, second={second}");
}
Clone 可能包含堆分配和逐元素复制,因此必须显式调用。
| 行为 | 是否显式 | 原值可用 | 可能复制堆数据 |
|---|
| Copy | 否 | 是 | 通常否 |
| move | 否 | 否 | 否 |
| clone() | 是 | 是 | 可能 |
七、函数参数也会 Copy 或 move
rust
fn consume(text: String) {
println!("{text}");
}
fn inspect(number: i32) {
println!("{number}");
}
fn main() {
let text = String::from("owned");
consume(text);
// println!("{text}"); // 已移动
let number = 42;
inspect(number);
println!("{number}"); // i32 是 Copy
}
函数也能返回所有权:
rust
fn add_suffix(mut text: String) -> String {
text.push('!');
text
}
fn main() {
let text = String::from("hello");
let text = add_suffix(text);
println!("{text}");
}
如果函数只想读取,来回传递所有权会很啰嗦,借用正是为此而生。
八、借用:使用值,不接管清理责任
rust
fn length(text: &String) -> usize {
text.len()
}
fn main() {
let text = String::from("hello");
let size = length(&text);
println!("{text} 的长度是 {size}");
}
&text 创建引用,函数只借用,不取得所有权,所以函数结束后 text 仍归调用者。
只读文本参数通常更适合写成 &str:
rust
fn length(text: &str) -> usize {
text.len()
}
fn main() {
let owned = String::from("hello");
let literal = "world";
println!("{}", length(&owned));
println!("{}", length(literal));
}
&str 既能接收字面量,也能接收 String 的视图,因此比 &String 更通用。
九、引用、解引用与共享读取
& 创建引用,* 访问引用指向的值:
rust
let x = 5;
let reference = &x;
assert_eq!(x, 5);
assert_eq!(*reference, 5);
x 是 i32,reference 是 &i32,二者类型不同。方法调用常有自动解引用,但理解 * 对阅读错误和智能指针代码很重要。
同一时刻可以有多个不可变引用:
rust
let text = String::from("hello");
let first = &text;
let second = &text;
println!("{first}, {second}");
多个读者不会互相破坏数据。通过 &String 或 &str 不能修改原值。
十、可变借用:独占写入
修改被借用的值,需要原绑定可变,并创建 &mut:
rust
fn append_world(text: &mut String) {
text.push_str(", world");
}
fn main() {
let mut text = String::from("hello");
append_world(&mut text);
println!("{text}");
}
关键限制是:同一时刻,对同一数据只能存在一个有效的可变引用。
rust
let mut text = String::from("hello");
let first = &mut text;
let second = &mut text; // 编译错误
println!("{first}, {second}");
这条规则在编译期排除数据竞争的重要前提:多个访问者同时访问同一数据、至少一个写入、又没有同步机制。
十一、共享读与独占写不能重叠
rust
let mut text = String::from("hello");
let reader = &text;
let writer = &mut text; // reader 后面仍使用,因此错误
println!("{reader}, {writer}");
读者假设读取期间数据不会被别人修改。但借用不一定持续到花括号末尾。非词法生命周期(NLL)通常让引用在最后一次使用后结束:
rust
let mut text = String::from("hello");
let first = &text;
let second = &text;
println!("{first}, {second}");
let writer = &mut text;
writer.push('!');
println!("{writer}");
共享借用已经在 println! 后结束,因此后面可以独占写入。
借用规则一句话
同一时刻,要么任意多个共享读者,要么一个独占写者;所有引用都必须始终有效。
十二、悬垂引用为什么被拒绝
rust
fn dangle() -> &String {
let text = String::from("hello");
&text
}
函数结束时 text 被释放,返回引用会指向无效内存,因此编译器拒绝。正确做法是返回所有权:
rust
fn no_dangle() -> String {
let text = String::from("hello");
text
}
返回时所有权移动给调用者。错误信息可能提到生命周期;当前先理解为引用不能比被引用值活得更久。
十三、切片:借用一段连续数据
数组切片:
rust
fn main() {
let numbers = [10, 20, 30, 40, 50];
let middle = &numbers[1..4];
assert_eq!(middle, &[20, 30, 40]);
}
middle 类型是 &[i32],保存起始地址和长度,不拥有元素。
字符串切片:
rust
fn main() {
let text = String::from("hello world");
let hello = &text[0..5];
let world = &text[6..11];
println!("{hello}, {world}");
}
范围左闭右开。还可以写 &text[..5]、&text[6..]、&text[..]。字符串切片类型是 &str。
十四、切片为什么总以引用出现
str 和 [T] 是动态大小类型(DST),仅看类型无法知道具体长度,因此不能像普通局部值那样直接使用:
rust
// let text: str = "hello"; // 大小未知
实际使用的是:
切片引用通常是“宽指针”,包含数据起始地址和长度,这两个字段大小固定。
| 底层切片 | 常用形式 | 含义 |
|---|
| str | &str | 有效 UTF-8 字节的借用视图 |
| [i32] | &[i32] | 连续 i32 元素的借用视图 |
日常说“字符串切片”通常指 &str,说“数组切片”通常指 &[T]。
十五、String、str、&str 与 &String
String
标准库拥有型字符串,拥有可增长的堆上 UTF-8 字节,move 时转移所有权:
rust
let mut text = String::from("Rust");
text.push('!');
str
语言内建的动态大小字符串切片类型,几乎不会单独作为局部变量,通常通过 &str、Box<str> 等指针形式使用。
&str
对 UTF-8 字符串数据的只读借用,不拥有字符数据,可以来自字面量或 String:
rust
let literal: &str = "hello";
let owned = String::from("world");
let view: &str = &owned;
&String
对 String 对象本身的借用。只读文本 API 通常优先接收 &str,因为调用者既可传字面量,也可传 String 的视图。
十六、UTF-8:字符串不能按字符下标访问
Rust 字符串是 UTF-8 字节序列。英文字符通常占 1 字节,常见汉字通常占 3 字节。下面代码无效:
rust
let text = String::from("中国");
// let first = text[0];
如果返回字节,它不一定是完整字符;如果返回第 N 个字符,定位过程又无法保证 O(1)。Rust 因而不提供整数下标访问 String。
按 Unicode 标量值遍历:
rust
for character in "中国人".chars() {
println!("{character}");
}
按底层字节遍历:
rust
for byte in "中国人".bytes() {
println!("{byte}");
}
字符串切片必须落在字符边界:
rust
let text = "中国人";
let first = &text[0..3]; // 合法
// let broken = &text[0..2]; // 运行时 panic
len() 返回字节数
"中国人".len() 是 9,不是 3。.chars().count() 返回 Unicode 标量值数量,但它也不一定等于用户眼中的字形数量。
十七、切片让 API 与所有权同步
实现“第一个单词”:
rust
fn first_word(text: &str) -> &str {
for (index, byte) in text.bytes().enumerate() {
if byte == b' ' {
return &text[..index];
}
}
text
}
fn main() {
let sentence = String::from("hello Rust");
let word = first_word(&sentence);
println!("{word}");
}
返回值是原文本的一段 &str,没有分配和复制。如果切片仍会使用,就不能修改原字符串:
rust
let mut sentence = String::from("hello Rust");
let word = first_word(&sentence);
// sentence.clear(); // word 仍借用 sentence
println!("{word}");
先完成切片的最后一次使用,再修改则可以:
rust
let mut sentence = String::from("hello Rust");
let word = first_word(&sentence);
println!("{word}");
sentence.clear();
切片不仅保存开始位置和长度,还让编译器知道视图依赖原数据,从而阻止原数据在视图有效期间被破坏。
十八、怎样读借用检查错误
按顺序排查:
- 确定类型:String、&str、&String 还是其他类型
- 标出所有者:谁最终负责 drop
- 找到 move 点:赋值、传参或方法是否接收 self
- 找到借用开始:哪里创建 &T 或 &mut T
- 找到最后一次使用:NLL 通常在这里结束借用
- 判断真实意图:转移、只读、修改还是独立副本
- 选择最小修复:调整顺序、缩短借用、改参数为引用,最后才考虑 clone
例如:
rust
fn print_length(text: String) {
println!("{}", text.len());
}
let text = String::from("hello");
print_length(text);
// println!("{text}"); // 已移动
函数只读取长度,更合理的接口是:
rust
fn print_length(text: &str) {
println!("{}", text.len());
}
let text = String::from("hello");
print_length(&text);
println!("{text}");
不要先写 print_length(text.clone())。那虽然能通过,却掩盖了函数根本不需要所有权。
十九、常见误区
- move 就是深拷贝:move 通常只转移清理责任,不复制堆数据
- 所有栈上值都 Copy:是否 Copy 由 trait 实现决定
- 所有权错误就加 clone:先判断函数是否应该借用
- &String 和 &str 没区别:只读文本参数通常优先 &str
- 字符串下标按字符计数:字符串切片使用字节偏移,并要求 UTF-8 边界
- &mut T 只是可修改指针:它还代表暂时的独占访问权
- 引用活到花括号末尾:现代 Rust 通常在最后一次使用后结束借用
- 切片拥有原数据:切片只是依赖原数据的借用视图
二十、实践练习
练习一:标注资源流向
阅读下面的代码,在每行旁标出 create、borrow、move 或 drop,并预测最后一行能否编译:
rust
let report = String::from("weekly");
let view = &report;
println!("{view}");
let archived = report;
println!("{archived}");
随后把 println!("{view}") 移到 move 之后,解释错误发生的原因。
练习二:缩短可变借用
编写函数 normalize_title(&mut String):去掉首尾空白并转成小写。尝试同时保留一个 &str 视图并修改原字符串,再通过缩短引用的最后使用位置修复冲突。
练习三:返回切片而非索引
实现 first_token(input: &str) -> &str,返回第一个空白字符之前的切片。验证空字符串、无空白、多字节中文和连续空白输入。
练习四:设计参数类型
分别为“只读取文本”“需要追加内容”“需要保存并稍后使用”设计函数签名。比较 &str、&mut String、String 各自表达的所有权承诺。
练习五:控制复制成本
创建包含 String 的结构体,分别尝试赋值、借用和 clone()。记录每种写法是否让原变量继续可用,以及是否可能复制堆数据。
二十一、总结
- 所有权是一份编译期资源账本:每个资源有负责人,负责人离开作用域时执行清理。
- Copy 让低成本值隐式复制;move 转交清理责任;Clone 显式创建独立副本。
- 借用不会改变所有者,&T 提供共享读取,&mut T 提供独占写入。
- 安全借用的核心规则是“多个读者或一个写者”,并且引用不能超过原值的有效范围。
- NLL 通常让借用在最后一次使用后结束,因此分析错误时应关注真实使用区间。
- 切片是带长度的借用视图;String 拥有 UTF-8 数据,&str 只观察其中一段。
- API 设计应优先表达真实需求:能借用就不接管,能接收 &str 就不把调用者限制为 String。
下一章开始把资源规则用于领域建模:结构体表达“同时存在”,枚举表达“只能选其一”,模式匹配负责完整处理每一种状态。
请继续阅读:第4章:数据建模 —— 结构体、枚举、模式匹配与方法。
- 第1章:开篇 —— Rust 为什么值得学与环境搭建
- 第2章:语言基础 —— 变量、类型、函数与流程控制
- 第4章:数据建模 —— 结构体、枚举、模式匹配与方法