本章内容涵盖
- 设计能够正确利用 Rust 所有权系统的系统
- 可视化 Rust 的生命周期系统以辅助调试
- 控制字符串分配以实现高速性能
- 枚举和基本错误处理
在将 Rust 库集成到其他语言编写的现有应用之前,我们首先需要了解 Rust 编程的基础。本章将通过一个简单的艺术博物馆数字艺术品管理应用示例,讲解所有权系统的工作原理。所有权与借用被许多新手 Rust 开发者认为是最难掌握的部分。我们从这里开始,而不是从更简单的内容开始,是因为这些是 Rust 与其他语言最大不同之处,也是所有 Rust 程序的核心。如果现在不花时间理解这些重要概念,后续内容会变得更难。
我们将通过一个示例,将 Rust 程序中的所有权与借用概念,联系到数字艺术品的所有权和使用上。这将使理解所有权更容易,并引入随时间变化的所有权可视化工具。
2.1 所有权和借用
Rust 与其他语言最大不同之一是强制执行若干关于数据访问方式及不同访问形式间依赖关系的重要规则。规则不复杂,但与许多没有这类约束的语言不同。所有权规则如下:
- Rust 中每个值都有一个称为“所有者”的变量。
- 同一时间只能有一个所有者。
- 当所有者超出作用域,值会被释放(drop)。
初看 Rust 代码时,这些规则可能不明显。Rust 的过程式代码和其他语言很相似,你可能能顺利阅读。但你可能会发现在修改已有 Rust 代码或写新代码时,无法让看似合理的代码通过编译。这是因为 Rust 编译器严格执行这些规则,而你尚未完全掌握。
接下来,我们用一个简单示例展示所有权和借用规则如何影响 Rust 程序。
假设一艺术博物馆委托你用 Rust 设计一个系统,管理其数字艺术品目录。系统允许访客购买门票,获得观看艺术品的权利。
首先用 Rust 的包管理工具 Cargo 创建新项目:
$ cargo new art-museum
这会创建一个名为 art-museum 的目录,包含写 Rust 代码所需的文件。我们先关注主代码文件 art-museum/src/main.rs,用喜欢的文本编辑器打开它即可开始。
首次打开你可能惊讶地发现文件非空,且已有一个经典的编程示例 —— “Hello world!” 程序。
// 代码示例 2.1 Rust 中的 "Hello world!" 程序
fn main() { // #1
println!("Hello world!"); // #2
}
- #1 绝大多数 Rust 程序都有一个作为入口点的
main函数。所有函数定义以fn开头,后接函数名。 - #2
println!末尾的!表示它是宏(macro),不是函数。
运行此程序,使用命令:
$ cargo run
Hello world!
确认它输出预期结果。
接下来,把 “Hello world!” 程序替换成艺术馆管理代码的开端,先定义表示艺术品的类型。
// 代码示例 2.2 表示艺术品的结构体
struct Artwork {
name: String,
}
fn main() {
let art1 = Artwork {
name: "Boy with Apple".to_string()
};
}
结构体(struct)是字段的集合,代表单一逻辑值。Rust 的结构体类似面向对象语言中的类,但不支持继承,更像 C++ 或 Go 中的结构体,允许数据与功能结合。
Rust 用 let 语句初始化变量。编译器能根据等号右侧的值推断变量类型。
你可能觉得 "Boy with Apple" 作为字符串字面量不够,为什么需要额外调用 to_string() 转成 String 类型?此情况在第 2.3 节会详细讲解。现在只需知道调用 to_string() 是将字符串字面量变成 String 的必要操作。
下一步可能想模拟查看艺术品的操作。
// 代码示例 2.3 允许欣赏艺术品的函数
struct Artwork {
name: String,
}
fn admire_art(art: Artwork) {
println!("Wow, {} really makes you think.", art.name);
}
fn main() {
let art1 = Artwork { name: "La Trahison des images".to_string() };
admire_art(art1);
}
传给 println! 宏的字符串字面量中的花括号 {} 会被替换成后续参数的值。这类似 C、Go 语言中 printf 函数的格式化字符串,或 Python 的 .format 方法。
此程序定义了函数 admire_art,接受单个 Artwork 作为参数,打印一条关于艺术品的赞叹信息。运行输出:
$ cargo run
Wow, La Trahison des images really makes you think.
看起来系统很棒:有艺术品,也能静静欣赏。我们不是管理世界上最小的艺术馆,继续加一件艺术品!
// 代码示例 2.4 可以欣赏两件艺术品的程序
struct Artwork {
name: String,
}
fn admire_art(art: Artwork) {
println!("Wow, {} really makes you think.", art.name);
}
fn main() {
let art1 = Artwork { name: "Las dos Fridas".to_string() };
let art2 = Artwork { name: "The Persistence of Memory".to_string() };
admire_art(art1);
admire_art(art2);
}
预期输出:
$ cargo run
Wow, Las dos Fridas really makes you think.
Wow, The Persistence of Memory really makes you think.
欣赏两件艺术品没问题,但假设博物馆有多个访客想看同一件艺术品呢?如下示例:
// 代码示例 2.5 试图两次欣赏同一艺术品的程序
struct Artwork {
name: String,
}
fn admire_art(art: Artwork) {
println!("Wow, {} really makes you think.", art.name);
}
fn main() {
let art1 = Artwork { name: "The Ordeal of Owain".to_string() };
admire_art(art1);
admire_art(art1);
}
尝试运行这段看似合理的程序,会得到编译错误,可能让没接触过 Rust 的人感到陌生。错误信息如下:
$ cargo run
error[E0382]: use of moved value: `art1`
--> src/main.rs:11:16
|
8 | let art1 = Artwork {};
| ---- move occurs because `art1` has type `Artwork`, which
| does not implement the `Copy` trait
9 |
10 | admire_art(art1);
| ---- value moved here
11 | admire_art(art1);
| ^^^^ value used here after move
error: aborting due to previous error; 1 warning emitted
这是什么意思?“use of moved value” 是什么?“Copy” trait 又是什么?Rust 在告诉我们什么?
Rust 编译器是在告诉我们违反了所有权规则,程序因此无效。要理解为何这段代码不能编译,我们需要先了解其他语言中内存管理的方式,接下来稍作介绍。
2.2 其他语言中的内存管理
通常,计算机程序会在运行时将使用或生成的数据存储在计算机内存中。内存通常分为两部分:栈(stack)和堆(heap)。
栈 用来存储当前运行函数内创建的局部变量,以及导致当前函数被调用的一系列函数的栈帧。栈的最大容量有限,通常是 8 MB。它总是像一叠纸一样增长,也就是说,值的添加和删除都发生在栈顶,因此栈中不会有空隙。
堆 则受限于运行程序的计算机内存大小,可能是几 GB 或 TB。堆用来存储更大或运行前大小未知的数据,比如数组和字符串。堆内存也称为动态内存,因为其大小在程序运行时才能确定。
假设我们想在艺术博物馆欢迎访客,打印 “Welcome {name}”。这需要先请求计算机在内存中分配足够空间来存储访客名字,存在变量 name 中。这个过程称为分配。在该内存区域里只能存放该访客名字。通过给 name 赋新值,可以替换或改变内存中的内容,但 name 始终指向同一内存区域。
程序需要定期清理内存,否则会被未使用的 name 值填满。当我们不再使用 name,比如打印完欢迎信息后,需要告诉计算机这块内存可以重复利用。这一清理过程在 Rust 中称为“drop”,更通用的术语是释放内存(deallocation)。
过去,编程语言允许开发者分配和释放内存主要有两种常见方式:
- 手动内存管理
开发者编写代码显式请求内存大小,并标记何时该内存不再使用、可以回收。此过程称为手动内存管理,因为需人工保证内存正确分配和释放。许多手动管理语言会自动回收函数返回时分配在栈上的内存,主要难点在堆内存管理。 - 垃圾回收(自动内存管理)
语言在后台运行额外代码,定期检查不再被任何变量引用的内存块并释放它们。此过程称为垃圾回收,开发者无需手动释放内存。这类语言通常提供更简单的分配方法,防止请求过多或过少内存。
如果你想写高性能程序,通常需要使用手动内存管理语言。比如 C 和 C++ 要求程序员确定所需内存大小并请求精确分配。请求过多导致分配慢或占用过多内存;请求过少并错误使用未分配内存会导致严重问题,如程序崩溃、泄露敏感信息(密码、密钥等)、甚至被恶意用户注入代码劫持程序。用这类语言写大型程序需大量思考或详尽文档支持。
手动内存管理中常见问题之一是“释放后使用”(use after free):指程序尝试使用已释放的内存区域。该内存可能已被重用、清零,或者仍残留旧数据。释放内存后,编译器可对该内存做任意处理,程序行为难以预测。
假设你用一个虚构语言 “K” 写程序,K 类似 Python,但需要开发者显式调用 free 函数释放动态内存。必须对每个动态分配的值调用且仅调用一次 free。若使用已释放的值,程序会崩溃。下面是用 K 写的欢迎程序:
def welcome(name):
print('Welcome ' + name)
name = input('Please enter your name: ')
welcome(name)
free(name)
该程序请求用户输入姓名,打印个性化欢迎消息,随后释放存储姓名的内存。你会觉得没问题,但通常调用 welcome 后还要记得调用 free,否则内存泄漏。于是将 free 调用移入 welcome 函数:
def welcome(name):
print('Welcome ' + name)
free(name)
name = input('Please enter your name: ')
welcome(name)
这样每次调用 welcome 时,内存都会被释放,省得忘记调用 free。这个示例程序仍然有效,但引入了一个微妙且未文档化的副作用:任何传给 welcome 的字符串调用后都不可再用。
如果项目有 10,000 行代码,你得检查所有调用 welcome 的地方,确保传入的字符串不再被使用,否则程序会崩溃。
若后来修改欢迎逻辑,要记录某入口的访客名单,就得再次修改 welcome 函数,不再释放字符串。此时又要重新检查所有调用,并确定是立即释放还是放入日志。所有决定必须在运行前做出,但 K 语言除运行外无任何工具验证程序正确性。
这时,Rust 的所有权系统优势显现出来。Rust 在类型层面编码了内存何时分配、何时可用、何时释放的信息。这防止了释放后使用等错误,许多内存损坏错误在 Rust 中根本无法表达。只要程序违反规则,编译器就拒绝编译。
Rust 结合了垃圾回收和手动内存管理的优点:它没有后台进程扫描内存,因而有手动管理的速度优势,同时编译器保障内存安全,避免程序崩溃或更糟情况发生。
回顾 2.5 代码,再次列出:
struct Artwork {
name: String,
}
fn admire_art(art: Artwork) {
println!("Wow, {} really makes you think.", art.name);
}
fn main() {
let art1 = Artwork { name: "The Ordeal of Owain".to_string() };
admire_art(art1);
admire_art(art1);
}
当定义 admire_art 函数时,我们告诉 Rust,调用该函数时必须提供一个拥有所有权的 Artwork 值,函数将获得该值的所有权。记住,在 Rust 中每个值只能有一个所有者。
变量 art1 拥有它所引用的 Artwork。当我们用 art1 调用 admire_art 时,Rust 把该值的所有权从 art1 移走,转移到 admire_art 函数内部的参数 art。这一步非常重要:首次调用后,art1 变量不再有效,因为它不再引用任何东西,不能再使用。每当调用 admire_art 函数,相关的内存会在函数结束时被释放。
理解所有权和所有权转移对写 Rust 代码至关重要,而生命周期的理解同样重要。
2.3 生命周期
Rust 中的生命周期概念是理解内存管理过程的核心。所有编程语言中的值都有生命周期,虽然大多数语言不像 Rust 那样显式。生命周期描述了一个值有效的时间段。如果是函数内的局部变量,其生命周期可能就是函数调用期间;如果是全局变量,则可能贯穿整个程序运行时间。一个值在其内存被分配后且未被释放之前是有效的。在此范围外使用该值都是无效的。在 C、C++ 等语言中,超出生命周期使用值可能导致崩溃或内存损坏错误;而在 Rust 中,这会导致程序无法编译。
为了帮助理解,我们引入一种称为“生命周期图”的可视化方法。这类图在本章以及本书其他部分会频繁出现。在尝试可视化 2.5 代码示例中的错误之前,我们先看本章前面较简单的例子。图 2.1 展示了 2.2 代码示例的生命周期图(代码附于图下,便于参考)。
注意,变量 art1 用一条线表示了变量的创建时间、可用时间以及销毁时间。在 Rust 中,值在超出作用域时会被释放(drop)。函数中的局部变量会在函数结束前被释放。当我们在排查 Rust 内存管理问题时,就会借助这些生命周期图来帮助理解发生了什么。
现在,让我们看看代码示例 2.3 的生命周期图是什么样子的。
图 2.2 引入了“移动”(move)值或将所有权转移到另一个变量的概念。正如我们在 2.3 代码示例讨论中所知,当我们用参数 art1 调用 admire_art 函数时,art1 的所有权从 main 函数“移动”到了 admire_art 函数中,之后在 main 函数中不再可访问。art1 在 main 函数中的生命周期一旦 admire_art 运行就消失了,这正是它被移动的信号。
如果我们将 2.4 代码示例可视化,就能看到两个变量共存、各自拥有独立生命周期的情况。
图 2.3 显示,每个 Artwork 变量都在 main 函数中被创建,然后分别移动到不同的 admire_art 调用点。每个变量都有自己独立的生命周期,并拥有合适的开始、中间和结束时间。
当我们尝试为代码示例 2.5 构建生命周期时,开始遇到了一些问题。让我们通过查看图 2.4 中的可视化,看看是否能对发生的情况获得一些洞见。
让我们来剖析发生了什么。注意,art1 被移动(move)到 admire_art 函数中,因而在 main 函数中不再可访问。当我们尝试第二次调用 admire_art 时,值已经不存在了;这正是 Rust 报错信息所指示的含义。记住,错误的标题是“use of moved value”(使用已移动的值)。在代码中,art1 已经从 main 函数中被移动出去了,但我们还试图在 main 中使用它。换句话说,我们尝试在值被移动后继续使用它,这使得该值无效。
此时你可能会问:“那又怎样?为什么我把值传给函数后,它们就基本消失了?这看起来像是额外的麻烦!”这似乎是 Rust 给程序员增加的负担,使我们的生活变得更复杂。但实际上,使用手动内存管理语言(如 C 或 C++)的程序员必须时刻遵守类似规则。不同的是,Rust 编译器会强制执行,而 C/C++ 只靠程序员自己记住遵守!
现在我们简要讨论如何编写不获取值所有权的函数。
2.3.1 引用与借用
除非你的程序只使用每个数据一次,否则通过移动传值的方式会极其受限。你总有一天需要从多个地方使用同一值,或者想使用值而不转移所有权。在 Rust 中,可以借用值,而非拥有它们。借用值总是得到对该值的引用。引用可以理解为告诉 Rust 如何找到对应值的指针。如果将计算机内存想象成一个庞大的数组,引用就像该数组中的索引,用于定位具体值。
Rust 中的借用就像现实生活中借用实体物品。既然我们不拥有该值,使用完毕后不能销毁它。我们可以暂时使用,但必须在所有者销毁前归还。借用有规则。与所有权类似,这些规则定义了数据在 Rust 程序中的流动,最终会成为你的“第二天性”。规则如下:
- 每个值在任何时间点只能有一个可变引用,或者任意多个不可变引用。
- 引用必须始终有效。
对于来自不具备受控可变性概念语言的开发者,这第一条规则可能显得奇怪。我们将在 2.2.2 节更详细讨论,但先通过修改 2.5 代码示例来直观了解引用的工作原理。
回顾 2.5 代码示例,我们试图多次将同一变量传给同一函数,但由于移动所有权导致编译失败。如果将 admire_art 函数的参数类型从拥有所有权的 Artwork 改为对 Artwork 的引用,程序就能按预期运行。
// 代码示例 2.9 两次欣赏同一艺术品的程序
struct Artwork {
name: String,
}
fn admire_art(art: &Artwork) { // #1
println!("Wow, {} really makes you think.", art.name);
}
fn main() {
let art1 = Artwork { name: "The Ordeal of Owain".to_string() };
admire_art(&art1); // #2
admire_art(&art1);
}
- #1 这一行的
&符号表明该参数是对Artwork的引用,而非拥有所有权的值。因此admire_art只能接受引用,而不是拥有所有权的值。 - #2 表达式中的
&称为“借用操作符”,&x表示对x的引用。
2.9 代码示例与 2.5 非常相似,唯一不同是 admire_art 接受的参数类型由拥有所有权变为引用。换个角度看博物馆,这很合理:我们不想为了欣赏一次就创建并销毁艺术品,而是希望许多人多次共享欣赏。内存角度也合理:频繁创建销毁会造成内存抖动,效率低下,复用内存更好。
如果对比 2.9 的生命周期图,你会立刻发现它更合理。图 2.5 显示,art1 并未移动到任何一次 admire_art 调用中,我们传入的是引用,但 art1 依旧由 main 拥有。art1 关联的内存会在 main 结束时释放,而对它的引用会在函数调用结束时释放,这样完全没问题。
为了理解 Rust 中可变引用和不可变引用的区别,我们接下来看看 Rust 是如何区别处理可变和不可变变量的。
2.3.2 可变性的控制
Rust 中所有变量都会附带一些额外信息,帮助开发者(以及 Rust 编译器)推断程序运行时的行为。该信息决定变量是可变的(mutable,意味着值可以更改)还是不可变的(immutable,意味着值不可更改)。
Rust 中所有变量默认都是不可变的,除非声明时显式标记为可变。下面代码展示了声明并使用不可变变量和可变变量的示例。
// 代码示例 2.10 Rust 中使用不可变和可变变量
fn main() {
let x = 0; // #1
let mut y = 0; // #2
println!("x={}, y={}", x, y);
y += 10; // #3
println!("x={}, y={}", x, y);
}
- #1 变量
x未加任何注解,表示不可变,不能修改。 - #2
mut关键字声明y是可变变量,可以更改。 - #3 由于我们想修改
y的值,所以必须声明为可变。若此处将y改为x,编译会报错。
刚开始你可能觉得 Rust 要求预先声明变量是否会被修改很奇怪,但你会惊讶于大多数 Rust 代码中其实能避免很多修改。此外,Rust 编译器了解变量的可变性,能够静态校验一些其他语言难以做到的代码正确性。我们将在第 8 章关于并发 Rust 代码时深入讲解。现在请知道,这只是声明变量方式的一点小改动,却能大幅提升你对运行代码的推理能力。
如 2.10 示例所示,标记变量为可变非常简单。可变变量允许重新赋值。这个简单示例里,你可能不明显感受到可变性控制的好处,但结合引用后,优势会非常明显。回到我们的艺术博物馆示例,看看是否能用上可变性的概念。
当前 admire_art 函数接受一个不可变引用,如果想让每件艺术品有个访问计数器,每被欣赏一次就加一呢?此时就需要修改函数,让它接受可变引用。
// 代码示例 2.11 使用可变引用递增艺术品访问计数
struct Artwork {
view_count: i32,
name: String,
}
fn admire_art(art: &mut Artwork) { // #1
println!("{} people have seen {} today!",
art.view_count, art.name);
art.view_count += 1; // #2
}
fn main() {
let mut art1 = Artwork {
view_count: 0, name: "".to_string() }; // #3
admire_art(&mut art1); // #4
admire_art(&mut art1);
}
- #1 函数参数类型由
&Artwork改为&mut Artwork,表示函数内可能修改艺术品内容。 - #2 这行代码修改了
view_count,因此需要对其所有者(包含该字段的Artwork)的可变引用。 - #3 即使
main函数内未修改art1,我们要创建它的可变引用,因此声明时必须加mut。 - #4 创建可变引用时,表达式
&mut art1也需加mut关键字。
在 2.11 示例中,我们成功实现了每次欣赏时计数递增并读取。你可能会问:“等等!我以为一个值同一时间只能有一个可变引用,这程序不违反这条规则吗?”如果仔细想想程序执行过程,会发现两个可变引用从未同时指向同一个值。图 2.6 说明了这一点。
注意我们创建的引用都有生命周期,函数调用结束后引用就不存在了。当我们调用 admire_art,传入一个引用,函数结束后该引用超出作用域并被释放。两次函数调用之间,art1 没有任何引用存在。因此,该程序是合法的 Rust 代码。
回到代码示例 2.9,我们可以看到显式可变注解的价值。通过查看 admire_art 函数的类型声明,我们就能确定它不会修改传入的 Artwork 值。为什么?因为它接受的是 &Artwork,而不是 &mut Artwork。你可以通过查看库文档中的函数声明,而不是猜测,知道哪些函数会修改传入的值,哪些函数只是读取它们。这个设计对安全性、性能和调试有着重要且广泛的影响。我们将在第 3 章关于 Rust 与 C、C++ 集成的讨论中深入探讨这一点。
2.3.3 引用与生命周期
就像 Rust 中的值有生命周期一样,引用也有生命周期。引用指向值,但引用本身也是值,且在超出作用域时会被释放。此外,Rust 对引用有一条额外的规则。回想我们对引用的初步讨论,所有引用都必须有效。什么意思?简单说,所有引用必须指向有效的值。还记得生命周期是 Rust 编译器判断值有效性的方法吗?因此,引用和生命周期紧密相关。引用不仅有自己的生命周期,还必须关心它所指向的值的生命周期。
来看一个具体例子:
// 代码示例 2.12 尝试在值被移动后使用它的程序
struct Artwork {
name: String,
}
fn admire_art(art: Artwork) { // #1
println!("Wow, {} really makes you think.", art.name);
}
fn main() {
let art1 = Artwork { name: "Man on Fire".to_string() };
let borrowed_art = &art1; // #2
admire_art(art1);
println!("I really enjoy {}", borrowed_art.name);
}
- #1 这里的
admire_art改为接受拥有所有权的Artwork,而非引用。 - #2
borrowed_art是指向art1的引用。
当我们尝试运行这段代码时,会得到编译错误!让我们尝试构建生命周期图,看看问题出在哪儿。
如图 2.7 所示,我们的程序无效,因为在调用 admire_art 函数后,borrowed_art 引用失效了。接下来让我们看看引用生命周期中的另一个常见陷阱。
// 代码示例 2.13 尝试返回已释放值的引用的函数
struct Artwork {
name: String,
}
fn build_art() -> &Artwork {
let art = Artwork { name: "La Liberté guidant le peuple".to_string() };
&art // #1
}
fn main() {
let art = build_art();
}
- #1 Rust 中
return关键字是可选的。如果函数最后一行表达式没有分号,它的值将作为返回值。
代码示例 2.13 中的 build_art 函数因一个稍微不同的原因无效。art 没有被移动,但我们试图返回它的引用,而该值在函数结束时被释放。图 2.8 展示了该程序的生命周期图。
图 2.8 中的生命周期图与图 2.7 中的图表现出同样的常见警告信号:引用的生命周期延伸超出了它所指向值的释放点。确实可以编写返回引用的 Rust 函数,但这类函数通常也会接受引用作为输入参数。如果一个函数返回引用,却没有参数或仅接受拥有所有权的参数,那么编译时通常会报生命周期错误。
2.4 Rust 的字符串类型
几乎所有编程语言都支持字符串操作,因为它们太实用了。许多语言都有 String 类型,但 Rust 有所不同:它有多种类型用来表示字符串。最常用的是 String 和 &str。我们来看看它们的用法。
&str(也称字符串引用)是两者中更简单的一种类型,只包含一个指向内存起始位置的指针和长度。由于结构简单,&str 更灵活,可以指向内存中任何位置的字符串数据。它可以是栈分配的数组缓冲区、一个 String,甚至是编译进程序二进制的字符串字面量。
如果你来自 C 或 C++,你可能知道这些语言中的字符串字面量与其他字符串值有细微差别,虽然它们类型相同。C/C++ 中的字符串字面量是只读的,因为它们编译进二进制,存放在只读内存中。试运行下面这个 C 程序,大概率会得到段错误(运行时非法内存访问)。
// 代码示例 2.14 试图写入只读内存的 C 程序
int main(void) {
char *str = "hello, world!";
str[0] = '!'; // #1
return 0;
}
- #1 这一行会导致段错误。
2.14 示例代码无效,因为它试图写入只读内存。C 编译器不知道 str 指向只读内存,因为 C 的类型系统不区分值是否可变。Rust 中字符串字面量对应的类型是 &'static str。这里的 'static 是生命周期标注,明确告诉编译器该引用有效期为整个程序运行时间。第 4 章会详细讨论这个话题。现在你只需知道,&'static 代表引用在程序整个运行期都有效。由于字符串字面量编译进二进制,&'static str 可以随时引用它们,无需担心被释放(因为它们不会被释放)。
Rust 也允许对字符串字面量使用非静态引用。来看示例:
// 代码示例 2.15 非静态引用示例
struct Artwork {
name: &'static str,
}
fn admire_art(art: &Artwork) {
print_admiration(art.name); // #1
}
fn print_admiration(name: &str) {
println!("Wow, {} really makes you think.", name);
}
fn main() {
let art1 = Artwork { name: "The Ordeal of Owain" }; // #2
admire_art(&art1);
}
- #1 当我们将
&'static str传入接受&str参数的函数时,&'static引用会被转换成普通的&引用。 - #2 这里不再需要调用
to_string(),因为name期望的是&'static str而非String。
字符串引用是不可变的,这一点很重要。因为它们仅仅指向内存缓冲区,并不了解这些缓冲区是如何构造的,也不知是否有额外容量,所以不能被修改。如果想修改字符串值,我们需要用 Rust 中的另一种字符串类型——String。
2.4.1 可变字符串
如果你来自 Java、JavaScript 或 Python 等语言,你可能最早在字符串上下文中听说过可变性(mutability)。在这些语言中,所有字符串都是不可变的,创建后无法更改。你可能觉得自己经常用 += 操作符将一个字符串拼接到另一个字符串上,但这其实不是修改原字符串,而是通过创建一个包含新内容的新字符串来“修改”。
假设我们需要编写一个程序,每发生一次操作就在字符串末尾添加一个点字符 ".",用一个 1000 万次迭代的 for 循环来模拟:
# 代码示例 2.16 Python 中逐字符创建超大字符串
x = ""
for _ in range(0, 10_000_000):
x += "."
print(len(x))
每次循环都会创建一个新字符串,包含当前字符串的所有内容加上一个点字符。构建 1000 万个点的字符串需要执行 1000 万次分配,导致 9,999,999 次冗余的字符串复制。这种复制过程称为内存重新分配(reallocation)。我们来对比一下 Rust,它提供了对字符串的可变操作能力。
Rust 中的 String(拥有字符串)是一个可增长的堆分配缓冲区,用于存储字符数据。如果你想添加字符,可以直接添加到缓冲区末尾;如果想替换中间的字符,也可以直接修改缓冲区。缓冲区有长度和容量两个概念:长度表示缓冲区中有效元素数,容量表示缓冲区满时能容纳的元素数。只有当修改字符串导致长度超过容量时,Rust 才会进行额外的分配和复制。此时,缓冲区会重新分配,容量至少满足存储新数据的需求。Rust 标准库没有保证缓冲区扩容策略,但缓冲区容量可能会翻倍增长,以减少后续的分配次数。
来看如何用 Rust 实现类似功能:
// 代码示例 2.17 Rust 中逐字符创建超大字符串
fn main() {
let mut x = String::new(); // #1
for _ in 0..10_000_000 {
x.push('.');
}
println!("{}", x.len());
}
- #1
String::new创建了一个容量为 0 的新字符串,未进行任何分配。
从示例 2.17 可见,大部分缓冲区维护对开发者是透明的。通常,你唯一需要做的就是根据预估大小预先设置容量,以减少分配次数。若想最小化分配次数、最大化程序运行速度,可以用 String::with_capacity 明确指定容量。这样,我们的 1000 万个点程序只需一次分配即可完成!处理大字符串时,这会带来显著性能提升。
// 代码示例 2.18 预分配字符串以提升性能
fn main() {
let mut x = String::with_capacity(10_000_000); // #1
for _ in 0..10_000_000 {
x.push('.');
}
println!("{}", x.len());
}
- #1 这一行是将分配次数减少到一次所需的唯一更改,字符串使用方式不变。
String::with_capacity 是一种性能优化。它返回的 String 可像 String::new 创建的字符串一样使用,但在某些场景下性能更佳。通过 push 增长字符串超过容量时,字符串会自动重新分配缓冲区。
你可能想了解如何在 Rust 两种字符串类型间转换。两种转换对开发者都很简单,但其中一个方向对计算机来说运行时开销更大。
将 String 转换为 &str 很便宜。因为 &str 只是一个指针和长度,我们只需复制 String 缓冲区的起始指针和长度即可。这通常只涉及两个 64 位整数的复制,开销非常小。示例如下:
// 代码示例 2.19 将 String 转为字符串引用
fn print_admiration(name: &str) {
println!("Wow, {} really makes you think.", name);
}
fn main() {
let value = String::new();
print_admiration(value.as_str());
}
反过来,将 &str 转为 String 则成本较高。因为所有 String 都拥有独立的堆缓冲区,从 &str 创建 String 需要为缓冲区分配足够空间,并将 &str 中的数据复制进去。在紧密循环中大量做这类转换会严重影响性能。好处是我们容易发现转换发生的位置,并尽可能限制它。在本章中,你已多次做过这种转换,即对 &str 调用 .to_string() 方法:
// 代码示例 2.20 将字符串引用转换为 String
fn print_admiration(name: String) {
println!("Wow, {} really makes you think.", name);
}
fn main() {
let value = "Artwork";
print_admiration(value.to_string());
}
Rust 常用命名习惯是提供 as_ 和 to_ 前缀的方法。as_ 通常表示获取廉价的引用,to_ 表示分配并复制为拥有数据结构。
和本章大多数内容一样,这些字符串类型的差异长期看会很有用,但短期内可能令人困惑。什么时候用哪个字符串类型,随着经验增长自然明了。现在可以简单总结:
- 如果在结构体里存储数据,且生命周期较长,应该用
String。 - 如果只是给函数传递只读数据,应用
&str。 - 不确定时,选
String更灵活,且从字符串引用创建的额外分配以后可以清理。
接下来,我们来看 Rust 与其他语言显著不同的最后一个方面——错误处理。
2.5 枚举与错误处理
许多编程语言使用异常(exceptions)将错误从发生处向上传递到某个处理代码。Rust 与这些语言不同,Rust 中错误是普通的值,通过普通的控制流处理,而非专门的错误机制。首先,我们用一个简单的例子讲解枚举(enum)的用法,待理解清楚后再介绍错误处理。
2.5.1 枚举(Enums)
FizzBuzz 是一道经典的编程题,用于考察候选人使用基本控制流(如循环和条件语句)的能力。题目是:写个程序数数从 1 到 100,遇到能被 3 整除的数打印 “fizz”,遇到能被 5 整除的数打印 “buzz”,同时能被 3 和 5 整除的打印 “fizzbuzz”,否则打印数字本身。我们用一个主函数做循环和打印,一个辅助函数做判断。辅助函数返回一个枚举,告诉主函数该做什么。
先写主函数:
// 代码示例 2.21 1 到 100 循环打印的主函数
fn main() {
for i in 1..101 { // #1
println!("{}", i);
}
}
- #1
for循环遍历 1 到 100,x..y范围表示包含下界不含上界。
接着写辅助函数,判断数字是否能被整除:
// 代码示例 2.22 带辅助函数的 FizzBuzz 程序
fn main() {
for i in 1..101 {
print_fizzbuzz(i);
}
}
fn print_fizzbuzz(x: i32) { // #1
println!("{}", fizzbuzz(x));
}
fn fizzbuzz(x: i32) -> String {
if x % 3 == 0 && x % 5 == 0 {
String::from("FizzBuzz")
} else if x % 3 == 0 {
String::from("Fizz")
} else if x % 5 == 0 {
String::from("Buzz")
} else {
format!("{}", x) // #2
}
}
- #1 分离了计算和展示逻辑,方便理解。
- #2
format!宏与println!语法相同,但返回字符串而非打印。
这段代码解决了问题,但在大系统中不建议用字符串传递状态。Rust 是强类型语言,应利用类型系统保证返回值正确处理。如果想用同样的判断结果用不同方式展示,比如以紧凑格式发送网络,就需要解析这些字符串和数字。我们可以用更好的方法。
正确做法是用枚举(enum)在 print_fizzbuzz 和 fizzbuzz 之间传递结果。枚举是具有固定若干可能取值的类型。fizzbuzz 有四种可能返回值:“Fizz”、“Buzz”、“FizzBuzz”和“不整除”,非常适合用枚举。枚举存在于很多语言中,在 Rust 中尤为核心。本节后面还会讲枚举如何用于错误处理,现在先用 FizzBuzz 说明。
定义枚举:
// 代码示例 2.23 FizzBuzz 结果枚举
enum FizzBuzzValue {
Fizz,
Buzz,
FizzBuzz,
NotDivisible,
}
枚举的每个取值称为“变体”(variant)。FizzBuzzValue 覆盖了所有可能的返回值。下面看看如何从函数返回这个枚举:
// 代码示例 2.24 返回枚举的函数
enum FizzBuzzValue {
Fizz,
Buzz,
FizzBuzz,
NotDivisible,
}
fn fizzbuzz(x: i32) -> FizzBuzzValue {
if x % 3 == 0 && x % 5 == 0 {
FizzBuzzValue::FizzBuzz
} else if x % 3 == 0 {
FizzBuzzValue::Fizz
} else if x % 5 == 0 {
FizzBuzzValue::Buzz
} else {
FizzBuzzValue::NotDivisible
}
}
用 match 表达式根据返回值打印:
// 代码示例 2.25 使用 match 处理枚举
enum FizzBuzzValue {
Fizz,
Buzz,
FizzBuzz,
NotDivisible,
}
fn main() {
for i in 1..101 {
print_fizzbuzz(i);
}
}
fn print_fizzbuzz(x: i32) {
match fizzbuzz(x) {
FizzBuzzValue::FizzBuzz => { // #1
println!("FizzBuzz");
}
FizzBuzzValue::Fizz => {
println!("Fizz");
}
FizzBuzzValue::Buzz => {
println!("Buzz");
}
FizzBuzzValue::NotDivisible => {
println!("{}", x);
}
}
}
fn fizzbuzz(x: i32) -> FizzBuzzValue {
if x % 3 == 0 && x % 5 == 0 {
FizzBuzzValue::FizzBuzz
} else if x % 3 == 0 {
FizzBuzzValue::Fizz
} else if x % 5 == 0 {
FizzBuzzValue::Buzz
} else {
FizzBuzzValue::NotDivisible
}
}
- #1
match每个分支(arm)包含条件、=>符号和满足条件时执行的表达式。
这种做法有效分离了计算与展示。示例虽小,但在大程序中用枚举创建统一、标准化的多变体值表示方式非常有益。
不过,我们的 FizzBuzzValue 枚举有缺陷:NotDivisible 变体应带上未被 3 或 5 整除的数字,但代码没保存这个信息。若要在程序其他处打印该数字,需要存储数字和该变体信息。Rust 的枚举对此支持极好,每个变体除了标识外,还能携带任意字段。
来看示例:
// 代码示例 2.26 带字段的 FizzBuzzValue 枚举
enum FizzBuzzValue {
Fizz,
Buzz,
FizzBuzz,
NotDivisible(i32), // #1
}
fn main() {
for i in 1..101 {
print_fizzbuzz(i);
}
}
fn print_fizzbuzz(x: i32) {
match fizzbuzz(x) {
FizzBuzzValue::FizzBuzz => {
println!("FizzBuzz");
}
FizzBuzzValue::Fizz => {
println!("Fizz");
}
FizzBuzzValue::Buzz => {
println!("Buzz");
}
FizzBuzzValue::NotDivisible(num) => { // #2
println!("{}", num);
}
}
}
fn fizzbuzz(x: i32) -> FizzBuzzValue {
if x % 3 == 0 && x % 5 == 0 {
FizzBuzzValue::FizzBuzz
} else if x % 3 == 0 {
FizzBuzzValue::Fizz
} else if x % 5 == 0 {
FizzBuzzValue::Buzz
} else {
FizzBuzzValue::NotDivisible(x) // #3
}
}
- #1
i32表示NotDivisible变体必须携带一个i32类型的数据。 - #2 变量
num从枚举的NotDivisible变体中提取值。 - #3 我们在这里把数字
x放进了NotDivisible变体。
最后一个 match 分支稍作修改,加入了 num 变量,从 NotDivisible 变体中取出 i32。这种从枚举变体中取出数据的过程称为解构(destructuring)。由于枚举声明要求 NotDivisible 必须携带一个 i32,因此不能构造不带数据的该变体;访问变体内数据时,也必须先确认当前值确实是该变体。
掌握了枚举和 match 的基本用法,接下来我们看看如何用它们做错误处理。
2.5.2 使用枚举进行错误处理
许多编程语言使用异常(exceptions)来传播错误,从错误发生处向上传递到某种错误处理代码,比如 try/except 代码块。Rust 则不同,错误被当作普通值处理,使用普通的控制流元素来处理错误,而非特殊机制。本节将展示如何编写可能运行时失败的函数,以及如何处理这些函数返回的错误。
假设我们给 fizzbuzz 函数添加了新需求:除了判断是否能被整除外,如果输入是负数,应返回错误。在我们程序中,fizzbuzz 的输入通常是直接写死在源码里的常量,但假设它来自用户输入,我们需要对错误和正常返回的枚举值分开处理,且不扩展 FizzBuzzValue 来表示错误状态。
Rust 标准库中有一个名为 Result 的类型,它要么表示成功计算和结果,要么表示错误和错误信息。以下是其定义:
// 代码示例 2.27 Result 类型定义
enum Result<T, E> { // #1
Ok(T), // #2
Err(E), // #3
}
- #1
<T, E>表示两个泛型类型变量 T 和 E。 - #2
Ok变体包含任意类型 T 的值。 - #3
Err变体包含任意类型 E 的值。
Result 是 Rust 中最常用的类型之一,因为任何可能失败的函数都用 Result 包裹返回值。来看修改后的程序,支持可能返回错误的 fizzbuzz 函数。
// 代码示例 2.28 可能返回错误的 fizzbuzz 函数
enum FizzBuzzValue {
Fizz,
Buzz,
FizzBuzz,
NotDivisible(i32),
}
fn main() {
for i in 1..101 {
match print_fizzbuzz(i) {
Ok(()) => {}
Err(e) => {
eprintln!("Error: {}", e); // #1
return;
}
}
}
}
fn print_fizzbuzz(x: i32) -> Result<(), &'static str> { // #2
match fizzbuzz(x) {
Ok(result) => { // #3
match result {
FizzBuzzValue::FizzBuzz => {
println!("FizzBuzz");
}
FizzBuzzValue::Fizz => {
println!("Fizz");
}
FizzBuzzValue::Buzz => {
println!("Buzz");
}
FizzBuzzValue::NotDivisible(num) => {
println!("{}", num);
}
}
Ok(())
}
Err(e) => {
Err(e)
}
}
}
fn fizzbuzz(x: i32) -> Result<FizzBuzzValue, &'static str> {
if x < 0 {
Err("Provided number must be positive!")
} else if x % 3 == 0 && x % 5 == 0 {
Ok(FizzBuzzValue::FizzBuzz) // #4
} else if x % 3 == 0 {
Ok(FizzBuzzValue::Fizz)
} else if x % 5 == 0 {
Ok(FizzBuzzValue::Buzz)
} else {
Ok(FizzBuzzValue::NotDivisible(x))
}
}
- #1
eprintln!宏与println!类似,但将信息打印到标准错误(STDERR)而非标准输出(STDOUT),常用于错误信息输出,避免干扰正常输出。 - #2
print_fizzbuzz返回Result,成功时返回()(单元类型,详见下一节),失败时返回静态字符串引用&'static str作为错误信息。 - #3 只有在
fizzbuzz返回Ok时才能访问内部FizzBuzzValue,否则需处理错误。 - #4 函数所有返回路径都用
Ok包裹FizzBuzzValue,表示计算成功。
这段代码引入了几个重要变化:print_fizzbuzz 和 fizzbuzz 的返回类型都改为 Result,错误类型相同(&'static str),但成功时的类型不同。fizzbuzz 依旧返回 FizzBuzzValue,而 print_fizzbuzz 返回单元类型 (),我们马上来认识它。
2.5.3 单元类型(Unit Type)
单元类型是一种只有一个可能值且不包含任何信息的类型,代表“无”的概念。它类似于其他语言中的 null,但有一个非常重要的区别:大多数带有 null 的语言允许所有引用类型赋值为 null。例如,下面这段 Java 代码能够编译并运行,在控制台打印 null:
// 代码示例 2.29 Java 中的 null
public class Main {
public static void main(String[] args) {
String x = null;
System.out.println(x);
}
}
这段代码之所以能运行,是因为 Java 和很多其他语言允许所有引用类型赋值为 null。这会导致很多运行时错误,当程序员忘记检查引用是否为 null 时会出错。我们尝试用 Rust 写相同的代码:
// 代码示例 2.30 Rust 中的单元类型
fn main() {
let x: String = ();
println!("{}", x);
}
这段代码无法编译,Rust 编译器会报错,提示实际类型 () 与预期的 String 类型不匹配:
error[E0308]: mismatched types
--> src/main.rs:2:19
|
2 | let x: String = ();
| ------ ^^ expected struct `String`, found `()`
| |
| expected due to this
error: aborting due to previous error
编译失败是因为单元类型 () 是独立的类型,不属于任何其他类型。与 null 更接近的类比是 void。你可能注意到,Java 代码中 main 方法的返回类型是 void,表示“无”类型。不同于 Rust 的单元类型,Java 中无法存储 void 类型的值。你可能还发现,在写 Rust 代码时,函数若不返回值,通常不标注返回类型,这不是因为没有返回值,而是未标注函数默认返回单元类型。下面三个函数是等价的:
// 代码示例 2.31 三个都返回单元类型的函数
fn foo() { // #1
println!("Hello!");
}
fn bar() -> () { // #2
println!("Hello!");
}
fn baz() -> () {
println!("Hello!");
() // #3
}
- #1 这是我们通常写不返回值函数的方式,返回单元类型是隐式的。
- #2 这是显式声明返回单元类型的函数。
- #3 除了显式返回类型声明外,还显式返回单元值
()。
这三个函数都会打印 “Hello” 并退出,返回单元类型值。区别只是后两个写法更明确。bar 函数类似其他语言中 void 函数的写法——显式声明返回类型,但隐式返回值。
回到 2.28 代码示例中的 print_fizzbuzz 函数,其声明为:
fn print_fizzbuzz(x: i32) -> Result<(), &'static str>
这里 Result 的 Ok 类型是单元类型 (),表示成功时返回的值不携带额外信息。考虑函数的作用,若成功完成,除了告诉调用者“成功”外,还能返回什么有用信息呢?因为成功分支不需传递额外信息,所以返回单元类型。在一般情况下,单元类型值本身没什么用,但在这里我们用它是因为 Result 需要为 Ok 和 Err 两个变体指定类型,对于不需要返回其他值的成功场景,使用 () 是最合适的。
在加上 Result 之前,print_fizzbuzz 函数的返回类型实际上是 (),只是当时是隐式的。
接下来,我们回到 FizzBuzz 代码,继续介绍错误处理,加入自定义错误类型。
2.5.4 错误类型
作为开发者,我们知道代码运行时可能遇到的错误类型,比如 I/O 错误、网络错误、前置条件失败、缺失数据等。大多数 Rust 程序会创建自定义类型枚举可能返回的错误,以便针对不同错误采取不同处理策略。比如遇到网络错误时可能重试请求,遇到文件缺失时则记录日志,尝试继续或终止程序。
因为我们希望用一个类型来表示不同的错误可能性,所以用枚举来表示。由于我们的 FizzBuzz 程序只有一种错误可能 —— 当 fizzbuzz 函数收到负数时返回错误 —— 我们定义如下:
// 代码示例 2.32 FizzBuzz 程序的错误类型
enum Error {
GotNegative,
}
Error 名称是惯例,也可以随意命名;它就是普通类型。复杂程序可能有更多错误变体,或者包含其他库的错误类型。定义了错误类型后,我们把它加进代码:
// 代码示例 2.33 带自定义错误类型的 FizzBuzz
enum FizzBuzzValue {
Fizz,
Buzz,
FizzBuzz,
NotDivisible(i32),
}
enum Error {
GotNegative,
}
fn main() {
for i in 1..101 {
match print_fizzbuzz(i) {
Ok(()) => {}
Err(e) => {
match e {
Error::GotNegative => {
eprintln!("Error: Fizz Buzz only supports positive numbers!");
return;
}
}
}
}
}
}
fn print_fizzbuzz(x: i32) -> Result<(), Error> {
match fizzbuzz(x) {
Ok(result) => {
match result {
FizzBuzzValue::FizzBuzz => println!("FizzBuzz"),
FizzBuzzValue::Fizz => println!("Fizz"),
FizzBuzzValue::Buzz => println!("Buzz"),
FizzBuzzValue::NotDivisible(num) => println!("{}", num),
}
Ok(())
}
Err(e) => Err(e),
}
}
fn fizzbuzz(x: i32) -> Result<FizzBuzzValue, Error> {
if x < 0 {
Err(Error::GotNegative)
} else if x % 3 == 0 && x % 5 == 0 {
Ok(FizzBuzzValue::FizzBuzz)
} else if x % 3 == 0 {
Ok(FizzBuzzValue::Fizz)
} else if x % 5 == 0 {
Ok(FizzBuzzValue::Buzz)
} else {
Ok(FizzBuzzValue::NotDivisible(x))
}
}
从代码看,加入自定义错误类型并不复杂。部分返回类型变了,print_fizzbuzz 函数中对错误的处理也做了修改,因为错误不再能直接打印。
接下来看看如何简化 print_fizzbuzz 函数中的错误处理。目前它遇到错误直接返回给调用者,未对错误进行任何判断,仅判断是否是错误。这种错误转发模式在 Rust 中非常常见。如果函数遇到错误,直接返回给调用它的函数,这类似异常向上抛出的机制,但这是程序员明确选择的行为,不会被忘记。
Rust 在语法层面支持这种模式,用的是问号操作符 ?。其用法是:
- 如果表达式返回
Ok,结果就是Ok中的值; - 如果返回
Err,立即从当前函数返回这个错误。
看下面两个 Rust 函数,功能相同,但第二个使用了 ?:
// 代码示例 2.34 ? 操作符的用法示例
fn foo(i: i32) -> Result<FizzBuzzValue, Error> {
let result = match fizzbuzz(i) { // #1
Ok(x) => x,
Err(e) => return Err(e),
};
println!("{} is a valid number for fizzbuzz", i);
Ok(result)
}
fn bar(i: i32) -> Result<FizzBuzzValue, Error> {
let result = fizzbuzz(i)?; // #2
println!("{} is a valid number for fizzbuzz", i); // #3
Ok(result)
}
- #1
match是表达式,所以能赋值给变量。Err分支会提前返回。 - #2 这是
?操作符的用法,如果fizzbuzz(i)返回Err,函数立即返回。 - #3 只有当
fizzbuzz(i)返回Ok时才会执行。
第二个函数与第一个功能完全相同,? 是对第一个函数中 match 和提前返回的简写。
将 ? 应用到 FizzBuzz 代码:
// 代码示例 2.35 添加 ? 操作符的 FizzBuzz
enum FizzBuzzValue {
Fizz,
Buzz,
FizzBuzz,
NotDivisible(i32),
}
enum Error {
GotNegative,
}
fn main() {
for i in 1..101 {
match print_fizzbuzz(i) {
Ok(()) => {}
Err(e) => {
match e {
Error::GotNegative => {
eprintln!("Error: Fizz Buzz only supports positive numbers!");
return;
}
}
}
}
}
}
fn print_fizzbuzz(x: i32) -> Result<(), Error> {
match fizzbuzz(x)? { // #1
FizzBuzzValue::FizzBuzz => println!("FizzBuzz"),
FizzBuzzValue::Fizz => println!("Fizz"),
FizzBuzzValue::Buzz => println!("Buzz"),
FizzBuzzValue::NotDivisible(num) => println!("{}", num),
}
Ok(())
}
fn fizzbuzz(x: i32) -> Result<FizzBuzzValue, Error> {
if x < 0 {
Err(Error::GotNegative)
} else if x % 3 == 0 && x % 5 == 0 {
Ok(FizzBuzzValue::FizzBuzz)
} else if x % 3 == 0 {
Ok(FizzBuzzValue::Fizz)
} else if x % 5 == 0 {
Ok(FizzBuzzValue::Buzz)
} else {
Ok(FizzBuzzValue::NotDivisible(x))
}
}
- #1
?操作符确保如果fizzbuzz(i)返回错误,则print_fizzbuzz函数会提前返回该错误。
很多 Rust 库都会设计良好的错误类型,帮助确定失败根因。但有时也需包装过于通用的错误,添加更具体的上下文信息。我们接下来简单看看如何转换错误。
2.5.5 错误转换
Rust 中可能失败的函数通常返回 Result 类型,这样可以清晰地区分成功和失败两种情况。通常错误变体中的类型用来表示错误原因,方便定位失败原因,但有时我们需要额外转换。
假设你需要写一个用于用户创建工具的简单验证函数 validate_username,它接受用户名字符串 &str,返回验证结果(成功或失败)和失败原因。已有两个库函数可用:
validate_lowercase:验证用户名是否全部小写,返回Result<(), ()>validate_unique:验证用户名是否唯一,返回Result<(), ()>
这两个函数不能修改,签名如下:
fn validate_lowercase(username: &str) -> Result<(), ()>
fn validate_unique(username: &str) -> Result<(), ()>
你需要写的 validate_username 函数签名为:
enum UsernameError {
NotLowercase,
NotUnique,
}
fn validate_username(username: &str) -> Result<(), UsernameError>
如果直接写:
fn validate_username(username: &str) -> Result<(), UsernameError> {
validate_lowercase(username)?;
validate_unique(username)?;
Ok(())
}
这只能工作于 validate_lowercase 和 validate_unique 已用 UsernameError 类型,否则无法直接转换。因为这两个函数返回的错误都是单位类型 (),需要某种机制把它转换为对应的 UsernameError 变体。
可以用 match 实现,但写法冗长,Ok 分支没事做。Rust 提供了一个 Result 的函数 map_err,类似函数式编程的 map,但它只在遇到错误时调用给定函数,将错误映射成另一类型。
map_err 作用如下:
fn map_err<T, E1, E2>(
r: Result<T, E1>,
transform: fn(E1) -> E2,
) -> Result<T, E2> {
match r {
Ok(x) => Ok(x),
Err(e) => Err(transform(e)),
}
}
举例中你有 Result<(), ()> 需要变成 Result<(), UsernameError>,用法是:
fn validate_username(username: &str) -> Result<(), UsernameError> {
validate_lowercase(username).map_err(lowercase_err)?;
validate_unique(username).map_err(unique_err)?;
Ok(())
}
fn lowercase_err(_: ()) -> UsernameError {
UsernameError::NotLowercase
}
fn unique_err(_: ()) -> UsernameError {
UsernameError::NotUnique
}
这成功将错误类型对应起来。写起来不少代码,但用闭包能更简洁。
闭包(closure)是匿名函数,内联写法很适合用作参数传递给函数,如 map_err。单表达式闭包写法示例如下:
|x, y| x + y
Rust 可显式写参数类型和返回类型,但通常省略,因为上下文能推断。下面两个闭包等效:
fn main() {
let add1 = |x: i32, y: i32| -> i32 { x + y };
let add2 = |x: i32, y: i32| x + y; // #1
println!("{}", add1(3, 4));
println!("{}", add2(3, 4));
}
- #1 省略返回类型,编译器自动推断。
结合 map_err 和闭包,可写出更简洁的 validate_username:
fn validate_username(username: &str) -> Result<(), UsernameError> {
validate_lowercase(username).map_err(|_| UsernameError::NotLowercase)?;
validate_unique(username).map_err(|_| UsernameError::NotUnique)?;
Ok(())
}
闭包参数未用,编译器会警告,可用 _ 替代以忽略:
fn validate_username(username: &str) -> Result<(), UsernameError> {
validate_lowercase(username).map_err(|_| UsernameError::NotLowercase)?;
validate_unique(username).map_err(|_| UsernameError::NotUnique)?;
Ok(())
}
完整示例:
enum UsernameError {
NotLowercase,
NotUnique,
}
fn main() {
match validate_username("user1") {
Ok(()) => println!("Valid username"),
Err(UsernameError::NotLowercase) => println!("Username must be lowercase"),
Err(UsernameError::NotUnique) => println!("Username already exists"),
}
}
fn validate_username(username: &str) -> Result<(), UsernameError> {
validate_lowercase(username).map_err(|_| UsernameError::NotLowercase)?;
validate_unique(username).map_err(|_| UsernameError::NotUnique)?;
Ok(())
}
fn validate_lowercase(username: &str) -> Result<(), ()> {
Ok(()) // #1
}
fn validate_unique(username: &str) -> Result<(), ()> {
Ok(())
}
- #1 假设
validate_lowercase和validate_unique是已有库函数,这里不实现。
有时我们不想返回错误,而是希望“断言”没有错误发生,若发生错误则程序立即崩溃。下一节将介绍如何用 panic 处理错误。
2.5.6 使用 panic 处理错误
在 Rust 中,错误是值。它们和数字、字符串等数据一样,是程序中普通的变量值。错误并不可怕,也没有专门的控制流(除了显式使用 ? 的提前返回),只是需要处理的值。通常,如何处理错误会由某个调用者负责。调用者可能会记录错误继续执行,或者重试操作直到成功,也可能放弃执行并带错误退出程序。
回到我们的 FizzBuzz 程序,假设我们想修改 print_fizzbuzz 函数,使它永远不返回错误,而是在遇到错误时直接终止程序。做法是:移除 ?,恢复之前 Ok/Err 的匹配分支(见代码示例 2.33),将传递错误给调用者的代码替换为调用 panic! 宏。
// 代码示例 2.37 遇错时调用 panic! 宏
enum FizzBuzzValue {
Fizz,
Buzz,
FizzBuzz,
NotDivisible(i32),
}
enum Error {
GotNegative,
}
fn main() {
print_fizzbuzz(-1); // #1
}
fn print_fizzbuzz(x: i32) { // #2
match fizzbuzz(x) {
Ok(result) => match result {
FizzBuzzValue::FizzBuzz => println!("FizzBuzz"),
FizzBuzzValue::Fizz => println!("Fizz"),
FizzBuzzValue::Buzz => println!("Buzz"),
FizzBuzzValue::NotDivisible(num) => println!("{}", num),
}, // #3
Err(Error::GotNegative) => {
panic!("Got a negative number for fizzbuzz: {}", x);
}
}
}
fn fizzbuzz(x: i32) -> Result<FizzBuzzValue, Error> {
if x < 0 {
Err(Error::GotNegative)
} else if x % 3 == 0 && x % 5 == 0 {
Ok(FizzBuzzValue::FizzBuzz)
} else if x % 3 == 0 {
Ok(FizzBuzzValue::Fizz)
} else if x % 5 == 0 {
Ok(FizzBuzzValue::Buzz)
} else {
Ok(FizzBuzzValue::NotDivisible(x))
}
}
- #1 修改主函数调用,确保错误处理逻辑执行。
- #2
print_fizzbuzz不再返回Result类型,失败可能性不再反映在函数签名里。 - #3 移除之前版本中该分支的尾部
Ok(()),因为不再返回Result。
panic! 宏类似 Go 语言的 panic 函数:它使当前线程抛出异常并展开调用栈,直到顶层线程。如果是主线程,将导致程序以错误状态退出;如果是后台线程,只退出该线程。
虽然遇到单个错误就退出程序听起来有点极端,但 panic! 适合用于运行时断言,保证程序不进入非法状态,或者遇到不可恢复的错误时终止执行。
运行示例代码:
$ cargo run
thread 'main' panicked at 'Got a negative number for fizzbuzz: -1', main.rs:35:7
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Rust 给出了提示,可以通过设置环境变量 RUST_BACKTRACE=1 来打印调用栈,方便调试:
$ env RUST_BACKTRACE=1 cargo run
thread 'main' panicked at 'Got a negative number for fizzbuzz: -1', main.rs:33:7
stack backtrace:
0: rust_begin_unwind
1: std::panicking::begin_panic_fmt
2: chapter_02_listing_35::print_fizzbuzz
3: chapter_02_listing_35::main
...
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
调用栈显示了 main 函数调用了 print_fizzbuzz,print_fizzbuzz 在第 33 行调用 panic!。
给函数添加 panic! 会使代码更难读写,如果想用类似 ? 的简洁方式触发 panic,可以用 .unwrap() 或 .expect():
fn print_fizzbuzz(x: i32) {
match fizzbuzz(x).unwrap() {
FizzBuzzValue::FizzBuzz => println!("FizzBuzz"),
FizzBuzzValue::Fizz => println!("Fizz"),
FizzBuzzValue::Buzz => println!("Buzz"),
FizzBuzzValue::NotDivisible(num) => println!("{}", num),
}
}
代码更短,但遇错仍会 panic。尝试编译时可能遇到:
error[E0599]: no method named `unwrap` found for enum `std::result::Result<FizzBuzzValue, Error>` ...
note: the method `unwrap` exists but the following trait bounds were not satisfied:
`Error: std::fmt::Debug`
错误是因为 Error 类型没有实现 Debug trait。实现 Debug 允许错误在终端打印时格式化显示。
给 Error 添加 derive(Debug):
#[derive(Debug)]
enum Error {
GotNegative,
}
derive 自动生成实现,类似 Java 的 toString,让错误类型可打印。
完整代码示例:
enum FizzBuzzValue {
Fizz,
Buzz,
FizzBuzz,
NotDivisible(i32),
}
#[derive(Debug)]
enum Error {
GotNegative,
}
fn main() {
print_fizzbuzz(-1);
}
fn print_fizzbuzz(x: i32) {
match fizzbuzz(x).unwrap() {
FizzBuzzValue::FizzBuzz => println!("FizzBuzz"),
FizzBuzzValue::Fizz => println!("Fizz"),
FizzBuzzValue::Buzz => println!("Buzz"),
FizzBuzzValue::NotDivisible(num) => println!("{}", num),
}
}
fn fizzbuzz(x: i32) -> Result<FizzBuzzValue, Error> {
if x < 0 {
Err(Error::GotNegative)
} else if x % 3 == 0 && x % 5 == 0 {
Ok(FizzBuzzValue::FizzBuzz)
} else if x % 3 == 0 {
Ok(FizzBuzzValue::Fizz)
} else if x % 5 == 0 {
Ok(FizzBuzzValue::Buzz)
} else {
Ok(FizzBuzzValue::NotDivisible(x))
}
}
运行程序:
$ cargo run
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: GotNegative', src/main.rs:18:21
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
错误信息清楚指出了 panic 位置和错误值。对初学者来说,使用 .unwrap() 是最简单的错误处理方式,比起复杂的 Result 处理更容易入门。
不过大型项目中应设计合理的错误处理逻辑,避免因为请求数据错误而导致服务器崩溃。比如,初始化阶段遇配置文件错误,panic 退出则合理,因为无有效后续路径。
.unwrap() 只能给出错误信息,有时想附加更多上下文,使用 .expect():
fn print_fizzbuzz(x: i32) {
match fizzbuzz(x).expect("Failed to run fizzbuzz") {
FizzBuzzValue::FizzBuzz => println!("FizzBuzz"),
FizzBuzzValue::Fizz => println!("Fizz"),
FizzBuzzValue::Buzz => println!("Buzz"),
FizzBuzzValue::NotDivisible(num) => println!("{}", num),
}
}
运行结果:
$ cargo run
thread 'main' panicked at 'Failed to run fizzbuzz: GotNegative', main.rs:18:21
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
这样即使不看代码,也能快速定位到错误产生于 fizzbuzz 函数。expect 在大型项目中比 unwrap 更有用。
总结
- Rust 的所有权和借用系统提供了高性能,同时避免了手动内存管理带来的错误风险。
- 值的所有权让 Rust 编译器能够在程序运行前确定该值的创建、有效使用和释放时机。
- 所有编程语言中的值都有生命周期,但 Rust 编译器会显式强制执行这些规则。
- Rust 的生命周期系统确保引用始终有效,避免读取无效内存。
- Rust 提供多种字符串类型,赋予程序员对内存分配的强大控制;部分类型支持创建后可变,部分是只读视图。
- 枚举(enum)可用于存储预定义的有限集合值。
- 可能失败的函数返回 Result 类型,它是一个枚举,包含成功或失败的标记,以及成功时的值或失败时的错误信息。
- 不处理错误的可能性,无法使用 Result 中的成功值。
- 单元类型
()表示“无”,是一个类型也是一个值。 - 自定义错误类型是编写 Rust 代码的最佳实践。
?操作符可用于当 Result 持有错误时提前返回函数。map_err可将持有一种错误类型的 Result 转换为另一种错误类型的 Result。- 闭包(closures)可作为接受函数参数的函数的参数使用。
panic!可用于程序处于非法状态时展开线程调用栈并退出程序。.unwrap()和.expect()可用于在 Result 持有错误时引发 panic。