在C语言编程时,我们会包含多个头文件,然后就可以使用头文件中定义的函数和数据类型。但是C语言这一套容易出现一些问题:

  1. 隐式依赖
  2. 全局命名空间污染
  3. 重复包含问题

RUST的模块系统很好的避免了这些问题

依旧是熟悉的复制粘贴

到目前为止,我们编写的程序都在一个文件的一个模块中。伴随着项目的增长,你应该通过将代码分解为多个模块和多个文件来组织代码。一个包(package)可以包含多个二进制 crate 项和一个可选的库 crate。伴随着包的增长,你可以将包中的部分代码提取出来,做成独立的 crate,这些 crate 则作为外部依赖项。本章将会涵盖所有这些概念。对于一个由一系列相互关联的包组成的超大型项目,Cargo 提供了工作空间workspaces)这一功能,我们将在第十四章的 “Cargo Workspaces” 对此进行讲解。

我们也会讨论封装来实现细节,这可以让你在更高层面重用代码:你实现了一个操作后,其他的代码可以通过该代码的公共接口来进行调用,而不需要知道它是如何实现的。你在编写代码时可以定义哪些部分是其他代码可以使用的公共部分,以及哪些部分是你有权更改实现细节的私有部分。这是另一种减少你在脑海中记住项目内容数量的方法。

这里有一个需要说明的概念 “作用域(scope)”:代码所在的嵌套上下文有一组定义为 “in scope” 的名称。当阅读、编写和编译代码时,程序员和编译器需要知道特定位置的特定名称是否引用了变量、函数、结构体、枚举、模块、常量或者其他有意义的项。你可以创建作用域,以及改变哪些名称在作用域内还是作用域外。同一个作用域内不能拥有两个相同名称的项;可以使用一些工具来解决名称冲突。

Rust 有许多功能可以让你管理代码的组织,包括哪些细节可以被公开,哪些细节作为私有部分,以及程序中各个作用域中有哪些名称。这些特性,有时被统称为 “模块系统(the module system)”,包括:

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

包和Create

crate 是 Rust 在编译时最小的代码单位。即使你用 rustc 而不是 cargo 来编译一个单独的源代码文件(正如我们在第 1 章“编写并运行 Rust 程序”中所做的那样),编译器还是会将那个文件视为一个 crate。crate 可以包含模块,模块可以定义在其他文件,然后和 crate 一起编译,我们会在接下来的章节中遇到。

crate 有两种形式:二进制 crate 和库 crate。二进制 crateBinary crates)可以被编译为可执行程序,比如命令行程序或者服务端。它们必须有一个名为 main 函数来定义当程序被执行的时候所需要做的事情。目前我们所创建的 crate 都是二进制 crate。

库 crateLibrary crates)并没有 main 函数,它们也不会编译为可执行程序。相反它们定义了可供多个项目复用的功能模块。比如 第二章rand crate 就提供了生成随机数的功能。大多数时间 Rustaceans 说的 “crate” 指的都是库 crate,这与其他编程语言中 “library” 概念一致。

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

**包(package)是提供一系列功能的一个或者多个 crate 的捆绑。**一个包会包含一个 Cargo.toml 文件,阐述如何去构建这些 crate。Cargo 实际上就是一个包,它包含了用于构建你代码的命令行工具的二进制 crate。其他项目也依赖 Cargo 库来实现与 Cargo 命令行程序一样的逻辑。

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

让我们来看看创建包的时候会发生什么。首先,我们输入命令 cargo new my-project

1
2
3
4
5
6
7
$ cargo new my-project
Created binary (application) `my-project` package
$ ls my-project
Cargo.toml
src
$ ls my-project/src
main.rs

运行了这条命令后,我们先用 ls (译者注:此命令为 Linux 平台的指令,Windows 下可用 dir)来看看 Cargo 给我们创建了什么,Cargo 会给我们的包创建一个 Cargo.toml 文件。查看 Cargo.toml 的内容,会发现并没有提到 src/main.rs,**因为 Cargo 遵循的一个约定:src/main.rs 就是一个与包同名的二进制 crate 的 crate 根。**同样的,Cargo 知道如果包目录中包含 src/lib.rs,则包带有与其同名的库 crate,且 src/lib.rs 是 crate 根。crate 根文件将由 Cargo 传递给 rustc 来实际构建库或者二进制项目。

1
2
3
4
5
6
7
8
9
10
[package]
name = "Functions"
version = "0.1.0"
edition = "2024"

[dependencies]
//假如我们要添加main跟lib之外的其他依赖
[[bin]]
name = "another-app" # 这是你希望这个额外二进制 crate 的名称
path = "src/bin/another_app.rs" # 这是它的源文件路径

在此,我们有了一个只包含 src/main.rs 的包,意味着它只含有一个名为 my-project 的二进制 crate。如果一个包同时含有 src/main.rssrc/lib.rs,则它有两个 crate:一个二进制的和一个库的,且名字都与包相同。通过将文件放在 src/bin 目录下,一个包可以拥有多个二进制 crate:每个 src/bin 下的文件都会被编译成一个独立的二进制 crate。

来点人话

单一二进制 Crate

最简单的 Rust 包结构是一个只包含 src/main.rs 文件的包。在这种情况下,你的包将编译成一个名为 my-project二进制 crate(其中 my-project 是你的包名)。所有代码都将包含在 main.rs 文件中。

1
2
3
4
my-project/
├── src/
│ └── main.rs
└── Cargo.toml

二进制 Crate 和库 Crate 共存

如果一个包同时包含 src/main.rssrc/lib.rs,那么它将拥有两个 crate:一个二进制 crate 和一个库 crate。这两个 crate 的名字都默认与包名相同。

  • src/main.rs 中的代码会编译成可执行程序。
  • src/lib.rs 中的代码会编译成库,可以被其他项目(包括当前包的二进制 crate)引用。
1
2
3
4
5
my-project/
├── src/
│ ├── main.rs
│ └── lib.rs
└── Cargo.toml

在这种结构中,main.rs 通常会使用 use my_project;use crate;(在 Rust 2018 版及更高版本中)来引用 lib.rs 中定义的模块和函数。

多个二进制 Crate

Rust 包可以通过将文件放置在 src/bin 目录下拥有多个二进制 cratesrc/bin 目录下的每个文件都会被编译成一个独立的二进制 crate。每个文件的名称(不包括 .rs 后缀)将成为对应二进制 crate 的名称。

1
2
3
4
5
6
7
8
my-project/
├── src/
│ ├── main.rs // 默认的二进制 crate
│ ├── lib.rs // 库 crate
│ └── bin/
│ ├── my_tool.rs // 额外的二进制 crate: `my_tool`
│ └── another_app.rs // 额外的二进制 crate: `another_app`
└── Cargo.toml

在这个例子中:

  • cargo run 默认会编译并运行 src/main.rs 对应的二进制 crate。

  • 你可以使用 cargo run --bin my_tool 来运行 src/bin/my_tool.rs 对应的二进制 crate。

  • 同样,cargo run --bin another_app 会运行 src/bin/another_app.rs

    当 Rust 包包含多个二进制 crate 时,src/main.rs 中的内容仍然是默认运行的二进制 crate

    即使你在 src/bin 目录下添加了其他的 .rs 文件来创建额外的二进制 crate,cargo run 命令在没有额外指定的情况下,依然会查找并运行 src/main.rs 编译而成的可执行文件。

    如果你想运行 src/bin 目录下特定的二进制 crate,你需要使用 cargo run --bin <crate-name> 命令,其中 <crate-name> 是你想要运行的二进制 crate 的文件名(不包括 .rs 后缀)。

    举个例子:

    假设你有这样的项目结构:

    1
    2
    3
    4
    5
    6
    my-project/
    ├── src/
    │ ├── main.rs // 默认的二进制 crate
    │ └── bin/
    │ └── my_tool.rs // 另一个二进制 crate: `my_tool`
    └── Cargo.toml
    • 运行 cargo run 会编译并执行 src/main.rs 中的代码。
    • 运行 cargo run --bin my_tool 则会编译并执行 src/bin/my_tool.rs 中的代码。

    所以,src/main.rs 就像是你的项目主要的或默认的入口点,而 src/bin 中的文件则提供了额外的、可独立运行的工具或应用程序。

    暂时不知道有什么用

模块的组织

在上述基本结构的基础上,你可以在 src 目录下创建子目录来进一步组织你的模块。每个 mod.rs 文件或与目录同名的 .rs 文件(例如 my_module/mod.rsmy_module.rs)都代表一个模块。

例如,如果你在 src/lib.rs 中有一个复杂的库,你可以将其拆分为多个文件和目录:

1
2
3
4
5
6
7
8
9
10
11
my-project/
├── src/
│ ├── main.rs
│ └── lib.rs
│ │ // lib.rs 的内容可能包含 `mod data;` 和 `mod utils;`
│ ├── data/ // 对应 `data` 模块
│ │ ├── mod.rs // data 模块的根文件
│ │ └── processing.rs // data 模块下的子模块 `processing`
│ └── utils/ // 对应 `utils` 模块
│ └── mod.rs // utils 模块的根文件
└── Cargo.toml

在这种结构中:

  • src/lib.rs 可以通过 mod data;mod utils; 声明并引入 datautils 模块。
  • src/data/mod.rsdata 模块的入口点,它可以通过 mod processing; 声明 processing 子模块。

总结来说,Rust 的文件目录结构和模块系统紧密配合,主要遵循以下原则:

  • src/main.rs: 默认的二进制 crate 入口。
  • src/lib.rs: 默认的库 crate 入口。
  • src/bin/ 目录: 用于存放额外的二进制 crate。
  • 子目录和 mod.rs (或与目录同名的 .rs 文件): 用于组织更复杂的模块结构。

理解这些约定将帮助你有效地组织和管理你的 Rust 项目,无论它们是简单的工具还是大型的应用程序。

Crate 根是什么东西?

在 Rust 里,Crate 根 (Crate Root) 可以理解为你整个 Rust 项目(或者说一个 Crate)的“总入口文件”。当 Rust 编译器开始编译你的代码时,它就是从这个文件开始读起,然后根据里面的声明,一步步地找到并包含你项目里所有其他的模块和代码。

可以把它想象成一本书的“扉页”或者一个网站的“首页”。

Crate 根在哪里?

Crate 根文件的位置是约定俗成的:

  1. 对于一个库 Crate (Library Crate)
    • 它的 Crate 根文件是 src/lib.rs
    • 这样的 Crate 编译出来的是一个库(就像一个工具箱,提供很多功能给其他代码使用),而不是一个独立运行的程序。
  2. 对于一个二进制 Crate (Binary Crate)
    • 它的 Crate 根文件是 src/main.rs
    • 这样的 Crate 编译出来的是一个可执行程序(就像一个应用程序,可以直接运行)。

Crate 根的作用是什么?

Crate 根文件主要有以下几个作用:

  • 编译起点:编译器总是从这里开始扫描和解析你的代码。
  • 模块声明:你会在 Crate 根文件中使用 mod 关键字来声明你 Crate 中所有顶级的模块。比如,在 src/main.rs 中写 mod garden;,就是告诉编译器:“我有一个叫做 garden 的模块,请去 src/garden.rssrc/garden/mod.rs 找到它的代码。”
  • 公共接口(对于库 Crate):如果你的 Crate 是一个库,src/lib.rs 就定义了这个库向外部暴露的公共 API。其他项目在使用你的库时,会通过这个文件来访问你的功能。
  • 程序入口(对于二进制 Crate):如果你的 Crate 是一个二进制程序,src/main.rs 中的 fn main() 函数就是程序执行的起点。

简单来说:

Crate 根就是你的 Rust 项目(一个 Crate)的“大脑”或者“指挥中心”,它告诉编译器如何找到并组织你所有的代码。没有它,编译器就不知道从何开始构建你的程序或库。

官方文档依旧不说人话

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

在本节,我们将讨论模块和其它一些关于模块系统的部分,如允许你命名项的 路径paths);用来将路径引入作用域的 use 关键字;以及使项变为公有的 pub 关键字。我们还将讨论 as 关键字、外部包(external packages)和 glob 运算符(glob operator)。

首先,我们将从一系列的规则开始,在你未来组织代码的时候,这些规则可被用作简单的参考。接下来我们将会详细的解释每条规则。

模块小抄(Cheat Sheet)

在深入了解模块和路径的细节之前,这里提供一个简单的参考,用来解释模块、路径、use关键词和pub关键词如何在编译器中工作,以及大部分开发者如何组织他们的代码。我们将在本章中举例说明每条规则,但这是回顾模块工作原理的绝佳参考。

  • 从 crate 根节点开始: 当编译一个 crate, 编译器首先在 crate 根文件(通常,对于一个库 crate 而言是 src/lib.rs,对于一个二进制 crate 而言是 src/main.rs)中寻找需要被编译的代码。

  • 声明模块

    : 在 crate 根文件中,你可以声明一个新模块;比如,用

    1
    mod garden;

    声明了一个叫做

    1
    garden

    的模块。编译器会在下列路径中寻找模块代码:

    • 内联,用大括号替换 mod garden 后跟的分号
  • 在文件 src/garden.rs

    • 在文件 src/garden/mod.rs
  • 声明子模块

    : 在除了 crate 根节点以外的任何文件中,你可以定义子模块。比如,你可能在src/garden.rs 中声明

    1
    mod vegetables;

    。编译器会在以父模块命名的目录中寻找子模块代码:

    • 内联,直接在 mod vegetables 后方不是一个分号而是一个大括号
  • 在文件 src/garden/vegetables.rs

    • 在文件 src/garden/vegetables/mod.rs
  • 模块中的代码路径: 一旦一个模块是你 crate 的一部分,你可以在隐私规则允许的前提下,从同一个 crate 内的任意地方,通过代码路径引用该模块的代码。举例而言,一个 garden vegetables 模块下的 Asparagus 类型可以通过 crate::garden::vegetables::Asparagus 访问。

  • 私有 vs 公用: 一个模块里的代码默认对其父模块私有。为了使一个模块公用,应当在声明时使用 pub mod 替代 mod。为了使一个公用模块内部的成员公用,应当在声明前使用pub

  • use 关键字: 在一个作用域内,use关键字创建了一个项的快捷方式,用来减少长路径的重复。在任何可以引用 crate::garden::vegetables::Asparagus 的作用域,你可以通过 use crate::garden::vegetables::Asparagus; 创建一个快捷方式,然后你就可以在作用域中只写 Asparagus 来使用该类型。

这里我们创建一个名为backyard的二进制 crate 来说明这些规则。该 crate 的路径同样命名为backyard,该路径包含了这些文件和目录:

1
2
3
4
5
6
7
8
backyard
├── Cargo.lock
├── Cargo.toml
└── src
├── garden
│ └── vegetables.rs
├── garden.rs
└── main.rs

这个例子中的 crate 根文件是 src/main.rs,该文件包含了:

文件名:src/main.rs

1
2
3
4
5
6
7
8
use crate::garden::vegetables::Asparagus;

pub mod garden;

fn main() {
let plant = Asparagus {};
println!("I'm growing {plant:?}!");
}

pub mod garden; 行告诉编译器将 src/garden.rs 中发现的代码包含进来:

文件名:src/garden.rs

1
pub mod vegetables;

在此处,pub mod vegetables; 意味着在 src/garden/vegetables.rs 中的代码也应该被包含。这些代码是:

1
2
#[derive(Debug)]
pub struct Asparagus {}

现在让我们深入了解这些规则的细节并在实践中演示它们!

在模块中对相关代码进行分组

模块让我们可以将一个 crate 中的代码进行分组,以提高可读性与重用性。因为一个模块中的代码默认是私有的,所以还可以利用模块控制项的私有性privacy)。私有项是不可为外部使用的内在详细实现。我们也可以将模块和它其中的项标记为公开的,这样,外部代码就可以使用并依赖于它们。

作为示例,让我们编写一个提供餐厅功能的库 crate。我们将定义函数的签名,但将其函数体留空以便将注意力集中在代码的组织结构上而不是餐厅实现的细节。

在餐饮业,餐馆中会有一些地方被称之为前台front of house),还有另外一些地方被称之为后台back of house)。前台是招待顾客的地方;这包括接待员为顾客安排座位、服务员接受点单和付款、调酒师制作饮品的地方。后台则是厨师和烹饪人员在厨房工作、洗碗工清理餐具,以及经理处理行政事务的区域。

为了以这种方式构建我们的 crate,我们可以将其功能组织到嵌套模块中。通过执行 cargo new restaurant --lib 来创建一个新的名为 restaurant 的库。然后将示例 7-1 中所罗列出来的代码放入 src/lib.rs 中,来定义一些模块和函数签名;这段代码即为前台部分。

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

mod serving {
fn take_order() {}
fn serve_order() {}
fn take_payment() {}
}
}

示例 7-1:一个包含了其他内置了函数的模块的 front_of_house 模块

我们使用 mod 关键字来定义模块,后跟模块名(本例中叫做 front_of_house),并且用花括号包围模块的主体。在模块内,我们还可以定义其它的模块,就像本例中的 hostingserving 模块。模块还可以保存一些定义的其它项,比如结构体、枚举、常量、trait、或者如示例 7-1 所示的函数。

通过使用模块,我们可以将相关的定义分组到一起,并指出它们为什么相关。程序员可以通过使用这段代码,更加容易地找到他们想要的定义,因为他们可以基于分组来对代码进行导航,而不需要阅读所有的定义。程序员向这段代码中添加一个新的功能时,他们也会知道代码应该放置在何处,可以保持程序的组织性。

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

示例 7-2 展示了示例 7-1 中模块树的结构。

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

示例 7-2: 示例 7-1 中代码的模块树

这个树展示了一些模块是如何被嵌入到另一个模块的(例如,hosting 嵌套在 front_of_house 中)。这个树还展示了一些模块是互为兄弟siblings)的,这意味着它们定义在同一模块中;hostingserving 被一起定义在 front_of_house 中。继续沿用家庭关系的比喻,如果一个模块 A 被包含在模块 B 中,我们将模块 A 称为模块 B 的 child)模块,模块 B 则是模块 A 的 parent)模块。注意,整个模块树都植根于名为 crate 的隐式模块下。

