Rust 程序设计语言 - Rust 程序设计语言 简体中文版

常见编程概念

变量

变量

变量默认是不可改变的(immutable)。

在变量前面添加 mut 可以声明其可变。

如:

常量

类似于不可变变量,常量 (constants) 是绑定到一个名称的不允许改变的值,不过常量与变量还是有一些区别。

不允许对常量使用 mut。声明常量使用 const 关键字而不是 let,并且 必须 注明值的类型。常量只能被设置为常量表达式,而不可以是其他任何只能在运行时计算出的值。

如:

const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;

Rust 对常量的命名约定是在单词之间使用全大写加下划线。

遮蔽

可以定义一个与之前变量同名的新变量。称之为第一个变量被第二个 遮蔽(Shadowing) 了,这意味着当您使用变量的名称时,编译器将看到第二个变量。

可以用相同变量名称来遮蔽一个变量,以及重复使用 let 关键字来多次遮蔽。

当不小心尝试对变量重新赋值时,如果没有使用 let 关键字,就会导致编译时错误。

通过使用 let,我们可以用这个值进行一些计算,不过计算完之后变量仍然是不可变的。

mut 与遮蔽的另一个区别是,当再次使用 let 时,实际上创建了一个新变量,我们可以改变值的类型,并且复用这个名字。

数据类型

Rust 是 静态类型statically typed)语言,也就是说在编译时就必须知道所有变量的类型。根据值及其使用方式,编译器通常可以推断出我们想要用的类型。

当多种类型均有可能时,必须增加类型注解。

标量

标量scalar)类型代表一个单独的值。Rust 有四种基本的标量类型:整型、浮点型、布尔类型和字符类型。

整型

isizeusize 类型依赖运行程序的计算机架构:64 位架构上它们是 64 位的,32 位架构上它们是 32 位的。

数字字面值允许使用类型后缀,例如 57u8 来指定类型

允许使用 _ 做为分隔符以方便读数

整数溢出:

release下不会检测,但是debug下会检测,并抛出panic

浮点型

默认为 f64,也有 f32

数值运算

支持基本数学运算:加法、减法、乘法、除法和取余。整数除法会向零舍入到最接近的整数。

let truncated = -5 / 3; // 结果为 -1

布尔类型

falsetrue

字符类型

Rust 的 char 类型是语言中最原始的字母类型。

1
2
3
let c = 'z';
let z: char = 'ℤ'; // with explicit type annotation
let heart_eyed_cat = '😻';

单引号声明 char 字面值,而与之相反的是,使用双引号声明字符串字面值。

Rust 的 char 类型的大小为四个字节 (four bytes),并代表了一个 Unicode 标量值(Unicode Scalar Value),这意味着它可以比 ASCII 表示更多内容。

复合类型

复合类型Compound types)可以将多个值组合成一个类型。Rust 有两个原生的复合类型:元组(tuple)和数组(array)。

元组类型

元组是一个将多个不同类型的值组合进一个复合类型的主要方式。

元组长度固定:一旦声明,其长度不会增大或缩小。

元组创建:

let tup: (i32, f64, u8) = (500, 6.4, 1);

从元组中获取单个值,可以使用模式匹配(pattern matching)来解构(destructure)元组值。

let tup = (500, 6.4, 1);

let (x, y, z) = tup;

let (x, y, z) = (1, 1.1, 3);

let 和一个模式将 tup 分成了三个不同的变量,xyz。这叫做 解构destructuring),因为它将一个元组拆成了三个部分。

访问:使用点号(.)后跟值的索引来直接访问所需的元组元素。

1
2
3
4
5
6
7
let x: (i32, f64, u8) = (500, 6.4, 1);

let five_hundred = x.0;

let six_point_four = x.1;

let one = x.2;

不带任何值的元组有个特殊的名称,叫做 单元(unit) 元组。这种值以及对应的类型都写作 (),表示空值或空的返回类型。如果表达式不返回任何其他值,则会隐式返回单元值。

数组类型

与元组不同,数组中的每个元素的类型必须相同。

Rust 中的数组长度是固定的。

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

当你想要在栈(stack)而不是在堆(heap)上为数据分配空间,或者是想要确保总是有固定数量的元素时,数组非常有用。

数组不如 vector 类型灵活。vector 类型是标准库提供的一个 允许 增长和缩小长度的类似数组的集合类型。当不确定是应该使用数组还是 vector 的时候,那么很可能应该使用 vector。

let months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];

编写数组的类型:在方括号中包含每个元素的类型,后跟分号,再后跟数组元素的数量。

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

访问数组元素

数组是可以在栈 (stack) 上分配的已知固定大小的单个内存块。

1
2
3
4
5
6
fn main() {
let a = [1, 2, 3, 4, 5];

let first = a[0];
let second = a[1];
}

无效的数组元素访问

程序在索引操作中使用一个无效的值时导致 运行时 错误。程序带着错误信息退出。

当尝试用索引访问一个元素时,Rust 会检查指定的索引是否小于数组的长度。

如果索引超出了数组长度,Rust 会 panic

这种检查必须在运行时进行,特别是在这种情况下,因为编译器不可能知道用户在以后运行代码时将输入什么值。

## 函数

fn 关键字,它用来声明新函数。

Rust 代码中的函数和变量名使用 snake case 规范风格。在 snake case 中,所有字母都是小写并使用下划线分隔单词。

1
2
3
4
5
6
7
8
9
fn main() {
println!("Hello, world!");

another_function();
}

fn another_function() {
println!("Another function.");
}

源码中 another_function 定义在 main 函数 之后;也可以定义在之前。Rust 不关心函数定义所在的位置,只要函数被调用时出现在调用之处可见的作用域内就行。

参数

1
2
3
4
5
6
7
fn main() {
another_function(5);
}

fn another_function(x: i32) {
println!("The value of x is: {x}");
}

在函数签名中,必须 声明每个参数的类型。

1
2
3
fn print_labeled_measurement(value: i32, unit_label: char) {
println!("The measurement is: {value}{unit_label}");
}

语句与表达式

函数体由一系列的语句和一个可选的结尾表达式构成。

Rust 是一门基于表达式(expression-based)的语言。

  • 语句Statements)是执行一些操作但不返回值的指令。
  • 表达式Expressions)计算并产生一个值。

函数定义也是语句,调用函数并不是语句。

1
2
3
4
5
6
let x = (let y = 6); // 报错,let y = 6 是语句而不是表达式

let y = {
let x = 3;
x + 1
}; // 不报错,是语句,因为 x + 1 后面没有加分号;

具有返回值的函数

不对返回值命名,但要在箭头(->)后声明它的类型。

在 Rust 中,函数的返回值等同于函数体最后一个表达式的值。

使用 return 关键字和指定值,可从函数中提前返回;但大部分函数隐式的返回最后的表达式。

1
2
3
4
5
6
7
fn five() -> i32 {
5
} // 返回 5

fn five() -> i32 {
5;
} // 报错,因为有分号; 不是表达式

注释

// 会无视该行其后面的内容。

使用 /// 可以使用markdown语法。

控制流

if - else if - else

if关键字后面条件必须bool 值。如果条件不是 bool 值,我们将得到一个错误。

let中使用:let number = if 3 > 2 { 5 } else { 6 };

当然,和 ? :三目运算符一样,必须类型一样。

而且,if语句必须都返回一样的类型。

比如:

1
2
3
4
5
if 3 > 2 {
5 // 返回i32
} else {
6; // 返回()
} // 错误,返回类型不一致。

loop、while、for

loop

无限循环,除非break

从循环返回值

在用于停止循环的 break 表达式后添加你希望返回的值;

1
2
3
4
5
6
7
8
9
10
11
12
13
fn main() {
let mut counter = 0;

let result = loop {
counter += 1;

if counter == 10 {
break counter * 2;
}
};

println!("The result is {result}");
}

