2026最新Rust入门教程[04]:理解所有权

100 阅读33分钟

Rust Book 编译版 04 · 理解所有权:Rust 最独特的内存安全模型

编译自 The Rust Programming Language(2024 edition),在原文基础上做了删减与补充。© The Rust Project Developers,依据 MIT OR Apache-2.0 许可编译转载。

开始之前

原书对这一章的定位毫不客气:可能是全书最重要的一章。这并没有夸大——Rust 没有垃圾回收器,也不让你手动 free,内存完全靠一套编译期规则来管,这套规则就是所有权(ownership)。它是 Rust 最与众不同的特性,对语言其余部分影响深远:函数怎么传参、集合怎么存数据、并发怎么写得安全,全都依赖这一章的概念。学不会它,后面每一章都会卡住。

本章围绕最常见的字符串,依次讲所有权、借用(borrowing)、切片(slice),以及 Rust 如何在内存中排布数据。

前置知识:第 3 章「通用编程概念」(变量与数据类型、函数、控制流)。

你会学到:

  • 用三条所有权规则推断一段代码能否编译:值有唯一所有者、赋值即移动、离开作用域自动清理
  • 分清栈与堆的分工,看懂 String「指针 + 长度 + 容量」的内存布局
  • 判断值何时被移动(move)、何时被隐式复制、何时必须显式 clone
  • 用引用与借用在不夺取所有权的前提下访问数据,记住借用检查器的两条铁律
  • 用切片(&str、&[T])引用数据中连续的一段,写出更通用的 API

什么是所有权?

所有权是一套规则,规定了 Rust 程序如何管理内存。所有程序都得管理内存,主流方案有两种:垃圾回收语言在运行时定期查找、回收不再使用的内存;无 GC 的语言把分配和释放都交给程序员。Rust 走第三条路:内存由一套所有权体系管理,编译器检查其中的规则,违反任何一条就拒绝编译。关键在于,这些检查全部发生在编译期,不会让程序在运行时变慢一丝一毫。

所有权对多数程序员是新概念,需要时间适应;好消息是,对规则越熟练,写出安全高效的代码就越自然。下面先补一点内存布局的背景,再进入规则本身。

栈与堆

许多语言不需要你经常考虑栈和堆,但在 Rust 这样的系统语言里,一个值在栈上还是堆上,会影响语言的行为、决定你不得不做的选择。栈和堆都是运行时供代码使用的内存,组织方式不同。

栈按顺序存值、反序移除,即后进先出(last in, first out,LIFO),像一摞盘子:只能放摞顶、也只能从摞顶拿。向栈添加数据叫入栈(pushing onto the stack),移除叫出栈(popping off the stack)。栈上存储的数据必须大小已知且固定;编译时大小未知、或大小可能变化的数据,必须放在堆上。

堆的组织性差一些:把数据放到堆上,你要申请一定量的空间,内存分配器找到一块足够大的空位、标记为已使用,返回一个指针(pointer),也就是那个位置的地址。这个过程叫在堆上分配(allocating on the heap),简称分配(把值压入栈不算分配)。指针本身大小固定,可以存在栈上;但要拿实际数据,必须顺着指针走。

性能上有两条差异。其一,入栈比堆分配快——栈的位置永远是栈顶,分配器不必寻找空位;堆分配则要先找到足够大的空间,再做记录工作为下次分配做准备。其二,访问堆数据比栈数据慢——你得顺着指针走,而数据在内存里越分散,处理器跑得越慢。

函数调用时,传入的值(可能包括指向堆数据的指针)和局部变量都会压栈,函数结束随之出栈。而记录堆上哪些数据还在使用、尽量减少堆上的重复数据、清理不再使用的堆数据——这些正是所有权要解决的问题。学完本章你不必再频繁想起栈和堆,但记住所有权的主要目标是管理堆数据,能解释它为什么以现在这种方式运作。

编译注: 这一节说的都是 C 系语言的常识:局部变量随函数调用在栈帧里自动进出,malloc/new 出来的东西放堆上、要人肉 free/delete。写过 C/C++ 的人对「栈快堆慢、栈上大小必须编译期已知」应该有体感。Rust 的不同之处在于:堆数据的生命周期也由编译器保证——怎么做到的,马上揭晓。