这个模块树可能会令你想起电脑上文件系统的目录树;这是一个非常恰当的类比!就像文件系统的目录,你可以使用模块来组织你的代码。并且,就像目录中的文件,我们需要一种方法来找到模块。

上面这段话多少看的人有点懵逼,我们现在让AI来说点人话

1. Rust 代码的起点:从“大门”开始找

你的整个 Rust 项目,无论多大,都有一个固定的“大门”。

  • 如果你写的是一个可执行程序(像一个 .exe 文件),大门通常是 src/main.rs
  • 如果你写的是一个库(给别人用的代码包),大门就是 src/lib.rs

编译器盖房子时,第一步就是从这个“大门”开始,一步步往里找代码。

2. 声明模块:给你的代码分房间

就像家里有客厅、卧室、厨房一样,你的代码也需要分门别类。在“大门”文件里(比如 src/main.rs),你可以用 mod 房间名; 来声明一个新的“房间”。

比如:mod garden;

这告诉编译器:“嘿,我有一个叫 garden 的房间!” 编译器就会去以下几个地方找这个房间的具体图纸:

  • 直接在大括号里: 你可以直接在 mod garden 后面跟一对 {},把所有代码写在里面,就像一个“开放式厨房”。
  • 在同级文件里: 编译器会去 src/garden.rs 这个文件里找。
  • 在同级文件夹里: 编译器会去 src/garden/mod.rs 这个文件里找(mod.rs 文件就像这个文件夹的“总说明书”)。

3. 声明子模块:房间里再隔小间

如果你的“房间”里内容太多,还可以再隔出“小间”。比如,你已经在 src/garden.rs(也就是 garden 房间的图纸)里,想再分一个 vegetables 的“小间”。

你可以写:mod vegetables;

这时,编译器就知道要去 garden 这个“房间”里找 vegetables 这个“小间”的图纸了。它会去:

  • 直接在大括号里: 和上面一样,可以直接写在大括号里。
  • 在父房间的子文件夹里: 编译器会去 src/garden/vegetables.rs 这个文件里找。
  • 在父房间的子文件夹里的 mod.rs 文件: 编译器会去 src/garden/vegetables/mod.rs 这个文件里找。

4. 模块中的代码路径:怎么找到某个东西?

想象你在一个大房子里,要描述某个东西在哪儿。你会说“在客厅里沙发的旁边”。在 Rust 里,你要引用一个模块里的东西,也需要指明它的“路径”。

比如,如果你有个叫 Asparagus 的类型,它在 garden 房间的 vegetables 小间里,那么它的完整路径就是: crate::garden::vegetables::Asparagus

  • crate:: 就代表你这个项目的根目录(也就是那个“大门”)。
  • 后面跟着的就是模块一层层嵌套的名字。

5. 私有 vs 公用:谁能看到我的东西?

这是关于隐私的规则:

  • 默认是私有的: 你在一个模块里写的东西,默认只有这个模块自己能用,外面是看不到的。就像你卧室里的东西,客厅里的人看不到。
  • pub mod 公开模块: 如果你想让整个 garden 房间都能被外面的人看到,你需要在声明它的时候加上 pub,写成 pub mod garden;
  • pub 公开模块内部成员: 即使一个房间是公开的,里面的家具(比如结构体、函数)默认还是私有的。如果你想让 Asparagus 这个“家具”也能被外面看到,你也要在它前面加上 pub

6. use 关键字:走捷径,少写路名!

想象你每次去厨房都要说“去客厅,然后穿过餐厅,再到厨房”。这太麻烦了!

use 关键字就是用来走捷径的。比如:

1
use crate::garden::vegetables::Asparagus;

这行代码就是告诉 Rust:“以后在这个地方,我提到 Asparagus,就直接指代 crate::garden::vegetables::Asparagus 这个东西,不用每次都写那么长一串路径了!”

这样,你以后在这个文件里直接写 Asparagus 就可以了,方便很多。

backyard 例子来说明:

现在,我们把上面的规则套到那个 backyard 项目里,就像搭建一个真实的小花园:

  1. src/main.rs (主屋,程序的入口):
    • pub mod garden;:主屋里声明了一个公开garden 房间(所以编译器去 src/garden.rs 里找这个房间的图纸)。
    • use crate::garden::vegetables::Asparagus;:这里设置了一个捷径!以后我直接写 Asparagus,就知道指的是 garden 房间里 vegetables 小间里的 Asparagus 这个植物。
    • fn main() { ... }:这是程序真正运行的地方,就像主屋里的客厅。它创建了一个 Asparagus 植物,并打印出来。
  2. src/garden.rs (花园的图纸):
    • pub mod vegetables;:花园里声明了一个公开vegetables 小间(所以编译器去 src/garden/vegetables.rs 里找这个小间的图纸)。
  3. src/garden/vegetables.rs (蔬菜小间的图纸):
    • pub struct Asparagus {}:这里定义了一个公开Asparagus 植物结构体。

通过这些规则,整个项目就被组织得井井有条,各个部分各司其职,又可以通过明确的路径相互引用。

src/garden.rs 不是一个独立的库 crate。它只是一个模块文件,是 backyard 这个二进制 crate 的一部分。

src/garden.rs 为什么不在 lib 目录下?

src/garden.rs 之所以直接放在 src 目录下,是因为它被 src/main.rs 中的 pub mod garden; 这行代码声明并包含了进来

当你在 src/main.rs(或 src/lib.rs)中声明 mod garden; 时,Rust 编译器会按照你之前看到的规则去寻找 garden 模块的代码:

  1. src/garden.rs 文件中。
  2. src/garden/mod.rs 文件中。

在这个例子中,它找到了 src/garden.rs,并将其中的代码(包括它声明的 vegetables 模块)作为 main.rs 所属的这个二进制 crate 的一部分编译进去。所以,garden.rs 仅仅是 backyard 这个二进制 crate 内部的一个模块,用来组织代码。

lib 目录是干啥用的?

lib 目录(具体来说是 src/lib.rs 文件)是用来创建库 crate 的。

  • 库 crate 是一种可以被其他项目(甚至是你自己项目的二进制 crate)引用和复用的代码包。它不直接生成可执行文件,而是生成一个库文件(例如 .rlib 文件)。
  • 当你的项目包含 src/lib.rs 时,Cargo 会自动将其视为一个库 crate 的根文件。

区别在于:

  • src/lib.rs:定义的是整个库 crate 的入口和结构,它是一个独立的编译单元,旨在被其他代码导入使用。
  • src/garden.rs:定义的是当前二进制 crate(或库 crate)内部的一个模块,它只是用来组织该 crate 内部的代码,而不是一个独立的、可被其他外部项目直接引用的 crate。

简而言之:

src/lib.rs 相当于你的项目要对外提供的“工具箱”的“总说明书”。而 src/garden.rs 只是你这个工具箱(或你的主程序)内部的一个“抽屉”,用来存放相关工具。

为了更好地理解,我们再回顾一下 Rust 包中不同类型的 Crate 和模块文件的角色:

  1. 默认二进制 Crate (src/main.rs):
    • 这是你运行 cargo run 时默认会编译和执行的程序。
    • 必须包含一个 fn main() 函数。
    • 它可以声明和使用内部的模块文件(比如本例中的 src/garden.rs),这些模块文件最终都成为了这个二进制 Crate 的一部分。
  2. 库 Crate (src/lib.rs):
    • 这是为了提供可复用的代码库而存在的。它不包含 main 函数。
    • 编译后生成的是一个库文件(例如 .rlib.so/.dll),而不是可执行文件。
    • 这个库 Crate 可以被其他项目(包括当前包的默认二进制 Crate src/main.rs 或其他位于 src/bin 目录下的二进制 Crate)引用和使用。
  3. 额外二进制 Crate (src/bin/\*.rs):
    • 如果你的项目需要除了 src/main.rs 之外的其他独立可执行程序,你就会把它们放在 src/bin/ 目录下。
    • src/bin 目录下的每个 .rs 文件都会被编译成一个独立的二进制 Crate,并且每个文件都需要包含一个 fn main() 函数。
    • 例如,如果你有 src/bin/my_tool.rs,那么你可以用 cargo run --bin my_tool 来运行它。

当然可以。在这个餐厅项目中,你可以完全按照 Backyard 项目的模式来组织代码,将餐厅功能模块化,但不是作为一个独立的库 crate,而是作为主二进制 crate (src/main.rs) 的一部分来包含。

这意味着:

  1. 没有 src/lib.rs 文件:你的项目将不会包含一个独立的库 crate。
  2. src/main.rs 作为主入口:所有功能将最终通过 src/main.rs 来使用。
  3. 模块文件直接在 src/ 目录下或其子目录中:像 src/garden.rs 在 Backyard 项目中一样,你可以在 src/ 目录下创建 restaurant_logic.rs 或者 front_of_house.rs 等文件,这些文件会通过 mod 声明被 src/main.rs 包含进来。

修改后的餐厅项目结构(类似 Backyard)

首先,创建一个新的二进制项目 (如果你之前创建的是库项目,可以新建一个):

Bash

1
2
cargo new restaurant_app
cd restaurant_app

然后,修改 src/main.rs 来声明和使用模块。

项目文件结构:

1
2
3
4
5
6
7
restaurant_app/
├── Cargo.toml
└── src/
├── front_of_house.rs # 对应 Backyard 项目中的 garden.rs
├── front_of_house/ # 对应 Backyard 项目中的 garden/ 目录
│ └── hosting.rs # 对应 Backyard 项目中的 garden/vegetables.rs
└── main.rs

src/main.rs 的内容:

1
2
3
4
5
6
7
8
9
// 声明 front_of_house 模块。因为它是顶级模块,并且我们想让它可以在文件中定义,所以用 `mod 模块名;`
// 它会去查找 src/front_of_house.rs 或 src/front_of_house/mod.rs
mod front_of_house; //

fn main() {
// 假设 front_of_house::hosting::add_to_waitlist 是公开的
front_of_house::hosting::add_to_waitlist(); //
println!("顾客已添加到等位列表!");
}

解释 src/main.rs

  • mod front_of_house; 这行告诉编译器,有一个名为 front_of_house 的模块,它的代码不在 main.rs 中内联,而是要去 src/front_of_house.rssrc/front_of_house/mod.rs 中寻找。在这个例子中,我们选择在 src/front_of_house.rs 中定义它。
  • fn main() 中通过 front_of_house::hosting::add_to_waitlist() 来调用功能。这要求 front_of_house 模块及其内部的 hosting 模块和 add_to_waitlist 函数都被声明为 pub,才能被 main 函数访问。

src/front_of_house.rs 的内容:

1
2
3
4
5
6
7
8
9
10
// 在 front_of_house 模块内声明 hosting 和 serving 子模块
// 它们会去查找 src/front_of_house/hosting.rs 或 src/front_of_house/hosting/mod.rs
// 以及 src/front_of_house/serving.rs 或 src/front_of_house/serving/mod.rs
pub mod hosting; //
pub mod serving; //

// 你也可以在这里直接定义一些 front_of_house 模块特有的函数或结构体
// pub fn greet_customer() {
// println!("欢迎光临!");
// }

解释 src/front_of_house.rs

  • 这里我们声明了 hostingserving 这两个子模块,并使用了 pub 关键字,表示它们是公开的,可以被它们的父模块(front_of_house)和通过路径访问到 front_of_house 的其他代码(例如 main.rs)使用。
  • 由于是在 src/front_of_house.rs 中声明的,Rust 会继续在 src/front_of_house/ 目录下查找 hosting.rsserving.rs

src/front_of_house/hosting.rs 的内容:

Rust

1
2
3
4
5
6
7
8
pub fn add_to_waitlist() { //
// 实际的等位逻辑
println!("顾客已加入等位列表。");
}

pub fn seat_at_table() { //
println!("顾客已入座。");
}

src/front_of_house/serving.rs 的内容:

1
2
3
4
5
6
7
8
9
10
11
pub fn take_order() { //
println!("正在点餐...");
}

pub fn serve_order() { //
println!("正在上菜...");
}

pub fn take_payment() { //
println!("正在付款...");
}

运行:

现在你可以运行 cargo run,它会编译并执行 src/main.rs 中的代码,从而调用你组织在不同文件和模块中的餐厅功能。

这种方式的好处是,你不需要一个单独的库 crate 就能将代码分解到多个文件中,保持项目的整洁和组织性。所有的代码最终都编译成一个单一的二进制可执行文件。

两种模块化的优劣(独立库 Crate (推荐)单一二进制 Crate (简化))

这两种实现模块化的方式(即:将功能代码放在一个独立的库 crate 中供二进制 crate 使用,或者将所有模块都作为单一二进制 crate 的一部分)没有绝对的“孰优孰劣”,而是取决于你的项目需求和目标

以下是它们的优缺点对比:

1. 将功能代码作为独立库 Crate 的一部分(src/lib.rs + src/main.rs

优点:

  • 代码复用性强
    • 这是最主要的好处。如果你的核心功能将来可能被其他项目、其他二进制工具或测试用例使用,将其封装在一个库 crate 中是最佳选择。其他项目可以直接添加你的库 crate 作为依赖。
    • 你的二进制 crate (src/main.rssrc/bin/*.rs) 可以像使用任何外部依赖一样 use 这个库 crate 中的功能。
  • 清晰的职责分离:库 crate 专注于提供核心功能,而二进制 crate 专注于提供可执行的应用程序入口或命令行界面。这种分离使得代码结构更清晰,更易于理解和维护。
  • 更好的测试粒度:你可以独立地对库 crate 进行单元测试和集成测试,因为它不依赖于 main 函数或特定的程序入口,这使得测试更纯粹和高效。
  • 减少主二进制文件大小(潜在):如果你的库 crate 包含大量可选功能,并且你的二进制 crate 只使用其中一部分,那么在某些编译优化下,最终的二进制文件可能更精简。
  • 便于分享:如果你想将你的核心功能发布到 crates.io 上供全球 Rust 开发者使用,那么它必须是一个库 crate。

缺点:

  • 初始设置略复杂:你需要管理 src/lib.rssrc/main.rs 两个入口文件,并确保它们之间的 use 路径正确。
  • 额外的层级:对于非常简单的项目,引入一个独立的库 crate 可能会显得有点“过度设计”,增加了不必要的抽象层级。

2. 将所有模块都作为单一二进制 Crate 的一部分(只有 src/main.rs 和其内部模块文件)

优点:

  • 简单直接:对于小型或中型项目,如果代码不会被其他项目复用,这种方式是最直接和简单的。你只需要管理一个主要的 main.rs 入口文件。
  • 更容易快速启动:当你开始一个新项目,不确定哪些代码将来会复用时,这种结构更容易开始。
  • 所有代码编译成一个文件:最终生成一个单一的可执行文件,没有额外的库文件需要管理。

缺点:

  • 代码复用性差:如果将来需要将某些功能用于其他项目,或者想将其作为独立的库发布,你就需要手动将这部分代码提取出来,并重新组织成一个库 crate,这会涉及到代码的迁移和路径的调整。
  • 职责不明确:所有的功能都被包含在同一个二进制 Crate 中,可能导致在大型项目中,核心逻辑和应用程序入口的边界变得模糊。
  • 测试可能更复杂:如果你想针对非 main 函数的某个内部模块进行独立的单元测试,你可能需要编写一些辅助代码或者使用特定的测试宏,而不能像库 crate 那样直接暴露接口进行测试。

总结与选择建议:

特性/场景 独立库 Crate (推荐) 单一二进制 Crate (简化)
复用性 极佳,核心逻辑可被其他 Crate 引用 差,功能不易被外部项目复用
项目规模 适合中大型项目,或有复用需求的模块化设计 适合小型或一次性工具,无需对外暴露库功能
代码组织 清晰的职责分离,库归库,应用归应用 所有模块都在一个 Crate 内,结构可能更扁平
测试 便于独立测试核心库功能 内部模块测试可能需要更多设置
发布 可发布到 crates.io,供他人使用 通常只发布可执行文件,不作为库发布
未来扩展性 高,方便拆分功能和引入新二进制程序 低,后续功能拆分或复用成本较高

我的建议是:

  • 如果你不确定将来是否需要复用代码,或者项目规模可能会增长, 优先选择“独立库 Crate + 二进制 Crate”的模式。即使是现在看起来很小的功能,未来也可能被需要。这种结构一开始可能多了一点点设置,但从长远来看可以节省大量重构时间。
  • 如果你确信这是一个非常小、一次性、且永远不会被其他项目复用的工具或脚本, 那么只使用单一二进制 Crate 的方式会更简单直接。

大多数成熟的 Rust 项目,即使是最终产生一个可执行程序的项目,也会倾向于将其核心逻辑放在 src/lib.rs 中,并通过 src/main.rs 来调用这个库,从而获得更好的模块化、复用性和可测试性。

在我们写C语言项目时,经常会有src,inc目录,我们自己编写或者下载好的.h.c文件塞进去,将它们添加进编译列表,然后就可以在项目中使用它们了,RUST中的模块系统是与之类似的,以下是AI给出的两者之间的联系以及区别

Rust 模块系统与 C 语言文件组织对比

在 C 语言项目中,我们通常将源代码 (.c 文件) 放在 src 目录,头文件 (.h 文件) 放在 inc (或 include) 目录。Rust 的模块系统与 C 语言这种文件组织方式在逻辑上有一些相似之处,但在实现和理念上存在显著不同

相似之处

  1. 代码组织与复用: 无论是 C 语言的头文件/源文件分离,还是 Rust 的模块,核心目的都是为了更好地组织代码、提高可读性,并实现代码的复用。通过将相关功能封装在一起,可以避免命名冲突,并使得项目结构更加清晰。
  2. 隐藏实现细节: C 语言中,头文件声明了函数和变量的接口,而 .c 文件包含具体实现。外部用户只需要包含头文件,不需要关心 .c 文件内部的实现细节。Rust 的模块系统也提供了类似的能力,通过 pub 关键字控制可见性,可以隐藏内部实现,只暴露公共 API。
  3. 编译单元的概念: C 语言的 .c 文件是独立的编译单元,通过 #include 预处理器指令将头文件内容插入到编译单元中。Rust 的模块在编译时也会被编译器处理,形成逻辑上的编译单元。

不同之处

  1. 声明与定义的绑定方式:
    • C 语言: C 语言通过预处理器宏 #include 将头文件的内容(声明)复制到源文件中。编译时,编译器需要同时看到声明和定义,或者通过链接器在链接阶段解析符号。这种方式相对松散,容易出现头文件循环引用、重复定义等问题。
    • Rust: Rust 的模块系统是语言级别内置的。它通过 mod 关键字定义模块,并通过 use 关键字引入路径。Rust 编译器在编译时会理解模块的层级结构和可见性规则,不需要像 C 语言那样手动管理头文件依赖。这使得 Rust 的模块管理更加健壮和安全。
  2. 可见性控制:
    • C 语言: C 语言主要通过头文件来控制可见性。头文件中声明的符号默认是外部可见的,.c 文件中定义的 static 变量或函数才是文件私有的。
    • Rust: Rust 提供了更细粒度的可见性控制。默认情况下,模块中的项(函数、结构体、枚举等)是私有的 (private),只有通过 pub 关键字明确声明的项才是公共的。此外,Rust 还支持 pub(crate)(当前 crate 可见)、pub(super)(父模块可见)等更灵活的可见性修饰符,极大地增强了封装性。
  3. 命名空间与路径:
    • C 语言: C 语言没有内置的命名空间机制,通常通过前缀(例如 my_library_function)来避免不同库之间的命名冲突。头文件的路径管理也相对简单,就是文件系统的路径。
    • Rust: Rust 的模块系统本身就提供了强大的命名空间管理。每个模块都有自己的路径,例如 crate::module_name::sub_module::item。通过 use 关键字,我们可以引入特定的项到当前作用域,或者使用 as 关键字为引入的项创建别名,从而解决潜在的命名冲突。这种基于路径的命名空间机制使得代码组织更加清晰和安全。
  4. 文件与模块的对应关系:
    • C 语言: 通常一个 .c 文件对应一个独立的编译单元,并可能有一个对应的 .h 文件用于声明接口。
    • Rust: 在 Rust 中,一个文件可以包含多个模块,或者一个模块可以分散在多个文件中(通过 mod name; 语法)。Rust 的文件系统布局约定是模块系统的一部分,例如,一个 mod foo; 声明会查找 foo.rs 文件或 foo/mod.rs 目录来加载模块内容。这种灵活的对应关系使得代码组织可以更好地反映逻辑结构。
  5. 宏与预处理器:
    • C 语言: 严重依赖预处理器宏(#define, #ifdef 等)进行条件编译、代码生成等操作。
    • Rust: Rust 拥有功能更强大的宏系统(过程宏、声明宏),它们在编译时执行,并且具有更好的类型安全性。Rust 强调零成本抽象,通过类型系统和编译器在编译阶段捕获错误,而不是依赖运行时检查或预处理器。

RUST中的枚举远比C语言中的强大的多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
enum IpAddrKind {
V4,
V6,
}

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

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

fn route(ip_kind: IpAddrKind) {}

第一个巨大的不同之处就是:RUST的枚举可以关联不同类型和数量的数据

1
2
3
4
5
6
7
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}

let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));

这无疑是非常灵活方便的,当然RUST的枚举可以关联结构体,甚至可以再关联一个枚举。

RUST还允许我们像给结构体定义方法一样给枚举定义方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn main() {
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}

impl Message {
fn call(&self) {
// 在这里定义方法体
}
}
let m = Message::Write(String::from("hello"));
m.call();
}

Option枚举

RUST中没有类似于C语言的NULL这种东西,在C语言中,假设你malloc/calloc一个内存区域,如果失败了,会返回一个NULL,调用者需要手动处理NULL的情况,不然程序就会出现bug,而RUST通过Option枚举避免了这种问题。

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

1
2
3
4
5
6
7
#![allow(unused)]
fn main() {
enum Option<T> {
None,
Some(T),
}
}

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

<T> 语法是一个我们还未讲到的 Rust 功能。它是一个泛型类型参数,第十章会更详细的讲解泛型。目前,所有你需要知道的就是 <T> 意味着 Option 枚举的 Some 变体可以包含任意类型的数据,同时每一个用于 T 位置的具体类型使得 Option<T> 整体作为不同的类型。这里是一些包含数字类型和字符类型 Option 值的例子:

1
2
3
4
let some_number = Some(5);
let some_char = Some('e');

let absent_number: Option<i32> = None;

some_number 的类型是 Option<i32>some_char 的类型是 Option<char>,是不同于 some_number 的类型。因为我们在 Some 变体中指定了值,Rust 可以推断其类型。对于 absent_number,Rust 需要我们指定 Option 整体的类型,因为编译器只通过 None 值无法推断出 Some 变体保存的值的类型。这里我们告诉 Rust 希望 absent_numberOption<i32> 类型的。

当有一个 Some 值时,我们就知道存在一个值,而这个值保存在 Some 中。当有个 None 值时,在某种意义上,它跟空值具有相同的意义:并没有一个有效的值。那么,Option<T> 为什么就比空值要好呢?

简而言之,因为 Option<T>T(这里 T 可以是任何类型)是不同的类型,编译器不允许像一个肯定有效的值那样使用 Option<T>。例如,这段代码不能编译,因为它尝试将 Option<i8>i8 相加:

1
2
3
4
let x: i8 = 5;
let y: Option<i8> = Some(5);

let sum = x + y;

如果运行这些代码,将得到类似这样的错误信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ cargo run
Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
--> src/main.rs:5:17
|
5 | let sum = x + y;
| ^ no implementation for `i8 + Option<i8>`
|
= help: the trait `Add<Option<i8>>` is not implemented for `i8`
= help: the following other types implement trait `Add<Rhs>`:
`&i8` implements `Add<i8>`
`&i8` implements `Add`
`i8` implements `Add<&i8>`
`i8` implements `Add`

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

很好!事实上,错误信息意味着 Rust 不知道该如何将 Option<i8>i8 相加,因为它们的类型不同。当在 Rust 中拥有一个像 i8 这样类型的值时,编译器确保它总是有一个有效的值。我们可以自信地使用而无需做空值检查。只有当使用 Option<i8>(或者任何用到的类型)的时候需要担心可能没有值,而编译器会确保我们在使用值之前处理了为空的情况。

换句话说,在对 Option<T> 进行运算之前必须将其转换为 T。通常这能帮助我们捕获到空值最常见的问题之一:假设某值不为空但实际上为空的情况。

消除了错误地假设一个非空值的风险,会让你对代码更加有信心。为了拥有一个可能为空的值,你必须要显式的将其放入对应类型的 Option<T> 中。接着,当使用这个值时,必须明确的处理值为空的情况。只要一个值不是 Option<T> 类型,你就可以安全的认定它的值不为空。这是 Rust 的一个经过深思熟虑的设计决策,来限制空值的泛滥以增加 Rust 代码的安全性。

那么当有一个 Option<T> 的值时,如何从 Some 变体中取出 T 的值来使用它呢?Option<T> 枚举拥有大量用于各种情况的方法:你可以查看它的文档。熟悉 Option<T> 的方法将对你的 Rust 之旅非常有用。

总的来说,为了使用 Option<T> 值,需要编写处理每个变体的代码。你想要一些代码只当拥有 Some(T) 值时运行,允许这些代码使用其中的 T。也希望一些代码只在值为 None 时运行,这些代码并没有一个可用的 T 值。match 表达式就是这么一个处理枚举的控制流结构:它会根据枚举的变体运行不同的代码,这些代码可以使用匹配到的值中的数据。

除了match跟if let外,还有以下方法可以提取Option<>中的值

unwrap()expect()

unwrap()expect() 是提取 Option 值的直接方法,但它们是不安全的,因为它们在 OptionNone 时会panic(导致程序崩溃)。

  • unwrap(): 如果 OptionSome(T),它会返回 T。如果 OptionNone,它会 panic。

    Rust

    1
    2
    3
    4
    5
    6
    7
    8
    9
    fn main() {
    let some_value = Some(100);
    let value = some_value.unwrap(); // value is 100
    println!("Unwrapped value: {}", value);

    let none_value: Option<i32> = None;
    // let another_value = none_value.unwrap(); // 这行代码会导致 panic!
    // println!("Another unwrapped value: {}", another_value);
    }
  • expect("自定义错误信息"): 和 unwrap() 类似,但允许你提供一个自定义的 panic 错误信息,这在调试时很有用。

    Rust

    1
    2
    3
    4
    5
    6
    7
    8
    fn main() {
    let file_content = Some(String::from("Some text in the file."));
    let content = file_content.expect("文件内容不存在!");
    println!("文件内容: {}", content);

    let empty_file: Option<String> = None;
    // let empty_content = empty_file.expect("读取文件失败,文件为空或不存在。"); // 这行代码会导致 panic!
    }

重要提示: 除非你百分之百确定 Option 永远不会是 None(或者在 None 的情况下程序崩溃是可以接受的),否则应避免使用 unwrap()expect()。它们主要用于原型开发、测试或在明确知道 None 是一个不可恢复的错误时。

unwrap_or(), unwrap_or_default(), unwrap_or_else()

这些方法提供了在 OptionNone 时提供一个默认值或通过闭包计算一个默认值的方式,而不会 panic。

  • unwrap_or(default_value): 如果 OptionSome(T),返回 T。如果 OptionNone,返回提供的 default_value

    1
    2
    3
    4
    5
    6
    7
    8
    9
    fn main() {
    let user_input = Some("Hello".to_string());
    let result = user_input.unwrap_or("Guest".to_string());
    println!("欢迎: {}", result); // 输出: 欢迎: Hello

    let no_input: Option<String> = None;
    let result_none = no_input.unwrap_or("Guest".to_string());
    println!("欢迎: {}", result_none); // 输出: 欢迎: Guest
    }
  • unwrap_or_default(): 如果 OptionSome(T),返回 T。如果 OptionNone,返回 T 类型的默认值(要求 T 实现 Default trait)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    fn main() {
    let count = Some(5);
    let num = count.unwrap_or_default(); // num 是 5
    println!("Count: {}", num);

    let no_count: Option<u32> = None;
    let default_num = no_count.unwrap_or_default(); // default_num 是 0 (u32 的默认值)
    println!("Default Count: {}", default_num);
    }
  • unwrap_or_else(|| { /\* 闭包计算默认值 \*/ }): 如果 OptionSome(T),返回 T。如果 OptionNone,执行提供的闭包并返回其结果。这在计算默认值比较复杂或有副作用时很有用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    fn get_default_username() -> String {
    println!("正在生成默认用户名...");
    "Anonymous".to_string()
    }

    fn main() {
    let username = Some("Alice".to_string());
    let final_username = username.unwrap_or_else(|| get_default_username());
    println!("用户: {}", final_username);

    let no_username: Option<String> = None;
    let final_no_username = no_username.unwrap_or_else(|| get_default_username());
    println!("用户: {}", final_no_username);
    }

5. 其他辅助方法

Option 还提供了许多其他有用的方法,例如:

  • is_some(): 返回 true 如果是 Some,否则返回 false
  • is_none(): 返回 true 如果是 None,否则返回 false
  • map(|value| new_value): 如果是 Some(T),应用闭包到 T 并返回 Some(U);如果是 None,返回 None。用于转换 Option 内的值类型。
  • and_then(|value| Option<U>): 如果是 Some(T),应用闭包到 T(闭包返回另一个 Option)并返回结果;如果是 None,返回 None。常用于链式处理多个可能失败的操作。

match 控制流结构

Rust 有一个叫做 match 的极为强大的控制流运算符,它允许我们将一个值与一系列的模式相比较,并根据相匹配的模式执行相应代码。模式可由字面值、变量、通配符和许多其他内容构成;第十九章会涉及到所有不同种类的模式以及它们的作用。match 的力量来源于模式的表现力,以及编译器能够确认所有可能情况均已被覆盖。

可以把 match 表达式想象成某种硬币分类器:硬币滑入有着不同大小孔洞的轨道,每一个硬币都会掉入符合它大小的孔洞。同样地,值也会通过 match 的每一个模式,并且在遇到第一个 “符合” 的模式时,值会进入相关联的代码块并在执行中被使用。

因为刚刚提到了硬币,让我们用它们来作为一个使用 match 的例子!我们可以编写一个函数来获取一个未知的美国硬币,并以一种类似验钞机的方式,确定它是何种硬币并返回它的美分值,如示例 6-3 中所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}

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

示例 6-3:一个枚举和一个以枚举变体作为模式的 match 表达式

拆开 value_in_cents 函数中的 match 来看。首先,我们列出 match 关键字后跟一个表达式,在这个例子中是 coin 的值。这看起来非常像 if 所使用的条件表达式,不过这里有一个非常大的区别:对于 if,表达式必须返回一个布尔值,而这里它可以是任何类型的。例子中的 coin 的类型是示例 6-3 中定义的 Coin 枚举。

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

match 表达式执行时,它将结果值按顺序与每一个分支的模式相比较。如果模式匹配了这个值,这个模式相关联的代码将被执行。如果模式并不匹配这个值,将继续执行下一个分支,非常类似一个硬币分类器。可以拥有任意多的分支:示例 6-3 中的 match 有四个分支。

每个分支相关联的代码是一个表达式,而表达式的结果值将作为整个 match 表达式的返回值。

如果分支代码较短的话通常不使用大括号,正如示例 6-3 中的每个分支都只是返回一个值。如果想要在分支中运行多行代码,可以使用大括号,而分支后的逗号是可选的。例如,如下代码在每次使用Coin::Penny 调用时都会打印出 “Lucky penny!”,同时仍然返回代码块最后的值,1

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

绑定值的模式

匹配分支的另一个有用的功能是可以绑定匹配的模式的部分值。这也就是如何从枚举变体中提取值的。

作为一个例子,让我们修改枚举的一个变体来存放数据。1999 年到 2008 年间,美国在 25 美分的硬币的一侧为 50 个州的每一个都印刷了不同的设计。其他的硬币都没有这种区分州的设计,所以只有这些 25 美分硬币有特殊的价值。可以将这些信息加入我们的 enum,通过改变 Quarter 变体来包含一个 State 值,示例 6-4 中完成了这些修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
#[derive(Debug)] // 这样可以立刻看到州的名称
enum UsState {
Alabama,
Alaska,
// --snip--
}

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

示例 6-4:Quarter 变体也存放了一个 UsState 值的 Coin 枚举

想象一下我们的一个朋友尝试收集所有 50 个州的 25 美分硬币。在根据硬币类型分类零钱的同时,也可以报告出每个 25 美分硬币所对应的州名称,这样如果我们的朋友没有的话,他可以将其加入收藏。

在这些代码的匹配表达式中,我们在匹配 Coin::Quarter 变体的分支的模式中增加了一个叫做 state 的变量。当匹配到 Coin::Quarter 时,变量 state 将会绑定 25 美分硬币所对应州的值。接着在那个分支的代码中使用 state,如下:

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

如果调用 value_in_cents(Coin::Quarter(UsState::Alaska))coin 将是 Coin::Quarter(UsState::Alaska)。当将值与每个分支相比较时,没有分支会匹配,直到遇到 Coin::Quarter(state)。这时,state 绑定的将会是值 UsState::Alaska。接着就可以在 println! 表达式中使用这个绑定了,像这样就可以获取 Coin 枚举的 Quarter 变体中内部的州的值。

匹配 Option

我们在之前的部分中使用 Option<T> 时,是为了从 Some 中取出其内部的 T 值;我们还可以像处理 Coin 枚举那样使用 match 处理 Option<T>!只不过这回比较的不再是硬币,而是 Option<T> 的变体,但 match 表达式的工作方式保持不变。

比如我们想要编写一个函数,它获取一个 Option<i32> ,如果其中含有一个值,将其加一。如果其中没有值,函数应该返回 None 值,而不尝试执行任何操作。

得益于 match,编写这个函数非常简单,它将看起来像示例 6-5 中这样:

1
2
3
4
5
6
7
8
9
10
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}

let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);

示例 6-5:一个在 Option<i32> 上使用 match 表达式的函数

让我们更仔细地检查 plus_one 的第一行操作。当调用 plus_one(five) 时,plus_one 函数体中的 x 将会是值 Some(5)。接着将其与每个分支比较。

1
None => None,

Some(5) 并不匹配模式 None,所以继续进行下一个分支。

1
Some(i) => Some(i + 1),

Some(5)Some(i) 匹配吗?当然匹配!它们是相同的变体。i 绑定了 Some 中包含的值,所以 i 的值是 5。接着匹配分支的代码被执行,所以我们将 i 的值加一并返回一个含有值 6 的新 Some

接着考虑下示例 6-5 中 plus_one 的第二个调用,这里 xNone。我们进入 match 并与第一个分支相比较。

1
None => None,

匹配成功!这里没有值来加一,所以程序结束并返回 => 右侧的值 None,因为第一个分支就匹配到了,其他的分支将不再比较。

match 与枚举相结合在很多场景中都是有用的。你会在 Rust 代码中看到很多这样的模式:match 一个枚举,绑定其中的值到一个变量,接着根据其值执行代码。这在一开始有点复杂,不过一旦习惯了,你会希望所有语言都拥有它!这一直是用户的最爱。

匹配是穷尽的

match 还有另一方面需要讨论:这些分支必须覆盖了所有的可能性。考虑一下 plus_one 函数的这个版本,它有一个 bug 并不能编译:

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