循环标签:跳出多层循环

如果存在嵌套循环,breakcontinue 应用于此时最内层的循环。你可以选择在一个循环上指定一个 循环标签loop label),然后将标签与 breakcontinue 一起使用,使这些关键字应用于已标记的循环而不是最内层的循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fn main() {
let mut count = 0;
'counting_up: loop {
println!("count = {count}");
let mut remaining = 10;

loop {
println!("remaining = {remaining}");
if remaining == 9 {
break;
}
if count == 2 {
break 'counting_up;
}
remaining -= 1;
}

count += 1;
}
println!("End count = {count}");
}

即使有标签也可以在标签后面添加返回值。

如:break ‘counting_up 123;

while

loop差不多。

for 遍历

1
2
3
4
5
6
7
fn main() {
let a = [10, 20, 30, 40, 50];

for element in a {
println!("the value is: {element}");
}
}

或者使用

1
2
3
4
5
6
7
for i in 0..4 { 
// ...
}

for i in (0..4).rev() { // 从 3 到 0
// ...
}

所有权

所有权ownership)是 Rust 用于如何管理内存的一组规则。

Rust 则选择了第三种方式:通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查。如果违反了任何这些规则,程序都不能编译。在运行时,所有权系统的任何功能都不会减慢程序的运行。

栈(Stack)与堆(Heap)

跟踪哪部分代码正在使用堆上的哪些数据,最大限度的减少堆上的重复数据的数量,以及清理堆上不再使用的数据确保不会耗尽空间,这些问题正是所有权系统要处理的。一旦理解了所有权,你就不需要经常考虑栈和堆了,不过明白了所有权的主要目的就是管理堆数据,能够帮助解释为什么所有权要以这种方式工作。

所有权规则

  1. Rust 中的每一个值都有一个 所有者owner)。
  2. 值在任一时刻有且只有一个所有者。
  3. 当所有者离开作用域,这个值将被丢弃。

变量作用域

1
2
3
4
5
{                      // s 在这里无效,它尚未声明
let s = "hello"; // 从此处起,s 是有效的

// 使用 s
} // 此作用域已结束,s 不再有效

String 类型

String类型管理被分配到堆上的数据,所以能够存储在编译时未知大小的文本。可以使用 from 函数基于字符串字面值来创建 String

1
2
3
let mut s = String::from("hello");
s.push_str(", world!"); // push_str() 在字符串后追加字面值
println!("{s}"); // 将打印 `hello, world!`

String 可变而字面值却不行

内存与分配

Rust 采取了一个不同的策略:内存在拥有它的变量离开作用域后就被自动释放。

当变量离开作用域,Rust 为我们调用一个特殊的函数。这个函数叫做 drop可以放置释放内存的代码。Rust 在结尾的 } 处自动调用 drop。类似于RAII

移动的变量与数据交互

在 Rust 中,多个变量可以采取不同的方式与同一数据进行交互。

1
2
3
4
let x = 5;
let y = x;
let s1 = String::from("hello");
let s2 = s1;

image-20250518203127261

当我们将 s1 赋值给 s2String 的数据被复制了,这意味着我们从栈上拷贝了它的指针、长度和容量。我们并没有复制指针指向的堆上数据。

这就有了一个问题:当 s2s1 离开作用域,它们都会尝试释放相同的内存。这是一个叫做 二次释放double free)的错误。

为了确保内存安全,在 let s2 = s1; 之后,Rust 认为 s1 不再有效,因此 Rust 不需要在 s1 离开作用域后清理任何东西。

1
2
3
let s1 = String::from("hello");
let s2 = s1;
println!("{s1} {s2}"); // 报错,s1被move到s2,s1失效。

这里还隐含了一个设计选择:Rust 永远也不会自动创建数据的 “深拷贝”。因此,任何自动的复制都可以被认为是对运行时性能影响较小的。

作用域与赋值

作用域、所有权和通过 drop 函数释放内存之间的关系反过来也同样成立。

当你给一个已有的变量赋一个全新的值时,Rust 将会立即调用 drop 并释放原始值的内存。

1
2
3
let mut s = String::from("hello");
s = String::from("ahoy");
println!("{s}, world!");

使用克隆的变量与数据交互

如果我们 确实 需要深度复制 String 中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone 的常用方法。

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

只在数据上的数据:拷贝

1
2
3
4
let x = 5;
let y = x;

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

原因是像整型这样的在编译时已知大小的类型被整个存储在栈上,所以拷贝其实际的值是快速的。这意味着没有理由在创建变量 y 后使 x 无效。换句话说,这里没有深浅拷贝的区别,所以这里调用 clone 并不会与通常的浅拷贝有什么不同,我们可以不用管它。

Rust 有一个叫做 Copy trait 的特殊注解,可以用在类似整型这样的存储在栈上的类型。如果一个类型实现了 Copy trait,那么一个旧的变量在将其赋值给其他变量后仍然有效。

Rust 不允许自身或其任何部分实现了 Drop trait 的类型使用 Copy trait。如果我们对其值离开作用域时需要特殊处理的类型使用 Copy 注解,将会出现一个编译时错误。要学习如何为你的类型添加 Copy 注解以实现该 trait,请阅读附录 C 中的 “可派生的 trait”

作为一个通用的规则,任何一组简单标量值的组合都可以实现 Copy,任何不需要分配内存或某种形式资源的类型都可以实现 Copy 。如下是一些 Copy 的类型:

  • 所有整数类型,比如 u32
  • 布尔类型,bool,它的值是 truefalse
  • 所有浮点数类型,比如 f64
  • 字符类型,char
  • 元组,当且仅当其包含的类型也都实现 Copy 的时候。比如,(i32, i32) 实现了 Copy,但 (i32, String) 就没有。

所有权和函数

将值传递给函数与给变量赋值的原理相似。向函数传递值可能会移动或者复制,就像赋值语句一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fn main() {
let s = String::from("hello"); // s 进入作用域

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

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

makes_copy(x); // x 应该移动函数里,
// 但 i32 是 Copy 的,
println!("{}", x); // 所以在后面可继续使用 x

} // 这里,x 先移出了作用域,然后是 s。但因为 s 的值已被移走,
// 没有特殊之处

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

fn makes_copy(some_integer: i32) { // some_integer 进入作用域
println!("{some_integer}");
} // 这里,some_integer 移出作用域。没有特殊之处

返回值与作用域

返回值也可以转移所有权。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
fn main() {
let s1 = gives_ownership(); // gives_ownership moves its return
// value into s1

let s2 = String::from("hello"); // s2 comes into scope

let s3 = takes_and_gives_back(s2); // s2 is moved into
// takes_and_gives_back, which also
// moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
// happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String { // gives_ownership will move its
// return value into the function
// that calls it

let some_string = String::from("yours"); // some_string comes into scope

some_string // some_string is returned and
// moves out to the calling
// function
}

// 该函数将传入字符串并返回该值
fn takes_and_gives_back(a_string: String) -> String {
// a_string comes into
// scope

a_string // 返回 a_string 并移出给调用的函数
}

变量的所有权总是遵循相同的模式:将值赋给另一个变量时它会移动。当持有堆中数据值的变量离开作用域时,其值将通过 drop 被清理掉,除非数据被移动为另一个变量所有。

为了不转移所有权就使用变量,Rust 对此提供了一个不用获取所有权就可以使用值的功能,叫做 引用references)。

引用与借用

引用reference)像一个指针,因为它是一个地址,我们可以由此访问储存于该地址的属于其他变量的数据。与指针不同,引用在其生命周期内保证指向某个特定类型的有效值。

引用:&

1
2
3
4
fn calculate_length(s: &String) -> usize { // s 是 String 的引用
s.len()
} // 这里,s 离开了作用域。但因为它并不拥有引用值的所有权,
// 所以什么也不会发生

创建一个引用的行为称为 借用borrowing)。

