rust编程入门视频教程 (Rust编程入门教程)

Rust 的快速介绍

Rust越来越受欢迎,但它有一个陡峭的学习曲线。通过介绍 Rust 的基本知识,以及学习如何操作一系列数据类型和变量,我们将能够使用相同代码行以与动态类型语言相同的方式编写简单的程序

本章的目标是介绍 Rust 和通用动态语言之间的主要区别,并让您快速了解如何使用 Rust。

在本章中,我们将介绍

  • 为什么是rust
  • 类型和变量
  • 控制
  • 构建结构体
  • 元编程

一旦我们介绍了本章中的主要概念,您将能够在 Rust 中编写将运行的基本程序。您还可以调试程序并理解 Rust 编译器抛出的错误消息。因此,您将拥有在 Rust 中提高生产力的基础。您还可以继续在多个文件中构建 Rust 代码。

技术要求

在本章中,我们只需要访问互联网,因为我们将使用在线 Rust 游乐场来体验rust代码。提供的代码示例可以在 在线 Rust 游乐场中运行

为什么 Rust 是革命性的?

对于编程,通常在速度和资源以及开发速度和安全性之间进行权衡。C/C++ 等低级语言可以通过快速代码执行和最少的资源消耗为开发人员提供对计算机的细粒度控制。但是,这不是免费的。手动内存管理可能会引入错误和安全漏洞。一个简单的例子是缓冲区溢出攻击。当程序员未分配足够的内存时,会发生这种情况。例如,如果缓冲区的大小只有 15 个字节,但却发送了 20 个字节,则多余的 5 个字节可能会写出边界。攻击者可通过传入超过缓冲区可以处理的字节数来利用此漏洞。这可能会覆盖包含具有自己的代码的可执行代码的区域。还有其他方法可以利用没有正确管理内存的程序。

除了漏洞增加之外,使用低级语言解决问题需要更多的代码和时间。因此,C++Web框架不会在Web开发中占据很大的份额。相反,使用高级语言(如Python,Ruby和JavaScript)通常是有意义的。使用这些语言通常会导致开发人员安全快速地解决问题。

但是,必须注意的是,这种内存安全性是有代价的。这些高级语言通常跟踪所有定义的变量及其对内存地址的引用。当没有更多指向内存地址的变量时,该内存地址中的数据将被删除。此过程称为垃圾回收,这会消耗额外的资源和时间,因为必须停止程序以清理变量。

使用 Rust,可以确保内存安全,而无需昂贵的垃圾收集过程。Rust 通过在编译时使用借用检查器检查一组所有权规则来确保内存安全。这些规则会是在下一节中提到。正因为如此,Rust 可以使用真正高性能的代码快速、安全地解决问题,从而打破速度/安全性的权衡。

内存安全

内存安全是具有始终指向有效内存的内存指针的程序的属性。

随着更多的数据处理、流量和复杂任务被提升到 Web 堆栈中,Rust 及其越来越多的 Web 框架和库现在已成为 Web 开发的可行选择。这在 Rust 的 Web 空间中带来了一些真正惊人的结果。2020 年,Shimul Chowdhury 针对具有相同规格但不同语言和框架的服务器进行了一系列测试。结果如下图所示

rust编程入门视频教程,Rust编程入门教程

在上图中,我们可以看到语言和框架存在一些变化。 但是,我们必须注意,Rust 框架包括 Actix Web 和 Rocket。 这些 Rust 服务器在处理的总请求和传输的数据方面处于完全不同的区域。 其他语言,例如 Golang,也已经出现,但 Rust 没有垃圾收集,使得 Golang 黯然失色。 Jesse Howarth 的博客文章 Why Discord 从 Go 切换到 Rust 中对此进行了演示,其中发布了以下图表:

rust编程入门视频教程,Rust编程入门教程

Golang 为保证内存安全而实施的垃圾收集导致了 2 分钟的峰值。 这并不是说我们应该在所有事情上使用 Rust。 最佳实践是使用正确的工具来完成工作。 所有这些语言都有不同的优点。 上图中我们所做的只是展示Rust的优点。

不需要垃圾收集是因为 Rust 使用强制规则来使用借用检查器来确保内存安全。 现在我们已经了解了为什么要使用 Rust 进行编码,我们可以继续在下一节中回顾数据类型。

检查 Rust 中的数据类型和变量

如果您以前用其他语言编写过代码,您将使用过变量并处理不同的数据类型。 然而,Rust 确实有一些怪癖,可能会让开发人员望而却步。 如果开发人员来自动态语言,则尤其如此,因为这些怪癖主要围绕内存管理和变量引用。 这些一开始可能会令人生畏,但当你开始理解它们时,你就会学会欣赏它们。 有些人可能听说过这些怪癖,并想知道为什么他们应该为这种语言烦恼。 这是可以理解的,但这些怪癖正是 Rust 成为范式转变语言的原因。

使用借用检查以及处理生命周期和引用等概念,为我们提供了动态语言(例如 Python)的高级内存安全性。 然而,我们也可以获得内存安全的低级资源,例如 C 和 C++ 提供的资源。 这意味着我们在用 Rust 编码时不必担心悬空指针、缓冲区溢出、空指针、分段错误、数据竞争和其他问题。 空指针和数据争用等问题可能很难调试。 考虑到这一点,强制执行的规则是一个很好的权衡,因为我们必须了解 Rust 的怪癖才能获得非内存安全语言的速度和控制,但我们不会遇到这些非内存安全语言带来的麻烦。

在进行任何 Web 开发之前,我们需要运行第一个程序。 我们可以在 Rust 游乐场 https://play.rust-lang.org/ 中执行此操作。

如果您以前从未访问过 Rust 游乐场,那么一旦您到达那里,您将看到以下布局:

fn main() {
    println!("hello world");
}

当使用在线 Rust 游乐场时,前面的代码将出现在类似于以下屏幕截图界面中:

rust编程入门视频教程,Rust编程入门教程

图 1.3 – 在线 Rust 游乐场的视图

在我们的 hello world 代码中,我们有一个 main 函数,它是我们的入口点。 当我们运行程序时会触发此函数。 所有程序都有入口点开始。 如果您之前没有听说过这个概念,由于来自动态语言,入口点就是您指向解释器的脚本文件。 对于Python,更接近的类比是如果文件由解释器直接运行则运行的主块,表示如下:

if __name__ == "__main__":
    print("Hello, World!")

如果您使用 Python 进行编码,您可能会在 Flask 应用程序中看到它的使用。 目前,我们还没有做任何新的事情。 这是一个标准的 Hello World 示例,语法略有变化; 然而,即使在这个例子中,我们打印的字符串也并不像看上去的那样。 例如,让我们编写自己的函数,它接受一个字符串并使用以下代码将其打印出来:

fn print(message: str) {
    println!("{}", message);
}
fn main() {
    let message = "hello world";
    print(message);
}

这段代码应该可以工作。 我们将其传递到我们的函数中并打印它。 但是,如果我们打印它,我们会得到以下打印输出:

10 |     print(message);
   |           ^^^^^^^ doesn't have a size known at compile-time
   |
   = help: the trait `Sized` is not implemented for `str`
   = note: all function arguments must have a statically known size 

这比较复杂,它把我们带到了如果我们要使用 Rust 编写代码,我们必须了解的第一个领域,那就是字符串。 别担心,字符串是编写 Rust 代码时需要花心思的奇怪的变量。

在 Rust 中使用字符串

在探讨上一节中的错误之前,让我们纠正它,以便我们知道要做什么。 我们可以使用以下代码使打印功能正常工作,不会出现任何错误:

fn print(message: String) {
    println!("{}", message);
}
fn main() {
    let message = String::from("hello world");
    print(message);
}

我们所做的是从“hello world”创建一个字符串(string)并将该字符串传递到打印函数中。 这次编译器没有抛出错误,因为我们总是知道字符串的大小,因此我们可以为其保留适量的可用内存。 这听起来可能违反直觉,因为字符串通常具有不同的长度。 如果我们只允许代码中的每个字符串使用相同长度的字母,那么它就不是一种灵活的编程语言。 这是因为字符串本质上是作为字节向量实现的指针,在 Rust 中表示为 Vec<u8>。 它保存了对堆内存中字符串内容(str,也称为字符串切片)的引用,如下图所示:

rust编程入门视频教程,Rust编程入门教程

图 1.4 – 字符串与 str“one” 的关系

在图 1.4 中我们可以看到,一个字符串是由三个数字组成的向量。 第一个是它所引用的 str 的实际内存地址。 第二个数字是分配的内存大小,第三个数字是字符串内容的长度。 因此,我们可以在代码中访问字符串文字,而无需在代码中传递各种大小的变量。 我们知道 String 有一个固定的大小,因此可以在 print 函数中分配这个大小。 还必须注意的是,字符串位于栈内存中,而字符串文字位于堆内存中。 考虑到我们知道 String 有一个固定的大小,而我们的字符串文字不同,我们可以推断栈内存用于可预测的内存大小,并在程序运行时提前分配。 我们的堆内存是动态的,因此内存在需要时分配。 现在我们已经了解了字符串的基础知识,我们可以使用创建字符串的不同方式,如以下代码所示:

    let string_one = "hello world".to_owned();
    let string_two = "hello world".to_string();
    let string_three = string_two.clone();

但是,我们必须注意,创建 string_three 的成本很高,因为我们必须复制堆中的基础数据,并且堆操作的成本很高。 这并不是 Rust 的独特之处。 在我们的示例中,我们只是体验幕后发生的事情。 例如,如果我们改变 Python 中的字符串,我们将得到不同的结果:

# slower method 
data = ["one", "two", "three", "four"]
string = ""
for i in data:
    string += i   
# faster method
"".join(data)

循环和添加字符串的速度较慢,因为 Python 必须分配新内存并将整个字符串复制到新内存地址。 join 方法速度更快,因为 Python 可以分配列表中所有数据的内存,然后复制数组中的字符串,这意味着字符串只需复制一次。 这告诉我们,虽然像Python这样的高级语言可能不会强迫你去思考字符串的内存分配,但如果你不承认这一点,你最终还是要付出代价的。

我们还可以通过借用字符串文字将其传递到 print 函数中,如以下代码所示:

fn print(message: &str) {
    println!("{}", message);
}
fn main() {
    print(&"hello world");
}

借用由 & 表示。 我们将在本章稍后讨论借用。 然而,现在我们可以推断借用只是对可变大小字符串切片的固定大小引用。 如果借用是固定大小,我们将无法将其传递到print函数中,因为我们不知道大小。 此时,我们可以轻松、高效地在 Rust 中使用字符串。 在开始编写 Rust 程序之前,我们必须了解的下一个概念是整数和浮点数。

使用整数和浮点数

在大多数高级 Web 编程语言中,我们只需将浮点数或整数分配给变量,然后继续执行程序。 然而,从上一节关于字符串的内容来看,我们现在了解到在 Rust 中使用字符串时必须担心内存大小。 这与整数和浮点数没有什么不同。 我们知道整数和浮点数有一定的大小范围。 因此,我们必须告诉 Rust 我们在代码中传递了什么。 Rust 支持有符号整数(用 i 表示)和无符号整数(用 u 表示)。

这些整数由 8、16、32、64 和 128 位组成。 探索二进制表示的数字背后的数学与本书无关; 然而,我们确实需要了解可以用几个位表示的数字范围,因为这将帮助我们理解 Rust 中不同类型的浮点数和整数表示什么。 因为二进制要么是 0,要么是 1,所以我们可以通过计算 2 的位数次方来计算可以用位表示的整数范围。 例如,如果我们有一个由 8 位表示的整数,则 2 的 8 次方等于 256。我们必须记住,也可以表示 0。 考虑到这一点,8位整数的范围是0到255。我们可以用下面的代码来测试这个计算:

let number: u8 = 256;

这比我们计算的范围要高。 因此,我们看到如下溢出错误就不会感到惊讶:

the literal `256` does not fit into the type 
`u8` whose range is `0..=255`

所以,我们可以推断,如果我们将无符号整数降低到 255,它就会通过。 但是,假设我们使用以下代码将无符号整数更改为有符号整数:

let number: i8 = 255;

我们将看到一条有用的错误消息,如下所示:

the literal `255` does not fit into the type 
`i8` whose range is `-128..=127`

通过这个有用的错误消息,我们可以看到有符号整数考虑负数,因此有符号整数可以取的绝对值大约是一半。 因此,我们可以通过使用以下代码将数字分配为 16 位有符号整数来增加范围:

let number: i16 = 255;

这会起作用。 但是,让我们使用以下代码将 16 位整数与 8 位整数相加:

let number = 255i16;
let number_two = 5i8;
let result = number + number_two;

前面的代码对您来说可能有点不同。 我们在前面的代码中所做的只是用后缀定义数据类型。 因此,number 的值为 255,类型为 i16,number_two 的值为 5,类型为 i8。 如果我们运行前面的代码,我们会得到以下错误:

11 |     let result = number + number_two;
   |                         ^ no implementation for `i16 + i8`
   |
   = help: the trait `Add<i8>` is not implemented for `i16`

我们将在本章后面介绍特征。 现在,我们必须明白的是我们不能将两个不同的整数相加。 如果它们都是同一类型,那么我们就可以。 我们可以使用 as 通过强制转换来更改整数类型,如以下代码行所示:

 let result = number + number_two as i16;

这意味着 number_two 现在是一个 16 位整数,结果将是 260。但是,我们必须小心转换,因为如果我们以错误的方式进行转换,我们最终可能会遇到一个无声错误。 如果我们将 number 转换为 i8 而不是将 number_two 转换为 i16,那么结果将等于 4,这是没有意义的,因为 255 + 5 等于 260。这是因为 i8 小于 i16。 因此,如果我们将 i16 整数转换为 i8 整数,那么我们实际上是通过仅取数字的低位而忽略高位来截断一些数据。 因此,如果我们将 number 转换为 i8 整数,则它最终会变为 -1。 为了更安全,我们可以使用 i8::from 函数,如下代码所示:

let result = i8::from(number) + number_two;

运行它会给我们带来以下错误:

let result = i8::from(number) + number_two;
|                  ^^^^^^^^ the trait `From<i16>` is not 
                            implemented for `i8`

同样,我们将在本章稍后讨论特征,但我们可以在前面的代码中看到,因为 From<i16> 特征不是针对 i8 整数实现的,所以我们无法将 i8 整数转换为 i16 整数。 理解了这一点,我们就可以安全、高效地处理整数了。 关于 Rust 中整数大小的最后一点是它们不是连续的。 支持的尺寸如下表所示:

计算

大小

8

2^8

256

16

2^16

65536

32

2^32

4294967296

64

2^64

1.8446744e+19

128

2^128

3.4028237e+38

表 1.1 – 整数类型的大小

当谈到浮点数时,Rust 可以容纳 f32 和 f64 浮点数。 这两种浮点类型都支持负值和正值。 声明浮点变量需要与整数相同的语法,如以下代码所示:

let float: f32 = 2.6;

有了这个,我们就可以在 Rust 代码中轻松地使用整数和浮点数。 然而,作为开发人员,我们知道仅仅声明浮点数和整数并不是很有用。 我们希望能够包含并循环它们。 在下一节中,我们将使用向量和数组来实现这一点。

将数据存储在向量和数组中

在 Rust 中,我们可以将浮点数、整数和字符串存储在数组和向量中。 首先,我们将重点关注数组。 数组存储在栈内存中。 知道了这一点,并记住我们学到的有关字符串的知识,我们可以推断出数组具有固定大小。 这是因为,正如我们所记得的,如果变量存储在堆栈上,那么内存会在程序启动时分配并加载到栈中。 我们可以定义一个整数数组,循环遍历它,打印每个整数,然后使用以下代码通过索引访问整数:

fn main() {
    let int_array: [i32; 3] = [1, 2, 3];
    for i in int_array {
        println!("{}", i);
    }
    println!("{}", int_array[1]);
}

在前面的代码中,我们通过将类型和大小括在方括号中来定义它们。 例如,如果我们要创建一个长度为 4 的浮点数数组,我们将使用 int_array: [f32; 4] = [1.1, 2.2, 3.3, 4.4]。 运行前面的代码将得到以下打印输出:

1
2
3
2

在前面的打印输出中,我们看到循环有效,并且我们可以使用方括号访问第二个整数。 虽然数组的内存大小是固定的,但是我们仍然可以改变它。 这就是可变性的用武之地。当我们将变量定义为可变时,这意味着我们可以改变它。 换句话说,如果变量是可变的,我们可以在定义变量后更改它的值。 如果您尝试更新我们在本章中编写的代码中的任何变量,您将会意识到这是不可能的。 这是因为 Rust 中的所有变量默认都是不可变的。 我们可以通过在变量名称前面放置 mut 标签来使 Rust 中的任何变量可变。 回到固定数组,我们无法更改数组的大小,这意味着我们无法向其追加新整数,因为它存储在栈内存中。 但是,如果我们定义一个可变数组,我们可以使用具有相同内存大小的其他整数来更新其中的部分内容。 下面的代码就是一个例子:

fn main() {
    let mut mutable_array: [i32; 3] = [1, 2, 0];
    mutable_array[2] = 3;
    println!("{:?}", mutable_array);
    println!("{}", mutable_array.len());
}

在前面的代码中,我们可以看到数组中的最后一个整数更新为 3。然后我们打印出完整的数组,然后打印出长度。 您可能还注意到,前面代码的第一个 print 语句现在使用了 {:?}。 这称为调试特征trait。 如果为我们尝试打印的内容实现了调试,那么我们正在打印的内容的完整表示将显示在控制台中。 你还可以看到我们打印出了数组长度的结果。 运行此代码将给出以下打印输出:

[1, 2, 3]
3

通过前面的打印输出,我们可以确认数组现在已更新。 我们还可以使用数组访问切片。 为了证明这一点,我们可以创建一个包含 100 个零的数组。 然后我们可以使用以下代码截取一部分并打印出来:

fn main() {
    let slice_array: [i32; 100] = [0; 100];
    println!("length: {}", slice_array.len());
    println!("slice: {:?}", &slice_array[5 .. 8]);
}

运行前面的代码将产生以下打印输出:

length: 100
slice: [0, 0, 0]

我们现在能够利用数组提高工作效率。 数组对于缓存很有用。 例如,如果我们知道需要存储的数量,那么我们就可以有效地使用数组。 然而,我们只能在数组中存储一种类型的数据。 如果我们尝试将字符串和整数存储在同一个数组中,就会遇到问题。 我们如何定义类型? 这个问题适用于所有集合,例如向量和 HashMap。 有多种方法可以做到这一点,但最简单的是使用枚举。 枚举就是enum。 在 Python 等动态语言中,您可能不必使用它们,因为能够在任何您想要的地方传递任何类型。 然而,它们仍然可用。 Enum 是枚举类型的缩写,基本上定义了具有可能变体的类型。 在我们的例子中,我们希望数组将字符串和整数存储在同一个集合中。 我们可以通过使用以下代码最初定义枚举来做到这一点:

enum SomeValue {
    StringValue(String),
    IntValue(i32)
}

在前面的代码中,我们可以看到我们定义了一个名为 SomeValue 的枚举。 然后我们表示 StringValue 保存字符串的值,IntValue 保存整数的值。 然后我们可以定义一个长度为4的数组,由2个字符串和2个整数组成,代码如下:

    let multi_array: [SomeValue; 4] = [
        SomeValue::StringValue(String::from("one")),
        SomeValue::IntValue(2),
        SomeValue::StringValue(String::from("three")),
        SomeValue::IntValue(4)
    ];

在前面的代码中,我们可以看到我们将字符串和整数包装在枚举中。 现在,循环并将其取出将是另一项任务。 例如,有些事情我们可以对整数做,但不能对字符串做,反之亦然。 考虑到这一点,我们在循环数组时必须使用 match 语句,如以下代码所示:

    for i in multi_array {
        match i {
            SomeValue::StringValue(data) => {
                println!("The string is: {}", data);
            },
            SomeValue::IntValue(data) => {
                println!("The int is: {}", data);
            }
        }
    }

在上面的代码中,我们可以看到,如果 i 是 SomeValue::StringValue,则我们将 SomeValue::StringValue 中包装的数据分配给名为 data 的变量。 然后我们将数据传递到要打印的内部范围。 我们对整数使用相同的方法。 即使我们只是打印来演示这个概念,我们也可以在这些内部作用域中对类型允许的数据变量执行任何操作。 运行前面的代*会码**得到以下打印输出:

The string is: one
The int is: 2
The string is: three
The int is: 4

使用枚举来包装数据并使用匹配语句来处理它们可以应用于 HashMap 和向量。 此外,我们所介绍的数组也可以应用于向量。 唯一的区别是我们不必定义长度,并且可以根据需要增加向量的大小。 为了演示这一点,我们可以创建一个字符串向量,然后使用以下代码在末尾添加一个字符串:

    let mut string_vector: Vec<&str> = vec!["one", "two", 
        "three"];
    println!("{:?}", string_vector);
    string_vector.push("four");
    println!("{:?}", string_vector);

在前面的代码中,我们可以看到我们使用了 vec! 用于创建字符串向量的宏。 您可能已经注意到诸如 vec! 之类的宏了! 和println! 我们可以改变输入的数量。 我们将在本章后面介绍宏。 运行前面的代码将产生以下打印输出:

["one", "two", "three"]
["one", "two", "three", "four"]

我们还可以使用 Vec 结构体中的新函数 let _empty_vector 创建一个空向量:Vec<&str> = Vec::new();。 您可能想知道何时使用向量以及何时使用数组。 向量更加灵活。 您可能会想使用数组来提高性能。 从表面上看,这似乎是合乎逻辑的,因为它存储在栈中。 访问栈将会更快,因为可以在编译时计算内存大小,从而使分配和释放比堆更简单。 但是,因为它位于栈上,所以它的寿命不能超过它分配的范围。 移动矢量只需要移动指针即可。 但是,移动数组需要复制整个数组。 因此,复制固定大小的数组比移动向量的成本更高。 如果您有少量数据,只在小范围内需要,并且您知道数据的大小,那么使用数组确实有意义。 但是,如果您要移动数据,即使您知道数据的大小,使用向量也是更好的选择。 现在我们可以高效地使用基本集合,我们可以转向更高级的集合,即 HashMap。

使用 HashMap 映射数据

在其他一些语言中,HashMap 被称为字典。 它们有一个键和一个值。 我们可以使用键插入和获取值。 现在我们已经了解了如何处理集合,我们可以在本节中进行更多冒险。 我们可以创建一个游戏角色的简单配置文件。 在此角色个人资料中,我们将提供姓名、年龄和他们拥有的物品列表。 这意味着我们需要一个包含字符串、整数和也包含字符串的向量的枚举。 我们需要打印出完整的 HashMap,以便一眼看出我们的代码是否正确。 为此,我们将为枚举实现 Debug 特征,如以下代码所示:

#[derive(Debug)]
enum CharacterValue {
    Name(String),
    Age(i32),
    Items(Vec<String>)
}

在前面的代码中,我们可以看到我们已经用derive属性注释了我们的枚举。 在本例中,属性是可以应用于 CharacterValue 枚举的元数据。 派生属性告诉编译器提供特征的基本实现。 因此,在前面的代码中,我们告诉编译器将 Debug 的基本实现应用于 CharacterValue 枚举。 这样,我们就可以创建一个新的 HashMap,其中的键指向我们使用以下代码定义的值:

use std::collections::HashMap;
fn main() {
    let mut profile: HashMap<&str, CharacterValue> = 
                     HashMap::new();
}

我们声明它是可变的,因为我们将使用以下代码插入值:

profile.insert("name", CharacterValue::Name("Maxwell".to_string()));
profile.insert("age", CharacterValue::Age(32));
profile.insert("items", CharacterValue::Items(vec![
    "laptop".to_string(),
    "book".to_string(),
    "coat".to_string()
]));
println!("{:?}", profile);

我们可以看到我们已经插入了我们需要的所有数据。 运行它会给我们以下打印输出:

{"items": Items(["laptop", "book", "coat"]), "age": Age(32), 
"name": Name("Maxwell")}

在前面的输出中,我们可以看到我们的数据是正确的。 然而,我们现在必须再次把它拿出来。 我们可以使用 get 函数来做到这一点。 get 函数返回一个 Option 类型。 Option 类型返回 Some 或 None。 因此,如果我们要从 HashMap 中获取name,我们需要进行两次匹配,如以下代码所示:

    match profile.get("name") {
        Some(value_data) => {
            match value_data {
                CharacterValue::Name(name) => {
                    println!("the name is: {}", name);
                },
                _ => panic!("name should be a string") 
            }
        },
        None => {
            println!("name is not present");
        }
    }

在前面的代码中,我们可以检查键中是否有名称。 如果不存在,那么我们就打印出它不存在。 如果名称键存在,我们将继续进行第二次检查,如果名称是CharacterValue::Name,则打印出名称。 但是,如果名称键不包含CharacterValue::Name,就会出现问题。 因此,我们只添加一项检查匹配,即 _。 这是一个陷阱。 我们对CharacterValue::Name 以外的任何东西都不感兴趣。 因此,_catch 映射到panic! 宏,这会引发错误。 我们可以把这个缩短。 如果我们知道名称键将位于 HashMap 中,我们可以使用 unwrap 函数,代码如下:

    match profile.get("name").unwrap() {
        CharacterValue::Name(name) => {
            println!("the name is: {}", name);
        }, 
        _ => panic!("name should be a string") 
    }

unwrap 函数直接展开结果。 但是,如果结果为 None,那么将直接导致错误终止程序,如下打印输出:

thread 'main' panicked at 'called `Option::unwrap()` on a `None` value'

这可能看起来有风险,但实际上,您最终会经常使用展开函数,因为您需要直接访问结果,并且没有它就无法继续执行程序。 一个典型的例子是连接到数据库。 在很多Web编程中,如果数据库连接不成功,那么就无法进行API调用。 因此,像大多数其他网络语言一样允许错误是有意义的。 现在我们已经遇到了终止程序的错误,我们不妨在下一节中学习如何处理错误。

处理结果(result)和错误(error)

在上一节中,我们了解到直接展开 Option 会导致 None 导致线程出现恐慌。 如果解包失败,还有另一种结果也可能引发错误,这就是 Result。 Result 类型可以返回 Ok 或 Err。 为了演示这一点,我们可以创建一个基本函数,它基于我们传递给它的布尔值返回 Result 类型:

fn error_check(check: bool) -> Result<i8, &'static str> {
    if check {
        Err("this is an error")
        } 
    else {
        Ok(1)
    }
}

在前面的代码中,我们可以看到我们返回了 Result<i8, &'static str>。 这意味着如果 Result 为 Ok,则返回一个i8整数;如果 Result 为 Err,则返回一个&'static str 变量的错误字符串。 由于 &,我们可以看出它是一个引用。 'static部分意味着引用在正在运行的程序的整个生命周期内有效。 如果现在不懂,请不要担心,我们将在本章后面讨论生命周期。 现在我们已经创建了错误检查函数,我们可以使用以下代码来测试这些结果:

fn main() {
    println!("{:?}", error_check(false));
    println!("{:?}", error_check(false).is_err());
    println!("{:?}", error_check(true));
    println!("{:?}", error_check(true).is_err());
}

运行前面的代*会码**得到以下打印输出:

Ok(1)
false
Err("this is an error")
true

在前面的输出中,我们可以看到它返回的正是我们想要的。 我们还可以注意到,我们可以对 Result 变量运行 is_err() 函数,如果返回 Ok 则结果为 false,如果返回 Err 则结果为 true。 我们还可以直接展开,但使用以下 Expect 函数向堆栈跟踪添加额外的跟踪:

let result: i8 = error_check(true).expect("this has been caught");

前面的函数将产生以下打印输出:

thread 'main' panicked at 'this has been caught: "this is an error"'

通过上面的例子,我们可以看到,我们首先从expect函数中获取消息,然后在Result中返回错误消息。 有了这些说明,我们就可以抛出、处理错误并添加额外的跟踪。 然而,随着我们的前进,我们越来越多地接触生命周期和借用。 现在是通过理解变量所有权来解决这个问题的时候了。

控制变量所有权

正如我们在本章开头所记得的,Rust 没有垃圾收集器。 但是,它具有内存安全性。 它通过对变量所有权制定严格的规则来实现这一点。 这些规则在编译 Rust 时强制执行。 如果您来自动态语言,那么这最初可能会导致挫败感。 可悲的是,这不公正地给 Rust 带来了虚假的陡峭学习曲线声誉,因为当你在不知道发生了什么的情况下与借用检查器进行斗争时,编写最基本的程序似乎是一项不可能完成的任务。 然而,如果我们在尝试编写过于复杂的代码之前花时间学习规则,那么规则的知识和编译器的帮助将使用 Rust 编写代码变得有趣和有益。 我再次花时间提醒您,Rust 已连续 7 年成为最受欢迎的语言。 这并不是因为其不可能完成任务。 在这些调查中投票支持 Rust 的人了解有关所有权的规则。 Rust 的编译、检查和执行这些规则可以防止以下错误:

释放后使用:当内存被释放后再次访问时会发生这种情况,这可能会导致崩溃。 它还可以允许黑客通过该内存地址执行代码。

悬空指针:当引用指向的内存地址不再包含指针所引用的数据时,就会发生这种情况。 本质上,该指针现在指向空数据或随机数据。

双重释放:当分配的内存被释放然后再次释放时,就会发生这种情况。 这可能会导致程序崩溃并增加敏感数据泄露的风险。 这也使得黑客能够执行任意代码。

分段错误:当程序尝试访问不允许访问的内存时,就会发生这种情况。

缓冲区溢出:此错误的一个示例是读取数组末尾。 这可能会导致程序崩溃。

为了防止这些错误并实现内存安全,Rust 强制执行以下规则:

值由分配给它们的变量拥有

一旦变量移出了它定义的范围,它就会从内存中释放

如果我们遵守复制、移动、不可变借用和可变借用的规则,则可以引用和更改值

了解规则是一回事,但为了实际使用 Rust 代码中的规则,我们需要更详细地了解复制、移动和借用。

复制变量

当复制一个值时,就会发生复制。 一旦被复制,新变量就拥有该值,而现有变量也拥有其自己的值。

rust编程入门视频教程,Rust编程入门教程

图 1.5 – 变量复制路径

在图1.5中,我们可以看到One的路径仍然是实心的,这表明它没有被中断,可以像没有发生复制一样处理。 路径two只是一个副本,使用方式也没有什么区别,就和自定义一样。 必须注意的是,如果变量具有复制特征,那么它将自动被复制,如以下代码所示:

let one: i8 = 10;
let two: i8 = one + 5;
println!("{}", one);
println!("{}", two);

运行前面的代码将为我们提供以下打印输出:

10
15

在前面的示例中,我们意识到变量one和变量two可以被打印这一事实表明变量one已被复制以供two使用。 为了测试这一点,我们可以使用以下代码用字符串测试我们的示例:

let one = "one".to_string();
let two = one;
println!("{}", one);
println!("{}", two);

运行此代码将导致以下错误:

move occurs because `one` has type `String`, which does not implement the `Copy` trait

由于字符串没有实现 Copy 特征,因此代码无法工作,因为one 被移至 two。 然而,如果我们去掉 println!("{}", one); ,代码就会运行。 这给我们带来了下一个必须理解的概念:移动。

移动变量

移动是指将值从一个变量移动到另一个变量。 但是,与复制不同的是,原始变量不再拥有该值。

rust编程入门视频教程,Rust编程入门教程

图 1.6 – 可变移动路径

从图 1.6 中可以看出,一旦将 one变为 two,就无法再访问了。 为了真正确定这里发生了什么以及字符串如何受到影响,我们可以设置一些设计失败的代码,如下所示:

let one: String = String::from("one");
let two: String = one + " two";
println!("{}", two);
println!("{}", one);

运行前面的代*会码**出现以下错误:

let one: String = String::from("one");
    --- move occurs because `one` has type 
    `String`, which does not implement the 
    `Copy` trait