我们没有处理 None 的情况,所以这些代码会造成一个 bug。幸运的是,这是一个 Rust 知道如何处理的 bug。如果尝试编译这段代码,会得到这个错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ cargo run
Compiling enums v0.1.0 (file:///projects/enums)
error[E0004]: non-exhaustive patterns: `None` not covered
--> src/main.rs:3:15
|
3 | match x {
| ^ pattern `None` not covered
|
note: `Option<i32>` defined here
--> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/option.rs:572:1
::: /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/option.rs:576:5
|
= note: not covered
= note: the matched value is of type `Option<i32>`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
|
4 ~ Some(i) => Some(i + 1),
5 ~ None => todo!(),
|

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

Rust 知道我们没有覆盖所有可能的情况甚至知道哪些模式被忘记了!Rust 中的匹配是 穷尽的exhaustive):必须穷举到最后的可能性来使代码有效。特别的在这个 Option<T> 的例子中,Rust 防止我们忘记明确的处理 None 的情况,这让我们免于假设拥有一个实际上为空的值,从而使之前提到的价值亿万的错误不可能发生。

通配模式和 _ 占位符

使用枚举,我们也可以针对少数几个特定值执行特殊操作,而对其他所有值采取默认操作。想象我们正在玩一个游戏,如果你掷出骰子的值为 3,角色不会移动,而是会得到一顶新奇的帽子。如果你掷出了 7,你的角色将失去一顶新奇的帽子。对于其他的数值,你的角色会在棋盘上移动相应的格子。这是一个实现了上述逻辑的 match,骰子的结果是硬编码而不是一个随机值,其他的逻辑部分使用了没有函数体的函数来表示,实现它们超出了本例的范围:

1
2
3
4
5
6
7
8
9
10
let dice_roll = 9;
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
other => move_player(other),
}

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

对于前两个分支,匹配模式是字面值 37,最后一个分支则涵盖了所有其他可能的值,模式是我们命名为 other 的一个变量。other 分支的代码通过将其传递给 move_player 函数来使用这个变量。

即使我们没有列出 u8 所有可能的值,这段代码依然能够编译,因为最后一个模式将匹配所有未被特殊列出的值。这种通配模式满足了 match 必须被穷尽的要求。请注意,我们必须将通配分支放在最后,因为模式是按顺序匹配的。如果我们在通配分支后添加其他分支,Rust 将会警告我们,因为此后的分支永远不会被匹配到。

Rust 还提供了一个模式,当我们不想使用通配模式获取的值时,请使用 _ ,这是一个特殊的模式,可以匹配任意值而不绑定到该值。这告诉 Rust 我们不会使用这个值,所以 Rust 也不会警告我们存在未使用的变量。

让我们改变游戏规则:现在,当你掷出的值不是 3 或 7 的时候,你必须再次掷出。这种情况下我们不需要使用这个值,所以我们改动代码使用 _ 来替代变量 other

1
2
3
4
5
6
7
8
9
10
let dice_roll = 9;
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
_ => reroll(),
}

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

这个例子也满足穷尽性要求,因为我们在最后一个分支中显式地忽略了其它值。我们没有忘记处理任何东西。

最后,让我们再次改变游戏规则,如果你掷出 3 或 7 以外的值,你的回合将无事发生。我们可以使用单元值(在“元组类型”一节中提到的空元组)作为 _ 分支的代码:

1
2
3
4
5
6
7
8
9
let dice_roll = 9;
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
_ => (),
}

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

在这里,我们明确告诉 Rust 我们不会使用与前面模式不匹配的值,并且这种情况下我们不想运行任何代码。

_(下划线)

_match 语句中是一个通配符模式。它的作用是:

  • 匹配任何值,但不会将该值绑定到任何变量。这意味着你不在乎具体匹配到的值是什么,只要模式匹配成功就执行对应的代码块。
  • 表示剩余情况的穷尽匹配。它通常作为 match 表达式的最后一个分支,用来捕获所有之前没有明确处理的模式。

_ 的主要特点:

  • 不绑定:匹配到的值不会绑定到任何变量,因此你不能在该分支的代码块中使用它。
  • 不可驳斥:它总是会匹配成功。
  • 常用于默认或“包罗万象”的情况

示例:

Rust

1
2
3
4
5
6
7
8
9
10
11
12
fn process_number(x: i32) {
match x {
1 => println!("数字是1!"),
2 => println!("数字是2!"),
_ => println!("其他数字!"), // 匹配任何其他 i32 值
}
}

fn main() {
process_number(1);
process_number(5);
}

在这个例子中,_ 处理了所有不是 12i32 值。

other(或任何其他标识符)

当你使用 other(或 xvalueremainder 等)这样的标识符在 match 语句中时,它充当一个变量绑定模式。它的作用是:

  • 匹配任何值并将其绑定到一个新的变量,变量名就是你指定的标识符。这允许你在该分支的代码块中使用匹配到的值。
  • 捕获值以便进一步处理。

标识符(如 other)的主要特点:

  • 绑定:匹配到的值会绑定到指定的变量,然后可以在该分支的代码中使用。
  • 可驳斥(但可以作为包罗万象的情况):虽然如果放在最后它也可以作为包罗万象的分支,但它的主要目的是绑定值。
  • 当需要处理匹配到的值时非常有用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
enum Result {
Success(String),
Error(u32),
Loading,
}

fn handle_result(res: Result) {
match res {
Result::Success(message) => println!("成功:{}", message),
Result::Error(code) => println!("错误码:{}", code),
other => println!("接收到其他状态:{:?}", other), // 将剩余的 Result 值绑定到 'other'
}
}

fn main() {
handle_result(Result::Success("操作完成".to_string()));
handle_result(Result::Error(404));
handle_result(Result::Loading); // 'Loading' 将被绑定到 'other'
}

在这个例子中,当匹配到 Result::Loading 时,Loading 变体本身被绑定到 other 变量,然后你可以在代码中打印或使用 other


区别总结 📊

特征 _(通配符) other(变量绑定)
目的 忽略值;作为“包罗万象”的模式 将值绑定到变量以供使用
值的使用 不能在该分支中使用匹配到的值 可以使用匹配到的值(通过变量)在该分支中
绑定 不发生绑定 将匹配到的值绑定到标识符
常见用例 默认情况,忽略模式的特定部分 捕获和处理匹配到的值

什么是“愉快路径”(Happy Path)?

首先,理解“愉快路径”的概念很重要。在编程中,“愉快路径”指的是程序在没有遇到错误、异常或意外情况时,按照预期顺利执行的流程。就像你在一条平坦的路上开车,没有堵车,没有故障,一路畅通。

相反,如果出现错误或不匹配的情况,我们就需要处理**“不愉快路径”**,比如报错、返回空值、退出程序等。


let...else 的核心思想

let...else 的核心目的是为了让你的代码在处理可能失败的操作时,能够清晰地把“愉快路径”的代码放在主线上,而把“不愉快路径”的退出逻辑快速处理掉。它就像一个“快速出口”,当条件不满足时,直接从函数中跳出,避免让主逻辑变得复杂。

为什么需要 let...else?(C 语言类比:繁琐的错误检查)

在 C 语言中,当你调用一个可能失败的函数,或者需要检查一个指针是否为 NULL 时,你通常会看到这样的模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// C 语言示例:模拟一个可能失败的函数调用
int* get_data() {
// 假设这个函数可能返回 NULL 表示失败
if (rand() % 2 == 0) { // 模拟随机失败
return NULL;
}
int* data = (int*)malloc(sizeof(int));
*data = 100;
return data;
}

void process_data() {
int* data = get_data(); // 调用可能失败的函数

// 经典 C 语言的错误检查模式:if (data == NULL) { return; }
if (data == NULL) { // 检查“不愉快路径”
printf("获取数据失败,提前返回。\n");
return; // 从函数中提前返回
}

// 走到这里,data 肯定不是 NULL,这是“愉快路径”
printf("成功获取数据: %d\n", *data);
free(data); // 释放资源
}

int main() {
srand(time(NULL)); // 初始化随机数种子
process_data();
process_data();
return 0;
}

在上面的 C 语言代码中,if (data == NULL) 就是处理“不愉快路径”的代码。它打断了主逻辑(处理数据)的流畅性,因为你必须先进行检查,如果失败就 return

如果有很多这样的检查,或者你需要从多个函数中获取数据并检查,你的代码可能会变成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// C 语言中嵌套的错误检查可能很丑陋
TypeA* objA = get_obj_a();
if (objA == NULL) {
return ERROR_A;
}

TypeB* objB = get_obj_b(objA);
if (objB == NULL) {
return ERROR_B;
}

TypeC* objC = get_obj_c(objB);
if (objC == NULL) {
return ERROR_C;
}

// 只有当所有都成功时,才能执行核心逻辑
process_final_data(objA, objB, objC);

这就是 if let 有时显得“繁琐”或“不对称”的原因。它虽然能绑定值,但在处理不匹配时,如果你想提前返回,就需要在 else 块中明确写 return,让控制流看起来有点跳跃:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 对应上面 C 语言的 if (data == NULL) { return; }
// Rust 的 if let 模拟:
fn describe_state_quarter_if_let(coin: Coin) -> Option<String> {
let state = if let Coin::Quarter(s) = coin {
s // 匹配成功,绑定 state
} else {
return None; // 匹配失败,直接返回 None
};
// 只有匹配成功,才会执行到这里
// 接下来是处理 state 的“愉快路径”代码
// ...
Some(format!("{state:?}"))
}

这段 if let 的代码虽然可以实现功能,但你会感觉 let state = if let ... else { return None; }; 这一行有点别扭。成功的逻辑是赋值,失败的逻辑是返回,两种控制流类型不一样。

let...else 如何简化?(C 语言类比:更直接的错误处理)

let...else 的出现就是为了让这种“如果模式匹配就绑定值,否则直接退出”的场景变得更简洁、更符合“愉快路径”的直觉。它强制 else 块必须包含一个非局部退出(Non-local Return),也就是跳出当前函数、循环或者直接使程序中断。

我们可以将 let...else 类比为 C 语言中结合了特定的错误处理约定来简化这种“检查-退出”模式:

想象一下,在 C 语言中,你可能会定义一个宏来做这样的事情(虽然不完全一样,但思想相似):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 伪 C 语言宏类比 let...else
#define CHECK_AND_GET_DATA(ptr_var, func_call) \
ptr_var = func_call; \
if (ptr_var == NULL) { \
printf("错误,提前退出!\n"); \
return; \
}

void process_data_simplified() {
int* data;
CHECK_AND_GET_DATA(data, get_data()); // 使用宏,如果 get_data 失败就直接返回

// 走到这里,data 肯定有效,直接处理“愉快路径”
printf("成功获取数据(简化版): %d\n", *data);
free(data);
}

Rust 的 let...else 就是把这种模式内置到了语言层面,让它安全且优雅。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn describe_state_quarter_let_else(coin: Coin) -> Option<String> {
// let Coin::Quarter(state) = coin else { ... };
// 尝试将 coin 匹配为 Coin::Quarter。
// 如果匹配成功,那么 state 变量就会被绑定,程序会继续往下执行(“愉快路径”)。
let Coin::Quarter(state) = coin else {
return None; // 如果不匹配(比如 coin 是 Coin::Dime 或 Coin::Nickel),直接从函数返回 None。
};

// 只有当 coin 确实是 Coin::Quarter 时,代码才会执行到这里。
// 此时 state 变量已经包含了 Quarter 中的 UsState 值。
// 这就是我们的“愉快路径”:直接使用 state 进行后续操作。
if state.existed_in(1900) {
Some(format!("{state:?} is pretty old, for America!"))
} else {
Some(format!("{state:?} is relatively new."))
}
}

let...else 的优点:

  1. 保持“愉快路径”的简洁性:它允许你的主要逻辑(即成功时执行的代码)保持在左对齐的、不被中断的块中。那些会导致函数退出的“不愉快路径”逻辑被清晰地隔离在 else 块里,而且这个块必须执行一个非局部返回,避免了遗漏。
  2. 更清晰的控制流:一眼就能看出如果模式不匹配,函数会立即退出,避免了 if let 某些情况下可能出现的控制流跳跃或分支逻辑不一致的问题。这使得代码更易读、易懂。

简而言之,let...else 是 Rust 语言为处理“如果能成功解构就继续,否则立即退出”这种常见模式提供的一个语法糖,让代码在面对潜在失败时,依然能保持“愉快路径”的简洁和直观。

结构体的定义和示例化

结构体的定义以及实例化

定义语法:

1
2
3
4
5
6
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}

没有**;**,需要指明类型。

实例化语法:

1
2
3
4
5
6
let user1 = User {
active: true,
username: String::from("someusername123"),
email: String::from("someone@example.com"),
sign_in_count: 1,
};

如果整个实例是可变的,那我们就可以通过user1.email = String::from("anotheremail@example.com");来修改实例中字段的值。

通过函数返回实例:

另外需要注意同其他任何表达式一样,我们可以在函数体的最后一个表达式中构造一个结构体的新实例,来隐式地返回这个实例。

1
2
3
4
5
6
7
8
fn build_user(email: String, username: String) -> User {
User {
active: true,
username: username,
email: email,
sign_in_count: 1,
}
}

字段初始化

我们可以通过字段初始化来简写语法,如下:

1
2
3
4
5
6
7
8
fn build_user(email: String, username: String) -> User {
User {
active: true,
username,
email,
sign_in_count: 1,
}
}

当参数与结构体中字段的名字相同时可以使用字段初始化来简写语法。

结构体更新语法

结构体更新语法:

1
2
3
4
5
6
fn main() {
let user2 = User {
email: String::from("another@example.com"),
..user1
};
}

请注意,结构更新语法就像带有 = 的赋值,因为它移动了数据,就像我们在“使用移动的变量与数据交互”部分讲到的一样。在这个例子中,总体上说我们在创建 user2 后就不能再使用 user1 了,因为 user1username 字段中的 String 被移到 user2 中。如果我们给 user2emailusername 都赋予新的 String 值,从而只复用 user1activesign_in_count 值,那么 user1 在创建 user2 后仍然有效。activesign_in_count 的类型是实现 Copy trait 的类型,所以我们在[“使用克隆的变量与数据交互”]部分讨论的行为同样适用。在本例中我们也可以继续使用 user1.email,因为它的值并未从 user1 中移动出去。

如果想不影响原来实例的话,可以使用.clone()方法来复制数据。

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

也可以定义与元组类似的结构体,称为 元组结构体tuple structs)。元组结构体有着结构体名称提供的含义,但没有具体的字段名,只有字段的类型。当你想给整个元组取一个名字,并使元组成为与其他元组不同的类型时,元组结构体是很有用的,这时像常规结构体那样为每个字段命名就显得多余和形式化了。

要定义元组结构体,以 struct 关键字和结构体名开头并后跟元组中的类型。例如,下面是两个分别叫做 ColorPoint 元组结构体的定义和用法:

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

fn main() {
let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);
}

注意 blackorigin 值的类型不同,因为它们是不同的元组结构体的实例。你定义的每一个结构体有其自己的类型,即使结构体中的字段可能有着相同的类型。例如,一个获取 Color 类型参数的函数不能接受 Point 作为参数,即便这两个类型都由三个 i32 值组成。除此之外,元组结构体实例类似于元组,你可以将它们解构为单独的部分,也可以使用 . 后跟索引来访问单独的值。与元组不同的是,解构元组结构体时必须写明结构体的类型。例如,我们可以写 let Point(x, y, z) = origin;,将 origin 的值解构到名为 xyz 的变量中。

类单元结构体

暂时用不到,就先不写了

结构体中字段的所有权

目前就是注意一点,结构体字段中不要包含引用就好了。


打印结构体中所有值

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

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

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

结构体中的方法

RUST官方教程中这一块写的非常清晰易懂,我直接复制下来了

方法语法

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

定义方法

让我们把前面实现的获取一个 Rectangle 实例作为参数的 area 函数,改写成一个定义于 Rectangle 结构体上的 area 方法,如示例 5-13 所示:

文件名:src/main.rs

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

impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}

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

println!(
"The area of the rectangle is {} square pixels.",
rect1.area()
);
}

示例 5-13:在 Rectangle 结构体上定义 area 方法

为了使函数定义于 Rectangle 的上下文中,我们开始了一个 impl 块(implimplementation 的缩写),这个 impl 块中的所有内容都将与 Rectangle 类型相关联。接着将 area 函数移动到 impl 大括号中,并将签名中的第一个(在这里也是唯一一个)参数和函数体中其他地方的对应参数改成 self。然后在 main 中将我们先前调用 area 方法并传递 rect1 作为参数的地方,改成使用 方法语法method syntax)在 Rectangle 实例上调用 area 方法。方法语法获取一个实例并加上一个点号,后跟方法名、圆括号以及任何参数。

area 的签名中,使用 &self 来替代 rectangle: &Rectangle&self 实际上是 self: &Self 的缩写。在一个 impl 块中,Self 类型是 impl 块的类型的别名。方法的第一个参数必须有一个名为 selfSelf 类型的参数,所以 Rust 让你在第一个参数位置上只用 self 这个名字来简化。注意,我们仍然需要在 self 前面使用 & 来表示这个方法借用了 Self 实例,就像我们在 rectangle: &Rectangle 中做的那样。方法可以选择获得 self 的所有权,或者像我们这里一样不可变地借用 self,或者可变地借用 self,就跟其他参数一样。

这里选择 &self 的理由跟在函数版本中使用 &Rectangle 是相同的:我们并不想获取所有权,只希望能够读取结构体中的数据,而不是写入。如果想要在方法中改变调用方法的实例,需要将第一个参数改为 &mut self。通过仅仅使用 self 作为第一个参数来使方法获取实例的所有权是很少见的;这种技术通常用在当方法将 self 转换成别的实例的时候,这时我们想要防止调用者在转换之后使用原始的实例。

使用方法替代函数,除了可使用方法语法和不需要在每个函数签名中重复 self 的类型之外,其主要好处在于组织性。我们将某个类型实例能做的所有事情都一起放入 impl 块中,而不是让将来的用户在我们的库中到处寻找 Rectangle 的功能。