借用结束之后,必须还回去。因为我们并不拥有它的所有权。

同时,借用不允许修改。

可变引用

使用 mut 即可修改借用值,即可变引用。

1
2
3
4
5
6
7
8
9
fn main() {
let mut s = String::from("hello");

change(&mut s);
}

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

可变引用有一个很大的限制:如果你有一个对该变量的可变引用,你就不能再创建对该变量的引用。这些尝试创建两个 s 的可变引用的代码会失败。(kora,这不还是独占嘛)

防止同一时间对同一数据存在多个可变引用。

好处是 Rust 可以在编译时就避免数据竞争。数据竞争data race)类似于竞态条件,它可由这三个行为造成:

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

注意一个引用的作用域从声明的地方开始一直持续到最后一次使用为止,而不是持续到作用域结束。

1
2
3
4
5
6
7
8
9
let mut s = String::from("hello");

let r1 = &s; // 没问题
let r2 = &s; // 没问题
println!("{r1} and {r2}");
// 此位置之后 r1 和 r2 不再使用

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

悬垂引用(Dangling References)

在具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个悬垂指针dangling pointer)—— 指向可能已被分配给其他用途的内存位置的指针。

相比之下,在 Rust 中编译器确保引用永远也不会变成悬垂引用:当你拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。

1
2
3
4
5
6
7
8
9
fn main() {
let reference_to_nothing = dangle();
}

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

&s
} // 报错,显然,s 的生命周期结束了。

正确的方法:

1
2
3
4
5
fn no_dangle() -> String {
let s = String::from("hello");

s
}

引用的规则

让我们概括一下之前对引用的讨论:

  • 在任意给定时间,要么只能有一个可变引用,要么只能有多个不可变引用。
  • 引用必须总是有效的。

Slice 类型

slice 是一种引用,不拥有所有权。

考虑题目:写一个函数,该函数接收一个用空格分隔单词的字符串,并返回在该字符串中找到的第一个单词。如果函数在该字符串中并未找到空格,则整个字符串就是一个单词,所以应该返回整个字符串。

1
2
3
4
5
6
7
8
9
10
11
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();

for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}

s.len()
}

这是一个返回单词结尾的索引的函数。

但这不太方便。于是有了切片:

[starting_index..ending_index]指定范围,相当于 [first, last]

1
2
3
4
let s = String::from("hello world");

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

对于 Rust 的 .. range 语法,如果想要从索引 0 开始,可以不写两个点号之前的值。

如果 slice 包含最后一个,也可以舍弃尾部的数字。

1
2
3
4
5
6
7
8
9
let s = String::from("hello");

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

let len = s.len();

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

注意:字符串 slice range 的索引必须位于有效的 UTF-8 字符边界内,如果尝试从一个多字节字符的中间位置创建字符串 slice,则程序将会因错误而退出。

String切片的类型为:&str

[Type;Length]的切片类型为:&[Type]

在这种情况下,为了保证引用有效:

1
2
3
4
5
6
7
8
9
fn main() {
let mut s = String::from("hello world");

let word = first_word(&s);

s.clear(); // 错误!

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

当拥有某值的不可变引用时,就不能再获取一个可变引用。

因为 clear(&mut self) 需要清空 String,它尝试获取一个可变引用。

在调用 clear 之后的 println! 使用了 word 中的引用,所以这个不可变的引用在此时必须仍然有效。

Rust 不允许 clear 中的可变引用和 word 中的不可变引用同时存在,因此编译失败。

Rust 不仅使得我们的 API 简单易用,也在编译时就消除了一整类的错误!

字符串字面值就是 slice

这也是为什么字面量不可变的原因,因为她是不可变引用。

字符串 slice 作为参数

比起:fn first_word(s: &String) -> &str

用:fn first_word(s: &str) -> &str 更好

如果有一个字符串 slice,可以直接传递它。如果有一个 String,则可以传递整个 String 的 slice 或对 String 的引用。

其他类型的 slice

字符串 slice,正如你想象的那样,是针对字符串的。不过也有更通用的 slice 类型。考虑一下这个数组:

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

就跟我们想要获取字符串的一部分那样,我们也会想要引用数组的一部分。我们可以这样做:

1
2
3
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
assert_eq!(slice, &[2, 3]);

这个 slice 的类型是 &[i32]。它跟字符串 slice 的工作方式一样,通过存储第一个集合元素的引用和一个集合总长度。

结构体

结构体与声明使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}

fn main() {
let mut user1 = User {
active: true,
username: String::from("someusername123"),
email: String::from("someone@example.com"),
sign_in_count: 1,
};
user1.email = String::from("anotheremail@example.com");
}

注意整个实例必须是可变的;Rust 并不允许只将某个字段标记为可变。

可以在函数体的最后一个表达式中构造一个结构体的新实例,来隐式地返回这个实例。

字段初始化简化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fn build_user(email: String, username: String) -> User {
User {
active: true,
username: username,
email: email,
sign_in_count: 1,
}
}

->
// 只有当当前作用域存在于 field 名字相同的变量才可以这样做,而且要求类型一致。
fn build_user(email: String, username: String) -> User {
User {
active: true,
username,
email,
sign_in_count: 1,
}
}

从其他实例创建实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fn main() {
let user1 = User {
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};

let user2 = User {
active: user1.active,
username: user1.username,
email: String::from("another@example.com"),
sign_in_count: user1.sign_in_count,
};

let user2 = User {
email: String::from("another@example.com"),
..user1 // 用..来省略余下,要求必须放在最后。
};
}

但是结构更新语法就像带有 = 的赋值,因为它移动了数据,至于克隆,则也是一样的。

使用没有命名字段的元组结构体来创建不同的类型

1
2
3
4
5
6
7
8
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

fn main() {
let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);
let Color(x,y,z) = black; // 于元组不同,必须指明类型,才能解构。
}

没有任何字段的类单元结构体

称为 类单元结构体unit-like structs)因为它们类似于 ()

常常在你想要在某个类型上实现 trait 但不需要在类型中存储数据的时候发挥作用。

如:

1
2
3
4
5
struct AlwaysEqual;

fn main() {
let subject = AlwaysEqual;
}

设想我们稍后将为这个类型实现某种行为,使得每个 AlwaysEqual 的实例始终等于任何其它类型的实例,也许是为了获得一个已知的结果以便进行测试。

结构体数据的所有权

可以使结构体存储被其他对象拥有的数据的引用,不过这么做的话需要用上 生命周期lifetimes),这是一个第十章会讨论的 Rust 特性。生命周期确保结构体引用的数据有效性跟结构体本身保持一致。

简单Debug

结构体派生trait

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Rectangle {
width: u32,
height: u32,
}

fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};

println!("rect1 is {}", rect1);
}

报错:

1
2
3
error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`
= help: the trait `std::fmt::Display` is not implemented for `Rectangle`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead

{} 默认告诉 println! 使用被称为 Display 的格式,没有实现Disyplay的结构体,将不予输出。

但即使输出方式为:

1
println!("rect1 is {:?}", rect1);

仍然报错:

1
2
3
error[E0277]: `Rectangle` doesn't implement `Debug`
= help: the trait `Debug` is not implemented for `Rectangle`
= note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`

正确方式(使用Debug trait):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}

fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};

println!("rect1 is {rect1:?}");
// 或者
dbg!(&rect1);
}

或者使用dbg!宏。

区别在于dbg!打印到stderrprintln!打印到stderr

方法

方法(method)与函数类似:它们使用 fn 关键字和名称声明,可以拥有参数和返回值,同时包含在某处调用该方法时会执行的代码。不过方法与函数是不同的,因为它们在结构体的上下文中被定义(或者是枚举或 trait 对象的上下文),并且它们第一个参数总是 self,它代表调用该方法的结构体实例。

如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#[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()
);
}

