- 精通Rust(第2版)
- (印)拉胡尔·沙玛 (芬)韦萨·凯拉维塔
- 13001字
- 2021-02-22 15:36:18
1.3 Rust简介
对于基本的语言功能,Rust不会偏离你在其他语言中习惯的内容;在较高层面,Rust程序会被组织成模块的形式,根模块会包含一个main()函数。对于二进制可执行项目,根模块通常是一个main.rs文件,而对于程序库,根模块通常是一个lib.rs文件。在模块中,你可以定义函数、导入程序库、定义类型、创建常量、编写测试和宏,甚至创建嵌套模块。我们将进行上述所有操作,但是让我们先从基础开始。接下来将介绍一个简单的Rust程序:
// greet.rs
1. use std::env;
2.
3. fn main() {
4. let name = env::args().skip(1).next();
5. match name {
6. Some(n) => println!("Hi there ! {}", n),
7. None => panic!("Didn't receive any name ?")
8. }
9. }
让我们编译并运行该程序。将上述代码存储成名为greet.rs的文件,并使用该文件名运行rustc,然后将你的名字作为参数传递给它。这里传递的名称是Rust的非官方吉祥物Ferris,并在计算机上得到以下输出结果:
很明显,它的输出结果与预期的一致。让我们逐行解释一下该程序。
在第1行中,我们从std库导入一个名为env的模块,std是Rust的标准库。在第3行代码中,我们可以看到常见的main函数。然后在第4行中,我们调用env模块中的函数args(),它会返回传递给程序的参数的迭代器(序列)。因为第一个参数包含程序名,我们希望跳过它,所以我们调用skip并传入一个数字,该数字表示我们希望跳过的元素数目(1)。因为Rust中的迭代器是惰性的,并且不会进行预先计算,我们必须显式要求它给出下一个元素,所以接下来会调用next(),它会返回一个名为Option的枚举类型。Option既可以是Some(value),也可以是None变量,因为用户可能忘记提供参数。
在第5行中,我们在变量名上使用Rust提供的math表达式特性,并检查它是Some(n)值还是None值。match和if else语句的构造类似,但功能更强大。在第6行中,当它是Some(n)时,我们调用println!(),并传入内部字符串变量n(这在使用match表达式时会自动声明),之后向用户展示输出结果。println!调用并非一个函数,而是一个宏(它们都是以!结尾)。最后,在第7行中,如果它是一个枚举类型的None变量,那么将会调用panic!()(另外一个宏),这将中止程序运行,并向用户输出一条错误提示信息。
println!宏会接收一个字符串,该字符串包含一个用"{}"表示的元素占位符。这些字符串被称为格式化字符串,而字符串中的"{}"被称为格式化声明符。要输出简单的类型(例如基元类型),可以使用"{}"格式化声明符,而对于其他类型,可以使用"{:?}"格式化声明符。当然,与之有关的细节还有很多。当println!遇到一个格式化声明符,即"{}",以及相应的替换值时,它会在该值上调用一个方法,并返回该值的字符串形式。这种方法是特征的一部分。对于"{}"格式化声明符,它会调用一个来自Display特征的方法,对于"{:?}",它会调用一个来自Debug特征的方法。后者主要用于调试,而前者用于显示数据类型的可读形式的输出。它有点类似Java中的toString()方法。在开发过程中,通常需要输出数据类型以进行调试。使用"{:?}"格式化声明符时,上述方法在类型上是不可用的,我们需要在类型上添加#[derive(Debug)]属性来获取这些方法。后续的章节将会详细介绍这些属性,不过在接下来的代码示例中就会看到它。我们将在第9章中重温println!宏的应用。
在本章中,手动运行rustc并不意味着你在实际开发工作中也必须这么做。在后文中,我们将使用Rust的软件包管理器来构建和运行程序。除了在本地运行编译器之外,另一个可用于运行代码示例的工具是名为Rust Playground的官方在线编译器,以下是计算机上的截图:
Rust Playground还支持导入外部库,并可在运行示例程序时使用。
在前面的示例中,我们对基本的Rust程序进行了概述,但没有深入了解所有细节和语法。在接下来的章节中,我们将分别解释该语言的特性和语法。下面的解释可为你提供足够的知识储备,以便你可以快速地启动并运行编写的Rust程序,而无须详尽地浏览所有用例。简单起见,每个部分还包含对相关内容的引用,以更详细地解释这些概念。此外,Rust文档页面和内置的搜索功能将帮助你了解详细信息。建议你主动搜索后文中介绍的任意概念,这将有助于你获得正在学习的相关概念的更多背景信息。
本章中的所有代码示例都可以在本书的GitHub版本库(PacktPublishing/Mastering- RUST-Second-Edition)中找到。对于本章,它们在“Chapter 1, Getting Started with Rust Directory”目录下——本书后文的代码示例将遵循相同的约定。
注意
某些代码文件是刻意提供的,它们无法编译,因此你可以在编译器的帮助下自行修复。
接下来,让我们从Rust的基元类型开始。
1.3.1 基元类型
Rust中内置的基元类型有以下几种。
bool:这些是常见的布尔值,可以是真(true),也可以是假(false)。
char:字符,例如字母e。
整型(integer):该类型的特征在于位宽。Rust支持的最大长度是128位。
isize:指针大小的有符号整型。相当于32位CPU上的i32和64位CPU上的i64。
usize:指针大小的无符号整型。相当于32位CPU上的i32和64位CPU上的i64。
f32:32位浮点型。实现了用于表示浮点数的IEEE 754标准。
f64:64位浮点型。
[T; N]:固定大小的数组,T表示元素类型,N表示元素数目,并且是编译期非负常数。
[T]:动态大小的连续序列的视图,T表示任意类型。
str:字符串切片,主要用做引用,即&str。
(T, U, ..):有限序列,(T, U, ..)中的T和U可以是不同类型。
fn(i32) -> i32:一个接收i32类型参数并返回i32类型参数的函数。函数也有一种类型。
1.3.2 变量声明和不可变性
变量允许我们存储一个值,以便可以在后续的代码中轻松地引用它。在Rust中,我们使用关键字let来声明变量。这在本节开头的greet.rs示例中已经展示了这一点。在诸如C或Python等主流的命令式语言中,不能阻止你为初始化后的变量重新分配其他值。Rust通过在默认情况下让变量不可变而另辟蹊径,也就是说,在初始化变量后,你无法为变量分配其他值。如果稍后需要将变量指向其他变量(同一类型),则需要在其前面加上关键字mut。Rust要求你明确地表达自己的意图。
考虑如下代码:
// variables.rs
fn main() {
let target = "world";
let mut greeting = "Hello";
println!("{}, {}", greeting, target);
greeting = "How are you doing";
target = "mate";
println!("{}, {}", greeting, target);
}
我们声明了两个变量,即target和greeting。target是一个不可变的绑定变量,而greeting前面有一个关键字mut,这使它成为一个可变的绑定变量。但是,如果我们运行此程序,则会出现以下错误提示信息:
从上述错误提示信息可以看出,Rust不允许你再次为target分配值。为了让该程序通过编译,我们需要在let语句中的target之前加上关键字mut,然后再次编译和运行它。以下是程序运行后的输出结果:
$ rustc variables.rs
$ ./variables
Hello, world
How are you doing, mate
let语句不仅是为变量分配值,也是Rust中的模式匹配语句。在第7章中,我们将详细介绍它。接下来我们将讨论函数。
1.3.3 函数
函数将一堆指令抽象为具名实体,稍后可以通过其他代码调用这些指令,并帮助用户管理复杂性。我们已经在greet.rs程序中使用了一个函数,即main函数。让我们看看如何定义另一个函数:
// functions.rs
fn add(a: u64, b: u64) -> u64 {
a + b
}
fn main() {
let a: u64 = 17;
let b = 3;
let result = add(a, b);
println!("Result {}", result);
}
在上述代码中,我们创建了一个名为add的新函数。关键字fn用于创建函数,随后跟着的是函数名add,圆括号中的a和b是参数,花括号中的是函数体。冒号的右边是参数的类型。函数的返回类型使用->指定,其后跟着的是类型,即u64。如果函数无返回值,那么可以省略该类型声明。函数也有类型,我们的函数add的类型表示为fn (u64,u64) -> u64。类型声明也可以存储在变量中传递给其他函数。
如果你仔细查看add的函数体,会发现我们不需要像其他语言那样使用关键字return来返回a+b,因为最后一个表达式会自动返回。不过Rust中仍有关键字return,但它用于提前退出。函数基本上是返回值的表达式,默认情况下是()(Unit)类型的值,这与C/C++中的void返回类型相似。也可以在其他函数中声明返回值,这用于你很难将某个函数(例如foo)中的某个功能作为语句序列进行推断时。在这种情况下,你可以在本地函数bar中提取这些行,然后在父函数foo中定义它们。
在main函数中,我们用let语句声明两个变量a和b。与b的情况类似,我们甚至可以省略指定类型,因为Rust可以通过检查代码来推断大多数情况下变量的类型。这种情况也适用于result,它是一个类型为u64的值。该特性有助于防止类型签名混乱,并提高代码可读性,特别是当你的类型嵌套在多个具有长名称的其他类型中时。
注意
Rust的类型推断基于Hindly-Milner类型系统。该系统包含一组规则和算法,可以通过编程语言进行类型推断。其采用了一种有效的类型推断方法,在线性时间内执行,使它对大型程序的类型检查具有实际意义。
我们还可以使用能够修改其参数的函数。考虑以下代码:
// function_mut.rs
fn increase_by(mut val: u32, how_much: u32) {
val += how_much;
println!("You made {} points", val);
}
fn main() {
let score = 2048;
increase_by(score, 30);
}
我们声明了一个变量score,并且为其赋值为2048,然后调用函数increase_by,将score作为第1个参数,30作为第2个参数传递给它。在increase_by中,我们将第1个参数指定为mut val,这表示该参数应该被视为可变的,这允许变量在函数内部被修改。我们的函数increase_by修改了val绑定变量并输出了该值。以下是程序运行后的输出结果:
$ rustc function_mut.rs
$ ./function_mut
You made 2078 points
接下来让我们探索一下闭包。
1.3.4 闭包
Rust也支持闭包。闭包与函数类似,但具有声明它们的环境或作用域的更多信息。虽然函数具有与之关联的名称,闭包的定义没有,但可以将它们分配给变量。Rust类型推断的另一个优点是,在大多数情况下,你可以为没有类型的闭包指定参数。这是一个最简单的闭包“let my_closure = || ();”。我们刚刚定义了一个什么都不做的无参数闭包。然后我们可以通过my_closure()来调用它,这和函数类似。两个竖条“||”用于存放闭包的参数(如果有的话),例如|a,b|。当Rust无法找出正确的类型时,有时需要指定参数类型(|a:u32|)。和函数类似,闭包也可以存储在变量中,稍后调用或传递给其他函数。但是,闭包的主体可以是单一表达式,也可以是由花括号标识的多个表达式组成。更复杂的闭包示例如下所示:
// closures.rs
fn main() {
let doubler = |x| x * 2;
let value = 5;
let twice = doubler(value);
println!("{} doubled is {}", value, twice);
let big_closure = |b, c| {
let z = b + c;
z * twice
};
let some_number = big_closure(1, 2);
println!("Result from closure: {}", some_number);
}
在上述代码中,我们定义了两个闭包:doubler和big_closure。doubler将给定的值加倍。在这种情况下,它从父作用域或上下文环境传递value,即main函数。同样,在big_closure中,我们从其环境中使用变量twice。这个闭包在花括号内有多行表达式,需要以分号结尾,以便我们将它分配给变量big_closure。之后,我们调用big_closure,传入1、2作为参数,并输出some_number。
闭包主要用作高阶函数的参数。高阶函数是一个以另一个函数或闭包作为参数的函数。例如,标准库中的thread::spawn函数接收一个闭包作为参数,你可以在其中编写要在另一个线程中运行的代码。闭包提供简便、抽象的另一个场景是,当你有一个对Vec等集合进行操作的函数时,希望根据某些条件过滤元素。Rust的迭代器特征(iterator trait)有一个名为filter的方法,可以接收一个闭包作为参数。此闭包由用户定义,并返回true或false,具体取决于用户希望过滤集合中元素的方式。我们将在第7章深入了解闭包。
1.3.5 字符串
字符串是在任何编程语言中最常用的数据类型之一。在Rust中,它们通常以两种形式出现:&str类型和String类型。Rust字符串保证是有效的UTF-8编码字节序列。它们不像C字符串那样以空值(NULL)终止,并且可以在字符串之间包含空的字节。以下程序展示了这两种类型:
// strings.rs
fn main() {
let question = "How are you ?"; // &str类型
let person: String = "Bob".to_string();
let namaste = String::from(![26.tif{15%}](/api/storage/getbykey/original?key=21016942a5efbb225ec6)); // unicodes yay!
println!("{}! {} {}", namaste, question, person);
}
在上述代码中,person和namaste的类型为String,而question的类型为&str。创建String类型数据的方法有多种。String类型数据是在堆上分配的,&str类型数据通常是指向现有字符串的指针,这些字符串可以在堆栈和堆上,也可以是已编译对象代码的数据段中的字符串。&是一个运算符,用于创建指向任何类型的指针。在初始化前面代码中的字符串后,我们使用println!宏通过格式化字符串将它们一起输出。这些是最基本的字符串知识,我们将在第7章对字符串进行详细介绍。
1.3.6 条件和判断
Rust中的条件判断和其他语言中的类似,它们也遵循类C语言风格的if else结构:
// if_else.rs
fn main() {
let rust_is_awesome = true;
if rust_is_awesome {
println!("Indeed");
} else {
println!("Well, you should try Rust !");
}
}
在Rust中,if构造不是语句,而是一个表达式。在一般的编程用语中,语句不返回任何值,但表达式会返回值。这种区别意味着Rust中的if else条件总是会返回一个值。该值可以是empty类型的(),也可能是实际的值。无论花括号中的最后一行是什么,都会成为if else表达式的返回值。重点是要注意if和else分支应该具有相同的返回类型。如前所示,我们不需要在if条件表达式的两边添加括号,我们甚至可以将if else代码块的值分配给变量:
// if_assign.rs
fn main() {
let result = if 1 == 2 {
"Wait, what ?"
} else {
"Rust makes sense"
};
println!("You know what ? {}.", result);
}
当将要分配的值从if else表达式返回时,我们需要用分号作为结束标志。例如,if是一个表达式,那么let是一个声明,期望我们在结尾处有分号。在赋值的情况下,如果需要从前面的代码中删除else代码块,编译器会抛出一个错误提示信息,如下所示:
如果没有else代码块,当if条件的判断结果为false时,那么结果将是(),变量result的值将可能是两个,即()和&str。Rust不允许将多种类型的数据存储在一个变量中。因此,在这种情况下,我们需要if和else代码块返回相同的类型。此外,在条件分支中添加分号会更改代码的含义。通过在一些代码中的if代码块中的字符串之后添加分号,编译器会认为用户希望抛弃该值:
// if_else_no_value.rs
fn main() {
let result = if 1 == 2 {
"Nothing makes sense";
} else {
"Sanity reigns";
};
println!("Result of computation: {:?}", result);
}
在这种情况下,结果将是一个empty类型的(),这就是我们必须更改 println!({:?})表达式的原因;此类型无法以常规方式输出。对于更复杂的多值条件判断,Rust提供了被称为match表达式的强大构造来处理。接下来我们将会对它进行介绍。
1.3.7 match表达式
Rust的match(匹配)表达式非常简单、易用。它基本上类似于C语言的switch语句简化版,允许用户根据变量的值,以及是否具有高级过滤功能做出判断。以下是一个使用match表达式的程序:
// match_expression.rs
fn req_status() -> u32 {
200
}
fn main() {
let status = req_status();
match status {
200 => println!("Success"),
404 => println!("Not Found"),
other => {
println!("Request failed with code: {}", other);
//从缓存中获取响应
}
}
}
在上述代码中有一个req_status函数,它返回一个伪超文本传输协议(HyperText Transfer Protocol,HTTP)请求状态代码200,然后在main函数中调用,并将它分配给变量status。之后使用关键字match匹配此值,关键字后面跟着的是要检查的变量(status),后面跟一对花括号。在花括号内,我们编写表达式——它们被称为匹配臂。这些匹配臂表示匹配的变量可以采用的候选值。每个匹配臂是通过可能写入变量的值来构造的,随后跟着的是一个“=>”,然后右边是表达式。在右侧,你可以在花括号中使用单行表达式或多行表达式。当编写的是单行表达式时,需要用逗号进行分隔。此外,每个匹配臂必须返回相同的类型。在这种情况下,每个匹配臂返回一个Unit类型()。
另一个很好的特性,或者可以称之为match表达式的保证,是我们必须对所有可能匹配的值进行彻底匹配。在本示例中,这将列出所有数字直到i32类型允许的最大值。实际上这是不可能的。如果我们想忽略相关的值,Rust允许我们通过使用catch all变量(这里是other)或者_(下画线)来忽略其余的可能性。当你有多个可能的值,并且需要简洁地进行构造时,match表达式是围绕这些值做出决策的主要方式。与if else 表达式一样,match表达式的返回值也可以在用分号分隔的let语句中为变量赋值,其中所有匹配臂的返回值类型相同。
1.3.8 循环
在Rust中重复做某些事情可以使用3种构造来完成,即loop、while和for。在所有这些构造中,通常都包含关键字continue和break,分别允许你跳过和跳出循环。以下是一个使用循环的示例,相当于C语言中的while(true):
// loops.rs
fn main() {
let mut x = 1024;
loop {
if x < 0 {
break;
}
println!("{} more runs to go", x);
x -= 1;
}
}
loop表示无限循环。在上述代码中,我们简单地递减x的值,当它达到if条件x<0时,中断循环。在Rust中执行循环的一个额外特性是,能够使用名称标记循环代码块。这可以在你有两个或多个嵌套循环,并想要从它们中的任何一个中断的情况下使用,而不仅针对直接包含break语句的循环。以下是使用循环标签中断loop的示例:
// loop_labels.rs
fn silly_sub(a: i32, b: i32) -> i32 {
let mut result = 0;
'increment: loop {
if result == a {
let mut dec = b;
'decrement: loop {
if dec == 0 {
//直接从 'increment循环中断
break 'increment;
} else {
result -= 1;
dec -= 1;
}
}
} else {
result += 1;
}
}
result
}
fn main() {
let a = 10;
let b = 4;
let result = silly_sub(a, b);
println!("{} minus {} is {}", a, b, result);
}
在上述代码中,我们正在执行一种非常低效的减法操作,只是为了演示标签在嵌套循环中的使用方法。在内部'decrement标签中,当dec等于0时,可以传递一个标签来中断循环(这里是'increment),并且中断外部的'increment循环。
现在,让我们看看while循环。这个示例非常简单:
// while.rs
fn main() {
let mut x = 1000;
while x > 0 {
println!("{} more runs to go", x);
x -= 1;
}
}
Rust中也有关键字for,它类似于其他语言中使用的for循环,但它们的实现完全不同。Rust的for循环基本上是一种更强大的重复构造(迭代器)的语法糖。我们将在第7章详细地讨论它。简单地说,Rust中的for循环只适用于可以转换为迭代器的类型。一种这样的类型是Range类型。Range类型可以指代一系列数字,例如(0..10)。它们可以用于for循环,如下所示:
// for_loops.rs
fn main() {
//不包括10
print!("Normal ranges: ");
for i in 0..10 {
print!("{},", i);
}
println!(); //另起一行
print!("Inclusive ranges: ");
//开始计数直到10
for i in 0..=10 {
print!("{},", i);
}
}
一般的区间语法0..10,是不包括10的,Rust还具有包含区间的语法,例如0..=10,它会一直迭代到10才停止,如第2个for循环所示。现在,我们将开始讨论自定义数据类型。
1.3.9 自定义数据类型
自定义类型,顾名思义,是由用户定义的类型。自定义类型可以由几种类型组成。它们可以是基元类型的包装器,也可以是多个自定义类型的组合。它们有3种形式:结构体、枚举及联合,或者被称为struct、enum及union。它们允许你更轻松地表示自己的数据。自定义类型的命名规则遵循驼峰命名法(CamelCase)。Rust的结构体和枚举功能比C语言的结构体和枚举功能更强大,而Rust的联合非常类似于C语言的联合,主要用于与C语言代码库交互。我们将在本节中介绍结构体和枚举,将在第7章中详细介绍联合。
结构体
在Rust中,结构体的声明形式有3种。其中最简单的是单元结构体(unit struct),它使用关键字struct进行声明,随后是其名称,并用分号作为结尾。以下代码示例定义了一个单元结构体:
// unit_struct.rs
struct Dummy;
fn main() {
let value = Dummy;
}
我们在上述代码中定义了一个名为Dummy的单元结构体。在main函数中,我们可以仅使用其名称初始化此类型。value现在包含一个Dummy实例,并且值为0。单元结构体在运行时不占用任何空间,因为没有与之关联的数据。用到单元结构体的情况非常少。它们可用于对没有与之关联的数据或状态进行实体建模;也可用于表示错误类型,结构体本身足以表述错误,而不需要对其进行描述;还可用于表示状态机实现过程中的状态。接下来,让我们看看结构体的第2种形式。
结构体的第2种形式是元组结构体(tuple struct),它具有关联数据。其中的每个字段都没有命名,而是根据它们在定义中的位置进行引用。假定你正在编写用于图形应用程序的颜色转换/计算库,并希望在代码中表示RGB颜色值。可以用以下代码表示Color类型和相关元素:
// tuple_struct.rs
struct Color(u8, u8, u8);
fn main() {
let white = Color(255, 255, 255);
//可以通过索引访问它们
let red = white.0;
let green = white.1;
let blue = white.2;
println!("Red value: {}", red);
println!("Green value: {}", green);
println!("Blue value: {}\n", blue);
let orange = Color(255, 165, 0);
//你也可以直接解构字段
let Color(r, g, b) = orange;
println!("R: {}, G: {}, B: {} (orange)", r, g, b);
//也可以在解构时忽略字段
let Color(r, _, b) = orange;
}
在上述代码中,Color(u8, u8, u8)是创建和存储到变量white的元组结构体。然后,我们使用white.0语法访问white中的单个颜色组件。元组结构体中的字段可以通过variable. <index>这样的语法访问,其中索引会引用结构体中字段的位置,并且是以0开头的。访问结构体中字段的另一种方法是使用let语句对结构体进行解构。后面,我们创建了一个颜色orange(橙色)。随后我们编写了一条let语句,并让Color(r, g, b)位于等号左边,orange位于等号右边。这使得orange中的3个字段分别存储到了变量r、g和b中。系统会自动为我们判定r、g和b的类型。
对于5个以下的属性进行数据建模时,元组结构体是理想的选择。除此之外的任何选择都会妨碍代码的可读性和我们的推理。对于具有3个以上字段的数据类型,建议使用类C语言的结构体,这是第3种形式,也是最常用的形式。请参考如下代码:
// structs.rs
struct Player {
name: String,
iq: u8,
friends: u8,
score: u16
}
fn bump_player_score(mut player: Player, score: u16) {
player.score += 120;
println!("Updated player stats:");
println!("Name: {}", player.name);
println!("IQ: {}", player.iq);
println!("Friends: {}", player.friends);
println!("Score: {}", player.score);
}
fn main() {
let name = "Alice".to_string();
let player = Player { name,
iq: 171,
friends: 134,
score: 1129 };
bump_player_score(player, 120);
}
在上述代码中,结构体的创建方式与元组结构体的相同,即通过指定关键字struct,随后定义结构体的名称。但是,结构体以花括号开头,并且声明了字段名称。在花括号内,我们可以将字段写成以逗号分隔的“field:type”对。创建结构体的实例也很简单;我们只需编写Player,随后跟一对花括号,花括号中包含以逗号分隔的字段。使用与字段具有相同名称的变量初始化字段时,我们可以使用字段初始化简化(field init shortland)特性,即前面代码中的name字段。然后,我们可以使用struct.field_name语法轻松地访问此前创建的实例中的字段。
在上述代码中,我们还有一个名为bump_player_score的函数,它将结构体Player作为参数。默认情况下,函数参数是不可变的,所以当我们需要修改播放器中的分数(score)时,需要将函数中的参数修改为mut player,以允许我们修改它的任何字段。在结构体上使用关键字mut意味着它的所有字段都是可修改的。
使用结构体而不是元组结构体的优点在于,我们可以按任意顺序初始化字段,还可以为字段提供有意义的名称。此外,结构体的大小只是其每个字段成员大小的总和,如有必要,还包括任意数据对齐填充所需的空间大小。它没有任何额外的元数据尺寸的开销。接下来,让我们来看看枚举。
枚举
当你需要为不同类型的东西建模时,枚举可能是一种好办法。它是使用关键字enum创建的,之后跟着的是枚举名称和一对花括号。在花括号内部,我们可以编写所有可能的类型,即变体。这些变体可以在包含或不包含数据的情况下定义,并且包含的数据可以是任何基元类型、结构体、元组结构体,甚至是枚举类型。
不过,在递归的情况下,例如你有一个枚举Foo和一个引用枚举的变体,则该变体需要在指针类型(Box、Rc等)的后面,以避免类型无限递归定义。因为枚举也可以在堆栈上创建,所以它们需要预先指定大小,而无限的类型定义使它无法在编译时确定大小。现在,我们来看看如何创建一个枚举:
// enums.rs
enum Direction {
N,
E,
S,
W
}
enum PlayerAction {
Move {
direction: Direction,
speed: u8
},
Wait,
Attack(Direction)
}
fn main() {
let simulated_player_action = PlayerAction::Move {
direction: Direction::N,
speed: 2,
};
match simulated_player_action {
PlayerAction::Wait => println!("Player wants to wait"),
PlayerAction::Move { direction, speed } => {
println!("Player wants to move in direction {:?} with speed {}",
direction, speed)
}
PlayerAction::Attack(direction) => {
println!("Player wants to attack direction {:?}", direction)
}
};
}
上述代码定义了两个变体:Direction和PlayerAction。然后我们通过选择任意变体来创建它们的实例,其中变体和枚举名用双冒号分隔,例如Direction::N和PlayerAction::Wait。注意,我们不能使用未初始化的枚举,它必须是变体之一。给定枚举值,要查看枚举实例包含哪些变体,可以使用match表达式进行模式匹配。当我们在枚举上匹配时,我们可以将变量放在PlayerAction::Attack(direction)中的direction等字段中,从而直接解构变体中的内容,反过来,这意味着我们可以在匹配臂中使用它们。
正如你在前面的Direction变体中看到的,我们有一个#[derive(Debug)]注释。这是一个属性,它允许用户在println!()中以{:?}格式输出Direction实例。这是通过名为Debug的特征生成方法来完成的。编译器告诉我们是否缺少Debug,并提供有关修复它的建议,因此我们需要从那里获得该属性:
从函数式程序员的角度看,结构体和枚举也称为代数数据类型(Algebraic Data Type,ADT),因为可以使用代数规则来表示它们能够表达的值的取值区间。例如,枚举被称为求和类型,是因为它可以容纳的值的范围基本上是其变体的取值范围的总和;而结构体被称为乘积类型,是因为它的取值区间是其每个字段取值区间的笛卡儿积。在谈到它们时,我们有时会将它们称为ADT。
1.3.10 类型上的函数和方法
没有行为的类型功能有限,并且通常情况下我们希望类型具有函数或方法,以便我们可以返回它们的实例而不是手动构造它们,或者使我们能够操作自定义类型中的字段。这可以通过impl块来实现,它被视作某个类型提供实现。我们可以为所有自定义类型或包装器类型提供实现。首先,我们来看看如何编写结构体的实现。
结构体上的impl块
我们可以使用两种机制向之前定义的结构体Player中添加行为:一种是类似构造函数的函数,它接收一个名称并为Person中的其余字段设置默认值,另一种是设置Person的friends字段的getter和setter方法。
// struct_methods.rs
struct Player {
name: String,
iq: u8,
friends: u8
}
impl Player {
fn with_name(name: &str) -> Player {
Player {
name: name.to_string(),
iq: 100,
friends: 100
}
}
fn get_friends(&self) -> u8 {
self.friends
}
fn set_friends(&mut self, count: u8) {
self.friends = count;
}
}
fn main() {
let mut player = Player::with_name("Dave");
player.set_friends(23);
println!("{}'s friends count: {}", player.name, player.get_friends());
//另一种调用实例方法的方式
let _ = Player::get_friends(&player);
}
我们指定关键字impl,然后指定我们希望实现方法的类型,后跟一对花括号。在花括号中,我们可以编写两种方法。
关联方法:该方法没有self类型作为第1个参数。with_name方法被称为关联方法,因为它没有使用self作为第1个参数。它类似于面向对象编程语言中的静态方法。这些方法在类型自身上即可调用,并且不需要类型的实例来调用。通过在方法名称前加上结构体名称和双冒号来调用关联方法,如下所示:
Player::with_name("Dave");
实例方法:将self作为第1个参数的函数。这里的self类似于Python中的self,并指向实现该方法的实例(这里是Player)。因此,get_friends()方法只能在已创建的结构体实例上调用:
let player = Player::with_name("Dave");
player.get_friends();
如果我们使用关联方法的语法调用get_friends,即Player::get_friends(),编译器会给出如下错误提示信息:
这里的错误提示信息具有误导性,但它表明实例方法基本上就是关联方法,self是第1个参数,而instance.foo()是一种语法糖。这意味着我们可以这样调用它:Player::get_friends (&player);。在此调用中,我们给方法传递了一个Player的实例,&self就是&player。
我们可以在类型上实现3种实例方法的变体。
self作为第一个参数。在这种情况下,调用此方法将不允许你后续使用该类型。
&self作为第一个参数。此方法仅提供对类型实例的读取访问权限。
&mut self作为第一个参数。此方法提供对类型实例的可变访问。
我们的set_friends方法是一个&mut self方法,它允许我们修改player中的字段。我们需要在self之前添加运算符&,这表示self在方法存续期间被借用,这正是我们想要的。如果没有&符号,调用者会将所有权移动到方法,这意味着在get_friends方法返回后将取消分配值,我们将不能再使用Player实例。不必担心,我们没有在第5章详细解释所有这些之前,移动和借用这些术语并没有什么特别的含义。
接下来,我们将讨论枚举的实现。
impl块和枚举
我们还可以为枚举提供实现。例如,考虑使用Rust构建的支付程序库,它公开了一个名为pay的API:
// enum_methods.rs
enum PaymentMode {
Debit,
Credit,
Paypal
}
//一些网络支付处理程序
fn pay_by_credit(amt: u64) {
println!("Processing credit payment of {}", amt);
}
fn pay_by_debit(amt: u64) {
println!("Processing debit payment of {}", amt);
}
fn paypal_redirect(amt: u64) {
println!("Redirecting to paypal for amount: {}", amt);
}
impl PaymentMode {
fn pay(&self, amount: u64) {
match self {
PaymentMode::Debit => pay_by_debit(amount),
PaymentMode::Credit => pay_by_credit(amount),
PaymentMode::Paypal => paypal_redirect(amount)
}
}
}
fn get_saved_payment_mode() -> PaymentMode {
PaymentMode::Debit
}
fn main() {
let payment_mode = get_saved_payment_mode();
payment_mode.pay(512);
}
上述代码中有一个名为get_saved_payment_mode的方法,它返回用户保存的付款方式。这些方式可以是信用卡、借记卡或Paypal。最好将其建模为枚举,其中可以添加不同的付款方式作为其变体。然后程序库为我们提供单一的pay()方法,以便用户可以方便地提供支付金额。此方法可以确定枚举中的某个变体,并相应地将方法指派给正确的支付服务供应商,而不会让程序库的用户担心要检查使用哪种付款方式。
枚举也广泛用于状态机,当其与match表达式搭配使用时,它们可使状态转换代码非常简洁。它们还可用于自定义错误类型的建模。当枚举变体没有任何与之关联的数据时,它们可以像C语言的枚举那样使用,其中的变体默认具有以0开头的整数值(isize),但也可以手动标记整数值。这在与外部C程序库交互时很有用。
1.3.11 module、import和use语句
编程语言通常会提供一种将大型代码块拆分为多个文件以管理复杂性的方法。Java遵循每个.java文件就是公共类的约定,而C++为我们提供了头文件和include语句。Rust也不例外,它为我们提供了模块机制。模块是Rust程序中命名和组织代码的一种方式。为了灵活地组织代码,Rust提供了多种创建模块的方法。
模块是一个复杂的主题,本章只对它进行简要介绍,我们将重点介绍它的应用。第2章将会对它进行深入讨论。以下是Rust模块的主要内容。
每个Rust程序都需要一个root模块。对于可执行文件,它通常是main.rs文件,对于程序库,它通常是lib.rs文件。
模块可以在其他模块内部声明,也可以组织为文件和目录。
为了让编译器能够识别我们的模块,我们需要使用关键字mod声明,例如mod my_module。在我们的root模块中,要在模块名称前使用关键字use,这表示将元素引入作用域。
模块中定义的元素默认是私有的,你需要使用关键字pub将它暴露给调用方。
上述内容是模块的简要介绍。第7章将会讨论模块的高级应用。接下来,让我们看一下标准库中常用的集合类型。
1.3.12 集合
通常情况下,你的程序必须处理多个数据实例,因此,可使用集合类型。根据你的需要以及数据驻留在内存中的位置,Rust提供了多种内置类型来存储数据集合。首先,我们有数组和元组。然后,我们的标准库中有动态集合类型,将介绍其中最常用的类型,即项目列表(vector)和键/值对(map)。最后,我们还引用了被称为切片的集合类型,它们基本上是对某些其他变量所拥有的连续数据的视图。让我们先从数组开始介绍。
数组
数组具有固定长度,可以存储相同类型的元素。它们用[T,N]表示,其中T表示任意类型,N表示数组元素的数量。数组的大小不能用变量表示,并且必须是usize的字面值:
// arrays.rs
fn main() {
let numbers: [u8; 10] = [1, 2, 3, 4, 5, 7, 8, 9, 10, 11];
let floats = [0.1f64, 0.2, 0.3];
println!("Number: {}", numbers[5]);
println!("Float: {}", floats[2]);
}
在上述代码中,我们声明了一个整型数组,其中包含10个元素,并在左侧指定了元素的类型。在第二个浮点型数组中,我们将类型指定为数组中第一个元素的后缀,即0.1f64。这是指定类型的另一种方法。接下来,让我们来介绍元组。
元组
元组与数组的不同之处在于,数组的元素必须具有相同的类型,而元组中的元素可以具有不同的类型。元组是异构集合,可用于将不同类型的元素存储在一起,从函数返回多个值时可以使用它。考虑下列应用元组的代码:
// tuples.rs
fn main() {
let num_and_str: (u8, &str) = (40, "Have a good day!");
println!("{:?}", num_and_str);
let (num, string) = num_and_str;
println!("From tuple: Number: {}, String: {}", num, string);
}
在上述代码中,num_and_str是一个包含两个元素的元组,即(u8, &str)。我们还将已经声明的元组中的值提取到单个变量中。输出元组后,我们将已经声明的元组解构为num和string变量,并自动推断它们的类型。该代码非常简洁。
项目列表
项目列表和数组类似,不过它们的内容和长度不需要事先指定,并且可以按需增长。它们是在堆上分配的。我们既可以使用构造函数Vec::new,也可以使用宏 vec![]创建它们:
// vec.rs
fn main() {
let mut numbers_vec: Vec<u8> = Vec::new();
numbers_vec.push(1);
numbers_vec.push(2);
let mut vec_with_macro = vec![1];
vec_with_macro.push(2);
let _ = vec_with_macro.pop(); //忽略空格
let message = if numbers_vec == vec_with_macro {
"They are equal"
} else {
"Nah! They look different to me"
};
println!("{} {:?} {:?}", message, numbers_vec, vec_with_macro);
}
在上述代码中,我们以不同方式创建了两个项目列表,即numbers_vec和vec_with_macro。我们可以使用push()方法将元素推送到vector中,并可以使用pop()方法删除元素。如果你希望了解更多相关的方法,可以参考官方帮助文档,还可以使用for循环语句迭代访问vector,因为它们也实现了Iterator特征。
键/值对
Rust还为我们提供了键/值对,它可以用于存储键/值对。它们来自std::collections模块,名为HashMap。它们是使用构造函数HashMap::new创建的:
// hashmaps.rs
use std::collections::HashMap;
fn main() {
let mut fruits = HashMap::new();
fruits.insert("apple", 3);
fruits.insert("mango", 6);
fruits.insert("orange", 2);
fruits.insert("avocado", 7);
for (k, v) in &fruits {
println!("I got {} {}", v, k);
}
fruits.remove("orange");
let old_avocado = fruits["avocado"];
fruits.insert("avocado", old_avocado + 5);
println!("\nI now have {} avocados", fruits["avocado"]);
}
在上述代码中,我们新建了一个名为fruits的HashMap。然后使用insert方法向其中插入了一些水果元素以及相关的计数。接下来,我们使用for循环遍历键/值对,其中通过&fruits引用我们的水果映射结构,因为我们只希望读取其中的键和值。默认情况下,for循环将使用该值。在上述情况下,for循环返回一个包含两个字段的元组((k,v))。还有单独的方法keys()和values()分别用于迭代访问键和值。用于哈希化HashMap类型键的哈希算法基于Robin hood开放寻址方案,但我们可以根据用例和性能替换成自定义哈希方案。接下来,让我们看看切片。
切片
切片是获取集合类型视图的常用做法。大多数用例是对集合类型中特定区间的元素进行只读访问。切片基本上是指针或引用,指向现有集合类型中某个其他变量所拥有的连续区间。实际上,切片是指向堆栈或堆中某处现有数据的胖指针,这意味着它还包含关于指向元素多少的信息,以及指向数据的指针。
切片用&[T]表示,其中T表示任意类型。它们的使用方式与数组非常类似:
// slices.rs
fn main() {
let mut numbers: [u8; 4] = [1, 2, 3, 4];
{
let all: &[u8] = &numbers[..];
println!("All of them: {:?}", all);
}
{
let first_two: &mut [u8] = &mut numbers[0..2];
first_two[0] = 100;
first_two[1] = 99;
}
println!("Look ma! I can modify through slices: {:?}", numbers);
}
在上述代码中有一个numbers数组,这是一个堆栈分配值。然后我们使用&numbers[..]语法对数组中的数字进行切片并存储到变量all中,其类型为&[u8]。末尾的[..]表示我们要获取整个集合。这里我们需要用到&,是因为切片是不定长类型(unsized types),不能将切片存储为裸值——即仅在指针后面。与之有关的细节将会在第7章详细介绍。我们还可以提供范围([0..2])以获得任意区间的切片。切片也可以可变地获得。first_two是一个可变切片,我们可以通过它修改原始的numbers数组。
对细心的读者来说,你会发现在上述代码中,我们在进行切片时额外使用了一对花括号。它们用于隔离从不可变引用中获取切片的可变引用的代码。没有它们,代码将无法进行编译。第5章将会对它们进行详细介绍。
注意
&str类型也属于切片类型([u8]),与其他字节切片的唯一区别在于,它们保证为UTF-8。也可以在Vec或String上执行切片。
接下来,让我们来讨论迭代器。
1.3.13 迭代器
迭代器是一种构造,它提供了一种高效访问集合类型元素的方法,不过它并不是一个新的概念。在许多命令式语言中,它们为从集合类型(例如list或map)构造的对象。例如,Python的iter(some_list)或者C++的vector.begin()是从现有集合构造迭代器的方法。迭代器的一个优点是它们提供了对集合中元素的更高级别抽象,而不是使用手动循环,因为后者很容易因为某个错误而终止执行。
迭代器的另一个优点它是不会在内存中读取整个集合,并且是惰性的。惰性表示迭代器仅在需要时对集合中的元素进行求值或访问。迭代器还可以与多个转换操作链接,例如根据相关条件过滤元素,并且在你需要之前不进行求值转换。当你需要访问这些元素时,迭代器会提供next()方法,该方法尝试从集合中读取下一个元素,这一操作会在迭代器进行链式计算求值时发生。
注意
只有在类型具有集合(语义)时,才有必要实现Iterator特征。例如,对于()单位类型实现Iterator特征是无意义的。
在Rust中,迭代器是实现了Iterator特征的任意类型。可以在for循环中使用迭代器来遍历其元素。它们是为大多数标准库集合类型实现的,例如vector、HashMap、BTreeMap等,并且还可以为自定义类型实现。
我们在Rust中处理集合类型时,经常会用到迭代器。事实上,Rust的for循环可以转换成一个普通的match表达式,其中包含对迭代器对象next()方法的调用。此外,我们可以通过调用其中的iter()或者into_iter()方法将大多数集合类型转换为迭代器。上述内容已经提供了与迭代器相关的足够多的信息,以便我们进行接下来的练习。我们将会在第7章深入介绍迭代器,并实现一个自定义迭代器。