请注意,我们可以选择将方法的名称与结构中的一个字段相同。例如,我们可以在 Rectangle 上定义一个方法,并命名为 width

文件名:src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
impl Rectangle {
fn width(&self) -> bool {
self.width > 0
}
}

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

if rect1.width() {
println!("The rectangle has a nonzero width; it is {}", rect1.width);
}
}

在这里,我们选择让 width 方法在实例的 width 字段的值大于 0 时返回 true,等于 0 时则返回 false:我们可以出于任何目的,在同名的方法中使用同名的字段。在 main 中,当我们在 rect1.width 后面加上括号时。Rust 知道我们指的是方法 width。当我们不使用圆括号时,Rust 知道我们指的是字段 width

通常,但并不总是如此,与字段同名的方法将被定义为只返回字段中的值,而不做其他事情。这样的方法被称为 getters,Rust 并不像其他一些语言那样为结构字段自动实现它们。Getters 很有用,因为你可以把字段变成私有的,但方法是公共的,这样就可以把对字段的只读访问作为该类型公共 API 的一部分。我们将在第七章中讨论什么是公有和私有,以及如何将一个字段或方法指定为公有或私有。

-> 运算符到哪去了?

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

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

它是这样工作的:当使用 object.something() 调用方法时,Rust 会自动为 object 添加 &&mut* 以便使 object 与方法签名匹配。也就是说,这些代码是等价的:

1
2
p1.distance(&p2);
(&p1).distance(&p2);

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

带有更多参数的方法

让我们通过实现 Rectangle 结构体上的另一方法来练习使用方法。这回,我们让一个 Rectangle 的实例获取另一个 Rectangle 实例,如果 self (第一个 Rectangle)能完全包含第二个长方形则返回 true;否则返回 false。一旦我们定义了 can_hold 方法,就可以编写示例 5-14 中的代码。

文件名:src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
let rect2 = Rectangle {
width: 10,
height: 40,
};
let rect3 = Rectangle {
width: 60,
height: 45,
};

println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

同时我们希望看到如下输出,因为 rect2 的两个维度都小于 rect1,而 rect3rect1 要宽:

1
2
Can rect1 hold rect2? true
Can rect1 hold rect3? false

因为我们想定义一个方法,所以它应该位于 impl Rectangle 块中。方法名是 can_hold,并且它会获取另一个 Rectangle 的不可变借用作为参数。通过观察调用方法的代码可以看出参数是什么类型的:rect1.can_hold(&rect2) 传入了 &rect2,它是一个 Rectangle 的实例 rect2 的不可变借用。这是可以理解的,因为我们只需要读取 rect2(而不是写入,这意味着我们需要一个不可变借用),而且希望 main 保持 rect2 的所有权,这样就可以在调用这个方法后继续使用它。can_hold 的返回值是一个布尔值,其实现会分别检查 self 的宽高是否都大于另一个 Rectangle。让我们在示例 5-13 的 impl 块中增加这个新的 can_hold 方法,如示例 5-15 所示:

文件名:src/main.rs

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

fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}

示例 5-15:在 Rectangle 上实现 can_hold 方法,它获取另一个 Rectangle 实例作为参数

如果结合示例 5-14 的 main 函数来运行,就会看到期望的输出。在方法签名中,可以在 self 后增加多个参数,而且这些参数就像函数中的参数一样工作。

关联函数

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

不是方法的关联函数经常被用作返回一个结构体新实例的构造函数。这些函数的名称通常为 new ,但 new 并不是一个关键字。例如我们可以提供一个叫做 square 关联函数,它接受一个维度参数并且同时作为宽和高,这样可以更轻松的创建一个正方形 Rectangle 而不必指定两次同样的值:

1
2
3
4
5
6
7
8
impl Rectangle {
fn square(size: u32) -> Self {
Self {
width: size,
height: size,
}
}
}

关键字 Self 在函数的返回类型和函数体中,都是对 impl 关键字后所示类型的别名,这里是 Rectangle

要调用这个关联函数,我们使用结构体名和 :: 语法;比如 let sq = Rectangle::square(3);。这个函数位于结构体的命名空间中::: 语法用于关联函数和模块创建的命名空间。第七章会讲到模块。

多个 impl

每个结构体都允许拥有多个 impl 块。例如,示例 5-15 中的代码等同于示例 5-16 中所示的代码,但后者每个方法有其自己的 impl 块。

1
2
3
4
5
6
7
8
9
10
11
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}

impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}

这里没有理由将这些方法分散在多个 impl 块中,不过这是有效的语法。第十章讨论泛型和 trait 时会看到实用的多 impl 块的用例。

总结

结构体让你可以创建出在你的领域中有意义的自定义类型。通过结构体,我们可以将相关联的数据片段联系起来并命名它们,这样可以使得代码更加清晰。在 impl 块中,你可以定义与你的类型相关联的函数,而方法是一种相关联的函数,让你指定结构体的实例所具有的行为。

if表达式

rust中的if语句跟C语言中的是类似的,基本格式如下

1
2
3
4
5
6
7
8
fn main() {
let number = 3;
if (number < 5) {
println!("condition was true");
} else {
println!("condition was false");
}
}

但也有不同的地方:

  1. if后紧跟的条件必须是一个bool值,如果不是的话,程序会无法编译
  2. 在rust中,if是一个表达式而不是语句,二者之间的区别就是语句仅仅只是执行一个动作而不返回值,表达式会在执行后返回一个值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fn main() {
let x = 10;

let y = if x > 5 {
20 // 这个块的值是 20
} else {
30 // 这个块的值是 30
}; // 注意这里的分号,表示表达式的结束

println!("y = {}", y); // 输出 y = 20

// 另一个例子:隐式返回
let description = if x > 100 {
"非常大"
} else if x > 50 {
"比较大"
} else {
"不大"
};
println!("x 的值: {}", description); // 输出 x 的值: 不大
}

关键点:

  1. 返回类型一致性:当 if 作为表达式使用时,所有分支(ifelse ifelse)必须返回相同类型的值。

    1
    2
    3
    4
    5
    let result = if true {
    10 // 返回 i32
    } else {
    "hello" // 错误!返回 &str,类型不匹配
    };

    编译器会报错,因为 10 是整数,而 "hello" 是字符串切片,它们的类型不同。

  2. 块的最后一个表达式是返回值:在 Rust 中,一个代码块的值是其最后一个表达式的值(没有分号),没法使用return ***;的形式返回。

    1
    2
    3
    4
    5
    6
    let value = if condition {
    let temp = 10;
    temp + 5 // 这个表达式的值 15 就是整个 if 分支的返回值
    } else {
    20
    };

循环

rust中的循环分3种:

  1. loop
  2. while
  3. for

需要注意的是,rust中是没有类似于C语言中do while()这种循环结构的,等效的功能可以由loop实现。

loop:

loop循环类似于C语言中while(1)或者for(;;),不手动用Break退出的话就会一直循环下去。

1
2
3
4
5
fn main() {
loop {
println!("again!");
}
}

需要注意的是,rust中有一个C语言中没有的特性:循环标签

循环标签(loop labels):

语法:

1
2
3
4
5
6
7
8
9
10
11
'label_name: loop {
// 循环体
}

'label_name: while condition {
// 循环体
}

'label_name: for item in collection {
// 循环体
}

循环标签的语法是在 loopwhilefor 关键字之前加上一个单引号开头的名称,后面紧跟着一个冒号,然后在使用 breakcontinue 时指定这个标签,从而控制跳出或继续执行哪个具体的循环,即使它不是最内层循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
fn main() {
let mut count = 0;

'outer_loop: loop { // 这是一个名为 'outer_loop 的循环
println!("Outer loop count: {count}");
let mut inner_count = 0;

'inner_loop: loop { // 这是一个名为 'inner_loop 的循环
println!("Inner loop count: {inner_count}");

if inner_count >= 2 {
break 'inner_loop; // 跳出 'inner_loop
}
if count >= 1 {
break 'outer_loop; // 跳出 'outer_loop
}
inner_count += 1;
}
count += 1;
if count >= 3 {
break; // 跳出当前(外层)循环,这里其实效果和 break 'outer_loop 一样,因为这是最外层循环
}
}
println!("Loop finished. Final count: {count}");
}

rust的这种特性很好的避免了C语言中需要跳出复杂循环时需要设置标志位以及单独判断的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
int done = 0;
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
if (i == 1 && j == 1) {
done = 1; // 设置标志
break; // 跳出内层循环
}
printf("i: %d, j: %d\n", i, j);
}
if (done) {
break; // 根据标志跳出外层循环
}
}

while:

1
2
3
4
5
fn main(){
while(true){
println!("Hello World")
}
}

while循环的语法跟特性与C语言都是类似的,唯一的不同就是它的条件必须是一个bool量而不能是其他类型。


for:

Rust的for循环与C语言的for循环有较大不同,Rust中的for循环类似于其他现代语言中的”for each”。

核心差异总结

特性 C 语言的 for 循环 Rust 的 for 循环
基础 基于计数器和条件,手动控制迭代过程 基于迭代器,遍历集合中的每个元素
语法 for (init; condition; step) for element in iterator
安全性 容易出现索引越界和“差一错误”,需要手动管理 Rust 的所有权和借用系统保证安全,避免越界访问
简洁性 对于简单计数循环简洁,但遍历集合时需要额外索引管理 遍历集合和范围时非常简洁,不需要手动索引或长度计算
灵活性 可以实现任何循环逻辑,包括非标准迭代(但可能不清晰) 通过迭代器适配器(map, filter 等)实现复杂迭代逻辑
返回值 循环本身不返回值 循环本身不返回值,但可以结合 break 来返回值

语法如下:

1
2
3
for 元素 in 迭代器 {
// 循环体
}

下面是简单的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fn main() {
// Rust 示例:遍历向量(Vector)
let numbers = vec![10, 20, 30, 40, 50];

// .iter() 方法返回一个迭代器,它产出对元素的不可变引用
for number in numbers.iter() {
print!("{} ", number);
}
println!(); // 输出: 10 20 30 40 50

// Rust 示例:使用范围(Range)
for i in 0..5 { // 范围 [0, 5) 不包含 5
print!("{} ", i);
}
println!(); // 输出: 0 1 2 3 4

// 如果需要索引,可以使用 .enumerate()
for (index, number) in numbers.iter().enumerate() {
println!("Index: {}, Value: {}", index, number);
}
}

注意:Rust是可以通过break;在循环(for while loop)中返回值的,例子如下:

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

// `loop` 作为一个表达式,通过 `break` 返回值
let result = loop { // 整个 loop 表达式将被赋值给 result
counter += 1;

if counter == 10 {
break counter * 2; // 当 counter 等于 10 时,跳出循环,并将 counter * 2 作为 loop 表达式的值
}
}; // 注意这里的分号,表示表达式的结束

println!("The result is: {}", result); // 输出: The result is: 20
println!("The final counter value is: {}", counter); // 输出: The final counter value is: 10
}

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

生命周期 作用域

核心区别总结

特性 作用域 (Scope) 生命周期 (Lifetime)
定义对象 变量的可见性区域及其存活期 引用的有效性时间段,即引用指向的数据存活的时间
控制对象 变量何时创建销毁 引用何时有效(不指向无效内存)
关注点 内存的分配与释放 内存安全,尤其是防止悬垂引用
目的 组织代码和管理资源 确保引用的有效性,由借用检查器(Borrow Checker)强制执行
形式 通常由 {} 代码块隐式定义 通常通过 'a, 'b 等显式注解(当编译器无法推断时)

剩下的,官方的文档写的非常好,我也是直接复制过来用了(Bushi)

什么是所有权?

所有权ownership)是 Rust 用于如何管理内存的一组规则。所有程序都必须管理其运行时使用计算机内存的方式。一些语言中具有垃圾回收机制,在程序运行时有规律地寻找不再使用的内存;在另一些语言中,程序员必须亲自分配和释放内存。Rust 则选择了第三种方式:通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查。如果违反了任何这些规则,程序都不能编译。在运行时,所有权系统的任何功能都不会减慢程序的运行。

因为所有权对很多程序员来说都是一个新概念,需要一些时间来适应。好消息是随着你对 Rust 和所有权系统的规则越来越有经验,你就越能自然地编写出安全和高效的代码。持之以恒!

当你理解了所有权,你将有一个坚实的基础来理解那些使 Rust 独特的功能。在本章中,你将通过完成一些示例来学习所有权,这些示例基于一个常用的数据结构:字符串。

栈(Stack)与堆(Heap)

在很多语言中,你并不需要经常考虑到栈与堆。不过在像 Rust 这样的系统编程语言中,值是位于栈上还是堆上在更大程度上影响了语言的行为以及为何必须做出这样的抉择。我们会在本章的稍后部分描述所有权与栈和堆相关的内容,所以这里只是一个用来预热的简要解释。

栈和堆都是代码在运行时可供使用的内存,但是它们的结构不同。栈以放入值的顺序存储值并以相反顺序取出值。这也被称作 后进先出last in, first out)。想象一下一叠盘子:当增加更多盘子时,把它们放在盘子堆的顶部,当需要盘子时,也从顶部拿走。不能从中间也不能从底部增加或拿走盘子!增加数据叫做 入栈pushing onto the stack),而移出数据叫做 出栈popping off the stack)。栈中的所有数据都必须占用已知且固定的大小。在编译时大小未知或大小可能变化的数据,要改为存储在堆上。

堆是缺乏组织的:当向堆放入数据时,你要请求一定大小的空间。内存分配器(memory allocator)在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的 指针pointer)。这个过程称作 在堆上分配内存allocating on the heap),有时简称为 “分配”(allocating)。(将数据推入栈中并不被认为是分配)。因为指向放入堆中数据的指针是已知的并且大小是固定的,你可以将该指针存储在栈上,不过当需要实际数据时,必须访问指针。想象一下去餐馆就座吃饭。当进入时,你说明有几个人,餐馆员工会找到一个够大的空桌子并领你们过去。如果有人来迟了,他们也可以通过询问来找到你们坐在哪。

入栈比在堆上分配内存要快,因为(入栈时)分配器无需为存储新数据去搜索内存空间;其位置总是在栈顶。相比之下,在堆上分配内存则需要更多的工作,这是因为分配器必须首先找到一块足够存放数据的内存空间,并接着做一些记录为下一次分配做准备。

访问堆上的数据比访问栈上的数据慢,因为必须通过指针来访问。现代处理器在内存中跳转越少就越快。继续类比,假设有一个服务员在餐厅里处理多个桌子的点菜。在一个桌子报完所有菜后再移动到下一个桌子是最有效率的。从桌子 A 听一个菜,接着桌子 B 听一个菜,然后再桌子 A,然后再桌子 B 这样的流程会更加缓慢。出于同样原因,处理器在处理的数据彼此较近的时候(比如在栈上)比较远的时候(比如可能在堆上)更高效。

当你的代码调用一个函数时,传递给函数的值(包括可能指向堆上数据的指针)和函数的局部变量被压入栈中。当函数结束时,这些值被移出栈。

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

所有权规则

首先,让我们看一下所有权的规则。当我们通过举例说明时,请谨记这些规则:

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

变量作用域

既然我们已经掌握了基本语法,将不会在之后的例子中包含 fn main() { 代码,所以如果你是一路跟过来的,必须手动将之后例子的代码放入一个 main 函数中。这样,例子将显得更加简明,使我们可以关注实际细节而不是样板代码。

在所有权的第一个例子中,我们看看一些变量的 作用域scope)。作用域是一个项(item)在程序中有效的范围。假设有这样一个变量:

1
let s = "hello";

变量 s 绑定到了一个字符串字面值,这个字符串值是硬编码进程序代码中的。这个变量从声明的点开始直到当前作用域结束时都是有效的。示例 4-1 中的注释标明了变量 s 在何处是有效的。

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

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

示例 4-1:一个变量和其有效的作用域

换句话说,这里有两个重要的时间点:

  • s 进入作用域时,它就是有效的。
  • 这一直持续到它离开作用域为止。

目前为止,变量是否有效与作用域的关系跟其他编程语言是类似的。现在我们在此基础上介绍 String 类型。

String 类型

为了演示所有权的规则,我们需要一个比第三章 “数据类型” 中讲到的都要复杂的数据类型。前面介绍的类型都是已知大小的,可以存储在栈中,并且当离开作用域时被移出栈,如果代码的另一部分需要在不同的作用域中使用相同的值,可以快速简单地复制它们来创建一个新的独立实例。不过我们需要寻找一个存储在堆上的数据来探索 Rust 是如何知道该在何时清理数据的,而 String 类型就是一个很好的例子。

我们会专注于 String 与所有权相关的部分。这些方面也同样适用于标准库提供的或你自己创建的其他复杂数据类型。在第八章会更深入地讲解 String

我们已经见过字符串字面值,即被硬编码进程序里的字符串值。字符串字面值是很方便的,不过它们并不适合使用文本的每一种场景。原因之一就是它们是不可变的。另一个原因是并非所有字符串的值都能在编写代码时就知道:例如,要是想获取用户输入并存储该怎么办呢?为此,Rust 有另一种字符串类型,String。这个类型管理被分配到堆上的数据,所以能够存储在编译时未知大小的文本。可以使用 from 函数基于字符串字面值来创建 String,如下:

这两个冒号 :: 是运算符,允许将特定的 from 函数置于 String 类型的命名空间(namespace)下,而不需要使用类似 string_from 这样的名字。在第五章的 “方法语法”(“Method Syntax”) 部分会着重讲解这个语法,而且在第七章的 “路径用于引用模块树中的项” 中会讲到模块的命名空间。

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

s.push_str(", world!"); // push_str() 在字符串后追加字面值

println!("{s}"); // 将打印 `hello, world!`

那么这里有什么区别呢?为什么 String 可变而字面值却不行呢?区别在于两个类型对内存的处理上。

内存与分配