为了使函数定义于 Rectangle 的上下文中,我们开始了一个 impl 块(implimplementation 的缩写),这个 impl 块中的所有内容都将与 Rectangle 类型相关联。

方法的第一个参数必须有一个名为 selfSelf 类型的参数,否则不会把方法绑定到类型上,仅仅是作用域的一个函数而已。

所以 Rust 让你在第一个参数位置上可以只用 self 这个名字来简化。

注意,我们仍然需要在 self 前面使用 & 来表示这个方法借用了 Self 实例,就像我们在 rectangle: &Rectangle 中做的那样。

同时Self仅仅表示类型而已,如果把函数绑定成方法,唯一要做的只是第一个参数名字叫self而已,同时类型一致。在C++中类似于在每个类中 using Self = type;

方法可以选择获得 self 的所有权,或者像我们这里一样不可变地借用 self,或者可变地借用 self,就跟其他参数一样。

方法也是函数,只是绑定到了类型上同时第一个参数为self而已,可以通过:

1
Rectangle::area(&rect1);

来调用对应方法/函数。

Rust 并没有一个与 -> 等效的运算符;相反,Rust 有一个叫 自动引用和解引用automatic referencing and dereferencing)的功能。方法调用是 Rust 中少数几个拥有这种行为的地方。

关联函数

所有在 impl 块中定义的函数被称为 关联函数associated functions),因为它们与 impl 后面命名的类型相关。我们可以定义不以 self 为第一参数的关联函数(因此不是方法),因为它们并不作用于一个结构体的实例。

不是方法的关联函数经常被用作返回一个结构体新实例的构造函数。这些函数的名称通常为 new ,但 new 并不是一个关键字。这样可以更轻松的创建对象。

多个 impl 块

每个结构体都允许有多个 impl块。

枚举与模式匹配

定义

如:

1
2
3
4
enum IpAddrKind {
V4,
V6,
}

枚举值

1
2
let four = IpAddrKind::V4;
let six: IpAddrKind = IpAddrKind::V6;

枚举变成了某个类型,而不是一个值。

可以使用一种更简洁的方式来表达相同的概念,仅仅使用枚举并将数据直接放进每一个枚举成员而不是将枚举作为结构体的一部分。IpAddr 枚举的新定义表明了 V4V6 成员都关联了 String 值:

1
2
3
4
5
6
enum IpAddr {
V4(String),
V6(String),
}
let home = IpAddr::V4(String::from("127.0.0.1"));
let loopback = IpAddr::V6(String::from("::1"));

用枚举替代结构体还有另一个优势:每个成员可以处理不同类型和数量的数据。

1
2
3
4
5
6
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));

更像是存储了类型信息的union共用体。

一个枚举的例子:

1
2
3
4
5
6
7
8
9
10
11
12
struct Ipv4Addr {
// --snip--
}

struct Ipv6Addr {
// --snip--
}

enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}

使用枚举类型,可以定义一个能够处理这些不同类型的结构体的函数,因为枚举是单独一个类型。

结构体和枚举还有另一个相似点:就像可以使用 impl 来为结构体定义方法那样,也可以在枚举上定义方法。

Option 枚举以及优势

Rust 没有空值(null)

我觉得空与不空是把,数据的状态和数据的值耦合在了一起。

但这种概念本身是没有错误的,错误的是具体实现。

Rust 用 Option 来解决这个问题。

1
2
3
4
enum Option<T> {
None,
Some(T),
}

因为 Option<T>T(这里 T 可以是任何类型)是不同的类型,编译器不允许像一个肯定有效的值那样使用 Option<T>。这是Option优于null的地方。

1
2
3
let x: i8 = 5;
let y: Option<i8> = Some(5);
let sum = x + y; // 报错。

换句话说,在对 Option<T> 进行运算之前必须将其转换为 T

通常这能帮助我们捕获到空值最常见的问题之一:假设某值不为空但实际上为空的情况。

也就是说,Option<T>要求程序员必须考虑值是否为空。

match 控制流

Rust 有一个叫做 match 的极为强大的控制流运算符,它允许我们将一个值与一系列的模式相比较,并根据相匹配的模式执行相应代码。

模式可由字面值、变量、通配符和许多其他内容构成;

值通过 match 的每一个模式,并且在遇到第一个 “符合” 的模式时,值会进入相关联的代码块并在执行中被使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
enum State {
A,
B,
}

enum Coin {
Penny,
Nickel,
Dime,
Quarter(State),
}

fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
}
}

这看起来非常像 if 所使用的条件表达式,不过这里有一个非常大的区别:对于 if,表达式必须返回一个布尔值,而这里它可以是任何类型的。

match 的分支:

一个分支有两个部分:一个模式和一些代码。

第一个分支的模式是值 Coin::Penny 而之后的 => 运算符将模式和将要运行的代码分开。这里的代码就仅仅是值 1。每一个分支之间使用逗号分隔。

match 表达式执行时,它将结果值按顺序与每一个分支的模式相比较。如果模式匹配了这个值,这个模式相关联的代码将被执行。

当然每个分支也可以是大括号。

1
2
3
4
5
6
7
8
9
10
11
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => {
println!("Lucky penny!");
1
}
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}

也可以绑定对应的值。

1
2
3
4
5
6
7
8
9
10
11
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
}
}
}

匹配 Option

1
2
3
4
5
6
7
8
9
10
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);

match 匹配是穷尽的

1
2
3
4
5
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
Some(i) => Some(i + 1),
} // 不可编译
}

通用匹配和占位符

1
2
3
4
5
6
7
8
9
10
let dice_roll = 9;
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
other => move_player(other), // 这里获取了剩下的所有情况,同时可以使用other来处理
}

fn add_fancy_hat() {}
fn remove_fancy_hat() {}
fn move_player(num_spaces: u8) {}

当我们不想使用通配模式获取的值时,请使用 _ ,这是一个特殊的模式,可以匹配任意值而不绑定到该值。

1
2
3
4
5
6
7
8
9
10
let dice_roll = 9;
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
_ => reroll(), // 对于值不关心。
}

fn add_fancy_hat() {}
fn remove_fancy_hat() {}
fn reroll() {}

if-let 控制流

if let 语法让我们以一种不那么冗长的方式结合 iflet,来处理只匹配一个模式的值而忽略其他模式的情况。

1
2
3
4
let config_max = Some(3u8);
if let Some(max) = config_max {
println!("The maximum is configured to be {max}");
}

这样可以不使用match而仅仅匹配一个情况,剩下的情况并不关心。

可以认为 if letmatch 的一个语法糖,它当值匹配某一模式时执行代码而忽略所有其他值。

Rust std 集合

Vector<T>

新建

1
let v: Vec<i32> = Vec::new();

增加了一个类型注解。因为没有向这个 vector 中插入任何值,Rust 并不知道我们想要储存什么类型的元素。

或者:

使用vec!宏,可以推断给定值创建一个vector

1
let v = vec![1, 2, 3];

更新

1
let mut v = Vec::new(); // 错误Rust推断不出类型

正确:

1
2
let mut v = Vec::new(); 
v.push(5); // 通过这句话,Rust推断出类型。

读取

1
2
3
4
5
6
7
8
9
10
11
let v = vec![1, 2, 3, 4, 5];

let third: &i32 = &v[2];
println!("The third element is {third}");

let third: Option<&i32> = v.get(2);
match third {
Some(third) => println!("The third element is {third}"),
None => println!("There is no third element."),
}

有两种方法引用 vector 中储存的值:通过索引或使用 get 方法。

Rust 提供了两种引用元素的方法的原因是当尝试使用现有元素范围之外的索引值时可以选择让程序如何运行。

1
2
3
let v = vec![1, 2, 3, 4, 5];
let does_not_exist = &v[100]; // panic
let does_not_exist = v.get(100); // None

一旦程序获取了一个有效的引用,借用检查器将会执行所有权和借用规则来确保 vector 内容的这个引用和任何其他引用保持有效。

