返回 Rust
Rust 2026-05-19

第4章 认识所有权

第4章 认识所有权

1. 本章核心目标

本章讲解 Rust 最关键、也最有辨识度的机制:所有权(ownership)。它的核心作用,是在不依赖垃圾回收器的前提下,依然保证内存安全。

理解本章后,应当建立下面这条主线:

  • Rust 需要管理堆内存;
  • 为了安全地管理堆内存,Rust 引入了所有权规则;
  • 所有权规则进一步约束了赋值、传参、返回值、引用、借用和切片;
  • 这些约束表面上增加了规则,实质上是在编译期消灭悬垂引用、重复释放、数据竞争等问题

本章内容分为三部分:

  1. 什么是所有权
  2. 引用与借用
  3. Slice 类型

2. 为什么所有权对 Rust 如此重要

Rust 的内存安全并不是依靠运行时垃圾回收实现的,而是依靠编译器在编译期检查一组规则来实现的。程序只要违反这些规则,就无法通过编译。

这意味着:

  • 错误被尽量提前到编译阶段暴露;
  • 运行时不需要为垃圾回收付出额外成本;
  • 程序既能保持高性能,又能尽量避免内存安全问题。

从设计思想上说,所有权并不是“附加功能”,而是 Rust 组织内存、表达资源管理、保证引用有效性的基础机制。


3. 栈与堆:理解所有权的前置知识

3.1 栈(Stack)

栈的特点:

  • 按后进先出(LIFO)方式工作;
  • 压栈、出栈速度快;
  • 只能存储大小在编译期已知且固定的数据;
  • 函数参数、局部变量通常会进入栈。

栈适合处理:

  • 简单标量值;
  • 固定大小的数据;
  • 生命周期清晰、退出作用域即可回收的数据。

3.2 堆(Heap)

堆的特点:

  • 由分配器在运行时寻找可用空间;
  • 适合存放大小在编译期未知运行中可能变化的数据;
  • 访问堆数据通常要先通过指针间接访问,因此开销比栈大;
  • 分配和释放都更复杂。

3.3 为什么所有权主要围绕堆数据展开

栈上数据通常复制简单、释放明确; 而堆数据会引出三类问题:

  • 谁拥有这块数据;
  • 何时释放这块数据;
  • 多个地方同时使用这块数据时如何保证安全。

所有权系统的根本任务,就是管理堆数据。


4. 所有权的三条基本规则

Rust 的所有权规则可以压缩为三条:

  1. Rust 中的每一个值都有一个所有者(owner)
  2. 一个值在任一时刻有且只有一个所有者
  3. 当所有者离开作用域,这个值将被丢弃(drop)

这三条规则看似简单,但几乎决定了本章后面所有行为。


5. 作用域与值的有效性

作用域(scope)是一个值在程序中有效的范围。

例如:

{
    let s = "hello";
    // s 在这里有效
}
// s 在这里失效

对于字符串字面值这类简单值,这和很多语言很像:进入作用域后有效,离开作用域后失效。

但对堆数据来说,离开作用域不仅意味着“名字不能再用”,还意味着其底层资源可能需要被释放。


6. String 与字符串字面值的区别

6.1 字符串字面值

字符串字面值:

let s = "hello";

它的特点是:

  • 内容在编译期就确定;
  • 数据被直接硬编码进可执行文件;
  • 因此高效;
  • 但不可变,不适合表示运行时动态生成或增长的文本。

6.2 String

Rust 提供 String 类型来处理可变、可增长、运行时才能确定内容的字符串:

let s = String::from("hello");

String 的特点:

  • 数据存储在堆上;
  • 大小可以在运行过程中变化;
  • 需要在运行时申请内存;
  • 需要在合适时机释放内存。

例如:

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

这说明 String 可以被扩展,而字符串字面值不行。


7. 内存分配与 drop

对于 String,Rust 在创建它时会向分配器申请堆内存;而当其所有者离开作用域时,Rust 会自动调用一个特殊函数:drop,释放这块内存。

例如:

{
    let s = String::from("hello");
    // 使用 s
}
// 这里自动调用 drop

这正是 Rust 资源管理的关键:

  • 你不需要手动写 free
  • 也不依赖 GC 定时回收;
  • 资源释放时机由作用域和所有权决定。

这种思路与 C++ 中的 RAII 很接近: 资源的获取与对象生命周期绑定,生命周期结束时自动释放资源。


8. 移动(move):赋值并不总是复制全部数据

8.1 栈上简单值的赋值

对于整数这类简单值:

let x = 5;
let y = x;

这里会直接复制值。因为整数大小固定、位于栈上,复制成本很低,所以 xy 都有效。

8.2 String 的赋值

对于 String

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

这时发生的并不是“深拷贝整个堆数据”,而是:

  • 复制栈上的那部分元数据;
  • 即指针、长度、容量;
  • 不会自动复制堆上的字符串内容本身

如果此时 s1s2 同时都被认为有效,那么它们离开作用域时都会尝试释放同一块堆内存,从而发生二次释放(double free)

为避免这一问题,Rust 在 let s2 = s1; 之后,直接把 s1 视为无效。

这就是移动(move)

  • 值的控制权从 s1 转移到 s2
  • s1 不再可用;
  • 只有 s2 负责最终释放资源。

因此,下面的代码会报错:

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

因为 s1 已经被 move 走了。

8.3 为什么 Rust 选择 move 而不是自动深拷贝

如果每次赋值都自动深拷贝堆数据,那么:

  • 语义上虽然方便;
  • 但对大对象会带来很高运行时开销;
  • 复制成本会变得不可控。

Rust 的设计选择是:

  • 默认不自动深拷贝堆数据;
  • 默认用 move 保证安全;
  • 只有在你明确要求时,才进行真正的深拷贝。

这让性能语义更透明。


9. clone:显式深拷贝

如果确实需要复制堆上的数据,而不是转移所有权,可以显式调用 clone

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

此时:

  • 栈上的元数据会复制;
  • 堆上的字符串内容也会复制;
  • s1s2 都有效;
  • 它们拥有彼此独立的数据。

需要特别记住:

  • clone 代表“我知道这里要复制真正的数据”;
  • 它通常比简单赋值更贵;
  • 因此看到 clone 就应该有性能意识。

10. Copy:适用于纯栈数据的轻量复制

Rust 中有一类类型实现了 Copy trait。对于这些类型,赋值之后原变量仍然有效。

例如:

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

这里 x 不会失效,因为 i32Copy 类型。

10.1 哪些类型通常是 Copy

一般来说,满足下面条件的类型可以是 Copy

  • 大小固定;
  • 不需要堆分配;
  • 不需要在离开作用域时执行特殊释放逻辑。

常见 Copy 类型包括:

  • 所有整数类型,如 u32
  • 布尔类型 bool
  • 所有浮点类型,如 f64
  • 字符类型 char
  • 仅由 Copy 成员组成的元组,如 (i32, i32)

10.2 Copy 与 Drop 互斥

如果一个类型实现了 Drop,就不能再实现 Copy

因为:

  • Copy 表示赋值后两个值都独立有效;
  • Drop 表示离开作用域时要执行资源释放;
  • 对持有资源的类型同时允许隐式复制,会重新引出重复释放问题。

11. 赋新值时,旧值会被立即丢弃

看下面的代码:

let mut s = String::from("hello");
s = String::from("ahoy");

在第二行执行时:

  • 旧的 "hello" 所在堆内存不再有任何所有者;
  • Rust 会立刻对旧值执行 drop
  • s 绑定到新的 "ahoy"

这说明:

  • 所有权不仅和作用域结束有关;
  • 也和“这个值是否还被任何所有者持有”有关。

12. 所有权与函数

12.1 传参本质上也是赋值

把值传给函数,和把值赋给变量的规则一致。

fn takes_ownership(some_string: String) {
    println!("{some_string}");
}

fn makes_copy(some_integer: i32) {
    println!("{some_integer}");
}

调用:

let s = String::from("hello");
takes_ownership(s);

let x = 5;
makes_copy(x);

结果:

  • s 被 move 到函数中,因此之后不能再使用;
  • xCopy 类型,因此调用后仍可继续使用。

12.2 返回值也会转移所有权

函数返回值时,也会发生所有权转移。

例如:

fn gives_ownership() -> String {
    let some_string = String::from("yours");
    some_string
}

返回的 String 会 move 给调用者。

12.3 为什么“拿进去再还出来”很别扭

如果一个函数只是想读取字符串长度,却不得不获取所有权再返回:

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len();
    (s, length)
}

这种写法虽然可行,但很笨重:

  • 调用者只想临时使用该值;
  • 却被迫交出所有权;
  • 然后再通过返回值拿回去。

这正是引用与借用要解决的问题。


13. 引用(reference)与借用(borrowing)

13.1 什么是引用

引用本质上是一个地址,但与普通裸指针不同,Rust 的引用有更强的安全保证:

  • 在其生命周期内必须指向有效值;
  • 类型必须正确;
  • 借用规则必须成立。

