Rustfully-Rust-编程笔记-全-

Rustfully Rust 编程笔记(全)

001:你的第一个Rust程序(如何安装Rust) 🚀

在本节课中,我们将要学习如何在你的计算机上安装 Rust。这是开始 Rust 编程的第一步。

概述

安装 Rust 是编写 Rust 程序的第一步。我们将通过官方工具 rustup 来完成安装,并学习如何创建、编译和运行你的第一个 Rust 程序。我们还将介绍 Rust 的构建系统和包管理器 cargo,它能极大地简化开发流程。

安装 Rust

首先,你需要访问 Rust 官方网站的安装页面。为了方便,你可以在视频描述中找到该页面的链接。

当你打开安装页面时,首先会看到一个绿色区域,标题是“使用 rustup”。这是安装 Rust 的推荐方法。

对于 macOS 和 Linux 用户,你只需要复制页面上的命令,并在终端中执行它。该命令会下载安装器,并询问一些关于安装配置的问题。

以下是安装步骤:

  1. 复制命令并在终端中运行。
  2. 安装器会提供选项:标准安装、自定义安装或取消安装。
  3. 选择标准安装(通常按 1 然后回车)即可。

如果一切顺利,你将看到一行提示,表明 Rust 已成功安装。

对于 Windows 用户,过程略有不同。你需要点击“其他安装方式”,然后很可能需要选择“独立安装程序”并根据你的系统选择合适的版本进行下载安装。

安装完成后,验证安装是否成功非常重要。你可以在终端中输入以下命令来检查 Rust 编译器的版本:

rustc --version

如果安装成功,该命令会返回类似 rustc 1.86.0 的版本号。至此,Rust 安装完成。

创建第一个 Rust 程序

上一节我们介绍了如何安装 Rust,本节中我们来看看如何创建并运行你的第一个程序。

首先,创建一个新文件夹作为项目目录,并用你喜欢的代码编辑器打开它。接下来,在该文件夹内创建一个名为 main.rs 的新文件。

现在,我们可以开始编写第一个 Rust 脚本。在 main.rs 文件中输入以下代码:

fn main() {
    println!("Hello, world!");
}

这段代码定义了一个 main 函数,这是每个可执行 Rust 程序的入口点。函数体内使用了 println! 宏来向屏幕输出一行文本。宏名后面的 ! 表示这是一个宏而不是普通函数,关于宏的细节我们将在后续视频中解释。最后,语句以分号 ; 结尾,表示表达式结束。

编译与运行程序

要运行这个程序,你需要先打开终端。在 Windows 上,你可能需要运行 .\main.exe;在 macOS 和 Linux 上,则是 ./main

但是,如果你直接尝试运行,可能会得到一个错误,因为代码尚未编译成可执行文件。

为了编译代码,你需要使用 rustc 编译器。在终端中,导航到你的项目目录,然后执行以下命令:

rustc main.rs

这个命令会将 main.rs 文件编译成可执行文件。编译成功后,你会在项目文件夹中看到生成的可执行文件(例如 mainmain.exe)。现在,你可以使用对应的命令(./main.\main.exe)来运行程序,并看到输出 Hello, world!

然而,每次修改代码后,你都需要重新执行 rustc main.rs 来编译,然后再运行,这个过程有些繁琐。

使用 Cargo 工具

你可能会想,每次运行程序都要先编译,肯定有更简单的方法。幸运的是,Rust 提供了一个名为 cargo 的强大工具。

cargo 是 Rust 的构建系统和包管理器。借助 cargo,我们可以同时完成编译和运行。

首先,让我们使用 cargo 创建一个新项目。在终端中(可以退出当前项目目录),执行以下命令:

cargo new hello_world

这个命令会创建一个名为 hello_world 的新目录,其中包含一个预设的 Rust 项目结构,包括 Cargo.toml 文件(用于管理项目信息和依赖)和 src 目录(内含一个已经写好的 main.rs 文件)。

创建后,使用 cd 命令进入新项目目录:

cd hello_world

现在,我们可以使用 cargo 命令了。cargo build 命令用于编译项目。编译后的可执行文件位于 target/debug/ 目录下。你可以通过一个较长的路径来运行它,例如在 Unix 系统上:

./target/debug/hello_world

但更常用的命令是 cargo run。这个命令会先编译代码(如果代码有变动),然后立即运行它。这样,你修改代码后,只需一个命令就能看到最新结果。

cargo run

此外,cargo 还提供了一个非常有用的 cargo check 命令。这个命令会快速检查你的代码是否能通过编译,而无需真正生成可执行文件。这在开发过程中用于快速发现语法错误非常高效。

cargo check

总结

本节课中我们一起学习了 Rust 编程的起点。我们首先通过 rustup 安装了 Rust 工具链,并验证了安装。然后,我们手动创建了一个简单的 Rust 程序,并使用 rustc 编译器进行编译和运行。最后,我们介绍了更强大的 cargo 工具,它能够管理项目、自动处理依赖,并通过 cargo runcargo check 等命令极大地简化了编译和检查流程。现在你已经准备好开始探索 Rust 的世界了。

002:Rust 代码结构解析 🧬

在本节课中,我们将详细解析上一节视频中看到的 Rust 代码结构。我们将学习 main 函数、代码块、println! 宏以及语句结束符等核心概念,帮助你理解一个基础 Rust 程序是如何组织的。

上一节我们运行了第一个 Rust 程序,本节中我们来看看构成这个程序的各个部分具体是什么含义。

函数定义与 main 函数

在 Rust 中,我们使用关键字 fn 来定义一个函数。main 函数非常特殊,因为它是程序的入口点,总是首先运行。

定义函数的语法如下:

fn function_name() {
    // 函数体
}

代码块与花括号

我们使用花括号 {} 来开启一个代码块。按照惯例,花括号通常与函数定义放在同一行开始,但编译器并不会阻止你使用其他格式。

例如,以下两种写法都是有效的,但第一种是推荐格式:

// 推荐格式
fn main() {
    println!("Hello");
}

// 不推荐但有效的格式
fn main()
{
    println!("Hello");
}

使用 println! 宏输出信息

为了向控制台打印信息,我们使用 println! 宏。请注意,println 后面有一个感叹号 !,这表示它是一个宏而不是普通函数。宏与函数略有不同,我们将在后续课程中详细讲解。

目前,你只需要知道 println! 用于输出信息。我们使用双引号 " 来定义一个字符串。

以下是字符串定义的代码示例:

println!("Hello Bob");

在许多编程语言中,定义字符串通常使用双引号。在 Rust 中,必须使用双引号,单引号 ' 有其它用途(表示字符类型)。

语句与分号

在 Rust 中,分号 ; 表示一个表达式的结束,并准备开始下一个表达式。这对于编译器理解代码结构至关重要。

以下是添加多个打印语句的示例。每个 println! 调用后都需要分号:

fn main() {
    println!("Hello World");
    println!("Hello Bob");
}

如果省略分号,编译器将无法区分两个语句,导致程序无法编译运行。例如,以下缺少分号的代码是错误的:

fn main() {
    println!("Hello World") // 错误:缺少分号
    println!("Hello Bob")
}

核心要点总结

本节课我们一起学习了 Rust 程序的基本解剖结构:

  1. 使用 fn 关键字定义函数,main 函数是程序入口。
  2. 代码块由花括号 {} 界定。
  3. 使用 println! 宏(注意感叹号)向控制台输出信息。
  4. 字符串必须使用双引号 " 定义。
  5. 语句末尾需要使用分号 ; 表示结束。

一个最基本的 Rust 程序就由这些元素构成。当然,实际程序会更加复杂,包含更多逻辑。在下一节视频中,我们将构建一个小项目,让你更好地理解一个完整的 Rust 程序(或脚本)应该如何编写和运作。

003:编写你的第一个Rust项目 🎯

在本节课中,我们将通过构建一个简单的“猜数字”游戏来编写你的第一个 Rust 项目。这个项目将帮助你初步了解 Rust 语言的基本结构和语法。


创建新项目 🚀

首先,我们需要使用 Cargo 创建一个新的 Rust 项目。Cargo 是 Rust 的包管理器和构建工具。

打开终端,输入以下命令来创建一个名为 guessing_game 的新项目:

cargo new guessing_game

项目创建完成后,使用 cd 命令进入项目目录:

cd guessing_game

现在,你可以打开 src/main.rs 文件开始编写代码。为了确保环境配置正确,你可以运行 cargo run 命令。如果程序编译成功并打印出 “Hello, world!”,说明一切就绪。


添加欢迎信息与生成随机数 🎲

上一节我们创建了项目,本节中我们来看看如何为游戏添加欢迎信息并生成一个随机数。

首先,在 main.rs 文件中,我们添加一个欢迎信息:

println!("猜数字!");

接下来,我们需要生成一个 1 到 100 之间(包含两端)的随机数。这需要使用外部库(在 Rust 中称为 crate)。我们将使用 rand 这个 crate。

在代码顶部,我们尝试导入 rand

use rand::Rng;

但是,rand 是一个外部 crate,默认不在 Rust 的标准库中。因此,我们需要先将其添加到项目的依赖中。

以下是添加依赖的步骤:

  1. 打开终端,在项目根目录下运行:
    cargo add rand
    
  2. 这条命令会自动更新 Cargo.toml 文件,在 [dependencies] 部分添加 rand

依赖添加完成后,我们就可以在代码中生成随机数了。我们创建一个名为 secret_number 的变量来存储这个秘密数字:

let secret_number: u32 = rand::thread_rng().gen_range(1..=100);
  • let 关键字用于声明变量。
  • u32 表示这是一个 32 位无符号整数类型。
  • rand::thread_rng() 获取一个随机数生成器。
  • gen_range(1..=100) 生成一个 1 到 100(包含)之间的随机数。

为了调试方便,我们可以先打印出这个秘密数字:

println!("秘密数字是:{}", secret_number);

注意:Rust 中语句的结尾需要加上分号 ;

现在,运行 cargo run,你应该能看到类似 “秘密数字是:81” 的输出。


获取用户输入 🔄

上一节我们生成了随机数,本节中我们将创建一个循环,让用户可以持续输入猜测,直到猜中为止。

首先,我们创建一个无限循环:

loop {
    println!("请输入你的猜测:");
}

在循环内部,我们需要获取用户的输入。为此,我们先创建一个可变的(mutable)字符串变量来存储输入:

let mut guess = String::new();
  • 在 Rust 中,变量默认是不可变的(immutable)。使用 mut 关键字可以声明一个可变变量。
  • String::new() 创建一个新的空字符串。

接下来,我们需要从标准输入(stdin)读取一行内容。这需要使用标准库 std::io 中的功能。在文件顶部添加导入:

use std::io;

然后,在循环内使用以下代码读取输入:

io::stdin()
    .read_line(&mut guess)
    .expect("读取行失败");
  • io::stdin() 获取标准输入句柄。
  • .read_line(&mut guess) 将用户输入的内容读取到 guess 变量中。&mut 表示这是一个可变引用,允许函数修改 guess 的值。
  • .expect("...") 用于处理可能出现的错误。如果读取失败,程序会崩溃并显示给定的错误信息。

用户输入的内容是字符串,但我们需要一个整数来与秘密数字进行比较。因此,我们需要进行类型转换:

let guess: u32 = guess.trim().parse().expect("请输入一个有效的数字!");
  • .trim() 去除字符串首尾的空白字符(如换行符)。
  • .parse() 尝试将字符串解析为指定的类型(这里是 u32)。
  • 同样使用 .expect() 来处理解析失败的情况。

为了给用户反馈,我们可以打印出他们的猜测:

println!("你猜的是:{}", guess);

现在,运行程序 cargo run。你可以尝试输入数字(如 10, 20),程序会重复提示你输入。如果输入非数字(如 “Bob”),程序会显示错误信息 “请输入一个有效的数字!”。


比较猜测与秘密数字 🏆

上一节我们实现了用户输入,本节中我们来实现游戏的核心逻辑:比较用户的猜测与秘密数字,并给出提示。

首先,我们需要导入用于比较的功能,它也在标准库中:

use std::cmp::Ordering;

在将用户输入转换为整数 guess 之后,我们使用 match 表达式来进行比较。match 是 Rust 中强大的控制流运算符,它根据值的不同模式执行不同的代码分支。

以下是实现比较的逻辑:

match guess.cmp(&secret_number) {
    Ordering::Less => println!("太小了!"),
    Ordering::Greater => println!("太大了!"),
    Ordering::Equal => {
        println!("恭喜你,猜对了!🎉");
        break;
    }
}
  • guess.cmp(&secret_number)guesssecret_number 进行比较,返回一个 Ordering 类型的枚举值。
  • Ordering::Less 表示猜测小于秘密数字。
  • Ordering::Greater 表示猜测大于秘密数字。
  • Ordering::Equal 表示猜测等于秘密数字。此时,我们打印胜利信息,并使用 break 关键字跳出循环,结束游戏。

现在,完整的游戏已经实现了!运行 cargo run 开始游戏。程序会提示你输入数字,并根据你的猜测给出“太小了”、“太大了”或“恭喜你,猜对了!”的反馈。猜对后程序会自动退出。

为了让游戏更具挑战性,你可以注释掉打印秘密数字的那行代码(println!("秘密数字是:{}", secret_number);),这样你就不知道答案了。


总结与挑战 📚

本节课中我们一起学习了如何构建第一个 Rust 项目——“猜数字”游戏。我们涵盖了以下核心概念:

  1. 使用 cargo new 创建项目。
  2. 使用 cargo add 添加外部依赖(crate)。
  3. 使用 let 声明变量,使用 mut 使其可变。
  4. 使用 rand::thread_rng().gen_range() 生成随机数。
  5. 使用 std::io 处理用户输入,并用 trim().parse() 进行类型转换。
  6. 使用 match 表达式和 std::cmp::Ordering 进行比较判断。
  7. 使用 break 退出循环。

挑战作业:尝试为游戏添加一个新功能:记录并告诉用户他们猜了多少次才猜中数字。例如,在输出“恭喜你,猜对了!”之后,添加一行:“你用了 {} 次尝试。”。

004:变量与常量 🧱

在本节课中,我们将要学习 Rust 中变量的核心概念:不可变变量、可变变量以及常量。理解它们之间的区别对于掌握 Rust 编程至关重要。

不可变变量

在 Rust 中,默认创建的变量都是不可变的。这意味着一旦给变量赋值,其值就不能再被改变。

我们使用 let 关键字来声明一个变量。例如,下面的代码创建了一个名为 number 的变量,并赋值为 10

let number = 10;

默认情况下,number 是不可变的。我们可以打印它的值:

println!("number is equal to {}", number);

运行代码会输出:number is equal to 10

现在,如果我们尝试改变 number 的值,例如将其改为 20

let number = 10;
number = 20; // 这行代码会导致编译错误
println!("number is equal to {}", number);

Rust 编译器会给出一个明确的错误:cannot assign twice to immutable variable。这证实了默认变量的不可变性。

可变变量

上一节我们介绍了不可变变量,本节中我们来看看如何创建可变的变量。如果你希望一个变量的值在未来可以被修改,就需要使用 mut 关键字将其声明为可变的。

以下是创建可变变量的方法:

let mut number = 10;

现在,我们可以自由地改变 number 的值:

let mut number = 10;
number = 20; // 现在这是允许的
println!("number is equal to {}", number);

我们还可以对可变变量进行运算,例如自增操作:

let mut number = 10;
number += 1; // number 现在等于 11
println!("number is equal to {}", number);

记住一个简单的规则:如果你认为一个变量的值将来可能需要改变,就使用 let mut 来声明它。

变量命名规范

在深入常量之前,我们先了解一下 Rust 的命名规范。与 Python 类似,Rust 对变量名使用蛇形命名法

以下是蛇形命名法的示例:

let first_name = "Federico";
println!("My name is {}", first_name);

蛇形命名法意味着单词之间用下划线 _ 连接,且全部使用小写字母。

常量

现在,让我们转向常量。常量与不可变变量有相似之处,但也有几个关键区别。

常量使用 const 关键字声明,并且必须在声明时显式标注类型。常量的命名规范是使用全大写字母和蛇形命名法。

以下是定义常量的方法:

const ONE_MINUTE: u32 = 60;
const ONE_HOUR: u32 = ONE_MINUTE * 60;

关于常量,有两点非常重要:

  1. 常量的值必须在编译时确定,不能是运行时计算的结果。
  2. 常量总是不可变的。

常量通常用于表示程序中永不改变的重要值,例如数学常数:

const PI: f64 = 3.1415;
println!("Pi is {}", PI);

常量的一个特殊优势是,它们可以在任何作用域中定义,包括全局作用域,而 Rust 编译器不会报错。这是普通变量(使用 let 声明)所不具备的特性。

总结

本节课中我们一起学习了 Rust 中三种存储数据的方式:

  • 不可变变量:使用 let 声明,默认不可更改。
  • 可变变量:使用 let mut 声明,值可以后续修改。
  • 常量:使用 const 声明,必须标注类型,命名全大写,值在编译时确定且永远不可变。

理解它们之间的区别有助于你编写更安全、意图更明确的 Rust 代码。在下一节,我们将探讨 Rust 中的各种数据类型。

005:变量遮蔽 🎭

在本节课中,我们将要学习 Rust 中一个独特且实用的概念:变量遮蔽。这个概念允许我们在同一作用域或不同作用域内,使用相同的变量名来存储不同的值,甚至是不同的数据类型,而无需编写额外的代码。

什么是变量遮蔽?

变量遮蔽允许我们“重新声明”一个已存在的变量名,为其赋予新的值或类型。这与修改变量(可变性)不同,因为它实际上是创建了一个同名的新变量。

作用域内的变量遮蔽

上一节我们介绍了变量遮蔽的基本概念,本节中我们来看看它在不同作用域中的具体表现。

以下是一个在不同作用域中使用变量遮蔽的例子:

fn main() {
    let n = 5; // 外部作用域的 n

    {
        let n = 10; // 内部作用域遮蔽了外部的 n
        println!("内部 n 是 {}", n);
    }

    println!("外部 n 是 {}", n);
}

运行这段代码,输出将是:

内部 n 是 10
外部 n 是 5

内部作用域中的 let n = 10; 创建了一个新的变量 n,它遮蔽了外部作用域的同名变量。当离开内部作用域后,外部的 n 依然保持其原始值 5

如果我们在内部作用域中不使用 let 关键字重新声明,而是直接使用 n,那么它将引用外部作用域的变量。

fn main() {
    let n = 5;

    {
        // 这里没有使用 let,所以 n 引用的是外部的变量
        println!("内部 n 是 {}", n); // 输出:内部 n 是 5
    }

    println!("外部 n 是 {}", n); // 输出:外部 n 是 5
}

改变数据类型的变量遮蔽

变量遮蔽的一个强大之处在于,它允许我们改变变量的数据类型。这是使用可变变量(mut)无法做到的。

假设我们有一个字符串,我们想计算它的长度,并将结果存储回同一个变量名中。

以下是使用变量遮蔽的方法:

fn main() {
    let spaces = "      "; // 这是一个 &str 类型的字符串
    let spaces = spaces.len(); // 遮蔽:spaces 现在是一个 usize 类型的整数

    println!("空格数量是 {}", spaces); // 输出:空格数量是 6
}

在这个例子中:

  1. 第一个 spaces 是一个字符串切片(&str)。
  2. 第二个 let spaces = ... 重新声明了 spaces,并将其值设置为字符串的长度,这是一个 usize 类型的整数。
  3. 从此以后,spaces 就代表这个整数值。

为什么不用可变变量(mut)?

你可能会想,为什么不直接声明一个可变变量然后修改它呢?让我们试试看:

fn main() {
    let mut spaces = "      "; // 可变字符串
    spaces = spaces.len(); // 错误:尝试将 usize 赋值给 &str
}

这段代码无法编译。Rust 是强类型语言,一个变量一旦被声明为某种类型(如 &str),就不能被赋予另一种类型(如 usize)的值,即使它是可变的。

以下是两种方式的对比:

  • 使用 mut:只能改变,不能改变类型
  • 使用遮蔽:可以改变,也可以改变类型

因此,当你需要重用变量名但赋予其全新含义(可能包括新类型)时,变量遮蔽是更合适的选择。

总结

本节课中我们一起学习了 Rust 中的变量遮蔽。

我们了解到:

  • 变量遮蔽通过 let 关键字重新声明同名变量来实现。
  • 它可以在不同作用域中创建独立的变量,互不影响。
  • 它的一个关键优势是能够改变变量的数据类型,这是可变变量无法做到的。
  • 变量遮蔽使代码更简洁,特别是在需要转换数据并重用变量名的场景中。

记住,变量遮蔽创建的是新变量,而 mut 是修改原有变量。根据你的需求选择合适的方式。

006:Rust 中的数据类型 🧱

在本节课中,我们将要学习 Rust 语言中的数据类型。这是一个非常重要的概念,因为 Rust 是一门静态类型语言,这意味着编译器必须在编译时知晓所有变量的类型。

通常,编译器能够根据上下文推断出变量的类型。但在某些情况下,你需要更明确地指定类型。例如,当你需要将一种类型转换为另一种类型时,就必须明确指出目标类型。

类型推断与显式声明

Rust 编译器通常能根据上下文推断变量类型。例如,一个被赋值为 "100" 的变量,其类型会被推断为字符串。

let user_input = "100"; // 类型被推断为 &str 或 String

然而,当你需要将这个字符串转换为整数时,就必须进行显式类型声明。Rust 无法自动知道你想转换成哪种整数类型。

以下是如何进行显式类型转换的示例。我们使用 .parse() 方法,并指定目标类型为 u32(32位无符号整数)。

let converted: u32 = user_input.parse().expect("无法解析为数字");
println!("转换后的值是: {}", converted);

完成转换后,该变量就可以参与数学运算了。

let result = converted + 100; // 现在可以进行加法运算
println!("转换后的值加 100 等于: {}", result);

数据类型分类 📊

上一节我们介绍了类型推断与显式声明,本节中我们来看看 Rust 数据类型的两个主要子集:标量类型和复合类型。

标量类型

标量类型代表单个值。Rust 有四种主要的标量类型:整数、浮点数、布尔值和字符。

以下是每种标量类型的示例:

  • 整数:表示没有小数部分的数字。
    let integer_example: i8 = 10; // 8位有符号整数
    
  • 浮点数:表示带有小数点的数字。
    let float_example: f32 = 3.1415; // 32位浮点数
    
  • 布尔值:表示逻辑真或假,只有两种状态。
    let boolean_example: bool = false; // 布尔值 false
    
  • 字符:表示单个 Unicode 标量值,使用单引号。
    let char_example: char = 'Δ'; // 字符类型
    

复合类型

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

以下是每种复合类型的示例:

  • 元组:将多个不同类型的值组合成一个复合类型。
    let tuple_example: (f32, f32) = (1.5, 2.5); // 包含两个 f32 的元组
    
  • 数组:将多个相同类型的值组合成一个固定长度的集合。
    let array_example: [&str; 3] = ["Bob", "Luis", "Ashley"]; // 包含3个字符串的数组
    

总结

本节课中我们一起学习了 Rust 数据类型的基础知识。我们了解到 Rust 是静态类型语言,认识了类型推断和显式类型声明的使用场景。我们还将数据类型分为标量类型(如整数、浮点数、布尔值、字符)和复合类型(如元组、数组),它们分别用于表示单个值和多个值的组合。在接下来的课程中,我们将对这些类型进行更深入的探讨。

007:详解Rust中的整数 🧮

在本节课中,我们将要学习 Rust 编程语言中的整数类型。整数是编程中最基础的数据类型之一,理解其工作原理对于编写正确的 Rust 程序至关重要。

有符号与无符号整数

在 Rust 中,整数主要分为两种类型:有符号整数和无符号整数。它们的核心区别在于能否表示负数。

以下是创建这两种整数的一个例子:

let n1: i8 = 10; // 有符号 8 位整数
let n2: u8 = 200; // 无符号 8 位整数

有符号整数可以包含负值,而无符号整数则始终为正值。但更重要的是,它们都受到“位宽”的限制,这决定了它们能存储的数值范围。

理解整数范围

对于 8 位整数,其数值范围是固定的:

  • 有符号整数 i8 的范围是 -128 到 127
  • 无符号整数 u8 的范围是 0 到 255

两者都包含 256 个不同的数值(i8:-128到127共256个;u8:0到255共256个)。有符号整数的上限(127)低于无符号整数(255),因为它需要分配一半的范围来表示负数。

选择正确的整数类型非常重要。如果你尝试将一个超出范围的值赋给变量,程序将无法编译。例如:

let n: i8 = 1000; // 错误:字面量超出 i8 的范围

编译器会报错 literal out of range for i8。现代代码编辑器通常会在你悬停于数字上时给出提示,帮助你选择正确的数据类型。对于 1000 这个值,你应该选择 i16 或更大的类型。

默认整数类型与位宽

如果你在声明变量时不指定整数类型,Rust 会默认使用 i32,即 32 位有符号整数。

32 位整数的范围非常大:

  • i32 范围:-2,147,483,648 到 2,147,483,647
  • u32 范围:0 到 4,294,967,295

对于日常的大多数运算,32 位整数已经足够。Rust 提供了多种位宽的整数类型供你选择。

以下是 Rust 中可用的整数类型概览:

长度 有符号类型 无符号类型
8 位 i8 u8
16 位 i16 u16
32 位 i32 u32
64 位 i64 u64
128 位 i128 u128
架构位 isize usize

表格底部的 isizeusize 类型比较特殊,它们的位宽取决于程序运行所在计算机的架构(例如 32 位或 64 位系统)。

查询类型的极值与溢出行为

上一节我们介绍了整数类型,本节中我们来看看如何获取类型的范围信息,以及当数值超出范围时会发生什么。

你可以使用 MAXMIN 关联函数来获取任何整数类型的最大值和最小值:

println!("i8 最大值: {}, 最小值: {}", i8::MAX, i8::MIN);
println!("isize 最大值: {}, 最小值: {}", isize::MAX, isize::MIN);

在 64 位系统上,isize::MAX 的值将与 i64::MAX 相同。

接下来,我们探讨一个重要的概念:整数溢出。当你对一个已经达到其类型最大值的变量进行递增操作时,就会发生溢出。

考虑以下代码:

let mut x: u8 = 255; // u8 的最大值
x += 10; // 尝试增加 10,这将导致溢出
println!("x = {}", x);

这段代码的行为取决于编译模式:

  • 调试(debug)模式 下运行,程序会“恐慌”(panic)并崩溃,这有助于在开发阶段发现问题。
  • 发布(release)模式 下运行,程序会进行“环绕”(wrapping),即从最大值回到最小值继续计算。对于 u8,255 加 1 变成 0,再加 9 变成 9。因此,最终输出 x = 9,而不是预期的 265。

这种环绕行为是为了避免程序在发布版本中崩溃,但它可能导致非预期的逻辑错误。因此,在编程时应始终确保选择足够大的整数类型来避免溢出。

数值字面量的格式化技巧

在结束之前,我想分享一个非常实用的小技巧,用于提高大数值字面量的可读性。

假设你需要定义一个代表“一万亿”的变量:

let trillion: i64 = 1000000000000; // 难以阅读和核对

手动数零很容易出错。Rust 允许你在数字字面量中使用下划线 _ 作为视觉分隔符,编译器会忽略这些下划线:

let trillion: i64 = 1_000_000_000_000; // 清晰易读
let formatted: i64 = 1_00_00_00_00_0000; // 你也可以使用任意分组方式

当你打印这些变量时,输出结果不会包含下划线。这个技巧能极大提升代码的可读性和可维护性,尤其是在处理财务、科学计算等涉及大数字的场景中。

本节课中我们一起学习了 Rust 中整数的核心知识:有符号与无符号整数的区别、不同位宽整数类型的数值范围、默认的 i32 类型、如何查询类型的极值、整数溢出的概念及其在不同编译模式下的行为,以及使用下划线格式化大数值字面量的实用技巧。理解这些内容是安全、高效地使用 Rust 进行数值计算的基础。在下一节课中,我们将学习另一种基础数值类型:浮点数。

008:Rust 中的浮点数 🧮

在本节课中,我们将要学习 Rust 中的浮点数类型。浮点数用于表示带有小数部分的数字,例如 3.142.718。我们将了解 Rust 提供的两种浮点数类型,它们的特点,以及在使用时需要注意的一些关键问题。

上一节我们介绍了整数类型,本节中我们来看看 Rust 如何处理小数。

浮点数类型:F32 与 F64

Rust 为浮点数提供了两种基本类型:f32f64。它们分别代表 32 位浮点数和 64 位浮点数。

当你在 Rust 中创建一个十进制数时,例如:

let pi = 3.1415;

这个变量 pi 默认会成为 f64 类型。默认选择 f64 而非 f32 的原因是现代 CPU 处理两者的速度大致相同,但 f64 拥有更高的精度。

为了直观展示两种类型的区别,我们可以显式声明类型并赋值。

以下是创建两种浮点数的示例:

// 创建一个 f32 类型的变量,精度约为7位十进制数字
let pi: f32 = 3.1415927;

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/6482b74ff40234ec224bd03c90c60300_6.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/6482b74ff40234ec224bd03c90c60300_7.png)

// 创建一个 f64 类型的变量,精度约为15位十进制数字
let decimal: f64 = 2.718281828459045;

f32 类型大约可以精确表示 7 位十进制数字,而 f64 类型则能表示大约 15 位十进制数字,精度大约翻倍。

打印与精度限制

接下来,我们尝试将这两个变量打印到控制台。

println!("pi = {}", pi);
println!("decimal = {}", decimal);

运行程序后,你会看到输出的浮点数。如果你尝试为变量赋予超出其精度范围的值,例如在小数部分添加更多数字,Rust 会进行截断或舍入。f32f64 在处理超限数字时的具体行为可能略有不同,这取决于具体的数值和舍入规则。

与整数类型一样,你必须尊重所指定类型的精度限制。

浮点数运算的精度问题

浮点数还有一个重要特性需要注意,那就是运算时的精度问题。我们通过一个例子来说明。

在这个例子中,我们将创建两个 f64 变量并进行加法运算。

let a: f64 = 0.1;
let b: f64 = 0.2;
let sum: f64 = a + b;
println!("The sum is: {}", sum);

运行这段代码后,你可能会惊讶地发现,输出的结果并非精确的 0.3,而是一个类似 0.30000000000000004 的数值。

这是因为十进制小数在二进制计算机中难以被精确表示。因此,在没有外部库辅助的情况下,你不能完全依赖浮点数运算的完美精度。

这个问题在比较两个浮点数是否相等时会变得尤为棘手。

例如,如果我们尝试判断 sum 是否等于 0.3

println!("Is the sum equal to 0.3? {}", sum == 0.3);

输出结果将是 false。因为 sum 的实际值(如 0.30000000000000004)与字面值 0.3 在二进制表示上并不完全相同。== 运算符检查的是两个值是否完全相等。

总结

本节课中我们一起学习了 Rust 的浮点数。

  • 我们使用 f32f64 类型来表示编程中的小数和分数。
  • f64 是默认类型,它比 f32 精度更高。
  • 浮点数类型有固定的精度限制,赋值时超出的部分会被处理。
  • 由于二进制表示的固有特性,浮点数运算可能存在微小的精度误差,直接使用 == 比较两个浮点数是否相等通常不可靠。

在未来的视频中,我们将学习如何正确地执行需要高精度的浮点数计算。但就今天的内容而言,以上就是关于 Rust 浮点数的基本介绍。

009:详解布尔类型与字符类型 🔤

在本节课中,我们将学习 Rust 中两种常用的基本数据类型:布尔类型字符类型。布尔类型用于表示逻辑上的真与假,而字符类型用于表示单个 Unicode 字符。掌握它们是编写具有逻辑判断和文本处理能力程序的基础。

布尔类型:真与假

布尔类型是编程中最基础的数据类型之一,它只有两个可能的值:true(真)和 false(假)。在 Rust 中,其类型标注为 bool

以下是定义布尔变量的示例:

let connected_to_internet: bool = false; // 表示没有有效的网络连接
let has_cat: bool = true; // 表示用户有一只猫

我们可以使用 println! 宏将这些值打印到控制台进行查看。

布尔类型最常见的用途之一是配合 if 语句进行条件判断。例如,我们可以检查一个表示金额的变量是否大于零:

let money: i32 = 5000;
println!("Money is greater than zero: {}", money > 0); // 输出:true

表达式 money > 0 会进行比较运算,并返回一个布尔值结果。因为 5000 大于 0,所以结果为 true

这种特性使得我们可以在程序中引入逻辑。想象一个银行程序,需要检查用户是否有余额:

if money > 0 {
    println!("You are not broke.");
}

只有当 money > 0 这个条件为 true 时,花括号 {} 内的代码块才会被执行。这就是利用布尔值控制程序流程的基本方式。

字符类型:单个 Unicode 字符

在学习了布尔类型之后,我们来看看 Rust 中的字符类型。字符类型用于存储单个字符,其类型标注为 char。在 Rust 中,字符使用单引号 ' 来定义。

以下是定义字符变量的示例:

let letter: char = 'Z';
let omega: char = 'Ω';
let heart: char = '❤';

即使不显式标注类型,Rust 也能通过单引号识别出这是一个 char 类型。但需要注意的是,char 类型只能包含一个字符。尝试放入多个字符(如 'AB')将导致编译错误。

我们可以轻松地打印出字符:

println!("{}", heart); // 输出:❤

字符类型支持包括 ASCII 和 Unicode 在内的各种字符,这使得 Rust 能够处理全球多语言的文本。

总结

本节课我们一起学习了 Rust 中的两种基本数据类型。

  • 布尔类型 (bool):表示逻辑值,只有 truefalse 两种状态。它是程序中进行条件判断和逻辑控制的核心。
  • 字符类型 (char):表示单个 Unicode 字符,使用单引号 ' 定义。它是构建字符串和处理文本的基础单元。

理解并熟练使用这两种类型,是迈向编写更复杂、更智能的 Rust 程序的重要一步。在接下来的课程中,我们将开始探讨 Rust 中的复合数据类型。

010:元组 🧩

在本节课中,我们将要学习 Rust 中的第一个复合数据类型——元组。元组是一种将多个不同类型的值组合成一个复合类型的通用方式。我们将学习如何创建元组、访问其中的数据,并了解一些相关的核心概念。

什么是元组?

根据 Rust 官方文档的定义,元组是一种将多个不同类型的值组合成一个复合类型的通用方式。元组具有固定长度,一旦声明,其大小就无法增长或缩小。

创建元组

要创建一个元组,我们需要将值放入圆括号 () 中,并用逗号 , 分隔这些值。元组中的每个位置都有一个类型,并且这些类型不必相同。

以下是创建元组的代码示例:

let data = (10, 3.5, false);

在这个例子中,我们创建了一个名为 data 的变量,它包含三个值:一个整数 10、一个浮点数 3.5 和一个布尔值 false。我们满足了创建元组的所有条件:值被圆括号包围,用逗号分隔,并且这些值可以是不同类型。

打印元组

如果要打印元组,我们必须使用调试模式。这意味着在格式化占位符中需要添加 :?

println!("data = {:?}", data);

运行上述代码,你将看到类似 data = (10, 3.5, false) 的输出。

类型注解

你也可以为元组提供类型注解。类型注解的写法如下:

let data: (i32, f32, bool) = (10, 3.5, false);

这里,我们明确指定了元组中每个元素的类型:第一个是 i32 整数,第二个是 f32 浮点数,第三个是 bool 布尔值。如果你觉得麻烦,大多数现代代码编辑器允许你悬停在变量上,直接复制推断出的类型。

访问元组数据

上一节我们介绍了如何创建元组,本节中我们来看看如何访问其中的数据。有两种主要方法:解构和使用索引。

方法一:解构

解构允许我们将元组中的值分别提取到不同的变量中。

let (number, decimal, boolean) = data;
println!("N: {}, D: {}, B: {}", number, decimal, boolean);

运行这段代码,你将能够单独使用 numberdecimalboolean 这三个变量。

方法二:使用索引

我们也可以通过索引来访问元组中的特定元素。元组的索引从 0 开始。

let first = data.0;   // 访问第一个元素 (10)
let second = data.1;  // 访问第二个元素 (3.5)
let last = data.2;    //访问第三个元素 (false)
println!("第一个元素是 {}", first);

大多数现代代码编辑器会提供自动补全,这可以防止我们访问不存在的索引。例如,尝试访问 data.3 会导致编译错误,因为我们的元组只有三个元素。

元组的实际应用

元组在大多数编程语言中都很常见。一个非常典型的例子是表示坐标。

let coordinates: (f32, f32) = (2.5, 1.5);
println!("宝藏位于坐标: {:?}", coordinates);

你可以像之前一样,通过解构或索引来使用坐标的各个部分:

// 解构
let (x, y) = coordinates;
// 或使用索引
let x = coordinates.0;
let y = coordinates.1;

空元组(单元类型)

最后,还有一个重要的概念需要了解:空元组。在 Rust 中,空元组有一个特殊的名称,叫做 单元(Unit),其类型表示为 ()

let empty: () = ();

单元类型通常用于表示不返回任何有意义值的表达式,我们会在未来的课程中更深入地探讨它。

总结

本节课中我们一起学习了 Rust 中的元组。我们了解了元组是一个固定长度、可以容纳不同类型值的复合数据类型。我们掌握了使用 (value1, value2, ...) 的语法来创建元组,以及通过解构 let (x, y) = tuple 或索引 tuple.0 来访问其中的数据。我们还看到了元组在表示像坐标这样的数据时的实际应用,并认识了特殊的空元组——单元类型 ()。元组是 Rust 中组织数据的简单而强大的工具,随着学习的深入,我们还会遇到更多它的用例。

011:Rust 中的数组 🧱

在本节课中,我们将要学习 Rust 中的数组类型。数组是一种存储多个相同类型值的集合,并且长度固定。我们将了解如何创建数组、指定其类型、访问其中的元素,以及一些需要注意的常见错误。

数组的基本概念

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

创建数组的语法很简单:使用变量名,然后在方括号 [] 内用逗号分隔各个值。

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

上面的代码创建了一个包含五个整型元素的数组。如果尝试在其中插入一个布尔值,代码将无法编译,因为数组要求所有元素类型一致。

数组非常有用,特别是当你希望数据分配在上,而非上时。栈和堆是程序执行时用于存储数据的两个不同内存区域,我们将在后续视频中详细讨论这个概念。

当你明确知道元素数量不需要改变时,使用数组是理想的选择。例如,存储一周的天数:

let days = [“Monday”, “Tuesday”, “Wednesday”, “Thursday”, “Friday”, “Saturday”, “Sunday”];

一周总是有七天,不会每周都发明新的一天,因此这是一个使用数组的完美例子。

Rust 还有一种更灵活的数据类型叫做向量,它允许你动态地添加和移除元素。我们将在不久的将来学习它。

指定数组类型

上一节我们介绍了数组的基本创建方法,本节中我们来看看如何显式地指定数组的类型。

我们可以像这样声明一个数组,明确其元素类型和长度:

let numbers: [u8; 3] = [1, 2, 3];

在这段代码中,[u8; 3] 告诉 Rust:我们想要一个包含 u8 类型元素的数组,并且该数组的长度为 3。这里的长度必须精确匹配,如果我们尝试放入四个元素 [1, 2, 3, 4],就会导致编译错误。

数组的初始化语法

除了逐个列出元素,Rust 还提供了一种便捷的语法来创建所有元素值都相同的数组。

以下是其语法格式:

let repeat = [“Bob”; 5];

这行代码会创建一个包含五个字符串 “Bob” 的数组。你可以将数字 5 替换为任何你想要的重复次数。

访问数组元素

现在我们已经知道如何创建数组,接下来看看如何访问其中的数据。

数组元素通过索引来访问,索引从 0 开始。例如,对于存储星期的数组:

let days = [“Monday”, “Tuesday”, “Wednesday”, “Thursday”, “Friday”, “Saturday”, “Sunday”];
let first_day = days[0]; // 访问第一个元素
let last_day = days[6];  // 访问最后一个元素
println!(“First day: {}, Last day: {}”, first_day, last_day);
// 输出:First day: Monday, Last day: Sunday

之所以能这样索引,是因为数组是一块已知固定大小的连续内存,可以分配在栈上。

需要注意的错误

访问数组时,一个需要特别注意的问题是:索引越界

如果你尝试访问一个不存在的索引,程序将会在运行时崩溃。例如:

let invalid_selection = days[10]; // 错误!数组只有 7 个元素,索引范围是 0 到 6。

这种情况在实际编程中可能更常见,例如,当程序允许用户选择一个选项,而用户可能选择了超出范围的选项时。在后续课程中,我们将学习如何恰当地处理这类错误。

本节课中我们一起学习了 Rust 数组的核心知识:数组是固定长度、同类型元素的集合;我们学会了如何创建数组、指定其类型、使用便捷语法初始化,以及如何通过索引安全地访问元素。同时,我们也了解了索引越界这一常见错误。掌握数组是理解 Rust 数据存储的基础,下一节我们将探索更灵活的动态集合——向量。

012:Rust 中的函数 🧩

在本节课中,我们将要学习 Rust 中函数的基础知识。函数是组织代码、避免重复和提升程序可维护性的核心工具。

到目前为止,我们只使用了 main 函数来执行代码。但随着程序复杂度增加,将所有代码都放在 main 函数中会变得难以管理。

为了说明这一点,假设你有一段需要多次使用的重复代码,例如打印一行文字。

为何需要函数?🤔

上一节我们介绍了将所有代码置于 main 函数中的局限性,本节中我们来看看一个具体的例子。

一种方法是,在程序中每个需要的地方复制粘贴这段代码。这确实可以工作,程序会编译并打印三次“hello Bob”。

但如果你需要更新这段代码呢?例如,将“hello Bob”改为“goodbye Bob”。这需要你在三个地方手动修改,不仅繁琐,还大大增加了出错的可能性,比如拼写错误。

对于简单程序,你或许能轻松确认三次打印都正确。但当程序变得复杂时,保持一致性将变得困难。你可能会在某个地方遗漏字符,尤其是当代码位于不同文件中时,这会浪费大量调试时间。

此外,有经验的程序员绝不会在项目中多次复制粘贴完全相同的代码片段,除非他们想给自己、老板或生活找麻烦。这里指的不是从 Stack Overflow 复制代码,而是在自己的项目内部进行复制。

创建你的第一个函数 🛠️

上一节我们看到了重复代码的问题,本节中我们将学习如何通过创建函数来解决它。

以下是创建函数的基本步骤:

  1. 使用 fn 关键字,它代表“function”。
  2. 为函数起一个名字,遵循蛇形命名法(例如 print_hello)。
  3. 在花括号 {} 内编写函数体代码。

例如,我们可以创建一个打印“hello Bob”的函数:

fn print_hello() {
    println!("hello Bob");
}

现在,我们可以在 main 函数中任何需要的地方调用它:

fn main() {
    print_hello();
    print_hello();
    print_hello();
}

运行程序,控制台会打印三次“hello Bob”。

你可能会问,这比直接写三次 println!("hello Bob"); 好在哪里?使用函数的好处在于,你只需在一个地方修改功能,这个改动就会应用到整个程序中。

例如,将函数内的打印内容从“hello”改为“hi”,重新运行程序,所有调用该函数的地方都会输出“hi”。此外,大多数代码编辑器都支持一键重命名函数,这能自动更新程序中所有对该函数的引用。

函数的更多功能 📦

上一节我们创建了一个简单的函数,本节中我们来看看函数体内可以包含多少代码。

你可以在函数体内添加任意多的功能。例如,一个函数可以包含多行代码:

fn print_hello() {
    println!("hello Bob");
    println!("Inside a function");
}

每次调用 print_hello 函数时,这两行代码都会被执行。

函数定义的位置 📍

在 Rust 中,你可以在 main 函数之前或之后定义其他函数。这是因为 main 是一个特殊的函数,它总是首先执行。

例如,在 main 函数之后定义一个 goodbye 函数:

fn main() {
    goodbye();
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/5338d5dd404d3a99cbf4ed51a4891c34_28.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/5338d5dd404d3a99cbf4ed51a4891c34_29.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/5338d5dd404d3a99cbf4ed51a4891c34_30.png)

fn goodbye() {
    println!("goodbye Bob");
}

程序运行时会正常调用 goodbye 函数,没有任何问题。

总结 📝

本节课中我们一起学习了 Rust 函数的基础知识。我们了解到,函数通过封装可重用的代码块,能有效避免重复、简化代码更新,并提升程序的可维护性。我们学习了如何使用 fn 关键字定义函数,如何调用函数,以及函数定义可以放置的位置。

这仅仅是函数的入门介绍。在接下来的课程中,我们将探讨如何通过参数和返回值来定制函数,使它们变得更加灵活和强大。

013:升级你的Rust函数 🚀

在本节课中,我们将学习如何在 Rust 函数中使用参数和返回类型。这两项功能将极大地增强我们函数的能力和灵活性。

概述

我们将通过创建几个示例函数来掌握参数和返回值的用法。首先,我们将创建一个可以向任意指定名字问好的函数。接着,我们会学习如何定义多个参数。最后,我们将重点介绍如何让函数返回一个值,并探索几种不同的返回值写法。


使用参数

上一节我们介绍了函数的基本结构,本节中我们来看看如何让函数接收外部输入,即参数。

参数允许我们向函数传递数据,使得同一个函数可以处理不同的输入,从而大大提高代码的复用性。

以下是创建一个带参数的 hello 函数的步骤:

  1. 在函数签名 hello(name: &str) 中,定义了一个名为 name 的参数,其类型为字符串切片 &str
  2. 在函数体内,我们可以像使用普通变量一样使用这个 name 参数。
  3. 调用函数时,在函数名后的括号内传入具体的值(称为“实参”),例如 hello("Bob")
fn hello(name: &str) {
    println!("Hello {}", name);
}

fn main() {
    hello("Bob");
    hello("James");
}

运行此程序,控制台将输出:

Hello Bob
Hello James

这样,我们无需为每个名字创建单独的函数,极大地提升了代码的复用性。


多个参数

一个函数不仅可以接收一个参数,还可以接收多个。接下来,我们创建一个 repeat 函数,它接受一段文本和重复的次数。

以下是 repeat 函数的定义和调用:

  1. 函数签名定义为 repeat(text: &str, times: usize),它接收两个参数。
  2. 在函数体内,我们使用字符串的 repeat 方法来生成重复的文本。
  3. 调用时,依次传入文本和次数,例如 repeat("Bob", 3)
fn repeat(text: &str, times: usize) {
    println!("{}", text.repeat(times));
}

fn main() {
    repeat("Bob", 3);
    repeat("Z", 10);
}

运行后,控制台将输出:

BobBobBob
ZZZZZZZZZZ

返回值

到目前为止,我们的函数都只是执行操作。但函数另一个强大的功能是返回一个值。例如,我们可以创建一个将摄氏温度转换为华氏温度的函数。

在 Rust 中,如果函数需要返回值,必须在函数签名中使用箭头 -> 指定返回类型。

以下是摄氏度转华氏度的函数 celsius_to_fahrenheit

  1. 函数签名声明为 fn celsius_to_fahrenheit(celsius: f64) -> f64,表示它接收一个 f64 类型的参数,并返回一个 f64 类型的值。
  2. 转换公式为:华氏度 = 摄氏度 × 9.0 / 5.0 + 32
  3. 因为这个函数有返回值,所以调用时通常需要将其结果赋值给一个变量或直接使用(例如打印)。
fn celsius_to_fahrenheit(celsius: f64) -> f64 {
    celsius * 9.0 / 5.0 + 32.0
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/a513e57a3ae512bfe99d59d7b46be0a6_16.png)

fn main() {
    println!("{}", celsius_to_fahrenheit(20.0)); // 直接打印
    let converted = celsius_to_fahrenheit(10.0); // 赋值给变量
    println!("Converted is {}", converted);
}


返回值的简写语法

在 Rust 中,返回一个值有更简洁的写法。你甚至可以不使用 return 关键字和分号。

以下是几种等价的返回值写法:

  1. 显式返回:使用 return 关键字并以分号结尾。这是最明确的写法。
  2. 隐式返回:如果函数体的最后一行是一个表达式(没有分号),Rust 会自动将其作为返回值。这是最常用且简洁的写法。
  3. 即使返回值是一个简单的字面量(如数字10),上述规则同样适用。
// 方法1:使用 return 关键字
fn explicit_return() -> i32 {
    return 10;
}

// 方法2:省略 return 和分号(推荐)
fn implicit_return() -> i32 {
    10 // 注意:这一行没有分号
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/a513e57a3ae512bfe99d59d7b46be0a6_20.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/a513e57a3ae512bfe99d59d7b46be0a6_21.png)

fn main() {
    println!("Explicit: {}", explicit_return());
    println!("Implicit: {}", implicit_return());
}

关键点:当使用隐式返回时,最后一行代码不能有分号 ;,因为分号会将表达式转换为语句,而语句没有值。


综合示例:加法函数

让我们结合参数和返回值,创建一个完整的 add 函数。

以下是 add 函数的实现:

  1. 函数签名 fn add(a: i32, b: i32) -> i32 声明它接收两个 i32 整数,并返回一个 i32 整数。
  2. 函数体内可以先执行一些操作(例如打印日志),最后一行 a + b 作为表达式隐式返回结果。
  3. 调用函数后,可以使用 dbg! 宏来方便地调试和输出结果。
fn add(a: i32, b: i32) -> i32 {
    println!("Adding {} and {}", a, b);
    a + b // 隐式返回相加的结果
}

fn main() {
    let result = add(10, 20);
    dbg!(result); // 使用 dbg! 宏输出调试信息
    let another_result = add(50, 20);
    println!("Another result: {}", another_result);
}

运行后,你将看到相加的结果被正确计算和输出。通过改变传入的参数,我们可以轻松地复用这个函数进行不同的计算。


总结

本节课中我们一起学习了如何升级 Rust 函数:

  • 参数:通过在函数签名中定义参数,我们可以向函数传递数据,使函数更加通用和可复用。
  • 多个参数:函数可以接收任意数量的参数,只需在签名中依次列出。
  • 返回值:使用 -> 指定返回类型,可以让函数计算结果并返回给调用者。
  • 返回语法:我们掌握了显式(return)和隐式(省略最后一行分号)两种返回值写法,后者更为简洁常用。

掌握参数和返回值是编写模块化、高效 Rust 代码的关键。我们的目标是让函数尽可能可复用,而这两项功能在此过程中提供了巨大的帮助。

014:语句与表达式 📝

在本节课中,我们将要学习 Rust 中两个核心概念:语句表达式。理解它们的区别至关重要,因为 Rust 是一门基于表达式的语言。我们将首先解释它们的定义,然后通过具体示例来加深理解。

概述

语句和表达式是构成 Rust 代码的基本单元。简单来说,语句执行操作但不返回值,而表达式则会计算并产生一个值。这种区别影响了代码的编写方式,尤其是在变量赋值和函数返回值时。

语句:执行但不返回

语句是执行某些动作的代码行,但它本身不产生一个可供使用的值。这意味着你不能将一个语句的结果赋值给变量。

以下是语句的两个例子:

  1. 变量声明let name = "Bob"; 这行代码是一个语句。它执行了将字符串 "Bob" 绑定到变量 name 的操作,但自身不返回任何值。
  2. 宏调用println!("Hello, {name}"); 这行代码也是一个语句。它执行了打印文本到控制台的操作,同样不返回任何值。

由于语句不返回值,因此不能将它们用作值。例如,在像 C 或 Ruby 这样的语言中,let a = (let b = 10); 可能是有效的,因为赋值操作会返回值。但在 Rust 中,这是无效的,因为 let b = 10; 是一个语句,不返回值,所以不能被赋值给变量 a

表达式:计算并返回值

表达式是代码中会计算(求值)并产生一个结果的片段。这个结果值可以被使用,例如赋值给变量或传递给函数。

让我们来看几个表达式的例子:

  1. 算术运算10 + 20 是一个表达式,它计算并返回值 30。因此,let result = 10 + 20; 是有效的,整个 let 声明是语句,但 10 + 20 这部分是表达式。
  2. dbg!dbg!(20 + 50) 会打印调试信息并返回括号内表达式的值(即 70)。因为它返回值,所以它本身也可以作为表达式使用。例如:
    let result = dbg!(20 + 50); // dbg! 作为表达式,返回值 70 并赋给 result
    println!("{result}"); // 输出: 70
    
  3. 代码块:在 Rust 中,用花括号 {} 包裹的代码块本身就是一个表达式,它会返回块中最后一个表达式的值(注意不能有分号)。
    let sum = {
        let x = 10;
        let y = 20;
        x + y // 注意这里没有分号,这是一个表达式,返回值 30
    };
    dbg!(sum); // 输出: [src/main.rs:2] sum = 30
    
    在上面的代码块中,let x = 10;let y = 20; 是语句,而 x + y 是表达式,它的值 30 成为整个代码块的值,并被赋值给变量 sum

总结

本节课我们一起学习了 Rust 中语句与表达式的核心区别。

  • 语句:执行操作,不返回值。例如变量声明 (let)、表达式后加分号、以及某些宏调用。
  • 表达式:进行计算,总会产生一个值。例如字面量、算术运算、函数调用、宏调用(如 dbg!)以及代码块。

牢记 Rust 是基于表达式的语言,这意味着很多结构(如 ifmatch、函数体)本身都是表达式,可以返回值。理解这一点对于编写地道的 Rust 代码和阅读文档至关重要。

015:在 Rust 中添加注释 🗒️

在本节课中,我们将要学习如何在 Rust 代码中添加注释。注释是用于解释代码、提供额外上下文或临时禁用代码行的文本,它们会被编译器忽略,但对开发者理解代码至关重要。

为什么需要注释

有时编写的代码需要一些额外的背景信息,提供这些信息的最佳方式就是使用简短的注释。你可能会听到“好的代码不需要注释”这种说法,在理想情况下这或许成立。但在现实中,完美的代码并不常见。因为对许多开发者而言,按时完成任务通常比确保一切都遵循完美的命名规范或旧代码遵循最佳实践更重要。因此,简短的注释会非常有用。

注释的实用示例

上一节我们介绍了注释的基本作用,本节中我们来看看具体的应用场景。

为函数提供上下文

假设我们有一个名为 get_rating 的函数。它的作用是接收一个 &str 类型的电影名,并返回该电影的评分。这里我们返回 i32 类型,它也很容易是 i8u8

以下是示例代码:

let rating: i32 = get_movie_data(movie);
return rating;

这个函数在我们的代码中尚不存在。我们需要在 get_movie_data 函数下快速创建它。该函数接收一个 &str 类型的电影名,并返回 i32。作为虚拟数据,我们返回 10。

fn get_movie_data(movie: &str) -> i32 {
    // 这是一个用于测试的虚拟函数
    10
}

仅看代码本身,其意义并不明确。因此,在这些上下文中添加一些注释会很有用。

以下是几种有用的注释类型:

  • 功能说明:例如,“这是一个用于测试的虚拟函数”。这是一个完全可以接受的注释。
  • 外部资源链接:我们可以在函数上方添加注释,说明它使用了某个电影 API 并链接到文档。例如:
    // 使用 Bob 的电影评分 API,文档:https://www.bobs-movie-ratings.co/docs
    fn get_rating(movie: &str) -> i32 {
        // ... 函数实现
    }
    
    这个注释对于函数运行并非必需,但它为查看此代码的人提供了关于函数的额外背景信息。如果他们想编辑此函数,可能需要研究这个 API。多亏了这个注释,他们可以直接复制这个 URL 并粘贴到浏览器中,这将帮助他们理解这个电影 API 的实际工作方式。

为学习做笔记

第二个例子是创建快速笔记。例如,在 main 函数顶部,我们可以输入:

// 这是我们的主程序入口点
fn main() {
    // ... 代码
}

这对于做笔记特别有用,尤其是如果你是编程新手,到处添加注释可以让你更容易记住某些代码的作用。在专业的代码库中,你可能会希望省略这个注释,因为 main 函数作为主入口点是众所周知的,无需额外说明。但如果你是 Rust 或编程新手,到处注释会非常有用。

调试时注释代码

注释实际上还有另一个你会经常用到的场景。为了演示,我将粘贴以下代码片段:

fn add_numbers() {
    let numbers = vec![1, 2, 3];
    let mut sum = 0;
    for num in numbers {
        sum += num;
        // println!("当前数字: {}, 当前总和: {}", num, sum); // 调试时启用
    }
    println!("总和: {}", sum);
}

这个函数计算总和,然后对我们函数中的每个整数,将其加到总和中并打印总结果。你可能已经注意到,里面有一段被注释掉的代码。这是因为我只想在调试这个函数时使用这段代码。

例如,现在如果我们运行这段代码(通过 cargo run),你会看到我们得到的总和是 6,因为它执行了那个操作。但有时事情不会按计划进行,循环中可能会发生一些奇怪的事情。

因此,我可以快速取消这行代码的注释。如你所见,这行代码现在将变为活动状态,这意味着下次我们运行时,调用函数时将包含这些打印语句。这告诉我们每次迭代时到底发生了什么。

如果你想测试一行代码,可以快速将其注释掉,然后再取消注释。每个代码编辑器都应该有相应的快捷键(例如,在许多编辑器中是 Ctrl + /Cmd + /)。这在你想要测试替代方法时非常有用,因为如果你必须将代码剪切、保存到某处,然后每次想调试这个函数时再粘贴回来,很快就会变得非常麻烦。

多行注释

最后,今天我还想讨论另一种注释类型:多行注释。要创建它,只需输入 /**/,这将打开一个多行注释块,允许你在这个封闭的空间内自由输入。

/*
这是一个注释。
作者:Bo
x = x + 1 或 1 + 1
*/

你可以在这里自由书写任何内容,Rust 在编译代码时会忽略它。

或者,大多数代码编辑器在你每次按回车键时,都会用 // 开始一个新行,因此你可能并不总是需要使用多行注释。在许多代码库中,你只会看到使用 //

// 这是一个注释
// 另一行
// 你好吗?

如你所见,每次我按回车键,它都为我开始了一个新的行注释。我发现这比必须输入 /**/ 稍微方便一些。但归根结底,请尝试选择你觉得最方便的方式。

还有其他特殊的注释类型(如文档注释 /////!),我们将在未来的视频中介绍它们。

总结

本节课中我们一起学习了在 Rust 中使用注释。我们了解了注释的重要性,它能为代码提供上下文、辅助学习做笔记,以及在调试时临时禁用代码。我们介绍了单行注释 // 和多行注释 /* ... */ 的用法,并讨论了在不同场景下如何选择使用它们。合理使用注释能让代码更易读、更易维护,是每个开发者都应掌握的基本技能。

016:Rust 中的控制流 - if else 表达式 🧠

在本节课中,我们将学习 Rust 语言中一个最基础且无处不在的概念:if else 表达式。它允许你的代码根据特定条件来决定执行哪一部分功能,从而赋予程序更强的逻辑控制能力。

概述

if else 表达式是编程中进行条件判断的核心工具。通过它,我们可以让程序在不同的情况下做出不同的反应。本节将通过一个密码长度验证的例子,来演示如何在 Rust 中使用 if else

使用 if else 进行条件判断

想象一个场景:你需要检查用户创建的密码长度,以确保密码足够安全。我们可以创建一个验证函数来实现这个逻辑。

以下是实现此功能的基本步骤:

  1. 定义函数:创建一个名为 check_length 的函数,它接收一个字符串切片(&str)类型的密码作为参数。
  2. 获取长度:使用字符串的 .len() 方法获取密码的长度。
  3. 条件判断:使用 if 关键字判断长度是否大于等于 10。
  4. 执行分支:根据条件判断的结果,执行不同的代码块。

让我们看看具体的代码实现:

fn check_length(password: &str) {
    let length = password.len();
    if length >= 10 {
        println!("密码足够长。");
    } else {
        println!("密码不够长,请添加更多字符。");
    }
}

fn main() {
    check_length("Bobhasahat123"); // 输出:密码足够长。
    check_length("Bob123");        // 输出:密码不够长,请添加更多字符。
}

运行上述代码,程序会根据传入密码的长度打印出不同的提示信息。这展示了 if else 如何根据条件 length >= 10 的真假,来控制程序执行不同的分支。

使用 if else 表达式返回值

if else 在 Rust 中不仅仅是一个语句,它还是一个表达式,这意味着它可以产生一个值。我们可以利用这个特性,让函数直接根据条件返回布尔值。

例如,我们可以重构上面的函数,让它返回一个 bool 类型的结果:

fn long_enough(password: &str) -> bool {
    let length = password.len();
    if length >= 10 {
        true
    } else {
        false
    }
}

fn main() {
    if long_enough("Bob123") {
        println!("密码足够长。");
    } else {
        println!("密码太短。");
    }
}

在这个例子中,long_enough 函数根据密码长度是否大于等于 10,返回 truefalse。然后在 main 函数中,我们根据这个返回值再次使用 if else 来打印相应的信息。

简化条件返回值

对于上面这种直接返回条件判断结果的情况,Rust 提供了一种更简洁的写法。由于条件表达式 length >= 10 本身就会求值为 bool 类型(truefalse),我们可以直接返回它,而无需显式地使用 if else 块。

因此,long_enough 函数可以简化为一行:

fn long_enough(password: &str) -> bool {
    password.len() >= 10
}

这种写法更加符合 Rust 的惯用风格,代码也更清晰。我们之前展示的完整 if else 形式是为了说明其作为表达式返回值的可能性。

总结

本节课我们一起学习了 Rust 中 if else 表达式的基本用法。我们了解到:

  • if else 用于根据条件执行不同的代码路径。
  • 在 Rust 中,if else 是一个表达式,可以产生值,这使得我们可以写出 let result = if condition { value1 } else { value2 }; 这样的代码。
  • 对于简单的布尔条件判断,可以直接返回条件表达式本身,这是更简洁的写法。

掌握了 if else,你就拥有了编写具有逻辑判断能力程序的基础。在下一节中,我们将学习另一个可以与 if else 搭配使用的关键字:else if,它用于处理多个连续的条件判断。

017:更多控制流

在本节课中,我们将学习 Rust 中处理多个条件判断的方法,并澄清一个关于字符串长度方法的常见误解。我们将通过构建一个简单的响应函数来实践 ifelse ifelse 的组合使用。

关于 len() 方法的澄清

上一节我们介绍了 Rust 中的 ifelse,以及如何根据条件执行代码。在继续之前,需要澄清一点:len() 方法的作用与我在 Python 中的认知不同。

在 Python 中,len() 可用于计算字符串的字符数或列表的元素数。但在 Rust 中,len() 返回的是字符串的字节长度,而非字符数。你可以通过悬停查看文档来验证这一点。

为了使函数准确计算字符数,我们需要先获取字符,然后再进行计数。

// 错误示例:返回字节长度
let length_in_bytes = "ø".len(); // 可能返回 2

// 正确示例:返回字符数
let character_count = "ø".chars().count(); // 返回 1

len() 方法的问题在于,某些字符(如斯堪的纳维亚字符 ø)可能占用多个字节。这意味着 "ø".len() 会返回 2 而不是 1,这通常不是我们想要的结果。

这个故事的启示是:在学习新语言时,务必阅读文档。因为 len() 在 Rust 中的行为与 Python 完全不同。我很高兴犯了这个错误,因为从现在起,我会更加注意那些与 Python 方法同名但功能不同的方法。

处理多个条件:else if

之前我们学习了使用 ifelse 根据特定条件执行代码。但如果我们想处理多个条件呢?在本节中,我们将通过创建一个简单的响应函数来学习如何使用 else if

我们将创建一个名为 get_response 的函数,它接收一个字符串切片作为用户输入,并返回一个字符串切片作为响应。

fn get_response(input: &str) -> &str {
    // 代码将在这里展开
}

首先,我们创建一个名为 loweredString 类型变量。它将接收输入,并将其转换为小写。这使字符串比较变得更容易,因为带大写 H"Hello" 不等于带小写 h"hello"。我们希望它们被识别为相同的内容。

let lowered = input.to_lowercase();

接下来,我们进行 if-else 检查。如果 lowered 包含 "hello",则返回 "hello there",否则返回 "I don't understand"

if lowered.contains("hello") {
    return "hello there";
} else {
    return "I don't understand";
}

到目前为止,这只是我们上节课学过的内容。但只有一个响应显得不够好。接下来,我们将添加多个响应,为此我们将使用 else if 关键字(或关键字组合)。

else if 本质上是第二个 if 检查。如果第一个 if 失败,程序会继续检查这个 else if。这意味着我们现在可以检查 lowered 是否包含 "how are you"

以下是添加多个条件判断的完整函数:

fn get_response(input: &str) -> &str {
    let lowered = input.to_lowercase();

    if lowered.contains("hello") {
        "hello there"
    } else if lowered.contains("how are you") {
        "good and you"
    } else if lowered.contains("good") {
        "good is good"
    } else {
        "I don't understand"
    }
}

通过这种方式,我们可以以多种方式处理用户输入。让我们通过调试来测试这个函数。

fn main() {
    dbg!(get_response("Hello, Bob"));
    dbg!(get_response("How are you"));
    dbg!(get_response("Good"));
    dbg!(get_response("Is this a cat in the hat"));
}

运行此程序,你会看到我们为每个输入都得到了恰当的响应。

你可以插入任意多个 else if 表达式,但有一点需要注意:这些条件的放置顺序很重要。因为一旦其中一个条件评估为 true,其余的条件就会被忽略。

条件顺序的重要性

为了说明顺序的重要性,让我们创建一个新函数 analyze_number,它接收一个 i32 类型的数字。

fn analyze_number(n: i32) {
    if n > 0 {
        println!("{} is greater than zero", n);
    } else if n > 10 {
        println!("{} is greater than ten", n);
    } else {
        println!("{} is a cool number", n);
    }
}

main 函数中调用它并传入数字 5

fn main() {
    analyze_number(5);
}

运行程序,输出是:5 is greater than zero

现在,如果我们传入 100,根据逻辑,我们应该触发 n > 10 这个条件块,输出 100 is greater than ten。但运行后你会发现,输出是 100 is greater than zero

这是因为顺序至关重要。第一个检查 n > 0 通过了,因此它执行了对应的代码行,并忽略了其余部分。

如果你希望代码按预期工作,必须在检查 n > 0 之前先检查 n > 10,因为 n > 0 有可能在 n > 10 之前就通过检查。当然,相应地修改输出字符串也会有用。

调整顺序后的函数如下:

fn analyze_number(n: i32) {
    if n > 10 {
        println!("{} is greater than ten", n);
    } else if n > 0 {
        println!("{} is greater than zero", n);
    } else {
        println!("{} is a cool number", n);
    }
}

现在,再次运行程序:

  • 输入 100 会输出 100 is greater than ten
  • 输入 5 会输出 5 is greater than zero

这是因为程序按顺序检查条件。由于 5 不大于 10,程序能够继续检查下一个条件 n > 0,该条件返回 true,因此执行了对应的代码行。

总结

本节课中我们一起学习了:

  1. 澄清了 len() 方法:在 Rust 中,str.len() 返回的是字节长度,而非字符数。要获取字符数,应使用 str.chars().count()
  2. 使用 else if 处理多个条件:通过组合 ifelse ifelse,可以构建复杂的条件判断逻辑。
  3. 理解了条件判断的顺序重要性if-else if 链会按顺序评估条件,一旦某个条件为 true,后续条件将被跳过。因此,条件的顺序直接影响程序的逻辑和输出。

下节课,我们将学习如何在赋值语句中直接使用 if-else 表达式。

Rust 初学者教程:P18:使用 letif..else 进行条件赋值

在本节课中,我们将学习 Rust 中 if..else 作为表达式的一个强大特性:如何用它来直接为变量赋值。这个特性能让代码更简洁,尤其是在处理简单的条件逻辑时。


上一节我们介绍了 if..else 的基本用法。本节中我们来看看如何将它用作表达式来赋值。

由于 if 在 Rust 中是一个表达式,这意味着它可以产生一个值。因此,我们可以直接将 if..else 的结果赋值给一个变量。其基本公式如下:

let variable_name = if condition { value_if_true } else { value_if_false };

以下是具体步骤:

  1. 定义条件变量:首先,定义一个需要判断的变量。
  2. 使用 let if 赋值:然后,使用 let 关键字声明一个新变量,并用 if..else 表达式为其赋值。
  3. 输出结果:最后,可以打印或使用这个新变量。

让我们通过一个例子来理解。假设我们有一个整数 n,我们想创建一个变量来告诉我们它是奇数还是偶数。

fn main() {
    let n = 11; // 要判断的数字
    let odd_even = if n % 2 == 0 { "even" } else { "odd" };
    println!("The number is: {}", odd_even);
}

在这段代码中:

  • n % 2 == 0 是条件,检查 n 除以 2 的余数是否为 0。
  • 如果条件为真(即余数为 0),整个 if 表达式的值就是字符串 "even"
  • 如果条件为假(即余数不为 0),表达式的值就是字符串 "odd"
  • 这个值被直接赋值给了变量 odd_even

运行这段代码,因为 11 是奇数,所以会输出 The number is: odd。如果将 n 的值改为 10,则会输出 The number is: even


使用 let if 时有一个非常重要的规则需要牢记。

ifelse 分支返回的值必须是相同的类型。 如果类型不匹配,Rust 编译器会报错,程序将无法通过编译。

请看一个错误的例子:

fn main() {
    let is_connected = false;
    // 错误示例:两个分支返回的类型不同(&str 和 i32)
    let result = if is_connected { "connected" } else { -1 };
    println!("{:?}", result);
}

在这段代码中,if 分支返回一个字符串切片 &str"connected"),而 else 分支返回一个整数 i32-1)。Rust 编译器会明确指出这个错误,例如提示 expected &str, found integer。因此,这种写法是不允许的。


本节课中我们一起学习了 Rust 中 letif..else 结合使用的技巧。我们了解到 if 作为表达式可以直接为变量赋值,这使得简单的条件判断代码更加简洁清晰。同时,我们掌握了使用时的核心规则:ifelse 分支的返回值类型必须一致,否则代码将无法编译。这个特性是 Rust 表达力强大的一个体现。

019:无需网络学习Rust 📚

在本节课中,我们将学习一个非常实用的技巧:如何在没有互联网连接的情况下学习 Rust。Rust 语言的一个出色特性是,其官方文档被内置到了工具链中,这意味着你可以随时随地查阅。

访问离线文档

上一节我们介绍了 Rust 的各种基础概念,本节中我们来看看如何利用内置工具进行离线学习。

在任何终端中,你都可以输入以下命令来打开本地的 Rust 文档:

rustup doc

执行这个命令后,你的默认浏览器将会打开一个本地版本的 Rust 文档网站。这个文档的内容与在线版本完全一致,但不需要任何网络连接。

文档内容概览

这个离线文档包含了 Rust 学习的核心资源。对于初学者,你可以直接跟随其中的“Rust 编程语言”一书(The Rust Book)进行学习,这也是本视频教程所依据的官方教材。

  • 在线版本地址https://doc.rust-lang.org/book/title-page.html
  • 离线版本位置:如截图所示,文档被托管在你本地计算机的特定文件夹中。

安装与更新离线文档

以下是确保你拥有最新离线文档的步骤。

如果你因为任何原因发现该命令无效或文档缺失,你可以使用以下命令来安装或更新 Rust 文档组件:

rustup component add rust-docs

执行此命令后:

  1. 如果你尚未安装文档,它会自动下载。
  2. 如果你已安装,它会检查并确保所有文档都是最新版本。

离线文档的实用价值

了解这个功能在多种场景下都非常有用。

  • 当你身处没有网络的环境时(例如在飞机上、偏远地区),可以继续学习。
  • 当网络速度很慢时,访问本地文档会比等待网页加载快得多。
  • 它包含了极其丰富的内容,是探索 Rust 的绝佳资料库。

离线文档集包含的主要资源有:

  • The Rust Book: Rust 官方入门教程。
  • The Cargo Book: 关于 Rust 包管理器和构建工具的详细指南。
  • The Rustdoc Book: 学习如何为你自己的代码生成文档。
  • The Reference: Rust 语言的参考手册。



总结

本节课中我们一起学习了一个简单但强大的 Rust 特性:离线文档访问。我们了解了如何使用 rustup doc 命令打开本地文档,以及如何通过 rustup component add rust-docs 来安装或更新它。掌握这个方法,能确保你在任何条件下都能持续学习和查阅 Rust,这对于深入掌握这门语言至关重要。

在接下来的视频中,我们将回归 Rust 语言本身,继续学习新的核心概念——循环

020:Rust 中的 loop 循环 🔄

在本节课中,我们将要学习 Rust 编程语言中的循环概念。循环是几乎所有编程语言中都存在的基础概念,它允许我们重复执行一段代码。我们将从最基础的 loop 循环开始,了解如何创建无限循环,以及如何使用 break 关键字来控制循环的退出。


循环简介

上一节我们介绍了如何使用 if else 来运行满足特定条件的代码。本节中,我们来看看循环。在 Rust 中,有三种主要的循环类型:loopwhile 循环和 for 循环。在接下来的几节课中,我们将逐一介绍它们。首先,让我们深入了解最基本的 loop 循环。

loop 关键字允许我们创建一个无限循环,代码会一直重复执行,直到我们明确告诉它停止。

创建 loop 循环

要创建一个 loop 循环,我们使用 loop 关键字,然后跟上一对花括号 {}。所有放在花括号内的代码都将被无限循环执行。

loop {
    // 这里的代码将无限循环
}

使用此功能时需要小心,因为如果我们在循环内打印一些内容,例如:

loop {
    println!("Hello Bob");
}

运行此程序,你会在控制台看到 “Hello Bob” 被无限打印。停止程序的唯一方法是按住 Ctrl + C 来强制终止程序。

观察循环过程

为了更直观地观察循环过程,我们可以引入一个计数器变量。

以下是具体步骤:

  1. 创建一个名为 n 的可变变量,初始值设为 0
  2. 在每次循环迭代中,将 n 的值增加 1
  3. 使用 println! 宏打印当前的 n 值。

let mut n = 0;
loop {
    n += 1;
    println!("{}", n);
}

运行这段代码,你会看到数字从 1 开始持续增长,循环将无限进行下去。同样,你可以使用 Ctrl + C 随时停止它。

使用 break 退出循环

接下来,我们讨论一个非常关键的关键字:break。它允许我们在遇到它时立即退出循环。

例如,我们创建一个倒计数的循环:

  1. 将变量名改为 counter,并从 5 开始。
  2. 每次迭代打印当前计数,并将计数器减 1
  3. 当计数器等于 0 时,打印信息并使用 break 退出循环。
let mut counter = 5;
loop {
    println!("Count is: {}", counter);
    counter -= 1;
    if counter == 0 {
        println!("We reached 0!");
        break;
    }
}
println!("Code after the loop.");

运行此代码,你会看到计数从 5 递减到 0。当 counter0 时,条件判断为真,执行 break,循环终止,程序继续执行循环之后的代码。如果没有 break 条件,循环后的代码将永远无法执行。

从循环中返回值

loop 循环还有一个强大的功能:它可以返回一个值。这是通过 break 关键字后跟一个值来实现的。

让我们看一个递增计数的例子,并从循环中返回最终值:

  1. counter 初始值设为 0
  2. 每次循环递增计数器。
  3. 当计数器达到 5 时,使用 break 退出循环并返回一个值(例如字符串 “success” 或计数器本身的值)。
  4. 将这个循环的返回值赋给一个变量。

let mut counter = 0;
let result = loop {
    counter += 1;
    if counter == 5 {
        break "success"; // 或者 break counter;
    }
};
println!("Result: {:?}", result);

运行这段代码,当计数器递增到 5 时,循环会中断,并将 break 后面的值(“success” 或数字 5)赋给变量 result,然后打印出来。


本节课中我们一起学习了 Rust 中 loop 循环的基础用法。我们了解了如何创建无限循环,如何使用 break 关键字在满足条件时退出循环,以及如何从循环中返回一个值。这些是控制程序流程的重要工具。在下一节视频中,我们将探讨 while 循环和 continue 关键字。

021:while 循环 🌀

在本节课中,我们将要学习 Rust 中的 while 循环,了解它的工作原理,并探索如何使用 continue 关键字来控制循环流程。

上一节我们介绍了使用 loop 关键字创建无条件循环。本节中我们来看看 while 循环,它的行为略有不同。while 循环需要一个条件表达式,每次迭代前都会检查该条件。只有当条件评估为 true 时,循环才会继续执行。一旦条件变为 false,循环就会退出。

while 循环的基本用法

让我们来看一个例子。首先,我们创建一个名为 number 的变量,并赋值为 5

let mut number = 5;

我们希望当 number 大于 0 时,循环继续执行。

while number > 0 {
    println!("{}", number);
    number -= 1;
}

如果我们只打印数字而不减少它的值,程序将陷入无限循环,因为条件 number > 0 永远不会变为 false。因此,在循环体内修改条件变量至关重要。在上面的代码中,我们使用 number -= 1 在每次迭代后将数字减 1。

运行程序,你会看到它从 5 倒数到 1。当 number 变为 0 时,条件 number > 0 评估为 false,循环退出,程序继续执行后续代码。

为了演示这一点,我们可以在循环后添加一行代码:

println!("循环结束");

再次运行程序,你会看到倒数完成后打印出“循环结束”。

while 循环的条件可以是任何能评估为布尔值(truefalse)的表达式。例如,while true 会创建一个无限循环。

使用 continue 关键字

接下来,我们通过另一个例子来介绍 continue 关键字。continue 用于跳过当前迭代中剩余的代码,直接开始下一次循环迭代。

我们创建一个新的可变变量 n,初始值设为 10。

let mut n = 10;
while n > 0 {
    n -= 1; // 递减 n,避免无限循环
    if n == 5 {
        println!("正在跳过 5");
        continue;
    }
    println!("当前值: {}", n);
}

以下是这段代码的执行步骤:

  1. 循环开始,n 初始为 10。
  2. 每次迭代,先将 n 减 1。
  3. 检查 n 是否等于 5。
  4. 如果等于 5,则打印“正在跳过 5”,然后执行 continue,跳过本次迭代中后面的 println! 语句,直接开始下一次迭代。
  5. 如果不等于 5,则正常打印 n 的当前值。

运行程序,输出将从 9 开始,递减到 6,然后跳过数字 5 的打印,接着打印 4、3、2、1、0。你会发现控制台没有输出“当前值: 5”。

continue 的更多应用

我们可以利用 continue 来实现更复杂的逻辑,例如只打印奇数。

通过稍微修改实现细节,我们可以只打印奇数。思路是:当 n 是偶数时,使用 continue 跳过打印。

let mut n = 10;
while n > 0 {
    n -= 1;
    if n % 2 == 0 { // 检查 n 是否为偶数
        continue; // 如果是偶数,跳过本次迭代的剩余部分
    }
    println!("奇数: {}", n);
}

在这段代码中:

  • n % 2 == 0 是一个条件表达式,使用取模运算符 % 来计算 n 除以 2 的余数。
  • 如果余数为 0,说明 n 是偶数,则执行 continue,跳过 println!
  • 只有余数不为 0(即 n 是奇数)时,才会执行打印语句。

运行此程序,输出将只包含 9、7、5、3、1 这些奇数。


本节课中我们一起学习了 Rust 的 while 循环。我们了解到 while 循环是一个条件循环,它在每次迭代前检查一个布尔条件。我们还学习了如何使用 continue 关键字来跳过当前迭代的剩余代码,直接进入下一次循环,这为控制循环流程提供了灵活性。记住,确保循环条件最终能变为 false 是避免无限循环的关键。

022:for 循环 🚀

在本节课中,我们将要学习 Rust 中的 for 循环。这是一种非常方便地遍历可迭代集合(如数组)的方式。我们将通过具体示例来理解其语法和优势,并与之前学过的循环进行对比。

上一节我们介绍了 while 循环。本节中,我们来看看另一种循环类型——for 循环,它能让我们以更便捷的方式遍历可迭代对象。

基础 for 循环示例

让我们立即开始,首先创建一个名为 names 的数组。

let names = ["Bob", "Ben", "Betty"];

接下来,我们将遍历每个名字,并在每次迭代中使用其值。

for name in names {
    println!("{} says hi", name);
}

这里,我们遍历这个 names 数组。name 是每次迭代中使用的临时变量名,你可以随意命名它,比如 nperson

在第一次迭代中,name 等于 "Bob",然后是 "Ben",最后是 "Betty"。运行此代码,我们将得到:

Bob says hi
Ben says hi
Betty says hi

使用 for 循环进行数值计算

让我们看第二个例子,其中包含一些数字。

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

接下来,我们创建一个名为 power_total 的可变变量,初始值为 0。

let mut power_total = 0;

我们的目标是计算每个数字的平方(二次幂),并将其加到总和里。

以下是遍历数字并计算平方和的代码:

for number in numbers {
    let squared = number.pow(2);
    println!("{}^2 = {:?}", number, squared);
    power_total += squared;
}
println!("Total sum of squares: {}", power_total);

在循环内部:

  1. 我们使用 number.pow(2) 方法计算当前数字的平方。如果你想计算立方,可以使用 pow(3)
  2. 我们打印出数字及其平方值。
  3. 我们将平方值累加到 power_total 中。

最后,在循环外部打印总和。运行此代码,我们将得到:

1^2 = 1
2^2 = 4
3^2 = 9
4^2 = 16
5^2 = 25
Total sum of squares: 55

如你所见,使用 for 循环非常方便。

为何优先选择 for 循环

虽然你可以使用 loopwhile 循环来实现相同的遍历功能,但这通常被视为不良实践,因为它需要更多代码来实现一个简单的操作,从而更容易导致错误。

为了演示这一点,我们使用 while 循环来遍历之前的名字数组。

let names = ["Bob", "Ben", "Betty"];
let mut index = 0;
while index < names.len() {
    println!("{:?}", names[index]);
    index += 1; // 递增索引
}

这段代码可以正常工作,输出三个名字。但它需要手动管理索引 (index) 和循环条件,代码量更多且稍难阅读。

更重要的是,它容易引入逻辑错误。例如,如果不小心将索引递增语句 index += 1 放错了位置:

while index < names.len() {
    index += 1; // 错误地在打印前递增
    println!("{:?}", names[index]); // 可能导致索引越界!
}

这可能导致程序恐慌(panic),因为索引可能超出数组边界。而使用 for 循环,则可以安全、简洁地完成同样的事情:

for name in names {
    println!("{:?}", name);
}

总结

本节课中我们一起学习了 Rust 的 for 循环。我们了解到:

  • for 循环的语法是 for item in iterable { ... },用于便捷地遍历集合。
  • 它避免了手动管理索引的麻烦,使代码更简洁、更安全,减少了出错的可能性。
  • 在处理需要遍历数组、向量等可迭代集合的任务时,应优先考虑使用 for 循环,即所谓“使用合适的工具做合适的事”。

记住这个核心原则,它将为你节省时间、避免麻烦。

023:循环标签

在本节课中,我们将要学习 Rust 中关于循环的最后一个重要概念:标签。标签主要用于处理嵌套循环,它允许你在使用 breakcontinue 时,精确地指定要中断或继续的是哪一个循环。

为什么需要循环标签?

上一节我们介绍了 Rust 中的所有循环类型。本节中我们来看看一个在嵌套循环中可能遇到的问题。

当使用嵌套循环时,内层循环中的 break 语句默认只会跳出离它最近的那一层循环。如果你需要从内层循环直接跳出到外层循环,就需要使用循环标签来指定目标。

创建和使用循环标签

以下是创建和使用循环标签的步骤。

首先,我们创建一个示例程序来演示问题。我们声明两个计数器变量,并构建一个嵌套循环结构。

fn main() {
    let mut main_count = 0; // 外层循环计数器
    'main: loop { // 为外层循环定义标签 'main
        println!("外层循环: {}", main_count);
        let mut inner_count = 0;

        loop { // 内层循环
            println!("  内层循环: {}", inner_count);
            inner_count += 1;

            if inner_count == 3 {
                println!("  --- 内层循环结束 ---");
                break; // 这个 break 只会跳出内层循环
            }

            if main_count == 3 {
                println!("退出所有循环");
                break 'main; // 使用标签 'main 来跳出外层循环
            }
        }
        main_count += 1; // 外层循环计数器递增
    }
}

代码解析:

  1. 'main: loop:这为外层循环定义了一个名为 'main 的标签。标签以单引号 ' 开头,后跟标签名和冒号 :
  2. break;:在内层循环中,这个 break 没有指定标签,因此它只会跳出当前的内层循环。
  3. break 'main;:这个 break 指定了标签 'main,因此它会直接跳出到标签所标记的外层循环,从而终止整个嵌套循环结构。

运行这段代码,输出将清晰地展示控制流的跳转过程。

循环标签的核心要点

以下是关于循环标签需要记住的几个关键点。

  • 语法:标签以单引号 ' 开头,例如 'outer_loop
  • 作用范围:标签定义在循环关键字(loopwhilefor)之前。
  • continue 共用continue 语句也可以使用标签,例如 continue 'outer_loop;,这将直接跳到指定标签循环的下一次迭代。
  • 多层嵌套:你可以为任意多层嵌套的循环定义标签,从而精确控制程序流。

总结

本节课中我们一起学习了 Rust 的循环标签。我们了解到,在处理复杂的嵌套循环时,可以使用标签来精确控制 breakcontinue 语句的作用对象。虽然嵌套循环可能使逻辑变得复杂,但标签提供了一种清晰的方式来管理这种复杂性。

现在,我们已经完成了 Rust 中所有关于循环的学习。接下来,我们将开始接触 Rust 更核心、更强大的主题:所有权

024:Rust 所有权入门 🧠

在本节课中,我们将要学习 Rust 的第一个重要概念:所有权。所有权是一套管理 Rust 程序如何管理内存的规则。理解所有权是掌握 Rust 内存安全和高效性的关键。

概述:Rust 的内存管理方法

Rust 在内存管理上采用了独特的方法。有些语言使用垃圾回收机制,在程序运行时自动寻找不再使用的内存。另一些语言则要求程序员显式地分配和释放内存。Rust 则通过一套所有权系统来管理内存,这套系统包含一系列我们必须遵守的规则,否则程序将无法编译。

本节内容将以理论讲解为主。在接下来的几节课中,我们将通过更具体的例子来深入理解所有权在 Rust 中是如何工作的。

内存基础:栈与堆

在深入所有权之前,我们必须了解内存管理中的两个核心概念:栈和堆。在许多高级编程语言中,我们无需考虑这些,但在 Rust 中,理解它们至关重要,因为它们会影响语言的行为。

栈和堆都是程序在运行时可以使用的内存部分,但它们的结构方式不同。

栈的工作原理

栈以“后进先出”的顺序存储值。想象一下堆叠盘子:当你添加一个盘子时,它被推到顶部;当你取走一个盘子时,你总是从顶部拿走。这种方式比从中间或底部取盘子要简单得多。

存储在栈上的所有数据都必须有一个已知的、固定的大小。在编译时大小未知或大小可能变化的数据,必须存储在堆上。

堆的工作原理

堆的组织性不如栈。当你将数据放入堆时,你需要请求一定大小的空间。内存分配器会在堆中找到一个足够大的空闲位置,将其标记为“正在使用”,然后返回一个指向该位置的指针(即内存地址)。这个过程被称为“在堆上分配”。

为了便于理解,想象你有一个储物空间和一些大小不一的箱子。你给箱子贴上标签,将它们随机放入储物空间。每次你需要找到某个特定箱子时,即使有标签,也需要花费时间去寻找。同样,如果你想存入一个新箱子,也需要为它找到一个位置。

栈比堆快,因为栈永远不需要搜索存储数据的位置,其位置(栈顶)总是已知的。

所有权的重要性

那么,为什么理解栈和堆如此重要呢?所有权系统正是为了解决以下与堆内存相关的问题:

  • 跟踪代码的哪些部分正在使用堆上的哪些数据。
  • 最小化堆上的重复数据。
  • 清理堆上不再使用的数据,以避免空间耗尽。

一旦我们理解了所有权,就不必经常思考栈和堆了。了解所有权的主要目的是管理堆数据,这能帮助我们理解其工作原理。

(注:本节的许多解释直接借鉴了 Rust 官方文档。如果你对此感兴趣,可以在视频描述中找到相关链接。)

总结

本节课中,我们一起学习了 Rust 所有权的引入背景和内存基础。我们了解到 Rust 通过独特的所有权规则来管理内存,这与垃圾回收或手动管理的方式不同。我们还探讨了程序运行时内存的两个重要区域:栈和堆,理解了它们不同的组织方式和访问速度,并明确了所有权系统主要致力于管理堆数据。

在下一节中,我们将开始详细探讨所有权的具体规则。

025:String 与 &str 初探 🧵

在本节课中,我们将继续学习 Rust 中的所有权概念。首先,我们需要了解一些核心规则,然后通过变量作用域和两种字符串类型的对比,来理解所有权如何在实际代码中运作。

所有权核心规则 📜

在 Rust 中处理所有权时,必须牢记以下三条核心规则:

  1. Rust 中的每一个值都有一个所有者。
  2. 同一时间只能有一个所有者。
  3. 当所有者离开作用域时,这个值将被丢弃。

变量作用域 🎯

上一节我们介绍了所有权的规则,本节中我们来看看变量作用域。作用域是一个程序中项目的有效范围。

例如,当我们创建一个函数时,就创建了一个属于该函数的作用域。花括号 {} 内的所有内容都被视为该函数的作用域。

fn function() {
    // 这是函数的作用域
    let s = "hello world";
    println!("{:?}", s); // 可以在这里使用 s
}

在函数内部声明变量 s 后,我们可以在声明之后使用它。但在声明之前,我们不能使用它。因为 Rust 在编译时还不知道这个变量。变量 s 的有效期持续到其作用域结束。一旦离开这个函数的作用域,我们就无法再使用 s

字符串类型:&str 与 String 🧩

在之前的课程中,你可能注意到我将字符串切片和字符串类型都称为“字符串”。在 Rust 学习的初期,这没有问题。但现在我们开始接触核心概念,必须明确指出:字符串切片 (&str)字符串 (String) 在底层工作机制上完全不同。

字符串字面量 (&str)

到目前为止,我们使用以下语法创建字符串字面量:

let wisdom = "wash your hands with soap";

这是一个字符串字面量,我们可以使用 println!("{:?}", wisdom); 来显示它。运行程序后,输出将是 "wash your hands with soap"

字符串字面量是不可变的。这意味着我们无法编辑它。即使添加 mut 关键字,也无法向这个字符串字面量添加更多文本。例如,以下代码将无法工作:

let mut wisdom = "wash your hands with soap";
wisdom += " clean hands are happy hands"; // 错误:无法修改字符串字面量

字符串字面量非常方便易用,但并不适用于所有场景。例如,我们创建的字符串是不可变的。但如果我们想编辑字符串内容,该怎么办?

String 类型

Rust 提供了第二种字符串类型 String,它允许我们进行修改。由于这种类型管理堆上的数据,它能够存储编译时未知大小的文本。

以下是创建 String 的方法:

let mut s = String::from("Hello");

如果你将鼠标悬停在 s 上,会发现它的类型是 String,而不再是字符串切片 &str。这是一个可以修改的类型,因为它存储在堆上。

代码中的两个冒号 :: 被称为路径分隔符,它允许我们访问类型的关联函数或模块中的项目。在这个例子中,我们调用了定义在 String 类型上的关联函数 from。我们将在未来的课程中了解更多相关内容。

现在,只需知道我们正在处理一个可变的字符串。接下来,我们可以尝试修改它:

s += ", Bob";
println!("{:?}", s);

运行程序后,我们将得到输出:"Hello, Bob"。我们成功地修改了原始字符串。

总结 📝

本节课中我们一起学习了 Rust 所有权的三条核心规则,并通过变量作用域理解了值的生命周期。我们重点比较了两种字符串类型:不可变的字符串字面量 &str 和可变的、存储在堆上的 String 类型。关键区别在于它们处理内存的方式不同,而这正是我们下一节课要深入探讨的内容。

026:所有权与字符串

概述

在本节课中,我们将学习 Rust 中字符串可变与不可变的原因,以及这两种类型在内存处理上的差异。我们将探讨字符串字面量和 String 类型在编译时与运行时的行为,并深入理解 Rust 所有权的核心概念,特别是“移动”语义。


字符串字面量与 String 类型

上一节我们介绍了所有权的基本概念,本节中我们来看看它在字符串上的具体体现。

字符串字面量在编译时其内容就已确定。Rust 编译器会将这些文本硬编码到最终的可执行文件中。

代码示例:

let s = "Bob"; // 字符串字面量

这种处理方式使得字符串字面量非常快速和高效,而这些特性正是源于其不可变性。例如,文本 "Bob" 在编译时就被确认为三个字符,且不会改变。与任何可能改变的数据相比,这具有极高的效率,因为可变数据需要更多的处理过程。


可变字符串与堆内存分配

要创建一个可变的字符串,我们需要在堆上分配一块内存,而这块内存的大小在编译时是未知的。

这意味着计算机需要执行两个操作:

  1. 在运行时通过内存分配器请求内存。
  2. 在我们使用完该字符串后,将内存返还给分配器。

第一部分由我们通过代码触发。例如,使用 String::from 方法:

代码示例:

let mut s = String::from("Bob"); // 在堆上分配内存

第二部分则由 Rust 自动完成。一旦变量离开其作用域,Rust 就会自动回收内存。我们可以创建一个简单的作用域来演示:

代码示例:

{
    let text = String::from("Bob");
    // 在此作用域内可以使用 text
} // 离开此作用域时,`text` 被丢弃,内存被释放
// 此处无法再使用 `text`

一旦离开该作用域,text 变量就不再有效。如果尝试在作用域外使用它,Rust 编译器会报错,提示找不到该值,因为变量在离开作用域时已被“丢弃”。


drop 函数与内存释放

当一个变量离开作用域时,Rust 会为我们调用一个特殊的函数——dropString 类型的作者可以在这个函数中编写释放内存的代码。

Rust 会在右花括号 } 处自动调用 drop。这个模式极大地影响了 Rust 代码的编写方式。尽管看似简单,但在更复杂的情况下,当多个变量使用堆上分配的同一份数据时,代码行为可能会出乎意料。

接下来,让我们探讨一些这类情况。


简单值的复制

首先,我们看一个简单值的例子。创建一个变量 a 并赋值为 1,然后创建变量 b 并赋值为 a

代码示例:

let a = 1;
let b = a; // 复制值并绑定到 b
println!("a: {}, b: {}", a, b);

由于整数是已知固定大小的简单值,它们可以轻松地被推入栈中,不会带来意外。这意味着我们可以同时打印 ab,运行代码会得到预期的输出。


String 的“移动”语义

现在,让我们看一个使用 String 的例子。

代码示例:

let original_text = String::from("Bob");
let text_copy = original_text; // 注意:这里发生的是移动,而非复制
println!("{}", text_copy);
// println!("{}", original_text); // 此行会导致编译错误!

如果我们尝试运行上述包含 println!("{}", original_text); 的代码,将会得到一个错误。这是因为在此上下文中,Rust 并没有进行复制。

根据官方文档,一个 String 由三部分组成:

  1. 一个指向存放字符串内容内存的指针
  2. 一个长度(当前内容使用的字节数)。
  3. 一个容量(从分配器获得的总字节数)。

这组数据存储在上。当我们执行 let text_copy = original_text; 时,我们复制的不是堆上的实际字符串数据,而是栈上的这组数据(即指针、长度和容量)。

之前我们提到,变量离开作用域时,Rust 会自动调用 drop 来清理其堆内存。如果 original_texttext_copy 都指向堆上的同一块内存,那么在作用域结束时就会产生严重问题:Rust 会尝试释放同一块内存两次,这被称为双重释放错误。这会导致内存损坏,并可能引发安全漏洞。

为了确保内存安全,当新变量复制了旧变量的栈数据(指针、长度、容量)时,Rust 会自动使旧变量失效。因此,上面代码中 original_text 在赋值后便不再有效。

这个过程在 Rust 中被称为移动——我们将数据的所有权从一个变量移动到了另一个变量,并废除了第一个变量。

因此,第二个变量更合适的名字应该是任何不暗示它是“副本”的名称,例如 name

代码示例:

let original_text = String::from("Bob");
let name = original_text; // 所有权从 original_text 移动到 name
println!("{}", name); // 正确
// original_text 在此处已不可用

深度复制

需要了解的是,Rust 默认永远不会创建数据的深度复制。深度复制是指创建一个变量的完全独立副本,这通常开销很大,因为它需要复制变量内部的每一个数据。

因此,在 Rust 中,任何复制操作(对于实现了 Copy trait 的类型,如整数)或移动操作(对于 String 等类型),你都可以认为其在运行时性能上是低开销的,因为它不执行深度复制。


总结

本节课我们一起学习了:

  1. 字符串字面量因其内容在编译时已知且不可变,所以高效,直接嵌入可执行文件。
  2. String 类型需要在堆上分配运行时才知道大小的内存,因此是可变的。
  3. Rust 通过作用域drop 函数自动管理堆内存的释放。
  4. 对于 String 这样的类型,赋值操作(如 let b = a;)触发的是移动语义,而非复制。所有权转移后,原变量失效,这避免了双重释放错误,保证了内存安全。
  5. Rust 默认进行的是浅层复制(移动栈数据)或简单值复制,而非开销大的深度复制。

所有权是 Rust 最独特的特性之一,虽然初看起来概念较多,但随着实践深入,你会逐渐习惯并欣赏它带来的安全保证。在下一课中,我们将继续探讨所有权的其他方面。

027:Rust 中的克隆与复制 📋

在本节课中,我们将继续学习 Rust 的所有权概念,重点探讨如何复制数据。我们将了解 clone 方法、Copy 特型,以及 Rust 如何根据数据类型决定是移动还是复制数据。


变量重新赋值与内存释放

上一节我们介绍了所有权的基本规则。本节中我们来看看一个简单的变量重新赋值场景。

以下代码演示了变量值的更新:

let mut variable = String::from("Bob");
variable = String::from("Ben");
println!("Hello {}", variable);

运行此代码将输出 Hello Ben。最初创建的字符串 "Bob" 在变量被重新赋值为 "Ben" 后,由于没有任何引用指向它,会立即离开作用域并被释放。Rust 的内存管理机制会自动处理这些不再使用的数据,无需手动干预。


移动与克隆

在之前的课程中,我们了解到将一个 String 赋值给新变量会导致数据移动,而非复制。

以下是移动的示例:

let name = String::from("Bob");
let name_copy = name; // 数据从 `name` 移动到 `name_copy`
// println!("{}", name); // 错误!`name` 在此处不再有效
println!("{}", name_copy); // 正确,输出 "Bob"

如果我们希望保留原始变量的使用权,就需要进行显式的克隆操作。

以下是使用 clone 方法进行深拷贝的示例:

let name = String::from("Bob");
let name_copy = name.clone(); // 显式克隆数据
println!("Original: {}, Copy: {}", name, name_copy); // 两者皆可使用

clone() 方法会创建数据的完整副本,允许两个变量独立使用各自的数据。但需要注意,克隆操作(尤其是对于大型数据)可能带来性能开销。


Copy 特型与栈上数据

你可能会问,为什么之前对整数的赋值操作可以正常工作?

以下是整数赋值的示例:

let n1 = 100;
let n2 = n1; // 这是复制,而非移动
println!("n1: {}, n2: {}", n1, n2); // 两者皆可正常使用

这是因为像整数这样的简单标量类型实现了 Copy 特型。Copy 特型是一个编译器标记,用于那些大小已知且存储在栈上的类型。对于实现了 Copy 的类型,赋值操作会自动进行按位复制,原始变量在赋值后依然有效。

核心概念:如果一个类型实现了 Copy 特型,那么它的变量在赋值时会被复制,而不是移动


哪些类型实现了 Copy?

以下是通常实现 Copy 特型的类型列表:

  • 所有整数类型:例如 i32u64i16 等。
  • 布尔类型bool
  • 浮点数类型f32f64
  • 字符类型char
  • 元组:仅当其包含的所有元素类型也都实现 Copy 时。例如,(i32, f64) 实现 Copy,但 (i32, String) 不实现。

任何需要堆分配或持有某种资源的类型(如 StringVec<T>)都不会实现 Copy 特型。


对比示例

让我们通过一个对比来巩固理解:

示例 1:实现 Copy 的类型(整数)

let number = 42;
let number_copy = number; // 复制发生
println!("Number: {}, Copy: {}", number, number_copy); // 两者皆有效

示例 2:未实现 Copy 的类型(String)

let text = String::from("Hello");
let text_copy = text; // 移动发生,`text` 的所有权转移
// println!("Original: {}", text); // 错误!`text` 已无效
println!("Copy: {}", text_copy); // 正确

总结

本节课中我们一起学习了 Rust 中数据复制的机制:

  1. 对于 String 等复杂类型,赋值默认是移动,使用 clone() 方法可以进行显式的深拷贝
  2. 对于整数等简单类型,由于实现了 Copy 特型,赋值是自动复制,原始变量保持有效。
  3. Copy 特型适用于大小固定、存储在栈上的类型,它是 Rust 高效内存管理的重要组成部分。

理解移动与复制的区别,是掌握 Rust 所有权系统的关键一步。下一节,我们将完成所有权部分的最终讲解。

028:函数与所有权 🔄

在本节课中,我们将要学习 Rust 中所有权机制如何与函数交互。具体来说,我们会探讨将值传递给函数时发生的所有权转移或复制行为,以及如何通过返回值和元组来管理所有权。


概述

将值传递给函数的机制与将值赋给变量非常相似。这意味着,将变量传递给函数会移动或复制它,就像赋值操作一样。

函数参数的所有权转移

以下是一个示例函数 greet,它接收一个 String 类型的参数。

fn greet(name: String) {
    println!("Hello {}", name);
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/85805ced8029b9b9b84fcbc3983e1a25_4.png)

fn main() {
    let text = String::from("Bob");
    greet(text);
}

运行此代码会输出 “Hello Bob”。然而,一个关键点是:函数 greet 取得了 text 的所有权。这意味着在调用 greet(text) 之后,变量 text 不再有效。

如果尝试在函数调用后再次使用 text,例如 println!("{}", text);,Rust 编译器会报错,提示 text 的值已被移动。这是因为 String 类型没有实现 Copy trait。

实现 Copy Trait 的类型

上一节我们介绍了所有权如何因函数调用而转移,本节中我们来看看当类型实现 Copy trait 时的情况。

对于实现了 Copy trait 的类型(如 i32),将值传递给函数时会发生复制,原始变量在函数调用后仍然可用。

fn display_number(n: i32) {
    println!("The number is {}", n);
}

fn main() {
    let n = 200;
    display_number(n);
    // n 仍然有效,因为 i32 实现了 Copy
    println!("Second attempt: {}", n);
}

此代码可以成功编译并运行,因为 i32 类型在传递时被复制。

返回值的所有权转移

函数不仅可以通过参数取得所有权,也可以通过返回值转移所有权。

以下是一个创建并返回 String 的函数示例。

fn create_string() -> String {
    String::from("Bob")
}

fn main() {
    let s1 = create_string(); // 返回值所有权转移给 s1
    let s2 = create_string(); // 返回值所有权转移给 s2
    println!("{:?}, {:?}", s1, s2);
}

需要注意的是,println! 宏也会取得其参数的所有权。对于未实现 Copy 的类型,在使用 println! 后便无法再次使用该变量。

取得并返回所有权

有时我们希望函数处理一个值,但之后还能继续使用它。这可以通过让函数取得所有权并再返回它来实现。

以下是处理字符串并返回的函数示例。

fn process_text(text: String) -> String {
    text.to_uppercase() // 返回新的 String,所有权转移回调用者
}

fn main() {
    let s1 = String::from("Bob");
    let s3 = process_text(s1); // s1 的所有权被移动
    // s1 在此之后不再有效
    println!("{}", s3); // 输出 "BOB"
}

在这个例子中,s1 的所有权被移动到 process_text 函数中。函数处理完后,通过返回值将所有权转移给了新变量 s3。原始的 s1 在移动后失效。

使用元组返回多个值

如果想让函数使用一个值但不取得其所有权,同时还想返回关于该值的其他信息,一种方法是使用元组返回多个值。

以下是计算字符串长度并返回原始字符串和长度的函数示例。

fn total_characters(text: String) -> (String, usize) {
    let length = text.chars().count(); // 计算字符数
    (text, length) // 以元组形式返回原始字符串和长度
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/85805ced8029b9b9b84fcbc3983e1a25_20.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/85805ced8029b9b9b84fcbc3983e1a25_21.png)

fn main() {
    let text = String::from("Bob");
    let (text, length) = total_characters(text); // 接收返回的元组
    println!("The text '{}' has a total length of {}.", text, length);
}

通过这种方式,我们成功地将所有权“往返”传递,使得在函数调用后仍能使用原始数据。但这对于简单的操作来说仍然显得繁琐。


总结

本节课中我们一起学习了 Rust 中函数与所有权的交互:

  1. 将变量传递给函数会移动或复制它,取决于该类型是否实现 Copy trait。
  2. 函数可以通过返回值转移所有权。
  3. 可以使用元组从函数返回多个值,从而在操作后保留对原始数据的所有权。
  4. 管理所有权的这些模式虽然强大,但有时会带来不便。幸运的是,Rust 提供了引用这一特性,它允许我们使用值而无需取得所有权,这将是我们下节课要探讨的内容。

029:理解引用

概述

在本节课中,我们将要学习 Rust 中的一个核心概念:引用。我们将了解什么是引用,它如何帮助我们避免所有权转移带来的复杂问题,以及如何使用引用来“借用”数据而不获取其所有权。

上一节我们介绍了所有权,最后探讨了如何通过返回元组来保持所有权,以便能继续使用变量。返回元组本身没有问题,但在我们之前的例子中,为了实现目标,这种方法显得过于复杂。一个更好的方法是提供对字符串值的引用

引用类似于指针,它是一个地址,我们可以通过这个地址访问由另一个变量拥有的、存储在该地址的数据。但与指针不同,引用在其生命周期内保证指向特定类型的有效值。

创建使用引用的函数

这次,我们将创建一个与上节视频中相同的函数,但会使用引用,这将帮助我们避免返回元组。

以下是函数定义步骤:

  1. 我们输入 get_length 作为函数名。
  2. 在参数中,我们创建 text。请注意,我将在数据类型前使用 & 符号。这个 & 用于表示一个引用。因此,text 现在必须是一个引用。
  3. 这个函数将返回 usize 类型。
  4. 在函数体内,我们可以输入 text.chars().count() 来计算长度。

对应的代码如下:

fn get_length(text: &String) -> usize {
    text.chars().count()
}

main 函数中调用

接下来,我们进入 main 函数。

以下是具体操作步骤:

  1. 创建一个名为 name 的变量,其值为字符串 "George"
  2. 然后,我们让变量 length 等于 get_length(&name)。这里我们需要传入一些文本,所以我们再次使用 & 符号传入 name
  3. 再次使用 & 允许我们引用一个值而不获取其所有权。由于这是一个引用,它所指向的值在我们使用完毕后不会被丢弃。
  4. 接下来,我们执行 println!,打印名字的长度。

对应的代码如下:

fn main() {
    let name = String::from("George");
    let length = get_length(&name);
    println!("The length of '{}' is {}.", name, length);
}

如果我们以安静模式运行此代码,会注意到 George 的长度是 6。我们也可以将其改为 Bob,这会返回 3。这样做最大的好处是我们的变量仍然有效,因为我们没有获取所有权,这意味着我们以后仍然可以使用它们。

所以在这里,我们可以传入 namelength,只要我在 println! 语句后加上分号,这就能正常工作。运行后,我们会在控制台看到名字和长度都被打印出来。

理解借用

我们称创建引用的动作为借用。就像现实生活中一样,如果你从别人那里借了东西,你可以使用它,但你不拥有它,最终必须归还。

如果我们尝试修改一个借用的变量会发生什么?在下一个示例中,我将创建一个名为 modify 的新函数。

以下是函数定义:

  1. 函数 modify 接受一个参数 text
  2. 该参数将是这种引用类型 &String
  3. 在函数体内,我们输入 text.push_str("!"),为文本追加一个感叹号。

对应的代码如下:

fn modify(text: &String) {
    text.push_str("!");
}

现在我们可以回到 main 函数,输入 modify(&name),传入一个引用。这满足了第一部分的要求,因为这是一个 String 类型的引用。

但是,Rust 再次不会编译这段代码,因为我们试图修改一个不可变的变量。引用在默认情况下是不可变的

不过,这并非世界末日,因为在下一节视频中,我将教你如何创建和使用可变引用。

总结

本节课中我们一起学习了 Rust 中的引用。我们了解到引用是一种允许你访问数据而不获取其所有权的机制,这通过 & 符号实现。我们创建了一个使用引用的函数来计算字符串长度,从而避免了所有权转移和返回元组的复杂性。我们还了解到,默认情况下引用是不可变的,尝试通过它们修改变量会导致编译错误。在下一节中,我们将探讨如何通过可变引用来修改借用的数据。

030:可变引用详解 🧩

在本节课中,我们将学习 Rust 中一个核心且强大的概念:可变引用。我们将了解如何通过可变引用来修改数据,同时避免所有权转移,并深入探讨其背后的规则与限制。


概述

上一节我们了解到,Rust 中的引用默认是不可变的。本节中,我们来看看如何创建和使用可变引用,从而在保留所有权的同时修改数据。我们还将学习可变引用的关键规则,这些规则是 Rust 保证内存安全、防止数据竞争的基石。


创建可变引用

之前我们尝试修改一个通过引用传递的字符串,但失败了,因为默认的引用是不可变的。

为了修复代码,我们需要明确告诉 Rust 我们想要一个可变引用。这可以通过在类型前添加 mut 关键字来实现。

以下是修复后的代码示例:

fn modify_text(text: &mut String) {
    text.push_str("!");
}

fn main() {
    let mut text = String::from("Bob");
    modify_text(&mut text);
    println!("The text is: {}", text); // 输出:The text is: Bob!
}

在这个例子中:

  • 我们在函数签名 &mut String 和调用处 &mut text 都使用了 mut 关键字。
  • 这次我们成功地修改了字符串,而没有转移其所有权,这一切都是通过可变引用完成的。

可变引用的核心限制

可变引用有一个重要的限制:在特定作用域内,对同一块数据只能有一个活跃的可变引用

以下代码将导致编译错误:

let mut text = String::from("Bob");
let r1 = &mut text;
let r2 = &mut text; // 错误!不能同时借用 `text` 为可变多次
println!("{}, {}", r1, r2);

这个限制是 Rust 在编译时防止数据竞争的关键机制。数据竞争发生在以下三种行为同时出现时:

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

数据竞争会导致未定义行为,在运行时难以追踪和修复。


通过作用域使用多个可变引用

虽然不能同时使用多个可变引用,但可以通过创建不同的作用域来使用它们。关键在于引用不能同时活跃。

以下代码可以正常工作:

let mut text = String::from("Bob");
{
    let r1 = &mut text;
    println!("r1: {}", r1);
} // r1 的作用域在此结束,它不再活跃
let r2 = &mut text; // 现在可以创建新的可变引用
println!("r2: {}", r2);

因为 r1r2 活跃于不同的作用域,它们没有同时引用同一数据。


可变引用与不可变引用的组合规则

关于引用组合,Rust 有明确的规则:

  • 允许同时存在多个不可变引用,因为它们都是只读的,不会引发数据竞争。
    let text = String::from("Bob");
    let r1 = &text;
    let r2 = &text; // 允许
    println!("{}, {}", r1, r2);
    

  • 不允许可变引用与不可变引用同时存在
    let mut text = String::from("Bob");
    let r1 = &text;      // 不可变引用
    let r2 = &mut text;  // 错误!不能同时借用为不可变和可变
    
    这是因为不可变引用的使用者理应能信赖数据不会被改变。如果同时存在可变引用,这种信赖就被破坏了。

引用作用域的结束时机

一个引用的作用域从它被引入的地方开始,到它最后一次被使用的地方结束,而非其所在代码块的末尾。理解这一点有助于编写合法的代码。

观察以下能成功编译的代码:

let mut text = String::from("Bob");
let r1 = &text;
let r2 = &text;
println!("{} and {}", r1, r2); // r1 和 r2 的最后一次使用
// 自此之后,r1 和 r2 不再被使用,它们的借用结束
let r3 = &mut text; // 现在创建可变引用是允许的
println!("{}", r3);

因为不可变引用 r1r2println! 之后就不再被使用,它们的借用在那时就已经结束,因此后面可以安全地创建可变引用 r3


活跃引用期间修改原变量

当对一个变量存在活跃的可变引用时,在引用作用域结束前,你不能直接修改原变量。

以下代码无法编译:

let mut x = 10;
let y = &mut x;
x = x + 3; // 错误!不能修改 `x`,因为存在对它的活跃可变借用 `y`
println!("{}", y);

要解决这个问题,必须确保可变引用 y 在修改原变量 x 之前离开作用域(即不再被使用)。

let mut x = 10;
let y = &mut x;
println!("y: {}", y); // y 在此被最后一次使用,借用结束
x = x + 3; // 现在可以修改 x
println!("x: {}", x);


总结

本节课中我们一起学习了 Rust 的可变引用。我们掌握了:

  1. 如何使用 &mut 创建可变引用来修改数据。
  2. 可变引用的核心限制:同一时间、同一作用域内,对同一数据只能有一个可变引用
  3. 可变引用与不可变引用不能共存。
  4. 引用的作用域持续到其最后一次被使用为止。
  5. 在存在活跃可变引用时,不能直接修改原变量。

这些严格的规则是 Rust 无需垃圾回收就能保证内存安全和并发安全的关键。理解并遵守它们,是编写健壮 Rust 代码的基础。

031:悬垂引用与编译器保障 🛡️

在本节课中,我们将要学习 Rust 中的一个核心安全特性:悬垂引用。我们将了解什么是悬垂引用,为什么它们危险,以及 Rust 编译器如何确保你的代码永远不会产生悬垂引用。

什么是悬垂引用?

在拥有指针概念的语言中,很容易错误地创建一个悬垂指针。这种情况通常发生在释放了某块内存,却保留了指向该内存的指针时。一个悬垂指针是指向一块可能已经被重新分配或释放的内存的指针。

幸运的是,Rust 的编译器保证引用永远不会是悬垂的。如果你拥有某个数据的引用,编译器会确保该数据在引用被使用之前不会离开其作用域。

一个悬垂引用的尝试示例

为了将上述概念置于具体情境中,我们来创建一个示例。我们将创建一个名为 function 的函数,它试图返回一个引用类型。

fn function() -> &str {
    let text = String::from("Bob");
    &text
}

main 函数中,我们将尝试获取这个函数的返回结果:

fn main() {
    let r = function();
}

我们在这里试图创建一个悬垂引用。或者说,我们试图这样做,因为 Rust 不允许我们创建悬垂引用。

编译器如何阻止我们

如果我们尝试编译这段代码,将会导致一个错误。错误信息大致是:“此函数的返回类型包含一个借用值,但没有可供借用的值”。

我们指定了想要返回一个引用 &str,然后在函数内部创建了一个新的 String 并试图返回对它的引用。然而,当函数执行到右花括号 } 时,变量 text 离开了作用域并被丢弃,其内存被释放。但我们却告诉 Rust 我们想返回一个指向 text 的引用。这意味着我们的引用将指向一个已不存在的数据。

Rust 编译器会阻止我们运行这种有问题的代码。我们不应该被允许引用已不存在的数据,否则会在代码中引发大量问题。

解决方案:正确使用所有权

接下来,我们看看这个问题的解决方案。本质上,解决方案就是确保在正确的地方使用引用。在上面的例子中,我们本不需要使用引用类型,使用它反而破坏了程序。

我们只需要返回一个普通的 String 即可:

fn function() -> String {
    let text = String::from("Bob");
    text
}

这样修改后,我们的程序就没有错误了。我们可以在终端中运行程序来验证,得到的输出将是 r 等于 "Bob"

引用规则回顾

作为对本节及之前内容的回顾,以下是 Rust 中关于引用的核心规则:

  • 在任意给定时间,对于特定数据,你只能拥有以下两者之一
    • 一个可变引用 (&mut T)。
    • 任意数量的不可变引用 (&T)。
  • 引用必须始终是有效的(即永不悬垂)。

本节课中,我们一起学习了悬垂引用的概念及其危险性。我们看到了 Rust 编译器如何通过所有权和作用域规则,在编译期就杜绝了产生悬垂引用的可能性,这是 Rust 内存安全的核心保障之一。我们还通过一个错误示例及其修正,理解了何时应该返回值本身而非引用。记住,引用必须始终指向有效的数据。

032:字符串切片入门 🧩

在本节课中,我们将开始学习 Rust 中的切片类型。切片允许你引用集合中一段连续的元素序列,而不是整个集合。我们将通过解决《Rust 官方教程》中的一个具体问题来理解切片的概念和必要性。

切片简介

切片是一种引用,因此它不拥有数据的所有权。这个概念直接来自 Rust 官方文档。为了开始学习切片,我们将分析一个具体问题:编写一个函数,接收一个由空格分隔的单词字符串,并返回找到的第一个单词。如果字符串只有一个单词,则返回整个字符串。

问题:获取第一个单词的索引

首先,我们创建一个名为 get_first_word 的函数。该函数接收一个 String 类型的句子引用,因为我们不需要所有权,并返回一个无符号整数。这个整数将是分隔第一个单词最后一个字符和第二个单词的空格的索引。

以下是函数实现的步骤:

  1. 将字符串转换为字节数组,以便逐个元素遍历。
  2. 创建一个迭代器来遍历字节数组。
  3. 使用 enumerate 方法同时获取索引和元素。
  4. 检查每个元素是否为空格字符的字节表示。
  5. 如果找到空格,返回其索引;否则,返回整个字符串的长度。

以下是 get_first_word 函数的代码实现:

fn get_first_word(sentence: &String) -> usize {
    let bytes = sentence.as_bytes();
    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }
    sentence.len()
}

理解 enumerate 方法

enumerate 方法将迭代器中的元素包装成元组,元组包含索引和元素的引用。这让我们能同时访问索引和值。

以下是一个简单的例子,展示 enumerate 的用法:

fn main() {
    let elements = ['A', 'B', 'C'];
    for (i, &element) in elements.iter().enumerate() {
        println!("Index: {}, Element: {}", i, element);
    }
}

运行上述代码将输出:

Index: 0, Element: A
Index: 1, Element: B
Index: 2, Element: C

测试函数并发现问题

现在,我们在 main 函数中测试 get_first_word

fn main() {
    let mut sentence = String::from("Bob doesn't care");
    let first_word = get_first_word(&sentence);
    println!("The first word ends at index: {}", first_word);
    sentence.clear();
    println!("Sentence after clear: '{}'", sentence);
}

运行这段代码,first_word 的值是 3,因为函数在索引 3 处遇到了空格。程序编译正常,但 first_word 这个索引值与 sentence 的状态是分离的。在我们调用 sentence.clear() 清空字符串后,first_word 所指向的索引值(3)就变得毫无意义了。

这种方法存在一个主要问题:我们必须时刻担心 first_word 的索引是否与 sentence 的实际数据保持同步。这种同步是繁琐且容易出错的。如果我们想找到句子的第一个和最后一个单词,就需要跟踪两个独立的变量,并确保它们始终同步,这会使程序变得更加复杂。

本节总结

本节课中,我们一起学习了如何通过索引来定位字符串中的第一个单词。我们创建了 get_first_word 函数,并理解了 enumerate 方法的作用。然而,我们也发现了这种方法的核心缺陷:返回的索引与原始数据是分离的,数据变化后索引可能失效,导致程序逻辑错误。

Rust 为这个问题提供了一个优雅的解决方案:字符串切片。在下一节课中,我们将看看如何使用字符串切片来解决这个数据同步的问题,使代码更安全、更简洁。

033:字符串切片实战 🧩

在本节课中,我们将继续学习 Rust 中的字符串切片。上一节我们介绍了字符串切片的基本概念,本节中我们来看看一些具体的实战例子。

字符串切片是对字符串某一部分的引用,其形式如下(这里指的不是 println! 语句):

&str

为了开始学习,我们再次创建一个句子:

let sentence = String::from("Bob loves Chinese food");

现在,假设我们想引用名字“Bob”或者单词“Chinese”。我们可以这样做:

let name = &sentence[0..3];
let food = &sentence[10..17];

这里,name 引用了从索引 0 到 3(不包括 3)的字符。food 引用了从索引 10 到 17(不包括 17)的字符。因为 17 是不包含在内的,所以我们需要指定为 17 才能完整地获取“Chinese”。Rust 也有语法(..=)可以包含结束索引,但这超出了本节的范围。

运行调试后,我们将得到 name 包含字符串切片“Bob”,food 包含字符串切片“Chinese”。

我们所做的是创建了对字符串一部分的引用,并通过切片符号选择了该部分。在内部,切片数据结构存储了起始位置和切片的长度。以 food 为例,它将是一个指向索引 10 处字节的指针,并带有一个长度为 7 的值。

在继续之前,了解用于切片的不同范围语法会非常有用。

以下是几种切片范围语法的示例:

  • 显式指定起始和结束索引&sentence[0..3]
  • 从索引 0 开始可以省略起始索引&sentence[..3]
  • 从某个索引开始直到结尾&sentence[10..]
  • 引用整个字符串&sentence[..]

你可以通过调试来验证这些切片是否按预期工作。

此外,创建切片时有一个极其重要的细节需要注意:切片必须在有效的字节边界处结束,否则程序会崩溃

例如,如果有一个包含多字节字符(如“Brün”)的名字,尝试在不完整的字节处切片会导致程序恐慌。你必须确保切片范围完整地包含多字节字符的所有字节。

现在,让我们修复上一节视频中创建的函数。原来的函数返回一个 usize 索引。我们将修改它,使其返回一个字符串切片。

修改后的函数如下:

fn get_first_word(s: &String) -> &str {
    let bytes = s.as_bytes();
    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[..i];
        }
    }
    &s[..]
}

这个函数现在返回从句子开头到第一个空格处的切片,如果没有空格,则返回整个句子的切片。

在主函数中,我们可以这样使用它:

let mut sentence = String::from("Holy Bananas");
let word = get_first_word(&sentence);
println!("The first word is: {}", word);
// sentence.clear(); // 如果在这里清空句子,会导致编译错误,因为word仍持有不可变引用

现在,我们不再需要担心 word 引用的数据被意外改变,因为借用检查器会确保在 word 的有效期内,sentence 不会被可变地修改。

接下来,我们谈谈字符串字面量作为切片。当我们写下 let name = "Bob"; 时,name 的类型就是 &str,它是一个指向程序二进制文件中特定位置的字符串切片。这也是字符串字面量不可变的原因——字符串切片本身就是不可变引用。

了解到可以对字面量和 String 值进行切片,这引出了对我们之前函数的一个便利改进:参数类型。

我们可以将函数签名改为接受 &str 类型,而不是 &String

fn get_first_word(s: &str) -> &str {
    // ... 函数体不变
}

这样做的好处是,我们的函数现在可以接受更多类型的字符串:

  • String 的切片:get_first_word(&sentence[..])
  • String 的引用:get_first_word(&sentence)
  • 字符串字面量:get_first_word("Bob says hi")

这非常方便,因为它提高了函数的通用性。如果我们坚持使用 &String 类型,那么传递一个字符串切片(&str)就会导致类型不匹配的错误。

最后需要提及的是,Rust 中除了字符串切片,还有其他类型的切片。字符串切片是专门用于字符串的。但还有更通用的切片类型,例如数组切片。

我们可以这样创建一个数组切片:

let array = [1, 2, 3, 4, 5];
let slice = &array[2..];
println!("The slice is: {:?}", slice);

运行后,我们将得到数组 [3, 4, 5] 的切片。slice 变量的类型将是 &[i32]。它的工作原理与字符串切片类似,存储对第一个元素的引用和长度。随着 Rust 学习的深入,我们会了解更多关于其他切片类型的知识。

本节课中我们一起学习了 Rust 字符串切片的实战应用,包括如何创建切片、不同的范围语法、处理多字节字符的注意事项,以及如何利用 &str 类型使函数更通用。我们还简单了解了数组切片的存在。掌握字符串切片是理解 Rust 所有权和借用系统的关键一步。

034:结构体详解 🏗️

在本节课中,我们将要学习 Rust 中的结构体。结构体是一种自定义数据类型,它允许你将多个相关的值打包并命名,从而构成一个有意义的组合。我们将从定义结构体开始,逐步学习如何创建实例、访问和修改字段,以及使用函数简化创建过程。

结构体的定义

结构体与元组类似,都用于保存相关值。但结构体更具结构性,因为你可以为每个数据片段命名,这使得代码更易读和使用。

要定义一个结构体,需要使用 struct 关键字。结构体的名称应与其包含的数据相关。例如,我们可以定义一个名为 Fruit 的结构体。在大括号内,我们定义数据片段的名称和类型,这些也称为字段。

以下是定义 Fruit 结构体的示例代码:

struct Fruit {
    name: String,
    color: String,
    grams: i32,
    price: f32,
}

在这个例子中,Fruit 结构体有四个字段:name(字符串类型)、color(字符串类型)、grams(32位整数类型)和 price(32位浮点数类型)。理论上,grams 也可以是浮点类型,但这里我们假设只关心整数克数。

创建结构体实例

上一节我们介绍了如何定义结构体,本节中我们来看看如何创建和使用它的实例。要使用结构体,首先必须通过为每个字段指定值来创建它的一个实例。

以下是创建 Fruit 实例的示例:

let apple = Fruit {
    name: String::from("Apple"),
    color: String::from("Green"),
    grams: 100,
    price: 5.5,
};

这个 apple 实例重 100 克,价格为 5.5 美元。在实际程序中,字段名可能更具体,如 price_per_kilo。需要注意的是,指定字段值的顺序不必与结构体定义中的顺序一致,只要提供所有必需信息即可。

访问和修改字段

创建实例后,可以使用点号来访问其字段值。例如,要获取名称,可以这样写:

let name = apple.name;
let grams = apple.grams;
println!("{} weighs {} grams", name, grams);

运行上述代码将输出:Apple weighs 100 grams

如果要修改字段的值,需要将实例声明为可变的。一旦实例可变,其所有字段都将可变,不能单独指定某个字段可变。

以下是修改字段的示例:

let mut apple = Fruit {
    name: String::from("Apple"),
    color: String::from("Green"),
    grams: 100,
    price: 5.5,
};
println!("Before: {} grams", apple.grams);
apple.grams = 200;
println!("After: {} grams", apple.grams);

运行后,输出将显示 grams 从 100 变为 200。

使用函数简化创建

如果觉得每次创建结构体实例都写很多代码,可以创建返回结构体的函数来简化过程。

以下是创建 create_fruit 函数的示例:

fn create_fruit(name: &str, grams: i32) -> Fruit {
    Fruit {
        name: String::from(name),
        grams,
        price: 0.02 * grams as f32,
    }
}

这个函数接收名称(字符串切片)和克数(整数),返回一个 Fruit 实例。注意,我们省略了 color 字段,并直接使用参数计算价格。使用函数后,创建水果实例变得非常简单:

let orange = create_fruit("Orange", 156);
println!("{} grams of {} costs ${:.2}", orange.grams, orange.name, orange.price);

运行后将输出:156 grams of Orange costs $3.12。可以轻松更改参数来创建不同的水果实例。

字段初始化简写语法

Rust 提供了一个名为“字段初始化简写语法”的特性,可以帮助减少结构体代码中的冗余。

当函数参数名与结构体字段名完全相同时,可以省略重复的赋值。以下是使用简写语法的示例:

fn create_fruit_simple(name: String, grams: i32) -> Fruit {
    Fruit {
        name,   // 字段名与参数名相同,使用简写
        grams,  // 字段名与参数名相同,使用简写
        price: 0.02 * grams as f32,
    }
}

在这个例子中,参数 namegrams 与结构体字段名一致,因此可以直接写入,无需写成 name: name。这使代码更加简洁。

总结

本节课中我们一起学习了 Rust 结构体的核心概念。我们首先了解了如何定义结构体,然后学习了如何创建实例、使用点号访问和修改字段。接着,我们探索了通过函数来简化结构体创建过程的方法。最后,我们介绍了字段初始化简写语法,这是一种减少代码冗余的有效技巧。结构体是 Rust 中组织和管理相关数据的重要工具,掌握它们对编写清晰、高效的代码至关重要。

035:结构体更新语法与数据移动

在本节课中,我们将学习如何使用一个现有结构体实例的值来创建新的实例,这个功能被称为“结构体更新语法”。我们还将探讨与之相关的数据“移动”概念,这对于理解 Rust 的所有权系统至关重要。

定义用户结构体

首先,我们定义一个名为 User 的结构体,它包含三个字段:用户ID、用户名和电子邮件地址。

struct User {
    id: i32,
    username: String,
    email: String,
}

创建初始实例

接下来,我们创建这个 User 结构体的第一个实例。

let user = User {
    id: 0,
    username: String::from("Bob123"),
    email: String::from("Bob@indently.io"),
};

低效的更新方式

假设我们需要创建一个新的用户实例,它的大部分信息与 user 相同,但电子邮件地址需要更新。一种直观但低效的方法是手动复制所有字段。

let updated_user = User {
    id: user.id,
    username: user.username,
    email: String::from("Bob@rustfully.com"),
};

这种方法的问题在于,即使我们只想更改一个字段(如 email),也必须显式列出所有其他字段。当结构体有很多字段时,这会变得非常冗长。

使用结构体更新语法

Rust 提供了一种更简洁的语法来解决这个问题,即结构体更新语法。它使用 .. 符号来指定剩余字段应从另一个实例中获取。

以下是使用更新语法的正确方式:

let updated_user = User {
    email: String::from("Bob@rustfully.com"),
    ..user
};

关键规则..user 必须放在最后。不能将它放在其他字段之前,否则代码将无法编译。

使用这个语法后,updated_user 将拥有新的电子邮件地址,而 idusername 字段的值则来自原始的 user 实例。

理解数据移动

上一节我们介绍了如何使用更新语法,本节中我们来看看这个操作背后一个非常重要的概念:数据移动

结构体更新语法使用 = 进行赋值,这会导致数据的移动。具体来说,在表达式 ..user 中,user 的字段(除了在更新语句中显式提供新值的字段)会被移动到新的 updated_user 实例中。

这意味着,一旦执行了移动操作,原始的 user 实例作为一个完整的结构体就不再有效。然而,这里存在一个初学者容易困惑的细节:部分移动

部分移动的细节

文档中提到移动后不能使用 user,但这可能被误解。实际情况是:

  • 被移动的字段(在本例中是 username,因为它是 String 类型且未被新值覆盖)将不再有效。
  • 未被移动的字段(在本例中是 id,因为它实现了 Copy 特征;以及 email,因为我们为 updated_user 创建了一个全新的 String)仍然有效。

因此,以下操作是允许的:

println!("Original user ID: {}", user.id); // 有效,因为 i32 实现了 Copy
println!("Original email: {}", user.email); // 有效,因为新实例使用了全新的 String

但以下操作会导致编译错误:

println!("Original username: {}", user.username); // 错误!username 已被移动

编译器会提示:borrow of moved value: \user.username``。

简单来说,原始结构体实例不再是一个“完整且有效”的结构体,因为它的部分数据已被移走,但未被移动的数据仍然可以访问。

总结

本节课中我们一起学习了 Rust 中两个紧密相关的核心概念:

  1. 结构体更新语法:使用 ..instance 语法可以基于现有实例快速创建新实例,只需指定需要更改的字段。这极大地提升了代码的简洁性。
  2. 数据移动与部分移动:更新语法会导致数据从旧实例移动到新实例。理解哪些字段被移动(通常是未实现 Copy 特征且未被覆盖的字段)至关重要。移动后,旧实例作为整体已失效,但其未被移动的部分数据仍可单独使用。

掌握这些知识是深入理解 Rust 所有权和借用系统的重要一步。在后续课程中,我们将更详细地探讨所有权、借用和生命周期。

036:元组结构体与结构体数据所有权 📚

在本节课中,我们将要学习 Rust 中一种特殊且实用的结构体类型——元组结构体。我们还将探讨结构体数据的所有权问题,了解结构体如何拥有其数据,以及使用引用时需要注意的事项。

元组结构体介绍 🧱

上一节我们介绍了常规的结构体,本节中我们来看看元组结构体。元组结构体本质上是带有名称的元组。在某些场景下,它们能提高代码的可读性。当你希望为整个元组命名,并使其与其他元组成为不同的类型时,元组结构体就非常有用。此外,如果你觉得为常规结构体的每个字段命名显得冗长或多余,元组结构体也是一个好选择。

以下是定义元组结构体的语法示例:

struct Color(u8, u8, u8);
struct Date(u16, u8, u8);

例如,我们可以定义一个名为 Color 的结构体,它包含三个 u8 类型的字段。或者定义一个名为 Date 的结构体,它也包含三个字段。

创建与使用元组结构体实例 🛠️

接下来,我们在 main 函数中创建一个名为 blueColor 实例,其值为 (0, 0, 255)。在其下方,我们创建一个 Date 实例,表示 2025 年 12 月 21 日。

以下是创建实例的代码:

let blue = Color(0, 0, 255);
let date = Date(2025, 12, 21);

现在,在这两个结构体定义下方,我们快速创建一个名为 display_date 的函数。该函数接收一个 Date 类型的引用作为参数。我们使用 println! 宏来打印日期,格式为“日/月/年”。我们需要传入 date.0date.1date.2 来访问元组结构体的字段。

函数定义如下:

fn display_date(date: &Date) {
    println!("Date is {}/{}/{}", date.0, date.1, date.2);
}

最后,我们回到 main 函数,调用 display_date 函数并传入 date 的引用。程序运行后,会正确显示日期“21/12/2025”。

元组结构体的类型安全性 🔒

我们不能将 blue 这个 Color 实例传递给 display_date 函数,因为 blue 不是 Date 类型,即使它包含相同的数据类型。这就是使用元组结构体优于普通元组的地方,因为它能区分不同的类型。

如果我们修改函数签名,使其接受一个普通的元组 (u16, u8, u8),那么任何符合此签名的三元组都能工作,包括 blue。但这会导致函数过于通用,可能接收不相关的数据。

因此,元组结构体是使你的数据更具体、类型更安全的好方法。实际上,元组结构体就是一个前面带有名称的元组,使其成为独立的数据类型。

解构元组结构体 🧩

有一点需要注意,如果你想解构一个元组或元组结构体,必须使用其类型名。

以下是解构的正确方式:

let Date(day, month, year) = date;
println!("{:?} {:?} {:?}", day, month, year);

我们不能使用普通元组的模式来直接解构元组结构体的数据,必须使用该元组结构体的类型名进行解构。运行上述代码,我们将得到在调试宏中提供的每个信息片段。

单元结构体 ⚙️

接下来,我想展示也可以定义没有任何字段的结构体,这被称为“单元结构体”。我们不需要为下一个示例保留之前的代码,所以将其删除。

我们输入:

struct Ready;

这就是我们需要的全部。在 main 函数中,我们可以创建一个名为 status 的变量,并将其设置为 Ready。将来,我们可以将其用作一个标志,通知程序某些事情已准备就绪,可以继续执行。单元结构体还有更多用例,我们将在未来的课程中讨论。

结构体数据的所有权 📦

接下来,我想讨论结构体数据的所有权。我再次创建一个 User 结构体,它包含一个 i32 类型的 id,一个 String 类型的 username,以及一个 String 类型的 email。这里我们使用了拥有所有权的 String 类型,而不是字符串切片 &str

这是有意为之,因为我们希望每个实例拥有其全部数据,并且只要整个结构体有效,这些数据就有效。但是,结构体也可以存储由其他东西拥有的数据的引用,这需要使用称为“生命周期”的功能,我们将在未来的课程中讨论。

我只是想指出这是可能的。我们不在本课中涵盖它,因为它并不简单。我们不能仅仅在结构体字段中键入一个引用类型(比如字符串切片 &str)而不做其他处理,Rust 编译器会报错,提示我们缺少生命周期说明符。所以,我只是想让你知道我们将在未来的课程中学习它。


本节课中我们一起学习了 Rust 的元组结构体,它是一种带有类型名称的元组,能提高代码的类型安全性和表达力。我们还了解了如何创建和使用元组结构体实例、其类型安全性、解构方法,以及简单的单元结构体。最后,我们探讨了结构体对其数据的所有权,并引出了未来将学习的生命周期概念。掌握这些基础是理解 Rust 更高级数据管理的关键。

037:结构体提供清晰性 🧱

在本节课中,我们将通过一个官方 Rust 书中使用的示例程序,继续学习结构体。我们将首先创建一个不使用结构体的程序,然后修改代码以使用结构体,以便更好地理解结构体的实用性。

该程序的目标是能够计算任意给定矩形的面积。

不使用结构体的方法

首先,我们采用不使用结构体的方法。我们将创建一个名为 get_area 的函数,它接收两个 u32 类型的参数作为矩形的尺寸,并返回一个 u32 类型的面积值。

以下是 get_area 函数的定义:

fn get_area(dimensions: (u32, u32)) -> u32 {
    dimensions.0 * dimensions.1
}

main 函数中,我们创建一个表示矩形的元组,并调用 get_area 函数。

以下是 main 函数的内容:

fn main() {
    let rectangle = (20, 30);
    println!("矩形的面积是 {} 平方像素。", get_area(rectangle));
}

运行此程序,输出结果为“矩形的面积是 600 平方像素。”,程序功能正常。

对于计算面积这个例子,使用元组是可行的,因为乘法运算满足交换律,20 * 3030 * 20 的结果相同。

然而,如果我们的目标是在屏幕上绘制这个矩形,那么尺寸的顺序就至关重要了。(20, 30) 表示一个宽20、高30的矩形,而 (30, 20) 则表示一个宽30、高20的矩形,两者形状完全不同。使用元组无法清晰地表达这种顺序的重要性。

使用结构体的方法

为了解决上述问题,并使代码意图更清晰,我们将引入结构体。

首先,我们定义一个名为 Rectangle 的结构体:

struct Rectangle {
    width: u32,
    height: u32,
}

接下来,我们修改 get_area 函数,使其接收一个 Rectangle 结构体的引用作为参数:

fn get_area(rectangle: &Rectangle) -> u32 {
    rectangle.width * rectangle.height
}

现在,在 main 函数中,我们创建一个 Rectangle 实例并调用函数:

fn main() {
    let rect = Rectangle { width: 20, height: 30 };
    println!("矩形的面积是 {} 平方像素。", get_area(&rect));
}

再次运行程序,结果依然是 600 平方像素。但这次,我们的代码通过命名字段(widthheight)清晰地表达了数据的含义,使得代码更易读、更易维护。如果未来矩形需要包含更多数据(如颜色、位置),结构体也能轻松扩展。

调试结构体

上一节我们介绍了如何使用结构体组织数据。本节中,我们来看看如何打印或调试结构体的内容。如果你尝试使用 println! 宏直接打印结构体实例,Rust 编译器会报错。

例如,以下代码无法编译:

println!("{:?}", rect); // 错误:`Rectangle` 未实现 `Debug`

println! 宏默认使用 Display 格式化特性来输出内容,该特性主要用于面向用户的友好显示。许多基本类型(如整数)都自动实现了 Display。但对于自定义的结构体,Rust 不知道该如何显示它。

为了调试目的,我们可以使用 Debug 格式化特性。要让我们的结构体支持 Debug,只需在结构体定义上方添加 #[derive(Debug)] 属性。

修改 Rectangle 结构体如下:

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

现在,我们可以使用 {:?} 占位符来打印结构体的调试信息:

println!("调试输出: {:?}", rect);

输出类似:调试输出: Rectangle { width: 20, height: 30 }

对于包含多个字段的复杂结构体,还可以使用 {:#?} 进行“美化打印”,使输出更具可读性:

println!("美化调试输出: {:#?}", rect);

输出格式会更加清晰,每个字段单独占一行。

通过实现 Debug 特性,我们能够方便地检查结构体在程序运行时的状态,这对开发和调试非常有帮助。

总结

本节课中我们一起学习了结构体如何为代码带来清晰性。我们通过计算矩形面积的例子,对比了使用元组和使用结构体的两种方法。结构体通过命名字段,使数据的含义一目了然,极大地提升了代码的可读性和可维护性。此外,我们还学习了如何通过 #[derive(Debug)] 属性让自定义结构体支持调试输出。

使用结构体将相关的数据分组,远比让一堆类型模糊的元组散落在代码中要方便和清晰得多。关于结构体,我们还有一个重要主题需要讨论,那就是方法,这将把我们的矩形代码提升到一个新的水平。我们将在下一节课中学习它。

038:Rust 中的方法 🧩

在本节课中,我们将要学习 Rust 中的方法。方法是与特定结构体(struct)关联的函数,它们为结构体实例提供了可操作的行为。我们将通过一个具体的例子,学习如何定义和使用方法。

什么是方法?

方法本质上是定义在结构体上下文中的函数。它们的第一个参数总是 self,代表调用该方法的当前结构体实例。这允许方法直接访问和操作实例内部的数据。

上一节我们介绍了结构体的基本概念,本节中我们来看看如何为结构体添加方法。

定义方法:实现块

要为结构体定义方法,我们需要使用 impl(implementation 的缩写)块。在 impl 块内部定义的函数就是该结构体的方法。

以下是定义一个 Rectangle 结构体及其方法的步骤:

  1. 首先,我们有一个 Rectangle 结构体。

    struct Rectangle {
        width: u32,
        height: u32,
    }
    
  2. 接着,我们使用 impl 关键字为 Rectangle 创建实现块。

    impl Rectangle {
        // 方法将在这里定义
    }
    

创建第一个方法:计算面积

让我们在 impl Rectangle 块中创建第一个方法 get_area。这个方法将返回矩形的面积。

impl Rectangle {
    fn get_area(&self) -> u32 {
        self.width * self.height
    }
}

  • &self:这是方法的第一个参数,表示我们不可变地借用了当前的 Rectangle 实例。通过 self,我们可以访问实例的字段,如 self.widthself.height
  • -> u32:指定该方法返回一个 u32 类型的值。
  • self.width * self.height:方法体,计算并返回面积。

关键点self 是对当前实例的引用。Rust 允许我们使用 &self 这种简写形式,它等价于 self: &Self。使用 & 表示该方法借用实例,而不会获取其所有权。方法也可以可变地借用(&mut self)或获取所有权(self),这与其他函数参数的行为一致。

使用方法

定义了方法后,我们可以创建结构体实例并使用点号(.)语法来调用方法。

fn main() {
    let rect = Rectangle { width: 20, height: 30 };
    println!("The area is: {}", rect.get_area()); // 输出: The area is: 600
}

运行此程序,输出结果为 The area is: 600rect.get_area() 调用会自动将 rect 实例的引用传递给 get_area 方法中的 &self 参数。

组织更多功能

使用方法的一个主要好处是可以将相关功能组织在一起,使代码结构更清晰。我们可以在同一个 impl 块中添加更多方法。

以下是新增方法的示例:

impl Rectangle {
    fn get_area(&self) -> u32 {
        self.width * self.height
    }

    // 检查矩形是否有效(宽高均大于0)
    fn is_valid(&self) -> bool {
        self.width > 0 && self.height > 0
    }

    // 根据有效性显示不同信息
    fn display(&self) {
        if self.is_valid() {
            println!("The rectangle is {} square pixels.", self.get_area());
        } else {
            println!("The rectangle is invisible.");
        }
    }
}

现在,我们可以这样使用:

fn main() {
    let rect1 = Rectangle { width: 10, height: 30 };
    rect1.display(); // 输出: The rectangle is 300 square pixels.

    let rect2 = Rectangle { width: 0, height: 50 };
    rect2.display(); // 输出: The rectangle is invisible.
}

自动引用与解引用

在调用方法时,Rust 提供了一项名为“自动引用与解引用”的便利功能。你不需要手动匹配方法签名中 self 的类型(如 &self&mut self),Rust 编译器会自动处理。

考虑以下两个方法:

impl Rectangle {
    fn sample1(&self) {
        println!("Immutable borrow");
    }

    fn sample2(&mut self) {
        println!("Mutable borrow");
    }
}

调用时,你可以直接写:

fn main() {
    let rect1 = Rectangle { width: 5, height: 5 };
    rect1.sample1(); // 正确,自动添加 `&`

    let mut rect2 = Rectangle { width: 5, height: 5 }; // rect2 必须是可变的
    rect2.sample2(); // 正确,自动添加 `&mut`
}

你无需写成 (&rect1).sample1()(&mut rect2).sample2(),Rust 会自动为你添加必要的引用符号,使代码更简洁。


本节课中我们一起学习了 Rust 中方法的核心概念。我们了解到方法是结构体的关联函数,其第一个参数为 self。我们学会了使用 impl 块来定义方法,并通过点号语法调用它们。我们还探讨了如何使用方法来组织代码,以及 Rust 的自动引用与解引用特性如何让方法调用更加方便。方法是构建面向对象风格 Rust 代码的重要基石。

039:关联函数与方法进阶 🧩

在本节课中,我们将继续学习 Rust 中结构体的方法。我们将重点探讨如何定义带多个参数的方法,以及关联函数的概念与用法。


带多个参数的方法

上一节我们介绍了如何为结构体定义基本方法。本节中我们来看看如何创建接收额外参数的方法。

以下是一个名为 fits_inside 的方法,它接收另一个 Rectangle 实例作为参数,用于判断当前矩形是否能放入另一个矩形中。

impl Rectangle {
    // 判断当前矩形是否能放入另一个矩形中
    fn fits_inside(&self, other: &Rectangle) -> bool {
        self.width < other.width && self.height < other.height
    }
}

该方法的核心逻辑是:当前矩形的宽和高都必须小于另一个矩形的宽和高

接下来,我们在 main 函数中创建两个矩形并调用这个方法。

fn main() {
    let r1 = Rectangle { width: 10, height: 20 };
    let r2 = Rectangle { width: 30, height: 30 };

    // 调用带参数的方法
    println!("r1 fits inside r2: {}", r1.fits_inside(&r2));
    println!("r2 fits inside r1: {}", r2.fits_inside(&r1));
}

运行程序,输出结果符合预期:r1 fits inside r2: true,而 r2 fits inside r1: false


多个实现块

Rust 允许为同一个结构体定义多个 impl 块。以下是定义多个实现块的示例:

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

// 另一个独立的实现块
impl Rectangle {
    fn describe(&self) {
        println!("Rectangle has width {} and height {}", self.width, self.height);
    }
}

虽然语法上允许,但在大多数情况下,将所有方法放在一个 impl 块中更清晰。我们将在后续课程中看到多个实现块的实际应用场景。


关联函数

所有定义在 impl 块中的函数都被称为关联函数,因为它们与 impl 关键字后的类型相关联。

关联函数中,不以 self 作为第一个参数的函数,被称为非方法关联函数。它们不需要结构体实例即可调用,通常用作返回新实例的构造函数。

我们之前已经见过一个关联函数:String::from("Bob")。这里的 from 就是 String 类型的一个关联函数。

现在,让我们为 Rectangle 定义一个关联函数 new_square,用于快速创建一个正方形:

impl Rectangle {
    // 关联函数:创建一个正方形
    fn new_square(size: u32) -> Self {
        Self {
            width: size,
            height: size,
        }
    }
}

main 函数中,我们可以这样使用它:

fn main() {
    // 使用关联函数创建正方形
    let square = Rectangle::new_square(40);
    square.describe(); // 输出:Rectangle has width 40 and height 40
}

注意,所有关联功能都可以通过双冒号 :: 语法调用,但对于需要实例的方法(如 describe),直接使用实例的 . 语法调用更为简洁直观。


总结

本节课中我们一起学习了:

  1. 带参数的方法:可以接收除 self 外的其他参数,扩展了方法的功能。
  2. 多个实现块:Rust 允许为同一类型拆分定义多个 impl 块。
  3. 关联函数:定义在 impl 块中的所有函数。不以 self 为参数的关联函数常用于实现构造函数(如 new_square)。

结构体让我们能将相关联的数据组织在一起,而实现块则让我们能为这些数据定义相关的行为,这极大地提升了代码的组织性和清晰度。然而,结构体并非 Rust 中定义自定义类型的唯一方式。在接下来的课程中,我们将学习另一种强大的自定义类型工具:枚举(Enums)

Rust 初学者教程:P40:枚举(Enums)📚

在本节课中,我们将要学习 Rust 中的枚举(enum)。枚举允许我们定义一组固定的可能值,这对于表示像开关状态、IP地址版本或消息类型这样的数据非常有用。我们将学习如何定义枚举、创建其实例、将数据与枚举变体关联,以及为枚举定义方法。


我们刚刚介绍了结构体(`struct``),它为我们提供了一种将相关字段和数据分组在一起的方法。接下来,我们将学习枚举,它允许我们创建一组固定的值。

例如,如果你有一盏灯,它可能只有两种状态:开和关。这组值非常适合用枚举来表示,因为这就是灯仅有的两个选项。

让我们创建第一个枚举,并列举出那盏灯所有可能的状态。

我们将输入 enum(代表 enumerate),然后提供一个名称,例如 State。你可以随意命名,但它应该代表其持有的数据,比如灯的状态。在这里,我们可以插入 OnOff

enum State {
    On,
    Off,
}

现在,State 是一个我们可以在代码中任何地方使用的自定义数据类型。

我们将创建几个实例。一个叫做 on,我们输入 State::On。我们可以为 off 做同样的事情。

let on = State::On;
let off = State::Off;

由于这两个值都是 State 类型,我们现在可以定义一个接受它们的函数。例如,我们可以创建一个跟踪状态并根据当前状态在关和开之间切换的函数。

fn toggle(current_state: State) {
    // 切换逻辑...
}

此时功能并不重要,重要的是我们可以通过传入一个 State(要么是 On,要么是 Off)来调用这个函数。

toggle(State::On); // 正常工作
toggle(State::Off); // 也正常工作

另一个例子是关于 IP 地址的。我将删除所有这些,创建一个名为 Ip 的新枚举。这里我们将有 IPv4 和 IPv6 两个版本。

enum Ip {
    V4,
    V6,
}

有了这个,我们现在可以在结构体或任何我们喜欢的地方将其用作数据类型。

我们将输入 struct,然后输入 IpAddress。在里面,我们可以添加一个类型为 Ipkind 字段,和一个类型为 Stringaddress 字段。

struct IpAddress {
    kind: Ip,
    address: String,
}

有了这两个,我们现在可以创建我们的 IP 地址。

让我们的 home_ip 等于一个 IpAddress,数据如下:kind 设置为 Ip::V4address 设置为字符串 "127.0.0.1"

let home_ip = IpAddress {
    kind: Ip::V4,
    address: String::from("127.0.0.1"),
};

否则,我们也可以有一个 loopback 地址,它也将等于一个 IpAddress,但这次我们使用版本 6。

let loopback = IpAddress {
    kind: Ip::V6,
    address: String::from("::1"),
};

这是我们使用枚举的另一种方式。

如果我们愿意,我们还可以将一些数据附加到枚举的每个变体上。例如,V4 可以接受一个字符串,然后我们可以对 V6 做同样的事情。

enum Ip {
    V4(String),
    V6(String),
}

得益于这种方法,我们可以简化这里的代码。我们将输入 let home = Ip::V4(String::from("127.0.0.1"))。这有点像使用构造函数。

let home = Ip::V4(String::from("127.0.0.1"));
let loopback = Ip::V6(String::from("::1"));

这可以被视为一种更好的方法,因为它更简洁,并且在使用时不需要我们创建一个全新的结构体。除此之外,我们定义的每个枚举变体的名称也成为一个构造枚举实例的函数。

但使用枚举而不是结构体的另一个优点是,每个变体可以具有不同类型和数量的关联数据。

例如,回到我们的 Ip 枚举,我们可以输入类似 V4(u8, u8, u8, u8) 的内容,因为 IPv4 地址总是有四个部分。这意味着我们现在可以输入 (127, 0, 0, 1)

enum Ip {
    V4(u8, u8, u8, u8),
    V6(String),
}

let home = Ip::V4(127, 0, 0, 1);
let loopback = Ip::V6(String::from("::1"));

版本 6 可以继续是一个自定义字符串。你真的可以在枚举中包含任何类型的数据,包括其他枚举。


接下来,让我们看另一个枚举的例子。首先,我将删除所有这些,并将这个枚举改为 Message

这个 Message 将有四种不同的可能操作:一个是当我们退出消息时发生什么(Quit),一个是当我们移动消息时发生什么(Move,这将接受一些 i32 坐标),一个是当我们创建消息时发生什么(Create,这将接受一个 String),以及当我们改变消息颜色时发生什么(ChangeColor,这将是 i32, i32, i32)。

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Create(String),
    ChangeColor(i32, i32, i32),
}

使用枚举使这很容易创建。如果我们必须使用结构体来做这件事,它看起来会像这样:我们必须为这些操作中的每一个创建一个单独的结构体,那将非常麻烦。除此之外,每个结构体现在都是它自己的类型,使得在我们的程序中将它们全部作为单一类型接受变得更加困难。

例如,如果我们有一个名为 function 的函数,它接受一个类型为 Message 的消息,那么它将适用于这些操作中的每一个。

fn function(msg: Message) {
    // 处理消息...
}

// 我们可以传入任何变体
function(Message::Quit);
function(Message::Move { x: 10, y: 20 });
function(Message::Create(String::from("Hello")));
function(Message::ChangeColor(255, 0, 0));

如果我们使用结构体方法,这会变得困难得多,因为现在我们只能接受 Quit 消息、Move 消息等等。我们必须找到一种方法来使用每一个,这又将非常麻烦。


在我们进入枚举的下一部分之前,还有最后一件事要介绍,那就是我们也可以使用相同的 impl 关键字为它们定义方法。

例如,对于这个 Message,我们可以输入 impl Message,然后创建一个名为 call 的函数,它接受当前实例,然后你可以对该实例做任何你想做的事情。它的工作方式与你在结构体中创建常规方法完全相同。

impl Message {
    fn call(&self) {
        // 在这里处理消息实例
        println!("Calling method on Message.");
    }
}

现在,在 main 函数中,我们可以让 message 等于 Message::Create(String::from("Hello, Bob"))。然后,我们可以引用这个实例并调用这个方法。这本质上与我们用结构体学到的概念完全相同。

let msg = Message::Create(String::from("Hello, Bob"));
msg.call(); // 输出:Calling method on Message.


本节课中我们一起学习了 Rust 枚举的核心概念。我们了解了如何定义枚举来表示一组固定的值,如何创建枚举实例,以及如何将数据与变体关联。我们还看到了枚举相比结构体在灵活性和简洁性上的优势,例如允许变体持有不同类型和数量的数据。最后,我们学习了如何为枚举定义方法,使其具备与结构体相似的行为。枚举是 Rust 中构建清晰、安全数据模型的重要工具。

041:Option 枚举与空值处理 🧩

在本节课中,我们将学习 Rust 中的 Option 枚举,并了解它相比传统空值(null)的优势。

许多编程语言使用 null 来表示“没有值”。然而,Rust 没有内置的 null 类型。相反,它使用标准库中定义的 Option 枚举来表示一个可选值,即一个值可能存在,也可能不存在。这种设计旨在避免因使用空值而引发的常见错误。

Option 枚举的定义

Option 枚举的定义如下:

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

这里的 T 是一个泛型类型参数,意味着 Option 可以包装任何类型的值。Some(T) 变体表示存在一个类型为 T 的值,而 None 变体则表示没有值。

由于 Option 被包含在 Rust 的预导入模块中,我们可以直接使用 SomeNone,而无需显式导入。

使用 Option

我们可以像这样创建 Option 值:

let some_number: Option<i32> = Some(10);
let no_number: Option<i32> = None;

let some_name: Option<&str> = Some("Bob");
let no_name: Option<&str> = None;

一个变量被声明为 Option<T> 类型,意味着它要么包含一个 Some 值,要么是 None。这强制我们在使用值之前,必须考虑它可能不存在的情况。

为什么 Option 优于 null

上一节我们介绍了 Option 的基本形式,本节中我们来看看它如何避免空值错误。

核心优势在于类型系统。Option<T>T 是两种不同的类型。例如,Option<&str> 不是一个字符串切片(&str)。这意味着你不能意外地将一个可能为空的 Option 值当作一个确定存在的值来使用。

请看以下示例:

let a: u8 = 10;
let b: Option<u8> = Some(20);
// let sum = a + b; // 这行代码会导致编译错误!

尝试将 u8Option<u8> 相加会导致编译失败。Rust 编译器不允许这种操作,因为它无法保证 b 中一定包含一个有效的 u8 值。你必须先处理 b 可能为 None 的情况。

这种设计帮助开发者避免了所谓的“十亿美元错误”——在其他语言中,尝试像使用有效值一样使用空值,常常导致程序崩溃或产生难以追踪的 Bug。

Option 中提取值

既然不能直接使用 Option 值,我们该如何安全地获取其中可能包含的值呢?以下是几种基本方法。

使用 unwrap_or

unwrap_or 方法提供了一种安全提取值的方式:如果 OptionSome,则返回内部的值;如果是 None,则返回你提供的默认值。

let selected_user: Option<&str> = Some("Bob");
let user_name = selected_user.unwrap_or("No user selected");
println!("{:?}", user_name); // 输出: "Bob"

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/f320e759847aa656ee8ef86737e7ac9e_13.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/f320e759847aa656ee8ef86737e7ac9e_14.png)

let no_user: Option<&str> = None;
let user_name2 = no_user.unwrap_or("No user selected");
println!("{:?}", user_name2); // 输出: "No user selected"

使用 unwrap(需谨慎)

unwrap 方法更直接:如果 OptionSome,则返回值;如果是 None,则会导致程序恐慌(panic)并崩溃。

let selected_number: Option<i32> = Some(10);
let value = selected_number.unwrap();
println!("{:?}", value); // 输出: 10

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/f320e759847aa656ee8ef86737e7ac9e_21.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/f320e759847aa656ee8ef86737e7ac9e_22.png)

let no_number: Option<i32> = None;
// let value2 = no_number.unwrap(); // 运行此行代码会导致程序崩溃!

由于 unwrap 在值为 None 时会引发程序崩溃,因此在生产代码中应尽量避免使用,除非你能百分之百确定 Option 不会是 None

处理 Option 的推荐方式是显式地处理 SomeNone 两种情况。一种非常方便的工具是 match 表达式,我们将在下一节课中详细介绍。

总结

本节课中我们一起学习了 Rust 中处理空值的核心机制——Option 枚举。我们了解到 Rust 没有 null,而是使用 Some(T)None 来明确表达值的“存在”与“缺失”。通过类型系统的强制检查,Option 要求我们在编译期就必须处理值可能不存在的情况,从而有效避免了运行时因空值引发的错误。我们还初步掌握了使用 unwrap_orunwrapOption 中提取值的方法。记住,安全地处理 Option 是编写健壮 Rust 代码的关键。

042:match 控制流结构

在本节课中,我们将要学习 Rust 中的 match 控制流结构。match 允许我们将一个值与一系列模式进行比较,然后根据匹配到的模式执行相应的代码。这是一个非常强大且常用的特性。

概述

match 表达式是 Rust 中处理条件分支的核心工具之一。它类似于其他语言中的 switch 语句,但功能更加强大和灵活。通过 match,我们可以清晰地处理枚举的不同变体、解构复杂数据类型,并确保所有可能的情况都得到处理。

一个简单的 match 示例

为了更好地理解 match,让我们从一个具体的例子开始。首先,我们需要创建一个枚举来表示不同的数据大小单位。

enum DataSize {
    Byte,
    Kilobyte,
    Megabyte,
    Gigabyte,
}

接下来,我们创建一个函数 bytes,用于将选定的 DataSize 转换为字节数。

fn bytes(size: DataSize) -> u64 {
    // match 表达式将在这里使用
}

使用 match 关键字

现在,我们来使用 match 关键字。match 后面跟着我们想要匹配的表达式,在这个例子中就是参数 size

fn bytes(size: DataSize) -> u64 {
    match size {
        // 匹配臂(arms)将在这里定义
    }
}

match 表达式执行时,它会将结果值与每个“臂”(arm)中的模式进行比较。每个“臂”由模式和要执行的代码组成,两者之间用 => 符号分隔。

以下是 bytes 函数的完整实现:

fn bytes(size: DataSize) -> u64 {
    match size {
        DataSize::Byte => 1,
        DataSize::Kilobyte => 1000,
        DataSize::Megabyte => 1000 * 1000,
        DataSize::Gigabyte => 1000 * 1000 * 1000,
    }
}

这个 match 表达式有四个臂。每个臂对应 DataSize 枚举的一个变体,并返回相应的字节转换值。

现在,让我们在 main 函数中使用它:

fn main() {
    let kilobyte = bytes(DataSize::Kilobyte);
    println!("1 kilobyte is equal to {} bytes", kilobyte);
}

运行这段代码,输出将是 1 kilobyte is equal to 1000 bytes。这要归功于 match 表达式:我们传入了 DataSize::Kilobytematch 找到了对应的臂,并返回了结果 1000

在匹配臂中执行多行代码

在上面的例子中,每个臂只返回一个简单的值。但如果你想执行多行代码并基于这些代码返回另一个值,该怎么办呢?

对于每个臂,你也可以选择使用花括号 {} 来定义一个代码块。这允许我们添加多行代码。

例如,我们可以修改第一个臂:

DataSize::Byte => {
    println!("One byte is one byte, mate.");
    1 // 这是该代码块的返回值
}

现在,如果我们回到 main 函数,将调用改为 DataSize::Byte,运行代码将会先打印信息,然后变量 b 的值将是 1

你可以为任何一个臂添加这样的代码块,它们的顺序可以任意排列。在使用这种语法时,末尾的分号是可选的,代码的运行方式完全相同。

绑定值的模式

match 更强大的功能之一是模式可以绑定值。让我们通过修改枚举来演示这一点。

我们将修改 Gigabyte 变体,让它携带一个 u64 类型的值,表示千兆字节的数量。

enum DataSize {
    Byte,
    Kilobyte,
    Megabyte,
    Gigabyte(u64), // 现在 Gigabyte 携带一个 u64 值
}

现在,我们需要更新 match 表达式来处理这个带数据的变体。在匹配 DataSize::Gigabyte 的臂中,我们可以指定一个变量名(例如 amount)来“吸收”这个值,并允许我们在该臂的代码块中使用它。

DataSize::Gigabyte(amount) => {
    let total = 1000 * 1000 * 1000 * amount;
    let billions = total / 1_000_000_000;
    println!("{} gigabytes is {} billion bytes", amount, billions);
    total // 返回总字节数
}

在这个臂中,amount 变量捕获了传入 DataSize::Gigabyte 的具体数值。然后我们用它来计算总字节数,并打印一个更易读的信息。

现在,我们可以在 main 函数中测试它:

fn main() {
    let two_gigabytes = bytes(DataSize::Gigabyte(2));
    let five_gigabytes = bytes(DataSize::Gigabyte(5));
    let twenty_five_gigabytes = bytes(DataSize::Gigabyte(25));

    println!("{:?}", two_gigabytes);
    println!("{:?}", five_gigabytes);
    println!("{:?}", twenty_five_gigabytes);
}

运行代码,你会看到针对每个调用打印的易读信息以及最终的计算结果。这展示了我们如何从 match 的模式中提取值并在代码中使用它。

总结

本节课中,我们一起学习了 Rust 的 match 控制流结构。我们了解了它的基本语法,如何用它来匹配枚举变体,如何在单个臂中执行多行代码,以及如何使用模式来绑定和解构值。match 是编写清晰、安全且无遗漏条件逻辑的基石,在后续学习 Option 和 Result 等枚举时尤为重要。

043:使用 match 处理 Option 与模式匹配进阶 🧩

在本节课中,我们将学习如何利用 Rust 中的 match 表达式来优雅地处理 Option 类型,并深入探讨 match 的两个关键特性:穷尽性检查和默认分支。

使用 match 处理 Option 类型

不久前,我们开始学习 Rust 中的 Option 类型。现在,让我们快速回顾一下,并学习如何使用新的 match 表达式来处理 Rust 中的可选值。

首先,我们将创建一个函数。这个函数接收一个 Option<&str> 类型的参数,并使用 match 表达式相应地处理该值。该函数将检查用户数据库中是否存在某个用户。

以下是函数定义:

fn user_exists(user: Option<&str>) -> bool {
    match user {
        None => {
            println!("请插入一个用户名进行搜索。");
            false
        }
        Some(username) => {
            println!("正在为用户 \"{}\" 进行搜索...", username);
            true
        }
    }
}

函数 user_exists 接收一个 Option<&str> 类型的 user 参数,并返回一个布尔值。如果用户存在,则返回 true,否则返回 false。我们使用 match 表达式来实现这个逻辑。

以下是 match 表达式的两个分支:

  • None 分支:当 userNone 时,打印提示信息并返回 false,因为显然没有提供用于搜索的用户名。
  • Some(username) 分支:当 userSome 时,我们模拟找到了该用户,打印搜索信息并返回 true

现在,我们可以在 main 函数中使用这个函数:

fn main() {
    let user = Some("Bob");
    let result = user_exists(user);
    println!("用户存在吗? {}", result);
}

如果我们运行程序,输出将显示函数搜索了用户 “Bob” 并返回了 true。如果将 user 设置为 None,程序会提示插入用户名并返回 false

match 的穷尽性

上一节我们介绍了如何使用 match 处理 Option。本节中我们来看看 match 的一个重要特性:穷尽性。这意味着当我们尝试匹配某个值时,必须覆盖所有可能的模式,否则代码将无法编译。

为了说明这一点,我们创建一个新示例。假设我们有一些成绩等级 A、B、C,我们想将它们转换为分数。

我们创建一个函数 grade_to_score

fn grade_to_score(grade: char) -> u8 {
    match grade {
        'A' => 100,
        // 这里缺少了 'B' 和 'C' 的分支
    }
}

我们不能只留下 ‘A’ 这一个分支。我们会看到语法高亮提示我们缺少了分支(arms)。我们必须覆盖所有情况,否则程序将无法编译。运行上述代码会导致编译错误,因为我们缺少了处理 ‘B’‘C’ 的分支。

为了修复这个问题,我们需要定义所有分支:

fn grade_to_score(grade: char) -> u8 {
    match grade {
        'A' => 100,
        'B' => 80,
        'C' => 60,
    }
}

现在,match 表达式覆盖了 grade 所有可能的值(在这个上下文中),程序可以正常编译。

使用默认分支(Catch-All Arm)

最后,还有一个重要的特性可以帮助我们充分利用 Rust 中的 match默认值处理,也称为“全捕获”分支。

对于下一个函数,我们将创建一个虚拟棋盘游戏。函数 board_event 接收一个骰子点数。

fn board_event(roll: u8) {
    match roll {
        1 => println!("Bob 进监狱了。"),
        2 => println!("Bob 中了彩票。"),
        other => println!("Bob 前进了 {} 格。", other),
    }
}

考虑到我们将 roll 定义为 u8 类型,为每一个可能的 u8 值(0-255)都写一个分支是不现实的。我们只关心点数 1 到 6,但为了处理其他所有情况,我们可以定义一个默认分支。这就是 other 分支,它会捕获所有未被前面分支匹配的值。

重要提示:默认分支必须放在最后,因为 match 的分支是按顺序求值的。

当然,我们可以创建功能来确保骰子只有 6 面,但在这个例子中,即使你神奇地投出了一个有 500 面的骰子,这个分支也会处理它,Bob 会前进 500 格。

为了让程序更真实,我们再创建一个掷骰子的函数。这需要用到 rand crate。

use rand::Rng;

fn roll_dice() -> u8 {
    let mut rng = rand::thread_rng();
    let roll = rng.gen_range(1..=6);
    println!("Bob 掷出了 {}.", roll);
    roll
}

首先,通过 cargo add rand 命令将 rand crate 添加到项目中。roll_dice 函数生成一个 1 到 6(包含)的随机数,打印结果并返回。

现在,在 main 函数中使用它:

fn main() {
    let roll = roll_dice();
    board_event(roll);
    // 也可以直接测试特定值
    board_event(1); // 输出:Bob 进监狱了。
    board_event(2); // 输出:Bob 中了彩票。
}

运行程序,根据掷出的点数,会触发不同的事件。如果点数是 1 或 2,触发特定事件;否则,触发默认分支,并且 other 变量的值会被用在打印语句中。

忽略默认分支的值

现在,假设你并不关心捕获到的具体值。我们并不必须为默认分支指定一个变量名。

我们可以使用下划线 _ 来忽略这个值:

fn board_event(roll: u8) {
    match roll {
        1 => println!("Bob 进监狱了。"),
        2 => println!("Bob 中了彩票。"),
        _ => println!("Bob 什么也没做。"),
    }
}

现在,对于任何其他值,都会触发 “Bob 什么也没做” 的事件。这样我们就可以在不关心具体值的情况下捕获所有其他情况。

最后,如果你甚至不想在默认分支中执行任何代码,可以传入单元类型 ()

fn board_event(roll: u8) {
    match roll {
        1 => println!("Bob 进监狱了。"),
        2 => println!("Bob 中了彩票。"),
        _ => (),
    }
}

这明确地告诉 Rust,当我们触发默认分支时,我们什么都不想做。现在如果运行代码,当触发默认分支时,将不会有任何输出。

总结

本节课中我们一起学习了 match 表达式在 Rust 中的强大应用。我们首先用它来安全地处理 Option 类型的 SomeNone 变体。接着,我们了解了 match穷尽性原则,即必须覆盖所有可能的情况。最后,我们探索了如何使用默认分支other_)来简洁地处理未明确列出的所有其他值,甚至可以使用 () 来明确表示不执行任何操作。掌握这些技巧将使你能够更清晰、更安全地编写 Rust 代码。

044:if let 语法糖 🍬

在本节课中,我们将要学习 Rust 中的 if let 语法。这是一种更简洁的方式来处理只匹配一个特定模式,而忽略所有其他情况的值。

概述

if let 是 Rust 提供的一种语法糖,它允许你在只关心一个匹配模式时,避免编写冗长的 match 表达式。它特别适用于处理 OptionResult 等枚举类型,当你只对其中一种变体(如 Some)感兴趣,而对其他所有情况(如 None)不做任何处理或进行统一处理时。

match 表达式开始

为了更好地理解 if let,我们先来看一个使用传统 match 表达式的例子。

假设我们有一个设置电脑屏幕亮度的函数。该函数接收一个 Option<i32> 类型的参数,表示亮度值(Some(i32))或没有设置(None)。

以下是使用 match 的实现:

fn set_brightness(brightness: Option<i32>) {
    match brightness {
        Some(value) => println!("亮度被设置为 {}%", value),
        _ => (),
    }
}

在上面的代码中,match 表达式有两个分支:

  • Some(value):当 brightnessSome 时,打印设置的值。
  • _:这是一个通配符模式,匹配所有其他情况(在这里就是 None),并且使用空元组 () 表示不执行任何操作。

main 函数中调用它:

fn main() {
    let user_input = Some(10); // 假设用户输入了 10
    set_brightness(user_input);
}

运行代码,输出为:亮度被设置为 10%。如果传入 None,则不会有任何输出。

虽然 match 表达式功能强大且清晰,但在这个场景中,我们只关心 Some 的情况,对 None 不做任何处理。此时,match 的写法就显得有些冗余。

引入 if let 语法

if let 提供了一种更简洁的方式来处理上述情况。它本质上是一个只关心单一模式的 match 表达式。

现在,我们用 if let 重写 set_brightness 函数:

fn set_brightness(brightness: Option<i32>) {
    if let Some(value) = brightness {
        println!("亮度被设置为 {}%", value);
    }
}

这段代码与之前的 match 表达式完全等效。它的含义是:如果 brightness 能够匹配 Some(value) 这个模式,那么就执行后面的代码块。如果匹配失败(即 brightnessNone),则跳过整个代码块。

运行修改后的代码,结果与之前完全相同。

使用 else

if 语句类似,if let 也可以搭配 else 块使用,用于处理模式匹配失败的情况。

这相当于 match 表达式中通配符 _ 分支的作用。

让我们为函数添加一个 else 分支:

fn set_brightness(brightness: Option<i32>) {
    if let Some(value) = brightness {
        println!("亮度被设置为 {}%", value);
    } else {
        println!("未设置亮度。");
    }
}

现在,如果调用 set_brightness(None),将会输出:未设置亮度。

另一个例子:枚举匹配

if let 不仅适用于 Option,也适用于任何枚举。让我们看一个自定义枚举的例子。

首先,定义一个表示加密货币的枚举:

enum Crypto {
    Btc(i32),      // 比特币,附带数量
    Ethereum(i32), // 以太坊,附带数量
}

接下来,在 main 函数中,我们使用 if let 来检查一个 Crypto 值是否是比特币:

fn main() {
    let coin = Crypto::Btc(2); // 持有 2 个比特币

    if let Crypto::Btc(amount) = coin {
        println!("你真富有!你有 {} 个比特币。", amount);
    } else {
        println!("没有比特币。");
    }
}

如果 coinCrypto::Btc 变体,它会将内部的值绑定到变量 amount 上,然后执行第一个代码块。
如果 coin 是其他变体(如 Crypto::Ethereum),则会执行 else 块。

运行代码,输出为:你真富有!你有 2 个比特币。
如果将 coin 改为 Crypto::Ethereum(5),输出则为:没有比特币。

为了对比,以下是使用 match 表达式实现的相同逻辑:

match coin {
    Crypto::Btc(amount) => println!("你真富有!你有 {} 个比特币。", amount),
    _ => println!("其他加密货币。"),
}

在这个具体的例子中,由于我们需要明确处理“其他所有情况”并给出提示,使用 match 表达式可能看起来更清晰、意图更明确。

if let 的使用时机

if let 是一个语法糖,目的是在特定场景下让代码更简洁。关于何时使用它,有以下几点需要注意:

  1. 首选场景:当你只关心一个匹配模式,并且对其他所有模式要么不做任何处理,要么进行统一的简单处理(使用 else)时,if let 通常更简洁。
  2. 权衡选择:你不应该强迫自己使用 if let。如果 match 表达式能让你的代码逻辑更清晰、更易读,尤其是在需要处理多个不同模式时,就应该坚持使用 match
  3. 未来应用:随着深入学习 Rust(例如处理错误 Result、解析复杂数据结构),你会遇到更多 if let 能显著简化代码的场景。目前,你只需要掌握它的基本概念。

总结

本节课我们一起学习了 Rust 中的 if let 语法。

  • 我们了解了 if letmatch 表达式在只匹配单一模式时的简洁替代写法。
  • 我们通过设置亮度的例子,对比了 matchif let 的实现,看到了 if let 如何消除冗余代码。
  • 我们学习了如何为 if let 添加 else 分支来处理匹配失败的情况。
  • 我们通过一个自定义枚举的例子,巩固了 if let 的用法。
  • 最后,我们讨论了 if let 的最佳使用时机,强调应根据代码清晰度在 if letmatch 之间做出选择。

记住,if let 是工具箱中的一件便利工具,旨在让代码在特定情况下更优雅,而不是用来完全取代 match

045:错误处理 - 是时候 panic 了!😱

在本节课中,我们将要学习 Rust 中的错误处理。理解错误处理是编写健壮程序的关键,它帮助我们区分程序中可能出现的不同问题,并采取相应的措施。

Rust 要求程序员必须承认某些代码可能导致错误。这个要求使得我们的程序更加健壮,因为它迫使我们在尝试编译代码之前,就妥善处理那些可能出错的代码。

Rust 将错误分为两大类:可恢复错误不可恢复错误

  • 可恢复错误通常指程序运行时出现的问题,但可以在运行时修复。例如,用户尝试打开一个不存在的文件,程序可以提示“文件未找到”错误,并让用户重新输入文件名。
  • 不可恢复错误则是程序本身的缺陷(bug),无法在运行时修复,表明程序存在需要解决的问题。

大多数语言不严格区分这两种错误,通常使用异常机制统一处理。但 Rust 没有异常机制。它使用 Result 类型来处理可恢复错误,使用 panic! 宏来处理不可恢复错误。

我们将在接下来的几个视频中介绍这两者。本节中,我们先来看看如何使用 panic! 宏处理不可恢复错误。

引发 Panic

有时,代码中会发生我们无法处理的糟糕情况。此时,Rust 提供了一个名为 panic! 的宏来终止程序。

在实践中,有两种方式会导致 panic。

方式一:执行导致 panic 的操作

第一种是通过执行某些操作导致代码 panic,例如访问数组的无效索引。

let x = [1, 2];
println!("{}", x[3]); // 尝试访问索引为 3 的元素

幸运的是,这段代码无法编译,因为我们试图访问一个不存在的元素。即使我们尝试访问索引 2,也同样会越界。编译器会提示这个操作将在运行时引发 panic。

方式二:显式调用 panic!

第二种方式是直接调用 panic! 宏。

panic!("Bob ran away");

默认情况下,panic 会打印失败信息、展开调用栈、清理资源,然后退出程序。我们可以通过设置环境变量,让 Rust 在 panic 发生时显示完整的调用栈,以便追踪错误根源。

运行上述代码,你会看到类似下面的输出:

thread 'main' panicked at src/main.rs:2:5:
Bob ran away

第一行信息显示了 panic 发生的位置:主线程、源文件、行号和字符位置。之后的部分是错误信息。

在这个例子中,panic 很容易追踪,因为它直接发生在当前文件中。但有时,panic 会间接发生,即当前文件中的函数调用了项目其他地方的函数,而那个函数导致了 panic。

使用回溯追踪间接 Panic

为了理解这种情况,我们创建一个使用 Vec(向量)的例子。向量类似于数组,但它是动态且可增长的,可以在运行时添加或删除元素。

let v = vec![1, 2, 3];
println!("{}", v[99]); // 尝试访问索引为 99 的元素

我们使用向量是因为 Rust 允许编译这段代码,即使它会在运行时立即 panic。运行后,程序会因为索引越界而 panic。

在 C 语言中,尝试读取数据结构末尾之外的数据会导致“未定义行为”,可能读取到不属于该结构的内存数据,这被称为“缓冲区过度读取”,可能引发安全漏洞。为了保护程序免受此类漏洞影响,当 Rust 发现你尝试读取不存在的索引时,它会停止执行并拒绝继续。

再次打开控制台,你会注意到提示告诉我们,可以设置环境变量来获取导致错误发生的完整回溯。回溯是一个列出所有被调用函数以到达当前点的列表。

我们可以通过以下命令在调试模式下运行程序来获取回溯:

cargo run

在调试模式下运行代码会包含调试符号,从而获得详细的回溯信息。而在发布模式下运行:

cargo run --release

则会得到一个更简短的回溯,省略了所有调试符号。

总结

本节课中我们一起学习了 Rust 错误处理的基础,特别是关于不可恢复错误和 panic! 宏的知识。我们了解到:

  1. Rust 将错误分为可恢复和不可恢复两类。
  2. panic! 宏用于处理不可恢复错误,它会终止程序。
  3. 引发 panic 有两种方式:执行非法操作(如数组越界访问)或显式调用 panic! 宏。
  4. 当 panic 间接发生时,可以通过设置环境变量或在调试模式下运行程序来获取函数调用回溯,帮助定位问题根源。

下一节,我们将开始学习如何在 Rust 中处理可恢复错误。

046:使用 Result 处理多个错误 🛠️

在本节课中,我们将学习 Rust 中的可恢复错误处理。许多错误并不严重到需要程序完全停止。例如,如果程序提示用户输入一个数字,但用户不小心输入了字母或符号,我们不希望程序因此崩溃。这种情况可以通过提示用户重新输入正确的数字来轻松修复。

Result 枚举简介

在之前的 Rust 学习中,我们创建了一个猜数字游戏,其中使用了许多 Rust 语法。我们特别使用了 Result 枚举,其结构如下:

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

这里,Result 接受两个泛型参数 TEOk(T) 表示操作成功,T 是成功时返回的值的类型。Err(E) 表示操作失败,E 是失败时返回的错误类型。

使用 Result 处理文件操作

接下来,我们尝试调用一个使用 Result 类型的函数。在 main 函数中,我们将创建一个路径并尝试打开一个文件。

use std::fs::File;
use std::io::Read;

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/25da715ca4eeda440068651cf2fe5e3b_6.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/25da715ca4eeda440068651cf2fe5e3b_8.png)

fn main() {
    let path = "secret.txt";
    let txt = File::open(path);
}

File::open 函数返回一个 Result<File, std::io::Error>。这意味着它可能成功返回一个 File,也可能失败返回一个 std::io::Error。这是合理的,因为打开文件时可能遇到多种问题,例如文件不存在或没有足够的权限访问文件。

Result 枚举为我们传递了这些信息,以便后续处理。我们可以像处理 Option 类型一样使用 match 表达式来处理它。

以下是处理文件打开结果的代码:

match txt {
    Ok(mut file) => {
        let mut contents = String::new();
        let _text = file.read_to_string(&mut contents);
        println!("File loaded: {}", contents);
    }
    Err(error) => {
        panic!("Problem opening the file: {:?}", error);
    }
}

如果文件成功打开,我们将读取其内容并打印。如果失败,程序将 panic 并显示错误信息。注意,read_to_string 操作本身也返回一个 Result,但在这个例子中,我们使用下划线 _ 忽略了它,因为我们只关心更新 contents 变量。

运行此脚本,如果 secret.txt 文件存在,我们将看到文件内容被打印出来。如果文件不存在(例如路径改为 bob.png),程序将 panic 并显示详细的错误信息,如“no such file or directory”。

处理多种特定错误

如前所述,尝试打开文件时可能遇到多种错误。我们如何分别处理每种错误呢?

在下一个例子中,我们将尝试打开一个不存在的文件 sample.txt,并演示如何根据具体的错误类型进行不同的处理。

use std::fs::File;
use std::io::ErrorKind;

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/25da715ca4eeda440068651cf2fe5e3b_22.png)

fn main() {
    let path = "sample.txt";
    let txt = File::open(path);

    let txt = match txt {
        Ok(file) => {
            println!("Loaded the path successfully.");
            file
        }
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create(path) {
                Ok(file) => {
                    println!("A new file was created at the following path.");
                    file
                }
                Err(e) => panic!("Problem creating the file: {:?}", e),
            },
            _ => panic!("Problem opening the file: {:?}", error),
        },
    };
}

我们首先尝试打开文件。如果成功,则打印成功信息并返回文件。如果失败,我们使用 error.kind() 来匹配具体的错误类型。

  • 如果错误类型是 ErrorKind::NotFound(即文件未找到),我们尝试创建该文件。
    • 如果文件创建成功,打印信息并返回新创建的文件。
    • 如果文件创建失败,则 panic
  • 对于所有其他类型的错误(用下划线 _ 捕获),我们直接 panic 并显示默认的错误信息。

通过这种方式,我们可以针对“文件未找到”这一特定情况采取创建文件的补救措施,而对于其他错误(如权限不足)则统一处理。运行此程序后,如果 sample.txt 原本不存在,它将被创建出来。

总结

本节课我们一起学习了如何使用 Rust 中的 Result 枚举来处理可恢复错误。我们了解了 Result<T, E> 的基本结构,并通过文件操作的实例演示了如何使用 match 表达式来处理成功和失败的情况。更重要的是,我们学习了如何通过匹配 ErrorKind 来区分和处理多种特定的错误,从而使程序能够更优雅地从错误中恢复。目前,match 方法足以满足我们处理 Result 的需求,在后续的 Rust 学习中,我们还将看到其他提取 Result 值的方法。

047:错误传播 🚀

在本节课中,我们将要学习 Rust 中错误处理的高级概念——错误传播。我们将了解如何不直接在函数内部处理错误,而是将错误返回给调用者,让调用者决定如何处理。

上一节我们介绍了如何使用 match 来处理 Result 类型。本节中我们来看看 Result 类型提供的一些便捷方法,以及如何将错误“传播”出去。

使用 unwrapexpect 方法

Result 类型定义了许多辅助方法来执行更具体的任务。有时使用 match 来处理 Result 可能显得过于繁琐。

例如,假设你想打开一个文件。我们会像任何 Rust 开发者一样调用 File::open

use std::fs::File;

fn main() {
    let file_result = File::open("file.txt");
}

这里我们尝试打开一个不存在的文件 file.txt。我们不对这个可能成功(返回 File)或失败(返回 Error)的 Result 进行处理,而是像处理 Option 类型一样,直接对其调用 .unwrap()

如果存在值,它将返回该值。否则,如果遇到错误,它将为我们调用 panic! 宏。

let file = file_result.unwrap(); // 文件不存在时会 panic

如果我们将文件名改为一个实际存在的文件,例如 secret.txt,程序将成功运行并返回文件句柄。

我们还可以使用另一个辅助方法 .expect().expect() 方法允许我们指定在出错时用于 panic 的消息。

let file = File::open("file.txt").expect("文件缺失");

以下是 unwrapexpect 的主要区别:

  • unwrap():快速原型设计的理想选择,因为输入工作量最小。
  • expect():允许提供包含更多错误信息的详细自定义消息。

什么是错误传播?📤

当函数实现调用了可能失败的操作时,你可以选择不在函数内部处理错误,而是将错误返回给调用代码,让调用者决定如何处理。这被称为传播错误

这给了调用代码更多的控制权,因为调用者可能拥有更多信息或逻辑来决定应如何处理错误,而这些信息在你的函数上下文中可能无法获得。

实现错误传播 🛠️

让我们通过一个例子来分解这个概念。我们将创建一个函数,它尝试读取文件内容,并将任何错误传播给调用者。

首先,我们需要从标准库导入一些模块。

use std::fs::File;
use std::io::{self, Read}; // 使用花括号分组导入

接下来,我们创建一个名为 get_data 的函数。注意它的返回类型:Result<String, io::Error>。这表明函数要么返回一个成功的 String,要么返回一个 io::Error

fn get_data() -> Result<String, io::Error> {
    // 尝试打开文件
    let data_file_result = File::open("data.txt");

    // 使用 match 处理打开文件的结果
    let mut data_file = match data_file_result {
        Ok(file) => file, // 成功,返回文件
        Err(e) => return Err(e), // 失败,提前返回错误
    };

    // 创建一个可变字符串来存储文件内容
    let mut data = String::new();

    // 将文件内容读入字符串
    match data_file.read_to_string(&mut data) {
        Ok(_) => Ok(data), // 读取成功,返回数据
        Err(e) => Err(e),  // 读取失败,返回错误
    }
}

在这个函数中:

  1. 我们尝试打开 data.txt 文件。
  2. 如果打开失败,我们使用 return Err(e) 提前返回错误。
  3. 如果打开成功,我们继续尝试将文件内容读入一个字符串。
  4. 读取操作的结果也通过 match 处理,并相应地返回 Ok(data)Err(e)

关键点在于,get_data 函数本身不处理错误。它只是将成功的结果或遇到的错误“传递”出去。

现在,在我们的 main 函数中调用它:

fn main() {
    let data_result = get_data(); // 现在错误处理的责任转移到了 main 函数
}

data_result 是一个 Result<String, io::Error>。如何处理这个结果——是使用 unwrapexpectmatch,还是进一步传播——现在完全由 main 函数的调用者决定。这就是错误传播的核心:将错误处理的决策权上移。

总结 📝

本节课中我们一起学习了 Rust 的错误传播机制。

  • 我们回顾了 Result 类型的便捷方法 unwrapexpect,它们适用于快速失败或原型设计。
  • 我们深入探讨了错误传播的概念:即函数将错误返回给调用者,而不是在内部处理。
  • 我们通过一个完整的示例,创建了一个返回 Result 类型的函数,演示了如何使用 match 来提前返回错误或将成功结果传递出去。

通过传播错误,你可以编写更清晰、更模块化的代码,将错误处理的逻辑放在最合适的地方。

048:Try 运算符 ? 的魔力 ✨

在本节课中,我们将要学习 Rust 中的 Try 运算符 ?。这个运算符可以极大地简化错误传播的代码,让代码更清晰、更易读。

上一节我们介绍了如何在 Rust 中传播错误。本节中我们来看看如何使用 ? 运算符来更优雅地实现相同的功能。

回顾之前的代码

在之前的代码中,我们创建了一个返回 Result<String, io::Error> 的函数。函数内部没有处理错误,而是将所有错误信息都传播给调用者。

fn read_data() -> Result<String, io::Error> {
    let mut data_file = File::open("data.txt")?; // 注意这里的 `?`
    let mut buffer = String::new();
    data_file.read_to_string(&mut buffer)?; // 以及这里的 `?`
    Ok(buffer)
}

这段代码本身没有问题,但我们可以用一种更简洁的方式来重写它。

引入 Try 运算符 ?

现在,我们可以将代码改写得更简洁。以下是改写后的版本:

fn read_data() -> Result<String, io::Error> {
    let mut data_file = File::open("data.txt")?;
    let mut buffer = String::new();
    data_file.read_to_string(&mut buffer)?;
    Ok(buffer)
}

我们只需在返回 Result 的表达式末尾添加 ? 运算符。这个运算符告诉 Rust:如果操作成功(返回 Ok),则解包并使用其值;如果操作失败(返回 Err),则立即从整个函数中返回该错误,并将错误值传播给调用者。

? 运算符的工作原理

? 运算符与我们在 match 表达式中编写的代码有一个重要区别:被 ? 调用的错误值会经过标准库中 From trait 定义的 from 函数。这个函数用于将值从一种类型转换为另一种类型。

? 调用 from 函数时,接收到的错误类型会被转换为当前函数返回类型中定义的错误类型。这在函数返回一种错误类型以表示所有可能的失败方式时非常有用,即使函数的不同部分可能因多种不同原因而失败。

进一步简化代码

我们可以通过链式调用使用 ? 运算符的方法来进一步缩短代码。例如:

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

这样,我们又减少了一行代码,同时保持了可读性。

使用标准库的便捷函数

在实际的 Rust 编程中,我们经常会遇到预置的功能来处理这类常见操作。例如,将文件读入字符串是一个相当常见的操作,因此标准库提供了方便的 fs::read_to_string 函数。

use std::fs;

fn read_data() -> Result<String, io::Error> {
    fs::read_to_string("data.txt")
}

这个函数会打开文件、创建一个新字符串、读取文件内容、将内容放入该字符串,然后返回。这取代了我们之前编写的所有代码。

? 运算符的使用限制

? 运算符只能在返回类型与所使用值兼容的函数中使用。对于 Result 类型,你的函数必须返回 Result;对于 Option 类型,你的函数必须返回 Option

例如,在默认返回单元类型 ()main 函数中使用 ?,Rust 会报错。

fn main() {
    let data = fs::read_to_string("data.txt")?; // 错误:`main` 不返回 `Result`
    println!("{}", data);
}

要修复这个问题,我们可以让 main 函数返回一个 Result

fn main() -> Result<(), io::Error> {
    let data = fs::read_to_string("data.txt")?;
    println!("{}", data);
    Ok(())
}

? 运算符与 Option 类型

? 运算符同样适用于 Option 类型。以下是一个示例函数,它返回给定文本第一行的最后一个字符:

fn last_char_of_first_line(text: &str) -> Option<char> {
    text.lines().next()?.chars().last()
}

在这个函数中,text.lines().next() 返回一个 Option。如果字符串为空,则返回 None;否则返回第一行。? 运算符在这里处理了 None 的情况,使得代码更简洁。

总结

本节课中我们一起学习了 Rust 中的 Try 运算符 ?。我们了解到:

  1. ? 运算符可以简化错误传播,减少样板代码。
  2. 它适用于返回 ResultOption 类型的函数。
  3. 通过 From trait 的 from 函数,? 运算符能够进行错误类型的转换。
  4. 我们可以使用标准库提供的便捷函数(如 fs::read_to_string)来进一步简化代码。
  5. main 函数中使用 ? 需要将其返回类型改为 Result

掌握 ? 运算符将使你的 Rust 代码更加简洁和易于维护。

049:何时使用 panic!Result

在本节课中,我们将学习如何在 Rust 中做出关键决策:何时让程序恐慌(panic!),何时返回一个 Result 来处理错误。理解这两者的适用场景对于编写健壮且安全的 Rust 代码至关重要。

概述

上一节我们介绍了 panic!Result 的基本概念。本节中,我们来看看如何在实际编码中选择使用它们。核心原则是:对于可恢复的错误使用 Result,对于不可恢复的、表明程序进入“坏状态”的错误则使用 panic!

使用 Result 的场景

当错误是预期内的、可以被修复或妥善处理时,应优先使用 Result

以下是适合使用 Result 的典型情况:

  • 用户输入错误:例如,用户本应输入年龄却输入了名字。程序可以提示用户重新输入,这是一个可恢复的错误。
  • 外部依赖错误:例如,进行 API 调用时,API 服务器可能达到限制或暂时不可用。这并非程序本身的缺陷,程序可以记录错误、重试或向用户报告。
fn call_api() -> Result<String, String> {
    // 模拟 API 达到调用上限
    Err("API limit reached".to_string())
}

fn main() {
    match call_api() {
        Ok(response) => println!("API Response: {}", response),
        Err(e) => println!("Error: {}", e), // 优雅地处理错误
    }
}

使用 panic! 的场景

当程序遇到无法或不应继续执行的“坏状态”时,应使用 panic!。坏状态通常指程序的假设、契约或不变式被破坏。

以下是适合使用 panic! 的典型情况:

  • 可能导致安全漏洞或严重错误的操作:例如,访问超出数组边界的索引。程序崩溃比允许潜在的内存不安全或数据损坏要好。
  • 原型设计与快速测试:在验证想法时,使用 panic!unwrap() 可以更快地编写和测试代码逻辑。
  • 逻辑上不可能失败,但编译器无法证明的情况:当你确信某个操作永远不会失败,但 Rust 编译器无法推断时,可以使用 expect() 来表明这是程序员的断言。
use std::net::IpAddr;

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/22b5dc59ed55189fb08cb1a32182b6f2_11.png)

fn main() {
    // 我们知道 "127.0.0.1" 是有效的 IP 地址,但 `parse` 方法返回 Result。
    // 使用 `expect` 表明我们确信它会成功,如果失败则 panic(意味着我们写错了硬编码值)。
    let home: IpAddr = "127.0.0.1".parse().expect("Hardcoded IP address should be valid");
    println!("Home IP: {:?}", home);
}

原型设计示例

假设我们想快速测试一个获取字符串首字符的函数:

fn get_first_char(s: &str) -> char {
    if s.is_empty() {
        panic!("String is empty"); // 快速失败,便于调试
    }
    s.chars().next().unwrap() // 因为前面检查了非空,这里用 unwrap 是安全的
}

fn main() {
    let word = "Hello";
    let first = get_first_char(word);
    println!("The first character is: {}", first); // 输出 H
    // 如果传入空字符串 "",程序会 panic 并显示清晰信息。
}

Rust 官方指南

根据 Rust 官方书籍的建议,在以下情况发生时,让你的代码 panic 是合适的:

  1. 程序处于“坏状态”(即某些假设、保证、契约或不变量被破坏,例如传入了无效值、矛盾值或缺失值)。
  2. 并且满足以下至少一个条件:
    • 这种坏状态是出乎意料的,而非偶尔会发生(如用户输错格式)。
    • 在此点之后的代码需要依赖不处于这种坏状态,而不是每一步都检查问题。
    • 没有一种好方法能将这种信息编码到你所使用的类型中

创建自定义类型进行验证

一个高级技巧是创建自定义类型来封装验证逻辑,这可以将错误处理提前,并减少后续代码中的重复检查。

例如,在猜数字游戏中,我们可以定义一个 Guess 类型,确保其值始终在 1 到 100 之间:

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }
        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}

fn main() {
    let guess = Guess::new(50); // 有效
    println!("Your guess is: {}", guess.value());

    // let bad_guess = Guess::new(200); // 这会 panic!
}

通过使用 Guess 类型,我们保证了只要有一个 Guess 实例,它的值就一定是有效的。这遵循了“使用类型系统来保证正确性”的 Rust 哲学。

总结

本节课中我们一起学习了在 Rust 中如何抉择 panic!Result

  • 使用 Result:处理可恢复的、预期内的错误,给予调用者处理错误的灵活性。
  • 使用 panic!:应对不可恢复的、表明程序基础假设失效的“坏状态”,防止错误扩散导致更严重问题。
  • 实践技巧:在原型设计、逻辑确信成功、以及通过自定义类型封装不变量时,可以审慎地使用 panic!unwrap()expect()

掌握错误处理是编写可靠 Rust 程序的核心。接下来,我们将通过构建一些实际项目来巩固这些概念,然后再继续深入学习 Rust 语言的其他特性。

050:用 Rust 构建你的第一个真实项目(Grep)🔍

在本节课中,我们将学习如何使用 Rust 构建一个名为 grep 的命令行工具。这个工具用于在文本文件中搜索包含特定模式的行。我们将创建一个简化版本,它能够处理搜索模式、多个文件以及大小写不敏感的搜索选项。

项目概述与目标 🎯

我们将构建一个命令行工具,其功能是在一个或多个文本文件中搜索包含指定模式的行。程序运行效果如下:在终端中输入 cargo run,后跟搜索模式、文件名以及可选的 -i 标志(用于启用大小写不敏感搜索)。例如,命令 cargo run Bob text1.txt -i 将返回 text1.txt 文件中所有包含“Bob”(不区分大小写)的行。

第一步:定义配置结构体 ⚙️

首先,我们需要定义一个结构体来存储程序的配置信息。这个结构体将包含搜索模式、要搜索的文件列表以及一个控制是否忽略大小写的标志。

上一节我们介绍了项目的目标,本节中我们来看看如何组织程序的核心配置。

struct Config {
    pattern: String,
    files: Vec<String>,
    case_insensitive: bool,
}

这里,Vec<String> 是一个字符串向量,你可以暂时将其理解为一个可以动态增减元素的数组。我们将在后续课程中详细学习向量。

第二步:实现配置的构造函数 🛠️

接下来,我们需要为 Config 结构体实现一个构造函数 new。这个函数负责解析命令行参数,并返回一个包含有效配置或错误信息的 Result

以下是 Config::new 方法需要完成的步骤:

  1. 检查参数数量:确保用户至少提供了模式和文件名。
  2. 处理标志参数:识别并处理 -i 标志。
  3. 提取非标志参数:从参数中分离出搜索模式和文件名。
  4. 验证并返回配置:确保模式已提供,并收集所有文件名。

impl Config {
    fn new(args: &[String]) -> Result<Config, String> {
        if args.len() < 3 {
            return Err(format!("用法: {} <模式> <文件>... [-i]", args[0]));
        }

        let mut case_insensitive = false;
        let mut non_flag_args: Vec<String> = Vec::new();

        for arg in &args[1..] {
            if arg == "-i" {
                case_insensitive = true;
            } else {
                non_flag_args.push(arg.clone());
            }
        }

        if non_flag_args.is_empty() {
            return Err("错误:未提供模式".to_string());
        }

        let pattern = non_flag_args[0].clone();

        if non_flag_args.len() < 2 {
            return Err("错误:未提供输入文件".to_string());
        }

        let files = non_flag_args[1..].to_vec();

        Ok(Config {
            pattern,
            files,
            case_insensitive,
        })
    }
}

代码解析:

  • args[0] 通常是程序自身的名称。
  • 我们遍历从第二个参数开始的所有参数(&args[1..])。
  • 如果遇到 -i,则将 case_insensitive 设为 true
  • 否则,将参数视为非标志参数(模式或文件名)并存入 non_flag_args 向量。
  • 最后,从 non_flag_args 中提取模式和文件列表,构建 Config 实例。

第三步:实现核心搜索函数 🔎

现在,我们需要实现两个核心函数:grep_file 用于处理单个文件,grep 用于在文件内容中逐行搜索模式。

首先,我们实现 grep 函数,它负责读取文件的每一行并进行匹配。

use std::io::{BufRead, BufReader};

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/08e3e4950ee4c0131327b3c6bc9de685_38.png)

fn grep(reader: &mut BufReader<std::fs::File>, pattern: &str, case_insensitive: bool) {
    let mut line = String::new();

    while let Ok(bytes_read) = reader.read_line(&mut line) {
        if bytes_read == 0 {
            break;
        }

        let matched = if case_insensitive {
            line.to_lowercase().contains(&pattern.to_lowercase())
        } else {
            line.contains(pattern)
        };

        if matched {
            print!("{}", line);
        }

        line.clear();
    }
}

代码解析:

  • BufReader 提供了高效的缓冲读取。
  • reader.read_line(&mut line) 每次读取一行到 line 字符串中。
  • 如果读取的字节数为0,表示已到达文件末尾,循环终止。
  • 根据 case_insensitive 标志,决定是否将行内容和模式都转换为小写后再进行包含性检查。
  • 如果匹配成功,则打印该行。
  • 每次循环后使用 line.clear() 清空缓冲区,以便读取下一行。

接着,我们实现 grep_file 函数,它打开指定文件并将文件句柄传递给 grep 函数。

use std::fs::File;

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/08e3e4950ee4c0131327b3c6bc9de685_44.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/08e3e4950ee4c0131327b3c6bc9de685_46.png)

fn grep_file(file: &str, config: &Config) -> Result<(), String> {
    let file = match File::open(file) {
        Ok(f) => f,
        Err(e) => return Err(format!("无法打开文件 '{}': {}", file, e)),
    };

    let mut reader = BufReader::new(file);
    grep(&mut reader, &config.pattern, config.case_insensitive);
    Ok(())
}

这个函数尝试打开文件,如果成功,则创建 BufReader 并调用 grep 函数进行搜索;如果失败,则返回一个描述性的错误信息。

第四步:组装主函数 🧩

最后,我们需要在 main 函数中将所有部分连接起来:收集命令行参数、创建配置、然后对每个文件执行搜索。

use std::env;

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/08e3e4950ee4c0131327b3c6bc9de685_58.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/08e3e4950ee4c0131327b3c6bc9de685_59.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/08e3e4950ee4c0131327b3c6bc9de685_61.png)

fn main() -> Result<(), String> {
    let args: Vec<String> = env::args().collect();
    let config = Config::new(&args)?;

    for file in &config.files {
        if let Err(e) = grep_file(file, &config) {
            eprintln!("处理文件 '{}' 时出错: {}", file, e);
        }
    }

    Ok(())
}

代码解析:

  • env::args().collect() 获取命令行参数并将其收集到一个 Vec<String> 中。
  • Config::new(&args)? 调用构造函数,如果出错则使用 ? 操作符将错误提前返回。
  • 遍历配置中的所有文件,对每个文件调用 grep_file。如果某个文件处理出错,我们会打印错误信息但不会终止整个程序(这是简化处理)。

运行与测试 🚀

现在,你可以使用 cargo run 来测试你的 grep 程序了。

  1. 基本搜索cargo run Bob text1.txt
  2. 多文件搜索cargo run Bob text1.txt text2.txt
  3. 大小写不敏感搜索cargo run BOB text1.txt -i
  4. 搜索短语cargo run "his cat" text1.txt text2.txt

确保你的项目目录下存在用于测试的 text1.txttext2.txt 文件。

总结 📝

本节课中我们一起学习了如何使用 Rust 构建一个简化版的 grep 命令行工具。我们涵盖了以下核心内容:

  • 使用 struct 组织程序配置。
  • 解析命令行参数,包括处理可选标志(-i)。
  • 使用 Vec<T>(向量)来存储动态列表。
  • 使用 FileBufReader 进行文件读写。
  • 实现逐行读取和字符串匹配的逻辑。
  • main 函数中整合所有模块,并处理潜在的错误。

这个项目虽然基础,但它很好地串联了 Rust 的多个核心概念,如结构体、枚举、错误处理、集合类型和文件 I/O。你可以在此基础上继续扩展功能,例如支持正则表达式、递归目录搜索等。项目的完整源代码可以在相关的 GitHub 仓库中找到。

051:todo!unimplemented! 宏 🚧

在本节课中,我们将学习 Rust 中两个非常实用的宏:todo!unimplemented!。它们能帮助我们在编写程序时,为尚未实现的功能创建占位符,从而让开发过程更加顺畅。

概述:为何需要占位符宏?

在上一节中,我们介绍了项目结构。但在实际开发中,我们常常对程序的功能有清晰的想法,而实现这些功能的代码却需要时间编写。有时,为了避免未来出现意外错误,我们可能会在未完成的代码块中直接使用 panic!。然而,Rust 提供了两个更合适的宏来标记未完成的功能:todo!unimplemented!

这两个宏功能几乎相同,都能使程序在调用时发生恐慌(panic),但它们传达了不同的意图。

todo! 宏:标记待办事项

todo! 宏用于标记你计划在未来实现的功能。它表明这部分代码是临时的,你打算稍后完成它。

以下是一个使用 todo! 宏的例子:

fn order_food(food: &str) {
    todo!("order_food still has to be coded");
}

在这个例子中,我们定义了一个 order_food 函数,它接收一个字符串切片作为参数。由于我们尚未想好如何实现订餐功能,我们使用 todo! 宏作为占位符。此时,如果我们调用这个函数:

order_food("banana");

程序会运行,但会因遇到 todo! 宏而恐慌退出,并输出类似“not yet implemented: order_food still has to be coded”的信息。你可以在宏中添加任何描述性信息。

unimplemented! 宏:标记概念性功能

unimplemented! 宏的用法与 todo! 类似,但它传达的意图不同。它用于标记那些可能永远不会被实现的功能,或者仅仅是一个概念、想法。

以下是一个使用 unimplemented! 宏的例子:

fn order_food_fast_mode(food: &str) {
    unimplemented!("Potential concept for faster delivery");
}

这里,order_food_fast_mode 代表一个“快速订餐模式”的概念。我们使用 unimplemented! 是因为这个功能目前只是一个想法,我们不确定未来是否会实现它。调用此函数同样会导致程序恐慌,但输出的信息是“not implemented”。

两者的核心区别在于意图:todo! 表示“稍后完成”,unimplemented! 表示“仅为概念”。

在结构体方法中的应用

这两个宏在定义结构体的方法时尤其有用。我们可以先搭建出方法的框架,而不必立即实现所有细节。

让我们创建一个 CustomFile 结构体作为示例:

struct CustomFile {
    path: String,
}

impl CustomFile {
    fn read(&self) {
        println!("Reading {}", self.path);
    }

    fn copy(&self) {
        todo!("copy still has to be coded");
    }

    fn delete(&self) {
        todo!("delete still has to be coded");
    }

    fn apple_intelligence(&self) {
        unimplemented!("Designed for Apple In");
    }
}

在上面的代码中:

  • read 方法已经实现。
  • copydelete 方法使用 todo! 宏标记,表示我们计划实现它们。
  • apple_intelligence 方法使用 unimplemented! 宏标记,表示这只是一个概念性功能。

现在,我们可以在 main 函数中使用这个结构体:

fn main() {
    let path = String::from("text.txt");
    let custom_file = CustomFile { path };

    custom_file.read(); // 这会正常工作,输出 "Reading text.txt"

    // 以下调用都会导致程序恐慌
    // custom_file.copy();
    // custom_file.delete();
    // custom_file.apple_intelligence();
}

辅助开发:消除语法高亮干扰

todo! 宏还有一个非常实用的场景:在编写复杂函数时,临时消除 Rust 语言服务器(LSP)的语法错误高亮干扰。

假设我们正在编写一个函数来读取和显示文件内容:

use std::fs::File;
use std::io::{BufRead, BufReader, Error};

struct Contents {
    path: String,
    contents: String,
}

fn get_and_display_contents(path: &str) -> Result<Contents, Error> {
    let file = File::open(path)?;
    let reader = BufReader::new(file);
    let mut contents = String::new();

    for line in reader.lines() {
        let line = line?;
        println!("{}", line);
        // 我们计划在这里将行内容添加到 `contents` 字符串中,但还没写
    }

    // 在函数完成前,这里会因为没有返回值而显示语法错误
    todo!()
}

在编写函数体的过程中,编译器会因为函数缺少确定的返回值而用红色高亮标出错误,这可能会干扰我们的思路。此时,在函数末尾放置一个 todo!() 可以“安抚”编译器,让这些高亮暂时消失,使我们能更专注地编写核心逻辑。

当我们完成函数主体后,再回来替换掉 todo!()

fn get_and_display_contents(path: &str) -> Result<Contents, Error> {
    let file = File::open(path)?;
    let reader = BufReader::new(file);
    let mut contents = String::new();

    for line in reader.lines() {
        let line = line?;
        println!("{}", line);
        contents.push_str(&line);
        contents.push('\n');
    }

    // 用实际的返回值替换 todo!()
    Ok(Contents {
        path: path.to_string(),
        contents,
    })
}

这种方法让开发流程更加顺畅,并且你不用担心会忘记完成函数,因为如果调用包含 todo! 的函数,程序会明确地提醒你。

总结

本节课中我们一起学习了 Rust 中的 todo!unimplemented! 宏。

  • todo!:用于标记计划实现但尚未完成的功能。它传达“稍后完成”的意图,并在被调用时导致程序恐慌,输出“not yet implemented”信息。
  • unimplemented!:用于标记概念性可能永不实现的功能。它传达“仅为想法”的意图,恐慌时输出“not implemented”信息。
  • 共同优点:它们允许我们为程序搭建清晰的框架和蓝图,而无需一次性实现所有细节。todo! 还能在开发过程中临时消除编译器错误提示的干扰。

合理使用这两个宏,可以让你的 Rust 项目开发过程更有条理,并避免留下未完成的 panic! 语句。在下一节中,我们将终于开始学习 Rust 中一个非常重要的集合类型:向量(Vectors)。

052:向量 🧮

在本节课中,我们将要学习 Rust 中的向量。向量是一种非常强大的集合类型,允许我们在单个数据结构中存储多个值。我们将学习如何创建、修改和访问向量中的元素,并理解其背后的核心概念。

向量允许我们在一个数据结构中存储多个值,这些值在内存中是连续存放的。与 Python 列表不同,Rust 向量只能存储相同类型的值。当你需要处理一系列项目时,例如文件中的文本行或购物车中的商品价格,向量会非常有用。向量指向的数据存储在上,这意味着数据量在编译时无需已知,并且可以在程序运行时增长或缩小。

创建向量

要创建一个新的空向量,我们使用 Vec::new 函数。创建空向量时需要指定类型,因为 Rust 没有关于该向量的信息,需要提前知道它要保存的类型。

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

Rust 还提供了一个用于创建向量的宏 vec!,这在你想创建一个非空向量时非常方便。

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

修改向量

上一节我们介绍了如何创建向量,本节中我们来看看如何修改它。要修改向量,首先需要将其声明为可变的。

以下是向向量中添加和移除元素的方法:

  • 使用 push 方法在向量末尾添加元素。
  • 使用 pop 方法移除并返回向量中的最后一个元素。pop 返回一个 Option<T> 类型,因为如果向量为空,它将返回 None
let mut numbers = vec![0];
numbers.push(1);
numbers.push(2);
numbers.push(3);

let popped = numbers.pop(); // 返回 Some(3)

访问向量元素

在 Rust 中,有两种方法可以引用向量中存储的值:通过索引或通过 get 方法。

以下是两种访问方式的区别:

  • 索引访问:使用 &v[index]。如果索引越界,程序会panic(崩溃)。
  • get 方法访问:使用 v.get(index)。它返回一个 Option<&T> 类型(例如 Some(&value)None),因此即使索引越界,程序也不会 panic,而是可以优雅地处理。

let numbers = vec![1, 2, 3];
let second = &numbers[1]; // 索引访问,可能 panic
let second_option = numbers.get(1); // get方法访问,返回 Option

这两种方法的存在是为了让你决定当访问超出范围的值时程序应如何行为。

所有权与借用规则

当程序持有一个有效的引用时,借用检查器会强制执行所有权和借用规则,以确保该引用以及对向量内容的任何其他引用保持有效。

例如,如果你有一个可变向量并获取了其中某个元素的不可变引用,那么在该引用被使用之前,你不能修改向量(例如使用 push)。你需要在使用引用之前之后修改向量,程序才能编译通过。

let mut numbers = vec![1, 2, 3, 4, 5];
let first = &numbers[0]; // 获取不可变引用
// numbers.push(6); // 这里编译错误!不能在持有不可变引用时修改向量
println!("The first element is {}", first); // 先使用引用
numbers.push(6); // 然后修改向量,这样是允许的

遍历向量

我们可以使用 for 循环来遍历向量中的所有值。

以下是遍历向量的两种方式:

  • 不可变遍历:迭代对每个元素的不可变引用。
  • 可变遍历:迭代对每个元素的可变引用,从而可以在遍历时修改它们。使用 * 解引用运算符来获取并修改值。
// 不可变遍历
let people = vec!["Bob", "James", "Sandra"];
for person in &people {
    println!("{}", person);
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/d119a7c99f7e75f28df603a55faef301_29.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/d119a7c99f7e75f28df603a55faef301_30.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/d119a7c99f7e75f28df603a55faef301_31.png)

// 可变遍历
let mut numbers = vec![1, 2, 3];
for n in &mut numbers {
    *n += 10; // 解引用并修改值
}
// numbers 现在是 [11, 12, 13]

在向量中存储多种类型

向量只能存储单一类型。但是,有一个变通方法:使用枚举。因为枚举的变体可以定义不同的关联数据类型,所以我们可以创建一个包含不同枚举变体的向量,从而间接存储“不同类型”的值。

Rust 需要在编译时知道向量中将包含哪些类型,因为它需要确切地知道堆上需要多少内存来存储每个元素。

#[derive(Debug)] // 为枚举派生Debug trait以便打印
enum Value {
    Int(i32),
    Float(f64),
    Text(String),
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/d119a7c99f7e75f28df603a55faef301_41.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/d119a7c99f7e75f28df603a55faef301_42.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/d119a7c99f7e75f28df603a55faef301_43.png)

let mut values = vec![
    Value::Float(std::f64::consts::PI),
    Value::Int(42),
];
values.push(Value::Text(String::from("Bob")));

本节课中我们一起学习了 Rust 向量的核心知识。我们了解了向量是一种在堆上存储同类型数据集合的方式,学习了如何用 Vec::newvec! 宏创建向量,如何使用 pushpop 修改向量,以及通过索引和 get 方法安全地访问元素。我们还探讨了遍历向量的方法,并了解了借用规则如何应用于向量引用。最后,我们看到了如何使用枚举在向量中间接存储多种类型。向量是 Rust 中处理数据集合的基础工具,掌握它们对后续学习至关重要。

053:Rust 中的字符串类型 🧵

在本节课中,我们将更深入地学习 Rust 中的字符串类型。通常,我们将字符串视为集合,因为在 Rust 中,字符串本质上是一个字节集合。在其他语言(如 Python)中,我们通常将字符串视为字符集合,这同样是一种集合。在接下来的几个视频中,我们将讨论所有集合类型共有的操作,例如创建、更新和读取。我们也会看看字符串与其他集合有何不同。但首先,让我们从了解什么是字符串开始本节的学习。

核心语言中的字符串类型

在 Rust 核心语言中,只有一种字符串类型:字符串切片。我们之前已经见过很多次,它看起来像这样:

let s = "Bob";

这是一个字符串切片。如果我们调试它,得到的输出是 "Bob"

在之前的课程中,我们讨论过字符串切片,它是对存储在其他地方的 UTF-8 编码字符串数据的引用。例如,字符串字面量直接存储在程序的二进制文件中,因此它们就是字符串切片。

标准库中的 String 类型

String 类型由 Rust 的标准库提供,而非内置于核心语言中。它是一种可增长、可变、拥有所有权的 UTF-8 编码字符串类型。当 Rust 开发者提到“字符串”时,他们可能指的是 String 类型或字符串切片类型,而不仅仅是其中一种。虽然本系列的这一部分将主要关注 String 类型,但这两种类型在 Rust 标准库中都大量使用,并且都是 UTF-8 编码的。

与向量的相似性

现在,许多适用于向量的操作也同样适用于 String 类型。这是因为 String 实际上是围绕一个字节向量(Vec<u8>)的包装器,附带一些额外的保证、限制和能力。

以下是它们工作方式相同的一个函数示例:new 函数。

let s = String::new(); // 创建一个空字符串
let v: Vec<i32> = Vec::new(); // 创建一个空的 i32 类型向量

这行代码创建了一个新的空字符串,我们稍后可以向其中加载数据。如果我们调试这两者,得到的输出将是一个空字符串和一个空向量。

创建带有初始值的字符串

通常,我们会有一个想要使用的字符串初始值。例如,我们可能有一个 &str 类型的文本:

let text: &str = "Hello, Bob";

此时,text 是一个字符串切片。正如你所见,我们没有使用 String 构造函数。当我们使用这样的引号时,默认创建的是一个字符串切片。

为了将其转换为一个拥有所有权的 String,我们需要使用以下方法:

let text = text.to_string(); // 现在 text 是 String 类型

或者,你也可以直接这样做:

let text = "Hello, Bob".to_string();

第二种方法是使用 String::from 函数:

let text = String::from("Hello, Bob");

这两种方法做的事情完全相同,选择哪一种取决于个人偏好。

UTF-8 编码的重要性

请记住,字符串是 UTF-8 编码的,这意味着我们可以在其中包含任何正确编码的数据。这就是为什么以下所有内容都是有效的,即使包含特殊字符:

let hello = String::from("你好");
let emoji = String::from("😊");

本节课中,我们一起学习了 Rust 中两种主要的字符串类型:核心语言的字符串切片(&str)和标准库的 String 类型。我们了解了 String 是可增长、可变且拥有所有权的,并且它是基于 Vec<u8> 实现的。我们还学习了如何创建空字符串以及如何从字符串字面量创建拥有所有权的 String(使用 .to_string()String::from)。最后,我们强调了 Rust 字符串的 UTF-8 编码特性,使其能够支持多种语言和字符。在下一节中,我们将探讨如何在 Rust 中更新字符串。

054:字符串的扩展与拼接 📝

在本节课中,我们将继续学习 Rust 中的字符串。我们将重点探讨如何扩展字符串的内容,以及如何将多个字符串拼接在一起。字符串在 Rust 中类似于向量(Vector),可以动态增长其大小和内容,只需向其“推送”更多数据即可。

扩展字符串:push_strpush 方法

上一节我们介绍了字符串的基本概念,本节中我们来看看如何向一个已有的字符串追加内容。Rust 提供了两种主要方法:push_str 用于追加字符串切片,push 用于追加单个字符。

首先,我们创建一个可变的字符串变量作为起点。

let mut text = String::from("Bob");

接下来,我们使用 push_str 方法向这个字符串追加内容。这个方法接收一个字符串切片(&str)作为参数,这样它就不会取得参数的所有权,我们之后仍然可以使用这个参数。

text.push_str(" loves apples");
println!("{:?}", text); // 输出: "Bob loves apples"

为了更清晰地展示所有权机制,我们可以用一个变量来存储要追加的内容。

let mut text = String::from("James");
let ending = " was here";
text.push_str(ending);
println!("{:?}", text); // 输出: "James was here"
println!("{:?}", ending); // 仍然可以正常使用 `ending`

即使 ending 是一个 String 类型,push_str 方法也会要求我们传入其切片形式(&ending),Rust 会确保这一点,避免意外的所有权转移。

除了追加整个字符串,我们还可以使用 push 方法向字符串末尾添加单个字符。

let mut text = String::from("hello");
text.push('!');
println!("{:?}", text); // 输出: "hello!"

以上就是向字符串中追加数据的基本方法。接下来,让我们看看如何将多个字符串连接成一个。

字符串拼接:+ 运算符与 format!

在 Python 等语言中,使用 + 运算符拼接字符串非常简单直接。但在 Rust 中,+ 运算符的行为稍有不同,涉及到所有权和引用的概念。

首先,我们创建两个字符串。

let s1 = String::from("hello");
let s2 = String::from(" Bob");

如果我们尝试直接使用 s1 + s2,编译器会报错。我们需要将第二个操作数 s2 以引用的形式传入。

let s3 = s1 + &s2;
println!("{:?}", s3); // 输出: "hello Bob"

执行此操作后,你会注意到两件事:

  1. s1 的所有权被移动:在拼接操作后,我们无法再使用 s1,因为它的所有权在 + 运算中被消耗了。
  2. s2 需要以引用形式传入:这是因为 + 运算符底层调用的 add 方法的签名类似于:
    fn add(self, s: &str) -> String
    它取得第一个字符串(self)的所有权,并接收第二个字符串的切片引用,然后返回一个新的 String

当需要拼接两个以上的字符串时,使用 + 运算符会变得繁琐且难以阅读。

let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s4 = s1 + "-" + &s2 + "-" + &s3;
println!("{:?}", s4); // 输出: "tic-tac-toe"
// 此时 s1 已不可用

因此,对于复杂的字符串拼接,更推荐使用 format! 宏。它的工作方式类似于 println! 宏,但不是将结果打印到屏幕,而是将其作为一个新的 String 返回。

以下是使用 format! 宏的示例:

let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s4 = format!("{}-{}-{}", s1, s2, s3);
println!("{:?}", s4); // 输出: "tic-tac-toe"

使用 format! 宏有两大优点:

  1. 代码可读性高:格式清晰,一目了然。
  2. 不取得参数所有权format! 宏生成的代码使用参数的引用,因此不会消耗 s1s2s3 的所有权。拼接操作后,你仍然可以继续使用它们。

println!("{:?}, {:?}, {:?}", s1, s2, s3); // 全部可以正常使用

注意:像 println! 这样的宏默认也会取得值的所有权。如果你希望之后再次使用这些变量,应该传入它们的引用,例如 println!("{:?}", &s1)

总结 🎯

本节课中我们一起学习了 Rust 中操作字符串的进阶技巧:

  • 我们掌握了使用 push_strpush 方法来动态扩展字符串内容。
  • 我们了解了使用 + 运算符进行字符串拼接时涉及的所有权转移机制,以及它更适合于简单的两字符串拼接场景。
  • 我们重点学习了 format! 宏,它是进行复杂、多字符串拼接的首选工具,因为它代码清晰且不会夺取参数的所有权,使得代码更安全、更易维护。

055:字符串索引与切片 🔍

在本节课中,我们将要学习 Rust 中字符串索引与切片的核心概念。我们将探讨为何 Rust 不允许直接通过整数索引访问字符串中的字符,以及如何通过切片和迭代来安全地操作字符串数据。

概述

在上一节中,我们学习了如何更新字符串。本节中,我们将覆盖本部分的最后一个重要主题:在 Rust 中索引字符串。在许多编程语言中,通过索引访问字符串中的单个字符是常见操作。然而,在 Rust 中,这并不简单。

字符串的内部表示

为了解释为何 Rust 不允许直接索引,我们首先需要讨论 Rust 如何在内存中存储字符串。Rust 的 String 本质上是 Vec<u8> 的包装器。让我们看一些正确编码的 UTF-8 字符串示例。

以下是创建字符串并查看其字节长度的示例:

let greeting = String::from("Hola");
let length = greeting.len(); // 返回字节长度,而非字符数
println!("{:?}", length); // 输出: 4

这个例子很简单,因为我们只使用了常规的拉丁字符。但如果引入特殊字符,情况会变得复杂。

let greeting = String::from("Здравствуйте");
let length = greeting.len();
println!("{:?}", length); // 输出: 24

在这个例子中,俄语字符“З”占用了两个字节的存储空间。这使得简单的索引操作变得困难,因为如果我们尝试索引 greeting[4],我们可能只请求了某个字符的一部分字节,这在单独情况下没有意义。

字符串的三种视角

对于 UTF-8 编码,从 Rust 的角度来看,有三种相关的方式来查看字符串:作为字节、作为标量值和作为字素簇。字素簇最接近我们所说的“字母”。

让我们以印地语单词“नमस्ते”为例,看看这三种视角。

首先,我们查看其字节表示:

let greeting = String::from("नमस्ते");
let bytes = greeting.as_bytes();
println!("{:?}", bytes); // 输出构成该字符串的 18 个字节

这是计算机存储此数据的最终方式。

其次,我们将其视为标量值:

let greeting = String::from("नमस्ते");
let scalar_values: Vec<char> = greeting.chars().collect();
println!("{:?}", scalar_values); // 输出 6 个字符,其中包含变音符号

这里返回了六个字符,但第四个和第六个不是字母,而是变音符号。变音符号是表示同一字母不同发音的符号。

第三,我们将其视为字素簇,这会产生人类称之为构成印地语单词“नमस्ते”的四个字母。

Rust 为我们提供了不同的方式来解释原始字符串数据,以便每个程序都可以选择其所需的解释,无论使用何种人类语言。

为何不允许直接索引

Rust 不允许我们通过索引字符串来获取字符的另一个最终原因是,索引操作预期总是花费常数时间 O(1)。但对于字符串,这种性能保证是不可能的,因为 Rust 需要从开头遍历内容到指定索引,以确定存在多少有效字符。

字符串切片

既然我们已经了解到索引字符串通常不是一个好主意,因为不清楚返回类型应该是什么(应该是一个字节、一个字符、一个字素簇还是一个字符串切片?),那么是时候讨论切片字符串了。

因此,Rust 要求我们在使用索引创建字符串切片时要更加明确。

以下是如何创建字符串切片的示例:

let greeting = String::from("Здравствуйте");
let s = &greeting[0..4];
println!("{:?}", s); // 输出: "Зд"

换句话说,我们需要非常明确地指定要从字符串中提取的数据部分。我们不能只说索引 4,因为 Rust 不理解我们的意思。它不知道我们是想要字符还是字节。所以我们需要极其明确我们想要什么。

如果我们尝试错误地索引或切片,例如使用索引 [4..5],Rust 会 panic,并给出非常详细的错误消息,例如“byte index 5 is not a char boundary; it is inside 'З'”。

处理字符串部分的最佳实践

处理字符串部分的最佳方法是明确你想要从中获得什么。你是想要字符还是字节?

例如,如果我们有一个单词并想遍历它:

let word = String::from("こんにちは");
// 遍历字节
for byte in word.bytes() {
    println!("{}", byte);
}
// 遍历字符
for c in word.chars() {
    println!("{}", c);
}

在这两种情况下,我们都额外明确了要从该字符串切片中获取哪些数据。

从字符串中获取字素簇更为复杂,甚至没有包含在标准库中。我们将在未来的视频中讨论。

总结

本节课中,我们一起学习了 Rust 中字符串索引与切片的核心概念。我们了解到,由于 UTF-8 编码的复杂性和性能考虑,Rust 不允许直接通过整数索引访问字符串。相反,我们需要使用切片操作,并明确指定我们想要的是字节、字符还是其他形式的表示。通过 as_bytes()chars() 方法和字符串切片语法 &string[start..end],我们可以安全且高效地操作字符串数据。字符串可能看起来很复杂,但只要你明确想要从中获取什么,它们就相当容易处理。

056:哈希映射详解 🗺️

在本节课中,我们将要学习 Rust 中的哈希映射。哈希映射是一种非常实用的集合类型,它以键值对的形式存储数据。当我们需要通过键而非索引来查找数据时,哈希映射就显得尤为有用。

创建哈希映射

让我们从创建一个新的哈希映射开始。在这个例子中,我们将创建一个存储分数的哈希映射。首先,我们需要从标准库中导入 HashMap

use std::collections::HashMap;

let mut scores = HashMap::new();

与往常一样,如果我们定义了一个没有初始数据的数据结构,Rust 编译器无法推断其类型,因此需要我们显式声明。我们可以选择在创建时声明类型,或者立即插入一些数据,让 Rust 自动推断。

这里,我们选择立即插入数据。

scores.insert(String::from("Bob"), 37);

这样,Rust 就能推断出这个哈希映射的类型是 HashMap<String, i32>。键是 String 类型,值是 i32 类型。

我们再添加一个用户。

scores.insert(String::from("James"), 40);

现在,我们可以打印这个哈希映射来查看内容。

println!("{:?}", scores);

输出结果会清晰地展示键值对。需要了解的是,与向量类似,哈希映射的数据也存储在堆上,并且它是同质的:所有键必须是相同类型,所有值也必须是相同类型。

访问哈希映射中的值

上一节我们介绍了如何创建和填充哈希映射,本节中我们来看看如何访问其中的值。我们继续使用上面的例子,尝试获取“Bob”对应的值。

要获取值,我们可以使用 get 方法,它接收一个键并返回一个 Option 枚举。

let score = scores.get("Bob");

get 方法返回的是 Option<&V>,即一个可能包含值引用的 Option。为了得到一个确定的值,我们可以使用 copied() 方法将其转换为 Option<V>,然后使用 unwrap_or 提供一个默认值。

let score = scores.get("Bob").copied().unwrap_or(0);
println!("Bob's score is: {}", score);

这种方法很便利,因为即使键不存在,程序也不会恐慌,而是返回我们指定的默认值 0。

遍历哈希映射

除了访问单个值,我们还可以遍历哈希映射中的所有键值对。以下是遍历的语法。

for (key, value) in &scores {
    println!("{}: {}", key, value);
}

在这个循环中,keyvalue 是每个键值对的引用。运行这段代码,会输出哈希映射中的所有条目。

哈希映射与所有权

现在,我们来探讨哈希映射如何处理所有权,这是 Rust 中的一个核心概念。

对于实现了 Copy 特征的类型(如 i32),值会被复制到哈希映射中。对于拥有所有权的值(如 String),值会被移动,哈希映射将取得其所有权。

让我们看一个例子。

let name = String::from("Bob");
let age = 27;

let mut users = HashMap::new();
users.insert(name, age); // `name` 被移动,`age` 被复制

// println!("{}", name); // 错误!`name` 的所有权已转移给哈希映射
println!("{}", age); // 正确,`age` 是 i32,实现了 Copy

如代码所示,插入后我们不能再使用变量 name,因为它的所有权已经转移。但变量 age 仍然有效。

我们也可以将值的引用插入哈希映射,这样值本身就不会被移动。但这要求引用所指向的数据的生命周期至少要和哈希映射一样长,这涉及到生命周期的知识,我们将在后续课程中详细讲解。

总结

本节课中我们一起学习了 Rust 的哈希映射。我们了解了如何创建和初始化哈希映射,如何通过键访问值(包括安全地处理不存在的键),以及如何遍历所有条目。最重要的是,我们探讨了哈希映射与所有权系统的交互:对于可复制的类型,值被复制;对于拥有所有权的类型,值被移动,哈希映射取得所有权。掌握这些知识是有效使用 Rust 集合类型的关键。

057:哈希映射的更新操作 🔄

在本节课中,我们将学习如何更新 Rust 中的哈希映射(HashMap)。哈希映射是一种存储键值对的数据结构,每个键必须是唯一的。我们将探讨几种不同的更新方法,包括直接覆盖、条件插入以及基于旧值更新。


上一节我们介绍了如何创建哈希映射,本节中我们来看看如何更新它。

首先,哈希映射中的每个键值对都必须拥有唯一的键。不能存在两个完全相同的键,因为后一个会覆盖前一个。更新哈希映射数据有多种方法。

让我们从第一种方法开始学习。

方法一:直接插入(覆盖)

对于这个示例,我们将创建一个哈希映射。以下是创建和插入的代码:

let mut items = HashMap::new();
items.insert(String::from("cup"), 10);
println!("{:?}", items);

这段代码创建一个 HashMap,键的类型是 String,值的类型是 i32。然后插入一个键为 "cup",值为 10 的键值对。运行后会输出 {"cup": 10}

现在,如果我们再次插入相同的键:

items.insert(String::from("cup"), 25);
println!("{:?}", items);

由于哈希映射中不能有重复的键,这次插入会完全覆盖之前的键值对。输出将变为 {"cup": 25}

方法二:条件插入(Entry API)

如果你想仅在键不存在时才插入键值对,哈希映射提供了一个特殊的 API,叫做 entry。它接收你想要检查的键作为参数。

以下是使用 entry API 的示例:

let mut items = HashMap::new();
items.insert(String::from("cup"), 10);

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/e9c3714c1e351e5b13e76a79e812b9fe_7.png)

items.entry(String::from("cup")).or_insert(20);
items.entry(String::from("spoon")).or_insert(20);

println!("{:?}", items);

entry 方法检查键是否存在。or_insert 方法仅在键不存在时插入提供的值。

  • 对于键 "cup",由于它已存在(值为10),or_insert(20) 不会更新其值。
  • 对于键 "spoon",由于它不存在,or_insert(20) 会将其插入,值为20。

因此,最终输出是 {"cup": 10, "spoon": 20}

or_insert 方法返回一个指向该键对应值的可变引用。这意味着你可以使用这个引用来修改值。

let value = items.entry(String::from("cup")).or_insert(20);
println!("{:?}", value); // 输出 10

方法三:基于旧值更新

哈希映射另一个常见用例是查找一个键的值,并基于旧值进行更新。例如,统计一段文本中每个单词的出现次数。

以下是实现单词计数器的代码:

let text = "Bob says: Bob said that. Bob said that Bob. Bob, bob didn't say anything";
let mut map = HashMap::new();

for word in text.split_whitespace() {
    let count = map.entry(word.to_lowercase()).or_insert(0);
    *count += 1;
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/e9c3714c1e351e5b13e76a79e812b9fe_20.png)

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

让我们分解这段代码:

  1. text.split_whitespace():将字符串按空白字符分割,得到一个单词迭代器。
  2. map.entry(word.to_lowercase()).or_insert(0):对于每个单词(转换为小写以忽略大小写差异),检查它是否已在映射中。如果不存在,则插入该键,并将值初始化为0。entry 方法返回一个指向该值的可变引用(count)。
  3. *count += 1:通过解引用操作符 *,我们增加该单词的计数。

运行此代码,会输出一个包含每个单词及其出现次数的哈希映射。

重要说明:此代码仅为演示目的。一个健壮的单词计数器还需要处理标点符号等问题(例如 "says:""says" 会被视为不同的单词)。

关于哈希函数

默认情况下,Rust 的 HashMap 使用一个名为 SipHash 的哈希函数。它能提供对涉及哈希表的拒绝服务(DoS)攻击的抵抗力。这不是最快的哈希算法,但用一定的性能代价换取更好的安全性通常是值得的。

如果你分析代码后发现默认的哈希函数对你的用途来说太慢,可以通过指定不同的哈希器(hasher)来切换到另一个函数。哈希器是实现了 BuildHasher trait 的类型。你不需要从头实现自己的哈希器,crates.io 上有其他 Rust 用户共享的库,提供了实现许多常见哈希算法的哈希器。


本节课中我们一起学习了更新 Rust 哈希映射的三种主要方法:直接覆盖插入、使用 Entry API 进行条件插入,以及基于旧值更新(常用于计数场景)。我们还了解了 HashMap 默认使用的 SipHash 哈希函数及其在安全与性能间的权衡。掌握这些更新技巧,能让你更灵活地使用哈希映射来管理数据。

058:哈希集合(HashSet)🚀

在本节课中,我们将要学习 Rust 中的哈希集合(HashSet)。这是一种非常有用的数据结构,它允许我们存储一组唯一的值,自动去除所有重复项。

导入与创建 HashSet

要使用哈希集合,首先需要从标准库中导入它。

use std::collections::HashSet;

与哈希映射(HashMap)类似,我们可以使用 new 函数来创建一个新的空集合。

let mut numbers: HashSet<i32> = HashSet::new();

插入元素与自动去重

我们可以使用 insert 方法向集合中添加元素。哈希集合的核心特性是自动去重,任何重复的值都不会被存储。

numbers.insert(10);
numbers.insert(20);
numbers.insert(10); // 这个重复的 10 不会被加入集合
println!("{:?}", numbers); // 输出:{10, 20}

可以看到,即使我们插入了两次 10,集合中最终也只包含一个 10

使用 from 函数初始化

如果我们在创建集合时就已经知道初始值,可以使用 HashSet::from 函数来初始化,这样更简洁。

let numbers = HashSet::from([10, 20, 10]); // 重复的 10 会被自动移除
println!("{:?}", numbers); // 输出:{10, 20}

常用方法

接下来,我们看看对哈希集合进行操作的几个常用方法。我们将创建一个包含多个数字的集合作为示例。

let numbers = HashSet::from([1, 2, 2, 3, 4, 5]); // 重复的 2 会被移除
println!("{:?}", numbers); // 输出:{1, 2, 3, 4, 5}

检查元素是否存在

使用 contains 方法可以检查某个值是否存在于集合中。它接受一个引用作为参数,以避免不必要的值拷贝。

println!("{}", numbers.contains(&2)); // 输出:true
println!("{}", numbers.contains(&99)); // 输出:false

contains 方法支持灵活的查找。例如,即使集合的类型是 String,我们也可以传入一个字符串切片(&str)进行查找。

let mut users: HashSet<String> = HashSet::new();
users.insert(String::from("Bob"));
users.insert(String::from("James"));
println!("{}", users.contains("Bob")); // 输出:true

移除元素

使用 remove 方法可以从集合中移除指定的元素。该方法会返回一个布尔值,指示该元素是否被成功移除(即它原先是否存在)。

let mut numbers = HashSet::from([1, 2, 3, 4, 5]);
let was_present = numbers.remove(&99); // 尝试移除一个不存在的值
println!("{}", was_present); // 输出:false
println!("{:?}", numbers); // 集合不变

let was_present = numbers.remove(&1); // 移除存在的值
println!("{}", was_present); // 输出:true
println!("{:?}", numbers); // 输出:{2, 3, 4, 5}

获取集合大小与清空

以下是获取集合信息和管理集合的常用方法。

let numbers = HashSet::from([1, 2, 3, 4, 5]);

// 获取集合长度(元素个数)
println!("{}", numbers.len()); // 输出:5

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/0287554462730bcaa448b148b94f1606_22.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/0287554462730bcaa448b148b94f1606_23.png)

// 检查集合是否为空
println!("{}", numbers.is_empty()); // 输出:false

// 清空集合
let mut mutable_numbers = numbers.clone();
mutable_numbers.clear();
println!("{:?}", mutable_numbers); // 输出:{}

扩展集合与抽取元素

extend 方法可以方便地将一个可迭代对象中的所有元素添加到集合中。drain 方法则会清空集合并返回所有被移除的元素。

let mut numbers = HashSet::new();
// 使用 extend 添加多个元素
numbers.extend([1, 2, 3, 4]);
println!("{:?}", numbers); // 输出:{1, 2, 3, 4}

// 使用 drain 抽取所有元素到一个向量中
let drained: Vec<i32> = numbers.drain().collect();
println!("{:?}", drained); // 输出:[1, 2, 3, 4] (顺序可能不同)
println!("{:?}", numbers); // 输出:{},集合已空

遍历集合

我们可以使用 for 循环来遍历哈希集合中的所有元素。需要注意的是,哈希集合不保证元素的存储顺序,每次遍历的顺序可能不同。

let users = HashSet::from(["Bob", "James", "Sandra"]);
for user in &users {
    println!("Hello, {}!", user);
}
// 可能的输出顺序(每次运行可能不同):
// Hello, Sandra!
// Hello, James!
// Hello, Bob!

集合运算

哈希集合支持标准的数学集合运算,如并集、交集、差集和对称差集。

首先,我们创建两个示例集合。

let hs1 = HashSet::from([1, 2, 3]);
let hs2 = HashSet::from([2, 3, 4]);

并集 (Union)

并集包含两个集合中的所有唯一元素。

let union: HashSet<&i32> = hs1.union(&hs2).collect();
println!("并集: {:?}", union); // 输出:{1, 2, 3, 4}

交集 (Intersection)

交集包含同时存在于两个集合中的元素。

let intersection: HashSet<&i32> = hs1.intersection(&hs2).collect();
println!("交集: {:?}", intersection); // 输出:{2, 3}

差集 (Difference)

差集包含存在于第一个集合但不存在于第二个集合中的元素。注意:差集的顺序很重要

let diff1: HashSet<&i32> = hs1.difference(&hs2).collect();
println!("hs1 - hs2: {:?}", diff1); // 输出:{1}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/0287554462730bcaa448b148b94f1606_31.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/0287554462730bcaa448b148b94f1606_32.png)

let diff2: HashSet<&i32> = hs2.difference(&hs1).collect();
println!("hs2 - hs1: {:?}", diff2); // 输出:{4}

对称差集 (Symmetric Difference)

对称差集包含只存在于其中一个集合中,而不同时存在于两个集合中的元素。

let sym_diff: HashSet<&i32> = hs1.symmetric_difference(&hs2).collect();
println!("对称差集: {:?}", sym_diff); // 输出:{1, 4}

本节课中我们一起学习了 Rust 的哈希集合(HashSet)。我们了解了如何创建和初始化集合,掌握了插入、检查、移除元素等基本操作,并学习了如何获取集合信息、遍历元素。最后,我们探索了强大的集合运算功能,包括并集、交集、差集和对称差集。哈希集合是处理需要保证元素唯一性场景的利器,希望你能在未来的项目中灵活运用它。

059:Rust 中的包和 Crate 详解 📦

在本节课中,我们将学习如何在 Rust 中更好地组织代码,核心概念是Crate。随着项目规模增长,将所有代码放在单个文件中会变得难以管理。Rust 的模块系统提供了强大的工具来组织代码、控制可见性和复用功能。

概述

在开始构建大型 Rust 项目前,有必要学习如何使用包、Crate 和模块来更好地组织代码。虽然我们可以将所有代码写在一个包含数千行的文件中,但这会很快变得难以管理。理想情况下,随着项目规模增长,我们应该将代码拆分到多个模块和文件中,将相关功能分组在一起。我们还将讨论如何封装实现细节,以便在更高层次上复用代码。这意味着,一旦我们实现了一个操作,其他代码可以通过其公共接口调用我们的代码,而无需了解其内部实现。

Rust 有几个特性来管理代码的组织结构,包括哪些细节是公开的、哪些是私有的,以及程序中每个作用域内的命名。这些系统有时统称为模块系统,它包括:

  • :一个 Cargo 功能,让你可以构建、测试和共享 Crate。
  • Crate:一个树形模块结构,可以生成库或可执行文件。
  • 模块use:让你控制路径的组织、作用域和私有性。
  • 路径:一种命名项(如结构体、函数或模块)的方式。

在接下来的几节中,我们将首先讨论包和 Crate。

什么是 Crate?📁

一个 Crate 是 Rust 编译器一次处理的最小代码单位。

即使我们决定使用 rustc 而不是 cargo 来编译,并传入一个单独的代码文件,编译器也会将该文件视为一个 Crate。

例如,我们可以尝试通过输入 rustcsrc/main.rs 来编译代码。

rustc src/main.rs

这将创建一个可执行文件。如上图所示,在文本上方,我们生成了 main 文件。要运行它,只需输入 ./main

./main

这样,我们就成功运行了代码。此外,Crate 可以包含模块,这些模块可能定义在其他文件中,并与 Crate 一起编译,我们稍后会看到这一点。

Crate 的两种形式

一个 Crate 可以有两种形式:二进制 Crate库 Crate

  • 二进制 Crate 是可以编译成可执行文件的程序,例如命令行工具或服务器。每个二进制 Crate 必须有一个名为 main 的函数,用于定义可执行文件运行时的行为。我们目前创建的所有 Crate 都是二进制 Crate。
  • 库 Crate 则没有 main 函数,也不会编译成可执行文件。相反,它们定义了旨在多个项目中共享的功能。

库 Crate 的一个例子是 rand crate,我们用它来生成随机数。要将这个 Crate 添加到我们的项目中,我们需要输入 cargo add rand。如果它存在,它会立即添加;否则可能会加载一会儿。添加后,我们就可以使用它了。

我们可以输入 use rand::Rng,然后创建一个名为 roll 的函数来生成一个随机骰子点数。

use rand::Rng;

fn roll() {
    let mut rng = rand::thread_rng();
    let roll = rng.gen_range(1..=6);
    println!("You rolled a {}", roll);
}

这里,我们创建了一个数字生成器,并让 roll 等于 rng.gen_range(1..=6),其中 6 是包含的。我们要做的就是打印一行,显示你掷出的点数。

要运行这段代码,我们只需调用 roll() 函数。下次运行时,我们应该会得到不同的点数。第一次运行时,我连续掷出了三个 3,起初我以为是 bug,但只是运气很好。如你所见,第二次运行时,我得到了不同的数字。




rand crate 提供了生成随机数的功能。需要注意的是,大多数时候当 Rustaceans 提到“crate”时,他们指的是库 Crate,这个术语与通用编程概念中的“库”可以互换使用。

Crate 根是 Rust 编译器开始处理的源文件,它构成了 Crate 的根模块。当我们讲到模块部分时,会深入探讨这一点。

什么是包?📦

接下来,让我们谈谈包。一个是一个或多个提供一组功能的 Crate 的集合。

每个包都包含一个 Cargo.toml 文件,它告诉 Rust 如何构建这些 Crate。

一个包可以包含任意多个二进制 Crate,但最多只能包含一个库 Crate。每个包必须至少包含一个 Crate,无论是二进制 Crate 还是库 Crate。

以下是当我们使用 cargo new 创建一个新包时发生的情况。我们将传入一个项目名 new_project

cargo new new_project

然后,我们输入 ls new_project 来列出该项目中存在的所有文件和目录。

此时,我们只有一个 Cargo.toml 文件和一个 src 目录。由于这个项目有 Cargo.toml 文件,它正式成为一个包。我们甚至可以导航到这个 new_project 目录以便直观地查看。

如你所见,我们有 Cargo.toml.gitignore(在此上下文中不重要)和 src 目录。在 src 目录中,你会看到一个 main.rs 文件。这个文件是二进制 Crate 的起点,它以包的名字命名。

如果在 src 目录中有一个名为 lib.rs 的文件,Cargo 会将其视为库 Crate。一个包可以同时包含这两者。它还可以通过在 src/bin 目录中放置文件来拥有多个二进制 Crate。

例如,如果我们要在这里创建一个新文件夹或新目录,我们可以输入 bin

mkdir src/bin

在里面,我们可以添加一些其他的二进制文件,例如 example.rs。这里的每个文件都会成为其自己的二进制 Crate。



总结

本节课我们一起学习了 Rust 中用于代码组织的核心概念:Crate

  • Crate 是编译的最小单位,分为二进制 Crate(可执行程序)和库 Crate(共享功能代码)。
  • 通过 Cargo.toml 文件管理一个或多个 Crate,一个包最多包含一个库 Crate,但可以有多个二进制 Crate。
  • 项目的入口文件:src/main.rs 是二进制 Crate 的根,src/lib.rs 是库 Crate 的根,额外的二进制 Crate 可以放在 src/bin/ 目录下。

我知道今天的视频涵盖了很多术语,一开始听起来可能很令人困惑,但我保证随着时间的推移,它会变得越来越清晰。这需要一点时间来适应。作为一个来自 Python 背景的人,我也在努力理解这些概念,所以不用担心,我们会一起弄明白的。理解这些基础是构建结构良好、可维护的 Rust 项目的关键。

060:Rust 模块系统入门 🏦

在本节课中,我们将开始学习 Rust 中的模块(modules)以及其他模块系统的组成部分,以更好地理解模块的工作原理。我们将通过创建一个模拟银行账户的小项目来实践,该项目将允许你创建账户、存款、取款并向客户发布重要公告。

项目结构与模块创建

我们将从创建第一个模块开始。首先,在 src 目录下创建一个名为 bank.rs 的新文件。接着,我们将为 bank 模块的子模块创建一个目录。

这个目录应命名为 bank。虽然目录名不必与模块名完全一致,但强烈建议这样做。如果使用不同的名称,将两者关联起来会复杂得多。我们将在后续课程中讨论这一点。目前,请确保两者名称完全相同,这样 bank.rs 文件才能知道它可以在 bank 目录中找到更多功能。

bank 目录中,我们将创建两个文件:accounts.rstransactions.rs。这些是隶属于 bank.rs 的子模块。

定义账户子模块

现在,让我们进入 accounts 子模块。我们将创建一个公开的结构体(pub struct),名为 Account。这个结构体将包含一个公开的 owner 字段(类型为 String)和一个公开的 balance 字段(类型为 i32)。为了便于调试,我们还将为其派生(derive)Debug 特征。

接下来,为这个结构体创建实现(impl)。在实现中,我们将创建一个公开的 new 函数,它接收一个类型为字符串切片(&str)的 owner 参数,并返回 Self(即 Account 实例)。在函数内部,我们初始化并返回一个 Account 实例,其中 owner 字段由传入的参数转换而来,balance 字段初始化为 0

我们定义这个 new 方法为公开的(pub),以便其他模块能够看到并使用它。换句话说,我们将其暴露给外部世界。如果你尝试移除 pub 关键字,稍后会发现 new 函数将无法在此模块外部工作。

定义交易子模块

接下来,我们进入 transactions 子模块。由于我们想在这里使用 accounts 模块的功能,需要先导入它。

为此,我们使用 use 关键字,后跟 cratecrate 部分指定了这是一个绝对路径,意味着你基本上可以在任何文件中包含它,效果相同。所以,我们写 use crate::bank::accounts::Account;

由于本课更侧重于语言的模块方面,我将直接粘贴代码,但会解释其作用。我们再次创建了一些希望在整个程序中公开的公共函数,即 deposit(存款)和 withdraw(取款)。deposit 函数允许我们向账户存入资金并打印当前余额。withdraw 函数允许我们从账户取款,并在尝试取款前检查用户是否有足够资金。同样重要的是,我们使用了 pub 关键字,因为我们希望能在文件外部使用这两个函数。

在父模块中声明子模块

现在,是时候回到我们的 bank 模块(bank.rs 文件)了。在这里,你可以声明哪些子模块和功能要对程序公开。

由于我们创建了一个名为 bank 的目录,Rust 默认会在这里寻找我们刚刚创建的子模块。这完全是因为我们的模块文件叫 bank.rs。再次强调,目录命名为 bank 并非巧合,我是有意为之,因为它能很好地将两者关联起来。

bank.rs 中,我们需要输入 pub mod accounts;pub mod transactions;。这将把我们在子模块中创建的功能包含进 bank 模块,以便我们能在 main.rs 中使用。

你并非必须创建子模块,完全可以直接在 bank.rs 中创建所有功能。但我想展示的是,如果你想进一步拆分功能,可以使用子模块来实现。

bank.rs 中,我将保留一个函数,名为 announce,它允许我们发布公告。同样,这个函数是公开的,因为我们希望在文件外部使用它。

在主程序中使用模块功能

现在我们已经拥有了所有功能,让我们尝试在 main.rs 中使用它。

首先,要使用 bank 模块的功能,我们需要输入 mod bank; 来声明使用该模块。

然后,我们可以创建一个可变的账户:let mut account = bank::accounts::Account::new("Bob");,并打印出我们创建了这个账户。

要使用模块中的功能,我们只需输入 bank::transactions::deposit(&mut account, 150);,这里我们存入了 150 单位货币(假设是欧元)。接着,我们复制这行代码并修改为取款 20:bank::transactions::withdraw(&mut account, 20);

然后,我想打印账户的最终状态。最后,为了展示我们可以直接使用 bank 模块中的功能,我将调用 bank::announce("There is some maintenance at 1:30 PM.");

运行程序后,我们应该得到以下消息:首先显示我们为 Bob 创建了一个账户;然后显示存入了 150(货币单位);接着 Bob 取款 20,新余额为 130;然后打印账户的最终状态,显示所有者仍是 Bob,余额为 130;最后还有一条银行公告,通知下午 1:30 将有维护。

理解公有与私有

现在让我们回到模块,这次我将移除 announce 函数的 pub 部分,看看会发生什么。一旦我移除 pub 并回到 main.rs,你会发现我们无法再使用 announce,因为它现在被视为私有的。默认情况下,你创建的所有功能都被认为是私有的,除非你明确指定其为公开。

因此,如果我们希望 main.rs 文件能看到这个函数,就必须将其设为公开。对于子模块也是如此,如果我们移除 transactions 模块声明前的 pubmain.rs 将无法看到它,因为 transactions 现在是私有的。所以我们需要确保它是公开的。

如果我们再深入一层,进入 transactions.rs 文件,移除 deposit 函数前的 pub,我们仍然会在 main.rs 中得到错误,因为这个函数不再是公开的,它只能在 transactions 文件内部使用。如果我们希望在文件外部访问它,就需要将其设为公开。注意,关键字是 pub,而不是 public

模块与目录的命名关联

最后,还有一点我想在今天说明。再次强调,如果你将这个目录命名为其他名称,将 bank 模块与目录关联起来会困难得多。例如,让我们将其重命名为 extra。一旦这样做,Rust 将很难找到 extra,我们将不得不使用一些特殊的语法来定位它。我们不能仅仅输入 pub mod accounts;,因为 Rust 会不知道它在哪里。

但是,如果我们将其命名为与模块完全相同的名称(即 bank),Rust 就能将两者联系起来,理解 bank 目录属于 bank 模块,从而轻松找到这些子模块。当然,你并非必须将其命名为 bank,我将在后续课程中教你如果将其命名为 extra 之类的名称,该如何引用它。

总结

本节课中,我们一起学习了 Rust 模块系统的基础知识。我们创建了一个银行模拟项目,实践了如何通过创建 bank.rs 文件和对应的 bank/ 目录来组织模块。我们定义了 accountstransactions 两个子模块,并在其中创建了公开的结构体和函数。我们了解了如何使用 pub 关键字控制模块、结构体和函数的可见性,以及模块文件与目录的命名约定如何影响 Rust 查找子模块。最后,我们在 main.rs 中成功使用了这些模块功能,完成了存款、取款和发布公告的操作。理解模块的公有与私有边界是构建清晰、可维护 Rust 程序的关键。

061:如何在 Rust 中使用 lib.rs 📚

在本节课中,我们将深入学习 Rust 中的模块系统,并创建我们的第一个库。我们将探讨模块定义的另一种方式,并构建一个包含账户和交易功能的银行系统库。

概述

上一节我们介绍了如何在 Rust 中创建模块。本节中,我们将进一步讨论模块,并学习如何在 Rust 中创建和使用库。我们将创建一个名为 lib.rs 的库文件,作为项目的入口点,将相关功能组织在一起供其他模块使用。

模块定义的另一种方式

之前我们学习了通过创建 bank.rs 文件和 bank 目录来定义模块。这是 Rust 文档中推荐的方式。然而,Rust 还提供了另一种定义模块的方法。

以下是另一种方法的具体步骤:

  1. bank.rs 文件重命名为 mod.rs
  2. mod.rs 文件移动到 bank 目录内部。

此时,main.rs 文件会报错,因为它找不到名为 bank 的模块。这是因为模块的入口点现在变成了 bank 目录下的 mod.rs 文件。Rust 会将 mod.rs 识别为所在目录的模块入口点。

这两种方式(bank.rsbank/mod.rs)在功能上完全等效,你可以根据项目结构和个人偏好选择使用哪一种。

创建第一个 Rust 库

现在,让我们开始创建第一个库。首先,清理之前的代码,并重置 main.rs 文件。

src 目录下,创建一个名为 lib.rs 的新文件。我们将在这里构建我们的银行系统库。库的目的是将相关的功能组织在一起,以便在其他地方复用,就像我们使用 rand crate 来生成随机数一样。

首先,我们创建顶层的模块,命名为 banking。这是我们的库的起点。

pub mod banking {

我们需要使用 pub 关键字将其公开,以便其他文件可以访问这个模块。

创建账户模块

banking 模块内部,我们创建第一个子模块 accounts

    pub mod accounts {

accounts 模块中,我们定义一个公开的结构体 Account,用于表示银行账户。

        pub struct Account {
            pub account_number: u32,
            pub balance: f64,
        }

接下来,我们创建一个公开的构造函数 open_account,用于创建新的 Account 实例。

        pub fn open_account(account_number: u32) -> Account {
            Account {
                account_number,
                balance: 0.0,
            }
        }

然后,我们创建一个私有函数 close_account,用于模拟关闭账户的操作。由于没有使用 pub 关键字,这个函数只能在 accounts 模块内部使用。

        #[allow(dead_code)]
        fn close_account(account: Account) {
            // 模拟执行关闭账户的危险操作
        }
    }

将某些功能(如关闭账户)设为私有是一种良好的实践,可以防止库的用户意外执行危险操作。

创建交易模块

accounts 模块下方,我们创建另一个子模块 transactions

    pub mod transactions {

为了在 transactions 模块中使用 accounts 模块中定义的 Account 结构体,我们需要将其引入作用域。这里使用 use super::accounts::Account; 语法。关键字 super 允许我们引用当前模块上一层级(即 banking 模块)中定义的功能。

        use super::accounts::Account;

现在,我们可以在 transactions 模块中创建功能函数。首先是 deposit 函数,用于向指定账户存款。

        pub fn deposit(account: &mut Account, amount: f64) {
            account.balance += amount;
            println!("存入 ${}。新余额:${}", amount, account.balance);
        }

接下来是 withdraw 函数,用于从指定账户取款。在取款前,我们需要检查账户余额是否充足。

        pub fn withdraw(account: &mut Account, amount: f64) {
            if account.balance >= amount {
                account.balance -= amount;
                println!("取出 ${}。新余额:${}", amount, account.balance);
            } else {
                println!("余额不足。无法取出 ${}。当前余额:${}", amount, account.balance);
            }
        }

最后,我们创建 transfer 函数,用于在两个账户之间转账。同样,在转账前需要检查源账户的余额。

        pub fn transfer(from: &mut Account, to: &mut Account, amount: f64) {
            if from.balance >= amount {
                from.balance -= amount;
                to.balance += amount;
                println!("从账户 {} 向账户 {} 转账 ${}。", from.account_number, to.account_number, amount);
            } else {
                println!("账户 {} 余额不足,无法完成 ${} 的转账。", from.account_number, amount);
            }
        }
    }
}

至此,我们完成了 lib.rs 库文件的编写。lib.rs 是库项目的默认入口点,用于公开你想让整个项目使用的功能。

main.rs 中使用库

库创建完成后,我们可以在 main.rs 文件中使用它。首先,需要通过 use 语句将库引入作用域。你需要使用你的项目名称(在 Cargo.toml[package] 部分定义)作为路径的起点。

use main::banking::{accounts, transactions};

假设项目名为 main,上述代码从 banking 库中导入了 accountstransactions 模块。

现在,我们可以使用库中定义的功能。首先,为 James 和 Bob 各创建一个账户。

fn main() {
    let mut account_james = accounts::open_account(1);
    let mut account_bob = accounts::open_account(2);

    println!("账户创建成功:{:?}, {:?}", account_james, account_bob);
}

运行程序,输出显示两个账户的初始余额均为 0。

接下来,执行一系列交易操作:

  1. 向 James 的账户存入 200 美元。
  2. 从 James 的账户取出 50 美元。
  3. James 向 Bob 的账户转账 100 美元。

    transactions::deposit(&mut account_james, 200.0);
    transactions::withdraw(&mut account_james, 50.0);
    transactions::transfer(&mut account_james, &mut account_bob, 100.0);

    println!("交易后账户状态:{:?}, {:?}", account_james, account_bob);

运行完整的程序,输出将逐步显示:

  • 两个账户的初始余额为 0。
  • 向 James 存款 200 美元后,其余额变为 200 美元。
  • 从 James 取款 50 美元后,其余额变为 150 美元。
  • James 向 Bob 转账 100 美元后,James 的余额变为 50 美元,Bob 的余额变为 100 美元。

所有这些操作都是通过我们创建的 banking 库完成的,该库暴露了所有可用的银行功能。

总结

本节课中我们一起学习了:

  1. Rust 中定义模块的另一种方式:使用目录内的 mod.rs 文件作为入口点。
  2. 如何创建 Rust 库:在 src/lib.rs 文件中组织模块和功能。
  3. 使用 pub 关键字控制模块和项的可见性。
  4. 使用 use super::... 语法引用父模块中的项。
  5. 在二进制包(src/main.rs)中通过项目名引入并使用自定义库的功能。

通过构建一个简单的银行系统库,我们实践了如何将代码组织成可复用的模块化组件,这是构建大型 Rust 项目的基础。

062:泛型函数 🧬

在本节课中,我们将开始学习 Rust 中的泛型。首先,我们会了解如何在函数中使用泛型,然后是结构体和枚举。我们还将讨论泛型对代码性能的影响。

泛型解决的问题

在深入泛型之前,我们先快速看一下它们旨在解决的问题。假设你想创建一个函数,用于从列表中获取第一个整数。

这并不难,我们可以通过创建以下函数来实现:

fn get_first_int(list: &[i32]) -> &i32 {
    &list[0]
}

我们将创建一个名为 get_first_int 的函数,它接收一个整数切片作为参数,并返回一个对 i32 的引用。在函数内部,我们只需返回列表的第一个元素。

要使用这个函数,我们需要创建一些数字:

fn main() {
    let numbers = vec![1, 2, 3];
    println!("{:?}", get_first_int(&numbers));
}

运行此代码,我们将得到列表中的第一个整数,即 1

现在,假设我们也想从列表中获取第一个字符。这也不难,但我们必须为此编写一个全新的函数:

fn get_first_char(list: &[char]) -> &char {
    &list[0]
}

我们复制并粘贴了上面的函数,将其重命名为 get_first_char,并将参数和返回类型从 i32 改为 char

然后,我们可以在主函数中使用它:

fn main() {
    let characters = vec!['A', 'B', 'C'];
    println!("{:?}", get_first_char(&characters));
}

运行此代码,我们将得到字符向量中的第一个字符 'A'

这两个函数都能正常工作,但我们违反了编程的一个基本原则:我们毫无理由地重复了代码。这两个函数都包含完全相同的逻辑,用于从可迭代对象中获取第一个元素。现在想象一下,如果你想从列表中获取第一个浮点数或布尔值,你真的要为每个操作都创建一个专门的函数吗?当然不会,除非你是按小时计费、讨厌你的老板并且没有任何截止日期。

使用泛型解决代码重复问题

接下来,我们来看看如何使用泛型来解决我们遇到的代码重复问题。

我们将保留上述所有代码,以便与即将创建的新函数进行比较。现在,我们来创建一个泛型函数:

fn get_first<T>(list: &[T]) -> &T {
    &list[0]
}

在尖括号 < > 中,我们指定一个泛型类型,将其设置为 T。这是一个常见的泛型命名约定,T 代表类型。接下来,我们创建列表变量,它将是泛型类型 T 的切片,并返回对该类型值的引用。

函数内部,我们执行与其他两个函数相同的操作:返回此可迭代对象的第一个元素。

现在,创建完成后,我们可以移除之前那两个专门的函数,代码将以完全相同的方式工作:

fn main() {
    let numbers = vec![1, 2, 3];
    let characters = vec!['A', 'B', 'C'];
    println!("{:?}", get_first(&numbers));
    println!("{:?}", get_first(&characters));
}

如你所见,我们使用泛型和单个函数从两个向量中获取了第一个元素。这意味着我们不再需要另外两个函数了。

请注意,这个泛型函数远非完美,如果列表为空,它将失败。我创建它只是为了演示泛型的工作原理。另外,你可能也注意到了 T 旁边的内联类型提示,这与生命周期有关,我们将在后续课程中介绍,现在不必担心。请专注于本视频中关于泛型的部分。

带约束的泛型函数示例

现在,让我们看另一个使用泛型的例子。这次我们将创建一个处理方式更恰当的函数,并为泛型类型添加约束。

这些约束允许我们在泛型函数中更具体。例如,我们可以创建一个 largest 函数,并通过定义 T 具有 PartialOrd 特征来指定 T 使用该特征。通过定义这个特征,我们告诉 Rust 元素必须是可比较的,例如整数、浮点数和字符。如果不包含它,Rust 会建议我们使用它,以便可以在类型 T 上使用比较运算符。稍后创建完函数后,我会展示移除它会发生什么。

现在,我们开始创建函数:

fn largest<T: PartialOrd>(list: &[T]) -> Option<&T> {
    if list.is_empty() {
        return None;
    }
    let mut largest = &list[0];
    for item in list.iter() {
        if item > largest {
            largest = item;
        }
    }
    Some(largest)
}

这次,我们尝试创建一个更健壮的函数。首先,检查列表是否为空,如果为空则返回 None,因为没有最大值。然后,将列表的第一个元素设为初始最大值。如果可迭代对象只包含一个值,它将返回这个值,因为这是唯一的值,也就是最大值。接着,遍历列表,检查当前项是否大于当前最大值,如果是,则更新最大值。最后,返回 Some(largest)

如前所述,这里的约束允许我们在类型 T 上使用比较运算符。因为没有它,Rust 将不知道我们在这里试图比较什么。毕竟,在没有约束的情况下,T 可以是任何东西。但通过添加这个约束,我们告诉 Rust 这必须是一个可以与比较运算符一起使用的类型。如果你将鼠标悬停在 PartialOrd 上,你会看到它是一个用于形成偏序的类型的特征,这意味着我们与泛型类型一起使用的类型必须适用于这些比较运算符。

定义了这个函数后,我们可以回到主函数并尝试使用它:

fn main() {
    let characters = vec!['A', 'B', 'C', 'Z'];
    let numbers = vec![1, 2, 3];
    println!("{:?}", largest(&characters));
    println!("{:?}", largest(&numbers));
}

我们将再次创建一些字符和数字,然后调试输出 largest 函数应用于字符和数字的结果。我们会注意到,它对字符和整数都完美工作:'Z' 是最大的字符,3 是最大的整数。我们能够使用单个函数找出最大的整数,这非常棒,因为它意味着更少的代码重复。即使这些是浮点数,例如 1.54.6,我们的代码仍然可以运行。

总结

本节课中,我们一起学习了 Rust 中的泛型函数。我们首先看到了没有泛型时,为不同类型编写相同逻辑函数导致的代码重复问题。然后,我们学习了如何使用泛型类型参数 T 来创建通用的函数,从而消除重复。最后,我们探讨了如何通过特征约束(如 PartialOrd)来限制泛型类型,使其支持特定的操作(如比较),从而编写出更健壮、更灵活的泛型函数。泛型是 Rust 实现代码复用和类型安全的核心工具之一。

063:泛型结构体与枚举 🧬

在本节课中,我们将继续学习泛型,并了解如何在结构体(struct)和枚举(enum)中使用它们。泛型允许我们编写灵活且可重用的代码,而无需为每种数据类型重复编写逻辑。

上一节我们介绍了泛型函数,本节中我们来看看如何将泛型应用于自定义数据类型。

泛型结构体

结构体可以使用泛型来定义其字段的类型。例如,我们可能想创建一个具有两个字段 X 和 Y 的点。

以下是定义一个泛型 Point 结构体的方法:

#[derive(Debug)]
struct Point<T> {
    x: T,
    y: T,
}

我们使用尖括号 <T> 来声明一个名为 T 的泛型类型。结构体内部的字段 xy 都使用这个类型 T#[derive(Debug)] 是为了能够使用 println! 宏打印结构体。

现在,当我们创建 Point 实例时,可以使用任何类型,只要 xy 的类型相同。

以下是创建不同类型 Point 实例的示例:

fn main() {
    let p1 = Point { x: 1, y: 2 }; // T 被推断为 i32
    let p2 = Point { x: 1.5, y: 2.5 }; // T 被推断为 f64

    println!("{:?}, {:?}", p1, p2);
}

运行此代码将输出两个点:一个包含整数,另一个包含浮点数。这都归功于泛型。再次强调,两个字段的类型必须匹配,代码才能正常工作。我们不能插入 1.52,因为 2 是整数而 1.5 是浮点数。使用泛型时,你实际上是在约定这些位置必须是相同的类型。

多个泛型参数

如果你的代码需要多个泛型类型,可以在结构体中指定额外的泛型参数。

例如,我们可以让 xy 使用不同的类型:

#[derive(Debug)]
struct Point<T, U> {
    x: T,
    y: U,
}

按照惯例,我们使用 TU 作为泛型参数名(UT 之后的下一个字母)。现在,yU 类型。

以下是使用示例:

fn main() {
    let p1 = Point { x: 1.5, y: 2 }; // T 为 f64, U 为 i32
    let p2 = Point { x: 1, y: 2 }; // T 和 U 均为 i32

    println!("{:?}, {:?}", p1, p2);
}

在这个例子中,第一个点的泛型类型被推断为 f64i32,而第二个点则都是 i32

为泛型添加约束(Trait Bounds)

为结构体添加泛型类型虽然增加了灵活性,但有时可能过于灵活。理论上,我们可以插入任何类型,包括在“点”的上下文中没有意义的类型,比如字符串。

为了避免这种情况,我们可以为泛型类型添加约束(Trait Bounds),只允许特定的类型。例如,我们可以约束 Point 只接受数值类型。

首先,我们需要添加一个提供数值特质的 crate(例如 num)。在 Cargo.toml 中添加依赖:

[dependencies]
num = "0.4"

然后,我们可以修改结构体定义,为泛型 T 添加 Num 特质约束:

use num::Num;

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/0a97c3c1ebd3ed7a9257f5d5e1a4693c_17.png)

#[derive(Debug)]
struct Point<T: Num> {
    x: T,
    y: T,
}

通过 T: Num,我们为类型 T 添加了约束,意味着 T 必须实现 Num 特质(即必须是数值类型)。

现在,如果我们尝试使用非数值类型(如字符串或布尔值)创建 Point,Rust 编译器会报错:

fn main() {
    // 以下代码将无法编译
    // let p = Point { x: "hello", y: "world" }; // 错误:字符串不是数值类型
    // let p = Point { x: true, y: false }; // 错误:布尔值不是数值类型

    let p = Point { x: 1.5, y: 2.0 }; // 正确:浮点数是数值类型
    println!("{:?}", p);
}

这样,我们确保了 Point 结构体只用于有意义的数值类型,提高了代码的安全性和清晰度。

泛型枚举

枚举也可以使用泛型。一个经典的例子是标准库中的 Option<T>Result<T, E> 枚举。

让我们尝试重新创建 Option 枚举来理解其原理:

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

这个枚举有一个泛型类型 T。变体 Some 包含一个 T 类型的值,而 None 不包含任何值。

以下是一个使用示例,模拟检查网络连接:

fn main() {
    let connection = true;

    let result = if connection {
        MyOption::Some("Connected")
    } else {
        MyOption::None
    };

    // 注意:我们的 MyOption 没有实现 Debug,这里仅为演示逻辑。
    // 实际中可以使用标准库的 Option<T>,它已实现 Debug。
    // println!("{:?}", result);
}

在这个例子中,MyOption 现在是 &str 类型。如果我们传入一个整数,它就会变成 i32 类型。这就是枚举的泛型部分。

就像函数和结构体一样,枚举也可以有多个泛型参数。最常见的例子是 Result 枚举:

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

它包含两个泛型类型:T 代表成功时返回的值(Ok 变体),E 代表错误时返回的类型(Err 变体)。这对于可能成功或失败的操作非常方便。

总结

本节课中我们一起学习了如何在 Rust 的结构体和枚举中使用泛型。

  • 我们定义了泛型结构体 Point<T>,使其字段可以使用灵活但一致的类型。
  • 我们了解了如何使用多个泛型参数(如 Point<T, U>)来允许字段具有不同的类型。
  • 我们探讨了通过添加特质约束(如 T: Num)来限制泛型类型,确保代码的语义正确性和安全性。
  • 最后,我们查看了泛型在枚举中的应用,例如自定义的 MyOption<T> 和标准库中的 Result<T, E>,它们利用泛型优雅地处理了可能包含值或错误的情况。

泛型是编写强大、灵活且可重用 Rust 代码的核心工具之一,在自定义数据类型中应用泛型能极大地提升代码的抽象能力和适用性。

064:泛型在方法定义中的使用 🧬

在本节课中,我们将学习如何在 Rust 的方法定义中使用泛型。我们将创建一个泛型结构体,为其实现泛型方法,并探讨如何为特定类型添加约束以及使用多层泛型。最后,我们会了解 Rust 如何通过单态化来保证泛型代码的运行时性能。

创建泛型结构体与实现

上一节我们介绍了泛型的基本概念,本节中我们来看看如何为结构体定义泛型方法。

首先,我们创建一个名为 Point 的泛型结构体,它包含两个类型为 T 的坐标 xy

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

接下来,我们为这个泛型结构体实现一个方法。在 impl 块中,我们需要声明泛型参数 T,并将其与 Point<T> 关联。

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

这个方法名为 coordinates,它返回一个包含 xy 坐标引用的元组。

现在,我们可以创建两个使用不同具体类型的 Point 实例。

fn main() {
    let p1 = Point { x: 1, y: 2 }; // Point<i32>
    let p2 = Point { x: 21.5, y: 2.0 }; // Point<f64>

    println!("{:?}", p1.coordinates()); // 输出: (1, 2)
    println!("{:?}", p2.coordinates()); // 输出: (21.5, 2.0)
}

通过一个结构体和一个实现块,我们就能让 Point 独立地处理 i32f64 类型的数据。

为特定类型添加约束

有时,我们希望某些方法只对特定的类型可用。这可以通过为特定类型单独实现一个 impl 块来实现。

以下是为 Point<i32> 专门实现一个方法 i32_method 的示例。

impl Point<i32> {
    fn i32_method(&self) {
        println!("This method is only available for Point<i32>");
    }
}

现在,只有 p1(类型为 Point<i32>)可以调用 i32_methodp2(类型为 Point<f64>)则无法调用此方法。

p1.i32_method(); // 可以调用
// p2.i32_method(); // 这行代码会编译错误

在实现块中使用多层泛型

泛型不仅可以用在结构体定义上,还可以在方法中引入新的、独立的泛型参数。

假设我们想为 Point 添加一个方法,为坐标数据打上标签。这个方法需要一个新的泛型参数 L 来表示标签的类型。

impl<T> Point<T> {
    fn label<L>(self, label: L) -> (L, T, T) {
        (label, self.x, self.y)
    }
}

label 方法接收一个 self(消耗所有权)和一个任意类型的 label,返回一个包含标签和两个坐标的元组。

现在我们可以这样使用它:

fn main() {
    let p1 = Point { x: 1, y: 2 };
    let point_with_label = p1.label("coordinates"); // 标签类型为 &str
    println!("{:?}", point_with_label); // 输出: ("coordinates", 1, 2)

    // 也可以使用其他类型作为标签
    let p2 = Point { x: 3, y: 4 };
    let another_label = p2.label(10); // 标签类型为 i32
    println!("{:?}", another_label); // 输出: (10, 3, 4)
}

泛型的性能:单态化

你可能会担心使用泛型会影响程序的运行时性能。好消息是,在 Rust 中,使用泛型不会比使用具体类型(如 i32f64)运行得更慢。

Rust 通过在编译时进行 单态化 来实现这一点。单态化是一个将泛型代码转换为特定代码的过程,编译器会用实际使用的具体类型来填充泛型参数。

例如,考虑以下泛型函数:

use std::fmt::Display;

fn print<T: Display>(item: T) {
    println!("{}", item);
}

当我们在代码中这样调用它时:

print("Hello");
print(42);

编译器在编译时,会为实际用到的类型生成两个具体的函数:

// 编译器生成的代码(概念上)
fn print_for_str(item: &str) {
    println!("{}", item);
}
fn print_for_i32(item: i32) {
    println!("{}", item);
}

编译器只会生成代码中实际使用的那些类型的变体(例如 &stri32),而不会为所有可能的类型生成代码,从而消除了运行时开销。

总结

本节课中我们一起学习了 Rust 泛型在方法定义中的高级用法。
我们首先创建了一个泛型 Point 结构体并为其实现了通用方法。
接着,我们探讨了如何通过为特定类型(如 Point<i32>)单独实现 impl 块来添加类型约束。
然后,我们学习了如何在方法中引入新的、独立于结构体的泛型参数。
最后,我们了解了 Rust 如何通过编译时的单态化过程来保证泛型代码的零成本抽象,确保其运行时性能与使用具体类型编写的代码无异。

065:Trait 基础 🧩

在本节课中,我们将要学习 Rust 中一个非常强大的特性:Trait。Trait 用于定义一组可以被不同类型共享的方法签名,是实现多态行为的关键。我们将从如何定义和实现一个 Trait 开始。

什么是 Trait?

Trait 定义了一组方法签名,不同的类型可以实现这些方法。其目的是将多个类型可以共享的行为(方法签名)进行分组。

要定义一个 Trait,你需要使用 trait 关键字,后跟 Trait 的名称。你可以在定义前添加 pub 关键字,以允许其他模块依赖此 Trait。在 Trait 内部,你只需提供方法签名。

例如,我们可以定义一个名为 Summary 的 Trait,它包含一个 summarize 方法:

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

这里我们只提供了方法签名 summarize,它接收一个 &self 参数并返回一个 String。具体的类型将在后续实现中为这些方法提供函数体。一个 Trait 可以声明多个方法,但在此示例中我们只定义了一个。

为具体类型实现 Trait

上一节我们介绍了如何定义 Trait,本节中我们来看看如何为具体的类型实现它。

我们将创建两个结构体(struct)作为示例:

  • NewsArticle:包含标题、地点、作者和内容。
  • SocialPost:包含用户名、内容、回复和转发信息。

以下是这两个结构体的定义:

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

struct SocialPost {
    username: String,
    content: String,
    reply: bool,
    repost: bool,
}

接下来,我们可以为这些结构体实现 Summary Trait。

以下是实现 Summary Trait 的步骤:

  1. NewsArticle 实现 Trait
    我们使用 impl Summary for NewsArticle 语法。编译器会提示我们必须实现 Trait 中定义的所有方法(即 summarize)。我们实现一个 summarize 方法,返回一个包含标题、作者和地点的格式化字符串。

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

  1. SocialPost 实现 Trait
    同样地,我们为 SocialPost 实现 Summary Trait。这里我们实现 summarize 方法,返回一个包含用户名和内容的字符串。

    impl Summary for SocialPost {
        fn summarize(&self) -> String {
            format!("{}: {}", self.username, self.content)
        }
    }
    

现在,我们可以像使用普通方法一样使用这些结构体的 summarize 功能。首先创建结构体实例,然后调用其 summarize 方法:

fn main() {
    let article = NewsArticle {
        headline: String::from("Penguins Win the Stanley Cup Championship!"),
        location: String::from("Pittsburgh, PA, USA"),
        author: String::from("Iceburgh"),
        content: String::from("The Pittsburgh Penguins once again are the best hockey team in the NHL."),
    };

    let post = SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from("of course, as you probably already know, people"),
        reply: false,
        repost: false,
    };

    println!("{}", article.summarize());
    println!("{}", post.summarize());
}

运行此程序,你将看到两个结构体各自输出的摘要信息。Trait 要求我们实现特定的功能,之后我们就可以像平常一样使用这些结构体了。

提供默认方法实现

我们已经学会了如何为类型实现 Trait,现在让我们看看如何为 Trait 中的方法提供默认实现。

首先,我们修改 Summary Trait 的定义,为 summarize 方法提供一个默认实现:

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

这个默认实现返回字符串 "(Read more...)"。实现此 Trait 的类型可以选择保留这个默认实现,也可以选择性地覆盖它。默认实现甚至可以调用 Trait 中的其他方法(包括那些必须实现的方法)。

为了演示如何使用默认行为,我们创建两个新的结构体:

struct SimpleNote {
    content: String,
}

struct DetailedPost {
    username: String,
    content: String,
}

以下是实现 Summary Trait 的两种方式:

  1. 使用默认实现
    SimpleNote 实现 Summary 时,我们可以提供一个空的实现块 {}。由于 summarize 方法已有默认实现,我们无需再提供函数体。

    impl Summary for SimpleNote {}
    
  2. 覆盖默认实现
    DetailedPost 实现 Summary 时,我们选择覆盖默认的 summarize 方法,提供我们自己的实现。

    impl Summary for DetailedPost {
        fn summarize(&self) -> String {
            format!("{} posted: {}", self.username, self.content)
        }
    }
    

最后,在 main 函数中创建实例并调用方法:

fn main() {
    let note = SimpleNote {
        content: String::from("This is a simple note."),
    };

    let post = DetailedPost {
        username: String::from("rustacean"),
        content: String::from("Learning traits is fun!"),
    };

    println!("{}", note.summarize()); // 输出默认实现
    println!("{}", post.summarize()); // 输出覆盖后的实现
}

运行此程序,你将看到 SimpleNote 使用了默认实现,而 DetailedPost 使用了我们自定义的实现。

总结

本节课中我们一起学习了 Rust Trait 的基础知识。我们首先了解了 Trait 的定义和作用,即定义一组共享的行为。接着,我们实践了如何为具体的结构体类型实现 Trait,并调用实现的方法。最后,我们探索了如何为 Trait 方法提供默认实现,这为类型提供了灵活性:它们可以选择使用默认行为,也可以提供自定义的实现。掌握 Trait 是理解 Rust 多态和代码复用的重要一步。

066:在 Rust 中使用 Trait 作为约束

在本节课中,我们将学习如何在 Rust 中使用 Trait 作为泛型函数的约束。我们将介绍两种语法形式:简写形式和显式泛型形式,并学习如何组合多个 Trait 约束。

使用 Trait 约束的两种形式

上一节我们学习了如何定义简单的 Trait。本节中,我们来看看如何将其用作泛型函数的约束。这主要有两种形式:简写语法和显式泛型语法。

简写语法

我们将使用上一课中定义的 Summary Trait,它有一个名为 summarize 的函数。

以下是使用简写语法定义函数的方法:

pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

定义此 item 参数时,必须指定其实现了 Summary Trait,否则代码将无法工作。

显式泛型语法

接下来,我们使用显式泛型语法,这是实现相同功能的完整形式。

以下是使用显式泛型语法的示例:

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

这里我们定义了一个泛型类型 T,它被约束为必须实现 Summary Trait。参数 itemT 的引用。函数内部的代码与简写形式完全相同,两种形式最终实现的功能一致。

处理多个参数

使用多个参数时,方法也很直接。

以下是处理多个参数的示例:

  • 对于简写形式,可以这样定义:
    pub fn notify_two(item1: &impl Summary, item2: &impl Summary) {
        // ...
    }
    
  • 对于显式泛型形式,使用相同的泛型类型可能更方便:
    pub fn notify_two_long<T: Summary>(item1: &T, item2: &T) {
        // ...
    }
    

实际应用示例

为了使用这些函数,我们需要创建一个结构体并为其实现 Summary Trait。

以下是创建结构体和实现 Trait 的步骤:

  1. 定义一个 BlogPost 结构体:
    struct BlogPost {
        title: String,
        author: String,
    }
    
  2. BlogPost 实现 Summary Trait:
    impl Summary for BlogPost {
        fn summarize(&self) -> String {
            format!("{} by {}", self.title, self.author)
        }
    }
    
    此实现使我们创建的任何 BlogPost 实例都能作为参数传递给所有 notify 函数。

现在,在 main 函数中,我们可以创建两个不同的结构体实例并调用函数。

以下是在 main 函数中调用函数的示例:

fn main() {
    let post1 = BlogPost { title: String::from("Rust Traits"), author: String::from("Alice") };
    let post2 = BlogPost { title: String::from("Ownership"), author: String::from("Bob") };

    notify(&post1); // 输出:Breaking news! Rust Traits by Alice
    notify_long(&post1); // 输出相同

    notify_two(&post1, &post2); // 可以传递多个参数
}

我们只需按照函数签名定义的要求,传递实现了 Summary Trait 的结构体引用即可。

组合多个 Trait 约束

有时,我们需要一个类型同时实现多个 Trait,以便使用来自所有这些 Trait 的方法。使用 + 运算符语法可以实现这一点。

例如,如果我们希望 item 同时实现 SummaryDisplay Trait。

以下是组合多个 Trait 约束的示例:

  1. 首先需要从标准库导入 Display Trait。
  2. 使用 + 运算符指定多个约束:
    use std::fmt::Display;
    
    // 简写形式
    pub fn notify_display(item: &(impl Summary + Display)) {
        println!("Item: {}", item); // 现在可以正常打印,因为实现了 Display
        println!("Summary: {}", item.summarize());
    }
    
    // 显式泛型形式
    pub fn notify_display_long<T: Summary + Display>(item: &T) {
        println!("Item: {}", item);
        println!("Summary: {}", item.summarize());
    }
    

为了使用这些函数,我们需要创建一个同时实现这两个 Trait 的类型。

以下是创建并实现所需 Trait 的示例:

  1. 定义一个 Article 结构体。
  2. 为其实现 Summary Trait。
  3. 还需要使用 std::fmt 中的格式化工具为其实现 Display Trait:
    use std::fmt;
    
    struct Article {
        title: String,
        author: String,
    }
    
    impl Summary for Article {
        fn summarize(&self) -> String {
            format!("{} by {}", self.title, self.author)
        }
    }
    
    impl fmt::Display for Article {
        fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
            write!(f, "Article: {} ({})", self.title, self.author)
        }
    }
    

现在,在 main 函数中,我们可以创建 Article 实例并调用新函数。

以下是在 main 函数中调用新函数的示例:

fn main() {
    let article = Article { title: String::from("Understanding Traits"), author: String::from("Charlie") };
    notify_display(&article);
}

运行代码将输出格式化后的文章信息和摘要。如果使用泛型版本,将得到完全相同的输出,因为这两种方法在功能上是相同的。

总结

本节课中,我们一起学习了在 Rust 中使用 Trait 作为泛型约束的核心方法。我们掌握了两种主要语法:简写的 &impl Trait 形式和显式的 <T: Trait> 泛型形式。我们还学习了如何为函数参数指定多个 Trait 约束,使用 + 运算符组合它们。这些技术使我们能够编写灵活且类型安全的函数,要求参数具备特定的行为能力。

067:使用 where 子句与 impl Trait 语法

概述

在本节课中,我们将学习两个提升 Rust 代码可读性和灵活性的重要概念:where 子句和 impl Trait 语法。我们将了解如何使用 where 子句来简化复杂的泛型约束,以及如何使用 impl Trait 语法来返回实现了特定 trait 的类型。


使用 where 子句提升可读性

当 trait 约束变得复杂时,内联语法会使函数签名难以阅读。解决方案是将约束移动到函数签名后的 where 子句中。

这样做的好处是,类型参数和返回类型能保持清晰,不受约束的干扰。

为了说明这一点,让我们创建一个示例。在 main 函数上方,我们先导入 DisplayDebug trait。

use std::fmt::{Display, Debug};

接着,在下方创建一个函数 func1。这个函数的签名如下:它定义了一个泛型类型 T,要求其实现 DisplayClone;同时定义了另一个泛型类型 U,要求其实现 CloneDebug。函数接收这两个类型的参数,并返回一个 i32

fn func1<T: Display + Clone, U: Clone + Debug>(t: T, u: U) -> i32 {
    unimplemented!()
}

如你所见,内联的 trait 约束逐渐变得难以阅读。现在,让我们使用 where 子句来重构这个函数。

在下方,我们创建函数 func2,粘贴相同的签名部分(接收泛型 TU,返回 i32)。然后,我们使用 where 子句来指定约束条件。

fn func2<T, U>(t: T, u: U) -> i32
where
    T: Display + Clone,
    U: Clone + Debug,
{
    unimplemented!()
}

在花括号内,我们添加代码(这里同样使用 unimplemented!)。可以看到,当泛型变得复杂时,where 子句使代码更易于阅读和编辑。


使用 impl Trait 语法返回类型

上一节我们介绍了如何使用 where 子句整理复杂的约束,本节中我们来看看如何返回实现了特定 trait 的类型。我们将使用 impl Trait 语法来实现。

在解释之前,让我们先创建一个使用此语法的示例。这个示例需要用到之前课程中定义的 Summary trait 和 SocialPost 结构体。

首先,我们假设已有以下定义:

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

struct SocialPost {
    // ... 字段定义
}
impl Summary for SocialPost {
    fn summarize(&self) -> String {
        // ... 实现细节
        String::from("这是一个社交帖子的摘要。")
    }
}

接着,我们可以创建一个名为 returns_summarizable 的函数,它返回一个实现了 Summary trait 的类型。

fn returns_summarizable() -> impl Summary {
    SocialPost {
        // ... 初始化字段
    }
}

在函数内部,我们需要返回一个具体实现了 Summary 的类型。在这个例子中,我们返回一个 SocialPost。关键在于,调用者只知道返回的类型实现了 Summary,而不需要知道具体的结构体类型。

这种方式的优点是,返回的具体类型依赖于 trait 的实现,而不是具体的结构体类型。这里我们返回的是 SocialPost,但如果我们有一个同样实现了 SummaryArticle 结构体,也可以返回它。

但需要注意一个限制:我们不能在不同的条件分支中返回两种不同的具体类型,即使它们都实现了同一个 trait。


一个更完整的示例

为了更好地理解没有错误的情况,让我们看一个更完整的例子。我们将回到“推特”时代,创建一个 Tweet 结构体。

首先,定义 Summary trait 和 Tweet 结构体,并为 Tweet 实现 Summary

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

struct Tweet {
    username: String,
    content: String,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("@{} 发推说:{}", self.username, self.content)
    }
}

接着,创建一个名为 returns_summarizable 的函数,它返回一个实现了 Summary trait 的类型。由于 Tweet 实现了 Summary,所以一切正常。

fn returns_summarizable() -> impl Summary {
    Tweet {
        username: String::from("rustacean"),
        content: String::from("学习 Rust 的 trait 真有趣!"),
    }
}

main 函数中,我们可以调用这个函数,并打印返回项的摘要。

fn main() {
    let item = returns_summarizable();
    println!("摘要:{}", item.summarize());
}

运行此程序,我们将得到如下输出:

摘要:@rustacean 发推说:学习 Rust 的 trait 真有趣!

这种方式很酷的一点在于,我们可以返回任何实现了 Summary trait 的类型。即使我们将返回的 Tweet 改为另一个实现了 SummaryArticle,代码也能正常工作(当然,前提是我们需要先创建并实现 Article)。


总结

本节课中我们一起学习了两个 Rust 的核心特性:

  1. where 子句:用于将复杂的泛型 trait 约束从函数签名中分离出来,显著提升代码的可读性和可维护性。
  2. impl Trait 语法:用于在返回位置指定“某个实现了特定 trait 的类型”,它提供了返回抽象类型的灵活性,同时保持了代码的简洁性,但需注意其不能用于返回多种可能的具体类型。

掌握这两个工具,将帮助你编写出更清晰、更灵活的 Rust 代码。

068:再见,Rust 的 Trait

在本节课中,我们将要学习如何根据 Trait 约束来有条件地实现方法。这将是我们在进入生命周期主题之前,关于 Trait 的最后一课。

概述

条件实现允许你仅在类型的泛型满足特定 Trait 约束时,才为其添加方法。这通过 impl 块后跟 Trait 及其约束来实现。Rust 还使用“一揽子实现”,即为所有满足某个约束的类型自动实现一个 Trait。这种方法保持了类型的灵活性,防止误用,并且仅在内层类型有能力支持时才暴露更丰富的 API。

上一节我们介绍了 Trait 的基本用法,本节中我们来看看如何更精细地控制方法的实现。

无条件方法实现

首先,让我们看一个如何定义无条件方法的例子。

我们将创建一个名为 Pair 的结构体,它持有两个类型为 T 的值 xy

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

接下来,我们可以为 Pair<T> 创建一个实现块,定义一个创建新 Pair 的方法。

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

这个方法适用于任何类型 T,对所有类型都可用。

条件方法实现

如果我们想创建条件方法,就必须在这里添加一些约束,例如 DisplayPartialOrd。显然,如果我们想使用 Display,需要从标准库中导入它。

以下是添加了条件约束的实现块:

use std::fmt::Display;

impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("最大的成员是 x,值为 {}", self.x);
        } else {
            println!("最大的成员是 y,值为 {}", self.y);
        }
    }
}

main 函数中,我们可以创建一个 Pair

fn main() {
    let pair = Pair::new(4, 10);
}

我们可以创建任何类型的 Pair。因为我们在 new 方法中定义的是泛型 T,所以它适用于任何类型。

由于这里的元素类型同时满足 DisplayPartialOrd 约束,我们可以对这个 pair 使用 cmp_display 方法。

    pair.cmp_display();

运行程序,我们将得到输出:最大的成员是 y,值为 10

现在,看看当我们引入一些不满足这些 Trait 约束的类型时会发生什么,例如 Vec

    let pair_vec = Pair::new(vec![1, 2], vec![3, 4]);
    // pair_vec.cmp_display(); // 这行代码将无法编译

因为 Vec 不满足 DisplayPartialOrd 约束,所以 cmp_display 方法对这些类型不可用。

一揽子实现

最后,让我们介绍一揽子实现。一揽子实现允许为所有满足约束的类型实现一个 Trait。

一个例子是标准库为所有实现了 Display Trait 的类型自动实现了 ToString。在这个例子中,我们将使用 Debug Trait。

首先,我们定义一个简单的 Trait:

trait Describable {
    fn describe(&self) -> String;
}

然后,在下面我们可以创建一揽子实现:

use std::fmt::Debug;

impl<T: Debug> Describable for T {
    fn describe(&self) -> String {
        format!("这个值是 {:?}", self)
    }
}

这将为所有实现了 Debug 的类型实现 Describable Trait,允许我们使用 format! 宏和 :? 格式化说明符。

现在在我们的 main 函数中,我们可以这样使用:

fn main() {
    let number = 42;
    println!("{}", number.describe());
}

运行后,我们得到的输出是:这个值是 42

如果没有这个 Debug 约束,Rust 将不知道在这里该做什么。

总结

本节课中我们一起学习了 Rust 中 Trait 的条件实现和一揽子实现。

我们了解到:

  • 使用 impl<T: TraitA + TraitB> 语法可以为满足特定 Trait 约束的泛型类型有条件地添加方法。
  • 一揽子实现使用 impl<T: TraitA> TraitB for T 的语法,为所有满足前置约束的类型自动实现一个 Trait。
  • 这些特性使得 API 设计更加灵活和安全,只在类型有能力支持时才提供相关功能,避免了编译期错误。

掌握这些知识,你将能更好地设计泛型代码的接口,使其既强大又不易误用。

069:你好,Rust的生命周期 ⏳

在本节课中,我们将要学习 Rust 中一个至关重要的概念:生命周期。生命周期是另一种我们一直在使用但尚未明确讨论的泛型。与确保类型具有我们所需的行为不同,生命周期确保引用在我们需要它们有效的时间内保持有效。Rust 中的每个引用都有一个生命周期,即该引用有效的范围。

大多数情况下,生命周期是隐式且由编译器推断的,就像大多数情况下类型被推断一样。我们只有在引用的生命周期可能以几种不同方式相关联时,才需要标注生命周期。生命周期标注甚至是大多数其他编程语言所没有的概念。因此,如果你来自 Python 或 Java 等语言,可能会感到陌生,但别担心,我们将循序渐进地学习。

生命周期的主要目标 🎯

上一节我们介绍了生命周期的基本概念,本节中我们来看看它的核心目标。

生命周期的主要目标是防止悬垂引用。如果允许悬垂引用存在,将导致程序引用非其预期引用的数据。

让我们看一个无法编译的示例。

一个悬垂引用的例子 ❌

以下是导致悬垂引用的代码示例:

fn main() {
    let r; // 声明一个引用变量 r

    {
        let x = 5; // 在内部作用域中创建一个变量 x
        r = &x; // 尝试让 r 引用 x
    } // 内部作用域结束,x 被销毁

    println!("r: {}", r); // 尝试使用 r,此时它指向的内存已无效
}

当我们尝试运行这段代码时,会得到一个错误。因为 r 是一个悬垂引用。错误信息会指出“被借用的值存活时间不够长”,原因是当内部作用域结束时,x 将离开作用域,但 r 在外部作用域中仍然有效,因为它的作用域更大。在这种情况下,我们说 r “活得更长”。

如果 Rust 允许这段代码运行,r 将引用 x 离开作用域时已被释放的内存,我们对 r 所做的任何操作都将无法正确工作。

Rust 如何检查:借用检查器 🔍

上一节我们看到了一个错误示例,本节中我们来看看 Rust 如何确定代码无效。

Rust 使用一个借用检查器。Rust 编译器内置的借用检查器会比较作用域,以确定所有借用是否有效。

让我们用标注来可视化生命周期。我们可以说 r 有一个生命周期标注 'a,它从这里开始,r 将在这里离开作用域,这是生命周期结束的地方。然后,这里有一个新的生命周期 'b 开始,它在这里结束。

以下是生命周期标注的可视化表示:

生命周期 'a: |--------------------------|
变量 r:      |----> (引用 x)            |

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/09bae4162659b22e9412cc439a4261a5_10.png)

生命周期 'b:        |-------|
变量 x:             |--5--|

在编译时,Rust 会比较两个生命周期的大小,发现 r 拥有生命周期 'a,但它引用的内存(x)只拥有生命周期 'b。程序被拒绝,因为 'b'a 短。被引用的主体(x)没有引用(r)活得长。

修复代码 ✅

上一节我们分析了错误原因,本节中我们来修复代码,使其没有悬垂引用。

为此,我们需要确保被引用的数据比引用存活得更久。修改后的代码如下:

fn main() {
    let x = 5; // 将 x 定义在外部作用域

    let r = &x; // r 引用 x

    println!("r: {}", r); // 可以安全使用 r
}

现在,我们可以再次添加生命周期标注。这里 x 拥有生命周期 'br 拥有生命周期 'a。在这种情况下,x 的生命周期比 'a 更长。这意味着 r 可以引用 x,因为 Rust 知道,只要 x 有效,r 中的引用就始终有效。

当你持有一个引用时,Rust 确保它所指向的数据在该引用存在的整个期间都是有效的。这防止了你在其他语言中可能遇到的“释放后使用”错误和数据竞争。

总结 📝

本节课中我们一起学习了 Rust 的生命周期。

  • 生命周期是引用有效的范围。
  • 其主要目标是防止悬垂引用
  • 大多数情况下,生命周期由编译器隐式推断
  • 借用检查器在编译时比较作用域,确保引用始终有效。
  • 核心规则是:引用的生命周期不能长于其引用的数据的生命周期

理解生命周期是掌握 Rust 所有权和借用系统的关键一步,它能帮助编写出内存安全且高效的代码。

070:函数中的泛型生命周期 🧬

在本节课中,我们将学习如何在函数签名中使用泛型生命周期参数。我们将编写一个函数,用于比较并返回两个字符串切片中较长的一个,并理解为何以及如何使用生命周期注解来确保代码的安全性。

上一节我们介绍了 Rust 中生命周期的基本概念以及编译器如何进行分析。本节中,我们来看看如何在函数参数和返回值中应用泛型生命周期。

函数目标与问题引入

我们的目标是编写一个名为 longest 的函数,它接收两个字符串切片(即引用)作为参数,并返回其中较长的一个切片。使用切片而非 String 类型是为了避免函数获取参数的所有权。

如果尝试在不添加生命周期注解的情况下实现这个函数,代码将无法编译。以下是问题代码:

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

编译器无法判断返回的引用是指向 x 还是 y。因为在编译时,我们无法确定程序实际会执行 if 分支还是 else 分支。因此,Rust 的借用检查器需要明确的指引来确定返回引用的有效范围。

生命周期注解语法

生命周期注解的语法有些特别。它们用于描述多个引用生命周期之间的关系,而不会改变任何引用的实际存活时间。

以下是生命周期注解的要点:

  • 生命周期参数名必须以撇号(')开头。
  • 名称通常全小写且非常简短,类似于泛型类型参数(如 T)。
  • 大多数人使用 'a 作为第一个生命周期注解。
  • 生命周期注解位于引用的 & 符号之后,并用一个空格与引用类型分隔。

示例:

  • 常规引用:&i32
  • 带有显式生命周期的引用:&'a i32
  • 带有显式生命周期的可变引用:&'a mut i32

单个生命周期注解本身没有太多意义,因为它们旨在描述多个引用生命周期之间的关系。

为函数添加生命周期注解

为了在函数签名中使用生命周期注解,我们需要在函数名和参数列表之间的尖括号内声明泛型生命周期参数,就像声明泛型类型参数一样。

以下是修复后的 longest 函数签名:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

这个签名向 Rust 表达了一个关键约束:返回的引用将与两个参数中生命周期较短的那个保持一致,并且在该生命周期内有效

具体来说,它告诉 Rust:

  1. 对于某个生命周期 'a,函数接受两个参数。
  2. 这两个参数都是字符串切片,其生命周期至少与 'a 一样长。
  3. 函数返回的字符串切片,其生命周期也至少与 'a 一样长。

通过这个约束,借用检查器可以确保函数在任何使用场景下都是安全的。

理解生命周期约束

需要明确的是,在函数签名中指定生命周期参数,并不会改变任何传入或返回值的实际生命周期。我们只是为借用检查器提供了一套规则,让它能够拒绝那些不符合这些约束的调用。

longest 函数本身不需要确切知道 xy 会活多久,它只需要知道存在某个作用域(被 'a 替代)能够满足签名中的关系即可。

函数使用示例

现在我们可以使用这个带有生命周期注解的函数了。以下是一个示例:

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

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

在这个例子中,string1string2 的切片被传递给 longest 函数。函数返回的 result 引用的有效生命周期,被限制为 string2 切片(生命周期较短者)的生命周期内,因此打印是安全的。

核心要点总结

本节课中我们一起学习了函数中泛型生命周期的应用。核心在于理解并建立输入参数与返回值之间的生命周期关系。

以下是编写返回引用的函数时的关键思路:

  • 当函数返回一个引用时,其生命周期必须与某个输入参数的生命周期相关联。
  • 返回引用的有效时间,至少要与输入参数中生命周期较短的那个一样长
  • 生命周期注解 'a 是一个用于在签名中建立这种关系的占位符,它代表了所有输入引用重叠的那段生命周期。

通过为函数签名添加恰当的生命周期注解,我们既能够编写灵活、高效的引用操作函数,又能借助 Rust 编译器确保内存安全。

071:函数中的生命周期注解

在本节中,我们将继续学习 Rust 中的生命周期。我们将重点探讨如何在函数签名中注解生命周期,理解编译器如何利用这些注解进行借用检查,以及如何避免常见的错误,例如悬垂引用。

上一节我们介绍了生命周期注解的基本概念,本节中我们来看看如何在函数中使用它们。

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

生命周期注解位于函数签名中,而非函数体内。这些注解成为函数契约的一部分,就像签名中的类型一样。让函数签名包含生命周期契约意味着 Rust 编译器的分析可以更简单。如果函数的注解方式或调用方式出现问题,编译器错误可以更精确地指向我们代码的特定部分及其约束。

当我们将具体的引用传递给带有生命周期参数的函数时,用于替换泛型生命周期的具体生命周期是参数作用域重叠的部分。换句话说,泛型生命周期将获得等于输入引用生命周期中较短者的具体生命周期。

生命周期注解实践

让我们通过一个例子来看看生命周期注解如何通过传入具有不同具体生命周期的引用来限制 longest 函数。

以下是示例代码:

fn main() {
    let string1 = String::from("long string is long");
    {
        let string2 = String::from("xyz");
        let result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {}", result);
    }
}

在这个例子中,string1 的有效期直到外部作用域结束,而 string2 只到内部作用域结束。此处的 result 引用了在内部作用域结束前有效的值。这意味着借用检查器会批准此代码,因为返回的引用没有超过两个输入生命周期中较短的那个。

生命周期约束导致的错误

现在,让我们尝试一个例子,展示 result 中引用的生命周期必须是两个参数中较短的生命周期。

以下是修改后的代码:

fn main() {
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    }
    println!("The longest string is {}", result);
}

运行此代码会产生错误。错误信息表明,为了使 resultprintln! 语句有效,string2 需要有效直到外部作用域结束。换句话说,string2 存活得不够久。

作为人类,我们可以查看这段代码并看到 string1string2 长,因此 result 将包含对 string1 的引用。由于 string1 尚未离开作用域,对 string1 的引用对 println! 语句仍然有效。然而,编译器无法看到在这种情况下引用是有效的。我们已经告诉 Rust,longest 函数返回的引用的生命周期与传入引用的生命周期中较短者相同。因此,借用检查器将此代码视为可能包含无效引用。

指定生命周期参数的方式

指定生命周期参数的方式取决于函数的功能。

例如,如果我们将 longest 函数的实现更改为始终返回第一个参数,而不是最长的字符串切片,则不需要在 y 参数上指定生命周期。

以下是修改后的函数签名:

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

在这个例子中,我们为参数 x 和返回类型指定了生命周期参数 'a,但没有为参数 y 指定,因为 y 的生命周期与 x 或返回值的生命周期没有任何关系。

这是一个重要的见解:你只需要注解相互关联的生命周期。如果一个参数的生命周期不影响返回值,你就不需要注解它。

返回引用与悬垂引用

当从函数返回引用时,返回类型的生命周期参数需要与某个参数的生命周期参数匹配。如果返回的引用不指向任何一个参数,那么它必须指向在此函数内部创建的值。

然而,这将是一个悬垂引用,因为该值将在函数结束时离开作用域。

以下是一个会产生编译错误的例子:

fn invalid_return<'a>() -> &'a str {
    let result = String::from("a very long string");
    result.as_str() // 错误!返回局部变量的引用
}

问题在于 result 在函数结束时离开作用域并被清理,而我们却试图从函数返回一个对 result 的引用。我们无法指定任何生命周期参数来改变这个悬垂引用,Rust 不会允许我们创建悬垂引用。

在这种情况下,最好的修复方法是返回一个拥有所有权的数据类型,而不是引用,这样调用函数就需要负责清理该值。

以下是修复后的代码:

fn valid_return() -> String {
    let result = String::from("a very long string");
    result // 返回所有权,而不是引用
}

当然,这也可以简化为直接返回字符串字面量或 String

总结

本节课中我们一起学习了函数中生命周期注解的核心机制。生命周期语法是关于连接函数各种参数和返回值的生命周期。一旦它们被连接起来,Rust 就有足够的信息来允许内存安全操作,并禁止会创建悬垂指针或以其他方式违反内存安全的操作。

你可以将生命周期注解视为告诉编译器的一种方式:“嘿,我返回的这个引用与这个参数的生命周期绑定,所以请确保调用者正确使用它。” 通过遵循这些规则,你可以编写出既安全又高效的 Rust 代码。

072:结构体中的生命周期 📚

在本节课中,我们将要学习如何在 Rust 的结构体中处理引用,以及为什么必须为包含引用的结构体添加生命周期注解。我们将通过一个具体的例子来理解生命周期注解的必要性,并了解编译器在特定情况下如何自动推断生命周期。


结构体与引用

到目前为止,我们定义的结构体都持有自有类型。我们也可以定义持有引用的结构体,但在这种情况下,我们需要在结构体定义中为每一个引用添加生命周期注解。

这一点非常重要,因为如果一个结构体持有一个引用,那么该引用必须在结构体存在的整个期间都保持有效。生命周期注解告诉 Rust 该引用需要存活的确切时长。

定义持有引用的结构体

让我们创建一个持有字符串切片的结构体。这个结构体需要一个生命周期注解,因为它包含一个引用。

以下是定义过程:

  1. 我们创建一个名为 ImportantExcerpt 的结构体。
  2. 在结构体名称后,我们使用尖括号 <> 传入一个生命周期注解,例如 'a
  3. 在结构体内部,我们定义一个字段 part,其类型为字符串切片 &str,并使用相同的生命周期注解 'a 进行标注。

用代码表示如下:

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

这个结构体有一个字段 part,它持有一个字符串切片引用。与泛型数据类型类似,我们在结构体名称后的尖括号内声明泛型生命周期参数的名称,以便在结构体定义体中使用该生命周期参数。

这个注解意味着:一个 ImportantExcerpt 的实例不能比它在其 part 字段中持有的引用活得更久。换句话说,在该结构体所引用的数据离开作用域之前,该结构体必须被销毁。

实践示例

让我们看看这在实践中是如何工作的。在 main 函数中,我们将:

  1. 创建一个字符串 novel
  2. 使用 .split('.').next() 方法获取第一句话,并解包得到 first_sentence
  3. 使用 first_sentence 创建一个 ImportantExcerpt 实例 i
  4. 打印出摘录的内容。

以下是代码实现:

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt { part: first_sentence };
    println!("The excerpt is: {}", i.part);
}

在这个例子中,novelfirst_sentence 必须至少与 i 活得一样长。novel 的数据在 ImportantExcerpt 实例创建之前就已存在,并且直到 ImportantExcerpt 离开作用域后,novel 才离开作用域。因此,ImportantExcerpt 实例中的引用是有效的。

如果我们尝试创建一个引用在结构体之前离开作用域的 ImportantExcerpt 实例,将会得到一个编译错误。这正是生命周期机制所要防止的情况。

生命周期省略规则

至此,你已经了解到每个引用都有生命周期,并且需要为使用引用的函数或结构体指定生命周期参数。然而,我们已经见过一些没有生命周期注解也能编译的函数。

例如,以下是一个名为 first_word 的函数,其参数和返回类型都是引用,但它可以在没有显式生命周期注解的情况下编译:

fn first_word(s: &str) -> &str {
    // ... 函数体
}

这个函数之所以能在没有生命周期注解的情况下编译,有其历史原因。在早期版本中,这段代码无法编译,因为每个引用都需要显式的生命周期。那时的函数签名会像这样书写:fn first_word<'a>(s: &'a str) -> &'a str

在编写了大量 Rust 代码后,Rust 团队发现 Rust 程序员在特定情况下会反复输入相同的生命周期注解。这些情况是可预测的,并且遵循少数确定的模式。开发者将这些模式编程到编译器的代码中,使得借用检查器可以在这些情况下推断生命周期,从而不需要显式注解。

这些被编程到 Rust 引用分析中的模式被称为 生命周期省略规则。这些不是程序员需要遵循的规则,而是一组编译器会考虑的特定情况。如果你的代码符合这些情况,你就不需要显式地编写生命周期。

生命周期省略规则并不提供完整的推断。如果在 Rust 应用了这些规则后,对于引用应具有的生命周期仍然存在歧义,编译器不会猜测剩余引用的生命周期应该是多少。相反,编译器会给你一个错误,你可以通过添加生命周期注解来解决它。

函数或方法参数上的生命周期被称为 输入生命周期,返回值上的生命周期被称为 输出生命周期。当没有显式注解时,编译器使用三条规则来推断引用的生命周期。我们将在下一集中详细讨论这些规则。就目前而言,只需知道编译器通常可以为常见模式自动推断生命周期。

生命周期省略非常有帮助,因为它意味着我不必为每个函数都编写生命周期注解。大多数时候,编译器可以推断出来。当它无法推断时,它会明确告诉我需要添加什么。

运行示例函数

在结束本课之前,让我们运行一下 first_word 函数。

以下是示例代码:

fn main() {
    let text = String::from("Hello world!");
    let word = first_word(&text);
    println!("The first word is: {}", word);
}

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[..]
}

当我们运行这段代码时,应该会得到输出:The first word is: Hello


总结

本节课中我们一起学习了:

  1. 结构体中的生命周期:当结构体持有引用时,必须使用生命周期注解来确保引用的有效性不会超过结构体本身。
  2. 生命周期注解的语法:在结构体名后使用 <'a> 声明,并在引用字段上使用 &'a 进行标注。
  3. 生命周期省略规则:编译器内置的一组规则,可以在常见模式下自动推断生命周期,减少了程序员需要编写的显式注解。
  4. 输入与输出生命周期:区分了参数和返回值的生命周期概念。

理解生命周期是掌握 Rust 所有权系统的关键一步。通过结构体中的生命周期管理,我们可以安全地构建复杂的数据结构,同时确保内存安全。

073:生命周期省略规则详解 🧬

在本节课中,我们将继续学习生命周期的相关知识,重点探讨 Rust 编译器如何在没有显式标注的情况下,自动推断引用的生命周期。我们将详细解析编译器使用的三条生命周期省略规则。

上一节我们介绍了生命周期注解的基本概念,本节中我们来看看编译器如何自动应用规则来推断生命周期。

编译器如何推断生命周期

当函数或方法的引用参数和返回值没有显式生命周期注解时,编译器会使用三条规则来推断它们的生命周期。如果应用完这三条规则后,仍有引用的生命周期无法确定,编译器将报错停止。

以下是编译器应用的三条生命周期省略规则:

第一条规则:编译器为每一个引用参数都分配一个独立的生命周期参数。换句话说,一个参数的函数获得一个生命周期参数,两个参数的函数获得两个独立的生命周期参数,依此类推。

第二条规则:如果恰好只有一个输入生命周期参数,那么该生命周期将被赋予所有输出生命周期参数。例如,在函数 fn foo(x: &str) -> &str 中,输入参数 x 的生命周期会被赋予返回值。

第三条规则:如果存在多个输入生命周期参数,但其中一个是 &self&mut self(即这是一个方法),那么 self 的生命周期将被赋予所有输出生命周期参数。这条规则使得方法的读写更加简洁,因为所需的符号更少。

规则应用示例:first_word 函数

让我们模拟编译器如何应用这些规则来确定 first_word 函数签名中引用的生命周期。

初始的函数签名没有任何生命周期注解:

fn first_word(s: &str) -> &str

首先,编译器应用第一条规则,为每个引用参数分配独立的生命周期。这里只有一个参数 s,我们称其生命周期为 'a。应用规则后,编译器看到的是:

fn first_word<'a>(s: &'a str) -> &str

接着,应用第二条规则。因为恰好只有一个输入生命周期参数 'a,所以该生命周期被赋予输出生命周期参数。应用后变为:

fn first_word<'a>(s: &'a str) -> &'a str

至此,函数签名中的所有引用都有了生命周期,编译器可以继续进行分析,而无需程序员显式标注。在实际编码中,我们通常不需要显式写出这些注解,因为 Rust 可以轻松推断出来。

规则应用示例:longest 函数

现在让我们看一个更复杂的例子 longest 函数,并应用生命周期省略规则。

初始函数签名:

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

首先应用第一条规则:每个引用参数获得自己的生命周期。这里有两个参数,因此得到两个生命周期 'a'b

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

第二条规则不适用,因为输入生命周期参数不止一个。
第三条规则也不适用,因为 longest 是一个普通函数而非方法,参数中没有 &self

在应用完所有三条规则后,返回值的生命周期仍然无法确定。这就是为什么在编译没有生命周期注解的 longest 函数时,我们会得到错误:

error[E0106]: missing lifetime specifier

编译器提示需要显式的生命周期注解。为了解决这个问题,我们需要手动添加注解,明确返回的引用与哪个输入参数的生命周期相关联,例如指定返回值与参数 x 具有相同的生命周期 'a

fn longest<'a>(x: &'a str, y: &str) -> &'a str {
    // 函数体
}

结构体方法中的生命周期

在为带有生命周期的结构体实现方法时,其语法与泛型类型参数类似。生命周期参数的声明和使用位置,取决于它们是与结构体字段相关,还是与方法参数和返回值相关。

结构体字段的生命周期名称必须在 impl 关键字后声明,并在结构体名后使用,因为这些生命周期是结构体类型的一部分。

让我们回到之前的重要示例 ImportantExcerpt。在 impl 块内部的方法签名中,引用可能与结构体字段引用的生命周期相关联,也可能是独立的。此外,生命周期省略规则通常使得方法签名中不需要显式的生命周期注解。

以下是几个方法示例:

  1. 一个简单的 level 方法:其唯一参数是 &self,返回值是 i32(非引用)。虽然需要在 impl 后声明生命周期并在结构体名后使用,但由于第一条省略规则,我们不需要标注 self 引用的生命周期。
    impl<'a> ImportantExcerpt<'a> {
        fn level(&self) -> i32 {
            3
        }
    }
    

  1. 应用第三条规则的例子:考虑一个带有额外参数的方法。
    impl<'a> ImportantExcerpt<'a> {
        fn announce_and_return_part(&self, announcement: &str) -> &str {
            println!("Attention please: {}", announcement);
            self.part
        }
    }
    
    • 编译器首先应用第一条规则,为 &selfannouncement 分别赋予独立的生命周期。
    • 然后,因为其中一个参数是 &self第三条规则生效:返回类型获得 self 的生命周期。
    • 至此,所有生命周期都已确定,无需手动标注。这正是在处理生命周期时,方法通常比独立函数更容易使用的原因——第三条省略规则自动处理了许多常见情况。

总结

本节课中我们一起学习了 Rust 编译器的三条生命周期省略规则。第一条规则为每个引用参数分配独立生命周期;第二条规则在单个输入生命周期时将其赋予输出;第三条规则在方法中优先将 self 的生命周期赋予输出。

这些规则非常有用,它们让我们能够编写更少的样板代码。大多数时候,编译器都能推断出我们的意图。当它无法推断时,会给出清晰的错误信息,明确指出需要添加哪些生命周期注解,从而引导我们写出既安全又高效的代码。

Rust 生命周期:P74:告别生命周期与综合应用 🎯

在本节课中,我们将要学习 Rust 中一个特殊的生命周期 'static,并探讨如何将生命周期参数、泛型类型参数和 trait 约束结合在一个函数签名中。这是对之前所学知识的综合运用。


'static 生命周期

上一节我们介绍了生命周期注解的基本概念,本节中我们来看看一个特殊的生命周期:'static

'static 生命周期表示受影响的引用可以在整个程序运行期间存活。所有字符串字面量都拥有 'static 生命周期,我们可以如下注解:

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

字符串字面量的文本直接存储在程序的二进制文件中,因此总是可用的。所以,所有字符串字面量的生命周期都是 'static

你可能会在错误信息中看到建议使用 'static 生命周期的提示。但在为引用指定 'static 生命周期之前,请仔细思考:你的引用是否真的会在程序的整个生命周期内存活?以及你是否真的希望它如此?

大多数时候,错误信息建议使用 'static 生命周期,是因为尝试创建悬垂引用或可用生命周期不匹配。在这些情况下,正确的解决方案是修复这些问题,而不是指定 'static 生命周期。

我个人建议,对 'static 要非常谨慎。很容易在你实际上只需要修复生命周期注解时,误以为需要它。


综合语法:泛型、Trait 约束与生命周期

现在,让我们简要地看一下如何在一个函数中同时指定泛型类型参数、trait 约束和生命周期。

以下是我们之前见过的 longest 函数,但现在它增加了一个泛型类型参数:

use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,
{
    println!("Announcement! {}", ann);
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

以下是创建此函数的步骤分解:

  1. 我们使用 Display trait。
  2. 创建一个名为 longest_with_an_announcement 的函数,它有一个额外的公告参数 ann
  3. 在函数名后的尖括号 <> 内,我们同时指定生命周期参数 'a 和泛型类型参数 T
  4. 函数返回一个 &'a str
  5. 在函数体之前,我们使用 where 子句指定 T 必须实现 Display trait。

这个函数仍然返回两个字符串切片中较长的一个,但现在它多了一个类型为 T 的参数 annT 可以是任何实现了 Display trait 的类型,正如 where 子句所指定的。这个额外参数将使用花括号 {} 打印,这就是为什么需要 Display trait 约束。

由于生命周期也是一种泛型,生命周期参数 'a 和泛型类型参数 T 的声明都放在函数名后的同一个尖括号列表中。

泛型参数的顺序约定是:生命周期优先,然后是类型,最后是常量泛型(如果有)。这个约定使代码在整个 Rust 生态系统中更具可读性和一致性。


总结与建议

本节课中我们一起学习了 'static 生命周期的含义与谨慎使用的原则,并看到了如何将泛型类型参数、trait 及 trait 约束、泛型生命周期参数结合使用。

现在你已经了解了这些概念,可以编写无重复且适用于多种情况的代码:

  • 泛型类型参数让代码能应用于不同类型。
  • trait 和 trait 约束确保即使类型是泛型的,它们也拥有代码所需的行为。
  • 生命周期注解则确保这种灵活的代码不会有任何悬垂引用。

所有这些分析都发生在编译时,不会影响运行时性能。

所以,这个 longest_with_an_announcement 函数展示了生命周期参数、泛型类型参数和 trait 约束如何在一个函数签名中协同工作。

不过,我的建议是:从简单开始,仅在需要时增加复杂性。不要试图一次性使用所有这些特性,除非你真的需要。大多数时候,你可以用更简单的代码完成任务,当你需要添加更多注解时,编译器会帮助你。

关于生命周期,还有更多可以学习的内容,也存在一些只会在非常高级的场景中才需要的、更复杂的生命周期注解场景。但对于大多数 Rust 代码来说,我们这里所涵盖的内容已经足够了。

至此,我们将暂时告别 Rust 的生命周期主题,并将在未来的视频中重新探讨它。

075:Rust 中的声明宏 🧩

在本节课中,我们将要学习 Rust 中最强大的特性之一:声明宏。宏是一种编写代码来生成其他代码的方式,这被称为元编程。理解宏的基础知识后,它会成为你 Rust 工具箱中极其有用的工具。

什么是宏?

宏是一种定义可重用代码模式的方法。当你调用宏时,它会在编译时、程序实际运行之前展开成代码。这与函数有几个重要的区别。

以下是宏与函数的主要区别:

  • 函数在运行时被调用,宏在编译时被展开。
  • 宏可以接受可变数量的参数。
  • 宏可以生成函数无法生成的代码。

你其实一直在使用宏而没有意识到。println! 是一个宏,vec! 也是一个宏,甚至用于创建格式化字符串的 format! 也是宏。感叹号 ! 是你区分宏和函数的方式。当你看到 println!vec! 时,你就知道你在调用宏,而不是函数。这很重要,因为宏可以做函数做不到的事情,比如接受可变数量的参数。

为什么要使用宏?

有以下几个充分的理由使用宏:

  • 代码生成:宏可以为你生成重复的代码。
  • 可变参数:宏可以接受不同数量的参数。
  • 编译时检查:宏可以在编译时执行检查。
  • 领域特定语言:宏可以为特定任务创建迷你语言。

我个人喜欢将宏视为减少样板代码并使代码更具表现力的一种方式,但你应该谨慎使用它们,并非所有东西都需要是宏。

宏的类型

Rust 有三种类型的宏:

  1. 声明宏:这是我们本系列要学习的类型。它们使用 macro_rules! 宏编写,基于模式匹配,是最常见且最容易理解的。
  2. 过程宏:它们更高级,作为 Rust 代码编写,本系列不会涉及。
  3. 内置宏:由标准库提供。例如 println!print! 都是内置宏。

在本系列中,我们专注于声明宏,因为它们最容易上手,并且涵盖了大多数用例。

宏的工作原理

当你编写宏时,你本质上是在编写一个模式匹配。宏系统查看你传递给宏的代码,将其与你定义的模式进行匹配,然后基于这些模式生成代码。这一切都发生在编译时,因此没有运行时开销。宏会展开成常规的 Rust 代码,然后正常编译。

让我们看一个非常简单的例子来感受一下这是如何工作的。我们将创建一个只返回数字 42 的宏。这个宏叫做 answer!,它不接受任何参数,正如空括号所示。

macro_rules! answer {
    () => {
        42
    };
}

当你调用它时,它会展开为数字 42。现在让我们尝试使用它。

let the_answer = answer!();
println!("The answer is {}", the_answer);

当编译器看到 answer!() 时,它会将其与我们的模式(即空括号)匹配,并将其替换为 42。因此,在编译后的代码中,the_answer 就变成了 42。这是一个简单的例子,但它展示了基本思想:宏匹配模式并生成代码。

在下一集中,我们将学习如何编写具有实际模式的更有用的宏。

总结

本节课中我们一起学习了 Rust 声明宏的基础概念。我们了解了宏是什么,它与函数的区别,以及使用宏的优势。我们还介绍了 Rust 中宏的三种类型,并重点讲解了声明宏的工作原理。通过一个简单的例子,我们看到了宏如何在编译时匹配模式并生成代码。理解这些基础知识是掌握 Rust 宏编程的第一步。

Rust 初学者教程:P76:声明式宏入门 🧩

在本节课中,我们将要学习如何编写 Rust 中的声明式宏。我们将从最基本的宏结构开始,逐步介绍其语法、片段指示符、多模式匹配等核心概念,并通过实际例子展示宏如何简化代码。

上一节我们了解了宏是什么,本节中我们来看看如何编写它们。

声明式宏的语法使用 macro_rules! 宏本身,这有点“元”的意味,但你会习惯的。宏的基本结构如下所示:

macro_rules! macro_name {
    (pattern) => { generated_code };
}

我们有 macro_rules!,后面跟着宏名、要匹配的模式以及要生成的代码。

让我们从一个简单的宏开始,它接受一个参数,这个宏的功能是将一个数字加倍。macro_rules! 声明一个新宏,double 是宏的名称。这里的这部分是匹配表达式的模式。$x 被称为元变量,用于捕获匹配的值。这里的 expr 是一个片段指示符,表示匹配一个表达式。然后我们有了生成的代码,$x 会被匹配到的值替换。

为了展示它是如何工作的,我们可以创建一个名为 result 的变量,它将使用 double! 宏,并在其中插入 5。然后我们可以打印出 5 的倍数。当我们运行这段代码时,输出应该是 10。或者,我们可以移除所有内容,插入 10 + 5 的倍数,无论结果是什么。最终,我们将得到 30

回到 5 的例子,当我们调用 double!(5) 时,宏系统将 5 与模式 $x:expr 进行匹配。它将 5 捕获到元变量 $x 中。然后它生成代码 5 * 2,这意味着最终它将 double!(5) 替换为 5 * 2。当我们处理像 10 + 5 这样的表达式时,也会发生同样的事情。这部分被插入到这里,而这里的这部分替换了那个位。

接下来,我想谈谈片段指示符。这里的这部分被称为片段指示符,它帮助宏系统了解期望哪种 Rust 代码。

以下是您将遇到的最常见的几种:

  • expr:匹配任何表达式,如 5x + y 或一个函数调用。
  • ident:匹配一个标识符,如 xmy_functionMyStruct
  • ty:匹配一个类型,如 i32StringVec<u8>
  • path:匹配一个路径,如标准库中的 std::collections::HashMap
  • literal:匹配一个字面量,如 42"hello"true
  • block:匹配一个代码块,如 { x + y }
  • stmt:匹配一个语句,如 let x = 5;
  • pat:匹配一个模式,如 Some(x)_

随着学习的深入,我们会看到更多,但 exprident 是最常用的。

接下来,让我们创建一个使用 ident 来创建变量的宏。我们将创建一个名为 create_var 的宏,这次我们使用一个名为 $name 的元变量,并指定 ident 片段指示符。

要使用它,我们可以输入 create_var!(my_number),当我们打印 my_number 时,输出应该是 42。这里的 $name:ident 匹配一个标识符。当我们用 my_number 调用 create_var! 时,宏展开为 let my_number = 42;。这在您想用特定变量名生成代码时很有用。

宏也可以有多个模式,这对于处理不同情况很有用。让我们创建一个可以处理一个或两个参数的宏,这个宏将被称为 greet。第一个模式接受一个名字并向该名字问好。第二个模式接受一个名字和一个问候语,然后在需要自定义问候时使用两者。

要使用它,我们可以输入 greet!(Alice),这是我的天才编剧,然后我们将问候 Bob,他是我想象中的伟大虚构人物。当我们运行这个时,您会注意到我们得到“Hello, Alice!”作为输出,并且得到自定义问候语作为输出。

当您调用 greet! 时,宏系统会按顺序尝试匹配每个模式。第一个匹配的模式被使用。因此,greet!(Alice) 匹配第一个模式,而 greet!(Bob, "Good morning") 匹配第二个模式。这与其他语言中的函数重载非常相似,但它是在编译时通过模式匹配发生的。

现在,让我们创建一个真正有用的宏,一个用一些初始值创建哈希映射的宏。这是您可能经常想做的事情。

首先,我们需要从 collections 中导入 HashMap。然后,我们将创建我们的 macro_rules! 并称之为 hashmap。在这里面,我们将有一个相当奇特的模式,这个模式将生成以下代码:它将创建一个新的哈希映射,将键和值插入到映射中,然后返回该映射。

这个宏使用了重复,我们将在接下来的几个视频中详细介绍。现在只需注意,它允许我们像这样创建一个哈希映射。在 main 函数中,我们将创建一个 map,它将使用 hashmap! 宏,我们需要做的就是提供键和值,非常简单。然后我们可以打印里面的值,当我们运行这个时,我们会得到映射中索引 1 的值是 12 包含 2。这比写 let mut hashmap = HashMap::new(); 然后逐个插入每个键要简洁得多。再次强调,我们将在接下来的几个视频中学习重复语法是如何工作的,但现在只需欣赏宏如何使代码更具表现力。

接下来,我想谈谈宏的卫生性。关于宏,需要理解的一个重要概念是卫生性。Rust 宏是卫生的,这意味着它们不会意外捕获或与周围代码中的变量发生冲突。让我们看一个例子。

这里我们将创建一个名为 increment 的宏,它所做的就是将一个值加一。在 main 函数中,我们可以创建一个名为 x 的变量。我们可以尝试递增那个变量,然后我们可以打印递增的结果。这里需要注意的是,我们同时打印了 x 和结果。现在当我们运行这个时,您会看到 5 的增量等于 6x 不会受到宏的影响。再次强调,宏展开为 x + 1,但它并不修改原始的 x。这是因为宏生成代码,它们不执行。生成的代码在宏被调用的位置插入,并且遵循正常的 Rust 作用域规则。这通常是一件好事,它防止宏产生意外的副作用。但有时您可能希望宏创建在宏外部可见的变量,我们将在后面的例子中看到。

最后,在编写宏时,有一些常见的错误需要避免。
以下是需要注意的几点:

  • 忘记分号:每个模式都需要以分号结尾。
  • 使用错误的片段指示符:例如,当您需要 ident 时使用了 expr
  • 没有处理所有情况:确保您的模式覆盖所有有效的输入。
  • 无限递归:小心不要创建会无限展开的宏。

个人建议是从简单开始,并彻底测试您的宏。宏错误可能令人困惑,因此最好逐步增加复杂性。

本节课中我们一起学习了 Rust 声明式宏的基础知识。我们了解了宏的基本结构 macro_rules!,认识了关键的片段指示符如 exprident,并学会了如何编写处理不同参数数量的多模式宏。我们还通过创建 hashmap! 宏的示例,看到了宏如何显著简化重复性代码。最后,我们讨论了宏的卫生性特性以及编写宏时需要避免的常见错误。掌握这些基础是有效使用和编写宏的第一步。

077:深入探索 Rust 宏 🔍

在本节课中,我们将深入探索 Rust 宏的模式匹配。模式匹配是声明式宏工作的核心,它通过匹配代码中的模式来生成新的代码。理解越多的模式,你编写的宏就越强大。我们将通过一系列实际代码中可能用到的例子来学习。

上一节我们介绍了宏的基本语法,本节中我们来看看模式匹配的具体应用。

匹配类型标识符

有时,我们希望匹配一个类型来创建类型别名或泛型辅助工具。让我们创建一个生成结果类型别名的宏。

以下是宏的定义:

macro_rules! result_type {
    ($name:ident = Result<$ok:ty, $err:ty>) => {
        type $name = Result<$ok, $err>;
    };
}

我们使用 macro_rules! 定义宏,宏名为 result_type。模式 $name:ident = Result<$ok:ty, $err:ty> 用于匹配一个标识符(作为类型别名名)和两个类型(作为 Result 的成功与错误类型)。当匹配成功时,宏会生成对应的类型别名代码。

使用这个宏的方法如下:

result_type!(MyResult = Result<i32, String>);

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/9ccfa8b60feff587b5bb515d85680373_8.png)

fn my_function() -> MyResult {
    Ok(42)
}

我们可以创建一个简单的函数,让它返回 MyResult 类型。之后,我们就可以像处理普通 Result 类型一样处理它。

fn handle_result() {
    let example: MyResult = Ok(100);
    match example {
        Ok(value) => println!("成功,值为: {}", value),
        Err(e) => println!("错误: {}", e),
    }
}

这里,如果匹配到 Ok,我们会打印成功的值;如果匹配到 Err,则打印错误信息。重要的是,$ok:ty$err:ty 匹配了类型。这个宏为 Result 类型创建了一个类型别名,当你在多处使用相同的 Result 类型时,这可以使代码更具可读性。

匹配字面量

字面量在需要确保编译时常量时非常有用。让我们创建一个从字面量生成常量的宏。

以下是宏的定义:

macro_rules! constant_from_literal {
    ($name:ident = $value:literal) => {
        const $name: &str = stringify!($value);
    };
}

我们使用 macro_rules! 定义宏,宏名为 constant_from_literal。这里的关键是使用了 $value:literal 说明符,它确保我们只接受实际的字面量,而不是变量。这对于创建必须在编译时已知的常量非常有用。在宏体内,我们使用 stringify! 将字面量转换为字符串,并创建一个同名的常量。

使用这个宏的方法如下:

constant_from_literal!(API_VERSION = "v1.0");
constant_from_literal!(DEFAULT_PORT = 8080);

fn main() {
    println!("API 版本: {}", API_VERSION);
    println!("默认端口: {}", DEFAULT_PORT);
}

要使用它,我们只需用我们选择的名字调用宏。从那时起,我们就可以将其用作常量。运行此代码,输出将是 API 版本和默认端口。

匹配路径

使用 path 说明符匹配路径在处理模块时很有用。让我们创建一个宏来帮助从路径导入和使用项目。

以下是宏的定义:

macro_rules! use_and_call {
    ($path:path) => {
        println!("正在使用路径: {:?}", $path);
    };
}

这里我们创建一个名为 use_and_call 的宏,它接受一个路径。在实际场景中,可能会使用这个路径,但在这个例子中,我们只是打印它。

使用这个宏的方法如下:

use_and_call!(std::collections::HashMap::new);
use_and_call!(std::io::stdout);

我们可以使用宏并插入一个路径,例如创建一个新的 HashMap,或者使用标准输出。运行此代码,我们将在控制台中看到我们正在使用每个路径。

path 说明符匹配限定路径。这在创建与不同模块或 crate 一起工作的宏时很有用,尽管在实践中你通常会直接使用 use 语句。

匹配代码块

代码块对于创建控制流宏非常强大。让我们创建一个更有用的计时宏,它还会打印正在计时的内容。

以下是宏的定义:

macro_rules! benchmark {
    ($name:expr, $block:block) => {
        {
            let start = std::time::Instant::now();
            let result = $block;
            let duration = start.elapsed();
            println!("执行 '{}' 耗时: {:?}", $name, duration);
            result
        }
    };
}

在这个例子中,我们创建一个 benchmark 宏。它接受表达式的名称和代码块。在宏体内,我们获取开始时间,执行代码块,计算耗时,然后打印执行该块所花费的时间,并返回结果。

使用这个宏的方法如下:

fn main() {
    let total = benchmark!("计算总和", {
        let mut sum = 0;
        for i in 0..1_000_000 {
            sum += i;
        }
        sum
    });
    println!("结果是: {}", total);
}

main 函数中,我们可以创建一些使用 benchmark 宏的功能。宏的名称是 "计算总和",里面的代码将 i 加到 total 很多次。在底部,我们也可以打印结果。运行此代码,输出将显示它花费了约 1.2 毫秒,结果是这个数字。

$block:block 匹配一个代码块。这个宏用计时代码和一个标签包装了代码块,使得对代码不同部分进行基准测试变得容易。这是一个你可能最终会实际使用的实用案例。

匹配语句

语句对于生成需要多次执行的代码很有用。让我们创建一个重试语句的宏。

以下是宏的定义:

macro_rules! retry {
    ($max_attempts:expr, $statement:stmt) => {
        {
            let mut attempts = 0;
            loop {
                $statement;
                attempts += 1;
                if attempts >= $max_attempts {
                    break;
                }
            }
        }
    };
}

这里我们创建一个名为 retry 的宏。表达式 $max_attempts:expr 代表最大尝试次数,语句 $statement:stmt 代表要执行的语句。在宏体内,我们跟踪尝试次数并创建一个循环。然后我们插入语句。每次运行时,我们将尝试次数加一。如果尝试次数大于或等于最大尝试次数,我们就跳出循环。

使用这个宏的方法如下:

fn main() {
    let mut counter = 0;
    retry!(3, {
        counter += 1;
        println!("尝试 {}", counter);
    });
}

我们创建一个计数器,然后在 retry 宏内部调用它,指定尝试次数和我们想要执行的语句。运行此代码,输出应该是“尝试 1”、“尝试 2”和“尝试 3”。

$statement:stmt 说明符匹配一个语句。这个例子展示了如何创建一个重试机制,尽管在实践中你可能需要更复杂的错误处理。

组合匹配

你可以组合多个匹配来创建强大的宏。让我们创建一个生成带有构造函数的结构体的宏。

以下是宏的定义:

macro_rules! struct_with_new {
    ($name:ident { $($field_name:ident : $field_type:ty),* $(,)? }) => {
        struct $name {
            $($field_name: $field_type),*
        }
        impl $name {
            fn new($($field_name: $field_type),*) -> Self {
                Self {
                    $($field_name),*
                }
            }
        }
    };
}

对于这个例子,我们创建一个名为 struct_with_new 的宏。这个宏的作用是创建一个结构体定义以及一个 new 构造函数。它匹配一个结构体名称的标识符,然后是一个包含字段定义的块,最后每个字段都有一个名称和一个类型。这比仅仅创建结构体更有用,它还生成了一个方便的构造函数。

使用这个宏的方法如下:

struct_with_new!(Point {
    x: i32,
    y: i32,
});

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/9ccfa8b60feff587b5bb515d85680373_53.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/9ccfa8b60feff587b5bb515d85680373_54.png)

fn main() {
    let point = Point::new(3, 4);
    println!("点: x = {}, y = {}", point.x, point.y);
}

为了展示它是如何工作的,我们进入 main 函数,使用 struct_with_new 创建一个 Point。然后我们可以使用该结构体创建一个新的点,最后我们可以打印这个点及其值。运行此代码,输出应该是一个值为 3 和 4 的点。

匹配特定标记以创建自定义语法

让我们看看如何匹配特定标记来创建自定义语法。创建一个宏,提供一种更清晰的方式来创建键值对。

以下是宏的定义:

macro_rules! kv {
    ($key:ident => $value:expr) => {
        (stringify!($key), $value)
    };
}

这里我们将创建一个名为 kv 的宏,它接受一个标识符作为键,一个表达式作为值。它的作用是将它们转换成一个键值对。

使用这个宏的方法如下:

fn main() {
    let person = "Alice";
    let age = 30;
    let pair1 = kv!(name => person);
    let pair2 = kv!(age => age);
    println!("键值对 1: {:?}", pair1);
    println!("键值对 2: {:?}", pair2);
}

我们创建一个名为 Alice 的人,让年龄等于 30。要使用我们的宏,我们只需要调用 kv!,提供一个标识符和值。然后为了查看里面有什么,我们可以打印 pair1pair2。打印后,我们应该会得到这些键值。

这里我们匹配 => 标记,为键值对创建类似 DSL 的语法。宏自动将标识符转换为字符串,这对于配置或日志记录很有用。

条件匹配

作为本视频的最后一部分,我们将讨论条件匹配。你可以创建灵活处理不同情况的宏。让我们创建一个处理有无尾随逗号的宏,这是 Rust 宏中的常见模式。

以下是宏的定义:

macro_rules! create_array {
    ($($element:expr),* $(,)?) => {
        [$($element),*]
    };
}

这里我们创建一个名为 create_array 的宏。这个宏的作用是允许我们创建带有或不带有尾随逗号的数组。如你所见,这里我们有 1, 2, 3 没有尾随逗号,以及 4, 5, 6, 带有尾随逗号。很酷的是两者都有效。

使用这个宏的方法如下:

fn main() {
    let arr1 = create_array!(1, 2, 3);
    let arr2 = create_array!(4, 5, 6,);
    println!("数组 1: {:?}", arr1);
    println!("数组 2: {:?}", arr2);
}

我知道这看起来语法很多,但这里重要的是,我们通过 $(,)? 告诉 Rust 这个宏应该可选地匹配一个逗号。这使得宏更加灵活和用户友好,无论是否有尾随逗号它都能工作。这是 Rust 宏中一个非常常见的模式。

总结

本节课中我们一起深入学习了 Rust 宏的模式匹配。我们从匹配类型标识符开始,学习了如何创建类型别名宏。接着,我们探讨了匹配字面量来生成编译时常量,以及匹配路径来处理模块导入。通过匹配代码块,我们创建了实用的性能基准测试宏。我们还学习了匹配语句来实现重试逻辑,并组合多种匹配来生成带有构造函数的复杂结构体。最后,我们了解了如何匹配特定标记创建自定义语法,以及使用条件匹配使宏更灵活地处理有无尾随逗号的情况。掌握这些模式匹配技巧,将极大地增强你编写强大、灵活且可读性高的 Rust 宏的能力。

078:声明式宏中的重复模式 🔁

在本节课中,我们将继续学习 Rust 中的声明式宏。声明式宏最强大的特性之一是重复。它允许你匹配和生成可变数量的代码。如果你曾好奇像 vec! 这样的宏如何接受任意数量的参数,答案就是重复。重复是让宏真正强大的原因,它们可以生成手动编写会非常繁琐甚至不可能的代码。

基础重复语法

上一节我们介绍了宏的基本概念,本节中我们来看看重复模式的基础语法。宏中的重复使用特殊的语法:$(...)*$(...)+$(...)?

  • $(...)* 匹配零次或多次。
  • $(...)+ 匹配一次或多次。
  • $(...)? 匹配零次或一次。

在括号内,你放置要重复的模式;在括号外,你放置为每个匹配项生成的内容。

让我们从一个实际例子开始:一个可以从任意数量的值创建元组的宏。

macro_rules! tuple {
    ($($item:expr),* $(,)?) => {
        ($($item),*)
    };
}

要使用它,我们可以这样做:

let t1 = tuple!(1, 2, 3);
let t2 = tuple!(4, 5, 6, 7, 8);
println!("{:?}, {:?}", t1, t2); // 输出: (1, 2, 3), (4, 5, 6, 7, 8)

现在让我们快速解释一下这个语法:

  • $($item:expr),* 匹配零个或多个由逗号分隔的表达式。
  • $(,)? 可选地匹配一个尾随逗号,这允许我们在元组末尾添加逗号。
  • 最后,($($item),*) 生成一个包含所有匹配项的元组。

* 表示零次或多次,因此这个宏适用于任意数量的参数。

重复运算符详解

接下来,让我们详细讨论三个重复运算符。它们的工作原理如下:

  • *(零次或多次):模式可以出现 0、1、2 或更多次。
  • +(一次或多次):模式必须至少出现一次。
  • ?(零次或一次):模式必须出现零次或一次。

以下是每种运算符的实用示例。

首先是 *(零次或多次),它可以为空,这对于可选参数非常有用。

macro_rules! log {
    ($msg:expr $(, $arg:expr)*) => {
        println!($msg $(, $arg)*);
    };
}

然后我们可以创建另一个使用 +(一次或多次)的宏 min,它必须至少有一个参数,这对于必需列表很有用。

macro_rules! min {
    ($first:expr $(, $rest:expr)+) => {
        {
            let mut min_val = $first;
            $(
                if $rest < min_val {
                    min_val = $rest;
                }
            )+
            min_val
        }
    };
}

要使用这些宏:

log!("应用程序已启动"); // 零个格式化参数
log!("错误:连接失败", "网络超时"); // 一个格式化参数
let result = min!(5, 2, 8, 1);
println!("最小值是: {}", result); // 输出: 最小值是: 1

这里需要注意的重要一点是,log 宏可以在没有格式化参数的情况下工作,它只需要一条消息。而 min 宏确实需要至少一个值。如果我们移除所有值并尝试运行程序,它将无法编译。

因此,当你想允许零次匹配时使用 *,当你需要至少一个值时使用 +

重复模式中的分隔符

在重复模式中,你可以在项目之间指定一个分隔符。不同的分隔符可以创建不同的语法。

对于这个例子,我们将创建一个允许我们创建集合的宏。

macro_rules! set {
    ($($item:expr),*) => {
        {
            let mut s = std::collections::HashSet::new();
            $(s.insert($item);)*
            s
        }
    };
    ($($item:expr);*) => {
        {
            let mut s = std::collections::HashSet::new();
            $(s.insert($item);)*
            s
        }
    };
}

你应该注意到这里有两个模式:一个检查逗号,另一个检查分号。两者都从我们提供的数据创建新的集合。因此,我们现在可以使用逗号和分号来创建新集合。

let set1 = set!{1, 2, 3, 4}; // 使用逗号
let set2 = set!{5; 6; 7; 8}; // 使用分号

你可以使用任何标记作为分隔符,无论是逗号、分号、箭头、加号,甚至是自定义标记。分隔符必须在模式和扩展中的每次重复之间出现。这允许你为同一个宏创建不同的语法。

嵌套重复

接下来是嵌套重复。你可以嵌套重复以处理更复杂的模式。

让我们创建一个宏,用简洁的语法创建一个 2D 数组(也称为矩阵)。

macro_rules! matrix {
    ($([$($elem:expr),* $(,)?]),* $(,)?) => {
        {
            let mut rows = Vec::new();
            $(
                let mut row = Vec::new();
                $(
                    row.push($elem);
                )*
                rows.push(row);
            )*
            rows
        }
    };
}

要使用它,我们将从这些容器创建一个矩阵并打印每一行。

let m = matrix!(
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
);
for row in &m {
    println!("{:?}", row);
}
// 输出:
// [1, 2, 3]
// [4, 5, 6]
// [7, 8, 9]

这里我们有:

  • 外部重复:匹配多行。
  • 内部重复:匹配每行中的项目。
  • 在扩展中,我们有一个为每一行生成向量的模式。

这展示了嵌套重复的强大功能。你可以用简洁、可读的语法创建复杂的数据结构。

总结

本节课中我们一起学习了 Rust 声明式宏中的重复模式。我们介绍了:

  1. 基础重复语法:$(...)*$(...)+$(...)?
  2. 重复运算符的区别和应用场景。
  3. 如何使用分隔符为宏定义灵活的输入语法。
  4. 如何通过嵌套重复来处理和生成复杂的数据结构(如矩阵)。

重复是宏编程的核心,它使得宏能够灵活地处理可变数量的输入,并生成强大而简洁的代码。关于重复语法还有很多内容,我们将在后续视频中继续探讨。

079:Rust 中的重复模式 🚀

在本节课中,我们将继续学习 Rust 宏中的重复模式。我们将探讨多重重复、条件重复、计数重复等高级用法,并通过实例展示如何利用这些模式创建灵活且强大的宏。

上一节我们介绍了 Rust 宏中重复模式的基础概念,本节中我们来看看更复杂的重复模式应用。

多重重复模式

在 Rust 中,你可以在同一个模式中使用多个重复器,并且它们必须具有相同数量的匹配项。这种模式非常适合处理像键值对这样的结构。

以下是创建一个使用 hashmap! 宏的示例:

let config = hashmap! {
    "timeout" => 30,
    "retries" => 3,
    "debug" => true,
};
println!("Config: {:?}", config);

运行此代码,输出将显示 config 包含我们提供的数据。回到宏定义本身,需要注意的是,key_exprvalue_expr 都重复了相同的次数。宏系统确保每个键都对应一个值。这就是宏中键值对的工作方式,也是一种非常常见的模式。

注意:不能只键入 timeout 而不提供值,这会导致错误。必须确保同时提供键和值对。

条件重复

条件重复可以使重复的某些部分变为可选。这通过使用问号运算符 ? 来实现。

以下是一个创建带有可选参数的宏的示例:

macro_rules! call_with_log {
    ($func:ident($($arg:expr),* $(,)?)) => {
        println!("Calling {} with args: {:?}", stringify!($func), &[$($arg),*]);
        $func($($arg),*)
    };
}

fn greet(name: &str) {
    println!("Hello, {}!", name);
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/705964c04e1873f8171b389f3e5fd375_15.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/705964c04e1873f8171b389f3e5fd375_16.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/705964c04e1873f8171b389f3e5fd375_17.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/705964c04e1873f8171b389f3e5fd375_18.png)

fn add(a: i32, b: i32) -> i32 {
    a + b
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/705964c04e1873f8171b389f3e5fd375_19.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/705964c04e1873f8171b389f3e5fd375_20.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/705964c04e1873f8171b389f3e5fd375_21.png)

fn main() {
    call_with_log!(greet("Alice"));
    println!("Result: {}", call_with_log!(add(1, 2)));
    // 即使有尾随逗号也能工作
    println!("Result: {}", call_with_log!(add(1, 2,)));
}

运行此代码,你会注意到我们同时得到了日志输出和原始函数调用的结果。回到宏定义,这里的问号 ? 使尾随逗号成为可选。这是宏中一个非常常见的模式,旨在使宏更加灵活和用户友好,允许用户按照自己偏好的风格编写代码。

计数重复

有时你需要知道某个模式会重复多少次。你可以通过使用重复器本身来实现这一点。

让我们创建一个宏,用于格式化消息并计数:

macro_rules! format_with_count {
    ($($item:expr),*) => {{
        let count = 0usize;
        $(let count = count + 1;)* // 对每个重复项递增计数器
        let items = vec![$($item),*];
        (count, items)
    }};
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/705964c04e1873f8171b389f3e5fd375_30.png)

fn main() {
    let (count, items) = format_with_count!("apple", "banana", "cherry");
    println!("Count: {}, Items: {:?}", count, items);
}

这个宏的作用是将项目格式化为一个向量并计算其数量。它的工作原理是为每次重复生成一个语句来计数,每次迭代都会递增计数器,最终得到总计数。当你需要知道传递了多少个参数时,这非常有用。

创建自定义 Vec 宏

理解了重复模式后,我们现在可以创建一个自定义的 vec! 宏。这是一个简化版本:

macro_rules! my_vec {
    () => {
        Vec::new()
    };
    ($($elem:expr),*) => {
        {
            let mut v = Vec::new();
            $(v.push($elem);)*
            v
        }
    };
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/705964c04e1873f8171b389f3e5fd375_37.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/705964c04e1873f8171b389f3e5fd375_39.png)

fn main() {
    let empty: Vec<i32> = my_vec!();
    let numbers = my_vec!(1, 2, 3, 4, 5);
    println!("Empty: {:?}", empty);
    println!("Numbers: {:?}", numbers);
}

运行此代码,我们将得到一个空向量和一个包含那些数字的向量。这演示了向量宏的核心概念。真正的 vec! 宏在可能的情况下会预分配容量,但此示例展示了重复模式如何使其能够处理任意数量的参数。

生成多个项目

你还可以使用重复模式来生成多个项目,而不仅仅是表达式。

让我们创建一个生成多个常量的宏:

macro_rules! constants {
    ($($name:ident = $value:expr);*) => {
        $(const $name: i32 = $value;)*
    };
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/705964c04e1873f8171b389f3e5fd375_44.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/705964c04e1873f8171b389f3e5fd375_45.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/rsfl-rs-prog/img/705964c04e1873f8171b389f3e5fd375_46.png)

constants! {
    MAX_SIZE = 1024;
    DEFAULT_TIMEOUT = 30;
    BUFFER_SIZE = 256;
}

fn main() {
    println!("Max Size: {}", MAX_SIZE);
    println!("Default Timeout: {}", DEFAULT_TIMEOUT);
    println!("Buffer Size: {}", BUFFER_SIZE);
}

如你所见,我们定义了 MAX_SIZEDEFAULT_TIMEOUTBUFFER_SIZE。这里我们生成的是整个常量声明,而不仅仅是表达式。这表明重复模式可以生成任何类型的 Rust 代码,而不仅仅是值,这在定义许多类似项目以减少样板代码时非常有用。

常见重复模式

最后,我们展示一些在实际代码中常见的重复模式。

以下是几种常见模式:

  • 带可选尾随逗号的列表$($item:expr),* $(,)?
  • 键值对$($key:expr => $value:expr),*
  • 交替模式:类似于键值对,但语法不同,例如 $($k:ident: $v:expr),*
  • 带分隔符的嵌套模式:用于处理更复杂的嵌套结构。
  • 生成多个项目:用于一次性声明多个常量、函数或结构体。

这些模式涵盖了大多数用例。一旦你理解了它们,就可以根据特定需求进行组合和修改。关键在于理解重复语法如何映射到你想要生成的代码。

本节课中我们一起学习了 Rust 宏中重复模式的高级应用,包括多重重复、条件重复、计数重复,以及如何利用这些模式创建自定义宏和生成代码。掌握这些模式将极大地提升你编写灵活、强大宏的能力。

posted @ 2026-03-29 09:25  布客飞龙II  阅读(13)  评论(0)    收藏  举报