let two: String = one + " two";
                  ------------ `one` moved due to usage in operator
println!("{}", two);
println!("{}", one);
               ^^^ value borrowed here after move

正如我们所看到的,编译器提示在这里很有帮助。 它向我们展示了字符串被移动到的位置以及该字符串的值被借用的位置。 因此,我们只需删除 println!("{}", one); 就可以让代码立即运行。 但是,我们希望能够使用前面代码块底部的打印函数。 我们不应该因为 Rust 实现的规则而限制代码的功能。 我们可以通过使用 to_owned 函数和以下代码来解决这个问题:

let two: String = one.to_owned() + " two";

to_owned 函数可用是因为字符串实现了 ToOwned 特征。 我们将在本章后面介绍特征,所以如果您还不知道这意味着什么,请不要停止阅读。 我们可以在字符串上使用克隆。 我们必须注意 to_owned 是 clone 的通用实现。 然而,我们使用哪种方法并不重要。 想知道为什么字符串没有 Copy 特征是可以理解的。 这是因为该字符串是指向字符串文字的指针。 如果我们要复制字符串,我们将有多个指向相同字符串文字数据的不受约束的指针,这将是危险的。 因此,我们可以使用字符串来探索移动概念。 如果我们用函数强制字符串超出范围,我们可以看到这如何影响我们的移动。 这可以通过以下代码完成:

fn print(value: String) {
    println!("{}", value);
}
fn main() {
    let one = "one".to_string();
    print(one);
    println!("{}", one);
}

如果我们运行前面的代码,我们将收到一条错误,指出 print 函数移动了one的值。 结果, println!("{}", one); 该行在移入打印函数后借用了one。 此消息的关键部分是“借用”一词。 为了了解发生了什么,我们需要探索不可变借用的概念。

不可变的变量借用

当一个变量可以被另一个变量引用而无需克隆或复制时,就会发生不可变借用。 这本质上解决了我们的问题。 如果借用的变量超出范围,则不会从内存中释放它,并且仍然可以使用对该值的原始引用。

rust编程入门视频教程,Rust编程入门教程

从图中我们可以看到,two 借用了one 的值。 必须注意的是,当借出时,onw将被锁定,在借用完成之前无法访问。 要执行借用操作,我们只需应用带有 & 的前缀。 这可以通过以下代码进行演示:

fn print(value: &String) {
    println!("{}", value);
}
fn main() {
    let one = "one".to_string();
    print(&one);
    println!("{}", one);
}

在前面的代码中,我们可以看到我们的不可变借位使我们能够将字符串传递到打印函数中,然后仍然打印它。 这可以通过以下打印输出来确认:

one
one

从我们在代码中看到的情况来看,我们执行的不可变借用如图 1.8 所示。

rust编程入门视频教程,Rust编程入门教程

图 1.8 – 与 print 函数相关的不可变借用

在上图中,我们可以看到打印功能运行时不可用。 我们可以用下面的代码来演示这一点:

fn print(value: &String, value_two: String) {
    println!("{}", value);
    println!("{}", value_two);
}
fn main() {
    let one = "one".to_string();
    print(&one, one);
    println!("{}", one);
}

如果我们运行前面的代码,我们将得到以下错误:

print(&one, one);
----- ----  ^^^ move out of `one` occurs here
|     |
|     borrow of `one` occurs here
borrow later used by call

我们可以看到,即使在 &one 之后的打印函数中使用了 one,我们也无法使用它。 这是因为 &one 的生命周期贯穿打印函数的整个生命周期。 因此,我们可以得出结论,图1.8是正确的。 不过,我们还可以再进行一项实验。 我们可以将 value_one 更改为借用,看看以下代*会码**发生什么:

fn print(value: &String, value_two: &String) {
    println!("{}", value);
    println!("{}", value_two);
}
fn main() {
    let one = "one".to_string();
    print(&one, &one);
    println!("{}", one);
}

在前面的代码中,我们可以看到我们对one进行了两次不可变的借用,并且代码运行。 这凸显了一个重要的事实:我们可以根据需要进行任意数量的不可变借用。 但是,如果借用可变的话会发生什么? 为了理解,我们必须探索可变借用。

变量的可变借用

可变借用本质上与不可变借用相同,只是借用是可变的。 因此,我们可以改变借用的值。 为了演示这一点,我们可以创建一个打印语句,该语句将在打印之前更改借用的值。 然后我们在主函数中打印它,以确定该值已使用以下代码更改:

fn print(value: &mut i8) {
     value += 1;
    println!("In function the value is: {}", value);
}
fn main() {
    let mut one: i8 = 5;
    print(&mut one);
    println!("In main the value is: {}", one);
}

运行前面的代码将为我们提供以下打印输出:

In function the value is: 6
In main the value is: 6

前面的输出证明,即使 print 函数中的可变引用的生命周期已到期,one 仍为 6。 我们可以看到,在 print 函数中,我们使用 * 运算符更新了 1 的值。 这称为解引用运算符。 该解引用运算符展开了基础值,以便对其进行操作。 这一切看起来很简单,但可以有多个可变引用吗? 我们记得,我们可以有多个不可变的引用。 我们可以使用以下代码对此进行测试:

fn print(value: &mut i8, value_two: &mut i8) {
     value += 1;
    println!("In function the value is: {}", value);
     value_two += 1;
}
fn main() {
    let mut one: i8 = 5;
    print(&mut one, &mut one);
    println!("In main the value is: {}", one);
}

在前面的代码中,我们可以看到我们创建了两个可变引用并传递它们,就像上一节一样,但使用了可变引用。 然而,运行它会给我们带来以下错误:

error[E0499]: cannot borrow `one` as mutable more than once at a time

通过这个例子,我们可以确认一次不能有多个可变引用。 这可以防止数据竞争,并给 Rust 带来了无所畏惧的并发特性。 根据我们在这里介绍的内容,当编译器与借用检查器结合使用时,我们现在可以提高工作效率。 然而,我们已经触及了范围和生命周期的概念。 它们的使用很直观,但就像借用规则一样,我们需要更详细地深入研究范围和生命周期。

范围

为了理解作用域,让我们回到如何声明变量。 你会注意到,当我们声明一个新变量时,我们使用let。 当我们这样做时,该变量是唯一拥有该资源的变量。 因此,如果该值被移动或重新分配,则初始变量将不再拥有该值。 当一个变量被移动时,它实际上被移动到另一个作用域中。 在外部作用域中声明的变量可以在内部作用域中引用,但是一旦内部作用域过期,就无法在内部作用域中访问在内部作用域中声明的变量。 我们可以将一些代码分解为下图中的范围:

rust编程入门视频教程,Rust编程入门教程

图 1.9 – 基本 Rust 代码分解为不同的作用域

图 1.9 向我们展示了我们可以仅使用大括号来创建一个内部作用域。 将我们刚刚学到的作用域应用到图 1.9 中,你能算出它是否会崩溃吗? 如果它会崩溃,会怎样?

如果您猜测这会导致编译器错误,那么您是对的。 运行代*会码**出现以下错误:

println!("{}", two);
               ^^^ not found in this scope

因为它是在内部作用域中定义的,所以我们无法在外部作用域中引用它。 我们可以通过在外部作用域中声明变量但在内部作用域中赋值来解决这个问题,代码如下:

fn main() {
    let one = &"one";
    let two: &str;
    {
        println!("{}", one);
        two = &"two";
    }
    println!("{}", one);
    println!("{}", two);
}

在上面的代码中,我们可以看到我们在赋值时没有使用let,因为我们已经在外部作用域中声明了变量。 运行前面的代*会码**得到以下打印输出:

one
one
two

我们还必须记住,如果我们将变量移动到函数中,那么一旦函数作用域结束,该变量就会被销毁。 即使我们在函数执行之前声明了变量,我们也无法在函数执行之后访问该变量。 这是因为一旦变量被移动到函数中,它就不再位于原始范围内。 它被移动了。 由于它已被移至该作用域,因此它会绑定到它所移入的作用域的生命周期。 这将我们带入下一部分:生命周期。

生命周期