回忆一下不能在相同作用域中同时存在可变和不可变引用的规则。

1
2
3
4
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0]; // 获取一个借用
v.push(6); // error. 获取一个引用。
println!("The first element is: {first}");

遍历

1
2
3
4
let v = vec![100, 32, 57];
for i in &v {
println!("{i}");
}

也可以遍历可变 vector 的每一个元素的可变引用以便能改变它们。

1
2
3
4
let mut v = vec![100, 32, 57];
for i in &mut v {
*i += 50;
}

因为借用检查器的规则,无论可变还是不可变地遍历一个 vector 都是安全的。

枚举存储多种类型

vector 只能储存相同类型的值。这是很不方便的;绝对会有需要储存一系列不同类型的值的用例。

如:

1
2
3
4
5
6
7
8
9
10
11
12
    enum SpreadsheetCell {
Int(i32),
Float(f64),
Text(String),
}

let row = vec![
SpreadsheetCell::Int(3),
SpreadsheetCell::Text(String::from("blue")),
SpreadsheetCell::Float(10.12),
];
// 之后再遍历的时候可以使用 match 来枚举。

丢弃 vector 时也会丢弃其所有元素

String 与 UTF-8

Rust 的核心语言中只有一种字符串类型:字符串 slice str,它通常以被借用的形式出现,&str

String

字符串(String)类型由 Rust 标准库提供,而不是编入核心语言,它是一种可增长、可变、可拥有、UTF-8 编码的字符串类型。

新建字符串

1
2
3
4
5
6
7
   let mut s = String::new();
let data = "initial contents";
let s = data.to_string();
// 该方法也可直接用于字符串字面值:
let s = "initial contents".to_string();
// 字面量创建 String。
let s = String::from("initial contents");

字符串是 UTF-8 编码的。

更新

String 的大小可以增加,其内容也可以改变,就像可以放入更多数据来改变 Vec 的内容一样。

另外,可以方便的使用 + 运算符或 format! 宏来拼接 String 值。

1
2
3
4
5
6
7
8
9
10
11
// push_str 字面量
let mut s = String::from("foo");
s.push_str("bar");
// push 字符
s.push('c');
// push_str String
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 is {s2}");

+ 运算符和 format! 宏

1
2
3
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // 注意 s1 被移动了,不能继续使用

因为add的签名: add(self, s: &str) -> String

所以self被移动了,而且&String可以被强制转化为&str

强转coercedDeref 强制转换deref coercion

1
2
3
4
5
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = s1 + "-" + &s2 + "-" + &s3;

不过这样就显得非常麻烦了,更适合:

s = s + &other_string这样效率更好,同时语义易于理解。

或者使用println!

1
2
3
4
5
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = format!("{s1}-{s2}-{s3}");

这个地方全copys1,s2,s3是没有被移动的。

索引字符串

1
2
let s1 = String::from("hi");
let h = s1[0]; // 错误,Rust 的字符串不支持索引。

String 内部表示

String 是一个 Vec<u8> 的封装。

1
let hello = String::from("Здравствуйте");

当问及这个字符是多长的时候有人可能会说是 12。然而,Rust 的回答是 24。

这是使用 UTF-8 编码 “Здравствуйте” 所需要的字节数,这是因为每个 Unicode 标量值需要两个字节存储。

因此一个字符串字节值的索引并不总是对应一个有效的 Unicode 标量值。

如下无效的 Rust 代码:

1
2
let hello = "Здравствуйте";
let answer = &hello[0]; // 拒绝索引。

字节、标量值和字形簇

从 Rust 的角度来讲,事实上有三种相关方式可以理解字符串:字节、标量值和字形簇(最接近人们眼中 字母 的概念)。

最后一个 Rust 不允许使用索引获取 String 字符的原因是,索引操作预期总是需要常数时间(O(1))。但是对于 String 不可能保证这样的性能,因为 Rust 必须从开头到索引位置遍历来确定有多少有效的字符。

字符串 slice

相比使用 [] 和单个值的索引,可以使用 [] 和一个 range 来创建含特定字节的字符串 slice:

1
2
3
let hello = "Здравствуйте";

let s = &hello[0..4];

但是如果:&hello[0..1],Rust会在运行时panic。

和无效索引一样。

遍历字符串的方法

操作字符串每一部分的最好的方法是明确表示需要字符还是字节。

对于单独的 Unicode 标量值使用 chars 方法。

1
2
3
4
5
6
for c in "Зд".chars() {
println!("{c}");
}
// 输出:
// З
// д

对于获取原始字节,使用bytes方法

1
2
3
4
5
6
7
8
for b in "Зд".bytes() {
println!("{b}");
}
// 输出:
// 208
// 151
// 208
// 180

有效的 Unicode 标量值可能会由不止一个字节组成。

不同的语言选择了不同的向程序员展示其复杂性的方式。Rust 选择了以准确的方式处理 String 数据作为所有 Rust 程序的默认行为,这意味着程序员们必须更多的思考如何预先处理 UTF-8 数据。

Hash Map

HashMap<K, V> 类型储存了一个键类型 K 对应一个值类型 V 的映射。

它通过一个 哈希函数hashing function)来实现映射,决定如何将键和值放入内存中。

新建

可以使用 new 创建一个空的 HashMap,并使用 insert 增加元素。

1
2
3
4
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

访问

通过 get 方法并提供对应的键来从哈希 map 中获取值

1
2
3
4
5
6
7
8
9
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).copied().unwrap_or(0);

get 方法返回 Option<&V>,如果某个键在哈希 map 中没有对应的值,get 会返回 None。程序中通过调用 copied 方法来获取一个 Option<i32> 而不是 Option<&i32>,接着调用 unwrap_orscores 中没有该键所对应的项时将其设置为零。

遍历

1
2
3
4
5
6
7
8
9
10
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 与所有权

对于像 i32 这样的实现了 Copy trait 的类型,其值可以拷贝进哈希 map。

对于像 String 这样拥有所有权的值,其值将被移动而哈希 map 会成为这些值的所有者。

1
2
3
4
5
6
7
8
9
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 中的数据时,必须决定如何处理一个键已经有值了的情况。

可以选择完全无视旧值并用新值代替旧值。

可以选择保留旧值而忽略新值,并只在键 没有 对应值时增加新值。

可以结合新旧两值。

覆盖:insert

1
2
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Blue"), 25); // 结果为 25

用 insert 插入会覆盖。

忽略新值:entry().or_insert()

1
2
scores.entry(String::from("Yellow")).or_insert(50);
scores.entry(String::from("Blue")).or_insert(50);

Entryor_insert 方法在键对应的值存在时就返回这个值的可变引用,如果不存在则将参数作为新值插入并返回新值的可变引用。

这比编写自己的逻辑要简明的多,另外也与借用检查器结合得更好。

根据旧值更新:

1
2
3
4
5
6
7
8
9
10
11
// 下面代码用来统计 text 中的单词 :
let text = "hello world wonderful world";
let mut map = HashMap::new();

// split_whitespace() 表示按照空格分隔 text 字符串并获取 slice。
for word in text.split_whitespace() {
let count = map.entry(word).or_insert(0);
*count += 1;
}

println!("{map:?}");

哈希函数

HashMap 默认使用一种叫做 SipHash 的哈希函数,它可以抵御涉及哈希表的拒绝服务(Denial of Service, DoS)攻击。然而这并不是可用的最快的算法,不过为了更高的安全性值得付出一些性能的代价。

基本概念

  • Packages):Cargo 的一个功能,它允许你构建、测试和分享 crate。
  • Crates :一个模块的树形结构,它形成了库或可执行文件项目。
  • 模块Modules)和 use:允许你控制作用域和路径的私有性。
  • 路径path):一个为例如结构体、函数或模块等项命名的方式。

crate 是 Rust 在编译时最小的代码单位。即使你用 rustc 而不是 cargo 来编译一个单独的源代码文件,编译器还是会将那个文件视为一个 crate。