所有权规则

先亮出规则:

  • Rust 中的每一个值都有一个所有者(owner)。
  • 同一时刻只能有一个所有者。
  • 所有者离开作用域时,这个值将被丢弃。

变量作用域

从本章起,示例不再包含完整的 fn main() {——跟着动手的话,请自行把代码放进 main 函数,好让注意力集中在细节而不是样板代码上。

作用域(scope)指一个项在程序内有效的范围。看这个变量:

let s = "hello";

变量 s 指向一个字符串字面量(string literal),值被硬编码进程序。示例 4-1 用注释标出了 s 在哪些地方有效:

{                      // s 在这里无效,因为它尚未声明
    let s = "hello";   // 从这里开始,s 有效

    // 使用 s 做些事情
}                      // 作用域到此结束,s 不再有效

示例 4-1:一个变量和它有效的作用域

要点就两条:s 从进入作用域时起有效,直到离开作用域前一直有效。到目前为止,作用域与变量有效性的关系和其他语言没有区别——差别从 String 类型开始。

String 类型

第 3 章讲过的类型大小都已知,可以存放在栈上,作用域结束时从栈上弹出;另一处代码要用同一个值,快速复制一份独立实例即可。但要考察 Rust 如何知道该在何时清理堆上的数据,需要一种真正存储在堆上的类型——String 正是绝佳例子。我们只关注它和所有权相关的部分,这些内容同样适用于标准库或你自己创建的其他复杂数据类型;String 的其余方面留到第 8 章《常用集合》再谈。

字符串字面量方便,但不适合所有场景:它不可变,而且并非所有字符串在写代码时就知道(比如要接收并保存用户输入)。为此 Rust 提供 String:管理分配在堆上的数据,能存编译时大小未知的文本。用 from 从字面量创建一个:

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

这里的双冒号(::)类似C++中的域限定符,不过 Rust 官方可没有这么叫,作用是把from 挂在 String 类型名下,Rust 不需要 string_from 这类全局函数名。像 from 这样直接定义在类型本身上的函数,称为关联函数(associated function),第 5 章讲方法时会专门细说。

这种字符串可以修改:

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

s.push_str(", world!"); // push_str() 向 String 追加一个字面量

println!("{s}"); // 这会打印 `hello, world!`

那么区别在哪?为什么 String 能改而字面量不能?答案就在这两种类型处理内存的方式里。

内存与分配

字符串字面量在编译时就已知内容,直接硬编码进可执行文件——这是它快速高效的原因,但这份高效恰恰来自不可变:我们没法为每段编译时大小未知、运行中还可能增长的文本都在二进制里预留一块内存。

String 支持可修改、可增长的文本,就要在堆上分配一块编译时大小未知的内存。这意味着两件事:

  • 必须在运行时向内存分配器申请内存。
  • 用完 String 之后,需要有一种办法把这块内存归还给分配器。

第一件事由 String::from 的实现完成,各种语言大同小异。第二件事就分野了:在带垃圾回收器(garbage collector,GC)的语言里,GC 追踪并清理不再使用的内存,我们不用操心;而在大多数没有 GC 的语言里,识别内存何时不再使用、调用代码显式释放,是程序员自己的责任。这件事历来容易做错:忘了释放浪费内存,释放得太早得到无效变量,释放两次同样是 bug——理想状态是每一次 allocate 恰好对应一次 free,而人肉保证这一点很难。

Rust 另辟蹊径:内存在拥有它的变量离开作用域后自动归还。把示例 4-1 换成 String 版本:

{
    let s = String::from("hello"); // 从这里开始,s 有效

    // 使用 s 做些事情
}                                  // 作用域到此结束,s 不再
                                   // 有效

时机很自然:s 离开作用域的右花括号处,Rust 自动替我们调用一个特殊函数 drop,String 的作者把归还内存的代码放在这里。

提示: 在 C++ 中,这种在某项生命周期结束时释放资源的模式有时被称为资源获取即初始化(Resource Acquisition Is Initialization,RAII)。如果你用过 RAII 模式,就会对 Rust 的 drop 函数感到熟悉。

这个模式对 Rust 代码的写法影响深远。眼下它看起来简单,但一旦多个变量想使用同一份堆数据,行为就会出人意料——这正是接下来几节的主题。

变量与数据交互:移动

在 Rust 中,多个变量可以用不同方式与"同一份"数据交互。示例 4-2 先用整数开场:

let x = 5;
let y = x;

示例 4-2:把变量 x 的整数值赋给 y

猜测与事实一致:把 5 绑定到 x,再复制一份绑定到 y,两个变量都等于 5。整数是大小已知且固定的简单值,两个 5 都压在栈上。

再看 String 版本:

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

看着非常相似,但第二行并没有把 s1 的值复制一份给 s2。看图 4-1,String 由三部分组成:一个指向存放字符串内容的内存的指针、一个长度、一个容量。这组数据在栈上;右侧是堆上存放的内容。

image.png

图 4-1:绑定到 s1、值为 "hello" 的 String 在内存中的表示

长度是内容当前占用的字节数,容量是从分配器获得的内存总量(差值有意义,这里先忽略容量也无妨)。

把 s1 赋给 s2,复制的是栈上的指针、长度和容量,不会复制指针指向的堆数据。内存中的样子如图 4-2 所示。

image.png

图 4-2:变量 s2 复制了 s1 的指针、长度和容量之后在内存中的表示

而不是图 4-3 那样连堆数据也一并复制——真要那样,堆数据一旦很大,s2 = s1 的运行时开销就可能非常高。

image.png

图 4-3:如果 Rust 也复制堆上的数据,s2 = s1 可能产生的另一种结果

但图 4-2 有麻烦:前面说过,变量离开作用域时 Rust 会自动调用 drop 清理堆内存,而这里两个指针指向了同一个位置。s2 和 s1 各自尝试释放同一块内存,这就是二次释放(double free)错误,可能导致内存损坏、进而引发安全漏洞。

为了保证内存安全,let s2 = s1; 之后,Rust 直接把 s1 视为不再有效,s1 离开作用域时无需释放任何东西。不信邪的话,创建 s2 之后用用 s1——行不通:

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

println!("{s1}, world!");

你会得到一个类似这样的错误,因为 Rust 阻止你使用已失效的引用:

cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:16
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{s1}, world!");
  |                ^^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let s2 = s1.clone();
  |                ++++++++

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

如果你听说过浅拷贝(shallow copy)和深拷贝(deep copy),「复制指针、长度和容量而不复制数据」听起来很像浅拷贝;但 Rust 还会让第一个变量失效,所以它不叫浅拷贝,而叫移动(move)。我们说 s1 被移动到了 s2,实际情形如图 4-4。

编译注: 这就是 C++11 移动语义的近亲——std::move 加移动构造。关键差别在强制力:C++ 的移动靠约定,被移动的对象处于「有效但未指定」的状态,move 完接着用编译器不拦,用出 use after free 是自己的事;Rust 把移动做进类型系统。

image.png

图 4-4:s1 失效之后的内存表示

问题解决:只有 s2 有效,由它独自释放内存。这里还藏着一条设计取舍:Rust 绝不自动为你的数据创建「深」拷贝,因此任何自动进行的复制都可以认为在运行时性能上是廉价的;昂贵的事必须显式写出来——那就是下一节的 clone。

作用域与赋值

为已有变量赋一个全新的值时,Rust 会立即对原值调用 drop、释放其内存。看这段代码:

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

println!("{s}, world!");

s 先绑定到值为 "hello" 的 String,随后一个值为 "ahoy" 的新 String 整体替换它——此刻已经没有任何东西引用堆上的原值了。图 4-5 展示了此时栈和堆上的数据:

image.png

图 4-5:初始值被整体替换之后的内存表示

原始字符串立即离开作用域,drop 执行、内存马上释放。最后打印这个值,输出是 "ahoy, world!"。

变量与数据交互:克隆

确实要深度复制 String 的堆数据时,用常见方法 clone:

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

println!("s1 = {s1}, s2 = {s2}");

这段代码完全可行,显式产生了图 4-3 所示的行为,堆上的数据确实被复制了。看到 clone 调用,你就知道这里要执行可能开销不菲的代码。

只在栈上的数据:Copy

还有一个没交代的小问题。下面这段使用整数的代码(一部分已在示例 4-2 出现过)是有效且能正常工作的:

let x = 5;
let y = x;

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

它似乎与刚学的相矛盾:没调 clone,x 却依然有效,没有被移动进 y。原因在于,整数这类编译时大小已知的类型完全存储在栈上,复制实际值非常快——这种情况下深拷贝与浅拷贝没有区别,让 x 失效毫无意义。

Rust 用 Copy trait (特征) 标注这类类型(trait 详见第 10 章《泛型、trait 与生命周期》):实现了 Copy 的类型,变量赋值时被简单地复制而不是移动,原变量在赋值后依然有效。反过来,如果一个类型自身或它的任何部分实现了 Drop trait,Rust 就不允许它再标 Copy——离开作用域时需要特殊清理的值,不允许被隐式复制,违反了直接编译期报错。如何为自己的类型添加 Copy 标注,见附录 C。

编译注: 可以把 Copy/Drop 对照着 C++ 记:C++ 的拷贝构造函数和析构函数写在同一个类里,深拷贝还是浅拷贝全凭作者自觉,写错了也要运行时才炸;Rust 把「赋值时是复制还是移动」和「离开作用域要不要清理」拆成两个 trait,并且禁止二者同时实现——需要清理的东西绝不隐式复制。组合规则在编译期定死。

一般规则是:简单标量值的组合可以实现 Copy,需要分配内存或本身就是某种资源的东西不能。下面是一些实现了 Copy 的类型:

  • 所有整数类型,比如 u32。
  • 布尔类型 bool,取值为 true 和 false。
  • 所有浮点类型,比如 f64。
  • 字符类型 char。
  • 元组,前提是它只包含同样实现了 Copy 的类型。例如,(i32, i32) 实现了 Copy,而 (i32, String) 没有。

所有权与函数

向函数传值的机制与给变量赋值相似:传值同样会发生移动或复制。示例 4-3 的注释标明了变量进入和离开作用域的位置:

fn main() {
    let s = String::from("hello");  // s 进入作用域

    takes_ownership(s);             // s 的值移动进函数……
                                    // ……所以在这里不再有效

    let x = 5;                      // x 进入作用域

    makes_copy(x);                  // 因为 i32 实现了 Copy trait,
                                    // x 不会移动进函数,
                                    // 之后再使用 x 也没问题。

} // 这里,x 先离开作用域,然后是 s。不过 s 的值已被移动,
  // 不会发生什么特别的事情。

fn takes_ownership(some_string: String) { // some_string 进入作用域
    println!("{some_string}");
} // 这里,some_string 离开作用域并调用 `drop`。
  // 其占用的内存被释放。

fn makes_copy(some_integer: i32) { // some_integer 进入作用域
    println!("{some_integer}");
} // 这里,some_integer 离开作用域。没有特别的事情发生。

示例 4-3:标注了所有权与作用域的函数

如果在调用 takes_ownership 之后尝试使用 s,Rust 会抛出一个编译期错误——这些静态检查替我们挡住了失误。建议动手试试:在 main 里添加使用 s 和 x 的代码,看看哪些地方能用、哪些地方被所有权规则拦下。

返回值与作用域

返回值同样可以转移所有权。示例 4-4 的注释与示例 4-3 类似:

fn main() {
    let s1 = gives_ownership();        // gives_ownership 将其返回值
                                       // 移动到 s1

    let s2 = String::from("hello");    // s2 进入作用域

    let s3 = takes_and_gives_back(s2); // s2 被移动进
                                       // takes_and_gives_back,
                                       // 它又把返回值移动到 s3
} // 这里,s3 离开作用域并被丢弃。s2 已被移动,所以什么
  // 都不会发生。s1 离开作用域并被丢弃。

fn gives_ownership() -> String {       // gives_ownership 会把它的
                                       // 返回值移动给
                                       // 调用它的函数

    let some_string = String::from("yours"); // some_string 进入作用域

    some_string                        // some_string 被返回并
                                       // 移动到
                                       // 调用函数
}

// 这个函数接收一个 String 并返回一个 String。
fn takes_and_gives_back(a_string: String) -> String {
    // a_string 进入
    // 作用域

    a_string  // a_string 被返回并移动到调用函数
}

示例 4-4:转移返回值的所有权

模式始终一致:把值赋给另一个变量就移动它;持有堆数据的变量离开作用域时由 drop 清理,除非所有权已被移走。这样可行,但未免繁琐——如果只是想让函数使用一个值、并不想交出所有权呢?每传入一个东西都得再传回来才能继续用,实在恼人。一个笨办法是用元组(tuple)把多个值一起返回,如示例 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(); // len() 返回 String 的长度

    (s, length)
}