了解生命周期将结束我们对借用规则和范围的探索。 我们可以使用以下代码来探索生命周期的影响:

fn main() {
    let one: &i8;
    {
        let two: i8 = 2;
        one = &two;
    } // -----------------------> two lifetime stops here
    println!("r: {}", one);
}

通过前面的代码,我们在内部作用域开始之前声明one。 然而,我们指定它有two的引用。 而two只有内部作用域的生命周期,因此在我们尝试打印它之前,two的生命周期就结束了。 以下是错误信息:

one = &two;    }    println!("r: {}", one);}
      ^^^^     -                      --- borrow later used here
      |        |
      |        `two` dropped here while still borrowed
      borrowed value does not live long enough

当two 的生命周期结束时, two就会被丢弃。 由此,我们可以说one和two的寿命并不相等。

虽然在编译时标记这一点很棒,但 Rust 并不止于此。 这个概念也适用于函数。 假设我们构建一个引用两个整数的函数,对它们进行比较,然后返回最大的整数引用。 该函数是一段独立的代码。 在这个函数中,我们可以表示两个整数的生命周期。 这是通过使用 ' 前缀来完成的,这是一个生命周期符号。 符号的名称可以是您想出的任何名称,但惯例是使用 a、b、c 等。 我们可以通过创建一个简单的函数来探索这一点,该函数接受两个整数并使用以下代码返回最大的一个:

fn get_highest<'a>(first_number: &'a i8, second_number: &'a
    i8) -> &'a i8 {
    if first_number > second_number {
        first_number
        } else {
        second_number
    }
}
fn main() {
    let one: i8 = 1;
    let outcome: &i8;
    {
        let two: i8 = 2;
        let outcome: &i8 = get_highest(&one, &two);
    }
    println!("{}", outcome);
}

正如我们所看到的,first生命周期和second生命周期具有相同的 a 表示法。 他们必须在活动期间都在场。 我们还必须注意,该函数返回一个 i8 整数,其生命周期也为 a。 如果我们尝试在不借用的情况下对函数参数使用生命周期表示法,我们会得到一些非常令人困惑的错误。 简而言之,如果没有借用,就不可能使用生命周期表示法。 这是因为如果我们不使用借位,传递给函数的值将被移入函数中。 因此,它的生命周期就是函数的生命周期。 这看起来很简单; 但是,当我们运行它时,我们收到以下错误:

println!("{}", outcome);}
               ^^^^^^^ use of possibly-uninitialized `outcome`

发生错误的原因是传递给函数的所有参数和返回的整数的生命周期都相同。 因此,编译器不知道会返回什么。 结果,two可以被退回。 如果返回two,则该函数的结果将不会存在足够长的时间来打印。 然而,如果有one被返回,那么它就会被返回。 因此,内部作用域执行后有可能没有值可以打印。 在动态语言中,我们将能够运行存在引用尚未初始化的变量风险的代码。 然而,使用 Rust,我们可以看到,如果有可能出现这样的错误,它将无法编译。 从短期来看,Rust 似乎需要更长的时间来编码,但随着项目的进展,这种严格性将通过防止无声错误来节省大量时间。 总结我们的错误,没有办法用我们拥有的确切功能和主要布局来解决我们的问题。 我们要么必须将结果的打印移到内部作用域中,要么克隆整数并将它们传递到函数中。

我们可以再创建一个函数来探索具有不同生命周期参数的函数。 这次我们将创建一个过滤函数。 如果第一个数字小于第二个数字,我们将返回 0。否则,我们将返回第一个数字。 这可以通过以下代码来实现:

fn filter<'a, 'b>(first_number: &'a i8, second_number: &'b
    i8) -> &'a i8 {
    if first_number < second_number {
        &0
    } else {
        first_number
    }
}
fn main() {
    let one: i8 = 1;
    let outcome: &i8;
    {
        let two: i8 = 2;
        outcome = filter(&one, &two);
    }
    println!("{}", outcome);
}

前面的代码之所以有效,是因为我们知道生命周期是不同的。 第一个参数与返回的整数具有相同的生命周期。 如果我们要实现 filter(&two, &one),我们会收到一条错误,指出结果的生存时间不够长,无法打印。 现在,我们已经涵盖了在 Rust 中编写高效代码而无需借用检查器妨碍我们的所有知识。 我们现在需要继续为我们的程序创建更大的构建块,以便我们可以专注于解决我们想要用代码解决的复杂问题。 我们将从程序的多功能构建块开始:结构体。

构建结构体

在现代高级动态语言中,对象已成为构建大型应用程序和解决复杂问题的基石,这是有充分理由的。 对象使我们能够封装数据、功能和行为。 在 Rust 中,我们没有对象。 但是,我们确实有可以在字段中保存数据的结构体。 然后我们可以管理这些结构体的功能并将它们与特征分组在一起。 这是一种强大的方法,它给我们带来了对象的好处,但没有高耦合,如下图所示:

rust编程入门视频教程,Rust编程入门教程

图 1.10 – Rust 结构体和对象之间的区别

我们将从基本的东西开始,使用以下代码创建一个 Human 结构:

#[derive(Debug)]
struct Human<'a> {
    name: &'a str,
    age: i8,
    current_thought: &'a str
}

在前面的代码中,我们可以看到字符串文字字段与结构本身具有相同的生命周期。 我们还将 Debug 特征应用于 Human 结构,因此我们可以将其打印出来并查看所有内容。 然后我们可以使用以下代码创建 Human 结构并打印该结构:

fn main() {
    let developer = Human{
        name: "Maxwell Flitton",
        age: 32,
        current_thought: "nothing"
    };
    println!("{:?}", developer);
    println!("{}", developer.name);
}

运行前面的代码将为我们提供以下打印输出:

Human { name: "Maxwell Flitton", age: 32, current_thought:    "nothing" }
Maxwell Flitton

我们可以看到我们的字段就是我们所期望的。 但是,我们可以将字符串切片字段更改为字符串以摆脱生命周期参数。 我们可能还想添加另一个字段,我们可以在朋友字段下引用另一个 Human 结构。 然而,我们也可能没有朋友。 我们可以通过创建一个好友的枚举并将其分配给好友字段来解决此问题,如以下代码所示:

#[derive(Debug)]
enum Friend {
    HUMAN(Human),
    NIL
}
#[derive(Debug)]
struct Human {
    name: String,
    age: i8,
    current_thought: String,
    friend: Friend
}

然后,我们可以最初定义没有朋友的 Human 结构,只是为了看看它是否适用:

    let developer = Human{
        name: "Maxwell Flitton".to_string(),
        age: 32,
        current_thought: "nothing".to_string(),
        friend: Friend::NIL
    };

然而,当我们运行编译器时,它不起作用。 我想这是因为编译器无法相信我没有朋友。 但可惜的是,这是因为编译器不知道要为此声明分配多少内存。 这通过以下错误代码显示:

enum Friend {    HUMAN(Human),    NIL}#[derive(Debug)]
^^^^^^^^^^^            ----- recursive without indirection
|
recursive type has infinite size

由于枚举,理论上,存储该变量所需的内存可能是无限的。 一个 Human 结构体可以引用另一个 Human 结构体作为友元字段,而后者又可以引用另一个 Human 结构体,从而导致潜在无限数量的 Human 结构体通过字段链接在一起。 我们可以用指针来解决这个问题。 我们没有将 Human 结构体的所有数据存储在字段中,而是存储在指针中,我们知道内存具有最大值的内存地址,因为它是标准整数。 该内存地址指向另一个 Human 结构体在内存中的存储位置。 因此,程序准确地知道在跨越 Human 结构时要分配多少内存,无论 Human 结构是否有朋友字段。 这可以通过使用 Box 结构来实现,它本质上是我们枚举的智能指针,代码如下:

#[derive(Debug)]
enum Friend {
    HUMAN(Box<Human>),
    NIL
}

所以,现在我们的枚举声明了这个朋友是否存在,如果存在,如果我们需要提取有关这个朋友的信息,它就在一个内存地址中。 我们可以通过以下代码来实现:

fn main() {
    let another_developer = Human{
        name: "Caroline Morton".to_string(),
        age:30,
        current_thought: "I need to code!!".to_string(),
        friend: Friend::NIL
    };
    let developer = Human{
        name: "Maxwell Flitton".to_string(),
        age: 32,
        current_thought: "nothing".to_string(),
        friend: Friend::HUMAN(Box::new(another_developer))
    };
    match &developer.friend {
        Friend::HUMAN(data) => {
            println!("{}", data.name);
        },
        Friend::NIL => {}
    }
}

在前面的代码中,我们可以看到我们创建了一个 Human 结构,然后创建了另一个 Human 结构,并引用第一个 Human 结构作为朋友字段。 然后我们通过字段访问第二个 Human 结构体的朋友。 请记住,我们必须处理这两种可能性,因为它可能是nil。

虽然结交朋友令人兴奋,但如果我们退后一步,我们会发现为我们创建的每个人编写了很多代码。 如果我们必须在一个程序中创建很多人,这没有帮助。 我们可以通过为我们的结构实现一些功能来减少这种情况。 我们本质上将为该结构创建一个具有额外函数的构造函数,因此我们可以根据需要添加可选值。 我们还将思想领域设为可选。 因此,可以使用以下代码实现带有仅填充最基本字段的构造函数的基本结构:

#[derive(Debug)]
struct Human {
    name: String,
    age: i8,
    current_thought: Option<String>,
    friend: Friend
}
impl Human {    
    fn new(name: &str, age: i8) -> Human {
        return Human{
            name: name.to_string(),
            age: age,
            current_thought: None,
            friend: Friend::NIL
        }
    }
}

因此,创建一个新人类现在只需要以下代码行:

let developer = Human::new("Maxwell Flitton", 32);

这将具有以下字段值:

姓名:麦克斯韦·弗利顿

年龄:32

当前想法:无

朋友:无

我们可以在实现块中添加更多功能来添加朋友和当前的想法,代码如下:

    fn with_thought(mut self, thought: &str) -> Human {
        self.current_thought = Some(thought.to_string());
        return self
    }
    fn with_friend(mut self, friend: Box<Human>) -> Human {
        self.friend = Friend::HUMAN(friend);
        return self
    }

在前面的代码中,我们可以看到我们传入了调用这些函数的结构体的可变版本。 这些函数可以链接起来,因为它们返回调用它们的结构。 如果我们想创建一个有思想的开发人员,我们可以使用以下代码来实现:

let developer = Human::new("Maxwell Flitton", 32)
    .with_thought("I love Rust!");

我们必须注意,不需要 self 作为参数的函数可以用 :: 来调用,而需要 self 作为参数的函数可以用简单的点(.)来调用。 如果我们想和朋友一起创建一个开发者,可以使用下面的代码来完成:

let developer_friend = Human::new("Caroline Morton", 30);
let developer = Human::new("Maxwell Flitton", 32)
    .with_thought("I love Rust!")
    .with_friend(Box::new(developer_friend));
Println!("{:?}", developer);

运行代码将为开发人员提供以下参数:

Name: "Maxwell Flitton"
Age: 32
Current Thought: Some("I love Rust!")
Friend: HUMAN(Human { name: "Caroline Morton", age: 30, 
    current_thought: None, friend: NIL })

我们可以看到,结构体与枚举以及使用这些结构体实现的函数相结合可以成为强大的构建块。 如果我们已经很好地定义了结构,那么我们只需少量代码就可以定义字段和功能。 然而,为多个结构编写相同的功能可能非常耗时,并会导致大量重复代码。 如果您以前使用过对象,那么您可能已经利用过继承。 Rust 会变得更好。 它有特征,我们将在下一节中探讨。

通过特征(trait)进行验证

我们可以看到枚举可以增强结构体的能力,使其可以处理多种类型。 这也可以转换为任何类型的函数或数据结构。 然而,这可能会导致大量重复。 以 User 结构为例。 用户拥有一组核心值,例如用户名和密码。 但是,它们还可以根据角色具有额外的功能。 对于用户,我们必须在启动某些进程之前检查角色。 我们可以通过创建一个简单的玩具程序来包装具有特征的结构,该程序通过以下步骤定义用户及其角色:

我们可以使用以下代码定义我们的用户:

struct AdminUser {

    username: String,

    password: String

}

struct User {

    username: String,

    password: String

}

我们可以在前面的代码中看到 User 和 AdminUser 结构体具有相同的字段。 对于本练习,我们只需要两个不同的结构来演示特征对它们的影响。 现在我们的结构已经定义了,我们可以继续下一步,即创建特征。

我们将在我们的结构中实现这些特征。 我们将拥有的全部特征包括创建、编辑和删除。 我们将使用它们为我们的用户分配权限。 我们可以使用以下代码创建这三个特征:

trait CanEdit {

    fn edit(&self) {

        println!("admin is editing");

    }

}

trait CanCreate {

    fn create(&self) {

        println!("admin is creating");

    }

}

trait CanDelete {

    fn delete(&self) {

        println!("admin is deleting");

    }

}

我们可以在前面的代码中看到 User 和 AdminUser 结构体具有相同的字段。 对于本练习,我们只需要两个不同的结构来演示特征对它们的影响。 现在我们的结构已经定义了,我们可以继续下一步,即创建特征。

我们将在我们的结构中实现这些特征。 我们将拥有的全部特征包括创建、编辑和删除。 我们将使用它们为我们的用户分配权限。 我们可以使用以下代码创建这三个特征:

impl CanDelete for AdminUser {}

impl CanCreate for AdminUser {}

impl CanEdit for AdminUser {}

impl CanEdit for User {

    fn edit(&self) {

        println!("A standard user {} is editing", 

    self.username);

    }

}

从上一步中,我们可以记住,通过打印出管理员正在执行该操作,所有功能都已对管理员起作用。 因此,我们不必为管理员的特征的实现做任何事情。 我们还可以看到,我们可以为单个结构实现多个特征。 这增加了很多灵活性。 在 CanEdit 特征的用户实现中,我们覆盖了编辑函数,以便可以打印出正确的语句。 现在我们已经实现了这些特征,我们的用户结构在代码中具有进入需要这些特征的范围的权限。 我们现在可以构建在下一步中使用这些特征的函数。

我们可以通过直接在已实现它们的结构的主函数中运行它们来利用特征中的函数。 然而,如果我们这样做,我们就不会在这次练习中看到他们的真正威力。 当我们将来跨越多个文件时,我们可能还希望在整个程序中使用这种标准功能。 以下代码显示了我们如何创建利用这些特征的函数:

fn create<T: CanCreate>(user: &T) -> () {

    user.create();

}

fn edit<T: CanEdit>(user: &T) -> () {

    user.edit();

}

fn delete<T: CanDelete>(user: &T) -> () {

    user.delete();

}

前面的符号非常类似于生命周期注释。 我们在输入定义之前使用尖括号来定义我们想要在 T 处接受的特征。然后我们声明我们将接受一个已将特征实现为 &T 的借用结构。 这意味着任何实现该特定特征的结构都可以通过该函数。 因为我们知道特征可以做什么,所以我们可以使用特征函数。

但是,因为我们不知道将要传递什么结构,所以我们无法利用特定字段。 但请记住,当我们实现结构体的特征时,我们可以覆盖特征函数以利用结构体字段。 这可能看起来很严格,但该过程强制执行良好的、隔离的、解耦的安全编码。 例如,假设我们从特征中删除函数或从结构中删除特征。 编译器将拒绝编译,直到此更改的所有效果完成为止。 因此,我们可以看到,尤其是对于大型系统,Rust 是安全的,并且可以通过降低无声错误的风险来节省时间。 现在我们已经定义了函数,我们可以在下一步的 main 函数中使用它们。

我们可以使用以下代码来测试所有特征是否都适用:

fn main() {

    let admin = AdminUser{

        username: "admin".to_string(), 

        password: "password".to_string()

    };

    let user = User{

        username: "user".to_string(), 

        password: "password".to_string()

    };

    create(&admin);

    edit(&admin);

    edit(&user);

    delete(&admin);

}

我们可以看到接受特征的函数的使用就像任何其他函数一样。

运行整个程序将为我们提供以下打印输出:

admin is creating
admin is editing
A standard user user is editing
admin is deleting

