由 C++ 转向 rust 之:(一)基础入门

2022/4/10

最近对 rust 突然来了兴趣,精简地记录一下学习 Rust 程序设计语言 中一些知识点。方便是为了日后快速复习和查阅。由于已经有了 C++ 的基础,看起来还是不慢的。

# 基础语法

# 变量和可变性

let var = 5;       // 默认,不可变变量
let mut var = 5;   // 可变变量
1
2

# 常量

const var: u32 = 10086;
1

常量和变量区别:

  • 首先,不允许对常量使用 mut。常量总是不能变。
  • 声明常量使用 const 关键字而不是 let,并且必须注明值的类型。即 : u32
  • 最后一个区别是,常量只能被设置为常量表达式,而不可以是其他任何只能在运行时计算出的值。

# 数据类型

# 标量

# 整型
长度 有符号 无符号
8-bit i8 u8
16-bit i16 u16
32-bit i32 u32
64-bit i64 u64
128-bit i128 u128
arch isize usize

有符号数以补码形式(two’s complement representation)存储。

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

每一个有符号的变体可以储存包含从 (2n1)-(2^n-1)(2n1)1-(2^n-1)-1 在内的数字,这里 nn 是使用的位数。无符号的变体可以储存从 002n12^n-1 的数字。

# 整型字面值
数字字面值 例子
Decimal (十进制) 98_222
Hex (十六进制) 0xff
Octal (八进制) 0o77
Binary (二进制) 0b1111_0000
Byte (单字节字符)(仅限于u8) b'A'

可以使用表格中的任何一种形式编写数字字面值。可以是多种数字类型的数字字面值允许使用类型后缀,例如 57u8 来指定类型,同时也允许使用 _ 做为分隔符以方便读数,例如 1_000,它的值与你指定的 1000 相同。

PS:数字类型默认是 i32。isize 或 usize 主要作为某些集合的索引。

整型溢出

比方说有一个 u8,它可以存放从 0 到 255 的值。那么当你将其修改为 256 时会发生什么呢?这被称为”整型溢出”(”integer overflow” ),这会导致以下两种行为之一的发生。当在 debug 模式编译时,Rust 检查这类问题并使程序 panic,这个术语被 Rust 用来表明程序因错误而退出。在 release 构建中,Rust 不检测溢出,相反会进行一种被称为二进制补码包装(two’s complement wrapping)的操作。简而言之,值 256 变成 0,值 257 变成 1,依此类推。依赖整型溢出被认为是一种错误,即便可能需要这种行为。

# 浮点型

Rust 的浮点数类型是

  • f32,占 32 位
  • f64,占 64 位。

默认类型是 f64,因为在现代 CPU 中,它与 f32 速度几乎一样,不过精度更高。所有的浮点型都是有符号的。

# 布尔型

truefalse

# 字符类型

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

let v = 'v';
1

注意,我们用单引号声明 char 字面量,而与之相反的是,使用双引号声明字符串字面量。

Rust 的 char 类型的大小为四个字节 (four bytes),并代表了一个 Unicode 标量值(Unicode Scalar Value),这意味着它可以比 ASCII 表示更多内容。在 Rust 中,拼音字母(Accented letters),中文、日文、韩文等字符,emoji (绘文字)以及零长度的空白字符都是有效的 char 值。Unicode 标量值包含从 U+0000U+D7FFU+E000U+10FFFF 在内的值。不过,”字符”并不是一个 Unicode 中的概念,所以人直觉上的”字符”可能与 Rust 中的 char 并不符合。

# 复合

# 元组(tuple)

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

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

为了从元组中获取单个值,可以使用模式匹配(pattern matching)来解构(destructure)元组值,像这样:

let tup = (500, 6.4, 1);
let (x, y, z) = tup;
1
2

也可以使用点号(.)后跟值的索引来直接访问

let x: (i32, f64, u8) = (500, 6.4, 1);
let a = x.0;
let b = x.1;
let c = x.2;
1
2
3
4

跟大多数编程语言一样,元组的第一个索引值是 0。

没有任何值的元组 () 是一种特殊的类型,只有一个值,也写成 () 。该类型被称为单元类型(unit type), 而该值被称为单元值(unit value)。如果表达式不返回任何其他值,则会隐式返回单元值。

# 数组(array)

与元组不同,数组中的每个元素的类型必须相同。Rust 中的数组与一些其他语言中的数组不同,Rust 中的数组长度是固定的。

let a = [1, 2, 3, 4, 5];
let a: [i32; 5] = [1, 2, 3, 4, 5];
let a = [3; 5]; // 等同于 let a = [3, 3, 3, 3, 3];
1
2
3

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

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

# 访问数组元素

数组是可以在堆栈上分配的已知固定大小的单个内存块。可以使用索引来访问数组的元素

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

如果索引超出了数组长度,Rust 会 panic,这是 Rust 术语,它用于程序因为错误而退出的情况。

# 函数

main 函数,它是很多程序的入口点。同 cpp

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

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

fn main() {
  println!("Hello, world!");
another_function();
}
fn another_function() {
  println!("Another function.");
}
1
2
3
4
5
6
7

PS: Rust 不关心函数定义于何处,只要定义了就行。这点与 cpp 显著不同。

# 参数

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

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

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

# 语句和表达式

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

例子:

  1. 使用 let 关键字创建变量并绑定一个值是一个语句:let y = 6; 是一个语句;函数定义也是语句。 语句不返回值。因此,不能把 let 语句赋值给另一个变量
let x = (let y = 6); // 错误,无法通过编译
1

表达式会计算出一个值,语句 let y = 6; 中 的 6 是一个表达式,它计算出的值是 6。函数调用是一个表达式。宏调用是一个表达式。用大括号创建的一个新的块作用域也是一个表达式。

表达式:

{
  let x = 3;
  x + 1
}
1
2
3
4

是一个代码块,它的值是 4。

表达式的结尾没有分号。如果在表达式的结尾加上分号,它就变成了语句,而语句不会返回值。

# 具有返回值的函数

fn five() -> i32 {
  5
}
1
2
3

在 Rust 中,函数的返回值等同于函数体最后一个表达式的值。使用 return 关键字和指定值,可从函数中提前返回;

# 控制流

# if 表达式

if condition {
  println!("condition was true");
} else {
  println!("condition was false");
}
1
2
3
4
5

Rust 并不会尝试自动地将非布尔值转换为布尔值。必须总是显式地使用布尔值作为 if 的条件。

# 循环

  1. loop

    let mut count = 0;
    'loop_label: loop {
      println!("count = {}", count);
      let mut remaining = 10;
      loop {
        println!("remaining = {}", remaining);
        if remaining == 9 {
          break;
        }
        if count == 2 {
          break 'loop_label;
        }
        remaining -= 1;
      }
    count += 1;
    }
    println!("End count = {}", count);
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
  2. while

    while number != 0 {
      println!("{}!", number);
      number -= 1;
    }
    
    1
    2
    3
    4
  3. for

    for element in a {
      println!("the value is: {}", element);
    }
    
    // 使用 Range,标准库提供的类型,来生成从一个数字开始到另一个数字之前结束的所有数字的序列
    for number in (1..4).rev() {
      println!("{}!", number);
    }
    
    1
    2
    3
    4
    5
    6
    7
    8

# 返回值

// if
let number = if condition { 5 } else { "six" };
1
2
// 循环
let result = loop {
  counter += 1;
  if counter == 10 {
    break counter * 2;
  }
};
1
2
3
4
5
6
7

# 所有权

# 所有权规则

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

# 变量作用域

作用域是一个项(item)在程序中有效的范围。

变量从声明的点开始直到当前作用域结束时都是有效的。

fn main() {
  {                      // s 在这里无效, 它尚未声明
    let s = "hello";     // 从此处起,s 是有效的
                         // 使用 s
  }                      // 此作用域已结束,s 不再有效
}
1
2
3
4
5
6

# 内存与分配

需要在堆上分配一块在编译时未知大小的内存来存放内容。这意味着:

  • 必须在运行时向内存分配器(memory allocator)请求内存。
  • 需要一个当我们处理完 String 时将内存返回给分配器的方法。

然而,第二部分实现起来就各有区别了。在有垃圾回收(garbage collector,GC)的语言中,GC 记录并清除不再使用的内存,而我们并不需要关心它。在大部分没有 GC 的语言中,识别出不再使用的内存并调用代码显式释放就是我们的责任了,跟请求内存的时候一样。从历史的角度上说正确处理内存回收曾经是一个困难的编程问题。如果忘记回收了会浪费内存。如果过早回收了,将会出现无效变量。如果重复回收,这也是个 bug。我们需要精确的为一个 allocate 配对一个 free。

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

fn main() {
  {
    let s = String::from("hello");      // 从此处起,s 是有效的
                                        // 使用 s
  }                                     // 此作用域已结束,
                                        // s 不再有效
}
1
2
3
4
5
6
7

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

RAII

注意:在 C++ 中,这种 item 在生命周期结束时释放资源的模式有时被称作 资源获取即初始化 (Resource Acquisition Is Initialization (RAII))。

看起来很简单,不过在更复杂的场景下代码的行为可能是不可预测的,比如当有多个变量使用在堆上分配的内存时。

# 变量与数据交互的方式(一):移动
fn main() {
  let x = 5;
  let y = x;
}
1
2
3
4

大致可以到发生了什么:将 5 绑定到 x;接着生成一个值 x 的拷贝并绑定到 y”。现在有了两个变量,x 和 y,都等于 5。这也正是事实上发生了的,因为整数是有已知固定大小的简单值,所以这两个 5 被放入了栈中。

fn main() {
  let s1 = String::from("hello");
  let s2 = s1;
}
1
2
3
4

我们可能会假设他们的运行方式也是类似的:也就是说,第二行可能会生成一个 s1 的拷贝并绑定到 s2 上。不过,事实上并不完全是这样。

之前我们提到过当变量离开作用域后,Rust 自动调用 drop 函数并清理变量的堆内存。不过图 4-2 展示了两个数据指针指向了同一位置。这就有了一个问题:当 s2 和 s1 离开作用域,他们都会尝试释放相同的内存。这是一个叫做 二次释放(double free)的错误,也是之前提到过的内存安全性 bug 之一。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。

为了确保内存安全,在 let s2 = s1 之后,Rust 认为 s1 不再有效,因此 Rust 不需要在 s1 离开作用域后清理任何东西。看看在 s2 被创建之后尝试使用 s1 会发生什么;这段代码不能运行:

如果你在其他语言中听说过术语 浅拷贝(shallow copy)和 深拷贝(deep copy),那么拷贝指针、长度和容量而不拷贝数据可能听起来像浅拷贝。不过因为 Rust 同时使第一个变量无效了,这个操作被称为 移动 (move),而不是浅拷贝。上面的例子可以解读为 s1 被移动到了 s2 中。

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

# 变量与数据交互的方式(二):克隆

如果我们确实需要深度复制上的数据,而不仅仅是上的数据,可以使用一个叫做 clone 的 通用函数。

# 只在栈上的数据:拷贝
let x = 5;
let y = x;
1
2

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

Rust 有一个叫做 Copy trait 的特殊注解,可以用在类似整型这样的存储在栈上的类型上。如果一个类型实现了 Copy trait,那么一个旧的变量在将其赋值给其他变量后仍然可用。Rust 不允许自身或其任何部分实现了 Drop trait 的类型使用 Copy trait。如果我们对其值离开作用域时需要特殊处理的类型使用 Copy 注解,将会出现一个编译时错误。

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

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

# 所有权与函数

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