示例 4-5:返还参数的所有权

为一个稀松平常的概念搞这套仪式,太费事。幸运的是,Rust 提供了一种无须转移所有权就能使用值的功能:引用。

引用与借用

示例 4-5 的问题在于:String 被移进了 calculate_length,必须原样返回才能继续使用。更好的做法是传一个指向该 String 值的引用(reference)。引用类似指针:它是一个可以循着访问数据的地址,数据本身由其他变量拥有;与指针不同的是,引用保证在其存续期间始终指向某个特定类型的有效值。

用引用改写 calculate_length:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

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

元组代码全都不见了:调用处传的是 &s1,函数定义里参数类型取 &String 而非 String。这些 & 就是引用,让你可以引用一个值而不取得其所有权。图 4-6 描绘了这一概念。

image.png

图 4-6:&String 类型的 s 指向 String 类型的 s1 的示意图

提示: 与用 & 建立引用相对的操作是解引用(dereferencing),通过解引用运算符 * 完成。第 8 章会看到它的一些用法,第 15 章《智能指针》详细讨论。

拆开看这次调用:

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

let len = calculate_length(&s1);

&s1 语法创建一个引用 s1 的值,它并不拥有 s1——正因为不拥有,引用失效时它指向的值也不会被丢弃。函数签名里的 & 表示参数 s 的类型是引用,加上注释看:

fn calculate_length(s: &String) -> usize { // s 是对一个 String 的引用
    s.len()
} // 这里,s 离开作用域。但因为 s 并不拥有它所引用的东西,
  // 这个 String 不会被丢弃。

s 的作用域与其他任何函数参数相同,但因为 s 不拥有所引用的数据,它离开作用域时 String 不会被丢弃——我们从未取得所有权,自然无需归还。

把创建引用的行为称为借用(borrowing):正如现实生活中,某样东西归某人所有,你可以向他借,用完归还——它并不属于你。

那么,修改借来的东西行不行?试试示例 4-6 的代码。剧透一下:行不通!

fn main() {
    let s = String::from("hello");

    change(&s);
}

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

示例 4-6:尝试修改借用的值

错误如下:

cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
 --> src/main.rs:8:5
  |
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^ `some_string` is a `&` reference, so it cannot be borrowed as mutable
  |
help: consider changing this to be a mutable reference
  |
7 | fn change(some_string: &mut String) {
  |                         +++

For more information about this error, try `rustc --explain E0596`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

正如变量默认不可变一样,引用也默认不可变——不允许修改通过引用指向的东西。

可变引用

只需几处小改动,把示例 4-6 换成可变引用(mutable reference),就能让它修改借来的值:

fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

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

三处改动:s 声明成 mut;调用处用 &mut s 创建可变引用;签名改成接收 some_string: &mut String。这样一眼可见:change 会修改它借用的值。

可变引用有一条很大的限制:对一个值,持有指向它的可变引用期间,就不能再持有任何其他指向它的引用。试图创建两个指向 s 的可变引用会失败:

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

let r1 = &mut s;
let r2 = &mut s;

println!("{r1}, {r2}");

错误如下:

cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> src/main.rs:5:14
  |
4 |     let r1 = &mut s;
  |              ------ first mutable borrow occurs here
5 |     let r2 = &mut s;
  |              ^^^^^^ second mutable borrow occurs here
6 |
7 |     println!("{r1}, {r2}");
  |                -- first borrow later used here

For more information about this error, try `rustc --explain E0499`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

同一时刻不能把 s 多次借用为可变:r1 的可变借用发生在创建处,且必须持续到它在 println! 中被使用为止;这中间 r2 又想创建另一个可变引用、借用同一份数据,被编译器拒绝。

「同一份数据同一时刻不得存在多个可变引用」确实允许修改,但方式受到非常严格的控制。Rust 新手常对此感到棘手,因为大多数语言允许你想改就改。这条限制的回报是:Rust 能在编译期就防止数据竞争(data race)。数据竞争在以下三种行为同时发生时出现:

  • 两个或更多指针同时访问同一份数据。
  • 至少有一个指针在被用来向数据写入。
  • 没有任何用来同步数据访问的机制。

数据竞争会导致未定义行为,运行时追踪、诊断、修复都很困难;Rust 直接拒绝编译带有数据竞争的代码,从根上杜绝了这个问题。

和往常一样,可以用花括号创建新作用域,从而允许多个可变引用先后存在,只是不能同时:

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

{
    let r1 = &mut s;
} // r1 在这里离开作用域,所以我们可以毫无问题地创建新引用。

let r2 = &mut s;

可变引用与不可变引用的混用,执行类似的规则。下面这段代码会报错:

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

let r1 = &s; // 没问题
let r2 = &s; // 没问题
let r3 = &mut s; // 大问题

println!("{r1}, {r2}, and {r3}");

错误如下:

cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
4 |     let r1 = &s; // no problem
  |              -- immutable borrow occurs here
5 |     let r2 = &s; // no problem
6 |     let r3 = &mut s; // BIG PROBLEM
  |              ^^^^^^ mutable borrow occurs here
7 |
8 |     println!("{r1}, {r2}, and {r3}");
  |                -- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

持有不可变引用时,同样不能持有可变引用——不可变引用的使用者可不希望值在自己眼皮底下突然变掉。多个不可变引用则允许,因为只读数据的任何一方都无法影响别人读取。

注意,引用的作用域从引入处开始,一直延续到最后一次使用的地方。这段代码能够编译,因为不可变引用最后一次使用(那个 println!)位于可变引用引入之前:

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

let r1 = &s; // 没问题
let r2 = &s; // 没问题
println!("{r1} and {r2}");
// 变量 r1 和 r2 在此之后不会再被使用。

let r3 = &mut s; // 没问题
println!("{r3}");

r1 和 r2 的作用域在最后一次使用后结束,与 r3 的作用域互不重叠——编译器能判断出引用在作用域结束前已经不再被使用,所以放行。

悬垂引用

在有指针的语言中,很容易错误地创建悬垂指针(dangling pointer)——指向某块可能已被另作他用的内存的指针:释放内存,却保留指向它的指针即可。相比之下,Rust 编译器保证引用永远不会成为悬垂引用(dangling reference):只要你持有对某些数据的引用,编译器就确保这些数据不会在引用之前离开作用域。试着造一个,看 Rust 如何用编译期错误拦住:

fn main() {
    let reference_to_nothing = dangle();
}

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

    &s
}