crate 可以包含模块,模块可以定义在其他文件,然后和 crate 一起编译。

crate 有两种形式:二进制 crate 和库 crate。

二进制 crateBinary crates)可以被编译为可执行程序,比如命令行程序或者服务端。它们必须有一个名为 main 函数来定义当程序被执行的时候所需要做的事情。

库 crateLibrary crates)并没有 main 函数,它们也不会编译为可执行程序。相反它们定义了可供多个项目复用的功能模块。与其他语言的库 library 概念一致。

crate root 是一个源文件,Rust 编译器以它为起始点,并构成 crate 的根模块。

package)是提供一系列功能的一个或者多个 crate的捆绑。一个包会包含一个 Cargo.toml 文件,阐述如何去构建这些 crate。

Cargo 实际上就是一个包,它包含了用于构建你代码的命令行工具的二进制 crate。

其他项目也依赖 Cargo 库来实现与 Cargo 命令行程序一样的逻辑。

包中可以包含至多一个库 crate(library crate)。

包中可以包含任意多个二进制 crate(binary crate),但是必须至少包含一个 crate(无论是库的还是二进制的)。

定义模块

模块、路径、use关键词和pub关键词如何在编译器中工作,以及大部分开发者如何组织他们的代码。

  • 从 crate 根节点开始: 当编译一个 crate, 编译器首先在 crate 根文件(通常,对于一个库 crate 而言是 src/lib.rs,对于一个二进制 crate 而言是 src/main.rs)中寻找需要被编译的代码。
  • 声明模块: 在 crate 根文件中,你可以声明一个新模块;比如,用 mod garden; 声明了一个叫做 garden 的模块。编译器会在下列路径中寻找模块代码:
    • 内联,用大括号替换 mod garden 后跟的分号
    • 在文件 src/garden.rs
    • 在文件 src/garden/mod.rs
  • 声明子模块: 在除了 crate 根节点以外的任何文件中,你可以定义子模块。比如,你可能在 src/garden.rs 中声明 mod vegetables;。编译器会在以父模块命名的目录中寻找子模块代码:
    • 内联,直接在 mod vegetables 后方不是一个分号而是一个大括号
    • 在文件 src/garden/vegetables.rs
    • 在文件 src/garden/vegetables/mod.rs
  • 模块中的代码路径: 一旦一个模块是你 crate 的一部分,你可以在隐私规则允许的前提下,从同一个 crate 内的任意地方,通过代码路径引用该模块的代码。举例而言,一个 garden vegetables 模块下的 Asparagus 类型可以通过 crate::garden::vegetables::Asparagus 访问。
  • 私有 vs 公用: 一个模块里的代码默认对其父模块私有。为了使一个模块公用,应当在声明时使用 pub mod 替代 mod。为了使一个公用模块内部的成员公用,应当在声明前使用pub
  • use 关键字: 在一个作用域内,use关键字创建了一个项的快捷方式,用来减少长路径的重复。在任何可以引用 crate::garden::vegetables::Asparagus 的作用域,你可以通过 use crate::garden::vegetables::Asparagus; 创建一个快捷方式,然后你就可以在作用域中只写 Asparagus 来使用该类型。
1
2
3
4
5
6
7
8
backyard
├── Cargo.lock
├── Cargo.toml
└── src
├── garden
│   └── vegetables.rs
├── garden.rs
└── main.rs

模块中对代码分组

模块 让我们可以将一个 crate 中的代码进行分组,以提高可读性与重用性。

因为一个模块中的代码默认是私有的,所以还可以利用模块控制项的 私有性

私有项是不可为外部使用的内在详细实现。我们也可以将模块和它其中的项标记为公开的,这样,外部代码就可以使用并依赖于它们。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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() {}
}
}

我们定义一个模块,是以 mod 关键字为起始,然后指定模块的名字(本例中叫做 front_of_house),并且用花括号包围模块的主体。

在模块内,我们还可以定义其他的模块,就像本例中的 hostingserving 模块。

模块还可以保存一些定义的其他项,比如结构体、枚举、常量、特性、或者函数。

src/main.rssrc/lib.rs 叫做 crate 根。之所以这样叫它们是因为这两个文件的内容都分别在 crate 模块结构的根组成了一个名为 crate 的模块,该结构被称为 模块树module tree)。

如:

1
2
3
4
5
6
7
8
9
crate
└── front_of_house
├── hosting
│ ├── add_to_waitlist
│ └── seat_at_table
└── serving
├── take_order
├── serve_order
└── take_payment

引用模块的路径

为了调用一个函数,我们需要知道它的路径。

路径有两种形式:

  • 绝对路径absolute path)是以 crate 根(root)开头的全路径;对于外部 crate 的代码,是以 crate 名开头的绝对路径,对于当前 crate 的代码,则以字面值 crate 开头。
  • 相对路径relative path)从当前模块开始,以 selfsuper 或定义在当前模块中的标识符开头。
1
2
3
4
5
6
7
8
9
10
11
12
13
mod front_of_house {
mod hosting {
fn add_to_waitlist() {}
}
}

pub fn eat_at_restaurant() {
// 绝对路径
crate::front_of_house::hosting::add_to_waitlist();

// 相对路径
front_of_house::hosting::add_to_waitlist();
} // 不可编译!!!!因为hosting模块不是 pub 的。

父模块中的项不能使用子模块中的私有项,但是子模块中的项可以使用它们父模块中的项。

这是因为子模块封装并隐藏了它们的实现详情,但是子模块可以看到它们定义的上下文。

因为eat_at_restaurantfront_of_house定义在同一个模块下,所以可以访问front_of_house

1
2
3
4
5
6
7
8
9
10
11
12
13
mod front_of_house {
pub mod hosting {
fn add_to_waitlist() {}
}
}

pub fn eat_at_restaurant() {
// 绝对路径
crate::front_of_house::hosting::add_to_waitlist();

// 相对路径
front_of_house::hosting::add_to_waitlist();
} // 不可编译!!!add_to_waitlist()不是pub的

mod hosting 前添加了 pub 关键字,使其变成公有的。伴随着这种变化,如果我们可以访问 front_of_house,那我们也可以访问 hosting

但是 hosting内容contents)仍然是私有的;这表明使模块公有并不使其内容也是公有的。模块上的 pub 关键字只允许其父模块引用它,而不允许访问内部代码。

因为模块是一个容器,只是将模块变为公有能做的其实并不太多;同时需要更深入地选择将一个或多个项变为公有。

私有性规则不但应用于模块,还应用于结构体、枚举、函数和方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
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();
} // 正确!!。
1
2
3
4
二进制和库 crate 包的最佳实践:
我们提到过包(package)可以同时包含一个 src/main.rs 二进制 crate 根和一个 src/lib.rs 库 crate 根,并且这两个 crate 默认以包名来命名。通常,这种包含二进制 crate 和库 crate 的模式的包,在二进制 crate 中只保留足以生成一个可执行文件的代码,并由可执行文件调用库 crate 的代码。又因为库 crate 可以共享,这使得其它项目从包提供的大部分功能中受益。

模块树应该定义在 src/lib.rs 中。这样通过以包名开头的路径,公有项就可以在二进制 crate 中使用。二进制 crate 就变得同其它在该 crate 之外的、使用库 crate 的用户一样:二者都只能使用公有 API。

super 开始的相对路径

通过在路径的开头使用 super ,从父模块开始构建相对路径,而不是从当前模块或者 crate 根开始。这类似以 .. 语法开始一个文件系统路径。

使用 super 允许我们引用父模块中的已知项,这使得重新组织模块树变得更容易 —— 当模块与父模块关联的很紧密,但某天父模块可能要移动到模块树的其它位置。

1
2
3
4
5
6
7
8
9
10
fn deliver_order() {}