例如:

fn calculate_length(s: &String) -> usize {
    s.len()
}

调用:

let s1 = String::from("hello");
let len = calculate_length(&s1);

这里:

  • &s1 创建了一个指向 s1 的引用;
  • calculate_length 只是借用该值;
  • 不取得所有权;
  • 因此函数结束后 s1 仍可继续使用。

13.2 借用的本质

“创建引用”这个动作叫做借用(borrowing)

可以把它理解为:

  • 数据仍归原所有者所有;
  • 只是暂时把访问权借给别人;
  • 用完后不需要显式归还,因为生命周期结束即可。

借用解决了前面那个“为了读长度还要转移所有权”的笨重问题。


14. 引用默认不可变

正如变量默认不可变,引用默认也不可变。

因此,下面的代码会报错:

fn change(some_string: &String) {
    some_string.push_str(", world");
}

因为:

  • some_string&String
  • 这是不可变引用;
  • 不能通过它修改底层数据。

这条规则非常重要:

借用并不意味着你自动拥有修改权限。


15. 可变引用(mutable reference)

如果确实需要通过引用修改值,就必须使用可变引用:

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

let mut s = String::from("hello");
change(&mut s);

这里要同时满足两件事:

  1. 原值本身必须是可变的:let mut s
  2. 借用时必须显式写 &mut s

这表明:

  • 原对象允许被修改;
  • 当前这次借用也请求了可变访问权限。

16. 借用规则:为什么 Rust 能阻止数据竞争

Rust 对引用施加了严格限制。核心规则可以概括为:

  • 在任意时刻,要么有任意多个不可变引用
  • 要么有且只有一个可变引用
  • 二者不能同时存在;
  • 并且引用必须始终有效。

这几条规则是 Rust 安全引用模型的核心。

16.1 不能同时存在两个可变引用

下面的代码会报错:

let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
println!("{r1}, {r2}");

原因是:

  • r1 已经对 s 做了独占的可变借用;
  • r1 仍然有效时,又试图创建 r2
  • 这意味着同一时刻有两个写入口,Rust 不允许。

16.2 这条限制的真正价值:防止数据竞争

Rust 之所以强制这样做,是为了在编译阶段阻止数据竞争(data race)。

数据竞争出现的典型条件是:

  1. 两个或更多指针同时访问同一数据;
  2. 至少一个指针执行写操作;
  3. 没有同步机制。

数据竞争的可怕之处在于:

  • 它往往不是稳定复现的错误;
  • 很难在运行时定位;
  • 经常表现为“偶发、诡异、不确定”的 bug。

Rust 的选择是:

只要代码有形成数据竞争的可能,直接拒绝编译。

16.3 作用域可以缩短借用持续时间

下面的写法是允许的:

let mut s = String::from("hello");
{
    let r1 = &mut s;
}
let r2 = &mut s;

因为:

  • r1 的作用域在内部块结束时已经结束;
  • 此后再创建 r2 时,不再与 r1 重叠。

这说明 Rust 并不是“永远只允许一个可变引用”,而是:

同一时间只能有一个可变引用。

16.4 不可变引用与可变引用不能重叠

下面也会报错:

let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
let r3 = &mut s;
println!("{r1}, {r2}, {r3}");

因为:

  • r1r2 表示“只读观察”;
  • r3 表示“独占修改”;
  • 观察和修改如果重叠,读到的数据可能就不稳定。

16.5 最后一次使用之后,借用就算结束

Rust 的借用结束时间并不一定等于词法作用域结束,而是可以早到“最后一次使用”之后。

例如:

let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{r1} and {r2}");
let r3 = &mut s;
println!("{r3}");

这是合法的。因为:

  • r1r2println! 后已不再使用;
  • 所以它们的借用在那里就结束了;
  • 后面的 r3 不再与它们重叠。

这体现出 Rust 编译器并不是机械地按大括号判断,而是会分析实际使用点。


17. 悬垂引用(dangling reference)

在很多带指针的语言中,很容易发生这种错误:

  • 某块内存已经被释放;
  • 但程序中仍保留着指向它的引用;
  • 之后继续访问,就会读到无效数据,甚至读到别的对象的数据。

这就叫悬垂引用(或悬垂指针)。

Rust 的承诺是:

编译器保证引用不会变成悬垂引用。

例如:

fn dangle() -> &String {
    let s = String::from("hello");
    &s
}