错误如下:

cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
 --> src/main.rs:5:16
  |
5 | fn dangle() -> &String {
  |                ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
  |
5 | fn dangle() -> &'static String {
  |                 +++++++
help: instead, you are more likely to want to return an owned value
  |
5 - fn dangle() -> &String {
5 + fn dangle() -> String {
  |

For more information about this error, try `rustc --explain E0106`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

这条错误信息提到了一个尚未讲到的特性:生命周期(lifetime),第 10 章详谈。先撇开那部分,信息里道破问题关键的其实是这一句:

this function's return type contains a borrowed value, but there is no value
for it to be borrowed from

逐阶段看看 dangle 里到底发生了什么:

fn dangle() -> &String { // dangle 返回对一个 String 的引用

    let s = String::from("hello"); // s 是一个新的 String

    &s // 我们返回对 String s 的引用
} // 这里,s 离开作用域并被丢弃,它的内存随之消失。
  // 危险!

s 在 dangle 内部创建,函数代码结束时就被释放;我们却试图返回对它的引用,这个引用将指向一个无效的 String。这可不行,Rust 不会让我们这么做。解决办法是直接把 String 返回:

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

    s
}

这样毫无问题:所有权被移出,也没有任何东西被释放。

引用的规则

回顾本章关于引用的两条规则:

  • 在任意给定时刻,你要么只能拥有一个可变引用,要么可以拥有任意多个不可变引用。
  • 引用必须始终有效。

接下来看另一种引用:切片。

切片类型

切片(slice)让你可以引用集合中一段连续的元素序列。切片是一种引用,因此它并不拥有所有权。

来做个小编程问题:编写一个函数,接收一个以空格分隔单词的字符串,返回找到的第一个单词;如果找不到空格,整个字符串必然是一个单词,返回整个字符串。

提示: 为了介绍切片,本节假定字符串只包含 ASCII 字符;关于 UTF-8 处理的更全面讨论,见第 8 章的「使用 String 存储 UTF-8 编码的文本」一节。

先想想不用切片时这个函数的签名该怎么写,才能看清切片解决的问题:

fn first_word(s: &String) -> ?

参数取 &String、不夺取所有权,没问题(地道的 Rust 中,函数除非确有必要不会夺取参数的所有权,个中缘由随着本章推进会逐渐清晰)。但返回值该是什么?我们没办法表达「字符串的一部分」,只能返回由空格指示的单词结尾处的索引,如示例 4-7:

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()
}

示例 4-7:返回 String 参数中字节索引值的 first_word 函数

拆开看。先用 as_bytes 方法把 String 转换成字节数组,以便逐个检查元素:

let bytes = s.as_bytes();

再用 iter 方法在字节数组上创建一个迭代器(iterator):

for (i, &item) in bytes.iter().enumerate() {

iter 逐个返回集合中的元素;enumerate 包装它的结果,把每个元素放进一个 (索引, 元素引用) 的元组返回(迭代器详见第 13 章《函数式语言特性:迭代器与闭包》)。由于返回的是元组,可以用模式(pattern)解构(详见第 6 章《枚举与模式匹配》):i 接收索引,&item 接收单个字节——元素以引用形式从 .iter().enumerate() 得到,所以模式中要用 &。

此处若不懂迭代器和元组模式匹配解构可以继续看下去,看完教程大概就懂了。

for 循环体内用字节字面量语法(可以前往标量复习)查找空格的字节,找到就返回其位置;否则用 s.len() 返回字符串长度:

    if item == b' ' {
        return i;
    }
}

s.len()

现在能找到字符串中第一个单词结尾的索引了,但存在问题:返回的是一个孤零零的 usize,它只在 &String 的语境下才是有意义的数字。它与 String 本身分离,没有任何东西保证它将来仍然有效。看看示例 4-8 这个使用示例 4-7 中 first_word 的程序:

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s); // word 会得到值 5

    s.clear(); // 这会清空 String,使其等于 ""

    // 此时 word 的值仍是 5,但 s 已经没有任何内容能和值 5 有意义地
    // 配合使用了,所以 word 现在完全无效了!
}