就字符串字面值来说,我们在编译时就知道其内容,所以文本被直接硬编码进最终的可执行文件中。这使得字符串字面值快速且高效。不过这些特性都只得益于字符串字面值的不可变性。不幸的是,我们不能为了每一个在编译时大小未知的文本而将一块内存放入二进制文件中,并且它的大小还可能随着程序运行而改变。

对于 String 类型,为了支持一个可变,可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来存放内容。这意味着:

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

第一部分由我们完成:当调用 String::from 时,它的实现 (implementation) 请求其所需的内存。这在编程语言中是非常通用的。

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

Rust 采取了一个不同的策略:内存在拥有它的变量离开作用域后就被自动释放。下面是示例 4-1 中作用域例子的一个使用 String 而不是字符串字面值的版本:

1
2
3
4
5
6
{
let s = String::from("hello"); // 从此处起,s 是有效的

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

这是一个将 String 需要的内存返回给分配器的很自然的位置:当 s 离开作用域的时候。当变量离开作用域,Rust 为我们调用一个特殊的函数。这个函数叫做 drop,在这里 String 的作者可以放置释放内存的代码。Rust 在结尾的 } 处自动调用 drop

注意:在 C++ 中,这种 item 在生命周期结束时释放资源的模式有时被称作 资源获取即初始化Resource Acquisition Is Initialization (RAII))。如果你使用过 RAII 模式的话应该对 Rust 的 drop 函数并不陌生。

这个模式对编写 Rust 代码的方式有着深远的影响。现在它看起来很简单,不过在更复杂的场景下代码的行为可能是不可预测的,比如当有多个变量使用在堆上分配的内存时。现在让我们探索一些这样的场景。

使用移动的变量与数据交互

在 Rust 中,多个变量可以采取不同的方式与同一数据进行交互。让我们看看示例 4-2 中一个使用整型的例子。

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

示例 4-2:将变量 x 的整数值赋给 y

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

现在看看这个 String 版本:

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

这看起来与上面的代码非常类似,所以我们可能会假设它们的运行方式也是类似的:也就是说,第二行可能会生成一个 s1 的拷贝并绑定到 s2 上。但事实并非如此。

看看图 4-1 以了解 String 的底层会发生什么。String 由三部分组成,如图左侧所示:一个指向存放字符串内容内存的指针,一个长度,和一个容量。这一组数据存储在栈上。右侧则是堆上存放内容的内存部分。

Two tables: the first table contains the representation of s1 on the stack, consisting of its length (5), capacity (5), and a pointer to the first value in the second table. The second table contains the representation of the string data on the heap, byte by byte.

图 4-1:将值 "hello" 绑定给 s1String 在内存中的表现形式

长度表示 String 的内容当前使用了多少字节的内存。容量是 String 从分配器总共获取了多少字节的内存。长度与容量的区别是很重要的,不过在当前上下文中并不重要,所以现在可以忽略容量。

当我们将 s1 赋值给 s2String 的数据被复制了,这意味着我们从栈上拷贝了它的指针、长度和容量。我们并没有复制指针指向的堆上数据。换句话说,内存中数据的表现如图 4-2 所示。

Three tables: tables s1 and s2 representing those strings on the stack, respectively, and both pointing to the same string data on the heap.

图 4-2:变量 s2 的内存表现,它有一份 s1 指针、长度和容量的拷贝

这个表现形式看起来并不像图 4-3 中的那样,如果 Rust 也拷贝了堆上的数据,那么内存看起来就是这样的。如果 Rust 这么做了,那么操作 s2 = s1 在堆上数据比较大的时候会对运行时性能造成非常大的影响。

Four tables: two tables representing the stack data for s1 and s2, and each points to its own copy of string data on the heap.

图 4-3:另一个 s2 = s1 时可能的内存表现,如果 Rust 同时也拷贝了堆上的数据的话

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

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

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

println!("{s1}, world!");

你会得到一个类似如下的错误,因为 Rust 禁止你使用无效的引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:15
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{s1}, world!");
| ^^^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
|
3 | let s2 = s1.clone();
| ++++++++

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

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

Three tables: tables s1 and s2 representing those strings on the stack, respectively, and both pointing to the same string data on the heap. Table s1 is grayed out be-cause s1 is no longer valid; only s2 can be used to access the heap data.

图 4-4:s1 无效之后的内存表现

这样就解决了我们的问题!因为只有 s2 是有效的,当其离开作用域,它就释放自己的内存,完毕。

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

作用域与赋值

作用域、所有权和通过 drop 函数释放内存之间的关系反过来也同样成立。当你给一个已有的变量赋一个全新的值时,Rust 将会立即调用 drop 并释放原始值的内存。例如,考虑如下代码:

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

println!("{s}, world!");

起初我们声明了变量 s 并绑定为一个 "hello" 值的 String。接着立即创建了一个值为 "ahoy"String 并赋值给 s。在这里,完全没有任何内容指向了原始堆上的值。

One table s representing the string value on the stack, pointing to the second piece of string data (ahoy) on the heap, with the original string data (hello) grayed out because it cannot be accessed anymore.

图 4-5: 当初始值被整体替换后的内存表现

因此原始的字符串立刻就离开了作用域。Rust 会在其上运行 drop 函数同时内存会马上释放。当结尾打印其值时,将会是 "ahoy, world!"

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

如果我们 确实 需要深度复制 String 中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone 的常用方法。第五章会讨论方法语法,不过因为方法在很多语言中是一个常见功能,所以之前你可能已经见过了。

这是一个实际使用 clone 方法的例子:

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

println!("s1 = {s1}, s2 = {s2}");

这段代码能正常运行,并且明确产生图 4-3 中行为,这里堆上的数据确实被复制了。

当出现 clone 调用时,你知道一些特定的代码被执行而且这些代码可能相当消耗资源。你很容易察觉到一些不寻常的事情正在发生。

只在栈上的数据:拷贝

这里还有一个没有提到的细节。这些代码使用了整型并且是有效的,它们是示例 4-2 中的一部分:

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

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

但这段代码似乎与我们刚刚学到的内容相矛盾:没有调用 clone,不过 x 依然有效且没有被移动到 y 中。

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

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

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

那么哪些类型实现了 Copy trait 呢?你可以查看给定类型的文档来确认,不过作为一个通用的规则,任何一组简单标量值的组合都可以实现 Copy,任何不需要分配内存或某种形式资源的类型都可以实现 Copy 。如下是一些 Copy 的类型:

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

所有权与函数

将值传递给函数与给变量赋值的原理相似。向函数传递值可能会移动或者复制,就像赋值语句一样。示例 4-3 使用注释展示变量何时进入和离开作用域:

文件名:src/main.rs

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

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

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

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

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

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

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

示例 4-3:带有所有权和作用域注释的函数

当尝试在调用 takes_ownership 后使用 s 时,Rust 会抛出一个编译时错误。这些静态检查使我们免于犯错。试试在 main 函数中添加使用 sx 的代码来看看哪里能使用它们,以及所有权规则会在哪里阻止我们这么做。

返回值与作用域

返回值也可以转移所有权。示例 4-4 展示了一个返回了某些值的示例,与示例 4-3 一样带有类似的注释。

文件名:src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
fn main() {
let s1 = gives_ownership(); // gives_ownership 将它的返回值传递给 s1

let s2 = String::from("hello"); // s2 进入作用域

let s3 = takes_and_gives_back(s2); // s2 被传入 takes_and_gives_back,
// 它的返回值又传递给 s3
} // 此处,s3 移出作用域并被丢弃。s2 被 move,所以无事发生
// s1 移出作用域并被丢弃

fn gives_ownership() -> String { // gives_ownership 将会把返回值传入
// 调用它的函数

let some_string = String::from("yours"); // some_string 进入作用域

some_string // 返回 some_string 并将其移至调用函数
}

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

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

示例 4-4: 转移返回值的所有权

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

虽然这样是可以的,但是在每一个函数中都获取所有权并接着返回所有权有些啰嗦。如果我们想要函数使用一个值但不获取所有权该怎么办呢?如果我们还要接着使用它的话,每次都传进去再返回来就有点烦人了,除此之外,我们也可能想返回函数体中产生的一些数据。

我们可以使用元组来返回多个值,如示例 4-5 所示。

文件名:src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
fn main() {
let s1 = String::from("hello");

let (s2, len) = calculate_length(s1);

println!("The length of '{s2}' is {len}.");
}

fn calculate_length(s: String) -> (String, usize) {
let length = s.len(); // len() 返回字符串的长度

(s, length)
}

示例 4-5: 返回参数的所有权

引用与借用

示例 4-5 中的元组代码有这样一个问题:我们必须将 String 返回给调用函数,以便在调用 calculate_length 后仍能使用 String,因为 String 被移动到了 calculate_length 内。相反我们可以提供一个 String 值的引用(reference)。引用reference)像一个指针,因为它是一个地址,我们可以由此访问储存于该地址的属于其他变量的数据。与指针不同,引用在其生命周期内保证指向某个特定类型的有效值。

下面是如何定义并使用一个(新的)calculate_length 函数,它以一个对象的引用作为参数而不是获取值的所有权:

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

let len = calculate_length(&s1);

println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
s.len()
}

首先,注意变量声明和函数返回值中的所有元组代码都消失了。其次,注意我们传递 &s1calculate_length,同时在函数定义中,我们获取 &String 而不是 String。这些 & 符号就是 引用,它们允许你使用值但不获取其所有权。图 4-6 展示了一张示意图。

Three tables: the table for s contains only a pointer to the table for s1. The table for s1 contains the stack data for s1 and points to the string data on the heap.

图 4-6:&String s 指向 String s1 示意图

注意:与使用 & 引用相反的操作是 解引用dereferencing),它使用解引用运算符 * 实现。我们将会在第八章遇到一些解引用运算符,并在第十五章详细讨论解引用。

仔细看看这个函数调用:

1
2
let s1 = String::from("hello");
let len = calculate_length(&s1);

&s1 语法让我们创建一个指向s1 的引用,但是并不拥有它。因为并不拥有这个值,所以当引用停止使用时,它所指向的值也不会被丢弃。

同理,函数签名使用 & 来表明参数 s 的类型是一个引用。让我们增加一些解释性的注释:

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

变量 s 有效的作用域与函数参数的作用域一样,不过当 s 停止使用时并不丢弃引用指向的数据,因为 s 并没有所有权。当函数使用引用而不是实际值作为参数,无需返回值来交还所有权,因为就不曾拥有所有权。

我们将创建一个引用的行为称为 借用borrowing)。正如现实生活中,如果一个人拥有某样东西,你可以从他那里借来。当你使用完后,必须还回去。因为我们并不拥有它的所有权。

那如果我们尝试修改借用的变量呢?尝试示例 4-6 中的代码。剧透:这行不通!

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

change(&s);
}

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

示例 4-6:尝试修改借用的值

这里是错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
--> src/main.rs:8:5
|
8 | some_string.push_str(", world");
| ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable
|
help: consider changing this to be a mutable reference
|
7 | fn change(some_string: &mut String) {
| +++

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

正如变量默认是不可变的,引用也一样。(默认)不允许修改引用的值。

可变引用

我们通过一个小调整就能修复示例 4-6 代码中的错误,允许我们修改一个借用的值,这就是 可变引用mutable reference):

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

change(&mut s);
}

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

首先,我们必须将 s 改为 mut。然后在调用 change 函数的地方创建一个可变引用 &mut s,并更新函数签名以接受一个可变引用 some_string: &mut String。这就非常清楚地表明,change 函数将改变它所借用的值。

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

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

let r1 = &mut s;
let r2 = &mut s;

println!("{}, {}", r1, r2);

错误如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0499]: cannot borrow `s` as mutable more than once at a time
--> src/main.rs:5:14
|
4 | let r1 = &mut s;
| ------ first mutable borrow occurs here
5 | let r2 = &mut s;
| ^^^^^^ second mutable borrow occurs here
6 |
7 | println!("{}, {}", r1, r2);
| -- first borrow later used here

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

这个报错说这段代码是无效的,因为我们不能在同一时间多次将 s 作为可变变量借用。第一个可变的借入在 r1 中,并且必须持续到在 println! 中使用它,但是在那个可变引用的创建和它的使用之间,我们又尝试在 r2 中创建另一个可变引用,该引用借用与 r1 相同的数据。

这一限制以一种非常小心谨慎的方式允许可变性,防止同一时间对同一数据存在多个可变引用。新 Rustacean 们经常难以适应这一点,因为大部分语言中变量任何时候都是可变的。这个限制的好处是 Rust 可以在编译时就避免数据竞争。数据竞争data race)类似于竞态条件,它可由这三个行为造成:

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

数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复;Rust 通过拒绝编译存在数据竞争的代码来避免此问题!

一如既往,可以使用大括号来创建一个新的作用域,以允许拥有多个可变引用,只是不能同时拥有:

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

{
let r1 = &mut s;
} // r1 在这里离开了作用域,所以我们完全可以创建一个新的引用

let r2 = &mut s;

Rust 在同时使用可变与不可变引用时也强制采用类似的规则。这些代码会导致一个错误:

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

let r1 = &s; // 没问题
let r2 = &s; // 没问题
let r3 = &mut s; // 大问题

println!("{}, {}, and {}", r1, r2, r3);

错误如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:6:14
|
4 | let r1 = &s; // no problem
| -- immutable borrow occurs here
5 | let r2 = &s; // no problem
6 | let r3 = &mut s; // BIG PROBLEM
| ^^^^^^ mutable borrow occurs here
7 |
8 | println!("{}, {}, and {}", r1, r2, r3);
| -- immutable borrow later used here

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

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

不可变引用的借用者可不希望在借用时值会突然发生改变!然而,多个不可变引用是可以的,因为没有哪个只能读取数据的引用者能够影响其他引用者读取到的数据。

注意一个引用的作用域从声明的地方开始一直持续到最后一次使用为止。例如,因为最后一次使用不可变引用的位置在 println!,它发生在声明可变引用之前,所以如下代码是可以编译的:

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

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

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

不可变引用 r1r2 的作用域在 println! 最后一次使用之后结束,这发生在可变引用 r3 被创建之前。因为它们的作用域没有重叠,所以代码是可以编译的。编译器可以在作用域结束之前判断不再使用的引用。

尽管借用错误有时令人沮丧,但请牢记这是 Rust 编译器在提前指出一个潜在的 bug(在编译时而不是在运行时)并精准显示问题所在。这样你就不必去跟踪为何数据并不是你想象中的那样。

悬垂引用(Dangling References)

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

让我们尝试创建一个悬垂引用,看看 Rust 如何通过通过一个编译时错误来防止它:

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

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

&s
}

这里是错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
--> src/main.rs:5:16
|
5 | fn dangle() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
|
5 | fn dangle() -> &'static String {
| +++++++
help: instead, you are more likely to want to return an owned value
|
5 - fn dangle() -> &String {
5 + fn dangle() -> String {
|

error[E0515]: cannot return reference to local variable `s`
--> src/main.rs:8:5
|
8 | &s
| ^^ returns a reference to data owned by the current function

Some errors have detailed explanations: E0106, E0515.
For more information about an error, try `rustc --explain E0106`.
error: could not compile `ownership` (bin "ownership") due to 2 previous errors

错误信息引用了一个我们还未介绍的功能:生命周期(lifetimes)。第十章会详细介绍生命周期。不过,如果你不理会生命周期部分,错误信息中确实包含了为什么这段代码有问题的关键信息:

1
2
this function's return type contains a borrowed value, but there is no value
for it to be borrowed from

让我们仔细看看我们的 dangle 代码的每个阶段到底发生了什么:

1
2
3
4
5
6
7
fn dangle() -> &String { // dangle 返回一个字符串的引用

let s = String::from("hello"); // s 是一个新字符串

&s // 返回字符串 s 的引用
} // 这里 s 离开作用域并被丢弃。其内存被释放。
// 危险!

因为 s 是在 dangle 函数内创建的,当 dangle 的代码执行完毕后,s 将被释放。不过我们尝试返回它的引用。这意味着这个引用会指向一个无效的 String,这可不对!Rust 不会允许我们这么做。

这里的解决方法是直接返回 String

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

s
}

这样就没有任何错误了。所有权被移动出去,所以没有值被释放。

引用的规则

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

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

接下来,我们来看看另一种不同类型的引用:slice。

Slice 类型

切片slice)允许你引用集合中一段连续的元素序列,而不用引用整个集合。slice 是一种引用,所以它不拥有所有权。

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

注意:出于介绍字符串 slice 的目的,本小节假设只使用 ASCII 字符集;一个关于 UTF-8 处理的更全面的讨论位于第八章“使用字符串储存 UTF-8 编码的文本”小节。

让我们推敲下如何不用 slice 编写这个函数的签名,来理解 slice 能解决的问题:

1
fn first_word(s: &String) -> ?

first_word 函数有一个参数 &String。因为我们不需要所有权,所以这没有问题。不过应该返回什么呢?我们并没有一个真正获取部分字符串的办法。不过,我们可以返回单词结尾的索引,结尾由一个空格表示。试试如示例 4-7 中的代码。

文件名:src/main.rs

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

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

s.len()
}

示例 4-7:first_word 函数返回 String 参数的一个字节索引值

因为需要逐个元素的检查 String 中的值是否为空格,需要用 as_bytes 方法将 String 转化为字节数组。

1
let bytes = s.as_bytes();

接下来,使用 iter 方法在字节数组上创建一个迭代器:

1
for (i, &item) in bytes.iter().enumerate() {

我们将在第十三章详细讨论迭代器。现在,只需知道 iter 方法返回集合中的每一个元素,而 enumerate 包装了 iter 的结果,将这些元素作为元组的一部分来返回。enumerate 返回的元组中,第一个元素是索引,第二个元素是集合中元素的引用。这比我们自己计算索引要方便一些。

因为 enumerate 方法返回一个元组,我们可以使用模式来解构,我们将在第六章中进一步讨论有关模式的问题。所以在 for 循环中,我们指定了一个模式,其中元组中的 i 是索引而元组中的 &item 是单个字节。因为我们从 .iter().enumerate() 中获取了集合元素的引用,所以模式中使用了 &

for 循环中,我们通过字节的字面值语法来寻找代表空格的字节。如果找到了一个空格,返回它的位置。否则,使用 s.len() 返回字符串的长度。

1
2
3
4
5
6
    if item == b' ' {
return i;
}
}

