5章、结构体
结构体比元组更灵活:不需要依赖顺序来指定或访问实例中的值。
定义结构体,需要使用 struct 关键字并为整个结构体提供一个名字。
#![allow(unused)]
fn main() {
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}
}
通过为每个字段指定具体值来创建这个结构体的 实例,
创建一个实例需要以结构体的名字开头,接着在大括号中使用 key: value 键-值对的形式提供字段,其中 key 是字段的名字,value 是需要存储在字段中的数据值。实例中字段的顺序不需要和它们在结构体中声明的顺序一致。换句话说,结构体的定义就像一个类型的通用模板,而实例则会在这个模板中放入特定数据来创建这个类型的值。
#![allow(unused)]
fn main() {
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}
let user1 = User {
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};
}
要更改结构体中的值,如果结构体的实例是可变的,我们可以使用点号并为对应的字段赋值
#![allow(unused)]
fn main() {
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}
let mut user1 = User {
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};
user1.email = String::from("anotheremail@example.com"); //赋值
}
注意整个实例必须是可变的;Rust 并不允许只将某个字段标记为可变。另外需要注意同其他任何表达式一样,我们可以在函数体的最后一个表达式中构造一个结构体的新实例,来隐式地返回这个实例。
#![allow(unused)]
fn main() {
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}
fn build_user(email: String, username: String) -> User {
User {
email: email,
username: username,
active: true,
sign_in_count: 1,
}
}
}
变量与字段同名简写
build_user 函数使用了字段初始化简写语法,因为 email 和 username 参数与结构体字段同名
这里我们创建了一个新的 User 结构体实例,它有一个叫做 email 的字段。我们想要将 email 字段的值设置为 build_user 函数 email 参数的值。因为 email 字段与 email 参数有着相同的名称,则只需编写 email 而不是 email: email。
#![allow(unused)]
fn main() {
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}
fn build_user(email: String, username: String) -> User {
User {
email,
username,
active: true,
sign_in_count: 1,
}
}
}
使用结构体更新语法从其他实例创建实例
不使用更新语法时,如何在 user2 中创建一个新 User 实例。我们为 email 和 username 设置了新的值,其他值则使用了 user1 中的同名值:
#![allow(unused)]
fn main() {
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}
let user1 = User {
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};
let user2 = User {
email: String::from("another@example.com"),
username: String::from("anotherusername567"),
active: user1.active,
sign_in_count: user1.sign_in_count,
};
}
使用结构体更新语法,我们可以通过更少的代码来达到相同的效果。.. 语法指定了剩余未显式设置值的字段应有与给定实例对应字段相同的值。
#![allow(unused)]
fn main() {
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}
let user1 = User {
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};
let user2 = User {
email: String::from("another@example.com"),
username: String::from("anotherusername567"),
..user1
};
}
元组结构体
元组结构体有着结构体名称提供的含义,但没有具体的字段名,只有字段的类型。当你想给整个元组取一个名字,并使元组成为与其他元组不同的类型时,元组结构体是很有用的,这时像常规结构体那样为每个字段命名就显得多余和形式化了。
要定义元组结构体,以 struct 关键字和结构体名开头并后跟元组中的类型。
#![allow(unused)]
fn main() {
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);
let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);
}
注意 black 和 origin 值的类型不同,因为它们是不同的元组结构体的实例。你定义的每一个结构体有其自己的类型,即使结构体中的字段有着相同的类型。例如,一个获取 Color 类型参数的函数不能接受 Point 作为参数,即便这两个类型都由三个 i32 值组成。在其他方面,元组结构体实例类似于元组:可以将其解构为单独的部分,也可以使用 . 后跟索引来访问单独的值,等等。
类单元结构体
我们也可以定义一个没有任何字段的结构体!它们被称为 类单元结构体(unit-like structs)因为它们类似于 (),即 unit 类型。类单元结构体常常在你想要在某个类型上实现 trait 但不需要在类型中存储数据的时候发挥作用。我们将在第十章介绍 trait。
结构体数据的所有权
一句话总结:结构体存储引用必须指定生命周期
生命周期确保结构体引用的数据有效性跟结构体本身保持一致。如果你尝试在结构体中存储一个引用而不指定生命周期将是无效的,比如这样:
struct User {
username: &str, //结构体存储引用必须指定生命周期
email: &str,
sign_in_count: u64,
active: bool,
}
fn main() {
let user1 = User {
email: "someone@example.com",
username: "someusername123",
active: true,
sign_in_count: 1,
};
}
此时编译会报错
error[E0106]: missing lifetime specifier
-->
|
2 | username: &str,
| ^ expected lifetime parameter
error[E0106]: missing lifetime specifier
-->
|
3 | email: &str,
| ^ expected lifetime parameter
第十章会讲到如何修复这个问题以便在结构体中存储引用,不过现在,我们会使用像 String 这类拥有所有权的类型来替代 &str 这样的引用以修正这个错误。
使用结构体
获取以像素为单位的长方形的宽度和高度,并计算出长方形的面积。
fn main() {
let width1 = 30;
let height1 = 50;
println!(
"The area of the rectangle is {} square pixels.",
area(width1, height1)
);
}
fn area(width: u32, height: u32) -> u32 {
width * height
}
cargo run 运行程序:
The area of the rectangle is 1500 square pixels.
使用元组重构
fn main() {
let rect1 = (30, 50);
println!(
"The area of the rectangle is {} square pixels.",
area(rect1)
);
}
fn area(dimensions: (u32, u32)) -> u32 {
dimensions.0 * dimensions.1
}
在某种程度上说,这个程序更好一点了。元组帮助我们增加了一些结构性,并且现在只需传一个参数。不过在另一方面,这个版本却有一点不明确了:元组并没有给出元素的名称,所以计算变得更费解了,因为不得不使用索引来获取元组的每一部分。
我们必须牢记 width 的元组索引是 0,height 的元组索引是 1。如果其他人要使用这些代码,他们必须要搞清楚这一点,并也要牢记于心。很容易忘记或者混淆这些值而造成错误,因为我们没有在代码中传达数据的意图。
使用结构体重构
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle { width: 30, height: 50 };
println!(
"The area of the rectangle is {} square pixels.",
area(&rect1)
);
}
fn area(rectangle: &Rectangle) -> u32 {
rectangle.width * rectangle.height
}
这里我们定义了一个结构体并称其为 Rectangle。在大括号中定义了字段 width 和 height,类型都是 u32。接着在 main 中,我们创建了一个具体的 Rectangle 实例,它的宽是 30,高是 50。
函数 area 现在被定义为接收一个名叫 rectangle 的参数,其类型是一个结构体 Rectangle 实例的不可变借用。第四章讲到过,我们希望借用结构体而不是获取它的所有权,这样 main 函数就可以保持 rect1 的所有权并继续使用它,所以这就是为什么在函数签名和调用的地方会有 &。
area 函数访问 Rectangle 实例的 width 和 height 字段。area 的函数签名现在明确的阐述了我们的意图:使用 Rectangle 的 width 和 height 字段,计算 Rectangle 的面积。这表明宽高是相互联系的,并为这些值提供了描述性的名称而不是使用元组的索引值 0 和 1 。结构体胜在更清晰明了。
派生 trait 增加实用功能
如果能够在调试程序时打印出 Rectangle 实例来查看其所有字段的值就更好了。
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle { width: 30, height: 50 };
println!("rect1 is {}", rect1);
}
编译报错
无法使用 println! 宏。
println! 宏能处理很多类型的格式,不过,{} 默认告诉 println! 使用被称为 Display 的格式:意在提供给直接终端用户查看的输出。目前为止见过的基本类型都默认实现了 Display,因为它就是向用户展示 1 或其他任何基本类型的唯一方式。不过对于结构体,println! 应该用来输出的格式是不明确的。
正确写法
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle { width: 30, height: 50 };
println!("rect1 is {:?}", rect1);
}
增加注解来派生 Debug trait,并使用调试格式打印 Rectangle 实例
现在我们再运行这个程序时,就不会有任何错误,并会出现如下输出:
rect1 is Rectangle { width: 30, height: 50 }
现在 println! 宏调用看起来像 println!("rect1 is {:?}", rect1); 这样。在 {} 中加入 :? 指示符告诉 println! 我们想要使用叫做 Debug 的输出格式。Debug 是一个 trait,它允许我们以一种对开发者有帮助的方式打印结构体,以便当我们调试代码时能看到它的值。
在结构体定义之前加上 #[derive(Debug)] 注解 会显示编译错误调试信息比如
= help: the trait `std::fmt::Debug` is not implemented for `Rectangle`
= note: add `#[derive(Debug)]` or manually implement `std::fmt::Debug`
如果在这个例子中使用了 {:#?} 风格的话,输出会看起来像这样:
rect1 is Rectangle {
width: 30,
height: 50
}
方法语法
方法 与函数类似:它们使用 fn 关键字和名称声明,可以拥有参数和返回值,同时包含在某处调用该方法时会执行的代码。不过方法与函数是不同的,因为它们在结构体的上下文中被定义,并且它们第一个参数总是 self,它代表调用该方法的结构体实例。
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}
fn main() {
let rect1 = Rectangle { width: 30, height: 50 };
println!(
"The area of the rectangle is {} square pixels.",
rect1.area()
);
}
一句话解释:把结构体当成接口类,把area方法当成添加的实现方法,用接口实例调用方法
为了使函数定义于 Rectangle 的上下文中,我们开始了一个 impl 块(impl 是 implementation 的缩写)。接着将 area 函数移动到 impl 大括号中,并将签名中的第一个(在这里也是唯一一个)参数和函数体中其他地方的对应参数改成 self。然后在 main 中将我们先前调用 area 方法并传递 rect1 作为参数的地方,改成使用 方法语法(method syntax)在 Rectangle 实例上调用 area 方法。方法语法获取一个实例并加上一个点号,后跟方法名、圆括号以及任何参数。
在 area 的签名中,使用 &self 来替代 rectangle: &Rectangle,因为该方法位于 impl Rectangle 上下文中所以 Rust 知道 self 的类型是 Rectangle。注意仍然需要在 self 前面加上 &,就像 &Rectangle 一样。方法可以选择获取 self 的所有权,或者像我们这里一样不可变地借用 self,或者可变地借用 self,就跟其他参数一样。
这里选择 &self 的理由跟在函数版本中使用 &Rectangle 是相同的:我们并不想获取所有权,只希望能够读取结构体中的数据,而不是写入。如果想要在方法中改变调用方法的实例,需要将第一个参数改为 &mut self。通过仅仅使用 self 作为第一个参数来使方法获取实例的所有权是很少见的;这种技术通常用在当方法将 self 转换成别的实例的时候,这时我们想要防止调用者在转换之后使用原始的实例。
使用方法替代函数,除了可使用方法语法和不需要在每个函数签名中重复 self 的类型之外,其主要好处在于组织性。我们将某个类型实例能做的所有事情都一起放入 impl 块中,而不是让将来的用户在我们的库中到处寻找 Rectangle 的功能。
自动引用和解引用
在 C/C++ 语言中,有两个不同的运算符来调用方法:. 直接在对象上调用方法,而 -> 在一个对象的指针上调用方法,这时需要先解引用(dereference)指针。换句话说,如果 object 是一个指针,那么 object->something() 就像 (*object).something() 一样。
Rust 并没有一个与 -> 等效的运算符;相反,Rust 有一个叫 自动引用和解引用(automatic referencing and dereferencing)的功能。方法调用是 Rust 中少数几个拥有这种行为的地方。
他是这样工作的:当使用 object.something() 调用方法时,Rust 会自动为 object 添加 &、&mut 或 * 以便使 object 与方法签名匹配。也就是说,这些代码是等价的:
p1.distance(&p2);
(&p1).distance(&p2);
第一行看起来简洁的多。这种自动引用的行为之所以有效,是因为方法有一个明确的接收者———— self 的类型。在给出接收者和方法名的前提下,Rust 可以明确地计算出方法是仅仅读取(&self),做出修改(&mut self)或者是获取所有权(self)。事实上,Rust 对方法接收者的隐式借用让所有权在实践中更友好。
关联函数
impl 块的另一个有用的功能是:允许在 impl 块中定义 不 以 self 作为参数的函数。这被称为 关联函数(associated functions),因为它们与结构体相关联。它们仍是函数而不是方法,因为它们并不作用于一个结构体的实例。你已经使用过 String::from 关联函数了。
关联函数经常被用作返回一个结构体新实例的构造函数。例如我们可以提供一个关联函数,它接受一个维度参数并且同时作为宽和高,这样可以更轻松的创建一个正方形 Rectangle 而不必指定两次同样的值:
#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn square(size: u32) -> Rectangle {
Rectangle { width: size, height: size }
}
}
}
使用结构体名和 :: 语法来调用这个关联函数:比如 let sq = Rectangle::square(3);。这个方法位于结构体的命名空间中::: 语法用于关联函数和模块创建的命名空间。
多个 impl 块
每个结构体都允许拥有多个 impl 块。
#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
}
这里没有理由将这些方法分散在多个 impl 块中,不过这是有效的语法。
总结
结构体让你可以创建出在你的领域中有意义的自定义类型。通过结构体,我们可以将相关联的数据片段联系起来并命名它们,这样可以使得代码更加清晰。方法允许为结构体实例指定行为,而关联函数将特定功能置于结构体的命名空间中并且无需一个实例。
但结构体并不是创建自定义类型的唯一方法:让我们转向 Rust 的枚举功能。
6章、枚举与match
任何一个 IP 地址要么是 IPv4 的要么是 IPv6 的,而且不能两者都是。IP 地址的这个特性使得枚举数据结构非常适合这个场景,因为枚举值只可能是其中一个成员。IPv4 和 IPv6 从根本上讲仍是 IP 地址,所以当代码在处理适用于任何类型的 IP 地址的场景时应该把它们当作相同的类型。
可以通过在代码中定义一个 IpAddrKind 枚举来表现这个概念并列出可能的 IP 地址类型,V4 和 V6。这被称为枚举的 成员(variants):
#![allow(unused)]
fn main() {
enum IpAddrKind {
V4,
V6,
}
}
现在 IpAddrKind 就是一个可以在代码中使用的自定义数据类型了。
枚举值
可以像这样创建 IpAddrKind 两个不同成员的实例:
#![allow(unused)]
fn main() {
enum IpAddrKind {
V4,
V6,
}
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
}
注意枚举的成员位于其标识符的命名空间中,并使用两个冒号分开。这么设计的益处是现在 IpAddrKind::V4 和 IpAddrKind::V6 都是 IpAddrKind 类型的。例如,接着可以定义一个函数来获取任何 IpAddrKind:
#![allow(unused)]
fn main() {
enum IpAddrKind {
V4,
V6,
}
fn route(ip_type: IpAddrKind) { }
}
现在可以使用任一成员来调用这个函数:
#![allow(unused)]
fn main() {
enum IpAddrKind {
V4,
V6,
}
fn route(ip_type: IpAddrKind) { }
route(IpAddrKind::V4);
route(IpAddrKind::V6);
}
使用枚举甚至还有更多优势。进一步考虑一下我们的 IP 地址类型,目前没有一个存储实际 IP 地址 数据 的方法;只知道它是什么 类型 的。考虑到已经在第五章学习过结构体了,你可能会像这样处理这个问题:
#![allow(unused)]
fn main() {
enum IpAddrKind {
V4,
V6,
}
struct IpAddr {
kind: IpAddrKind,
address: String,
}
let home = IpAddr {
kind: IpAddrKind::V4,
address: String::from("127.0.0.1"),
};
let loopback = IpAddr {
kind: IpAddrKind::V6,
address: String::from("::1"),
};
}
我们可以使用一种更简洁的方式来表达相同的概念,仅仅使用枚举并将数据直接放进每一个枚举成员而不是将枚举作为结构体的一部分。IpAddr 枚举的新定义表明了 V4 和 V6 成员都关联了 String 值:
#![allow(unused)]
fn main() {
enum IpAddr {
V4(String),
V6(String),
}
let home = IpAddr::V4(String::from("127.0.0.1"));
let loopback = IpAddr::V6(String::from("::1"));
}
我们直接将数据附加到枚举的每个成员上,这样就不需要一个额外的结构体了。
用枚举替代结构体还有另一个优势:每个成员可以处理不同类型和数量的数据。IPv4 版本的 IP 地址总是含有四个值在 0 和 255 之间的数字部分。如果我们想要将 V4 地址存储为四个 u8 值而 V6 地址仍然表现为一个 String,这就不能使用结构体了。枚举则可以轻易的处理这个情况:
#![allow(unused)]
fn main() {
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));
}
这些代码展示了使用枚举来存储两种不同 IP 地址的几种可能的选择。让我们看看标准库是如何定义 IpAddr 的:它正有着跟我们定义和使用的一样的枚举和成员,不过它将成员中的地址数据嵌入到了两个不同形式的结构体中,它们对不同的成员的定义是不同的:
#![allow(unused)]
fn main() {
struct Ipv4Addr {
// --snip--
}
struct Ipv6Addr {
// --snip--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
}
这些代码展示了可以将任意类型的数据放入枚举成员中:例如字符串、数字类型或者结构体。甚至可以包含另一个枚举!另外,标准库中的类型通常并不比你设想出来的要复杂多少。
注意虽然标准库中包含一个 IpAddr 的定义,仍然可以创建和使用我们自己的定义而不会有冲突,因为我们并没有将标准库中的定义引入作用域。第七章会讲到如何导入类型。
来看看另一个枚举的例子:它的成员中内嵌了多种多样的类型:
#![allow(unused)]
fn main() {
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
}
一个 Message 枚举,其每个成员都存储了不同数量和类型的值
这个枚举有四个含有不同类型的成员:
Quit没有关联任何数据。Move包含一个匿名结构体。Write包含单独一个String。ChangeColor包含三个i32。
与有关联值的枚举的方式和定义多个不同类型的结构体的方式很相像,除了枚举不使用 struct 关键字以及其所有成员都被组合在一起位于 Message 类型下。如下这些结构体可以包含与之前枚举成员中相同的数据:
#![allow(unused)]
fn main() {
struct QuitMessage; // 类单元结构体
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String); // 元组结构体
struct ChangeColorMessage(i32, i32, i32); // 元组结构体
}
结构体和枚举还有另一个相似点:就像可以使用 impl 来为结构体定义方法那样,也可以在枚举上定义方法。这是一个定义于我们 Message 枚举上的叫做 call 的方法:
#![allow(unused)]
fn main() {
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
impl Message {
fn call(&self) {
// 在这里定义方法体
}
}
let m = Message::Write(String::from("hello"));
m.call();
}
方法体使用了 self 来获取调用方法的值。这个例子中,创建了一个值为 Message::Write(String::from("hello")) 的变量 m,而且这就是当 m.call() 运行时 call 方法中的 self 的值。
option
让我们看看标准库中的另一个非常常见且实用的枚举:Option。
Option 是标准库定义的另一个枚举。Option 类型应用广泛因为它编码了一个非常普遍的场景,即一个值要么有值要么没值。
Rust 并没有很多其他语言中有的空值功能。空值(Null )是一个值,它代表没有值。在有空值的语言中,变量总是这两种状态之一:空值和非空值。
Rust 并没有空值,不过它确实拥有一个可以编码存在或不存在概念的枚举。这个枚举是 Option<T>,而且它定义于标准库中,如下:
#![allow(unused)]
fn main() {
enum Option<T> {
Some(T),
None,
}
}
目前,所有你需要知道的就是 <T> 意味着 Option 枚举的 Some 成员可以包含任意类型的数据。这里是一些包含数字类型和字符串类型 Option 值的例子:
#![allow(unused)]
fn main() {
let some_number = Some(5);
let some_string = Some("a string");
let absent_number: Option<i32> = None;
}
如果使用 None 而不是 Some,需要告诉 Rust Option<T> 是什么类型的,因为编译器只通过 None 值无法推断出 Some 成员保存的值的类型。
当有一个 Some 值时,我们就知道存在一个值,而这个值保存在 Some 中。当有个 None 值时,在某种意义上,它跟空值具有相同的意义:并没有一个有效的值。
简而言之,因为 Option<T> 和 T(这里 T 可以是任何类型)是不同的类型,编译器不允许像一个肯定有效的值那样使用 Option<T>。例如,这段代码不能编译,因为它尝试将 Option<i8> 与 i8 相加:
let x: i8 = 5;
let y: Option<i8> = Some(5);
let sum = x + y;
如果运行这些代码,将得到类似这样的错误信息:(简而言之:一个是枚举一个是整型)
error[E0277]: the trait bound `i8: std::ops::Add<std::option::Option<i8>>` is
not satisfied
-->
|
5 | let sum = x + y;
| ^ no implementation for `i8 + std::option::Option<i8>`
|
事实上,错误信息意味着 Rust 不知道该如何将 Option<i8> 与 i8 相加,因为它们的类型不同。当在 Rust 中拥有一个像 i8 这样类型的值时,编译器确保它总是有一个有效的值。
这是 Rust 的一个经过深思熟虑的设计决策,来限制空值的泛滥以增加 Rust 代码的安全性。
match 控制流运算符
Rust 有一个叫做 match 的极为强大的控制流运算符,它允许我们将一个值与一系列的模式相比较,并根据相匹配的模式执行相应代码。模式可由字面值、变量、通配符和许多其他内容构成;
可以把 match 表达式想象成某种硬币分类器:硬币滑入有着不同大小孔洞的轨道,每一个硬币都会掉入符合它大小的孔洞。同样地,值也会通过 match 的每一个模式,并且在遇到第一个 “符合” 的模式时,值会进入相关联的代码块并在执行中被使用。
我们可以编写一个函数来获取一个未知的硬币,并以一种类似验钞机的方式,确定它是何种硬币并返回它的美分值
#![allow(unused)]
fn main() {
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
}
#![allow(unused)]
fn main() {
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => {
println!("Lucky penny!");
1
},
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
}
当 match 表达式执行时,它将结果值按顺序与每一个分支的模式相比较。如果模式匹配了这个值,这个模式相关联的代码将被执行。如果模式并不匹配这个值,将继续执行下一个分支,非常类似一个硬币分类器。如果想要在分支中运行多行代码,可以使用大括号。
匹配分支的另一个有用的功能是可以绑定匹配的模式的部分值。这也就是如何从枚举成员中提取值的。
作为一个例子,让我们修改枚举的一个成员来存放数据。1999 年到 2008 年间,美国在 25 美分的硬币的一侧为 50 个州的每一个都印刷了不同的设计。其他的硬币都没有这种区分州的设计,所以只有这些 25 美分硬币有特殊的价值。可以将这些信息加入我们的 enum,通过改变 Quarter 成员来包含一个 State 值,
#![allow(unused)]
fn main() {
#[derive(Debug)] // 这样可以立刻看到州的名称
enum UsState {
Alabama,
Alaska,
// --snip--
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
}
想象一下我们的一个朋友尝试收集所有 50 个州的 25 美分硬币。在根据硬币类型分类零钱的同时,也可以报告出每个 25 美分硬币所对应的州名称,这样如果我们的朋友没有的话,他可以将其加入收藏。
在这些代码的匹配表达式中,我们在匹配 Coin::Quarter 成员的分支的模式中增加了一个叫做 state 的变量。当匹配到 Coin::Quarter 时,变量 state 将会绑定 25 美分硬币所对应州的值。接着在那个分支的代码中使用 state,如下:
#![allow(unused)]
fn main() {
#[derive(Debug)]
enum UsState {
Alabama,
Alaska,
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter(state) => {
println!("State quarter from {:?}!", state);
25
},
}
}
}
如果调用 value_in_cents(Coin::Quarter(UsState::Alaska)),coin 将是 Coin::Quarter(UsState::Alaska)。当将值与每个分支相比较时,没有分支会匹配,直到遇到 Coin::Quarter(state)。这时,state 绑定的将会是值 UsState::Alaska。接着就可以在 println! 表达式中使用这个绑定了,像这样就可以获取 Coin 枚举的 Quarter 成员中内部的州的值。
匹配 Option<T>
我们在之前的部分中使用 Option<T> 时,是为了从 Some 中取出其内部的 T 值;我们还可以像处理 Coin 枚举那样使用 match 处理 Option<T>!只不过这回比较的不再是硬币,而是 Option<T> 的成员,但 match 表达式的工作方式保持不变。
比如我们想要编写一个函数,它获取一个 Option<i32> ,如果其中含有一个值,将其加一。如果其中没有值,函数应该返回 None 值,而不尝试执行任何操作。
得益于 match,编写这个函数非常简单
#![allow(unused)]
fn main() {
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}
匹配 Some(T)
让我们更仔细地检查 plus_one 的第一行操作。当调用 plus_one(five) 时,plus_one 函数体中的 x 将会是值 Some(5)。接着将其与每个分支比较。
None => None,
值 Some(5) 并不匹配模式 None,所以继续进行下一个分支。
Some(i) => Some(i + 1),
Some(5) 与 Some(i) 匹配吗?当然匹配!它们是相同的成员。i 绑定了 Some 中包含的值,所以 i 的值是 5。接着匹配分支的代码被执行,所以我们将 i 的值加一并返回一个含有值 6 的新 Some。
接着考虑下示例 6-5 中 plus_one 的第二个调用,这里 x 是 None。我们进入 match 并与第一个分支相比较。
None => None,
匹配上了!这里没有值来加一,所以程序结束并返回 => 右侧的值 None,因为第一个分支就匹配到了,其他的分支将不再比较。
将 match 与枚举相结合在很多场景中都是有用的。你会在 Rust 代码中看到很多这样的模式:match 一个枚举,绑定其中的值到一个变量,接着根据其值执行代码。这在一开始有点复杂,不过一旦习惯了,你会希望所有语言都拥有它!这一直是用户的最爱。
match 还有另一方面需要讨论。考虑一下 plus_one 函数的这个版本,它有一个 bug 并不能编译:
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
Some(i) => Some(i + 1),
}
}
我们没有处理 None 的情况,所以这些代码会造成一个 bug。幸运的是,这是一个 Rust 知道如何处理的 bug。如果尝试编译这段代码,会得到这个错误:
error[E0004]: non-exhaustive patterns: `None` not covered
-->
|
6 | match x {
| ^ pattern `None` not covered
Rust 知道我们没有覆盖所有可能的情况甚至知道哪些模式被忘记了!Rust 中的匹配是 穷尽的
特别的在这个 Option<T> 的例子中,Rust 防止我们忘记明确的处理 None 的情况,这使我们免于假设拥有一个实际上为空的值
_ 通配符
Rust 也提供了一个模式用于不想列举出所有可能值的场景。例如,u8 可以拥有 0 到 255 的有效的值,如果我们只关心 1、3、5 和 7 这几个值,就并不想必须列出 0、2、4、6、8、9 一直到 255 的值。所幸我们不必这么做:可以使用特殊的模式 _ 替代:
#![allow(unused)]
fn main() {
let some_u8_value = 0u8;
match some_u8_value {
1 => println!("one"),
3 => println!("three"),
5 => println!("five"),
7 => println!("seven"),
_ => (),
}
}
_ 模式会匹配所有的值。通过将其放置于其他分支之后,_ 将会匹配所有之前没有指定的可能的值。() 就是 unit 值,所以 _ 的情况什么也不会发生。因此,可以说我们想要对 _ 通配符之前没有列出的所有可能的值不做任何处理。
然而,match 在只关心 一个 情况的场景中可能就有点啰嗦了。为此 Rust 提供了if let。
if let 简单控制流
if let 语法让我们以一种不那么冗长的方式结合 if 和 let,来处理只匹配一个模式的值而忽略其他模式的情况。它匹配一个 Option<u8> 值并只希望当值为 3 时执行代码:
#![allow(unused)]
fn main() {
let some_u8_value = Some(0u8);
match some_u8_value {
Some(3) => println!("three"),
_ => (),
}
}
我们想要对 Some(3) 匹配进行操作但是不想处理任何其他 Some<u8> 值或 None 值。为了满足 match 表达式(穷尽性)的要求,必须在处理完这唯一的成员后加上 _ => (),这样也要增加很多样板代码。
不过我们可以使用 if let 这种更短的方式编写。如下代码的 match 行为一致:
#![allow(unused)]
fn main() {
let some_u8_value = Some(0u8);
if let Some(3) = some_u8_value {
println!("three");
}
}
if let 获取通过等号分隔的一个模式和一个表达式。它的工作方式与 match 相同,这里的表达式对应 match 而模式则对应第一个分支。
使用 if let 意味着编写更少代码,更少的缩进和更少的样板代码。然而,这样会失去 match 强制要求的穷尽性检查。match 和 if let 之间的选择依赖特定的环境以及增加简洁度和失去穷尽性检查的权衡取舍。
换句话说,可以认为 if let 是 match 的一个语法糖,它当值匹配某一模式时执行代码而忽略所有其他值。
可以在 if let 中包含一个 else。else 块中的代码与 match 表达式中的 _ 分支块中的代码相同,这样的 match 表达式就等同于 if let 和 else。回忆一下示例 6-4 中 Coin 枚举的定义,其 Quarter 成员也包含一个 UsState 值。如果想要计数所有不是 25 美分的硬币的同时也报告 25 美分硬币所属的州,可以使用这样一个 match 表达式:
#![allow(unused)]
fn main() {
#[derive(Debug)]
enum UsState {
Alabama,
Alaska,
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
let coin = Coin::Penny;
let mut count = 0;
match coin {
Coin::Quarter(state) => println!("State quarter from {:?}!", state),
_ => count += 1,
}
}
或者可以使用这样的 if let 和 else 表达式:
#![allow(unused)]
fn main() {
#[derive(Debug)]
enum UsState {
Alabama,
Alaska,
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
let coin = Coin::Penny;
let mut count = 0;
if let Coin::Quarter(state) = coin {
println!("State quarter from {:?}!", state);
} else {
count += 1;
}
}
如果你的程序遇到一个使用 match 表达起来过于啰嗦的逻辑,记住 if let 也在你的 Rust 工具箱中。
总结
现在我们涉及到了如何使用枚举来创建有一系列可列举值的自定义类型。我们也展示了标准库的 Option<T> 类型是如何帮助你利用类型系统来避免出错的。当枚举值包含数据时,你可以根据需要处理多少情况来选择使用 match 或 if let 来获取并使用这些值。
你的 Rust 程序现在能够使用结构体和枚举在自己的作用域内表现其内容了。在你的 API 中使用自定义类型保证了类型安全:编译器会确保你的函数只会得到它期望的类型的值。
为了向你的用户提供一个组织良好的 API,它使用起来很直观并且只向用户暴露他们确实需要的部分,那么现在就让我们转向 Rust 的模块系统吧。
7章、包与Crate
rust 的“模块系统(the module system)”,包括:
- 包(Packages): Cargo 的一个功能,它允许你构建、测试和分享 crate。
- Crates :一个模块的树形结构,它形成了库或二进制项目。
- 模块(Modules)和 use: 允许你控制作用域和路径的私有性。
- 路径(path):一个命名例如结构体、函数或模块等项的方式
一个包中至多 只能 包含一个库 crate(library crate);包中可以包含任意多个二进制 crate(binary crate);包中至少包含一个 crate,无论是库的还是二进制的。
首先,我们输入命令
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 会给我们的包创建一个 Cargo.toml 文件。查看 Cargo.toml 的内容,会发现并没有提到 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 会将一个作用域内的相关功能分组到一起,使得该功能可以很方便地在多个项目之间共享。举一个例子,我们在 第二章 使用的 rand crate 提供了生成随机数的功能。通过将 rand crate 加入到我们项目的作用域中,我们就可以在自己的项目中使用该功能。rand crate 提供的所有功能都可以通过该 crate 的名字:rand 进行访问。
将一个 crate 的功能保持在其自身的作用域中,可以知晓一些特定的功能是在我们的 crate 中定义的还是在 rand crate 中定义的,这可以防止潜在的冲突。例如,rand crate 提供了一个名为 Rng 的特性(trait)。我们还可以在我们自己的 crate 中定义一个名为 Rng 的 struct。因为一个 crate 的功能是在自身的作用域进行命名的,当我们将 rand 作为一个依赖,编译器不会混淆 Rng 这个名字的指向。在我们的 crate 中,它指向的是我们自己定义的 struct Rng。我们可以通过 rand::Rng 这一方式来访问 rand crate 中的 Rng 特性(trait)。
定义模块来控制作用域与私有性
通过执行 cargo new --lib restaurant,来创建一个新的名为 restaurant 的库。然后将示例中所罗列出来的代码放入 src/lib.rs 中,来定义一些模块和函数。
Filename: src/lib.rs
#![allow(unused)]
fn main() {
mod front_of_house {
mod hosting {
fn add_to_waitlist() {}
fn seat_at_table() {}
}
mod serving {
fn take_order() {}
fn server_order() {}
fn take_payment() {}
}
}
}
以上代码显示一个包含了其他内置了函数的模块的 front_of_house 模块
我们定义一个模块,是以 mod 关键字为起始,然后指定模块的名字(本例中叫做 front_of_house),并且用花括号包围模块的主体。在模块内,我们还可以定义其他的模块,就像本例中的 hosting 和 serving 模块。模块还可以保存一些定义的其他项,比如结构体、枚举、常量、特性、或者函数。
在前面我们提到了,src/main.rs 和 src/lib.rs 叫做 crate 根。之所以这样叫它们是因为这两个文件的内容都分别在 crate 模块结构的根组成了一个名为 crate 的模块,该结构被称为 模块树(module tree)。
展示了示例中的模块树的结构。
crate
└── front_of_house
├── hosting
│ ├── add_to_waitlist
│ └── seat_at_table
└── serving
├── take_order
├── serve_order
└── take_payment
这个树展示了一些模块是如何被嵌入到另一个模块的(例如,hosting 嵌套在 front_of_house 中)。这个树还展示了一些模块是互为 兄弟(siblings) 的,这意味着它们定义在同一模块中(hosting 和 serving 被一起定义在 front_of_house 中)。
路径用于引用
路径有两种形式:
- 绝对路径(absolute path)从 crate 根开始,以 crate 名或者字面值
crate开头。 - 相对路径(relative path)从当前模块开始,以
self、super或当前模块的标识符开头。
绝对路径和相对路径都后跟一个或多个由双冒号(::)分割的标识符。
我们在 crate 根定义了一个新函数 eat_at_restaurant,并在其中展示调用 add_to_waitlist 函数的两种方法。eat_at_restaurant 函数是我们 crate 库的一个公共API,所以我们使用 pub 关键字来标记它。注意,这个例子无法编译通过,我们稍后会解释原因。
文件名: src/lib.rs
mod front_of_house {
mod hosting {
fn add_to_waitlist() {}
}
}
pub fn eat_at_restaurant() {
// Absolute path
crate::front_of_house::hosting::add_to_waitlist();
// Relative path
front_of_house::hosting::add_to_waitlist();
}
第一种方式,我们在 eat_at_restaurant 中调用 add_to_waitlist 函数,使用的是绝对路径。add_to_waitlist 函数与 eat_at_restaurant 被定义在同一 crate 中,这意味着我们可以使用 crate 关键字为起始的绝对路径。
第二种方式,我们在 eat_at_restaurant 中调用 add_to_waitlist,使用的是相对路径。这个路径以 front_of_house 为起始,这个模块在模块树中,与 eat_at_restaurant 定义在同一层级。与之等价的文件系统路径就是 front_of_house/hosting/add_to_waitlist。以名称为起始,意味着该路径是相对路径。
选择使用相对路径还是绝对路径,还是要取决于你的项目。取决于你是更倾向于将项的定义代码与使用该项的代码分开来移动,还是一起移动。
使用 pub 关键字暴露路径
使用 pub 关键字声明 hosting 模块使其可在 eat_at_restaurant 使用
mod front_of_house {
pub mod hosting {
fn add_to_waitlist() {}
}
}
pub fn eat_at_restaurant() {
// Absolute path
crate::front_of_house::hosting::add_to_waitlist();
// Relative path
front_of_house::hosting::add_to_waitlist();
}
在 mod hosting 前添加了 pub 关键字,使其变成公有的。伴随着这种变化,如果我们可以访问 front_of_house,那我们也可以访问 hosting。但是 hosting 的 内容(contents) 仍然是私有的;这表明使模块公有并不使其内容也是公有的。模块上的 pub 关键字只允许其父模块引用它。
add_to_waitlist 函数是私有的。私有性规则不但应用于模块,还应用于结构体、枚举、函数和方法。
让我们继续将 pub 关键字放置在 add_to_waitlist 函数的定义之前,使其变成公有。
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
pub fn eat_at_restaurant() {
// Absolute path
crate::front_of_house::hosting::add_to_waitlist();
// Relative path
front_of_house::hosting::add_to_waitlist();
}
fn main() {}
为 mod hosting 和 fn add_to_waitlist 添加 pub 关键字使他们可以在 eat_at_restaurant 函数中被调用
现在代码可以编译通过了!
使用 super 起始的相对路径
我们还可以使用 super 开头来构建从父模块开始的相对路径。这么做类似于文件系统中以 .. 开头的语法。
文件名: src/lib.rs
fn serve_order() {}
mod back_of_house {
fn fix_incorrect_order() {
cook_order();
super::serve_order();
}
fn cook_order() {}
}
fn main() {}
使用以 super 开头的相对路径从父目录开始调用函数
fix_incorrect_order 函数在 back_of_house 模块中,所以我们可以使用 super 进入 back_of_house 父模块,也就是本例中的 crate 根。在这里,我们可以找到 serve_order。这里没解释清楚,就是同级目录
创建公有的结构体和枚举
我们还可以使用 pub 来设计公有的结构体和枚举,不过有一些额外的细节需要注意。如果我们在一个结构体定义的前面使用了 pub ,这个结构体会变成公有的,但是这个结构体的字段仍然是私有的。我们可以根据情况决定每个字段是否公有。
在示例中,我们定义了一个公有结构体 back_of_house:Breakfast,其中有一个公有字段 toast 和私有字段 seasonal_fruit。这个例子模拟的情况是,在一家餐馆中,顾客可以选择随餐附赠的面包类型,但是厨师会根据季节和库存情况来决定随餐搭配的水果。餐馆可用的水果变化是很快的,所以顾客不能选择水果,甚至无法看到他们将会得到什么水果。
#![allow(unused)]
fn main() {
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() {
// Order a breakfast in the summer with Rye toast
let mut meal = back_of_house::Breakfast::summer("Rye");
// Change our mind about what bread we'd like
meal.toast = String::from("Wheat");
println!("I'd like {} toast please", meal.toast);
// The next line won't compile if we uncomment it; we're not allowed
// to see or modify the seasonal fruit that comes with the meal
// meal.seasonal_fruit = String::from("blueberries");
}
}
因为 back_of_house::Breakfast 结构体的 toast 字段是公有的,所以我们可以在 eat_at_restaurant 中使用点号来随意的读写 toast 字段。注意,我们不能在 eat_at_restaurant 中使用 seasonal_fruit 字段,因为 seasonal_fruit 是私有的。尝试去除那一行修改 seasonal_fruit 字段值的代码的注释,看看你会得到什么错误!
还请注意一点,因为 back_of_house::Breakfast 具有私有字段,所以这个结构体需要提供一个公共的关联函数来构造 Breakfast 的实例(这里我们命名为 summer)。如果 Breakfast 没有这样的函数,我们将无法在 eat_at_restaurant 中创建 Breakfast 实例,因为我们不能在 eat_at_restaurant 中设置私有字段 seasonal_fruit 的值。
与之相反,如果我们将枚举设为公有,则它的所有成员都将变为公有。我们只需要在 enum 关键字前面加上 pub 如下所示
#![allow(unused)]
fn main() {
mod back_of_house {
pub enum Appetizer {
Soup,
Salad,
}
}
pub fn eat_at_restaurant() {
let order1 = back_of_house::Appetizer::Soup;
let order2 = back_of_house::Appetizer::Salad;
}
}
因为我们创建了名为 Appetizer 的公有枚举,所以我们可以在 eat_at_restaurant 中使用 Soup 和 Salad 成员。
使用 use 关键字将名称引入作用域
我们可以使用 use 关键字将路径一次性引入作用域,然后调用该路径中的项,就如同它们是本地项一样。
我们将 crate::front_of_house::hosting 模块引入了 eat_at_restaurant 函数的作用域,而我们只需要指定 hosting::add_to_waitlist 即可在 eat_at_restaurant 中调用 add_to_waitlist 函数。
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();
}
fn main() {}
还可以使用 use 和相对路径来将一个项引入作用域。
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
use front_of_house::hosting; //看这里
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
hosting::add_to_waitlist();
hosting::add_to_waitlist();
}
fn main() {}
使用 use 引入结构体、枚举和其他项时,习惯是指定它们的完整路径。
use std::collections::HashMap;
fn main() {
let mut map = HashMap::new();
map.insert(1, 2);
}
这个习惯用法有一个例外,那就是我们想使用 use 语句将两个具有相同名称的项带入作用域,Rust 不允许这样做。除非指定父路径,下面代码展示了如何将两个具有相同名称但不同父模块的 Result 类型引入作用域,以及如何引用它们。
#![allow(unused)]
fn main() {
use std::fmt;
use std::io;
fn function1() -> fmt::Result {
// --snip--
Ok(())
}
fn function2() -> io::Result<()> {
// --snip--
Ok(())
}
}
使用父模块可以区分这两个 Result 类型。
如果我们是指定 use std::fmt::Result 和 use std::io::Result,我们将在同一作用域拥有了两个 Result 类型,当我们使用 Result 时,Rust 则不知道我们要用的是哪个。
使用 as 关键字提供新的名称
使用 use 将两个同名类型引入同一作用域这个问题还有另一个解决办法:在这个类型的路径后面,我们使用 as 指定一个新的本地名称或者别名。
#![allow(unused)]
fn main() {
use std::fmt::Result;
use std::io::Result as IoResult;
fn function1() -> Result {
// --snip--
Ok(())
}
fn function2() -> IoResult<()> {
// --snip--
Ok(())
}
}
在第二个 use 语句中,我们选择 IoResult 作为 std::io::Result 的新名称,它与从 std::fmt 引入作用域的 Result 并不冲突。
使用 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();
}
fn main() {}
通过 pub use,现在可以通过新路径 hosting::add_to_waitlist 来调用 add_to_waitlist 函数。如果没有指定 pub use,eat_at_restaurant 函数可以在其作用域中调用 hosting::add_to_waitlist,但外部代码则不允许使用这个新路径。
当你的代码的内部结构与调用你的代码的程序员的思考领域不同时,重导出会很有用。
使用 pub use,我们可以使用一种结构编写代码,却将不同的结构形式暴露出来。这样做使我们的库井井有条,方便开发这个库的程序员和调用这个库的程序员之间组织起来。
使用外部包
在第二章中我们编写了一个猜猜看游戏。那个项目使用了一个外部包,rand,来生成随机数。为了在项目中使用 rand,在 Cargo.toml 中加入了如下行:
文件名: Cargo.toml
[dependencies]
rand = "0.5.5"
在 Cargo.toml 中加入 rand 依赖告诉了 Cargo 要从 crates.io 下载 rand 和其依赖,并使其可在项目代码中使用。
接着,为了将 rand 定义引入项目包的作用域,我们加入一行 use 起始的包名,它以 rand 包名开头并列出了需要引入作用域的项。回忆一下第二章的 “生成一个随机数” 部分,我们曾将 Rng trait 引入作用域并调用了 rand::thread_rng 函数:
use rand::Rng;
fn main() {
let secret_number = rand::thread_rng().gen_range(1, 101);
}
crates.io 上有很多 Rust 社区成员发布的包,将其引入你自己的项目都需要一道相同的步骤:在 Cargo.toml 列出它们并通过 use 将其中定义的项引入项目包的作用域中。
注意标准库(std)对于你的包来说也是外部 crate。因为标准库随 Rust 语言一同分发,无需修改 Cargo.toml 来引入 std,不过需要通过 use 将标准库中定义的项引入项目包的作用域中来引用它们,比如我们使用的 HashMap:
use std::collections::HashMap;
这是一个以标准库 crate 名 std 开头的绝对路径。
当需要引入很多定义于相同包或相同模块的项时,为每一项单独列出一行会占用源码很大的空间。
use std::cmp::Ordering;
use std::io;
// ---snip---
我们可以使用嵌套路径将相同的项在一行中引入作用域。这么做需要指定路径的相同部分,接着是两个冒号,接着是大括号中的各自不同的路径部分,如示下所示。
use std::{cmp::Ordering, io};
// ---snip---
我们可以在路径的任何层级使用嵌套路径,这在组合两个共享子路径的 use 语句时非常有用。例如 通过两行 use 语句引入两个路径,其中一个是另一个的子路径
use std::io;
use std::io::Write;
两个路径的相同部分是 std::io,这正是第一个路径。为了在一行 use 语句中引入这两个路径,可以在嵌套路径中使用 self,示例
use std::io::{self, Write};
部分重复的路径合并为一个 use 语句
这一行便将 std::io 和 std::io::Write 同时引入作用域。
通过 glob 运算符将所有的公有定义引入作用域
如果希望将一个路径下 所有 公有项引入作用域,可以指定路径后跟 *,glob 运算符:
use std::collections::*;
这个 use 语句将 std::collections 中定义的所有公有项引入当前作用域。使用 glob 运算符时请多加小心!Glob 会使得我们难以推导作用域中有什么名称和它们是在何处定义的。
glob 运算符经常用于测试模块 tests 中,这时会将所有内容引入作用域;我们将在第十一章 “如何编写测试” 部分讲解。
将模块分割进不同文件
当模块变得更大时,你可能想要将它们的定义移动到单独的文件中,从而使代码更容易阅读。
文件名: 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();
}
声明 front_of_house 模块,其内容将位于 src/front_of_house.rs
在 src/front_of_house.rs 中定义 front_of_house 模块如下
(文件名即模块名)
文件名: src/front_of_house.rs
pub mod hosting {
pub fn add_to_waitlist() {}
}
在 mod front_of_house 后使用分号,而不是代码块,这将告诉 Rust 在另一个与模块同名的文件中加载模块的内容。
8章 常见集合
我们将详细的了解三个在 Rust 程序中被广泛使用的集合:
- vector 允许我们一个挨着一个地储存一系列数量可变的值
- 字符串(string)是字符的集合。我们之前见过
String类型,不过在本章我们将深入了解。 - 哈希 map(hash map)允许我们将值与一个特定的键(key)相关联。这是一个叫做 map 的更通用的数据结构的特定实现。
我们要讲到的第一个类型是
Vec<T>,也被称为 vector。vector 允许我们在一个单独的数据结构中储存多于一个的值,它在内存中彼此相邻地排列所有的值。vector 只能储存相同类型的值。
新建 vector
可以调用 Vec::new 函数,新建一个空的 vector 来储存 i32 类型的值
#![allow(unused)]
fn main() {
let v: Vec<i32> = Vec::new();
}
注意这里我们增加了一个类型注解。因为没有向这个 vector 中插入任何值,Rust 并不知道我们想要储存什么类型的元素。这是一个非常重要的点。vector 是用泛型实现的。
更常见的做法是使用初始值来创建一个 Vec,而且为了方便 Rust 提供了 vec! 宏。这个宏会根据我们提供的值来创建一个新的 Vec。
新建一个拥有值 1、2 和 3 的 Vec<i32>:
#![allow(unused)]
fn main() {
let v = vec![1, 2, 3];
}
因为我们提供了 i32 类型的初始值,Rust 可以推断出 v 的类型是 Vec<i32>,因此类型注解就不是必须的。
更新 vector
对于新建一个 vector 并向其增加元素,可以使用 push 方法,删除元素用 pop 方法。
#![allow(unused)]
fn main() {
let mut v = Vec::new();
v.push(5);
v.push(6);
v.push(7);
v.push(8);
v.pop(5); //删除
}
类似于任何其他的 struct,vector 在其离开作用域时会被释放
#![allow(unused)]
fn main() {
{
let v = vec![1, 2, 3, 4];
// 处理变量 v
} // <- 这里 v 离开作用域并被丢弃
}
当 vector 被丢弃时,所有其内容也会被丢弃,这意味着这里它包含的整数将被清理。不过一旦开始使用 vector 元素的引用,情况就变得有些复杂了。
读取 vector 的元素
现在你知道如何创建、更新和销毁 vector 了,接下来的一步最好了解一下如何读取它们的内容。有两种方法引用 vector 中储存的值。为了更加清楚的说明这个例子,我们标注这些函数返回的值的类型。
访问 vector 中一个值的两种方式,索引语法或者 get 方法
#![allow(unused)]
fn main() {
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."),
}
}
这里有两个需要注意的地方。首先,我们使用索引值 2 来获取第三个元素,索引是从 0 开始的。其次,这两个不同的获取第三个元素的方式分别为:使用 & 和 [] 返回一个引用;或者使用 get 方法以索引作为参数来返回一个 Option<&T>。
Rust 有两个引用元素的方法的原因是程序可以选择如何处理当索引值在 vector 中没有对应值的情况。 尝试访问超过 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 的工作方式:在 vector 的结尾增加新元素时,在没有足够空间将所有所有元素依次相邻存放的情况下,可能会要求分配新内存并将老的元素拷贝到新的空间中。这时,第一个元素的引用就指向了被释放的内存。借用规则阻止程序陷入这种状况。
遍历 vector 中的元素
如果想要依次访问 vector 中的每一个元素,我们可以遍历其所有的元素,
使用 for 循环来获取 i32 值的 vector 中的每一个元素的不可变引用并将其打印:
#![allow(unused)]
fn main() {
let v = vec![100, 32, 57];
for i in &v {
println!("{}", i);
}
}
我们也可以遍历可变 vector 的每一个元素的可变引用以便能改变他们。比如如下代码for 循环会给每一个元素加 50:
#![allow(unused)]
fn main() {
let mut v = vec![100, 32, 57];
for i in &mut v {
*i += 50;
}
}
为了修改可变引用所指向的值,在使用 += 运算符之前必须使用解引用运算符(*)获取 i 中的值。
使用枚举来储存多种类型
枚举的成员都被定义为相同的枚举类型,所以当需要在 vector 中储存不同类型值时,我们可以定义并使用一个枚举!
假如我们想要从电子表格的一行中获取值,而这一行的有些列包含数字,有些包含浮点值,还有些是字符串。我们可以定义一个枚举,其成员会存放这些不同类型的值。接着可以创建一个储存枚举值的 vector,这样最终就能够储存不同类型的值了。
#![allow(unused)]
fn main() {
enum SpreadsheetCell {
Int(i32),
Float(f64),
Text(String),
}
let row = vec![
SpreadsheetCell::Int(3),
SpreadsheetCell::Text(String::from("blue")),
SpreadsheetCell::Float(10.12),
];
}
Rust 在编译时就必须准确的知道 vector 中类型的原因在于它需要知道储存每个元素到底需要多少内存。第二个好处是可以准确的知道这个 vector 中允许什么类型。 如果在编写程序时不能确切无遗地知道运行时会储存进 vector 的所有类型,枚举技术就行不通了。相反,你可以使用 trait 对象,第十七章会讲到它。
String字符串
在这一部分,我们会讲到 String 中那些任何集合类型都有的操作,比如创建、更新和读取,索引。
Rust 的核心语言中只有一种字符串类型:str,字符串 slice,它通常以被借用的形式出现,&str。第四章讲到了 字符串 slice:它们是一些储存在别处的 UTF-8 编码字符串数据的引用。比如字符串字面值被储存在程序的二进制输出中,字符串 slice 也是如此。
称作 String 的类型是由标准库提供的,而没有写进核心语言部分,它是可增长的、可变的、有所有权的、UTF-8 编码的字符串类型。
Rust 标准库中还包含一系列其他字符串类型,比如 OsString、OsStr、CString 和 CStr。相关库 crate 甚至会提供更多储存字符串数据的选择。看到这些由 String 或是 Str 结尾的名字了吗?这对应着它们提供的所有权和可借用的字符串变体,就像是你之前看到的 String 和 str。
新建字符串
很多 Vec 可用的操作在 String 中同样可用,从以 new 函数创建字符串开始,新建一个空的 String
#![allow(unused)]
fn main() {
let mut s = String::new();
}
通常字符串会有初始数据,因为我们希望一开始就有这个字符串。为此,可以使用 to_string 方法进行初始化
#![allow(unused)]
fn main() {
let data = "initial contents";
let s = data.to_string();
// 该方法也可直接用于字符串字面值:
let s = "initial contents".to_string();
}
也可以使用 String::from 函数来从字符串字面值创建 String。代码等同于使用 to_string。
#![allow(unused)]
fn main() {
let s = String::from("initial contents");
}
记住字符串是 UTF-8 编码的,所以可以包含任何可以正确编码的数据,在字符串中储存不同语言的问候语。
所有这些都是有效的 String 值。
#![allow(unused)]
fn main() {
let hello = String::from("السلام عليكم");
let hello = String::from("Dobrý den");
let hello = String::from("Hello");
let hello = String::from("שָׁלוֹם");
let hello = String::from("नमस्ते");
let hello = String::from("こんにちは");
let hello = String::from("안녕하세요");
let hello = String::from("你好");
let hello = String::from("Olá");
let hello = String::from("Здравствуйте");
let hello = String::from("Hola");
}
更新字符串
String 的大小可以增加,其内容也可以改变,就像可以放入更多数据来改变 Vec 的内容一样。另外,可以方便的使用 + 运算符或 format! 宏来拼接 String 值。
使用 push_str 和 push 附加字符串
可以通过 push_str 方法来附加字符串 slice,从而使 String 变长
#![allow(unused)]
fn main() {
let mut s = String::from("foo");
s.push_str("bar");
}
执行这两行代码之后,s 将会包含 foobar。push_str 方法采用字符串 slice,因为我们并不需要获取参数的所有权。
使用 + 运算符或 format! 宏拼接字符串
通常你会希望将两个已知的字符串合并在一起。一种办法是像这样使用 + 运算符
#![allow(unused)]
fn main() {
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 {
这并不是标准库中实际的签名;标准库中的 add 使用泛型定义。这里我们看到的 add 的签名使用具体类型代替了泛型,这也正是当使用 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 没有 使用 &。这意味着示例 8-18 中的 s1 的所有权将被移动到 add 调用中,之后就不再有效。所以虽然 let s3 = s1 + &s2; 看起来就像它会复制两个字符串并创建一个新的字符串,而实际上这个语句会获取 s1 的所有权,附加上从 s2 中拷贝的内容,并返回结果的所有权。换句话说,它看起来好像生成了很多拷贝,不过实际上并没有:这个实现比拷贝要更高效。
如果想要级联多个字符串,+ 的行为就显得笨重了:
#![allow(unused)]
fn main() {
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! 宏:
#![allow(unused)]
fn main() {
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。这个版本就好理解的多,并且不会获取任何参数的所有权。
索引字符串
在很多语言中,通过索引来引用字符串中的单独字符是有效且常见的操作。然而在 Rust 中,如果你尝试使用索引语法访问 String 的一部分,会出现错误。
let s1 = String::from("hello");
let h = s1[0];
Rust 的字符串不支持索引。
String 是一个 Vec<u8> 的封装。
正确编码的字符串的例子。首先是这一个:
let len = String::from("Hola").len();
在这里,len 的值是 4 ,这意味着储存字符串 “Hola” 的 Vec 的长度是四个字节:这里每一个字母的 UTF-8 编码都占用一个字节。
这个例子又如何呢?(注意这个字符串中的首字母是西里尔字母的 Ze 而不是阿拉伯数字 3 。)
let len = String::from("Здравствуйте").len();
当问及这个字符是多长的时候有人可能会说是 12。然而,Rust 的回答是 24。这是使用 UTF-8 编码 “Здравствуйте” 所需要的字节数,这是因为每个 Unicode 标量值需要两个字节存储。因此一个字符串字节值的索引并不总是对应一个有效的 Unicode 标量值。
作为演示,考虑如下无效的 Rust 代码:
let hello = "Здравствуйте";
let answer = &hello[0];
answer 的值应该是什么呢?它应该是第一个字符 З 吗?当使用 UTF-8 编码时,З 的第一个字节 208,第二个是 151,所以 answer 实际上应该是 208,不过 208 自身并不是一个有效的字母。
为了避免返回意外的值并造成不能立刻发现的 bug,Rust 根本不会编译这些代码,并在开发过程中及早杜绝了误会的发生。
相比使用 [] 和单个值的索引,可以使用 [] 和一个 range 来创建含特定字节的字符串 slice:
let hello = "Здравствуйте";
let s = &hello[0..4];
这里,s 会是一个 &str,它包含字符串的头四个字节。早些时候,我们提到了这些字母都是两个字节长的,所以这意味着 s 将会是 “Зд”。
如果获取 &hello[0..1] 会发生什么呢?答案是:Rust 在运行时会报错,就跟访问 vector 中的无效索引时一样
遍历字符串的方法
如果你需要操作单独的 Unicode 标量值,最好的选择是使用 chars 方法。对 “नमस्ते” 调用 chars 方法会将其分开并返回六个 char 类型的值,接着就可以遍历其结果来访问每一个元素了:
#![allow(unused)]
fn main() {
for c in "नमस्ते".chars() {
println!("{}", c);
}
}
这些代码会打印出如下内容:
न
म
स
्
त
े
bytes 方法返回每一个原始字节,这可能会适合你的使用场景:
#![allow(unused)]
fn main() {
for b in "नमस्ते".bytes() {
println!("{}", b);
}
}
这些代码会打印出组成 String 的 18 个字节:
224
164
168
224
164
174
224
164
184
224
165
141
224
164
164
224
165
135
不过请记住有效的 Unicode 标量值可能会由不止一个字节组成。
哈希 Map
最后介绍的常用集合类型是 哈希 map(hash map)。HashMap<K, V> 类型储存了一个键类型 K 对应一个值类型 V 的映射。它通过一个 哈希函数(hashing function)来实现映射,决定如何将键和值放入内存中。
哈希 map 可以用于需要任何类型作为键来寻找数据的情况,而不是像 vector 那样通过索引。例如,在一个游戏中,你可以将每个团队的分数记录到哈希 map 中,其中键是队伍的名字而值是每个队伍的分数。给出一个队名,就能得到他们的得分。
新建一个哈希 map
可以使用 new 创建一个空的 HashMap,并使用 insert 增加元素。在示例中我们记录两支队伍的分数,分别是蓝队和黄队。蓝队开始有 10 分而黄队开始有 50 分:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
}
注意必须首先 use 标准库中集合部分的 HashMap。在这三个常用集合中,HashMap 是最不常用的,所以并没有被预编译自动引用。标准库中对 HashMap 的支持也相对较少,例如,并没有内建的构建宏。
像 vector 一样,哈希 map 将它们的数据储存在堆上,这个 HashMap 的键类型是 String 而值类型是 i32。类似于 vector,哈希 map 是同质的:所有的键必须是相同类型,值也必须都是相同类型。
另一个构建哈希 map 的方法是使用一个元组的 vector 的 collect 方法,其中每个元组包含一个键值对。collect 方法可以将数据收集进一系列的集合类型,包括 HashMap。例如,如果队伍的名字和初始分数分别在两个 vector 中,可以使用 zip 方法来创建一个元组的 vector,其中 “Blue” 与 10 是一对,依此类推。接着就可以使用 collect 方法将这个元组 vector 转换成一个 HashMap,如示例所示:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
let teams = vec![String::from("Blue"), String::from("Yellow")];
let initial_scores = vec![10, 50];
let scores: HashMap<_, _> = teams.iter().zip(initial_scores.iter()).collect();
}
这里 HashMap<_, _> 类型注解是必要的,因为可能 collect 为很多不同的数据结构,而除非显式指定否则 Rust 无从得知你需要的类型。但是对于键和值的类型参数来说,可以使用下划线占位,而 Rust 能够根据 vector 中数据的类型推断出 HashMap 所包含的类型。
哈希 map 和所有权
对于像 i32 这样的实现了 Copy trait(trait可以理解为特性) 的类型,其值可以拷贝进哈希 map。对于像 String 这样拥有所有权的值,其值将被移动而哈希 map 会成为这些值的所有者
#![allow(unused)]
fn main() {
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 不再有效,
// 尝试使用它们看看会出现什么编译错误!
}
当 insert 调用将 field_name 和 field_value 移动到哈希 map 中后,将不能使用这两个绑定。
如果将值的引用插入哈希 map,这些值本身将不会被移动进哈希 map。但是这些引用指向的值必须至少在哈希 map 有效时也是有效的。
访问哈希 map 中的值
可以通过 get 方法并提供对应的键来从哈希 map 中获取值
#![allow(unused)]
fn main() {
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);
}
这里,score 是与蓝队分数相关的值,应为 Some(10)。因为 get 返回 Option<V>,所以结果被装进 Some;如果某个键在哈希 map 中没有对应的值,get 会返回 None。这时就要用某种第六章提到的方法之一来处理 Option。
可以使用与 vector 类似的方式来遍历哈希 map 中的每一个键值对,也就是 for 循环:
#![allow(unused)]
fn main() {
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);
}
}
这会以任意顺序打印出每一个键值对:
Yellow: 50
Blue: 10
更新哈希 map
覆盖一个值
如果我们插入了一个键值对,接着用相同的键插入一个不同的值,与这个键相关联的旧值将被替换
#![allow(unused)]
fn main() {
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Blue"), 25);
println!("{:?}", scores);
}
这会打印出 {"Blue": 25}。原始的值 10 则被覆盖了。
只在键没有对应值时插入
我们经常会检查某个特定的键是否有值,如果没有就插入一个值。为此哈希 map 有一个特有的 API,叫做 entry,它获取我们想要检查的键作为参数。entry 函数的返回值是一个枚举,Entry,它代表了可能存在也可能不存在的值。比如说我们想要检查黄队的键是否关联了一个值。如果没有,就插入值 50,对于蓝队也是如此。使用 entry API 的代码看起来像示例这样:
#![allow(unused)]
fn main() {
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 方法在键对应的值存在时就返回这个值的可变引用,如果不存在则将参数作为新值插入并返回新值的可变引用。这比编写自己的逻辑要简明的多,另外也与借用检查器结合得更好。
运行示例的代码会打印出 {"Yellow": 50, "Blue": 10}。第一个 entry 调用会插入黄队的键和值 50,因为黄队并没有一个值。第二个 entry 调用不会改变哈希 map 因为蓝队已经有了值 10。
根据旧值更新一个值
另一个常见的哈希 map 的应用场景是找到一个键对应的值并根据旧的值更新它。
我们使用哈希 map 以单词作为键并递增其值来记录我们遇到过几次这个单词。如果是第一次看到某个单词,就插入值 0。 通过哈希 map 储存单词和计数来统计出现次数
#![allow(unused)]
fn main() {
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},or_insert 方法事实上会返回这个键的值的一个可变引用(&mut V)。这里我们将这个可变引用储存在 count 变量中,所以为了赋值必须首先使用星号(*)解引用 count。这个可变引用在 for 循环的结尾离开作用域,这样所有这些改变都是安全的并符合借用规则。
哈希函数
HashMap 默认使用一种 “密码学安全的”(“cryptographically strong” )哈希函数,它可以抵抗拒绝服务(Denial of Service, DoS)攻击。然而这并不是可用的最快的算法,不过为了更高的安全性值得付出一些性能的代价。如果性能监测显示此哈希函数非常慢,以致于你无法接受,你可以指定一个不同的 hasher 来切换为其它函数。hasher 是一个实现了 BuildHasher trait 的类型。第十章会讨论 trait 和如何实现它们。你并不需要从头开始实现你自己的 hasher;crates.io 有其他人分享的实现了许多常用哈希算法的 hasher 的库。
总结
vector、字符串和哈希 map 会在你的程序需要储存、访问和修改数据时帮助你。