示例 4-8:保存调用 first_word 函数的结果,然后修改 String 的内容

这个程序毫无错误地编译通过,就算 s.clear() 之后再使用 word 也照样编译——word 与 s 的状态毫无关联,始终保存着 5。可拿这个 5 去配合已清空的 s 提取第一个单词,就是一个 bug:存下 5 之后,s 的内容早就变了。

幸运的是,Rust 对这个问题有解:字符串切片。

字符串切片

字符串切片(string slice)是对 String 中一段连续元素的引用,写起来像这样:

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

let hello = &s[0..5];
let world = &s[6..11];

hello 不是对整个 String 的引用,而是对 String 一部分的引用——由额外的 [0..5] 指定。方括号里写 [starting_index..ending_index] 这样的范围:starting_index 是切片的第一个位置,ending_index 比切片最后一个位置大 1。内部实现上,切片数据结构存储的是起始位置和长度(长度等于结束索引减起始索引);因此 let world = &s[6..11]; 的 world 是「指向 s 索引 6 处字节的指针 + 长度 5」。图 4-7 展示了这一点。

image.png

图 4-7:指向 String 一部分的字符串切片

使用 .. 范围语法时,两端可以省略:从索引 0 开始可省略前面的值,包含最后一个字节可省略末尾的值,两端全省就是整个字符串。三组等价写法:

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