这段代码不能通过编译,因为:

  • s 是在函数内部创建的;
  • 函数返回时 s 会被丢弃;
  • 返回 &s 就会把一个指向已释放对象的引用交给外部。

Rust 直接在编译阶段禁止这种写法。

编译器甚至会提示:

  • 要么显式说明生命周期(这里只是提示,不是正确修复方式);
  • 更合理的是直接返回拥有所有权的 String

因此正确思路通常是:

fn no_dangle() -> String {
    let s = String::from("hello");
    s
}

也就是返回所有权,而不是返回指向局部值的引用


18. Slice:对集合局部区域的借用

18.1 什么是 Slice

Slice(切片)允许你:

  • 引用集合中的一段连续元素;
  • 而不是引用整个集合;
  • 并且不取得该集合的所有权。

本质上,slice 是一种特殊的引用。

它解决的问题是:

  • 我只想借用“部分数据”;
  • 不想复制它;
  • 也不想把“部分数据”的信息和原对象分离开。

19. 为什么“返回索引”不是好 API

本节先从一个小练习入手:

写一个函数,接收一个由空格分隔单词的字符串,返回第一个单词。

如果不用 slice,最容易想到的方式是返回空格索引:

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();
    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }
    s.len()
}

这段代码的思路是:

  • String 转成字节数组;
  • iter() 创建迭代器;
  • enumerate() 同时拿到索引和值;
  • 发现空格就返回其位置;
  • 没发现空格就返回整个字符串长度。

这个实现本身没错,但 API 设计有问题:

  • 它返回的是一个 usize 索引;
  • 索引本身和原始字符串没有绑定关系;
  • 一旦原字符串变化,这个索引就可能失效。

这说明:

“位置”不是“借用到的那一段数据”。


20. 字符串 Slice 的基本语法

20.1 区间语法

可以通过区间语法引用字符串的一部分:

let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];

helloworld 都不是新的字符串对象,而是对 s 的一部分的借用。

从实现角度看,一个字符串 slice 会记录:

  • 起始位置;
  • 长度。

因此它仍然与原始字符串绑定在一起。

20.2 语法简写

Rust 为区间写法提供了简写:

let slice = &s[0..2];
let slice = &s[..2];

表示从开头到索引 2 前。

let slice = &s[3..len];
let slice = &s[3..];

表示从索引 3 到结尾。

let slice = &s[0..len];
let slice = &s[..];

表示整个字符串的切片。

这些简写在日常开发中非常常见。

20.3 UTF-8 边界限制

字符串 slice 的索引必须落在有效的 UTF-8 字符边界上

这非常重要。

因为 Rust 的字符串是 UTF-8 编码的:

  • 一个字符不一定只占 1 个字节;
  • 如果从某个多字节字符的中间切开;
  • 就会得到无效的 UTF-8。

因此 Rust 不允许这样的 slice,并会在运行时因错误而退出。

本章为了讲清概念,示例默认按 ASCII 来理解;更完整的 UTF-8 讨论放在第八章。


21. 用 Slice 重写 first_word

有了 slice 之后,first_word 可以改写成真正返回“第一个单词本身”:

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

这里返回类型是 &str,也就是字符串 slice。

相比返回 usize

  • 它直接表达“借用到的那段字符串”;
  • 它和原始数据有生命周期关系;
  • 编译器可以据此检查其有效性;
  • 因此更安全、更自然。

22. Slice 为什么能消灭“索引失效”问题

先看旧设计的问题:

  • 先调用 first_word 拿到单词结束索引;
  • 再修改原字符串;
  • 索引仍然存在,但语义已经失效。

而使用 slice 版本后,这类错误会被提前阻止。

例如:

let mut s = String::from("hello world");
let word = first_word(&s);
s.clear();
println!("{word}");

这会编译失败。原因是:

  • word 是对 s 的不可变借用;
  • clear() 需要对 s 进行可变借用;
  • Rust 不允许二者重叠。

于是编译器直接报错,而不是等到运行时再让 bug 暴露。

这正是 slice 的真正价值:

不仅 API 更自然,而且编译器可以借此更早地发现逻辑错误。


23. 字符串字面值本质上就是 Slice

现在可以更准确地理解:

let s = "Hello, world!";

这里 s 的类型其实是:

&str

也就是说,字符串字面值本质上就是一个字符串 slice:

  • 它引用的是程序二进制中的一段固定字符串数据;
  • 它不是拥有型字符串;
  • 它天然不可变。

这也解释了为什么字符串字面值不能像 String 那样增长、修改。