fn main() {
  let s = String::from("hello");      // s 进入作用域
  
  takes_ownership(s);                 // s 的值移动到函数里 ...
                                      // ... 所以到这里 s 不再有效
  
  let x = 5;                          // x 进入作用域
  
  makes_copy(x);                      // x 应该移动函数里,
                                      // 但 i32 是 Copy 的,
                                      // 所以在后面可继续使用 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

# 返回值与作用域

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

fn main() {
  let s1 = gives_ownership();         // gives_ownership 将返回值
                                      // 转移给 s1
  
  let s2 = String::from("hello");     // s2 进入作用域
  
  let s3 = takes_and_gives_back(s2);  // s2 被移动到
                                      // takes_and_gives_back 中,
                                      // 它也将返回值移给 s3
} // 这里, s3 移出作用域并被丢弃。s2 也移出作用域,但已被移走,
  // 所以什么也不会发生。s1 离开作用域并被丢弃

fn gives_ownership() -> String {            // gives_ownership 会将
                                            // 返回值移动给调用它的函数
  let some_string = String::from("yours");  // some_string 进入作用域.
  some_string                               // 返回 some_string
                                            // 并移出给调用的函数
}

// takes_and_gives_back 将传入字符串并返回该值
fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域
  a_string // 返回 a_string 并移出给调用的函数
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

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

# 引用与借用

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

fn main() {
   let s1 = String::from("hello");
   let len = calculate_length(&s1);
   println!("The length of '{}' is {}.", s1, len);
}

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

& 符号就是引用,它们允许你使用值但不获取其所有权。与使用 & 引用相反的操作是 解引用(dereferencing),它使用解引用运算符 *

引用(默认)不允许修改引用的值。

# 可变引用

fn main() {
  let mut s = String::from("hello");     // 变量需为可变变量
  change(&mut s);                        // 使用可变引用 &mut
}
fn change(some_string: &mut String) {    // 使用可变引用 &mut
  some_string.push_str(", world");
}
1
2
3
4
5
6
7

可变引用有一个很大的限制:在同一时间只能有一个对某一特定数据的可变引用。

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

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

数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复;Rust 避免了这种情况的发生,因为它甚至不会编译存在数据竞争的代码!

我们也不能在拥有不可变引用的同时拥有可变引用。

fn main() {
  let mut s = String::from("hello");
  let r1 = &s;       // 没问题
  let r2 = &s;       // 没问题
  let r3 = &mut s;   // 大问题
}

fn main() {
  let mut s = String::from("hello");
  let r1 = &s;       // 没问题
  let r2 = &s;       // 没问题
  println!("{} and {}", r1, r2);
                     // 此位置之后 r1 和 r2 不再使用
  let r3 = &mut s;   // 没问题
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

非词法作用域生命周期

编译器在作用域结束之前判断不再使用的引用的能力被称为 非词法作用域生命周期(Non−Lexical Lifetimes,简称 NLL)。

# 悬垂引用(Dangling References)

在具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个 悬垂指针(dangling pointer),所谓悬垂指针是其指向的内存可能已经被分配给其它持有者。相比之下,在 Rust 中编译器确保引用永远也不会变成悬垂状态:当你拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。

fn main() {
  let reference_to_nothing = dangle();
}

fn dangle() -> &String {             // dangle 返回一个字符串的引用
  let s = String::from("hello");     // s 是一个新字符串
  &s                                 // 返回字符串 s 的引用
} // 这里 s 离开作用域并被丢弃。其内存被释放。
  // 不被允许
1
2
3
4
5
6
7
8
9

# 引用的规则总结

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

# Slice 类型

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

可以使用一个由中括号中的 [starting_index..ending_index] 指定的 range 创建一个 slice

字符串 slice 的类型声明写作 &str

# 结构体

# 定义并实例化结构体

struct User {
  active: bool,
  username: String,
  email: String,
  sign_in_count: u64,
}

fn main() {
  let mut user1 = User {
    email: String::from("[email protected]"),
    username: String::from("someusername123"),
    active: true,
    sign_in_count: 1,
  };
  
  user1.email = String::from("[email protected]")
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 字段初始化简写语法

fn build_user(email: String, username: String) -> User {
    User {
    email,            // 参数名与字段名都完全相同即可使用
    username,
    active: true,
    sign_in_count: 1,
  }
}
1
2
3
4
5
6
7
8

# 使用结构体更新语法从其他实例创建实例

struct User {
  active: bool,
  username: String,
  email: String,
  sign_in_count: u64,
}

fn main() {
  let user1 = User {
    email: String::from("[email protected]"),
    username: String::from("someusername123"),
    active: true,
    sign_in_count: 1,
  };
  
  let user2 = User {
    email: String::from("[email protected]"),
    ..user1 // .. 语法指定了剩余未显式设置值的字段应有与给定实例对应字段相同的值。
            // .. user1 必须放在最后后,以指定其余的字段应从 user1 的相应字段中获取其值
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

请注意,结构更新语法就像带有 = 的赋值,因为它移动了数据,就像我们在” 变量与数据交互的方式(一):移动” 部分讲到的一样。

在这个例子中,我们在创建 user2 后不能再使用 user1,因为 user1 的username 字段中的 String 被移到 user2 中。如果我们给 user2 的 email 和 username 都赋予新的String 值,从而只使用 user1 的 active 和 sign_in_count 值,那么 user1 在创建 user2 后仍然有效。active 和 sign_in_count 的类型是实现 Copy trait 的类型,所以我们在” 变量与数据交互的方式(二):克隆” 部分讨论的行为同样适用。

# 元组结构体

元组结构体有着结构体名称提供的含义,但没有具体的字段名,只有字段的类型。

struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

fn main() {
  let black = Color(0, 0, 0);
  let origin = Point(0, 0, 0);
}
1
2
3
4
5
6
7

注意 black 和 origin 值的类型不同,因为它们是不同的元组结构体的实例。你定义的每一个结构体有其自己的类型,即使结构体中的字段有着相同的类型。

# 类单元结构体

struct AlwaysEqual;   // 一个没有任何字段的结构体
                      // 定义 struct 关键字,然后是名称,最后是一个分号。不需要花括号或圆括号!
fn main() {
  let subject = AlwaysEqual;  // 使用我们定义的名称,不需要任何花括号或圆括号。
}
1
2
3
4
5

# 结构体数据的所有权

可以使结构体存储被其他对象拥有的数据的引用,不过这么做的话需要用上 生命周期(lifetimes)

# 结构体使用

# 通过派生 trait 增加实用功能

Rust 包含了打印出调试信息的功能,不过必须显式选择这个功能。

#[derive(Debug)]     // 显式选择
struct Rectangle {
  width: u32,
  height: u32,
}

fn main() {
  let rect1 = Rectangle {
    width: 30,
    height: 50,
  };
  println!("rect1 is {:#?}", rect1);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
$ cargo run
  Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
      Running `target/debug/rectangles`
rect1 is Rectangle {
  width: 30,
  height: 50,
}
1
2
3
4
5
6
7
8

另一种使用 Debug 格式打印数值的方法是使用 dbg! 宏。dbg! 宏接收一个表达式的所有权,打印出代码中调用 dbg! 宏时所在的文件和行号,以及该表达式的结果值,并返回该值的所有权。

注意:调用 dbg! 宏会打印到标准错误控制台流(stderr),与 println! 不同,后者会打印到标准输出控制台流(stdout)。

#[derive(Debug)]
struct Rectangle {
  width: u32,
  height: u32,
}
fn main() {
  let scale = 2;
  let rect1 = Rectangle {
    width: dbg!(30 * scale),
    height: 50,
  };
  dbg!(&rect1);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
$ cargo run
  Compiling rectangles v0.1.0 (file:///projects/rectangles)
  Finished dev [unoptimized + debuginfo] target(s) in 0.61s
  Running `target/debug/rectangles`
[src/main.rs:10] 30 * scale = 60
[src/main.rs:14] &rect1 = Rectangle {
  width: 60,
  height: 50,
}
1
2
3
4
5
6
7
8
9

# 方法语法

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

# 定义

#[derive(Debug)]
struct Rectangle {
  width: u32,
  height: u32,
}

impl Rectangle {              // 这个 impl 块中的所有内容都将与 Rectangle 类型相关联
  fn area(&self) -> u32 {     // &self 实际上是 self:&Self 的缩写,Self 类型是 impl 块的类型的别名
                              // 方法的第一个参数必须有一个名为 self 的 Self 类型的参数
                              // 这里为了方便使用了引用
    self.width * self.height
  }
  
  fn width(&self) -> bool {
    self.width > 0
  }
} // end impl

fn main() {
  let rect1 = Rectangle {
    width: 30,
    height: 50,
  };
  
  println!(
    "The area of the rectangle is {} square pixels.",
    rect1.area()  // 方法语法获取一个实例并加上一个点号,后跟方法名、圆括号以及任何参数。
  );
  
  // 在同名的方法中使用同名的字段
  // 使用 () 则为方法
  // 不使用 () 则为字段
  if rect1.width() {
    println!("The rectangle has a nonzero width; it is {}", rect1.width);
  }
}
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
31
32
33
34
35
36

# 自动引用和解引用

在 C∕C++ 语言中,有两个不同的运算符来调用方法:. 直接在对象上调用方法,而 −> 在一个对象的指针上调用方法,这时需要先解引用(dereference)指针。换句话说,如果 object 是一个指针,那么 object−>something() 就像 (*object).something() 一样。

Rust 并没有一个与 −> 等效的运算符;相反,Rust 有一个叫 自动引用和解引用(automatic referencing and dereferencing)的功能。方法调用是 Rust 中少数几个拥有这种行为的地方。它是这样工作的:当使用 object.something() 调用方法时,Rust 会自动为 object 添加 &&mut* 以便使 object 与方法签名匹配。也就是说,这些代码是等价的

#[derive(Debug,Copy,Clone)]
  struct Point {
  x: f64,
  y: f64,
}

impl Point {
  fn distance(&self, other: &Point) -> f64 {
    let x_squared = f64::powi(other.x - self.x, 2);
    let y_squared = f64::powi(other.y - self.y, 2);

    f64::sqrt(x_squared + y_squared)
  }
}

fn main() {
  let p1 = Point { x: 0.0, y: 0.0 };
  let p2 = Point { x: 5.0, y: 6.5 };
  
  p1.distance(&p2);
  (&p1).distance(&p2);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

第一行看起来简洁的多。这种自动引用的行为之所以有效,是因为方法有一个明确的接收者——self 的类型。在给出接收者和方法名的前提下,Rust 可以明确地计算出方法是仅仅读取(&self),做出修改(&mut self)或者是获取所有权(self)。事实上,Rust 对方法接收者的隐式借用让所有权在实践中更友好。

# 关联函数

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

#[derive(Debug)]
struct Rectangle {
  width: u32,
  height: u32,
}

impl Rectangle {
  fn square(size: u32) -> Rectangle {
    Rectangle {
      width: size,
      height: size,
    }
  }
}

fn main() {
  let sq = Rectangle::square(3);   // 使用结构体名和 :: 语法来调用这个关联函数
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 多个 impl 块

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

等同于

impl Rectangle {
  fn area(&self) -> u32 {
    self.width * self.height
  }
  
  fn can_hold(&self, other: &Rectangle) -> bool {
    self.width > other.width && self.height > other.height
  }
}
1
2
3
4
5
6
7
8
9

# 枚举

enum IpAddrKind {
  V4,      // 成员
  V6,
}

fn main() {
  let four = IpAddrKind::V4;
  let six = IpAddrKind::V6;

  route(IpAddrKind::V4);
  route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

目前只有类型,现在添加数据

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

使用了一个结构体来将 kind 和 address 打包在一起,现在枚举成员就与值相关联了。我们可以使用一种更简洁的方式来表达相同的概念,仅仅使用枚举并将数据直接放进每一个枚举成员而不是将枚举作为结构体的一部分。

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

可以理解为枚举类成员添加元组属性描述;

虽然可以如此命名,但请注意,并不能像访问结构体字段一样访问枚举类绑定的属性。可以使用稍后的 match 匹配访问。

# Option 枚举和其相对于空值的优势

Option 是标准库定义的另一个枚举。Option 类型应用广泛因为它编码了一个非常普遍的场景,即一个值要么有值要么没值。

Rust 并没有很多其他语言中有的空值功能。空值(Null)是一个值,它代表没有值。在有空值的语言中,变量总是这两种状态之一:空值和非空值。

Tony Hoare,null 的发明者,在他 2009 年的演讲 ”Null References: The Billion Dollar Mistake” 中曾经说到:

I call it my billion-dollar mistake. At that time, I was designing the first comprehensive type system for references in an object-oriented language. My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn’t resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.

我称之为我十亿美元的错误。当时,我在为一个面向对象语言设计第一个综合性的面向引用的类型系统。我的目标是通过编译器的自动检查来保证所有引用的使用都应该是绝对安全的。不过我未能抵抗住引入一个空引用的诱惑,仅仅是因为它是这么的容易实现。这引发了无数错误、漏洞和系统崩溃,在之后的四十多年中造成了数十亿美元的苦痛和伤害。

空值的问题在于当你尝试像一个非空值那样使用一个空值,会出现某种形式的错误。因为空和非空的属性无处不在,非常容易出现这类错误。然而,空值尝试表达的概念仍然是有意义的:空值是一个因为某种原因目前无效或缺失的值。

问题不在于概念而在于具体的实现。为此,Rust 并没有空值,不过它确实拥有一个可以编码存在或不存在概念的枚举。这个枚举是 Option,而且它定义于标准库中,如下:

enum Option<T> {   // <T> 泛型类型参数
  None,
  Some(T),
}
1
2
3
4

Option 枚举是如此有用以至于它甚至被包含在了 prelude 之中,你不需要将其显式引入作用域。它的成员也是如此,可以不需要 Option:: 前缀来直接使用 Some 和 None。即便如此 Option 也仍是常规的枚举,Some(T) 和 None 仍是 Option 的成员。

fn main() {
  let some_number = Some(5);
  let some_string = Some("a string");
  let absent_number: Option<i32> = None;
}
1
2
3
4
5

some_number 的类型是 Option。some_string 的类型是 Option<&str>,这(与 some_number)是一个不同的类型。因为我们在 Some 成员中指定了值,Rust 可以推断其类型。对于 absent_number,Rust 需要我们指定 Option 整体的类型,因为编译器只通过 None 值无法推断出 Some 成员保存的值的类型。这里我们告诉 Rust 希望 absent_number 是 Option 类型的。

# match 控制流运算符

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,
  }
}

fn main() {}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 绑定值的模式

#[derive(Debug)]
enum UsState {
Alabama,
Alaska,
// --snip--
}

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
    }
  }
}

fn main() {
  value_in_cents(Coin::Quarter(UsState::Alaska));
}
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

# 匹配 Option

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

# 匹配是穷尽的

Rust 中的匹配是 穷尽的(exhaus−tive):必须穷举到最后的可能性来使代码有效。

# 通配模式和 _ 占位符

match dice_roll {
  3 => add_fancy_hat(),
  7 => remove_fancy_hat(),
  other => move_player(other),
}

match dice_roll {
  3 => add_fancy_hat(),
  7 => remove_fancy_hat(),
  _ => (),  // 空元组
}
1
2
3
4
5
6
7
8
9
10
11

第一个匹配模式是我们命名为 other 的一个变量, 即 other 分支的代码通过将其传递给 move_player 函数来使用这个变量(通过绑定)。

第二个匹配模式,这是一个特殊的模式,可以匹配任意值而不绑定到该值。这告诉 Rust 我们不会使用这个值,所以 Rust 也不会警告我们存在未使用的变量。

# if let 简单控制流

fn main() {
  let config_max = Some(3u8);
  if let Some(max) = config_max {
    println!("The maximum is configured to be {}", max);
  }
}

// 等效于,仅仅匹配下方第一个分支
fn main() {
let config_max = Some(3u8);
  match config_max {
    Some(max) => println!("The maximum is configured to be {}", max),
    _ => (),
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

使用 if let 意味着编写更少代码,更少的缩进和更少的样板代码。然而,这样会失去 match 强制要求的穷尽性检查。match 和 if let 之间的选择依赖特定的环境以及增加简洁度和失去穷尽性检查的权衡取舍。

// case if-let with else
fn main() {
  let config_max = Some(3u8);
  if let Some(max) = config_max {
    println!("The maximum is configured to be {}", max);
  }
  else {
    // skip
  }
}
1
2
3
4
5
6
7
8
9
10

# 项目管理

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

# 包和 crate

  • 包中可以包含至多一个库 crate(library crate)。
  • 包中可以包含任 意多个二进制 crate(binary crate),但是必须至少包含一个 crate(无论是库的还是二进制的)。
$ cargo new rustProjectName
     Created binary (application) `rustProjectName` package
$ ls rustProjectName/
Cargo.toml  src
$ ls rustProjectName/src/
main.rs
1
2
3
4
5
6

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 的包,意味着它只含有一个名为rustProjectName 的二进制 crate。如果一个包同时含有 src∕main.rs 和 src∕lib.rs,则它有两个 crate:一个二进制的和一个库的,且名字都与包相同。通过将文件放在 src∕bin 目录下,一个包可以拥有多个二进制 crate:每个 src∕bin 下的文件都会被编译成一个独立的二进制 crate。

# 定义模块来控制作用域与私有性

cargo new −−lib restaurant
1
// src∕lib.rs
mod front_of_house {
  mod hosting {
    fn add_to_waitlist() {}
    fn seat_at_table() {}
  }
  mod serving {
    fn take_order() {}
    fn serve_order() {}
    fn take_payment() {}
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

模块树结构如下

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

# 路径用于引用模块树中的项

  • 绝对路径(absolute path)从 crate 根开始,以 crate 名或者字面值 crate 开头。
  • 相对路径(relative path)从当前模块开始,以 self、super 或当前模块的标识符开头。
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
5
6
7
8
9
10
11
12

# 使用 pub 关键字暴露路径

如上方,pub fn add_to_waitlist() {}

# 使用 super 起始的相对路径

我们还可以使用 super 开头来构建从父模块开始的相对路径。这么做类似于文件系统中以 .. 开头的语法。

 // src∕lib.rs
fn serve_order() {}

mod back_of_house {
  fn fix_incorrect_order() {
    cook_order();
    super::serve_order();
  }
  fn cook_order() {}
}
1
2
3
4
5
6
7
8
9
10

# 创建公有的结构体和枚举

如果我们在一个结构体定义的前面使用了 pub ,这个结构体会变成公有的,但是这个结构体的字段仍然是私有的。如果具有私有字段,则这个结构体需要提供一个公共的关联函数来构造的实例。

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

# 使用 use 关键字将名称引入作用域

# 创建惯用的 use 路径

基本与 C++ 相同

use std::collections::HashMap;
use std::collections::HashMap as Hash;  //  使用 as 关键字提供新的名称

fn main() {
  let mut map1 = HashMap::new();
  let mut map2 = Hash::new();
}
1
2
3
4
5
6
7

# 使用 pub use 重导出名称

利用 pub use 可以允许再次被导入到其他作用域,即重导出(re−exporting)。

mod front_of_house {
  pub mod hosting {
    pub fn add_to_waitlist() {}
  }
}

pub use crate::front_of_house::hosting;
1
2
3
4
5
6
7

# 使用外部包

在 Cargo.toml 中添加

rustProjectName = "X.X.X"
1

然后使用 use

use rustProjectName::abaabaaba;
1

# 嵌套路径来消除大量的 use 行

use std::cmp::Ordering;
use std::io;
// 等效于
use std::{cmp::Ordering, io};
// -------------------------
use std::io;
use std::io::Write;
// 等效于
use std::io::{self, Write};
1
2
3
4
5
6
7
8
9

# 通过 glob 运算符将所有的公有定义引入作用域

use std::collections::*;
1

这个 use 语句将 std::collections 中定义的所有公有项引入当前作用域。

# 将模块分割进不同文件

mod hosting;
pub mod hosting;  // 类似于 pub use
1
2

mod hosting 后使用分号,而不是代码块,这将告诉 Rust 在另一个与模块同名的文件中加载 模块的内容。

# 常见集合

Rust 标准库中包含一系列被称为 集合(collections)的非常有用的数据结构。不同于内建的数组和元组类型,这些集合指向的数据是储存在上的,这意味着数据的数量不必在编译时就已知,并且还可以随着程序的运行增长或缩小。

  • vector 允许我们一个挨着一个地储存一系列数量可变的值
  • 字符串(string)是字符的集合。我们之前见过 String 类型,不过在本章我们将深入了解
  • 哈希 map(hash map)允许我们将值与一个特定的键(key)相关联。这是一个叫做 map 的更通用的数据结构的特定实现。

# vector

vector 允许我们在一个单独的数据结构中储存多于一个的值,它在内存中彼此相邻地排列所有的值,但只能储存相同类型的值。

fn main() {
  let mut v: Vec<i32> = Vec::new();
  let mut vec = vec![1, 2, 3];

  v.push(5);  // 增加值

  let does_not_exist = &v[100];     // 当引用一个不存在的元素时 Rust 会造成 panic
  let does_not_exist = v.get(100);  // 当 get 方法被传递了一个数组外的索引时,
                                    // 它不会 panic 而是返回 None

  for i in &v {
    println!("{}", i);
  }

  for i in &mut v {
    *i += 50;  // 解引用
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 字符串与 UTF-8 文本

称作 String 的类型是由标准库提供的,而没有写进核心语言部分,它是可增长的、可变的、有所有权的、UTF-8 编码的字符串类型。

fn main() {
  let mut s = String::new();
  let data = "initial contents";
  let s = data.to_string();
  let s = "initial contents".to_string();
  let s = String::from("initial contents");
}
1
2
3
4
5
6
7

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

fn main() {
  let mut s = String::from("foo");
  s.push_str("bar");   // 使用 push_str 方法向 String 附加字符串 slice
                       // s: "foobar"
  s.push('l');         // push 将一个字符加入 String 值中
  
  let s1 = String::from("Hello, ");
  let s2 = String::from("world!");
  let s3 = s1 + &s2;   // 注意 s1 被移动了,不能继续使用
  
  let s1 = String::from("tic");
  let s2 = String::from("tac");
  let s3 = String::from("toe");
  let s = s1 + "-" + &s2 + "-" + &s3;  // 与下方等效
  let s = format!("{}-{}-{}", s1, s2, s3);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

s1 不在能使用,是因为 + 运算符使用了 add 函数,看来来类似于 fn add(self, s: ) -> String

&String 可以被 强转(coerced)成 &str —— Deref 强制转换(deref coercion)技术。

# 内部表现

fn main() {
  let hello = String::from("مالسلا مكيلع");
  println!("{} len: {}", hello, hello.len());
  
  let hello = String::from("Здравствуйте");
  println!("{} len: {}", hello, hello.len());
  
  let hello = String::from("Hola");
  println!("{} len: {}", hello, hello.len());
  
  let hello = String::from("你好");
  println!("{} len: {}", hello, hello.len());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
$ cargo run
# --skip--
مالسلا مكيلع len: 23
Здравствуйте len: 24
Hola len: 4
你好 len: 6
1
2
3
4
5
6

len 返回字节数目

let hello = "Здравствуйте";
let answer = &hello[0];  // 无法编译
1
2

由于 UTF-8 为可变长编码,为了避免返回意外的值并造成不能立刻发现的 bug,Rust 根本不会允许编译使用索引获取 String 字符,在开发过程中及早杜绝了误会的发生。

# 字节、标量值和字形簇

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

比如这个用梵文书写的印度语单词 “ नमस◌्त◌े”,最终它储存在 vector 中的 u8 值看起来像这样:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164, 224, 165, 135]

这里有 18 个字节,也就是计算机最终会储存的数据。如果从 Unicode 标量值的角度理解它们,也就像 Rust 的 char 类型那样,这些字节看起来像这样:

[' न', ' म', ' स', ' ◌्', ' त', ' ◌े']

这里有六个 char,不过第四个和第六个都不是字母,它们是发音符号本身并没有任何意义。最后,如果以字形簇的角度理解,就会得到人们所说的构成这个单词的四个字母:

[" न", " म", " स◌्", " त◌े"]

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

# 字符串 slice

let hello = "Здравствуйте";
let s = &hello[0..4];  // s: "”Зд"
1
2

因为变长,所以这种方式不是很优美

# 遍历字符串

for c in " नमस◌्त◌े".chars() {
  println!("{}", c);
}
1
2
3

这些代码会打印出如下内容:

न
म
स
◌्
त
◌
1
2
3
4
5
6

for b in " नमस◌्त◌े".bytes() {
  println!("{}", b);
}
1
2
3

这些代码会打印出如下内容:

224
164
// --snip--
165
135
1
2
3
4
5

有效的 Unicode 标量值可能会由不止一个字节组成,从字符串中获取字形簇是很复杂的,所以标准库并没有提供这个功能,可找寻第三方库。

# 哈希 map

HashMap 类型储存了一个键类型 K 对应一 个值类型 V 的映射。哈希 map 是同质的:所有的键必须是相同类型,值也必须都是相同类型。

fn main() {
  use std::collections::HashMap;
  let mut scores = HashMap::new();
  
  scores.insert(String::from("Blue"), 10);  // 插入键值对
  scores.insert(String::from("Yellow"), 50);
}
1
2
3
4
5
6
7

# 哈希 map 和所有权

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 不再有效
}
1
2
3
4
5
6
7
8
9

# 访问

let team_name = String::from("Blue");
let score = scores.get(&team_name);  // get 返回 Option<V>
                                     // 如果某个键在哈希 map 中没有对应的值,get 会返回 None
for (key, value) in &scores {
  println!("{}: {}", key, value);
}
1
2
3
4
5
6

# 覆盖

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Blue"), 25);  // 原始的值 10 则被覆盖了
1
2

# 只在键没有对应值时插入

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); // entry 函数的返回值是一个枚举,Entry
                                                      // 它代表了可能存在也可能不存在的值
  scores.entry(String::from("Blue")).or_insert(50);   // Entry 的 or_insert 方法
                                                      // 在键对应的值存在时,
                                                      // 返回这个值的可变引用,
                                                      // 如果不存在则将参数作为新值插入
                                                      // 并返回新值的可变引用
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 根据旧值更新一个值

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

# 哈希函数

HashMap 默认使用一种叫做 SipHash 的哈希函数,它可以抵御涉及哈希表(hash table)的拒绝服务(Denial of Service, DoS)攻击。然而这并不是可用的最快的算法,不过为了更高的安全性值得付出一些 、性能的代价。如果性能监测显示此哈希函数非常慢,以致于你无法接受,你可以指定一个不同的 hasher 来切换为其它函数。

# 错误处理

Rust 将错误组合成两个主要类别:可恢复错误(recoverable)(Result<T, E>)和 不可恢复错误(unrecoverable)(panic!)。可恢复错误通常我们希望向用户报告错误并重试操作,比如未找到文件(file not found)错误。不可恢复错误通常是 bug 的同义词,比如尝试访问超过数组结尾的位置。

# panic! 与不可恢复错误

Rust 有 panic!宏。当执行这个宏时,程序会打印出一个错误信息,展开并清理栈数据,然后接着退出。

fn main() {
  panic!("crash and burn");
}
1
2
3

# 对应 panic 时的栈展开或终止

当出现 panic 时,程序默认会开始 展开(unwinding),这意味着 Rust 会回溯栈并清理它遇到的每一个函数的数据,不过这个回溯并清理的过程有很多工作。另一种选择是直接 终止(abort),这会不清理数据就退出程序。那么程序所使用的内存需要由操作系统来清理。如果你需要项目的最终二进制文件越小越好,panic 时通过在 Cargo.toml 的 [profile] 部分增加 panic = 'abort',可以由展开切换为终止。例如,如果你想要在 release 模式中 panic 时直接终止:

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

# 使用 panic! 的 backtrace

backtrace 是一个执行到目前位置所有被调用的函数的列表。为了获取带有这些信息的 backtrace,必须启用 debug 标识。

$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
stack backtrace:
  0: rust_begin_unwind
      at /rustc/7eac88abb2e3d7adfb4/library/std/src/panicking.rs:483
  1: core::panicking::panic_fmt
      at /rustc/7eac88abb2e3d7adfb4/library/core/src/panicking.rs:85
  2: core::panicking::panic_bounds_check
      at /rustc/7eac88abb2e3d7adfb4/library/core/src/panicking.rs:62
  3: <usize as core::slice::index::SliceIndex<[T]>>::index
      at /rustc/7eac88abb2e3d7adfb4/library/core/src/slice/index.rs:255
  4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
      at /rustc/7eac88abb2e3d7adfb4/library/core/src/slice/index.rs:15
  5: <alloc::vec::Vec<T> as core::ops::index::Index<I>>::index
      at /rustc/7eac88abb2e3d7adfb4/library/alloc/src/vec.rs:1982
  6: panic::main
      at ./src/main.rs:4
  7: core::ops::function::FnOnce::call_once
      at /rustc/7eac88abb2e3d7adfb4/library/core/src/ops/function.rs:227
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# Result 与可恢复错误

result 原型

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

# 匹配不同的错误

  • case 1:

    use std::fs::File;
    use std::io::ErrorKind;
    
    fn main() {
      let f = File::open("hello.txt");
      
      let f = match f {
        Ok(file) => file,
        Err(error) => match error.kind() {
          ErrorKind::NotFound => match File::create("hello.txt") {
            Ok(fc) => fc,
            Err(e) => panic!("Problem creating the file: {:?}", e),
          },
          other_error => {
            panic!("Problem opening the file: {:?}", other_error)
          }
        },
      };
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
  • case 2:

    use std::fs::File;
    use std::io::ErrorKind;
    
    // 闭包示例
    fn main() {
      let f = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
          File::create("hello.txt").unwrap_or_else(|error| {
            panic!("Problem creating the file: {:?}", error);
          })
        } else {
          panic!("Problem opening the file: {:?}", error);
        }
      });
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15

注意与 Option 枚举一样,Result 枚举和其成员也被导入到了 prelude 中,所以就不需要在 match 分支中的 Ok 和 Err 之前指定 Result::。

# 失败时 panic 的简写:unwrap 和 expect

  • unwrap: 如果 Result 值是成员 Ok,unwrap 会返回 Ok 中的值。如果 Result 是成员 Err,unwrap 会为我们调用 panic!。
  • expect: expect 与 unwrap 的使用方式一样:返回成员 Ok 值或调用 panic! 宏。expect 在调用 panic! 时使用的错误信息将是我们传递给 expect 的参数,而不像 unwrap 那样使用默认的 panic! 信息。

# 传播错误

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
  let f = File::open("hello.txt");
  
  let mut f = match f {
    Ok(file) => file,
    Err(e) => return Err(e),
  };
  
  let mut s = String::new();
  
  match f.read_to_string(&mut s) {
    Ok(_) => Ok(s),
    Err(e) => Err(e),
  }
}
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;
use std::io::Read;

fn read_username_from_file() -> Result<String, io::Error> {
  let mut f = File::open("hello.txt")?;
  let mut s = String::new();
  f.read_to_string(&mut s)?;
  Ok(s)
}
1
2
3
4
5
6
7
8
9
10

Result 值之后的 ? 的工作方式:如果 Result 的值是 Ok,这个表达式将会返回 Ok 中的值而程序将继续执行。如果值是 Err,Err 中的值将作为整个函数的返回值,就好像使用了 return 关键字一样,这样错误值就被传播给了调用者。

? 运算符所使用的错误值被传递给了 from 函数,它定义于标准库的 From trait 中,其用来将错误从一种类型转换为另一种类型。当 ? 运算符调用 from 函数时,收到的错误类型被转换为由当前函数返回类型所指定的错误类型。这在当函数返回单个错误类型来代表所有可能失败的方式时很有用,即使其可能会因很多种原因失败。只要每一个错误类型都实现了 from 函数来定义如何将自身转换为返回的错误类型,? 运算符会自动处理这些转换。

PS:? 运算符只能被用于返回值与 ? 作用的值相兼容的函数。

# 错误处理指导原则

在当有可能会导致有害状态的情况下建议使用 panic! —— 在这里,有害状态是指当一些假设、保证、协议或不可变性被打破的状态,例如无效的值、自相矛盾的值或者被传递了不存在的值 —— 外加如下几种情况:

  • 有害状态是非预期的行为,与偶尔会发生的行为相对,比如用户输入了错误格式的数据。
  • 在此之后代码的运行依赖于不处于这种有害状态,而不是在每一步都检查是否有问题。
  • 没有可行的手段来将有害状态信息编码进所使用的类型中的情况。我们会在第十七章 ” 将状态和行为编码为类型” 部分通过一个例子来说明我们的意思。

如果别人调用你的代码并传递了一个没有意义的值,最好的情况也许就是 panic! 并警告使用你的库的人他的代码中有 bug 以便他能在开发时就修复它。类似的,如果你正在调用不受你控制的外部代码,并且它返回了一个你无法修复的无效状态,那么 panic! 往往是合适的。

然而当错误预期会出现时,返回 Result 仍要比调用 panic! 更为合适。这样的例子包括解析器接收到格式错误的数据,或者 HTTP 请求返回了一个表明触发了限流的状态。在这些例子中,应该通过返回 Result 来表明失败预期是可能的,这样将有害状态向上传播,调用者就可以决定该如何处理这个问题。使用 panic! 来处理这些情况就不是最好的选择。

# 泛型、trait 和生命周期

# 泛型语法

# 语法

fn largest<T>(var: &[T]) -> T {}

struct Point<T, U> {
  x: T,
  y: U,
}

struct Point<T, U> {
  x: T,
  y: U,
}

enum Option<T> {
  Some(T),
  None,
}

enum Result<T, E> {
  Ok(T),
  Err(E),
}

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

impl<T, U> Point<T, U> {
  fn mixup<T2, U2>(self, other: Point<T2, U2>) -> Point<T, U> {
    Point {
      x: self.x,
      y: other.y,
    }
  }
}
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
31
32
33
34
35
36

# 泛型代码的性能

Rust 通过在编译时进行泛型代码的 单态化(monomorphization)来保证效率。单态化是一个通过填充编译时使用的具体类型,将通用代码转换为特定代码的过程。

编译器所做的工作正好与我们创建泛型函数的步骤相反。编译器寻找所有泛型代码被调用的位置并使用泛型代码针对具体类型生成代码。

# trait:定义共享的行为

trait 告诉 Rust 编译器某个特定类型拥有可能与其他类型共享的功能。可以通过 trait 以一种抽象的方式定义共享的行为。可以使用 trait bounds 指定泛型是任何拥有特定行为的类型。

注意:trait 类似于其他语言中的常被称为 接口(interfaces)的功能,虽然有一些不同。

pub trait Summary {
  fn summarize(&self) -> String;
}
1
2
3

trait 体中可以有多个方法:一行一个方法签名且都以分号结尾。

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}
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

实现 trait 时需要注意的一个限制是,只有当至少一个 trait 或者要实现 trait 的类型位于 crate 的本地作用域时,才能为该类型实现 trait。这个限制是被称为 相干性(coherence)的程序属性的一部分,或者更具体的说是 孤儿规则(orphan rule),其得名于不存在父类型。这条规则确保了其他人编写的代码不会破坏你代码,反之亦然。没有这条规则的话,两个 crate 可以分别对相同类型实现相同的 trait,而 Rust 将无从得知应该使用哪一个实现。

# 默认实现

有时为 trait 中的某些或全部方法提供默认的行为,而不是在每个类型的每个实现中都定义自己的行为是很有用的。这样当为某个特定类型实现 trait 时,可以选择保留或重载每个方法的默认行为。

pub trait Summary {
  fn summarize(&self) -> String {
    String::from("(Read more...)")
  }
}

impl Summary for NewsArticle {}
1
2
3
4
5
6
7

# trait 作为参数

pub fn notify(item: &impl Summary) {
  println!("Breaking news! {}", item.summarize());
}
1
2
3
# Trait Bound 语法

实际上是一种较长形式语法的语法糖

pub fn notify<T: Summary>(item: &T) {
  println!("Breaking news! {}", item.summarize());
}
1
2
3

impl Trait 很方便,适用于短小的例子。trait bound 则适用于更复杂的场景。例如,可以获取两个实现了 Summary 的参数。使用 impl Trait 的语法看起来像这样:

pub fn notify(item1: &impl Summary, item2: &impl Summary) {}
1

这适用于 item1 和 item2 允许是不同类型的情况(只要它们都实现了 Summary)。不过如果你希望强制它们都是相同类型呢?这只有在使用 trait bound 时才有可能:

pub fn notify<T: Summary>(item1: &T, item2: &T) {}
1

泛型 T 被指定为 item1 和 item2 的参数限制,如此传递给参数 item1 和 item2 值的具体类型必须一致。

# 通过 + 指定多个 trait bound
pub fn notify(item: &(impl Summary + Display)) {}
pub fn notify<T: Summary + Display>(item: &T) {}
1
2
# 通过 where 简化 trait bound

然而,使用过多的 trait bound 也有缺点。每个泛型有其自己的 trait bound,所以有多个泛型参数的函数在名称和参数列表之间会有很长的 trait bound 信息,这使得函数签名难以阅读。

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {}
1

还可以像这样使用 where 从句:

fn some_function<T, U>(t: &T, u: &U) -> i32
  where T: Display + Clone,
        U: Clone + Debug
{
  // ====skip====
}
1
2
3
4
5
6

这个函数签名就显得不那么杂乱,函数名、参数列表和返回值类型都离得很近

# 返回实现了 trait 的类型

这只适用于返回单一类型的情况

fn returns_summarizable() -> impl Summary {
  Tweet {
    username: String::from("horse_ebooks"),
    reply: false,
    retweet: false,
    ...
  }
}
1
2
3
4
5
6
7
8

返回一个只是指定了需要实现的 trait 的类型的能力在闭包和迭代器场景十分的有用

# 使用 trait bound 有条件地实现方法

通过使用带有 trait bound 的泛型参数的 impl 块,可以有条件地只为那些实现了特定 trait 的类型实现方法。

// 根据 trait bound 在泛型上有条件的实现方法

use std::fmt::Display;

struct Pair<T> {
  x: T,
  y: T,
}

impl<T> Pair<T> {
  fn new(x: T, y: T) -> Self {
    Self { x, y }
  }
}

impl<T: Display + PartialOrd> Pair<T> {
  fn cmp_display(&self) {
    if self.x >= self.y {
      println!("The largest member is x = {}", self.x);
    } else {
      println!("The largest member is y = {}", self.y);
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

也可以对任何实现了特定 trait 的类型有条件地实现 trait。对任何满足特定 trait bound 的类型实现 trait 被称为 blanket implementations,他们被广泛的用于 Rust 标准库中。

impl<T: Display> ToString for T {
  // --snip--
}

let s = 3.to_string();
1
2
3
4
5

# 生命周期与引用有效性

# 生命周期避免了悬垂引用

fn main() {
  {
    let r;
    {
      let x = 5;
      r = &x;
    }
    println!("r: {}", r); // 尝试使用离开作用域的值的引用
  }
}
1
2
3
4
5
6
7
8
9
10

如果直接编译则会报错

$ cargo run
  Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `x` does not live long enough
  --> src/main.rs:7:17
    |
7   | r = &x;
    | ^^ borrowed value does not live long enough
8   | }
    | - `x` dropped here while still borrowed
9   |
10   | println!("r: {}", r);
    | - borrow later used here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` due to previous error
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 借用检查器

Rust 编译器有一个 借用检查器(borrow checker),它比较作用域来确保所有的借用都是有效的。

// r 和 x 的生命周期注解,分别叫做 'a 和 'b
fn main() {
  {
    let r;                  // ---------+-- 'a
                            //          |
  {                         //          |
      let x = 5;            // -+-- 'b  |
      r = &x;               //  |       |
  }                         // -+       |
                            //          |
    println!("r: {}", r);   //          |
  }                         // ---------+
}
1
2
3
4
5
6
7
8
9
10
11
12
13

在编译时,Rust 比较这两个生命周期的大小,并发现 r 拥有生命周期 'a,不过它引用了一个拥有生命周期 'b 的对象。程序被拒绝编译,因为生命周期 'b 比生命周期 'a 要:被引用的对象比它的引用者存在的时间更短。

// 一个有效的引用,因为数据比引用有着更长的生命周期
fn main() {
{
  let x = 5;              // ----------+-- 'b
                          //           |
  let r = &x;             // --+-- 'a  |
                          //   |       |
  println!("r: {}", r);   //   |       |
                          // --+       |
  }                       // ----------+
}
1
2
3
4
5
6
7
8
9
10
11

# 函数中的泛型生命周期

fn main() {
  let string1 = String::from("abcd");
  let string2 = "xyz";

  let result = longest(string1.as_str(), string2);
  println!("The longest string is {}", result);
}

fn longest(x: &str, y: &str) -> &str {
  if x.len() > y.len() {
    x
  } else {
    y
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

事实上,这无法通过编译。因为 Rust 并不知道将要返回的引用是指向 x 或 y。事实上我们也不知道,因为函数体中 if 块返回一个 x 的引用而 else 块返回一个 y 的引用!借用检查器自身同样也无法确定,因为它不知道 x 和 y 的生命周期是如何与返回值的生命周期相关联的。因此,将增加泛型生命周期参数来定义引用间的关系以便借用检查器可以进行分析。

# 生命周期注解语法

生命周期注解并不改变任何引用的生命周期的长短。与当函数签名中指定了泛型类型参数后就可以接受任何类型一样,当指定了泛型生命周期后函数也能接受任何生命周期的引用。生命周期注解描述了多个引用生命周期相互的关系,而不影响其生命周期。

&i32        // 引用
&'a i32     // 带有显式生命周期的引用
&'a mut i32 // 带有显式生命周期的可变引
1
2
3

# 函数签名中的生命周期注解

fn main() {
  let string1 = String::from("abcd");
  let string2 = "xyz";

  let result = longest(string1.as_str(), string2);
  println!("The longest string is {}", result);
}

// 指定了签名中所有的引用必须有相同的生命周期 'a,便可通过编译 
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  if x.len() > y.len() {
    x
  } else {
    y
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

它的实际含义是 longest 函数返回的引用的生命周期与传入该函数的引用的生命周期的较小者一致。这些关系就是我们希望 Rust 分析代码时所使用的。

记住通过在函数签名中指定生命周期参数时,我们并没有改变任何传入值或返回值的生命周期,而是指出任何不满足这个约束条件的值都将被借用检查器拒绝。注意 longest 函数并不需要知道 x 和 y 具体会存在多久,而只需要知道有某个可以被 'a 替代的作用域将会满足这个签名。

当在函数中使用生命周期注解时,这些注解出现在函数签名中,而不存在于函数体中的任何代码中。生命周期注解成为了函数约定的一部分,非常像签名中的类型。让函数签名包含生命周期约定意味着 Rust 编译器的工作变得更简单了。如果函数注解有误或者调用方法不对,编译器错误可以更准确地指出代码和限制的部分。

fn main() {
  let string1 = String::from("long string is long");
  let result;
  // 这里稍作修改
  {
      let string2 = String::from("xyz");
      result = longest(string1.as_str(), string2.as_str());
  }
  println!("The longest string is {}", result);
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  if x.len() > y.len() {
      x
  } else {
      y
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

修改后无法通过编译,因为我们通过生命周期参数告诉 Rust 的是:longest 函数返回的引用的生命周期应该与传入参数的生命周期中较短那个保持一致。因此,借用检查器不允许修改后的代码,因为它可能会存在无效的引用。

# 深入理解生命周期

指定生命周期参数的正确方式依赖函数实现的具体功能。

fn longest<'a>(x: &'a str, y: &str) -> &'a str {
  x
}
1
2
3

参数 x 和返回值指定了生命周期参数 'a,不过没有为参数 y 指定,因为 y 的生命周期与参数 x 和返回值的生命周期没有任何关系。

当从函数返回一个引用,返回值的生命周期参数需要与一个参数的生命周期参数相匹配。如果返回的引用没有指向任何一个参数,那么唯一的可能就是它指向一个函数内部创建的值,它将会是一个悬垂引用,因为它将会在函数结束时离开作用域。

综上,生命周期语法是用于将函数的多个参数与其返回值的生命周期进行关联的。一旦他们形成了某种关联,Rust 就有了足够的信息来允许内存安全的操作并阻止会产生悬垂指针亦或是违反内存安全的行为。

# 结构体定义中的生命周期注解

// 一个存放引用的结构体,所以其定义需要生命周期注解

struct ImportantExcerpt<'a> {
  part: &'a str,
}

fn main() {
  let novel = String::from("Call me Ishmael. Some years ago...");
  let first_sentence = novel.split('.').next().expect("Could not find a '.'");
  let i = ImportantExcerpt {
    part: first_sentence,
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 生命周期省略(Lifetime Elision)

fn first_word(s: &str) -> &str {
  let bytes = s.as_bytes();
  for (i, &item) in bytes.iter().enumerate() {
    if item == b' ' {
      return &s[0..i];
    }
  }
  &s[..]
}
1
2
3
4
5
6
7
8
9

函数或方法的参数的生命周期被称为 输入生命周期(input lifetimes),而返回值的生命周期被称为 输出生命周期(output lifetimes)。

编译器采用三条规则来判断引用何时不需要明确的注解:

  1. 每一个是引用的参数都有它自己的生命周期参数。即:

    fn foo<'a>(x: &'a i32)                    // 有一个引用参数的函数有一个生命周期参数
    fn foo<'a, 'b>(x: &'a i32, y: &'b i32)    // 有两个引用参数的函数有两个不同的生命周期参数
    
    1
    2
  2. 如果只有一个输入生命周期参数,那么它被赋予所有输出生命周期参数

    fn foo<'a>(x: &'a i32)> &'a i32
    
    1
  3. 如果方法有多个输入生命周期参数并且其中一个参数是 &self&mut self,说明是个对象的方法 (method), 那么所有输出生命周期参数被赋予 self 的生命周期。

对照上诉规则:

fn longest(x: &str, y: &str) -> &str {
1

可以被视为:

fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {
1

应用了三个规则之后编译器还没有计算出返回值类型的生命周期,故而编译器会报错。

# 方法定义中的生命周期注解

(实现方法时)结构体字段的生命周期必须总是在 impl 关键字之后声明并在结构体名称之后被使用,因为这些生命周期是结构体类型的一部分。

impl<'a> ImportantExcerpt<'a> {
  fn announce_and_return_part(&self, announcement: &str) -> &str {
    println!("Attention please: {}", announcement);
    self.part
  }
}
1
2
3
4
5
6

这里有两个输入生命周期,所以 Rust 应用第一条生命周期省略规则并给予 &self 和 announcement 他们各自的生命周期。接着,因为其中一个参数是 &self,返回值类型被赋予了 &self 的生命周期,这样所有的生命周期都被计算出来了。

# 静态生命周期

这里有一种特殊的生命周期值得讨论:'static,其生命周期能够存活于整个程序期间。所有的字符串字面值都拥有 'static 生命周期:

let s: &'static str = "I have a static lifetime.";
1

这个字符串的文本被直接储存在程序的二进制文件中而这个文件总是可用的。

# 结合泛型类型参数、trait bounds 和生命周期

use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(
  x: &'a str,
  y: &'a str,
  ann: T,
) -> &'a str
where
  T: Display,
{
  // ===skip===
}
1
2
3
4
5
6
7
8
9
10
11
12

因为生命周期也是泛型,所以生命周期参数 'a 和泛型类型参数 T 都位于函数名后的同一尖括号列表中。

# 编写自动化测试

# 编写测试

# 测试函数

#[cfg(test)]
mod tests {
  #[test]
  fn exploration() {
    assert_eq!(2 + 2, 4);
  }
}
1
2
3
4
5
6
7

# 使用宏来检查结果

  • assert!:assert! 宏由标准库提供,在希望确保测试中一些条件为 true 时非常有用。需要向 assert! 宏提供一个求值为布尔值的参数。如果值是 true,assert! 什么也不做,同时测试会通过。如果值为 false,assert! 调用 panic! 宏,这会导致测试失败。
  • assert_eq!:比较两个值是相等。
  • assert_ne!:比较两个值是否不相等。

# 自定义失败信息

可以向 assert!、assert_eq! 和 assert_ne! 宏传递一个可选的失败信息参数,可以在测试失败时将自定义失败信息一同打印出来。任何在 assert! 的一个必需参数和 assert_eq! 和 assert_ne! 的两个必需参数之后指定的参数都会传递给 format! 宏,所以可以传递一个包含 {} 占位符的格式字符串和需要放入占位符的值。

assert!(
  result.contains("abaaba"),
  "Greeting did not contain name, value was `{}`",
  result
);
1
2
3
4
5

# 使用 should_panic 检查 panic

should_panic:在函数中的代码 panic 时会通过,而在其中的代码没有 panic 时失败。

#[cfg(test)]
mod tests {
  use super::*;
  
  #[test]
  #[should_panic]
  fn greater_than_100() {
    Guess::new(200);
  }
}
1
2
3
4
5
6
7
8
9
10

可持有特定的错误信息

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  #[should_panic(expected = "Guess value must be less than or equal to 100")]
  fn greater_than_100() {
    Guess::new(200);
  }
}
1
2
3
4
5
6
7
8
9
10

# 将 Result<T, E> 用于测试

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() -> Result<(), String> {
        if 2 + 2 == 4 {
            Ok(())
        } else {
            Err(String::from("two plus two does not equal four"))
        }
    }
}

1
2
3
4
5
6
7
8
9
10
11
12

在函数体中,不同于调用 assert_eq! 宏,而是在测试通过时返回 Ok (()),在测试失败时返回带有 String 的 Err。这样编写测试来返回 Result<T, E> 就可以在函数体中使用问号运算符,如此可以方便的编写任何运算符会返回 Err 成员的测试。

不能对这些使用 Result<T, E> 的测试使用 #[should_panic] 注解。为了断言一个操作返回 Err 成员,不要使用对 Result<T, E> 值使用问号表达式(?)。而是使用 assert!(value.is_err ())

# 控制测试如何运行

cargo test 在测试模式下编译代码并运行生成的测试二进制文件。(默认行为是并行的运行所有测试,并截获测试运行过程中产生的输出)

可以将一部分命令行参数传递给 cargo test,而将另外一部分传递给生成的测试二进制文件,以 -- 为分隔符。

cargo test [options] -- [options]
1

# 并行或连续的运行测试

当运行多个测试时,Rust 默认使用线程来并行运行。故而应该确保测试不能相互依赖,或依赖任何共享的状态,包括依赖共享的环境,比如当前工作目录或者环境变量。

cargo test -- --test-threads=1
1

可以用 --test-threads=1 参数控制并行数量,设置为 1 即不并行。

# 显示函数输出

默认情况下,当测试通过时,Rust 的测试库会截获打印到标准输出的所有内容。比如在测试中调用了 println! 而测试通过了,我们将不会在终端看到 println! 的输出:只会看到说明测试通过的提示行。如果测试失败了,则会看到所有标准输出和其他错误信息。

如果你希望也能看到通过的测试中打印的值,也可以在结尾加上 −−show−output 告诉 Rust 显示成功测试的输出。

cargo test -- --show-output
1

# 通过指定名字来运行部分测试

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn add_two_and_two() {
        assert_eq!(4, add_two(2));
    }
    #[test]
    fn add_three_and_two() {
        assert_eq!(5, add_two(3));
    }
    #[test]
    fn one_hundred() {
        assert_eq!(102, add_two(100));
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

如果没有传递任何参数就运行测试,所有测试都会并行运行,可通过指定名字来运行部分测试:

$ cargo test one_hundred
1

# 过滤运行多个测试

我们可以指定部分测试的名称,任何名称匹配这个名称的测试会被运行。同时注意测试所在的模块也是测试名称的一部分,所以可以通过模块名来运行一个模块中的所有测试。

$ cargo test add
  Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.61s
      Running unittests (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s
1
2
3
4
5
6
7
8
9
10

这运行了所有名字中带有 add 的测试,也过滤掉了名为 one_hundred 的测试。

# 忽略某些测试

有时一些特定的测试执行起来是非常耗费时间的,所以在大多数运行 cargo test 的时候希望能排除他们。

#[test]
fn it_works() {
    assert_eq!(2 + 2, 4);
}

#[test]
#[ignore]
fn expensive_test() {
    // 需要运行一个小时的代码
}
1
2
3
4
5
6
7
8
9
10

expensive_test 被列为 ignored,如果我们只希望运行被忽略的测试,可以使用 cargo test −− −−ignored 如果你希望不管是否忽略都要运行全部测试,可以运行 cargo test −− −−include−ignored

# 测试的组织结构

测试是一个复杂的概念,而且不同的开发者也采用不同的技术和组织。Rust 社区倾向于根据测试的两个主要分类来考虑问题:单元测试(unit tests)与 集成测试(integration tests)。单 元测试倾向于更小而更集中,在隔离的环境中一次测试一个模块,或者是测试私有接口。而集成测试对于你的库来说则完全是外部的。它们与其他外部代码一样,通过相同的方式使用你的代码,只测试公有接口而且每个测试都有可能会测试多个模块。

# 单元测试

单元测试的目的是在与其他部分隔离的环境中测试每一个单元的代码,以便于快速而准确的某个单元的代码功能是否符合预期。

单元测试与他们要测试的代码共同存放在位于 src 目录下相同的文件中。规范是在每个文件中创建包含测试函数的 tests 模块,并使用 cfg(test ) 标注模块。

  • 测试模块和 #[cfg(test)]

    测试模块的 #[cfg(test)] 注解告诉 Rust 只在执行 cargo test 时才编译和运行测试代码,而在运行 cargo build 时不这么做。这在只希望构建库的时候可以节省编译时间,并且因为它们并没有包含测试,所以能减少编译产生的文件的大小。与之对应的集成测试因为位于另一个文件夹,所以它们并不需要 #[cfg(test)] 注解。然而单元测试位于与源码相同的文件中,所以你需要使用 #[cfg(test)] 来指定他们不应该被包含进编译结果中。

    #[cfg(test)]
      mod tests {
      
      #[test]
      fn it_works() {
        assert_eq!(2 + 2, 4);
      }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8

    cfg 属性代表 configuration ,它告诉 Rust 其之后的项只应该被包含进特定配置选项中。在这个例子中,配置选项是 test,即 Rust 所提供的用于编译和运行测试的配置选项。

  • 测试私有函数:

    测试社区中一直存在关于是否应该对私有函数直接进行测试的论战,而在其他语言中想要测试私有函数是一件困难的,甚至是不可能的事。不过无论你坚持哪种测试意识形态,Rust 的私有性规则确实允许你测试私有函数。

    pub fn add_two(a: i32) -> i32 {
        internal_adder(a, 2)
    }
    fn internal_adder(a: i32, b: i32) -> i32 {
        a + b
    }
    #[cfg(test)]
    mod tests {
        use super::*;
        #[test]
        fn internal() {
            assert_eq!(4, internal_adder(2, 2));
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14

# 集成测试

在 Rust 中,集成测试对于你需要测试的库来说完全是外部的。同其他使用库的代码一样使用库文件,也就是说它们只能调用一部分库中的公有 API 。集成测试的目的是测试库的多个部分能否一起正常工作。一些单独能正确运行的代码单元集成在一起也可能会出现问题,所以集成测试的覆盖率也是很重要的。为了创建集成测试,你需要先创建一个 tests 目录。

  • tests 目录:为了编写集成测试,需要在项目根目录创建一个 tests 目录,与 src 同级。Cargo 知道如何去寻找这个目录中的集成测试文件。接着可以随意在这个目录中创建任意多的测试文件,Cargo 会将每一个文件当作单独的 crate 来编译。

    use adder;
    
    #[test]
    fn it_adds_two() {
        assert_eq!(4, adder::add_two(2));
    }
    
    1
    2
    3
    4
    5
    6

    与单元测试不同,我们需要在文件顶部添加 use adder。这是因为每一个 tests 目录中的测试文件都是完全独立的 crate,所以需要在每一个文件中导入库。并不需要将任何代码标注为 #[cfg(test)]。tests 文件夹在 Cargo 中是一个特殊的文件夹,Cargo 只会在运行 cargo test 时编译这个目录中的文件。

    仍然可以通过指定测试函数的名称作为 cargo test 的参数来运行特定集成测试。也可以使用 cargo test 的 −−test 后跟文件的名称来运行某个特定集成测试文件中的所有测试。

  • 集成测试中的子模块

    tests 目录中的子目录不会被作为单独的 crate 编译或作为一个测试结果部分出现在测试输出中。

    即创建通用的帮助函数需要如下结构:

    tests
      ├───── common
      │        └───── mod.rs
      └───── integration_test.rs
    
    1
    2
    3
    4
    // tests∕common∕mod.rs 
    pub fn setup() {
      // setup code specific to your library's tests would go here
    }
    
    1
    2
    3
    4
    // tests∕integration_test.rs
    use adder;
    mod common;
    
    #[test]
    fn it_adds_two() {
        common::setup();
        assert_eq!(4, adder::add_two(2));
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
  • 二进制 crate 的集成测试

    如果项目是二进制 crate 并且只包含 src∕main.rs 而没有 src∕lib.rs,这样就不可能在 tests 目录创建集成测试并使用 extern crate 导入 src∕main.rs 中定义的函数。只有库 crate 才会向其他 crate 暴露了可供调用和使用的函数;二进制 crate 只意在单独运行。

# Rust 中的函数式语言功能:迭代器与闭包

  • 闭包(Closures),一个可以储存在变量里的类似函数的结构
  • 迭代器(Iterators),一种处理元素序列的方式

# 闭包:可以捕获环境的匿名函数

Rust 的 闭包(closures)是可以保存进变量或作为参数传递给其他函数的匿名函数。可以在一个地方创建闭包,然后在不同的上下文中执行闭包运算。不同于函数,闭包允许捕获调用者作用域中的值。

在 C++ 中也叫做 lambda 表达式或者 lambda

# 使用闭包创建行为的抽象

fn generate_workout(intensity: u32, random_number: u32) {
  let expensive_closure = |num| {
    println!("calculating slowly...");
    thread::sleep(Duration::from_secs(2));
    num
  };
}
1
2
3
4
5
6
7

闭包定义是 expensive_closure 赋值的 = 之后的部分。闭包的定义以一对竖线(|)开始,在竖线中指定闭包的参数。这个闭包有一个参数 num;如果有多于一个参数,可以使用逗号分隔,比如 |param1, param2|

参数之后是存放闭包体的大括号 —— 如果闭包体只有一行则大括号是可以省略的。大括号之后闭包的结尾,需要用于 let 语句的分号。因为闭包体的最后一行没有分号(正如函数体一样),所以闭包体(num),最后一行的返回值作为调用闭包时的返回值。

注意这个 let 语句意味着 expensive_closure 包含一个匿名函数的 定义,不是调用匿名函数的 返回值

定义完成以后便可像正常函数那样调用。

# 闭包类型推断和注解

闭包不要求像 fn 函数那样在参数和返回值上注明类型。函数中需要类型注解是因为他们是暴露给用户的显式接口的一部分。严格的定义这些接口对于保证所有人都认同函数使用和返回值的类型来说是很重要的。但是闭包并不用于这样暴露在外的接口:他们储存在变量中并被使用,不用命名他们或暴露给库的用户调用。

闭包通常很短,并只关联于小范围的上下文而非任意情境。在这些有限制的上下文中,编译器能可靠的推断参数和返回值的类型,类似于它是如何能够推断大部分变量的类型一样。

强制在这些小的匿名函数中注明类型是很恼人的,并且与编译器已知的信息存在大量的重复。

果相比严格的必要性你更希望增加明确性并变得更啰嗦,可以选择增加类型注解:

let expensive_closure = |num: u32| -> u32 {
  println!("calculating slowly...");
  thread::sleep(Duration::from_secs(2));
  num
};
1
2
3
4
5

闭包和函数的语法非常相似:

fn  add_one_v1 = (x: u32) -> u32 { x + 1 };
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x|             { x + 1 };
let add_one_v4 = |x|               x + 1 ;
1
2
3
4

调用闭包要求 add_one_v3 和 add_one_v4 必须更够编译因为会根据其用途推断其类型。

# 使用带有泛型和 Fn trait 的闭包

可以创建一个存放闭包和调用闭包结果的结构体。该结构体只会在需要结果时执行闭包,并会缓存结果值,这样余下的代码就不必再负责保存结果并可以复用该值。你可能见过这种模式被称 memoization 或 lazy evaluation (惰性求值)

为了让结构体存放闭包,我们需要指定闭包的类型,因为结构体定义需要知道其每一个字段的类型。每一个闭包实例有其自己独有的匿名类型:也就是说,即便两个闭包有着相同的签名,他们的类型仍然可以被认为是不同。为了定义使用闭包的结构体、枚举或函数参数,需要使用泛型和 trait bound。

Fn 系列 trait 由标准库提供。所有的闭包都实现了 trait Fn、FnMut 或 FnOnce 中的一个。为了满足 Fn trait bound 需要增加了代表闭包所必须的参数和返回值类型的类型。

// 定义一个 Cacher 结构体来在 calculation 中存放闭包并在 value 中存放 Option 值
struct Cacher<T>
where
  T: Fn(u32) -> u32,
{
  calculation: T,
  value: Option<u32>,
}
1
2
3
4
5
6
7
8

T 的 trait bound 指定了 T 是一个使用 Fn 的闭包。任何我们希望储存到 Cacher 实例的 calculation 字段的闭包必须有一个 u32 参数(由 Fn 之后的括号的内容指定)并必须返回一个 u32(由 −> 之后的内容)。

注意:函数也都实现了这三个 Fn trait。如果不需要捕获环境中的值,则可以使用实现了 Fn trait 的函数而不是闭包。

字段 value 是 Option<u32> 类型的。在执行闭包之前,value 将是 None。如果使用 Cacher 的代码请求闭包的结果,这时会执行闭包并将结果储存在 value 字段的 Some 成员中。接着如果代码再次请求闭包的结果,这时不再执行闭包,而是会返回存放在 Some 成员中的结果。

// Cacher 的缓存逻辑
impl<T> Cacher<T>
where
    T: Fn(u32) -> u32,
{
    fn new(calculation: T) -> Cacher<T> {
        Cacher {
            calculation,
            value: None,
        }
    }
    fn value(&mut self, arg: u32) -> u32 {
        match self.value {
            Some(v) => v,
            None => {
                let v = (self.calculation)(arg);
                self.value = Some(v);
                v
            }
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

当调用代码需要闭包的执行结果时,不同于直接调用闭包,它会调用 value 方法。这个方法会检查 self .value 是否已经有了一个 Some 的结果值;如果有,它返回 Some 中的值并不会再次执行闭包。

不同于直接将闭包保存进一个变量,我们保存一个新的 Cacher 实例来存放闭包。接着,在每一个需要结果的地方,调用 Cacher 实例的 value 方法。可以调用 value 方法任意多次,或者一次也不调用,而慢计算最多只会运行一次。

# Cacher 实现的限制

值缓存是一种更加广泛的实用行为,我们可能希望在代码中的其他闭包中也使用他们。然而,目前 Cacher 的实现存在两个小问题,这使得在不同上下文中复用变得很困难。

第一个问题是 Cacher 实例假设对于 value 方法的任何 arg 参数值总是会返回相同的值。也就是说,这个 Cacher 的测试会失败:

#[cfg(test)]

mod tests {
    use super::*;

    #[test]
    fn call_with_different_values() {
        let mut c = Cacher::new(|a| a);
        let v1 = c.value(1);     // return 1
        let v2 = c.value(2);     // return 1
        assert_eq!(v2, 2);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

尝试修改 Cacher 存放一个哈希 map 而不是单独一个值。哈希 map 的 key 将是传递进来的 arg 值,而 value 则是对应 key 调用闭包的结果值。相比之前检查 self .value 直接是 Some 还是 None 值,现在 value 函数会在哈希 map 中寻找 arg,如果找到的话就返回其对应的值。如果不存在,Cacher 会调用闭包并将结果值保存在哈希 map 对应 arg 值的位置。

当前 Cacher 实现的第二个问题是它的应用被限制为只接受获取一个 u32 值并返回一个 u32 值的闭包。可以尝试引入更多泛型参数来增加 Cacher 功能的灵活性。

# 闭包会捕获其环境

闭包还有另一个函数所没有的功能:他们可以捕获其环境并访问其被定义的作用域的变量。

fn main() {
    let x = 4;
    let equal_to_x = |z| z == x;
    let y = 4;
    assert!(equal_to_x(y));
}
1
2
3
4
5
6

这里,即便 x 并不是 equal_to_x 的一个参数,equal_to_x 闭包也被允许使用变量 x,因为它与 equal_to_x 定义于相同的作用域。

当闭包从环境中捕获一个值,闭包会在闭包体中储存这个值以供使用。这会使用内存并产生额外的开销,在更一般的场景中,当我们不需要闭包来捕获环境时,我们不希望产生这些开销。因为函数从未允许捕获环境,定义和使用函数也就从不会有这些额外开销。

闭包可以通过三种方式捕获其环境,他们直接对应函数的三种获取参数的方式:获取所有权,可变借用和不可变借用。这三种捕获值的方式被编码为如下三个 Fn trait:

  1. FnOnce 消费从周围作用域捕获的变量,闭包周围的作用域被称为其 环境,environment。为了使用捕获到的变量,闭包必须获取其所有权并在定义闭包时将其移动进闭包。其名称的 Once 部分代表了闭包不能多次获取相同变量的所有权的事实,所以它只能被调用一次
  2. FnMut 获取可变的借用值所以可以改变其环境
  3. Fn 从其环境获取不可变的借用值

如果希望强制闭包获取其使用的环境值的所有权,可以在参数列表前使用 move 关键字。这个技巧在将闭包传递给新线程以便将数据移动到新线程中时最为实用。

let equal_to_x = move |z| z == x;
1

注意:即使其捕获的值已经被移动了,move 闭包仍需要实现 Fn 或 FnMut。这是因为闭包所实现的trait 是由闭包所捕获了什么值而不是如何捕获所决定的。而 move 关键字仅代表了后者。

大部分需要指定一个 Fn 系列 trait bound 的时候,可以从 Fn 开始,而编译器会根据闭包体中的情况告诉你是否需要 FnMut 或 FnOnce。

# 使用迭代器处理元素序列

迭代器模式允许你对一个序列的项进行某些处理。迭代器(iterator)负责遍历序列中的每一项和决定序列何时结束的逻辑。当使用迭代器时,我们无需重新实现这些逻辑。

迭代器模式允许你对一个序列的项进行某些处理。迭代器(iterator)负责遍历序列中的每一项和决定序列何时结束的逻辑。当使用迭代器时,我们无需重新实现这些逻辑。

fn main() {
    let v1 = vec![1, 2, 3];
    let v1_iter = v1.iter();  // 创建一个迭代器

    for val in v1_iter {      // 调用
      println!("Got: {}", val);
    }
}
1
2
3
4
5
6
7
8

在标准库中没有提供迭代器的语言中,我们可能会使用一个从 0 开始的索引变量,使用这个变量索引 vector 中的值,并循环增加其值直到达到 vector 的元素数量。

迭代器为我们处理了所有这些逻辑,这减少了重复代码并消除了潜在的混乱。另外,迭代器的实现方式提供了对多种不同的序列使用相同逻辑的灵活性,而不仅仅是像 vector 这样可索引的数据结构。

# Iterator trait 和 next 方法

迭代器都实现了一个叫做 Iterator 的定义于标准库的 trait。这个 trait 的定义看起来像这样:

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
    // 此处省略了方法的默认实现
}
1
2
3
4
5
6

type Item 和 Self :: Item,他们定义了 trait 的 关联类型(as−sociated type)。Iterator trait 要求同时定义一个 Item 类型,这个 Item 类型被用作 next 方法的返回值类型。换句话说,Item 类型将是迭代器返回元素的类型。

next 是 Iterator 实现者被要求定义的唯一方法。next 一次返回迭代器中的一个项,封装在 Some 中,当迭代器结束时,它返回 None。

注意 v1_iter 需要是可变的:在迭代器上调用 next 方法改变了迭代器中用来记录序列位置的状态。使用 for 循环时无需使 v1_iter 可变因为 for 循环会获取 v1_iter 的所有权并在后台使 v1_iter 改变。

另外需要注意到从 next 调用中得到的值是 vector 的不可变引用。 iter 方法生成一个不可变引用的迭代器。如果我们需要一个获取 v1 所有权并返回拥有所有权的迭代器,则可以调用 into_iter 而不是 iter 。类似的,如果我们希望迭代可变引用,则可以调用 iter_mut 而不是 iter 。

# 消费迭代器的方法

Iterator trait 有一系列不同的由标准库提供默认实现的方法;你可以在 Iterator trait 的标准库 API 文档中找到所有这些方法。一些方法在其定义中调用了 next 方法,这也就是为什么在实现 Iterator trait 时要求实现 next 方法的原因。

这些调用 next 方法的方法被称为 消费适配器(consuming adaptors),因为调用他们会消耗迭代器。

# 产生其他迭代器的方法

Iterator trait 中定义了另一类方法,被称为 迭代器适配器(iterator adaptors),他们允许我们将当前迭代器变为不同类型的迭代器。可以链式调用多个迭代器适配器。不过因为所有的迭代器都是惰性的,必须调用一个消费适配器方法以便获取迭代器适配器调用的结果。

fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];
    v1.iter().map(|x| x + 1);   // 无事发生,因为迭代器适配器是惰性的
                                // 所指定的闭包从未被调用过
}
1
2
3
4
5

修改后

fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];
    let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();
}
1
2
3
4

# 使用闭包获取环境

迭代器的 filter 方法获取一个使用迭代器的每一个项并返回布尔值的闭包。如果闭包返回 true,其值将会包含在 filter 提供的新迭代器中。如果闭包返回 false,其值不会包含在结果迭代器中。

#[derive(PartialEq, Debug)]
struct Shoe {
    size: u32,
    style: String,
}

fn shoes_in_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> {
  // into_iter 获取 vector 所有权的迭代器
    shoes.into_iter().filter(|s| s.size == shoe_size).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn filters_by_size() {
        let shoes = vec![
            Shoe {
                size: 10,
                style: String::from("sneaker"),
            },
            Shoe {
                size: 13,
                style: String::from("sandal"),
            },
            Shoe {
                size: 10,
                style: String::from("boot"),
            },
        ];

        let in_my_size = shoes_in_size(shoes, 10);

        assert_eq!(
            in_my_size,
            vec![
                Shoe {
                    size: 10,
                    style: String::from("sneaker")
                },
                Shoe {
                    size: 10,
                    style: String::from("boot")
                },
            ]
        );
    }
}
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

# 实现 Iterator trait 来创建自定义迭代器

struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        if self.count < 5 {
            self.count += 1;

            Some(self.count)
        } else {
            None
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn calling_next_directly() {
        let mut counter = Counter::new();
        assert_eq!(counter.next(), Some(1));
        assert_eq!(counter.next(), Some(2));
        assert_eq!(counter.next(), Some(3));
        assert_eq!(counter.next(), Some(4));
        assert_eq!(counter.next(), Some(5));
        assert_eq!(counter.next(), None);
    }

    // 通过定义 next 方法实现 Iterator trait
    // 我们现在就可以使用任何标准库定义的拥有默认实现的 Iterator

trait 方法了,因为他们都使用了 next 方法的功能。
    #[test]
    fn using_other_iterator_trait_methods() {
        let sum: u32 = Counter::new()
            .zip(Counter::new().skip(1))
            .map(|(a, b)| a * b)
            .filter(|x| x % 3 == 0)
            .sum();
        assert_eq!(18, sum);
    }
}
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

# 性能对比:循环 VS 迭代器

迭代器,作为一个高级的抽象,被编译成了与手写的底层代码大体一致性能代码。迭代器是 Rust 的 零成本抽象(zero−cost abstractions)之一,它意味着抽象并不会引入运行时开销,它与本贾尼・斯特劳斯特卢普(C++ 的设计和实现者)在 ”Foundations of C++”(2012)中所定义的 零开销(zero−overhead)如出一辙:

In general, C++ implementations obey the zero-overhead principle: What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any better.

-- Bjarne Stroustrup ”Foundations of C++”

从整体来说,C++ 的实现遵循了零开销原则:你不需要的,无需为他们买单。更有甚者的是:你需要的时候,也不可能找到其他更好的代码了。

-- 本贾尼・斯特劳斯特卢普 ”Foundations of C++”

let buffer: &mut [i32];
let coefficients: [i64; 12];
let qlp_shift: i16;

for i in 12..buffer.len() {
  let prediction = coefficients.iter()
                               .zip(&buffer[i - 12..i])
                               .map(|(&c, &s)| c * s as i64)
                               .sum::<i64>() >> qlp_shift;
  let delta = buffer[i];
  buffer[i] = prediction as i32 + delta;
}
1
2
3
4
5
6
7
8
9
10
11
12

上方的代码会展开循环(一种优化手段),所有的系数都被储存在了寄存器中,这意味着访问他们非常快。这里也没有运行时数组访问边界检查。所有这些 Rust 能够提供的优化使得结果代码极为高效。

# 进一步认识 Cargo 和 Crates.io

# 采用发布配置自定义构建

在 Rust 中的 发布配置(release profiles)是预定义的、可定制的带有不同选项的配置,他们允许程序员更灵活地控制代码编译的多种选项。每一个配置都彼此相互独立。

Cargo 有两个主要的配置:

  • 运行 cargo build 时采用的 dev 配置,被定义为开发时的好的默认配置
  • 运行 cargo build −−release 的 release 配置,有着良好的发布构建的默认配置

当项目的 Cargo.toml 文件中没有任何 [profile.*] 部分的时候,Cargo 会对每一个配置都采用默认设置。通过增加任何希望定制的配置对应的 [profile.*] 部分,我们可以选择覆盖任意默认设置的子集。例如,如下是 dev 和 release 配置的 opt−level 设置的默认值:

[profile.dev]
opt-level = 0

[profile.release]
opt-level = 3
1
2
3
4
5

opt−level 设置控制 Rust 会对代码进行何种程度的优化。这个配置的值从 0 到 3。越高的优化级别需要更多的时间编译,所以如果你在进行开发并经常编译,可能会希望在牺牲一些代码性能的情况下编译得快一些。这就是为什么 dev 的 opt−level 默认为 0。当你准备发布时,花费更多时间在编译上则更好。只需要在发布模式编译一次,而编译出来的程序则会运行很多次,所以发布模式用更长的编译时间换取运行更快的代码。这正是为什么 release 配置的 opt−level 默认为 3。

对于每个配置的设置和其默认值的完整列表,请查看 Cargo 的文档 (opens new window)

# 将 crate 发布到 Crates.io

可以通过发布自己的包来向他人分享代码。crates.io 用来分发包的源代码,所以它主要托管开源代码。

# 编写有用的文档注释

准确的包文档有助于其他用户理解如何以及何时使用他们,所以花一些时间编写文档是值得的。Rust 也有特定的用于文档的注释类型,通常被称为 文档注释(documentation comments),他们会生成 HTML 文档。这些 HTML 展示公有 API 文档注释的内容,他们意在让对库感兴趣的程序员理解如何 使用这个 crate,而不是它是如何被实现的。

文档注释使用三斜杠 ∕∕∕ 而不是两斜杆以支持 Markdown 注解来格式化文本。文档注释就位于需要文档的项的之前。

/// Adds one to the number given.
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
  x + 1
}
1
2
3
4
5
6
7
8
9
10
11
12
13

可以运行 cargo doc 来生成这个文档注释的 HTML 文档。这个命令运行由 Rust 分发的工具 rustdoc 并将生成的 HTML 文档放入 target∕doc 目录。

为了方便起见,运行 cargo doc −−open 会构建当前 crate 文档(同时还有所有 crate 依赖的文档)的HTML 并在浏览器中打开。导航到 add_one 函数将会发现文档注释的文本是如何渲染的,如图所示:

文档注释 HTML

# 常用(文档注释)部分
  • Panics:这个函数可能会 panic! 的场景。并不希望程序崩溃的函数调用者应该确保他们不会在这些情况下调用此函数。
  • Errors:如果这个函数返回 Result,此部分描述可能会出现何种错误以及什么情况会造成这些错误,这有助于调用者编写代码来采用不同的方式处理不同的错误。
  • Safety:如果这个函数使用 unsafe 代码(这会在第十九章讨论),这一部分应该会涉及到期望函数调用者支持的确保 unsafe 块中代码正常工作的不变条件(invariants)。
# 文档注释作为测试

在文档注释中增加示例代码块是一个清楚的表明如何使用库的方法,这么做还有一个额外的好处:cargo test 也会像测试那样运行文档中的示例代码!没有什么比有例子的文档更好的了,但最糟糕的莫过于写完文档后改动了代码,而导致例子不能正常工作。

# 注释包含项的结构

还有另一种风格的文档注释, ∕∕! ,这为包含注释的项,而不是位于注释之后的项增加文档。这通常用于 crate 根文件(通常是 src∕lib.rs)或模块的根文件为 crate 或模块整体提供文档。

//! # My Crate
//!
//! `my_crate` is a collection of utilities to make performing certain
//! calculations more convenient.
/// Adds one to the number given.
// --snip--
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
    x + 1
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

注意 ∕∕! 的最后一行之后没有任何代码。因为他们以 ∕∕! 开头而不是 ∕∕∕ ,这是属于包含此注释的项而不是注释之后项的文档。在这个情况中,包含这个注释的项是 src∕lib.rs 文件,也就是 crate 根文件。这些注释描述了整个 crate。

如果运行 cargo doc −−open,将会发现这些注释显示在 my_crate 文档的首页,位于 crate 中公有项列表之上,位于项之中的文档注释对于描述 crate 和模块特别有用。使用他们描述其容器整体的目的来帮助 crate 用户理解你的代码组织。

# 使用 pub use 导出合适的公有 API

公有 API 的结构是你发布 crate 时主要需要考虑的。crate 用户没有你那么熟悉其结构,并且如果模块层级过大他们可能会难以找到所需的部分。

好消息是,即使文件结构对于用户来说 不是很方便,你也无需重新安排内部组织:你可以选择使用 pub use 重导出(re-export)项来使公有结构不同于私有结构。重导出获取位于一个位置的公有项并将其公开到另一个位置,好像它就定义在这个新位置一样。

//! # Art
//!
//! A library for modeling artistic concepts.

// pub use 语句重导出项
pub use self::kinds::PrimaryColor;
pub use self::kinds::SecondaryColor;
pub use self::utils::mix;

pub mod kinds {
    /// The primary colors according to the RYB color model.
    pub enum PrimaryColor {
        Red,
        Yellow,
        Blue,
    }

    /// The secondary colors according to the RYB color model.
    pub enum SecondaryColor {
        Orange,
        Green,
        Purple,
    }
}

pub mod utils {
    use crate::kinds::*;

    /// Combines two primary colors in equal amounts to create
    /// a secondary color.
    pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
        // --snip--
        unimplemented!();
    }
}
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
31
32
33
34
35
// 调用
use art::mix;
use art::PrimaryColor;

fn main() {
    // --snip--
    let red = PrimaryColor::Red;
    let yellow = PrimaryColor::Yellow;
    mix(red, yellow);
}
1
2
3
4
5
6
7
8
9
10

对于有很多嵌套模块的情况,使用 pub use 将类型重导出到顶级结构对于使用 crate 的人来说将会是大为不同的体验。创建一个有用的公有 API 结构更像是一门艺术而非科学,你可以反复检视他们来找出最适合用户的 API。pub use 提供了解耦组织 crate 内部结构和与终端用户体现的灵活性。

# Cargo 工作空间

# 创建工作空间

工作空间是一系列共享同样的 Cargo.lock 和输出目录的包。让我们使用工作空间创建一个项目 —— 这里采用常见的代码以便可以关注工作空间的结构。有多种组织工作空间的方式;我们将展示一个常用方法。我们的工作空间有一个二进制项目和两个库。二进制项目会提供主要功能,并会依赖另两个库。一个库会提供 add_one 方法而第二个会提供 add_two 方法。这三个 crate 将会是相同工作空间的一部分。

让我们以新建工作空间目录开始:

$ mkdir add
$ cd add
1
2

接着在 add 目录中,创建 Cargo.toml 文件。这个 Cargo.toml 文件配置了整个工作空间。它不会包含 [package] 或其他我们在 Cargo.toml 中见过的元信息。相反,它以 [workspace] 部分作为开始,并通过指定 adder 的路径来为工作空间增加成员,如下会加入二进制 crate:

[workspace]

members = [
  "adder",
]
1
2
3
4
5

接下来,在 add 目录运行 cargo new 新建 adder 二进制 crate:

$ cargo new adder
    Created binary (application) `adder` package
1
2

到此为止,可以运行 cargo build 来构建工作空间。add 目录中的文件应该看起来像这样:

add
 ├── Cargo.lock
 ├── Cargo.toml
 ├── adder
 |     ├── Cargo.toml
 |     └── src
 |          └── main.rs
 └── target
1
2
3
4
5
6
7
8

工作空间在顶级目录有一个 target 目录;adder 并没有自己的 target 目录。即使进入 adder 目录运行 cargo build,构建结果也位于 add∕target 而不是 add∕adder∕target。工作空间中的 crate 之间相互依赖。如果每个 crate 有其自己的 target 目录,为了在自己的 target 目录中生成构建结果,工作空间中的每一个 crate 都不得不相互重新编译其他 crate。通过共享一个 target 目录,工作空间可以避免其他 crate 多余的重复构建。

# 在工作空间中创建第二个包

接下来,让我们在工作空间中指定另一个成员 crate。这个 crate 位于 add−one 目录中,所以修改顶级 Cargo.toml 为也包含 add−one 路径:

[workspace]

members = [
  "adder",
  "add_one",
]
1
2
3
4
5
6

接着添加一个库

$ cargo new add_one --lib
    Created library `add_one` package
1
2

现在 add 目录应该有如下目录和文件:

add
 ├── Cargo.lock
 ├── Cargo.toml
 ├── add_one
 |     ├── Cargo.toml
 |     └── src
 |          └── lib.rs
 ├── adder
 |     ├── Cargo.toml
 |     └── src
 |          └── main.rs
 └── target
1
2
3
4
5
6
7
8
9
10
11
12

add−one∕src∕lib.rs 文件中,增加一个 add_one 函数:

// add-one∕src∕lib.rs
pub fn add_one(x: i32) -> i32 {
  x + 1
}
1
2
3
4

现在工作空间中有了一个库 crate,让 adder 依赖库 crate add−one。首先需要在 adder∕Cargo.toml 文件中增加 add−one 作为路径依赖:

[dependencies]

add_one = { path = "../add_one" }
1
2
3

cargo 并不假定工作空间中的 Crates 会相互依赖,所以需要明确表明工作空间中 crate 的依赖关系。

接下来,在 adder crate 中使用 add−one crate 的函数 add_one。修改 adder∕src∕main.rs

// adder∕src∕main.rs

use add_one;   // 将新 add−one 库 crate 引入作用域

fn main() {
    let num = 10;

    println!(
        "Hello, world! {} plus one is {}!",
        num,
        add_one::add_one(num)
    );
}
1
2
3
4
5
6
7
8
9
10
11
12
13

add 目录中运行 cargo build 来构建工作空间:

$ cargo build
  Compiling add_one v0.1.0 (file:///projects/add/add_one)
  Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished dev [unoptimized + debuginfo] target(s) in 0.68s
1
2
3
4

为了在顶层 add 目录运行二进制 crate,可以通过 −p 参数和包名称来运行 cargo run 指定工作空间中我们希望使用的包:

$ cargo run -p adder
  Finished dev [unoptimized + debuginfo] target(s) in 0.0s
    Running `target/debug/adder`
Hello, world! 10 plus one is 11!
1
2
3
4

这会运行 adder∕src∕main.rs 中的代码,其依赖 add−one crate

# 在工作空间中依赖外部包

还需注意的是工作空间只在根目录有一个 Cargo.lock。这确保了所有的 crate 都使用完全相同版本的依赖。

如果在 Cargo.tomladd−one∕Cargo.toml 中都增加 rand crate,则 Cargo 会将其都解析为同一版本并记录到唯一的 Cargo.lock 中。使得工作空间中的所有 crate 都使用相同的依赖意味着其中的 crate 都是相互兼容的。让我们在 add−one∕Cargo.toml 中的 [dependencies] 部分增加 rand crate 以便能够在 add−one crate 中使用 rand crate:

[dependencies]

rand = "0.8.3"
1
2
3

现在就可以在 add−one∕src∕lib.rs 中增加 use rand; 了,接着在 add 目录运行 cargo build 构建整个工作空间就会引入并编译 rand crate:

$ cargo build
  Updating crates.io index
  Downloaded rand v0.8.3
  --snip--
  Compiling rand v0.8.3
  Compiling add_one v0.1.0 (file:///projects/add/add_one)

warning: unused import: `rand`
 --> add_one/src/lib.rs:1:5
  |
1 | use rand;
  |     ^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

warning: 1 warning emitted
  Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished dev [unoptimized + debuginfo] target(s) in 10.18s
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

现在顶级的 Cargo.lock 包含了 add−onerand 依赖的信息。然而,即使 rand 被用于工作空间的某处,也不能在其他 crate 中使用它,除非也在他们的 Cargo.toml 中加入 rand。例如,如果在顶级的 adder crate 的 adder∕src∕main.rs 中增加 use rand;,会得到一个错误:

$ cargo build
  --snip--
  Compiling adder v0.1.0 (file:///projects/add/adder)
  
error[E0432]: unresolved import `rand`
  --> adder/src/main.rs:2:5
  |
2 | use rand;
  |     ^^^^ no external crate `rand`
1
2
3
4
5
6
7
8
9

为了修复这个错误,修改顶级 adder crate 的 Cargo.toml 来表明 rand 也是这个 crate 的依赖。构建 adder crate 会将 rand 加入到 Cargo.lockadder 的依赖列表中,但是这并不会下载 rand 的额外拷贝。Cargo 确保了工作空间中任何使用 rand 的 crate 都采用相同的版本。在整个工作空间中使用相同版本的 rand 节省了空间,因为这样就无需多个拷贝并确保了工作空间中的 crate 将是相互兼容的。

# 为工作空间增加测试

作为另一个提升,让我们为 add_one crate 中的 add_one::add_one 函数增加一个测试:

// add-one∕src∕lib.rs

pub fn add_one(x: i32) -> i32 {
    x + 1
}

#[cfg(test)]
mod tests {
    use super::*;
}

#[test]
fn it_works() {
    assert_eq!(3, add_one(2));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

在顶级 add 目录运行 cargo test

$ cargo test
  Compiling add_one v0.1.0 (file:///projects/add/add_one)
  Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.27s

      Running target/debug/deps/add_one-f0253159197f7841
running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 14.3.

      Running target/debug/deps/adder-49979ff40686fa8e
running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 14.3.

  Doc-tests add_one
running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 14.3.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

在像这样的工作空间结构中运行 cargo test 会运行工作空间中所有 crate 的测试。也可以选择运行工作空间中特定 crate 的测试,通过在根目录使用 −p 参数并指定希望测试的 crate 名称。

如果你选择向 crates.io 发布工作空间中的 crate,每一个工作空间中的 crate 需要单独发布。cargo publish 命令并没有 −−all 或者 −p 参数,所以必须进入每一个 crate 的目录并运行 cargo publish 来发布工作空间中的每一个 crate。

# 从 Crates.io 安装二进制文件

cargo install 命令用于在本地安装和使用二进制 crate。它并不打算替换系统中的包;它意在作为一个方便 Rust 开发者们安装其他人已经在 crates.io 上共享的工具的手段。只有拥有二进制目标文件的包能够被安装。二进制目标文件是在 crate 有 src∕main.rs 或者其他指定为二进制文件时所创建的可执行程序,这不同于自身不能执行但适合包含在其他程序中的库目标文件。通常 crate 的 README 文件中有该crate 是库、二进制目标还是两者都是的信息。

所有来自 cargo install 的二进制文件都安装到 Rust 安装根目录的 bin 文件夹中。如果你使用 rustup.rs 安装的 Rust 且没有自定义任何配置,这将是 $HOME∕.cargo∕bin。

# Cargo 自定义扩展命令

Cargo 的设计使得开发者可以通过新的子命令来对 Cargo 进行扩展,而无需修改 Cargo 本身。如果$PATH 中有类似 cargo−something 的二进制文件,就可以通过 cargo something 来像 Cargo 子命令一样运行它。像这样的自定义命令也可以运行 cargo −−list 来展示出来。

Last Updated: 2023-10-29T08:26:04.000Z