s.len()

现在有了一个找到字符串中第一个单词结尾索引的方法,不过这有一个问题。我们返回了一个独立的 usize,不过它只在 &String 的上下文中才是一个有意义的数字。换句话说,因为它是一个与 String 相分离的值,无法保证将来它仍然有效。考虑一下示例 4-8 中使用了示例 4-7 中 first_word 函数的程序。

文件名:src/main.rs

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

let word = first_word(&s); // word 的值为 5

s.clear(); // 这清空了字符串,使其等于 ""

// word 在此处的值仍然是 5,
// 但是没有更多的字符串让我们可以有效地应用数值 5。word 的值现在完全无效!
}

示例 4-8:存储 first_word 函数调用的返回值并接着改变 String 的内容

这个程序编译时没有任何错误,而且在调用 s.clear() 之后使用 word 也不会出错。因为 words 状态完全没有联系,所以 word 仍然包含值 5。可以尝试用值 5 来提取变量 s 的第一个单词,不过这是有 bug 的,因为在我们将 5 保存到 word 之后 s 的内容已经改变。

我们不得不时刻担心 word 的索引与 s 中的数据不再同步,这既繁琐又易出错!如果编写这么一个 second_word 函数的话,管理索引这件事将更加容易出问题。它的签名看起来像这样:

1
fn second_word(s: &String) -> (usize, usize) {

现在我们要跟踪一个开始索引一个结束索引,同时有了更多从数据的某个特定状态计算而来的值,但都完全没有与这个状态相关联。现在有三个飘忽不定的不相关变量需要保持同步。

幸运的是,Rust 为这个问题提供了一个解决方法:字符串 slice。

字符串 slice

字符串 slicestring slice)是 String 中一部分值的引用,它看起来像这样:

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

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

不同于整个 String 的引用,hello 是一个部分 String 的引用,由一个额外的 [0..5] 部分指定。可以使用一个由中括号中的 [starting_index..ending_index] 指定的 range 创建一个 slice,其中 starting_index 是 slice 的第一个位置,ending_index 则是 slice 最后一个位置的后一个值。在其内部,slice 的数据结构存储了 slice 的开始位置和长度,长度对应于 ending_index 减去 starting_index 的值。所以对于 let world = &s[6..11]; 的情况,world 将是一个包含指向 s 索引 6 的指针和长度值 5 的 slice。

图 4-7 展示了一个图例。

Three tables: a table representing the stack data of s, which points to the byte at index 0 in a table of the string data "hello world" on the heap. The third table rep-resents the stack data of the slice world, which has a length value of 5 and points to byte 6 of the heap data table.

图 4-7:引用了部分 String 的字符串 slice

对于 Rust 的 .. range 语法,如果想要从索引 0 开始,可以不写两个点号之前的值。换句话说,如下两个语句是相同的:

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

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

依此类推,如果 slice 包含 String 的最后一个字节,也可以舍弃尾部的数字。这意味着如下也是相同的:

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

let len = s.len();

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

也可以同时舍弃这两个值来获取整个字符串的 slice。所以如下亦是相同的:

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

let len = s.len();

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

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

在记住所有这些知识后,让我们重写 first_word 来返回一个 slice。“字符串 slice” 的类型声明写作 &str

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

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

&s[..]
}

我们使用跟示例 4-7 相同的方式获取单词结尾的索引,通过寻找第一个出现的空格。当找到一个空格,我们返回一个字符串 slice,它使用字符串的开始和空格的索引作为开始和结束的索引。

现在当调用 first_word 时,会返回与底层数据关联的单个值。这个值由一个 slice 开始位置的引用和 slice 中元素的数量组成。

second_word 函数也可以改为返回一个 slice:

1
fn second_word(s: &String) -> &str {

现在我们有了一个不易混淆且直观的 API 了,因为编译器会确保指向 String 的引用持续有效。还记得示例 4-8 程序中,那个当我们获取第一个单词结尾的索引后,接着就清除了字符串导致索引就无效的 bug 吗?那些代码在逻辑上是不正确的,但却没有显示任何直接的错误。问题会在之后尝试对空字符串使用第一个单词的索引时出现。slice 就不可能出现这种 bug 并让我们更早的知道出问题了。使用 slice 版本的 first_word 会抛出一个编译时错误:

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

let word = first_word(&s);

s.clear(); // 错误!

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

这里是编译错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:18:5
|
16 | let word = first_word(&s);
| -- immutable borrow occurs here
17 |
18 | s.clear(); // error!
| ^^^^^^^^^ mutable borrow occurs here
19 |
20 | println!("the first word is: {word}");
| ------ immutable borrow later used here

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

回忆一下借用规则,当拥有某值的不可变引用时,就不能再获取一个可变引用。因为 clear 需要清空 String,它尝试获取一个可变引用。在调用 clear 之后的 println! 使用了 word 中的引用,所以这个不可变的引用在此时必须仍然有效。Rust 不允许 clear 中的可变引用和 word 中的不可变引用同时存在,因此编译失败。Rust 不仅使得我们的 API 简单易用,也在编译时就消除了一整类的错误!

字符串字面值就是 slice

还记得我们讲到过字符串字面值被储存在二进制文件中吗?现在知道 slice 了,我们就可以正确地理解字符串字面值了:

1
let s = "Hello, world!";

这里 s 的类型是 &str:它是一个指向二进制程序特定位置的 slice。这也就是为什么字符串字面值是不可变的;&str 是一个不可变引用。

字符串 slice 作为参数

在知道了能够获取字面值和 String 的 slice 后,我们对 first_word 做了改进,这是它的签名:

1
fn first_word(s: &String) -> &str {

而更有经验的 Rustacean 会编写出示例 4-9 中的签名,因为它使得可以对 &String 值和 &str 值使用相同的函数:

1
fn first_word(s: &str) -> &str {

示例 4-9: 通过将 s 参数的类型改为字符串 slice 来改进 first_word 函数

如果有一个字符串 slice,可以直接传递它。如果有一个 String,则可以传递整个 String 的 slice 或对 String 的引用。这种灵活性利用了 deref coercions 的优势,这个特性我们将在“函数和方法的隐式 Deref 强制转换”章节中介绍。定义一个获取字符串 slice 而不是 String 引用的函数使得我们的 API 更加通用并且不会丢失任何功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fn main() {
let my_string = String::from("hello world");

// `first_word` 适用于 `String`(的 slice),部分或全部
let word = first_word(&my_string[0..6]);
let word = first_word(&my_string[..]);
// `first_word` 也适用于 `String` 的引用,
// 这等价于整个 `String` 的 slice
let word = first_word(&my_string);

let my_string_literal = "hello world";

// `first_word` 适用于字符串字面值,部分或全部
let word = first_word(&my_string_literal[0..6]);
let word = first_word(&my_string_literal[..]);

// 因为字符串字面值已经 **是** 字符串 slice 了,
// 这也是适用的,无需 slice 语法!
let word = first_word(my_string_literal);
}

其他类型的 slice

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

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

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

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

let slice = &a[1..3];

assert_eq!(slice, &[2, 3]);

这个 slice 的类型是 &[i32]。它跟字符串 slice 的工作方式一样,通过存储第一个集合元素的引用和一个集合总长度。你可以对其他所有集合使用这类 slice。第八章讲到 vector 时会详细讨论这些集合。

变量的声明

rust的变量分为可变变量与不可变变量,声明变量使用let关键字,类型可以不显式声明,可变变量用mut关键字。

1
2
3
4
5
let x:i32 = 42; //显式告知编译器变量类型
let guess = 42; // 编译器推断 guess 是 i32 类型
let pi = 3.14; // 编译器推断 pi 是 f64 类型
let active = true; // 编译器推断 active 是 bool 类型
let mut y = 10; // 声明一个可变变量 y 并赋值为 10

变量的遮蔽

在 Rust 中,变量遮蔽指的是你可以声明一个与之前变量同名的新变量。这个新变量会“遮盖”(或“隐藏”)之前声明的同名变量,使得在当前作用域内,对这个名字的引用会指向新的变量,而不是旧的。

1
2
3
4
5
6
7
8
9
10
fn main() {
let x = 5; // 第一次声明 x
println!("The initial value of x is: {}", x); // 输出 5

let x = x + 1; // 第二次声明 x,它遮蔽了之前的 x
println!("The new value of x is: {}", x); // 输出 6

let x = x * 2; // 第三次声明 x,再次遮蔽
println!("The final value of x is: {}", x); // 输出 12
}

乍一看,变量遮蔽可能与使用 mut 关键字的可变变量(Mutable Variables)有些相似,但它们之间存在根本的区别:

  1. 内存位置

    • 可变变量 (let mut):修改的是同一个内存位置的值。当你说 let mut x = 5; x = 6; 时,x 的值从 5 变成了 6,存储 x 的那块内存区域中的内容发生了变化。
    • 变量遮蔽 (let):是创建了一个全新的变量。每次你使用 let 关键字并带上一个已有的变量名时,Rust 实际上是在内存中分配了一个新的空间来存储新变量的值,而不是修改旧变量的值。旧变量可能仍然存在于内存中,只是你无法通过它的名字访问它了。
  2. 类型改变

    • 可变变量 (let mut):不能改变变量的类型。如果 x 最初是 i32,那么它永远是 i32
    • 变量遮蔽 (let):可以改变变量的类型。这是遮蔽的一个强大之处,也是它与可变变量最明显的区别之一。
    1
    2
    3
    4
    5
    6
    7
    fn main() {
    let spaces = " "; // spaces 是一个 &str 类型
    println!("Type of spaces (string): {:?}", spaces);

    let spaces = spaces.len(); // 新的 spaces 是一个 usize 类型,旧的 &str 被遮蔽了
    println!("Type of spaces (number): {:?}", spaces);
    }

变量遮蔽在 Rust 中被视为一个有用的特性,主要有以下几个原因:

  1. 类型转换或数据转换:当你需要对一个变量进行转换操作(例如从字符串解析数字,或者进行某种计算),并希望用同一个有意义的变量名来表示转换后的结果时,遮蔽非常方便。这样可以避免创建 x_strx_int 这样一系列冗余的变量名。
  2. 避免意外修改:因为遮蔽是创建新变量,而不是修改旧变量,所以它强制你重新使用 let 关键字。这有助于避免无意中修改了某个远处的变量,因为你需要明确地重新声明它。
  3. 提高可读性:在某些情况下,使用相同的变量名可以使代码更具可读性,因为它清楚地表明你正在处理同一个逻辑概念的不同“阶段”或“表示”。

变量遮蔽只在当前作用域内有效。当代码块结束时,被遮蔽的变量(如果它仍然在作用域内)可能会重新变得可见,或者新的遮蔽变量会消失。

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

{ // 这是一个新的内部作用域
let s = String::from("world"); // s2,遮蔽了外部的 s1
println!("Inside inner scope: {}", s); // 输出 "world"
} // 内部作用域结束,s2 被丢弃

println!("Outside inner scope: {}", s); // 输出 "hello" (s1 重新可见)
}

基本数据类型

rust中基本数据类型包括以下几种

  1. 整形(u32、i32)
  2. 浮点型(f32,f64)
  3. 布尔型(bool)
  4. 字符型(char)
1
2
3
4
5
6
fn main() {
let x:i32 = 42;
let y:u64 = 200;
let pi:f64 = 3.14159;
let is_snowing: bool = true;
}

跟C语言没什么大区别,就不详细说了


复合数据类型

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

元组(tuple):

元组是一个将多个不同类型的值组合进一个复合类型的主要方式。元组长度固定:一旦声明,其长度不会增大或缩小。

我们使用包含在圆括号中的逗号分隔的值列表来创建一个元组。元组中的每一个位置都有一个类型,而且这些不同值的类型也不必是相同的。这个例子中使用了可选的类型注解:

1
2
3
fn main() {
let tup: (i32, f64, u8) = (500, 6.4, 1);
}

tup 变量绑定到整个元组上,因为元组是一个单独的复合元素。为了从元组中获取单个值,可以使用模式匹配(pattern matching)来解构(destructure)元组值,像这样:

1
2
3
4
5
6
7
fn main() {
let tup = (500, 6.4, 1);

let (x, y, z) = tup;

println!("The value of y is: {y}");
}

程序首先创建了一个元组并绑定到 tup 变量上。接着使用了 let 和一个模式将 tup 分成了三个不同的变量,xyz。这叫做 解构destructuring),因为它将一个元组拆成了三个部分。最后,程序打印出了 y 的值,也就是 6.4

我们也可以使用点号(.)后跟值的索引来直接访问所需的元组元素。例如:

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

let five_hundred = x.0;

let six_point_four = x.1;

let one = x.2;
}

这个程序创建了一个元组,x,然后使用其各自的索引访问元组中的每个元素。跟大多数编程语言一样,元组的第一个索引值是 0。

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

数组(array):

另一个包含多个值的方式是 数组array)。与元组不同,数组中的每个元素的类型必须相同。Rust 中的数组与一些其他语言中的数组不同,Rust 中的数组长度是固定的。

我们将数组的值写成在方括号内,用逗号分隔的列表:

1
2
3
fn main() {
let numbers:[i32;5] = [1,2,3,4,5];
}

当你想要在栈(stack)而不是在堆(heap)上为数据分配空间,或者是想要确保总是有固定数量的元素时,数组非常有用。但是数组并不如 vector 类型灵活。vector 类型是标准库提供的一个 允许 增长和缩小长度的类似数组的集合类型。当不确定是应该使用数组还是 vector 的时候,那么很可能应该使用 vector。

然而,当你确定元素个数不会改变时,数组会更有用。例如,当你在一个程序中使用月份名字时,你更应趋向于使用数组而不是 vector,因为你确定只会有 12 个元素。

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

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

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

这里,i32 是每个元素的类型。分号之后,数字 5 表明该数组包含五个元素。

你还可以通过在方括号中指定初始值加分号再加元素个数的方式来创建一个每个元素都为相同值的数组:

1
let a = [3; 5];

变量名为 a 的数组将包含 5 个元素,这些元素的值最初都将被设置为 3。这种写法与 let a = [3, 3, 3, 3, 3]; 效果相同,但更简洁。

数组是可以在栈 (stack) 上分配的已知固定大小的单个内存块。可以使用索引来访问数组的元素,像这样:

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

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

在这个例子中,叫做 first 的变量的值是 1,因为它是数组索引 [0] 的值。变量 second 将会是数组索引 [1] 的值 2


引用类型与切片(slices)

引用类型分为可变引用以及不可变引用。

可变引用可以理解为一种更加安全的指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
fn main() {
let mut s = String::from("hello"); // 原始变量必须是可变的

// s 的可变引用被传递给 change 函数
change(&mut s);

println!("{}", s); // 输出 "hello, world"
}

// 这个函数接收一个 String 的可变引用
fn change(some_string: &mut String) {
some_string.push_str(", world");
}

不可变引用可以理解为类似于C语言中const int * 的类型,可以通过指针访问引用的值,但是不能修改。注意引用的对象是可以发生改变的

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let a = 10;
let b = 20;

let mut r = &a; // r 引用 a
println!("r points to a: {}", r); // 输出 10

r = &b; // 重新绑定 r,让它引用 b
println!("r now points to b: {}", r); // 输出 20
}

Rust 的借用检查器(borrow checker) 是一个在编译时运行的工具,它会严格执行以下引用规则,确保内存安全:

  1. 没有空引用: Rust 的引用保证总是指向有效的数据。你无法创建空引用,这避免了其他语言中常见的空指针解引用错误。
  2. 没有悬垂引用: 引用指向的数据在其生命周期内必须始终有效。借用检查器会确保你不会引用一块已经被释放的内存。
  3. 读写互斥规则: 这是最关键的规则,它防止了数据竞争(data races):
    • 在任何给定时间,你只能拥有一个可变引用 (&mut T)。这意味着当数据被修改时,不能有其他任何引用(无论是可变还是不可变)指向它。
    • 你可以拥有任意数量的不可变引用 (&T)。这意味着数据可以被多次同时读取。
    • 但是,当存在不可变引用时,就不能有任何可变引用。

切片是 Rust 中一种特殊的引用类型,它允许你引用集合(如数组、VecString)中连续、特定范围的元素序列。切片本身也是一种引用,不拥有数据。可以类比为指向数组的更安全的指针。

  • 泛型切片 (&[T]):用于引用数组或 Vec 的部分或全部。

    1
    2
    let numbers = [1, 2, 3, 4, 5];
    let middle = &numbers[1..4]; // middle 是 &[i32],引用 [2, 3, 4]
  • 字符串切片 (&str):用于引用 String 的部分或全部,或直接表示字符串字面量。

    1
    2
    3
    let s = String::from("hello world");
    let word = &s[0..5]; // word 是 &str,引用 "hello"
    let literal = "Rust is great"; // 字符串字面量本身就是 &str

String与String slices

在rust中 String是一个结构体,是封装好的可进行修改的字符串。

String slices是字符串切片,无法修改,即不可变引用。

&mut String 和字符串切片 (&str) 之间存在一种非常重要的关系,但它们是两个截然不同且用途互补的概念。

1. &mut String:可变且拥有数据的引用

&mut String 是对一个 String 类型变量的可变引用

  • 所有权和可变性:
    • String 类型本身是拥有数据的,并且其内容是可变的(存储在堆上,可以增长、收缩和修改)。
    • &mut String 允许你通过这个引用来修改原始的 String 数据
  • 用途: 当你需要在函数内部改变一个 String 变量的内容,但又不希望函数获取这个 String 的所有权时,你会传递 &mut String
  • 独占性: 遵循 Rust 的借用规则,当一个 &mut String 存在时,不能有其他任何引用(无论是 &mut String 还是 &String)指向同一个 String 实例。这保证了数据在修改时的唯一访问,从而防止了数据竞争。