24. 为什么 &str&String 更好的参数类型

虽然我们刚才把 first_word 改成了:

fn first_word(s: &String) -> &str

但这还不是最通用的写法。更地道的写法是:

fn first_word(s: &str) -> &str

这样做的好处是:

  • &String 可以传进来;
  • String 的整个 slice 可以传进来;
  • 字符串字面值 &str 也可以直接传进来;
  • API 更通用;
  • 不损失任何能力。

例如下面这些都可以:

let my_string = String::from("hello world");
first_word(&my_string[0..6]);
first_word(&my_string[..]);
first_word(&my_string);

let my_string_literal = "hello world";
first_word(&my_string_literal[0..6]);
first_word(&my_string_literal[..]);
first_word(my_string_literal);

所以在 Rust 中,一个很重要的 API 设计思想是:

如果函数只需要“看作字符串的一段内容”,优先把参数写成 &str,而不是 &String

这是更通用、更灵活的接口设计。


25. 其他类型的 Slice

Slice 不只适用于字符串,也适用于其他集合。

例如数组:

let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
assert_eq!(slice, &[2, 3]);

这里的类型是:

&[i32]

它与字符串 slice 的工作方式相同:

  • 引用底层集合的一部分;
  • 不拥有数据;
  • 记录起始位置和长度;
  • 保持与原集合的有效性约束。

这说明 slice 是一个更一般的概念:

  • &str 是字符串切片;
  • &[T] 是通用数组/序列切片。

26. 本章最重要的逻辑链

可以把第 4 章压缩成一条完整逻辑链:

  1. Rust 需要管理堆内存;
  2. 为了不依赖 GC 仍保证安全,Rust引入所有权;
  3. 所有权规定一个值同一时刻只能有一个所有者;
  4. 对堆数据做赋值时,默认发生 move;
  5. 如果要真正复制堆数据,必须显式 clone
  6. 为了不在读操作里频繁转移所有权,Rust提供引用与借用;
  7. 借用又被严格区分为不可变借用和可变借用;
  8. 借用规则防止数据竞争和悬垂引用;
  9. slice 是一种更精细的借用,它把“局部视图”和“底层数据有效性”绑定在一起;
  10. Rust 借此在编译期阻止大量内存与并发错误。

27. 本章易错点与高频误区

27.1 误区:赋值总是在复制值

不对。

  • i32 这类 Copy 类型,赋值通常是复制;
  • String 这类堆数据类型,赋值默认是 move。

27.2 误区:借用就是把对象“复制一份”

不对。

借用只是创建引用,不复制对象,也不取得所有权。

27.3 误区:不可变引用只是“我不想改”,但别人可以改

不对。

只要某值当前存在不可变引用,Rust 就会阻止同时出现可变引用。

27.4 误区:可变引用只要写 &mut 就够了

不对。

原对象本身也必须是 mut

27.5 误区:字符串的一部分可以用整数索引随便切

不对。

Rust 的字符串是 UTF-8 编码,切片必须位于合法字符边界。

27.6 误区:返回索引和返回切片本质一样

不对。

  • 索引只是一个数字;
  • 切片是和底层数据绑定的借用;
  • 切片能被编译器检查有效性,索引不能。

28. 本章精华总结

28.1 所有权不是限制,而是资源管理协议

Rust 的所有权规则并不是“故意为难开发者”,而是在用更严格的静态规则换取:

  • 内存安全;
  • 并发安全;
  • 资源释放时机确定;
  • 性能可预测。

28.2 move 语义是 Rust 性能模型的一部分

Rust 不默认做昂贵深拷贝,而是默认 move。这样:

  • 安全性由编译器保障;
  • 复制成本由程序员显式控制;
  • 性能语义更加透明。

28.3 引用的核心不在“语法”,而在“约束”

Rust 的引用之所以强大,不是因为有 &&mut,而是因为:

  • 它们背后有明确借用规则;
  • 编译器能检查这些规则;
  • 这些规则直接防止了数据竞争和悬垂引用。

28.4 Slice 是 Rust API 设计的一个典型思想

Slice 展示了 Rust 很典型的一种设计倾向:

  • 尽量让接口表达真实语义;
  • 尽量把错误暴露在类型层面;
  • 尽量把错误检查提前到编译期。

29. 一句话总括第 4 章

第 4 章的本质,是通过所有权、借用和切片三组机制,把“谁拥有数据、谁能访问数据、访问能持续多久、局部视图是否仍然有效”这些问题全部交给编译器检查,从而在不牺牲性能的前提下获得内存安全。