mod back_of_house {
fn fix_incorrect_order() {
cook_order();
super::deliver_order(); // 从上一级开始搜索。
}

fn cook_order() {}
}

创建公用结构体与枚举类型

如果我们在一个结构体定义的前面使用了 pub ,这个结构体会变成公有的,但是这个结构体的字段仍然是私有的。

我们可以根据情况决定每个字段是否公有。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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");
}

相反,如果我们将枚举设为公有,则它的所有成员都将变为公有。

只需要在 enum 关键字前面加上 pub

如果枚举成员不是公有的,那么枚举会显得用处不大;

给枚举的所有成员挨个添加 pub 是很令人恼火的,因此枚举成员默认就是公有的。

use 引入作用域

在作用域中增加 use 和路径类似于在文件系统中创建软连接(符号连接,symbolic link)。

通过在 crate 根增加 use crate::front_of_house::hosting,现在 hosting 在作用域中就是有效的名称了,如同 hosting 模块被定义于 crate 根一样。

通过 use 引入作用域的路径也会检查私有性,同其它路径一样。

注意 use 只能创建 use 所在的特定作用域内的短路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}

use crate::front_of_house::hosting;

mod customer {
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}
} // 不能编译,因为eat_at_restaurant()所在模块customer没有使用use。
// 需要把mod curtomer {} 删除。

## 创建惯用的 use 路径

1
2
3
4
5
6
7
8
9
10
11
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();
}

这样也正确,但是不符合习惯。因为没有清晰的指出函数是从哪里调用来的。

另一方面,使用 use 引入结构体、枚举和其他项时,习惯是指定它们的完整路径。

使用 as 提供新的名字

用 as 重命名防止两个相同名字的类型引入同一个作用域,

1
2
3
4
5
6
7
8
9
10
use std::fmt::Result;
use std::io::Result as IoResult;

fn function1() -> Result {
// --snip--
}

fn function2() -> IoResult<()> {
// --snip--
}

用pub use重导出名称

使用 use 关键字,将某个名称导入当前作用域后,这个名称在此作用域中就可以使用了,但它对此作用域之外还是私有的。

如果想让其他人调用我们的代码时,也能够正常使用这个名称,就好像它本来就在当前作用域一样,那我们可以将 pubuse 合起来使用。

这种技术被称为 “重导出re-exporting)”:我们不仅将一个名称导入了当前作用域,还允许别人把它导入他们自己的作用域。

1
2
3
4
5
6
7
8
9
10
11
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();
}

现在这个 pub use 从根模块重导出了 hosting 模块,外部代码现在可以使用路径 restaurant::hosting::add_to_waitlist

使用外部包

1
2
3
// Cargo.toml
[dependencies]
rand = "0.8.5"

Cargo.toml 中加入 rand 依赖告诉了 Cargo 要从 crates.io 下载 rand 和其依赖,并使其可在项目代码中使用。

比如:

1
2
3
4
5
use rand::Rng;

fn main() {
let secret_number = rand::thread_rng().gen_range(1..=100);
}

嵌套路径消除 use

当需要引入很多定义于相同包或相同模块的项时,为每一项单独列出一行会占用源码很大的空间。

可以使用嵌套路径将相同的项在一行中引入作用域。这么做需要指定路径的相同部分,接着是两个冒号,接着是大括号中的各自不同的路径部分。

1
2
3
4
use std::cmp::Ordering;
use std::io;
// 优化之后:
use std::{cmp::Ordering, io};

比如:

1
2
3
4
5
use std::io;
use std::io::Write;
// =>
use std::io::{self, Write};
// 这一行便将 std::io 和 std::io::Write 同时引入作用域。

用 glob 将所有共有定义引入作用域。

如果希望将一个路径下 所有 公有项引入作用域,可以指定路径后跟 *,glob 运算符:

1
use std::collections::*;

用 glob 运算符时请多加小心!Glob 会使得我们难以推导作用域中有什么名称和它们是在何处定义的。

glob 运算符经常用于测试模块 tests 中,这时会将所有内容引入作用域;

错误处理

Rust 将错误分为两大类:可恢复的recoverable)和 不可恢复的unrecoverable)错误。

对于一个可恢复的错误,比如文件未找到的错误,我们很可能只想向用户报告问题并重试操作。

不可恢复的错误总是 bug 出现的征兆,比如试图访问一个超过数组末端的位置,因此我们要立即停止程序。

Rust 没有异常。相反,它有 Result<T, E> 类型,用于处理可恢复的错误,还有 panic! 宏,在程序遇到不可恢复的错误时停止执行。

panic! 处理不可恢复错误

Rust 有 panic!宏。在实践中有两种方法造成 panic:执行会造成代码 panic 的操作(比如访问超过数组结尾的内容)或者显式调用 panic! 宏。

这两种情况都会使程序 panic。

通常情况下这些 panic 会打印出一个错误信息,展开并清理栈数据,然后退出。

通过一个环境变量,你也可以让 Rust 在 panic 发生时打印调用堆栈(call stack)以便于定位 panic 的原因。

对应 panic 时的栈展开或终止

当出现 panic 时,程序默认会开始 展开unwinding),这意味着 Rust 会回溯栈并清理它遇到的每一个函数的数据,不过这个回溯并清理的过程有很多工作。另一种选择是直接 终止abort),这会不清理数据就退出程序。

那么程序所使用的内存需要由操作系统来清理。如果你需要项目的最终二进制文件越小越好,panic 时通过在 Cargo.toml[profile] 部分增加 panic = 'abort',可以由展开切换为终止。

例如,如果你想要在 release 模式中 panic 时直接终止:

1
2
[profile.release]
panic = 'abort'

使用 panic! 的 backtrace

比如,当索引超出数组范围时。会出现panic。

可以在运行时:RUST_BACKTRACE=1 cargo run来获取panic时的堆栈情况。

为了获取带有这些信息的 backtrace,必须启用 debug 标识。

Rust 的 backtrace 跟其他语言中的一样:阅读 backtrace 的关键是从头开始读直到发现你编写的文件。

## Result 处理可恢复错误

Result 枚举,它定义有如下两个成员,OkErr

1
2
3
4
enum Result<T, E> {
Ok(T),
Err(E),
}

TE 是泛型类型参数;

1
2
3
4
5
use std::fs::File;

fn main() {
let greeting_file_result = File::open("hello.txt");
}

File::open 的返回值是 Result<T, E>

泛型参数 T 会被 File::open 的实现放入成功返回值的类型 std::fs::File,这是一个文件句柄。

错误返回值使用的 E 的类型是 std::io::Error

这些返回类型意味着 File::open 调用可能成功并返回一个可以读写的文件句柄。这个函数调用也可能会失败:例如,也许文件不存在,或者可能没有权限访问这个文件。

File::open 函数需要一个方法在告诉我们成功与否的同时返回文件句柄或者错误信息。这些信息正好是 Result 枚举所代表的。

File::open 成功时,greeting_file_result 变量将会是一个包含文件句柄的 Ok 实例。

当失败时,greeting_file_result 变量将会是一个包含了更多关于发生了何种错误的信息的 Err 实例。

1
2
3
4
5
6
7
8
9
10
use std::fs::File;

fn main() {
let greeting_file_result = File::open("hello.txt");

let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => panic!("Problem opening the file: {error:?}"),
}; // 处理枚举
}

匹配不同的错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use std::fs::File;
use std::io::ErrorKind;

fn main() {
let greeting_file_result = File::open("hello.txt");

let greeting_file = match greeting_file_result {
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:?}"),
},
_ => {
panic!("Problem opening the file: {error:?}");
}
},
};
}

失败时 panic 的简写:unwrap 和 expect

esult<T, E> 类型定义了很多辅助方法来处理各种情况。

其中之一叫做 unwrap,它的实现就类似于 match 语句。

如果 Result 值是成员 Okunwrap 会返回 Ok 中的值。