let slice = &s[0..2];
let slice = &s[..2];
let s = String::from("hello");

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..];
let s = String::from("hello");

let len = s.len();

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

提示: 字符串切片的范围索引必须落在有效的 UTF-8 字符边界上。如果试图在多字节字符中间创建字符串切片,程序会报错退出。

有了这些知识,把 first_word 改写成返回切片的版本。表示「字符串切片」的类型写作 &str:

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[..]
}

求单词结尾索引的方式与示例 4-7 相同:找到第一个空格,就用字符串开头和空格索引作为起止,返回一个字符串切片;没找到就返回 &s[..]。现在调用 first_word,拿回的是一个与底层数据绑定在一起的值:指向切片起点的引用 + 切片中的元素个数。

second_word 返回切片同样可行:

fn second_word(s: &String) -> &str {

现在我们有了一套简单直白、且很难用错的 API,因为编译器会确保指向 String 的引用始终有效。还记得示例 4-8 的 bug 吗——拿到第一个单词结尾的索引后清空了字符串,索引随之失效。那段代码逻辑上并不正确,却毫无怨言地编译通过,要等我们拿着失效索引去操作空字符串时才暴露。切片让这种 bug 无从发生:使用返回切片版本的 first_word,同样的代码会直接抛出编译期错误:

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s);

    s.clear(); // 错误!

    println!("the first word is: {word}");
}