1
2
3
4
5
6
7
fn modify_my_string(s: &mut String) {
s.push_str(" world!"); // 通过可变引用修改原始 String
}

let mut my_string = String::from("Hello");
modify_my_string(&mut my_string); // 传递可变引用
println!("{}", my_string); // Output: Hello world!

2. 字符串切片 (&str):不可变且不拥有数据的引用

&str(字符串切片)是对一个 UTF-8 编码的字符串数据段的不可变引用

  • 所有权和不可变性:
    • &str 不拥有数据;它只是“借用”了另一段内存中的字符串数据。
    • &str 自身是不可变的。你不能通过 &str 来修改它所引用的字符串内容。
  • 来源: &str 可以来自多种地方:
    • 字符串字面量("hello")本身就是 &'static str 类型。
    • String 类型可以通过 &my_string[..]&my_string 自动强制转换(deref coercion)为 &str
  • 用途: 当你只需要读取字符串内容,或函数需要接受任何形式的字符串(无论是 String 还是字符串字面量)作为参数时,通常会使用 &str
  • 共享性: 由于是不可变的,你可以创建多个 &str 引用指向同一段数据。
1
2
3
4
5
6
7
8
9
10
11
fn print_my_string(s: &str) {
println!("{}", s); // 只能读取,不能修改
// s.push_str("!"); // 错误!&str 是不可变的
}

let my_string = String::from("Rust");
let literal_string: &str = "Programming";

print_my_string(&my_string); // 将 String 借用为 &str
print_my_string(literal_string); // 直接使用字符串字面量
print_my_string(&my_string[0..2]); // 借用 String 的一部分作为 &str

它们之间的关系和区别总结

特性 &mut String &str
所有权 不拥有数据,是对 String借用 不拥有数据,是对 UTF-8 字符串数据借用
可变性 可变:允许通过引用修改原始 String 的内容 不可变:不允许通过引用修改所引用的字符串内容
指向目标 总是指向一个完整的 String 实例 可以指向 String 的一部分,也可以是整个 String,或者是字符串字面量
独占性 独占:同一时间只能有一个 &mut String 存在 共享:可以同时存在多个 &str 引用
用途 需要在函数中修改 String 的场景 需要读取字符串数据,或作为通用字符串参数的场景

简而言之:

  • &mut String 是你想要修改一个堆上可变字符串时用的。它就像给函数一个“写权限”去操作原始的 String
  • &str 是你想要读取一个**字符串(无论来自哪里)**时用的。它就像给函数一个“只读视图”。

&str 是更通用的字符串视图类型,因为它既可以引用 String 的内容,也可以引用字符串字面量。而 &mut String 明确表示你正在操作一个底层的 String 对象,并且你有权修改它。


1
2
3
4
5
6
7
8
9
10
11
12
13
let book_slices:&[&String] = &[&"JVAV".to_string(),&"IT".to_string(),&"ZEN".to_string()];
println!("Book_Slices:{:?}",book_slices);

//String Vs String Slices(&str)
let mut stone_cold:String = String::from("Hell,");
println!("Stone Cold Says:{}",stone_cold);
stone_cold.push_str("Yeah!");
println!("Stone Cold Says:{}",stone_cold);

//B- &str(String Slice)
let string: String = String::from("Hello,World");
let slice: &str = &string[0..5];
println!("Slice Value:{}",slice);

let book_slices:&[&String] = &[&"JVAV".to_string(),&"IT".to_string(),&"ZEN".to_string()];

是一个是一个指向 &String 类型数组的切片

1
let book_slices: &[&String] = &[&"JVAV".to_string(),&"IT".to_string(),&"ZEN".to_string()];
  1. "JVAV".to_string()
    • 这一步创建了一个拥有所有权String 类型实例,存储在堆上。
  2. &"JVAV".to_string()
    • 这一步从上面创建的 String 实例中,获取了一个不可变引用。这个引用的类型是 &String
  3. [& ... , & ... , & ...]
    • 这是一个数组字面量,它在编译时被创建。这个数组的元素类型,就是它里面包含的那些引用的类型,即 &String。所以,这是一个 [&String; 3] 类型的数组。
  4. &[&String; 3]
    • 最外层的 & 运算符,是获取这个数组的引用
    • 这个引用(& 后面的部分)的类型就是 [&String; 3]
  5. let book_slices: &[&String]
    • 最后,book_slices 被明确地类型标注为 &[&String]。这是一个切片,它指向的底层数组的元素类型是 &String

关键点:没有发生 Deref Coercion

虽然 Rust 有 Deref Coercion(解引用强制转换) 的机制,允许 &String 在需要 &str 的地方自动转换。但是,这种转换发生在单个引用层面,而不会改变数组或切片中元素的实际类型

在这个特定的例子中:

  • 你明确地构建了一个包含 &String 引用的数组:[&some_string_ref_1, &some_string_ref_2]
  • 这个数组的类型就是 [&String; N]
  • 你从这个数组创建的切片,其类型也必须与其元素的实际类型匹配,即 &[&String]

如果 Rust 允许 &[&String] 自动强制转换为 &[&str],那将意味着它需要修改切片中每个元素的类型,这是 Deref Coercion 不会做的事情。Deref Coercion 是一个单个引用到单个引用的转换,而不是一个集合到另一个集合的转换。


例子:当你确实想要 &[&str]

如果你真的想拥有一个 &str 切片(即 &[&str]),你需要这样构建它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn main() {
// 方式一:直接使用字符串字面量(它们本身就是 &str)
let book_str_slices_1: &[&str] = &["JVAV", "IT", "ZEN"];
println!("Book Str Slices 1: {:?}", book_str_slices_1);

// 方式二:从 String 创建 &str,然后将 &str 放入数组
let s1 = "JVAV".to_string();
let s2 = "IT".to_string();
let s3 = "ZEN".to_string();

// 注意这里我们是将 &String 显式地转换(通过 Deref Coercion)为 &str 放入数组
// 但更常见的是直接从 String 创建 &str
let book_str_slices_2: &[&str] = &[&s1, &s2, &s3]; // &s1 (type &String) automatically derefs to &str here
println!("Book Str Slices 2: {:?}", book_str_slices_2);
}

book_str_slices_2 的例子中,当你把 &s1(类型是 &String)放入一个期望 &str 元素的数组时,Deref Coercion 就会发生,将每个 &String 自动转换为 &str


&String与&str

&[&String]&[&str] 都不能直接修改它们所指向的字符串内容,但它们之间存在重要的区别,尤其是在所有权生命周期底层内存布局方面。

核心区别概览

特性 / 类型 &[&String] (指向 &String 数组的切片) &[&str] (指向 &str 数组的切片)
切片元素类型 &String (指向 String 的不可变引用) &str (字符串切片,指向 str 的不可变引用)
被引用数据的 String 类型(拥有数据,在堆上) str 类型(通常在数据段或来自 String 的部分)
内存位置 引用指向堆上的 String 对象 引用可以直接指向字符串字面量(静态区)或 String 的堆数据
底层数据所有权 切片本身不拥有数据,但它引用的 String 拥有数据 切片本身不拥有数据,它引用的 str不拥有数据
生命周期管理 String 实例的生命周期必须比 &String 引用长 str 数据源的生命周期必须比 &str 引用长
创建成本 通常涉及 String::from(),堆分配成本 直接使用字面量,无运行时成本;从 String 借用也无额外成本
灵活性 间接引用堆数据,多了一层封装 直接引用字符串数据,更灵活,可接受多种来源

1. &[&String]:指向 String 引用的切片

当你有 let book_slices: &[&String] 时:

  • 内部元素是 &String:这意味着切片中的每个元素都是一个指向 String 类型数据的引用
  • String 拥有数据:这些 String 对象本身是独立的,它们各自在**堆(heap)**上分配了内存来存储字符串数据。
  • 多层引用:你可以将其想象为“一个数组的切片,这个数组里的每个元素都是一个指针,而这些指针又指向堆上的字符串对象”。
  • 生命周期:这个 &[&String] 切片的有效性,取决于它所引用的那个数组(它是一个临时数组字面量),以及数组里面所有 &String 引用所指向的原始 String 实例的生命周期。这些 String 实例必须在 &[&String] 切片被使用期间保持有效。
    • 在你的例子中 &"JVAV".to_string(),这些 String 实例是匿名的。它们在切片被创建的那一行被创建,它们的生命周期被 Rust 自动管理。由于它们在表达式中创建并立即被引用,Rust 编译器会确保它们存活足够长的时间,以供 book_slices 使用。
  • 修改:你不能通过 &[&String] 切片修改原始的 String 内容,也不能修改切片本身(例如增加或删除元素)。

2. &[&str]:指向字符串切片的切片

当你有 let book_str_slices: &[&str] 时:

  • 内部元素是 &str:这意味着切片中的每个元素都是一个字符串切片,直接指向 UTF-8 编码的字符串数据。
  • &str 不拥有数据:这些 &str 只是视图,它们不负责内存管理。它们可以指向:
    • 静态字符串字面量(存储在程序的二进制文件中,生命周期是 'static',贯穿整个程序)。
    • 堆上 String 的一部分或全部(如果 &str 是从 String 借用而来)。
  • 单层引用:这可以看作是“一个数组的切片,这个数组里的每个元素都是一个字符串指针(胖指针),直接指向字符串数据”。
  • 生命周期:这个 &[&str] 切片的有效性,取决于它所引用的数组,以及数组里面所有 &str 引用所指向的原始字符串数据源的生命周期。这些数据源必须在 &[&str] 切片被使用期间保持有效。
  • 修改:你不能通过 &[&str] 切片修改原始的字符串内容,也不能修改切片本身。

为什么这种区别很重要?

  1. 性能与内存开销
    • &[&String]:创建时需要先创建 String 对象(堆分配),再获取引用。这会带来额外的内存分配和解分配开销。
    • &[&str]:如果元素是字符串字面量,就没有堆分配开销。如果元素是从 String 借用的 &str,那么也没有额外的堆分配。通常更轻量级。
  2. 生命周期复杂性
    • &[&String]:你引用的 String 实例必须在切片的整个生命周期内都存在。如果这些 String 是匿名临时创建的,Rust 编译器会尽力延长它们的生命周期,但这并非总是能成功。
    • &[&str]:其生命周期取决于它所引用的实际 str 数据。&'static str 最简单,可以无限制地使用。从 String 借用的 &str 则受限于 String 的生命周期。
  3. 函数通用性
    • 通常情况下,函数参数更倾向于使用 &str 而不是 &String,因为 &str 更通用,可以接受各种来源的字符串数据。
    • &[&str] 这样的类型在处理一组通用字符串视图时更为常见。

总结

虽然 &[&String]&[&str] 都表示不可修改的字符串序列,但它们在底层数据类型、所有权链、内存分配和生命周期管理上存在根本区别。

  • &[&String] 是一个切片,它的元素是指向堆上 String 对象的引用
  • &[&str] 是一个切片,它的元素是直接指向 str 数据的引用(可以是静态区或堆上 String 的一部分)。

理解这些细微差别对于编写高效、安全且符合 Rust 习惯的代码至关重要。

但是&[&mut String]是可以对包含的字符串内容进行修改的,可以类比C语言中的字符数组数组。

最近在倒腾C语言实现类似于OOP的东西,在油管上看到了这样一种实现方法,昨天尝试了一下,现在记录一下

Object-Oriented Programming in regular C

最终的主函数长这样,实现了一个非常简陋的String类以及字符串拼接功能,当然,也几乎没有健壮性。这位博主只是简单提供了一种思路。

1
2
3
4
5
6
7
8
9
10
11
12
int main(int argc,char *argv[]){
String *s1;
String *s2;
s1 = mkstring("Hello ");
s2 = mkstring("World");
$(s1)->concat(s2);
printfstr(s1);

free(s1);
free(s2);
return 0;
}

这个设计的核心在于全局this指针以及宏定义(虽然全局的this指针不是很安全)。

1
2
3
4
5
6
7
8
typedef struct s_string String; 
typedef String* (*method)(String*);

typedef struct s_string{
method concat;
int8_t length;
char data[];
}String;

首先我们需要用结构体模拟一个String类出来,其中包含了concat方法、长度length以及一个char数组(之前在别处见到的另一种实现多态的方法好像用到了接口结构体跟聚合表,我暂时还没太弄明白,等我弄明白了或许会再写个博客出来)。

实际上这个 data[]也可以写成char *data;,本质上没什么区别

method实际上是一个函数指针,指向一个返回值为String*,参数为**String***的函数,我们需要自己实现这个函数。

接下来我们为这个类实现构造函数以及打印函数,下面是这两个函数的声明。

1
2
String *mkstring(char*);
void printfstr(const String*);

printfstr函数没什么好讲的,这里讲一下mkstring函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
String *mkstring(char*str){
int16_t len;
int16_t size;
String *p;

assert(str);
len = strlen(str);
assert(len);

size = len +sizeof(String) +1;
p = (String*)malloc(size);
assert(p);
memset(p,0,size);

memcpy(p->data,str,len);
p->length = len;
p->concat = concat_;
return p;
}

我们可以先忽略掉这些assert(断言),这个函数进行了以下操作:

  1. 根据输入参数计算了String对象中length参数的长度并赋值。

  2. 根据输入字符数组的长度申请了足够的内存空间,并且使用memcpy函数将字符数组的内容复制进String对象中。

  3. 将自己实现的concat_函数与类中的函数指针进行了绑定。

  4. 最后返回了一个指向初始化好的String对象的指针。

此处需要注意,C语言字符数组以’\0’作为结尾,在这个函数中,通过memset将整个结构体置0时就相当于将类中char数组最后一位置0了,所以不再需要显式的置0。

现在来看一下这个设计最核心的部分,全局this指针

1
2
typedef void thisptr;
thisptr* _this;

可以看到我们创建了一个全局this指针,它将始终指向我们正在操作的String对象。

接下来我们来实现这个concat_方法函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
String* concat_(String* input) {
String* current_this = _this;

char* temp_input_data = (char*)malloc(input->length + 1);
strcpy(temp_input_data, input->data);

int16_t original_current_this_length = current_this->length;
int16_t new_length = original_current_this_length + input->length;
size_t new_size = sizeof(String) + new_length + 1;

String* reallocated_string = (String*)realloc(current_this, new_size);

if (reallocated_string == NULL) {
perror("realloc 失败,无法原地扩展字符串");
free(temp_input_data);
return NULL;
}

current_this = reallocated_string;
_this = current_this;

current_this->length = new_length;

memcpy(current_this->data + original_current_this_length, temp_input_data, input->length);

current_this->data[new_length] = '\0';

free(temp_input_data);

return current_this;
}

我们先忽略掉错误处理部分,这个函数实现了以下的功能

  1. 保存输入字符串数据 函数首先将 input 字符串的数据复制到一个临时缓冲区 temp_input_data 中。这是为了防止在 realloc 失败时,input->data 中的数据丢失,或者如果在 reallocinput->data 指向的内存被释放或移动而导致后续操作出错。

  2. 计算新字符串长度和所需内存大小 它计算了连接后的新字符串的总长度 new_length(原字符串长度 + 输入字符串长度),并根据这个新长度计算了 String 结构体加上字符串数据所需的总内存大小 new_size

  3. 重新分配内存 函数尝试使用 realloc 来扩展当前字符串 _this 所占用的内存。realloc 会尝试在原地扩展内存,如果原地扩展失败,它会分配一块新的内存区域并将原有数据复制过去,然后释放旧的内存区域。

  4. 处理内存重新分配失败 如果 realloc 返回 NULL,表示内存重新分配失败。此时,函数会打印错误信息,释放之前分配的临时缓冲区,并返回 NULL

  5. 更新当前字符串指针和长度 如果内存重新分配成功,current_this(以及全局或成员变量 _this)会更新为 reallocated_string 返回的新地址。然后,current_thislength 字段会被更新为 new_length

  6. 拷贝输入字符串数据 使用 memcpytemp_input_data(即 input 字符串的数据)拷贝到 current_this->data 的末尾,从 original_current_this_length 的位置开始。

  7. 添加字符串结束符 在新字符串的末尾(new_length 的位置)添加空字符 \0,以确保它是一个合法的 C 字符串。

  8. 释放临时缓冲区并返回 最后,释放之前为 temp_input_data 分配的内存,并返回更新后的 current_this 指针。

这个函数是我修改过的,博主原代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
String *concat_(String *input){
    int16_t len;
    int16_t size;
    char *p;
    String *this;
    this = (String*)_this;
    len = this->length + input->length;
    size = len+sizeof(struct s_string)+1;
    p = this->data +this->length;
    this = (String*)realloc(this,size);
    assert(this);
    memcpy(p,input->data,input->length);
    p = this->data +len;
    *p = '0';
    return this;

}

可能是因为编译环境不同,这种写法在我的编译环境下会导致严重的内存问题。

现在如果我们想要在主函数中实现字符串拼接,需要以下步骤:

  1. 初始化s1,s2。
  2. this指针指向s1。
  3. 调用s1的concat方法,将s2传入。

体现在代码上如下

1
2
_this = s1;
s1 = s1->concat(s2);

这里会出现s1 = s1->concat(*)的写法,是因为在concat函数中进行realloc操作时,会改变s1指针指向的内存,不管是原地扩容还是在新内存空间扩容,在扩容完成后将地址返回给s1就可以保证不出现悬空指针了。

接下来我们可以实现一个操作宏来简化我们的操作

1
#define $(x) _this = (x);(x) = (x)

这个宏让我们可以以$(s1)->concat(s2);的形式直接调用对象中的方法,展开后本质上跟上面的写法是一样的。

注意:这种写法实际上是不安全的,我只是将博主的实现方法照抄下来并且进行记录,暂时还没想到怎么才能优化这种写法

但是有一点显而易见的就是这个全局的this指针是不安全的。