在我们的输出中,我们可以看到 User 结构体的编辑函数的覆盖是有效的。

我们现在已经了解了足够多的特征,可以提高 Web 开发的效率。 特征变得更加强大,我们将在网络编程的一些关键部分中使用它们。 例如,一些 Web 框架具有在视图/API 端点处理请求之前执行的特征。 实现具有这些特征的结构会自动加载带有特征函数结果的视图函数。 这可以是数据库连接、从标头中提取token或我们希望使用的任何其他内容。 在进入下一章之前,我们还需要解决最后一个概念,那就是宏。

使用宏进行元编程

元编程通常可以描述为程序可以根据某些指令操纵自身的一种方式。 考虑到 Rust 的强类型,元编程最简单的方法之一就是使用泛型。 演示泛型的一个经典示例是通过坐标,如下所示:

struct Coordinate <T> {
    x: T,
    y: T
}
fn main() {
    let one = Coordinate{x: 50, y: 50};
    let two = Coordinate{x: 500, y: 500};
    let three = Coordinate{x: 5.6, y: 5.6};
}

在前面的代码片段中,我们可以看到坐标结构成功地接收并处理了三种不同类型的数字。 我们可以向坐标结构添加更多变化,这样我们就可以使用以下代码在一个结构中拥有两种不同类型的数字:

struct Coordinate <T, X> {
    x: T,
    y: X
}
fn main() {
    let one = Coordinate{x: 50, y: 500};
    let two = Coordinate{x: 5.6, y: 500};
    let three = Coordinate{x: 5.6, y: 50};
}

前面使用泛型的代码中发生的情况是,编译器正在查找使用该结构的所有实例,并使用编译运行时使用的类型创建结构。 现在我们已经介绍了泛型,我们可以继续讨论元编程的主要机制:宏。

宏使我们能够抽象代码。 我们已经在打印函数中使用了宏。 这 ! 函数末尾的符号表示这是一个正在被调用的宏。 定义我们自己的宏是定义函数和在函数的匹配语句中使用生命周期表示法的结合。 为了演示这一点,我们将使用以下代码定义一个将字符串大写的宏:

macro_rules! capitalize {
    ($a: expr) => {
        let mut v: Vec<char> = $a.chars().collect();
        v[0] = v[0].to_uppercase().nth(0).unwrap();
        $a = v.into_iter().collect();
    }
}
fn main() {
    let mut x = String::from("test");
    capitalize!(x);
    println!("{}", x);
}

我们不使用术语 fn,而是使用 Macro_rules! 定义。 然后我们说 $a 是传递到宏中的表达式。 我们获取表达式,将其转换为字符向量,然后将第一个字符变为大写,然后将其转换回字符串。 我们必须注意,我们不会在大写宏中返回任何内容,并且当我们调用该宏时,我们不会为其分配变量。 然而,当我们在最后打印 x 变量时,我们可以看到它是大写的。 这与普通函数的行为不同。 我们还必须注意,我们没有定义类型,相反,我们只是说它是一个表达式; 宏仍然通过特征进行检查。 将整数传递到宏中会产生以下错误:

|     capitalize!(32);
|     ---------------- in this macro invocation
|
= help: the trait `std::iter::FromIterator<char>` is not implemented for `{integer}`

也可以传递生命周期、块、文字、路径、元编程等,而不是表达式。 虽然简要了解基本宏的底层内容对于调试和进一步阅读很重要,但更多地深入开发复杂的宏将无助于我们开发 Web 应用程序。 我们必须记住,宏是最后的手段,应该谨慎使用。 宏中抛出的错误可能很难调试。 在 Web 开发中,许多宏已经在第三方包中定义。 因此,我们不需要自己编写宏来启动和运行网络应用程序。 相反,我们将主要使用开箱即用的派生宏。

概括

对于 Rust,我们已经看到,来自动态编程语言背景时存在一些陷阱。 然而,只要了解一点引用和基本内存管理的知识,我们就可以避免常见的陷阱,并快速编写可以处理错误的安全、高性能的代码。 通过利用结构和特征,我们可以构建类似于标准动态编程语言中的类的对象。 最重要的是,这些特征使我们能够构建类似 mixin 的功能。 这不仅使我们能够在对我们有用时插入功能,而且还可以通过键入对结构进行检查,以确保容器或函数正在处理具有属于我们可以在代码中使用的特征的某些属性的结构。

凭借我们功能齐全的结构,我们通过宏附加了更多功能,并通过构建我们自己的大写函数来了解基本宏的底层,为我们进一步阅读和调试提供指导。 我们还看到了一个简短的演示,说明宏与结构的结合在使用 JSON 序列化的 Web 开发中是多么强大。 根据本章所学,我们现在可以编写基本的 Rust 程序了。 因为我们了解借用检查器强制执行的概念,所以我们可以调试我们编写的应用程序。 与其他语言一样,我们可以做的实际应用程序还很有限。 然而,我们确实拥有构建在我们自己的本地计算机上运行的跨多个文件的实际应用程序的必要基础。

我们现在可以进入下一章,研究在我们自己的计算机上设置 Rust 环境来构建文件和代码,使我们能够构建可以解决现实世界问题的程序。

问题

str 和 String 有什么区别?

为什么不能将字符串切片传递到函数中(字符串切片表示 str,而不是 &str)?

我们如何访问属于 HashMap 中某个键的数据?

当一个函数出错时,我们可以处理其他进程吗?或者错误会立即导致程序崩溃吗?

为什么 Rust 在某个时间点只允许一次可变借用?

什么时候我们需要在函数中定义两个不同的生命周期?

结构如何通过其字段之一链接到同一结构?

我们如何向结构体添加额外的功能,并且该功能也可以由其他结构体实现?

我们如何允许容器或函数接受不同的数据结构?

将特征(例如 Copy)添加到结构的最快方法是什么?

答案

String是存储在栈中的固定大小的引用,指向堆上的字符串类型数据。 str 是存储在内存中某处的不可变字节序列。 因为str的大小未知,所以只能通过&str指针来处理。

由于我们在编译时不知道字符串切片的大小,因此无法为其分配正确的内存量。 另一方面,字符串在堆栈上存储了一个固定大小的引用,该引用指向堆上的字符串切片。 因为我们知道字符串引用的固定大小,所以我们可以分配正确的内存量并将其传递给函数。

我们使用HashMap的get函数。 但是,我们必须记住,get 函数仅返回一个 Option 结构。 如果我们确信那里有东西,或者我们希望程序在没有找到任何东西的情况下崩溃,我们可以直接解开它。 但是,如果我们不希望这样,我们可以使用 match 语句并根据需要处理 Some 和 None 输出。

不可以,在暴露错误之前必须解开结果。 一个简单的匹配语句可以处理解包结果并按照我们认为合适的方式管理错误。

Rust 只允许一种可变借用,以防止内存不安全。 Goregaokar 的博客中用枚举的例子来说明这一点。 如果枚举支持两种不同的数据类型(String 和 i64),如果对枚举的字符串变体进行了可变引用,然后进行了另一个引用,则该可变引用可以更改数据,然后第二个引用仍然会发生变化。 引用枚举的字符串变体。 然后,第二个引用将尝试取消引用枚举的字符串变体,这可能会导致分段错误。 进一步阅读部分提供了对此示例和其他示例的详细说明。

当函数的结果依赖于其中一个生命周期并且函数的结果需要在调用范围之外时,我们需要定义两个不同的生命周期。

如果一个结构体在其字段之一中引用自身,则大小可能是无限的,因为它可以继续连续地引用自身。 为了防止这种情况,我们可以将对字段中结构的引用包装在 Box 结构中。

我们可以通过使用特征将额外的功能和自由度添加到结构中。 实现一个特征将使结构体能够使用属于该特征的函数。 该特征的实现还允许结构体通过该特征的类型检查。

我们允许容器或函数通过在类型检查中声明枚举或特征或利用泛型来接受不同的数据结构(请参阅进一步阅读部分:掌握 Rust 或 Rust 中的函数式编程实践(第一章))。

将特征添加到结构的最快方法是使用具有复制和克隆特征的派生宏注释该结构。