如果 Result 是成员 Errunwrap 会为我们调用 panic!

例子:

1
2
3
4
5
use std::fs::File;

fn main() {
let greeting_file = File::open("hello.txt").unwrap();
}

还有另一个类似于 unwrap 的方法它还允许我们选择 panic! 的错误信息:expect

使用 expect 而不是 unwrap 并提供一个好的错误信息可以表明你的意图并更易于追踪 panic 的根源。

expect 的语法看起来像这样:

1
2
3
4
5
6
use std::fs::File;

fn main() {
let greeting_file = File::open("hello.txt")
.expect("hello.txt should be included in this project");
}

传播错误

当编写一个其实先会调用一些可能会失败的操作的函数时,除了在这个函数中处理错误外,还可以选择让调用者知道这个错误并决定该如何处理。

这被称为 传播propagating)错误,这样能更好的控制代码调用,因为比起你代码所拥有的上下文,调用者可能拥有更多信息或逻辑来决定应该如何处理错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
let username_file_result = File::open("hello.txt");

let mut username_file = match username_file_result {
Ok(file) => file,
Err(e) => return Err(e),
};

let mut username = String::new();

match username_file.read_to_string(&mut username) {
Ok(_) => Ok(username),
Err(e) => Err(e),
}
}

这种传播错误的模式在 Rust 是如此的常见,以至于 Rust 提供了 ? 问号运算符来使其更易于处理。

传播错误简写:?运算符

1
2
3
4
5
6
7
8
9
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
let mut username_file = File::open("hello.txt")?;
let mut username = String::new();
username_file.read_to_string(&mut username)?;
Ok(username)
}

Result 值之后的 ? 被定义为与示例 9-6 中定义的处理 Result 值的 match 表达式有着完全相同的工作方式。

如果 Result 的值是 Ok,这个表达式将会返回 Ok 中的值而程序将继续执行。

如果值是 ErrErr 将作为整个函数的返回值,就好像使用了 return 关键字一样,这样错误值就被传播给了调用者。

甚至可以在 ? 之后直接使用链式方法调用来进一步缩短代码:

1
2
3
4
5
6
7
8
9
10
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
let mut username = String::new();

File::open("hello.txt")?.read_to_string(&mut username)?;

Ok(username)
}

哪里可以使用 ?运算符

? 运算符只能被用于返回值与 ? 作用的值相兼容的函数。因为 ? 运算符被定义为从函数中提早返回一个值,这与 match 表达式有着完全相同的工作方式。

match 作用于一个 Result 值,提早返回的分支返回了一个 Err(e) 值。函数的返回值必须是 Result 才能与这个 return 相兼容。

1
2
3
4
5
use std::fs::File;

fn main() {
let greeting_file = File::open("hello.txt")?;
} // 错误,只能返回 Result

? 也可用于 Option<T> 值。如同对 Result 使用 ? 一样,只能在返回 Option 的函数中对 Option 使用 ?

Option<T> 上调用 ? 运算符的行为与 Result<T, E> 类似:如果值是 None,此时 None 会从函数中提前返回。

如果值是 SomeSome 中的值作为表达式的返回值同时函数继续。

main 函数也可以返回 Result<(), E>

1
2
3
4
5
6
7
8
use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
let greeting_file = File::open("hello.txt")?;

Ok(())
}

main 函数也可以返回任何实现了 std::process::Termination trait 的类型,它包含了一个返回 ExitCodereport 函数。

要不要panic

示例、代码原型和测试都非常适合 panic

测试失败的时候,直接panic以结束程序。

当我们比编译器知道更多的情况

当你有一些其他的逻辑来确保 Result 会是 Ok 值时,调用 unwrap 或者 expect 也是合适的,虽然编译器无法理解这种逻辑。你仍然需要处理一个 Result 值:即使在你的特定情况下逻辑上是不可能的,你所调用的任何操作仍然有可能失败。

错误处理指导原则

在当有可能会导致有害状态的情况下建议使用 panic!

然而当错误预期会出现时,返回 Result 仍要比调用 panic! 更为合适。

创建自定义类型进行有效性验证

在每个函数中正确性的检查将是非常冗余的(并可能潜在的影响性能)。

相反我们可以创建一个新类型来将验证放入创建其实例的函数中,而不是到处重复这些检查。这样就可以安全地在函数签名中使用新类型并相信它们接收到的值。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
}
}

泛型、Trait 和生命周期

泛型允许我们使用一个可以代表多种类型的占位符来替换特定类型,以此来减少代码冗余。让算法与类型分离。

泛型

泛型函数

使用泛型为像函数签名或结构体这样的项创建定义,这样它们就可以用于多种不同的具体数据类型。

为了参数化这个新函数中的这些类型,我们需要为类型参数命名。

Rust 类型名的命名规范是首字母大写驼峰式命名法(UpperCamelCase)。

为了定义泛型函数,类型参数声明位于函数名称与参数列表中间的尖括号 <> 中,像这样:fn largest<T>(list: &[T]) -> &T

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fn largest<T>(list: &[T]) -> &T {
let mut largest = &list[0];

for item in list {
if item > largest {
largest = item;
}
}

largest
}

fn main() {
let number_list = vec![34, 50, 25, 100, 65];

let result = largest(&number_list);
println!("The largest number is {result}");

let char_list = vec!['y', 'm', 'a', 'q'];

let result = largest(&char_list);
println!("The largest char is {result}");
}

上面代码无法编译。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
error[E0369]: binary operation `>` cannot be applied to type `&T`
--> src/main.rs:5:17
|
5 | if item > largest {
| ---- ^ ------- &T
| |
| &T
|
help: consider restricting type parameter `T` with trait `PartialOrd`
|
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
| ++++++++++++++++++++++

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

这个错误表明 largest 的函数体不能适用于 T 的所有可能的类型。因为在函数体需要比较 T 类型的值,不过它只能用于我们知道如何排序的类型。

泛型结构体

同样也可以用 <> 语法来定义结构体,它包含一个或多个泛型参数类型字段。

1
2
3
4
5
6
7
8
9
struct Point<T> {
x: T,
y: T,
}

fn main() {
let integer = Point { x: 5, y: 10 };
let float = Point { x: 1.0, y: 4.0 };
}

泛型枚举

1
2
3
4
enum Result<T, E> {
Ok(T),
Err(E),
}

泛型方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Point<T> {
x: T,
y: T,
}

impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}

fn main() {
let p = Point { x: 5, y: 10 };

println!("p.x = {}", p.x());
}

必须在 impl 后面声明 T,这样就可以在 Point<T> 上实现的方法中使用 T 了。

通过在 impl 之后声明泛型 T,Rust 就知道 Point 的尖括号中的类型是泛型而不是具体类型。

定义方法时也可以为泛型指定限制(constraint)。

1
2
3
4
5
impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}

结构体定义中的泛型类型参数并不总是与结构体方法签名中使用的泛型是同一类型。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct Point<X1, Y1> {
x: X1,
y: Y1,
}

impl<X1, Y1> Point<X1, Y1> {
fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
Point {
x: self.x,
y: other.y,
}
}
}

fn main() {
let p1 = Point { x: 5, y: 10.4 };
let p2 = Point { x: "Hello", y: 'c' };

let p3 = p1.mixup(p2);

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

类型的混合。

泛型的性能

Rust 通过在编译时进行泛型代码的单态化monomorphization)来保证效率。

单态化是一个通过填充编译时使用的具体类型,将通用代码转换为特定代码的过程。

泛型并不会使程序比具体类型运行得慢。

泛型 被编译器替换为了具体的定义。

因为 Rust 会将每种情况下的泛型代码编译为具体类型,使用泛型没有运行时开销。

当代码运行时,它的执行效率就跟好像手写每个具体定义的重复代码一样。

这个单态化过程正是 Rust 泛型在运行时极其高效的原因。

Trait:定义共同行为