7. 使用包、Crate和模块管理不断增长的项目
模块系统(the module system) 包括:
- 包(Packages): Cargo 的一个功能,它允许你构建、测试和分享 crate。
- Crates :一个模块的树形结构,它形成了库或二进制项目。
- 模块(Modules)和 use: 允许你控制作用域和路径的私有性。
- 路径(path):一个命名例如结构体、函数或模块等项的方式
包和 crate
crate 是一个二进制项或者库。crate root 是一个源文件,Rust 编译器以它为起始点,并构成你的 crate 的根模块。
包(package) 是提供一系列功能的一个或者多个 crate。一个包会包含有一个 Cargo.toml 文件,阐述如何去构建这些 crate。
包中可以包含至多一个库 crate(library crate)。包中可以包含任意多个二进制 crate(binary crate),但是必须至少包含一个 crate(无论是库的还是二进制的)。
创建包(package) cargo new
:
$ cargo new my-project
Created binary (application) `my-project` package
$ ls my-project
Cargo.toml
src
$ ls my-project/src
main.rs
Cargo 遵循的一个约定:src/main.rs 就是一个与包同名的二进制 crate 的 crate 根。同样的,Cargo 知道如果包目录中包含 src/lib.rs,则包带有与其同名的库 crate,且 src/lib.rs 是 crate 根。crate 根文件将由 Cargo 传递给 rustc
来实际构建库或者二进制项目。
在此,我们有了一个只包含 src/main.rs 的包,意味着它只含有一个名为 my-project
的二进制 crate。如果一个包同时含有 src/main.rs 和 src/lib.rs,则它有两个 crate:一个二进制的和一个库的,且名字都与包相同。通过将文件放在 src/bin 目录下,一个包可以拥有多个二进制 crate:每个 src/bin 下的文件都会被编译成一个独立的二进制 crate。
定义模块来控制作用域与私有性
模块 让我们可以将一个 crate 中的代码进行分组,以提高可读性与重用性。模块还可以控制项的 私有性。
我们可以将函数放置到嵌套的模块中,来使我们的 crate 结构与实际的餐厅结构相同。通过执行 cargo new --lib restaurant
,来创建一个新的名为 restaurant
的库。
文件名: src/lib.rs
mod front_of_house {
mod hosting {
fn add_to_waitlist() {}
fn seat_at_table() {}
}
mod serving {
fn take_order() {}
fn serve_order() {}
fn take_payment() {}
}
}
模块还可以保存一些定义的其他项,比如结构体、枚举、常量、特性、或者函数。
src/main.rs
和 src/lib.rs
叫做 crate 根。
模块树(module tree)
crate
└── front_of_house
├── hosting
│ ├── add_to_waitlist
│ └── seat_at_table
└── serving
├── take_order
├── serve_order
└── take_payment
路径用于引用模块树中的项
路径有两种形式:
- 绝对路径(absolute path)从 crate 根开始,以 crate 名或者字面值
crate
开头。 - 相对路径(relative path)从当前模块开始,以
self
、super
或当前模块的标识符开头。
绝对路径和相对路径都后跟一个或多个由双冒号(::
)分割的标识符。
我们更倾向于使用绝对路径,因为把代码定义和项调用各自独立地移动是更常见的。
Rust 中默认所有项(函数、方法、结构体、枚举、模块和常量)都是私有的。父模块中的项不能使用子模块中的私有项,但是子模块中的项可以使用他们父模块中的项。这是因为子模块封装并隐藏了他们的实现详情,但是子模块可以看到他们定义的上下文。
你可以通过使用 pub
关键字来创建公共项,使子模块的内部部分暴露给上级模块。
使用 pub 关键字暴露路径
使模块公有并不使其内容也是公有的。模块上的 pub
关键字只允许其父模块引用它。
文件名: src/lib.rs
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
pub fn eat_at_restaurant() {
// 绝对路径
crate::front_of_house::hosting::add_to_waitlist();
// 相对路径
front_of_house::hosting::add_to_waitlist();
}
front_of_house
模块不是公有的,不过因为 eat_at_restaurant
函数与 front_of_house
定义于同一模块中,我们可以从 eat_at_restaurant
中引用 front_of_house
。也因定义于同一模块,所以从 eat_at_restaurant
中开始定义的该模块相对路径是有效的。
使用 super 起始的相对路径
我们还可以使用 super
开头来构建从父模块开始的相对路径。
文件名: src/lib.rs
fn serve_order() {}
mod back_of_house {
fn fix_incorrect_order() {
cook_order();
super::serve_order(); // 使用 `super` 进入 `back_of_house` 父模块
}
fn cook_order() {}
}
如果我们要重新组织这个 crate 的模块树,需要一起移动它们。
创建公有的结构体和枚举
如果我们在一个结构体定义的前面使用了 pub
,这个结构体会变成公有的,但是这个结构体的字段仍然是私有的。
文件名: src/lib.rs
mod back_of_house {
pub struct Breakfast {
pub toast: String,
seasonal_fruit: String,
}
impl Breakfast {
pub fn summer(toast: &str) -> Breakfast {
Breakfast {
toast: String::from(toast),
seasonal_fruit: String::from("peaches"),
}
}
}
}
pub fn eat_at_restaurant() {
// 在夏天订购一个黑麦土司作为早餐
let mut meal = back_of_house::Breakfast::summer("Rye");
// 改变注意更换想要面包的类型
meal.toast = String::from("Wheat");
println!("I'd like {} toast please", meal.toast);
// 如果取消下一行的注释代码不能编译;
// 不允许查看或修改早餐附带的季节水果
// meal.seasonal_fruit = String::from("blueberries");
}
这个例子模拟的情况是,在一家餐馆中,顾客可以选择随餐附赠的面包类型,但是厨师会根据季节和库存情况来决定随餐搭配的水果。餐馆可用的水果变化是很快的,所以顾客不能选择水果,甚至无法看到他们将会得到什么水果。
因为 back_of_house::Breakfast
具有私有字段,所以这个结构体需要提供一个公共的关联函数来构造 Breakfast
的实例(这里我们命名为 summer
)。如果 Breakfast
没有这样的函数,我们将无法在 eat_at_restaurant
中创建 Breakfast
实例,因为我们不能在 eat_at_restaurant
中设置私有字段 seasonal_fruit
的值。
与之相反,如果我们将枚举设为公有,则它的所有成员都将变为公有。
使用 use
关键字将名称引入作用域
文件名: src/lib.rs
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
use crate::front_of_house::hosting;
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
hosting::add_to_waitlist();
hosting::add_to_waitlist();
}
现在 hosting
在作用域中就是有效的名称了,通过 use
引入作用域的路径也会检查私有性,同其它路径一样。
你还可以使用 use
和相对路径来将一个项引入作用域。
use self::front_of_house::hosting;
创建惯用的 use 路径
文件名: src/lib.rs
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
use crate::front_of_house::hosting::add_to_waitlist;
pub fn eat_at_restaurant() {
add_to_waitlist();
add_to_waitlist();
add_to_waitlist();
}
使用 use
将 add_to_waitlist
函数引入作用域,这并不符合习惯。
另一方面,使用 use
引入结构体、枚举和其他项时,习惯是指定它们的完整路径。
use std::collections::HashMap;
fn main() {
let mut map = HashMap::new();
map.insert(1, 2);
}
使用 as 关键字提供新的名称
use std::fmt;
use std::io;
fn function1() -> fmt::Result {
// --snip--
}
fn function2() -> io::Result<()> {
// --snip--
}
使用 use
将两个同名类型引入同一作用域这个问题还有另一个解决办法:在这个类型的路径后面,我们使用 as
指定一个新的本地名称或者别名。
use std::fmt::Result;
use std::io::Result as IoResult;
使用 pub use 重导出名称
使用 use
关键字,将某个名称导入当前作用域后,这个名称在此作用域中就可以使用了,但它对此作用域之外还是私有的。如果想让其他人调用我们的代码时,也能够正常使用这个名称,就好像它本来就在当前作用域一样,那我们可以将 pub
和 use
合起来使用。这种技术被称为 “重导出(re-exporting)”:我们不仅将一个名称导入了当前作用域,还允许别人把它导入他们自己的作用域。
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
pub use crate::front_of_house::hosting;
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
hosting::add_to_waitlist();
hosting::add_to_waitlist();
}
通过 pub use
使名称可从新作用域中被导入至任何代码
通过 pub use
,外部代码现在可以通过新路径 hosting::add_to_waitlist
来调用 add_to_waitlist
函数。如果没有指定 pub use
,eat_at_restaurant
函数可以在其作用域中调用 hosting::add_to_waitlist
,但外部代码则不允许使用这个新路径。
使用外部包
文件名: Cargo.toml
rand = "0.8.3"
use rand::Rng;
fn main() {
let secret_number = rand::thread_rng().gen_range(1..101);
}
嵌套路径来消除大量的 use 行
use std::cmp::Ordering;
use std::io;
use std::{cmp::Ordering, io};
use std::io;
use std::io::Write;
use std::io::{self, Write};
通过 glob 运算符将所有的公有定义引入作用域
use std::collections::*;
将模块分割进不同文件
文件名: src/lib.rs
mod front_of_house;
pub use crate::front_of_house::hosting;
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
hosting::add_to_waitlist();
hosting::add_to_waitlist();
}
在 mod front_of_house
后使用分号,而不是代码块,这将告诉 Rust 在另一个与模块同名的文件中加载模块的内容。mod
关键字声明了模块,Rust 会在与模块同名的文件中查找模块的代码。
文件名: src/front_of_house.rs
pub mod hosting;
文件名: src/front_of_house/hosting.rs
pub fn add_to_waitlist() {}
8. 常见集合
不同于内建的数组和元组类型,这些集合指向的数据是储存在堆上的,这意味着数据的数量不必在编译时就已知,并且还可以随着程序的运行增长或缩小。
- vector 允许我们一个挨着一个地储存一系列数量可变的值
- 字符串(string)是字符的集合。我们之前见过
String
类型,不过在本章我们将深入了解。 - 哈希 map(hash map)允许我们将值与一个特定的键(key)相关联。这是一个叫做 map 的更通用的数据结构的特定实现。
对于标准库提供的其他类型的集合,请查看文档。
使用可变长数组(Vector)储存一系列的值
Vec<T>
,也被称为 vector。vector 允许我们在一个单独的数据结构中储存多于一个的值,它在内存中彼此相邻地排列所有的值。vector 只能储存相同类型的值。
新建 vector
新建一个空的 vector 来储存 i32
类型的值。
let v: Vec<i32> = Vec::new();
新建一个包含初值的 vector
let v = vec![1, 2, 3];
vec!
这个宏会根据我们提供的值来创建一个新的 vector。推断为 i32
是因为这是默认整型类型。
更新 vector
let mut v = Vec::new();
v.push(1);
v.push(2);
放入其中的所有值都是 i32
类型的,而且 Rust 也根据数据做出如此判断,所以不需要 Vec<i32>
注解。
丢弃 vector 时也会丢弃其所有元素
类似于任何其他的 struct
,vector 在其离开作用域时会被释放。
读取 vector 的元素
访问 vector 中一个值的两种方式,索引语法或者 get
方法:
let v = vec![1, 2, 3, 4, 5];
let third: &i32 = &v[2];
println!("The third element is {}", third);
match v.get(2) {
Some(third) => println!("The third element is {}", third),
None => println!("There is no third element."),
}
这两个不同的获取第三个元素的方式分别为:使用 &
和 []
返回一个引用;或者使用 get
方法以索引作为参数来返回一个 Option<&T>
。
尝试在当有一个 5 个元素的 vector 接着访问索引 100 位置的元素会发生什么:
let v = vec![1, 2, 3, 4, 5];
let does_not_exist = &v[100];
let does_not_exist = v.get(100);
对于第一个 []
方法,当引用一个不存在的元素时 Rust 会造成 panic。
当 get
方法被传递了一个数组外的索引时,它不会 panic 而是返回 None
。
一旦程序获取了一个有效的引用,借用检查器将会执行所有权和借用规则(第四章讲到)来确保 vector 内容的这个引用和任何其他引用保持有效。
当我们获取了 vector 的第一个元素的不可变引用并尝试在 vector 末尾增加一个元素的时候,如果尝试在函数的后面引用这个元素是行不通的:
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0];
v.push(6);
println!("The first element is: {}", first);
在拥有 vector 中项的引用的同时向其增加一个元素。
编译会给出这个错误:cannot borrow v
as mutable because it is also borrowed as immutable
为什么第一个元素的引用会关心 vector 结尾的变化? 不能这么做的原因是由于 vector 的工作方式:在 vector 的结尾增加新元素时,在没有足够空间将所有所有元素依次相邻存放的情况下,可能会要求分配新内存并将老的元素拷贝到新的空间中。这时,第一个元素的引用就指向了被释放的内存。借用规则阻止程序陷入这种状况。
遍历 vector 中的元素
通过 for
循环遍历 vector 的元素并打印
let v = vec![100, 32, 57];
for i in &v {
println!("{}", i);
}
我们也可以遍历可变 vector 的每一个元素的可变引用以便能改变他们。
let mut v = vec![100, 32, 57];
for i in &mut v {
*i += 50; // 给每一个元素加 `50`
}
使用解引用运算符(*
)获取 i
中的值。
使用枚举来储存多种类型
枚举的成员都被定义为相同的枚举类型,所以当需要在 vector 中储存不同类型值时,我们可以定义并使用一个枚举!
enum SpreadsheetCell {
GridInt(i32),
GridFloat(f64),
GridText(String),
}
let row = vec![
SpreadsheetCell::GridInt(3),
SpreadsheetCell::GridText(String::from("blue")),
SpreadsheetCell::GridFloat(10.12),
];
如果在编写程序时不能确切无遗地知道运行时会储存进 vector 的所有类型,枚举技术就行不通了。相反,你可以使用 trait 对象,第十七章会讲到它。
请一定去看看标准库中 Vec
定义的很多其他实用方法的 API 文档。
使用字符串(String)存储 UTF-8 编码的文本
Rust 的核心语言中只有一种字符串类型:str
,字符串 slice,它通常以被借用的形式出现,&str
。第四章讲到了 字符串 slice:它们是一些储存在别处的 UTF-8 编码字符串数据的引用。比如字符串字面值被储存在程序的二进制输出中,字符串 slice 也是如此。
称作 String
的类型是由标准库提供的,而没有写进核心语言部分,它是可增长的、可变的、有所有权的、UTF-8 编码的字符串类型。
String
和字符串 slice 都是 UTF-8 编码的。
新建字符串
let mut s = String::new();
通常字符串会有初始数据,因为我们希望一开始就有这个字符串。为此,可以使用 to_string
方法,它能用于任何实现了 Display
trait 的类型,字符串字面值也实现了它。
使用 to_string
方法从字符串字面值创建 String
。
let data = "initial contents";
let s = data.to_string();
// 该方法也可直接用于字符串字面值:
let s = "initial contents".to_string();
也可以使用 String::from
函数来从字符串字面值创建 String
。
let s = String::from("initial contents");
使用 String::from
函数从字符串字面值创建 String
更新字符串
使用 push_str
方法向 String
附加字符串 slice。
let mut s = String::from("foo");
s.push_str("bar");
push_str
方法采用字符串 slice,因为我们并不需要获取参数的所有权。
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 is {}", s2);
将 s2
的内容附加到 s1
之后还能使用它。
push
方法被定义为获取一个单独的字符作为参数,并附加到 String
中。
let mut s = String::from("lo");
s.push('l');
使用 + 运算符或 format! 宏拼接字符串
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // 注意 s1 被移动了,不能继续使用
执行完这些代码之后,字符串 s3
将会包含 Hello, world!
。s1
在相加后不再有效的原因,和使用 s2
的引用的原因,与使用 +
运算符时调用的函数签名有关。+
运算符使用了 add
函数,这个函数签名看起来像这样:
fn add(self, s: &str) -> String {
首先,s2
使用了 &
,意味着我们使用第二个字符串的 引用 与第一个字符串相加。这是因为 add
函数的 s
参数:只能将 &str
和 String
相加,不能将两个 String
值相加。不过等一下 —— 正如 add
的第二个参数所指定的,&s2
的类型是 &String
而不是 &str
。那么为什么还能编译呢?
之所以能够在 add
调用中使用 &s2
是因为 &String
可以被 强转(coerced)成 &str
。当add
函数被调用时,Rust 使用了一个被称为 Deref 强制转换(deref coercion)的技术,你可以将其理解为它把 &s2
变成了 &s2[..]
。第十五章会更深入的讨论 Deref 强制转换。因为 add
没有获取参数的所有权,所以 s2
在这个操作后仍然是有效的 String
。
其次,可以发现签名中 add
获取了 self
的所有权,因为 self
没有 使用 &
。这意味着 s1
的所有权将被移动到 add
调用中,之后就不再有效。所以虽然 let s3 = s1 + &s2;
看起来就像它会复制两个字符串并创建一个新的字符串,而实际上这个语句会获取 s1
的所有权,附加上从 s2
中拷贝的内容,并返回结果的所有权。换句话说,它看起来好像生成了很多拷贝,不过实际上并没有:这个实现比拷贝要更高效。
如果想要级联多个字符串,+
的行为就显得笨重了:
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = s1 + "-" + &s2 + "-" + &s3;
这时 s
的内容会是 “tic-tac-toe”。对于更为复杂的字符串链接,可以使用 format!
宏:
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = format!("{}-{}-{}", s1, s2, s3);
这些代码也会将 s
设置为 “tic-tac-toe”。format!
与 println!
的工作原理相同,不过不同于将输出打印到屏幕上,它返回一个带有结果内容的 String
。这个版本就好理解的多,宏 format!
生成的代码使用索引并且不会获取任何参数的所有权。
索引字符串
Rust 的字符串不支持索引。
String
是一个 Vec<u8>
的封装。
字节、标量值和字形簇
从 Rust 的角度来讲,事实上有三种相关方式可以理解字符串:字节、标量值和字形簇(最接近人们眼中 字母 的概念)。
印度语单词 “नमस्ते”
- 字节(byte,
Vec<u8>
)
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164, 224, 165, 135]
- Unicode 标量值(
char
)
['न', 'म', 'स', '्', 'त', 'े']
- 字形簇
["न", "म", "स्", "ते"]
字符串 slice
相比使用 []
和单个值的索引,可以使用 []
和一个 range 来创建含特定字节的字符串 slice:
let hello = "Здравствуйте";
let s = &hello[0..4];
这里,s
会是一个 &str
,它包含字符串的头四个字节。早些时候,我们提到了这些字母都是两个字节长的,所以这意味着 s
将会是 “Зд”。
如果获取 &hello[0..1]
会发生什么呢?答案是:Rust 在运行时会 panic,就跟访问 vector 中的无效索引时一样。
遍历字符串的方法
操作字符串每一部分的最好的方法是明确表示需要字符还是字节。对于单独的 Unicode 标量值使用 chars
方法。
for c in "नमस्ते".chars() {
println!("{}", c);
}
这些代码会打印出如下内容:
न
म
स
्
त
े
另外 bytes
方法返回每一个原始字节,这可能会适合你的使用场景:
for b in "नमस्ते".bytes() {
println!("{}", b);
}
这些代码会打印出组成 String
的 18 个字节:
224
164
// --snip--
165
135
不过请记住有效的 Unicode 标量值可能会由不止一个字节组成。
从字符串中获取字形簇是很复杂的,所以标准库并没有提供这个功能。
使用哈希映射(Hash Map)储存键值对
HashMap<K, V>
类型储存了一个键类型 K
对应一个值类型 V
的映射。它通过一个 哈希函数(hashing function)来实现映射,决定如何将键和值放入内存中。
新建一个哈希 map
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
这个 HashMap
的键类型是 String
而值类型是 i32
。类似于 vector,哈希 map 是同质的:所有的键必须是相同类型,值也必须都是相同类型。
另一个构建哈希 map 的方法是在一个元组的 vector 上使用迭代器(iterator)和 collect
方法,其中每个元组包含一个键值对。collect
方法可以将数据收集进一系列的集合类型,包括 HashMap
。例如,如果队伍的名字和初始分数分别在两个 vector 中,可以使用 zip
方法来创建一个元组的迭代器,其中 “Blue” 与 10 是一对,依此类推。接着就可以使用 collect
方法将这个元组的迭代器转换成一个 HashMap
,如示例 8-21 所示:
use std::collections::HashMap;
let teams = vec![String::from("Blue"), String::from("Yellow")];
let initial_scores = vec![10, 50];
let mut scores: HashMap<_, _> =
teams.into_iter().zip(initial_scores.into_iter()).collect();
这里 HashMap<_, _>
类型注解是必要的,对于键和值的类型参数来说,可以使用下划线占位,而 Rust 能够根据 vector 中数据的类型推断出 HashMap
所包含的类型。
哈希 map 和所有权
对于像 i32
这样的实现了 Copy
trait 的类型,其值可以拷贝进哈希 map。对于像 String
这样拥有所有权的值,其值将被移动而哈希 map 会成为这些值的所有者,如示例 8-22 所示:
use std::collections::HashMap;
let field_name = String::from("Favorite color");
let field_value = String::from("Blue");
let mut map = HashMap::new();
map.insert(field_name, field_value);
// 这里 field_name 和 field_value 不再有效
如果将值的引用插入哈希 map,这些值本身将不会被移动进哈希 map。但是这些引用指向的值必须至少在哈希 map 有效时也是有效的。
访问哈希 map 中的值
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
let team_name = String::from("Blue");
let score = scores.get(&team_name);
get
返回 Option<V>
,结果被装进 Some
;如果某个键在哈希 map 中没有对应的值,get
会返回 None
。
可以使用与 vector 类似的方式来遍历哈希 map 中的每一个键值对,也就是 for
循环:
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
for (key, value) in &scores {
println!("{}: {}", key, value);
}
更新哈希 map
- 覆盖一个值
用相同的键插入一个不同的值,与这个键相关联的旧值将被替换。
- 只在键没有对应值时插入
使用 entry
方法只在键没有对应一个值时插入,entry
函数的返回值是一个枚举,Entry
,它代表了可能存在也可能不存在的值。
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.entry(String::from("Yellow")).or_insert(50);
scores.entry(String::from("Blue")).or_insert(50);
println!("{:?}", scores);
Entry
的 or_insert
方法在键对应的值存在时就返回这个值的可变引用,如果不存在则将参数作为新值插入并返回新值的可变引用。
根据旧值更新一个值
我们使用哈希 map 以单词作为键并递增其值来记录我们遇到过几次这个单词。
use std::collections::HashMap;
let text = "hello world wonderful world";
let mut map = HashMap::new();
for word in text.split_whitespace() {
let count = map.entry(word).or_insert(0);
*count += 1;
}
println!("{:?}", map);
会打印出 {"world": 2, "hello": 1, "wonderful": 1}
。split_whitespace
方法会迭代 text
的值由空格分隔的子 slice。or_insert
方法返回这个键的值的一个可变引用(&mut V
)。这里我们将这个可变引用储存在 count
变量中,所以为了赋值必须首先使用星号(*
)解引用 count
。
HashMap
默认使用一种叫做 SipHash 的哈希函数。你可以指定一个不同的 hasher 来切换为其它函数。
9. 错误处理
Rust 将错误组合成两个主要类别:可恢复错误(recoverable)和 不可恢复错误(unrecoverable)。可恢复错误通常我们希望向用户报告错误并重试操作,比如未找到文件(file not found)错误。不可恢复错误通常是 bug 的同义词,比如尝试访问超过数组结尾的位置。
可恢复错误 Result<T, E>
,和不可恢复(遇到错误时停止程序执行)错误 panic!
。
panic!
与不可恢复的错误
Rust 有 panic!
宏。当执行这个宏时,程序会打印出一个错误信息,展开并清理栈数据,然后接着退出。出现这种情况的场景通常是检测到一些类型的 bug,而且程序员并不清楚该如何处理它。
当出现 panic 时,程序默认会开始 展开(unwinding)。
如果你想要在release模式中 panic 时直接 终止(abort):
Cargo.toml
[profile.release]
panic = 'abort'
调用 panic!
:
fn main() {
panic!("crash and burn");
}
使用 panic!
的 backtrace
fn main() {
let v = vec![1, 2, 3];
v[99];
}
$ cargo run
Compiling panic v0.1.0 (file:///projects/panic)
Finished dev [unoptimized + debuginfo] target(s) in 0.27s
Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
下面的说明(note)行提醒我们可以设置 RUST_BACKTRACE
环境变量来得到一个 backtrace。backtrace 是一个执行到目前位置所有被调用的函数的列表。
$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
stack backtrace:
0: rust_begin_unwind
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/std/src/panicking.rs:483
1: core::panicking::panic_fmt
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/panicking.rs:85
2: core::panicking::panic_bounds_check
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/panicking.rs:62
3: <usize as core::slice::index::SliceIndex<[T]>>::index
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/slice/index.rs:255
4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/slice/index.rs:15
5: <alloc::vec::Vec<T> as core::ops::index::Index<I>>::index
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/alloc/src/vec.rs:1982
6: panic::main
at ./src/main.rs:4
7: core::ops::function::FnOnce::call_once
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/ops/function.rs:227
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
Result
与可恢复的错误
Result
枚举定义有如下两个成员,Ok
和 Err
:
enum Result<T, E> {
Ok(T),
Err(E),
}
T
代表成功时返回的 Ok
成员中的数据的类型,而 E
代表失败时返回的 Err
成员中的错误的类型。
让我们调用一个返回 Result
的函数,因为它可能会失败:如示例 9-3 所示打开一个文件:
use std::fs::File;
fn main() {
let f = File::open("hello.txt");
}
如何知道 File::open
返回一个 Result
呢?我们可以查看 标准库 API 文档,或者可以直接问编译器!
let f: u32 = File::open("hello.txt");
现在尝试编译会给出如下输出:
$ cargo run
Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0308]: mismatched types
--> src/main.rs:4:18
|
4 | let f: u32 = File::open("hello.txt");
| --- ^^^^^^^^^^^^^^^^^^^^^^^ expected `u32`, found enum `Result`
| |
| expected due to this
|
= note: expected type `u32`
found enum `Result<File, std::io::Error>`
For more information about this error, try `rustc --explain E0308`.
error: could not compile `error-handling` due to previous error
泛型参数 T
放入了成功值的类型 std::fs::File
,它是一个文件句柄。E
被用在失败值上时 E
的类型是 std::io::Error
。
use std::fs::File;
fn main() {
let f = File::open("hello.txt");
let f = match f {
Ok(file) => file,
Err(error) => panic!("Problem opening the file: {:?}", error),
};
}
匹配不同的错误
如果 File::open
因为文件不存在而失败,我们希望创建这个文件并返回新文件的句柄。如果 File::open
因为任何其他原因失败,例如没有打开文件的权限,我们仍然希望像示例 9-4 那样 panic!
。
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let f = File::open("hello.txt");
let f = match f {
Ok(file) => file,
Err(error) => match error.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("Problem creating the file: {:?}", e),
},
other_error => {
panic!("Problem opening the file: {:?}", other_error)
}
},
};
}
闭包(closure) 可以用于很多 Result<T, E>
上定义的方法。在处理代码中的 Result<T, E>
值时这些方法可能会更加简洁。使用闭包和 unwrap_or_else
方法的例子:
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let f = File::open("hello.txt").unwrap_or_else(|error| {
if error.kind() == ErrorKind::NotFound {
File::create("hello.txt").unwrap_or_else(|error| {
panic!("Problem creating the file: {:?}", error);
})
} else {
panic!("Problem opening the file: {:?}", error);
}
});
}
失败时 panic 的简写:unwrap 和 expect
如果 Result
值是成员 Ok
,unwrap
会返回 Ok
中的值。如果 Result
是成员 Err
,unwrap
会为我们调用 panic!
。
use std::fs::File;
fn main() {
let f = File::open("hello.txt").unwrap();
}
expect
与 unwrap
的使用方式一样:返回文件句柄或调用 panic!
宏。expect
在调用 panic!
时使用的错误信息将是我们传递给 expect
的参数,而不像 unwrap
那样使用默认的 panic!
信息。
use std::fs::File;
fn main() {
let f = File::open("hello.txt").expect("Failed to open hello.txt");
}
传播错误
当编写一个其实现会调用一些可能会失败的操作的函数时,除了在这个函数中处理错误外,还可以选择让调用者知道这个错误并决定该如何处理。这被称为 传播(propagating)错误。
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let f = File::open("hello.txt");
let mut f = match f {
Ok(file) => file,
Err(e) => return Err(e),
};
let mut s = String::new();
match f.read_to_string(&mut s) {
Ok(_) => Ok(s),
Err(e) => Err(e),
}
}
调用这个函数的代码最终会得到一个包含用户名的 Ok
值,或者一个包含 io::Error
的 Err
值。
传播错误的简写:? 运算符
use std::fs::File;
use std::io;
use std::io::Read;
fn read_username_from_file() -> Result<String, io::Error> {
let mut f = File::open("hello.txt")?;
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}
Result
值之后的 ?
被定义为与示例 9-6 中定义的处理 Result
值的 match
表达式有着完全相同的工作方式。如果 Result
的值是 Ok
,这个表达式将会返回 Ok
中的值而程序将继续执行。如果值是 Err
,Err
中的值将作为整个函数的返回值,就好像使用了 return
关键字一样,这样错误值就被传播给了调用者。
有一点不同:?
运算符所使用的错误值被传递给了 from
函数,它定义于标准库的 From
trait 中,其用来将错误从一种类型转换为另一种类型。当 ?
运算符调用 from
函数时,收到的错误类型被转换为由当前函数返回类型所指定的错误类型。
我们甚至可以在 ?
之后直接使用链式方法调用来进一步缩短代码:
use std::fs::File;
use std::io;
use std::io::Read;
fn read_username_from_file() -> Result<String, io::Error> {
let mut s = String::new();
File::open("hello.txt")?.read_to_string(&mut s)?;
Ok(s)
}
甚至还有一个更短的写法:
use std::fs;
use std::io;
fn read_username_from_file() -> Result<String, io::Error> {
fs::read_to_string("hello.txt")
}
将文件读取到一个字符串是相当常见的操作,所以 Rust 提供了名为 fs::read_to_string
的函数,它会打开文件、新建一个 String
、读取文件的内容,并将内容放入 String
,接着返回它。
哪里可以使用 ? 运算符
?
运算符只能被用于返回值与 ?
作用的值相兼容的函数。
尝试在返回 ()
的 main
函数中使用 ?
的代码不能编译:
use std::fs::File;
fn main() {
let f = File::open("hello.txt")?;
}
?
运算符作用于 File::open
返回的 Result
值,不过 main
函数的返回类型是 ()
而不是 Result
。
?
也可用于 Option<T>
值。在 Option<T>
上调用 ?
运算符的行为与 Result<T, E>
类似:如果值是 None
,此时 None
会从函数中提前返回。如果值是 Some
,Some
中的值作为表达式的返回值同时函数继续。
从给定文本中返回第一行最后一个字符的函数:
fn last_char_of_first_line(text: &str) -> Option<char> {
text.lines().next()?.chars().last()
}
幸运的是 main
函数也可以返回 Result<(), E>
,这段代码可以编译:
use std::error::Error;
use std::fs::File;
fn main() -> Result<(), Box<dyn Error>> {
let f = File::open("hello.txt")?;
Ok(())
}
Box<dyn Error>
类型是一个 trait 对象(trait object)。目前可以将 Box<dyn Error>
理解为 “任何类型的错误”。
main
函数也可以返回任何实现了 std::process::Termination
trait 的类型。
panic!
还是不 panic!
在当有可能会导致有害状态的情况下建议使用 panic!
当错误预期会出现时,返回 Result
仍要比调用 panic!
更为合适。
创建自定义类型进行有效性验证
回忆一下第二章的猜猜看游戏,我们的代码要求用户猜测一个 1 到 100 之间的数字,当用户猜测一个超出范围的数字或者输入字母时采取不同的行为。
一种实现方式是将猜测解析成 i32
而不仅仅是 u32
,来默许输入负数,接着检查数字是否在范围内:
loop {
// --snip--
let guess: i32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
if guess < 1 || guess > 100 {
println!("The secret number will be between 1 and 100.");
continue;
}
match guess.cmp(&secret_number) {
// --snip--
}
在每个函数中都有这样的检查将是非常冗余的(并可能潜在的影响性能)。
相反我们可以创建一个新类型来将验证放入创建其实例的函数中,而不是到处重复这些检查。这样就可以安全的在函数签名中使用新类型并相信他们接收到的值。
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {}.", value);
}
Guess { value }
}
pub fn value(&self) -> i32 {
self.value
}
}
示例 9-13:一个 Guess
类型,它只在值位于 1 和 100 之间时才继续
如果 value
没有通过测试则调用 panic!
,这会警告调用这个函数的程序员有一个需要修改的 bug,因为创建一个 value
超出范围的 Guess
将会违反 Guess::new
所遵循的契约。
于是,一个接收(或返回) 1 到 100 之间数字的函数就可以声明为接收(或返回) Guess
的实例,而不是 i32
,同时其函数体中也无需进行任何额外的检查。
总结
Rust 的错误处理功能被设计为帮助你编写更加健壮的代码。panic!
宏代表一个程序无法处理的状态,并停止执行而不是使用无效或不正确的值继续处理。Rust 类型系统的 Result
枚举代表操作可能会在一种可以恢复的情况下失败。可以使用 Result
来告诉代码调用者他需要处理潜在的成功或失败。在适当的场景使用 panic!
和 Result
将会使你的代码在面对不可避免的错误时显得更加可靠。