编译器错误如下:

cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
  --> src/main.rs:18:5
   |
16 |     let word = first_word(&s);
   |                           -- immutable borrow occurs here
17 |
18 |     s.clear(); // error!
   |     ^^^^^^^^^ mutable borrow occurs here
19 |
20 |     println!("the first word is: {word}");
   |                                   ---- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

对照借用规则:clear 要截断 String,需要拿到可变引用;而 clear 之后的 println! 还要用 word 里的引用,说明那个不可变引用届时必须仍然有效。两者不能同时存在,编译失败。Rust 不仅让 API 更好用,还在编译期消灭了一整类错误。

字符串字面量就是切片

回忆一下,字符串字面量存储在二进制文件内部。学了切片,现在可以正确理解它了:

let s = "Hello, world!";

这里 s 的类型是 &str:一个指向二进制文件中那个特定位置的切片。这也解释了字符串字面量为什么不可变——&str 是不可变引用。

字符串切片作为参数

知道了字面量和 String 值都能取切片,first_word 还能做最后一处改进——它的签名:

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

更有经验的 Rustacean 会写成示例 4-9 所示的签名,让同一个函数既用于 &String 值,也用于 &str 值:

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

示例 4-9:用字符串切片作 s 参数的类型,改进 first_word 函数

手上是字符串切片,直接传入;手上是 String,传它的切片或对它的引用。这种灵活性利用了解引用强转(deref coercion),第 15 章「在函数和方法中使用解引用强转」一节介绍。让函数接收字符串切片而不是 &String,API 更通用、更有用,且不损失任何功能:

fn main() {
    let my_string = String::from("hello world");

    // `first_word` 适用于 `String` 的切片,无论是部分还是整体。
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word` 也适用于对 `String` 的引用,
    // 这等价于 `String` 的整体切片。
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word` 适用于字符串字面量的切片,无论是部分
    // 还是整体。
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // 因为字符串字面量本来就是字符串切片,
    // 所以这样也可以,连切片语法都不用写!
    let word = first_word(my_string_literal);
}

其他切片

字符串切片是字符串专用的,但切片这个概念更通用。看这个数组:

let a = [1, 2, 3, 4, 5];

正如可能想引用字符串的一部分,也可以引用数组的一部分:

let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];

assert_eq!(slice, &[2, 3]);

这个切片的类型是 &[i32],工作方式与字符串切片相同:存储指向第一个元素的引用和一个长度。各种其他集合里你都会用到这种切片,第 8 章讨论 vector 时再详细展开。

小结与下一步

  • 三条所有权规则:每个值都有唯一所有者;赋值、传参、返回默认是移动;所有者离开作用域时自动 drop
  • 栈上的简单标量默认 Copy(隐式复制、廉价),堆数据移动,深拷贝必须显式 clone——昂贵的操作永远看得见
  • 借用两条铁律:同一时刻要么一个可变引用、要么任意多个不可变引用;引用必须始终有效,编译器保证无悬垂
  • 切片(&str、&[T])是「引用集合中连续一段」的推广,字符串字面量本身就是 &str,API 收切片比收 &String 更通用
  • 以上全部由编译器在编译期强制、运行时零开销——这就是 Rust 不靠 GC 做到内存安全的方式

下一篇:第 5 章「使用结构体组织关联数据」——把本章的裸数据组织成有意义的自定义类型。