以前一直是写C的,对于现代语言的错误处理一直是搞不太懂,不过目前看来Rust的错误处理看起来还是比Java顺眼一点的

panic与不可恢复的错误

panic就类似于程序崩溃,程序会直接崩溃掉,这种错误是无法被程序处理的

有的时候代码出问题了,而你对此束手无策。对于这种情况,Rust 有 panic!宏。当执行这个宏时,程序会打印出一个错误信息,展开并清理栈数据,然后接着退出。出现这种情况的场景通常是检测到一些类型的 bug,而且开发者并不清楚该如何处理它。

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

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

让我们在一个简单的程序中调用 panic!

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

运行程序将会出现类似这样的输出:

1
2
3
4
5
6
$ cargo run
Compiling panic v0.1.0 (file:///projects/panic)
Finished dev [unoptimized + debuginfo] target(s) in 0.25s
Running `target/debug/panic`
thread 'main' panicked at 'crash and burn', src/main.rs:2:5
note: Run with `RUST_BACKTRACE=1` for a backtrace.

最后两行包含 panic! 调用造成的错误信息。第一行显示了 panic 提供的信息并指明了源码中 panic 出现的位置:src/main.rs:2:5 表明这是 src/main.rs 文件的第二行第五个字符。

在这个例子中,被指明的那一行是我们代码的一部分,而且查看这一行的话就会发现 panic! 宏的调用。在其他情况下,panic! 可能会出现在我们的代码所调用的代码中。错误信息报告的文件名和行号可能指向别人代码中的 panic! 宏调用,而不是我们代码中最终导致 panic! 的那一行。我们可以使用 panic! 被调用的函数的 backtrace 来寻找代码中出问题的地方。下面我们会详细介绍 backtrace 是什么。

backtrace宏的作用就是让我们可以找到“我们编写的代码中导致程序崩溃的地方”

1
2
3
4
5
fn main() {
let v = vec![1, 2, 3];

v[99];
}

这是一个越界调用,在C语言中,这段代码会运行,然后给你一个错误的值,这是相当危险的

为了使程序远离这类漏洞,如果尝试读取一个索引不存在的元素,Rust 会停止执行并拒绝继续。尝试运行上面的程序会出现如下:

1
2
3
4
5
6
$ cargo run
Compiling panic v0.1.0 (file:///projects/panic)
Finished dev [unoptimized + debuginfo] target(s) in 0.27s
Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', libcore/slice/mod.rs:2448:10
note: Run with `RUST_BACKTRACE=1` for a backtrace.

这指向了一个不是我们编写的文件,libcore/slice/mod.rs。其为 Rust 源码中 slice 的实现。这是当对 vector v 使用 []libcore/slice/mod.rs 中会执行的代码,也是真正出现 panic! 的地方。

接下来的几行提醒我们可以设置 RUST_BACKTRACE 环境变量来得到一个 backtrace。backtrace 是一个执行到目前位置所有被调用的函数的列表。Rust 的 backtrace 跟其他语言中的一样:阅读 backtrace 的关键是从头开始读直到发现你编写的文件。这就是问题的发源地。这一行往上是你的代码所调用的代码;往下则是调用你的代码的代码。这些行可能包含核心 Rust 代码,标准库代码或用到的 crate 代码。让我们将 RUST_BACKTRACE 环境变量设置为任何不是 0 的值来获取 backtrace 看看。示例 9-2 展示了与你看到类似的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
$ RUST_BACKTRACE=1 cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', libcore/slice/mod.rs:2448:10
stack backtrace:
0: std::sys::unix::backtrace::tracing::imp::unwind_backtrace
at libstd/sys/unix/backtrace/tracing/gcc_s.rs:49
1: std::sys_common::backtrace::print
at libstd/sys_common/backtrace.rs:71
at libstd/sys_common/backtrace.rs:59
2: std::panicking::default_hook::{{closure}}
at libstd/panicking.rs:211
3: std::panicking::default_hook
at libstd/panicking.rs:227
4: <std::panicking::begin_panic::PanicPayload<A> as core::panic::BoxMeUp>::get
at libstd/panicking.rs:476
5: std::panicking::continue_panic_fmt
at libstd/panicking.rs:390
6: std::panicking::try::do_call
at libstd/panicking.rs:325
7: core::ptr::drop_in_place
at libcore/panicking.rs:77
8: core::ptr::drop_in_place
at libcore/panicking.rs:59
9: <usize as core::slice::SliceIndex<[T]>>::index
at libcore/slice/mod.rs:2448
10: core::slice::<impl core::ops::index::Index<I> for [T]>::index
at libcore/slice/mod.rs:2316
11: <alloc::vec::Vec<T> as core::ops::index::Index<I>>::index
at liballoc/vec.rs:1653
12: panic::main
at src/main.rs:4
13: std::rt::lang_start::{{closure}}
at libstd/rt.rs:74
14: std::panicking::try::do_call
at libstd/rt.rs:59
at libstd/panicking.rs:310
15: macho_symbol_search
at libpanic_unwind/lib.rs:102
16: std::alloc::default_alloc_error_hook
at libstd/panicking.rs:289
at libstd/panic.rs:392
at libstd/rt.rs:58
17: std::rt::lang_start
at libstd/rt.rs:74
18: panic::main

这种信息一般是从下往上读,直到找到我们自己编写的代码位置

RUST_BACKTRACE=1 cargo run

我们可以通过终端传递这个环境变量的值来进入BACKTRACE。


现在让我们回到C语言

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <stdio.h>  // 用于 printf
#include <limits.h> // 用于 INT_MIN

/**
* @brief 执行两个整数的除法。
*
* @param numerator 被除数。
* @param denominator 除数。
* @return int 如果除数不为零,返回计算出的商。
* 如果除数为零,返回 INT_MIN 表示错误。
*/
int safe_divide(int numerator, int denominator) {
if (denominator == 0) {
fprintf(stderr, "Error: Division by zero is not allowed.\n");
return INT_MIN; // 传播错误:返回一个特殊值表示失败
}
return numerator / denominator; // 成功:返回计算结果
}

int main() {
int result;

printf("--- 正常除法示例 ---\n");
result = safe_divide(10, 2);
if (result != INT_MIN) { // 检查返回值是否是错误码
printf("10 / 2 = %d\n", result); // 成功处理
} else {
printf("Error occurred during division.\n"); // 错误处理
}

printf("\n--- 除数为零示例 ---\n");
result = safe_divide(10, 0);
if (result != INT_MIN) { // 检查返回值是否是错误码
printf("10 / 0 = %d\n", result); // 理论上不会执行到这里
} else {
printf("Error occurred during division. Cannot divide by zero.\n"); // 错误处理
}

printf("\n--- 另一个正常除法示例 ---\n");
result = safe_divide(-15, 3);
if (result != INT_MIN) {
printf("-15 / 3 = %d\n", result);
} else {
printf("Error occurred during division.\n");
}

return 0;
}

当我们写一个除法程序,当我们传”0”为除数,程序很明显不应该崩溃,而是该返回一个标记值来提醒调用者出错了,然后调用者再进行处理,这就是可恢复的错误以及错误的传播

C语言中错误的传播相对原始,一般都是通过返回值层层传递,一旦有一层忘记处理这种情况,整个程序的稳定性就会受到极大的影响。

在讨论Rust中错误的传播前,我们先来讨论Rust对于这种可恢复错误的处理方式。


在 Rust 中,Option<T>Result<T, E> 是两个非常核心的枚举(enum),它们是 Rust 强大的错误处理存在性(presence)管理机制的基石。它们的设计理念是强制你在编译时处理可能缺失的值或可能发生的错误,从而避免了其他语言中常见的空指针异常和未处理的运行时错误。

Option<T>:处理值可能缺失的情况

Option<T> 枚举用来表示一个值可能存在,也可能不存在的情况。它的定义如下:

1
2
3
4
enum Option<T> {
None, // 值不存在
Some(T), // 值存在,并包含类型 T 的数据
}

它解决什么问题?

在许多其他语言(如 C++、Java、Python 等)中,你可能会使用 NULLnullNone 来表示一个变量没有值。然而,直接使用这些“空”值往往会导致运行时错误,比如著名的空指针异常(Null Pointer Exception)。因为你可能会忘记检查一个值是否为 null,然后尝试对其进行操作。

Option<T> 强制你在编译时就处理值存在或不存在的两种情况。如果你尝试直接使用一个 Option<T> 中的值而不先确定它是否是 Some(T),编译器会报错。

Result<T, E>:处理可能发生的错误

Result<T, E> 枚举用来表示一个操作可能成功并返回一个值,也可能失败并返回一个错误。它的定义如下:

1
2
3
4
enum Result<T, E> {
Ok(T), // 操作成功,并包含类型 T 的结果数据
Err(E), // 操作失败,并包含类型 E 的错误数据
}

它解决什么问题?

在 C 语言中,你通常通过返回值和错误码来表示函数成功或失败。在 Java/Python 等语言中,则通常使用异常(exceptions)。然而,这些方式都有其弊端:

  • C 语言的错误码:容易被忽略,需要手动检查,且错误信息有限。
  • 异常:虽然方便,但可能会导致控制流难以预测(“goto 式的错误处理”),且编译器通常不会强制你捕获或声明异常,可能导致未处理的运行时崩溃。

Result<T, E> 强制你在编译时就考虑并处理成功和失败的两种情况,使得错误处理成为你代码类型系统的一部分。

如何使用?

Option 类似,你通常会使用 match 表达式、if letResult 提供的各种方法(如 is_ok(), is_err(), unwrap(), expect(), map_err(), and_then(), ? 运算符等)来处理 Result 值。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::fs::File; // 引入文件系统模块

fn main() {
// 尝试打开一个不存在的文件
// File::open 返回一个 Result<File, std::io::Error>
let greeting_file = File::open("hello.txt");
let greeting_file = match greeting_file {
Ok(file) => file,
Err(error) => {
panic!("Probled opening the file {:?}",error)
},
};

// 如果上面一行没有 panic,说明文件成功打开了
println!("文件 'hello.txt' 已成功打开!");

// 注意:如果 hello.txt 不存在,上面的 println! 永远不会执行
}

这是Rust中最简单的错误处理,通过match匹配Result<>成员来实现对于错误的处理,但是在上面那段程序中,当打开文件失败时,程序直接崩溃掉了,这很明显不是我们想要的结果,接下来我们引入下一个知识点 错误的匹配

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

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

let f = match f {
Ok(file) => file,
Err(error) => match error.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("Problem creating the file: {:?}", e),
},
other_error => panic!("Problem opening the file: {:?}", other_error),
},
};
}

File::open 返回的 Err 成员中的值类型 io::Error,它是一个标准库中提供的结构体。这个结构体有一个返回 io::ErrorKind 值的 kind 方法可供调用。io::ErrorKind 是一个标准库提供的枚举,它的成员对应 io 操作可能导致的不同错误类型。我们感兴趣的成员是 ErrorKind::NotFound,它代表尝试打开的文件并不存在。这样,match 就匹配完 f 了,不过对于 error.kind() 还有一个内层 match

我们希望在内层 match 中检查的条件是 error.kind() 的返回值是否为 ErrorKindNotFound 成员。如果是,则尝试通过 File::create 创建文件。然而因为 File::create 也可能会失败,还需要增加一个内层 match 语句。当文件不能被打开,会打印出一个不同的错误信息。外层 match 的最后一个分支保持不变,这样对任何除了文件不存在的错误会使程序 panic。

match确实是强大的,但是有时候我们确实是希望当出现错误时直接panic!掉,此时再写match会有点麻烦,Rust为我们提供了两个简写方法。

unwrapexpect

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

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

如果调用这段代码时不存在 hello.txt 文件,我们将会看到一个 unwrap 调用 panic! 时提供的错误信息:

1
2
3
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Error {
repr: Os { code: 2, message: "No such file or directory" } }',
src/libcore/result.rs:906:4

还有另一个类似于 unwrap 的方法它还允许我们选择 panic! 的错误信息:expect。使用 expect 而不是 unwrap 并提供一个好的错误信息可以表明你的意图并更易于追踪 panic 的根源。expect 的语法看起来像这样:

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

fn main() {
let f = File::open("hello.txt").expect("Failed to open hello.txt");
}

expectunwrap 的使用方式一样:返回文件句柄或调用 panic! 宏。expect 在调用 panic! 时使用的错误信息将是我们传递给 expect 的参数,而不像 unwrap 那样使用默认的 panic! 信息。它看起来像这样:

1
2
thread 'main' panicked at 'Failed to open hello.txt: Error { repr: Os { code:
2, message: "No such file or directory" } }', src/libcore/result.rs:906:4

因为这个错误信息以我们指定的文本开始,Failed to open hello.txt,将会更容易找到代码中的错误信息来自何处。如果在多处使用 unwrap,则需要花更多的时间来分析到底是哪一个 unwrap 造成了 panic,因为所有的 unwrap 调用都打印相同的信息。

接下来我们就可以进入到下一个知识点 错误的传播,C语言一般都是通过返回值的层层传递来实现错误的传播。传播错误的好处就是这样能更好地控制代码调用,因为比起你代码所拥有的上下文,调用者可能拥有更多信息或逻辑来决定应该如何处理错误。

简单点说错误的传播就是把可能发生的错误返回给调用者,让调用者来处理而不是由被调用的函数来处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#![allow(unused)]
fn main() {
use std::io;
use std::io::Read;
use std::fs::File;

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

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

let mut s = String::new();

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

首先让我们看看函数的返回值:Result<String, io::Error>。这意味着函数返回一个 Result<T, E> 类型的值,其中泛型参数 T 的具体类型是 String,而 E 的具体类型是 io::Error。如果这个函数没有出任何错误成功返回,函数的调用者会收到一个包含 StringOk 值 —— 函数从文件中读取到的用户名。如果函数遇到任何错误,函数的调用者会收到一个 Err 值,它储存了一个包含更多这个问题相关信息的 io::Error 实例。这里选择 io::Error 作为函数的返回值是因为它正好是函数体中那两个可能会失败的操作的错误返回值:File::open 函数和 read_to_string 方法。

函数体以 File::open 函数开头。接着使用 match 处理返回值 Result,类似于示例 9-4 中的 match,唯一的区别是当 Err 时不再调用 panic!,而是提早返回并将 File::open 返回的错误值作为函数的错误返回值传递给调用者。如果 File::open 成功了,我们将文件句柄储存在变量 f 中并继续。

接着我们在变量 s 中创建了一个新 String 并调用文件句柄 fread_to_string 方法来将文件的内容读取到 s 中。read_to_string 方法也返回一个 Result 因为它也可能会失败:哪怕是 File::open 已经成功了。所以我们需要另一个 match 来处理这个 Result:如果 read_to_string 成功了,那么这个函数就成功了,并返回文件中的用户名,它现在位于被封装进 Oks 中。如果 read_to_string 失败了,则像之前处理 File::open 的返回值的 match 那样返回错误值。不过并不需要显式的调用 return,因为这是函数的最后一个表达式。

调用这个函数的代码最终会得到一个包含用户名的 Ok 值,或者一个包含 io::ErrorErr 值。我们无从得知调用者会如何处理这些值。例如,如果他们得到了一个 Err 值,他们可能会选择 panic! 并使程序崩溃、使用一个默认的用户名或者从文件之外的地方寻找用户名。我们没有足够的信息知晓调用者具体会如何尝试,所以将所有的成功或失败信息向上传播,让他们选择合适的处理方法。

这种写法是相当常见的,Rust也为我们提供了这种情况下可供使用的简写

传播错误的简写:? 运算符

示例 9-7 展示了一个 read_username_from_file 的实现,它实现了与示例 9-6 中的代码相同的功能,不过这个实现使用了 ? 运算符:

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

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

示例 9-7:一个使用 ? 运算符向调用者返回错误的函数

Result 值之后的 ? 被定义为与示例 9-6 中定义的处理 Result 值的 match 表达式有着完全相同的工作方式。如果 Result 的值是 Ok,这个表达式将会返回 Ok 中的值而程序将继续执行。如果值是 ErrErr 将作为整个函数的返回值,就好像使用了 return 关键字一样,这样错误值就被传播给了调用者。

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

在示例 9-7 的上下文中,File::open 调用结尾的 ? 将会把 Ok 中的值返回给变量 f。如果出现了错误,? 运算符会提早返回整个函数并将一些 Err 值传播给调用者。同理也适用于 read_to_string 调用结尾的 ?

当你在一个不返回 Result 的函数中需要调用返回 Result 的函数时,文本提供了两种主要的修复方法:

  1. 修改当前函数的返回值类型为 Result<T, E>: 这是最常见和推荐的方法,特别是当你的函数确实需要传播错误时。你将函数的签名从默认的 () 修改为 Result<T, E>,使得它能够兼容 ? 运算符传播的错误。

    示例:

    Rust

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    use std::error::Error; // 引入 Error trait
    use std::fs::File;

    // 将 main 函数的返回值类型修改为 Result<(), Box<dyn Error>>
    fn main() -> Result<(), Box<dyn Error>> {
    let f = File::open("hello.txt")?; // 现在 ? 运算符可以正常工作了

    // ... 其他操作 ...

    Ok(()) // 如果所有操作成功,返回 Ok(())
    }
    • -> Result<(), Box<dyn Error>>:这里 main 函数被声明为返回一个 Result
      • Ok(()) 表示成功,没有具体返回值。
      • Err(Box<dyn Error>) 表示失败,并包含一个错误对象
    • Box<dyn Error>:这被称为 “trait 对象”。它的作用是允许你返回任何实现了 std::error::Error 这个 trait 的错误类型。这是 Rust 处理“多种可能错误类型”的一种通用方法。在这里,你可以简单地理解为 main 函数现在可以返回任何类型的错误,只要这个错误实现了 Error trait。
  2. 在当前函数内使用 matchResult 的其他方法处理错误: 如果你不希望函数传播错误,或者函数不能修改返回值类型(例如,一些回调函数),那么你就必须在当前函数内部显式地处理 Result,而不是使用 ? 运算符。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    use std::fs::File;

    fn main() { // main 函数的返回值仍然是 ()
    // 使用 match 显式处理 File::open 返回的 Result
    let f = match File::open("hello.txt") {
    Ok(file) => file,
    Err(e) => {
    eprintln!("Error opening file: {}", e); // 打印错误到标准错误输出
    return; // 如果发生错误,直接从 main 函数返回,程序终止
    }
    };

    println!("File opened successfully!");
    // ... 继续使用 f ...
    }

    在这个例子中,我们使用 match 语句来检查 File::open 的结果。如果它返回 Err,我们就打印错误信息并使用 return; 提前退出 main 函数。这样就没有错误需要被“传播”出 main 函数了。

RUST标准库中给我们提供了很多集合,这里介绍三种最常见的:

  1. vector
  2. String
  3. hash map

可能是类似于CPP中的STL,标准库提供了一些别的数据类型,方便我们使用.

Vector

第一个类型是 Vec<T>,也被称为 vector。vector 允许我们在一个单独的数据结构中储存多个值,所有值在内存中彼此相邻排列。vector 只能储存相同类型的值。它们在拥有一系列项的场景下非常实用,例如文件中的文本行或购物车中商品的价格。

Vector的初始化

  1. ler v:Vec[i32] = Vec::new(); 新建一个空的Vec来存储i32类型的值.
  2. let v = vec![1,2,3]; 利用vec!宏来使用初始值来创建一个Vec.此时Rust可以自动推断出v的类型.

Vector的更新

1
2
3
4
5
6
7
8
9
#![allow(unused)]
fn main() {
let mut v = Vec::new();
v.push(5);
v.push(6);
v.push(7);
v.push(8);
}

很明显,v.push是将值推入Vec中的方法

我们需要注意的一点是:与其他结构类似,当vector离开他的作用域时会被销毁,并且当存在一个有效的不可变引用时,是没办法通过push向vec中新增值的.

1
2
3
4
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0];
v.push(6);
println!("The first element is: {}", first);

Vector元素的读取

  1. 通过索引来读取值
  2. 通过.get来读取值
1
2
3
4
5
6
7
8
9
10
11
12
13
14

#![allow(unused)]
fn main() {
let v = vec![1, 2, 3, 4, 5];

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

match v.get(2) {
Some(third) => println!("The third element is {}", third),
None => println!("There is no third element."),
}
}

这里有两个需要注意的地方。首先,我们使用索引值 2 来获取第三个元素,索引是从 0 开始的。其次,这两个不同的获取第三个元素的方式分别为:使用 &[] 返回一个引用;或者使用 get 方法以索引作为参数来返回一个 Option<&T>

使用.get方法可能是更优的,因为当索引越界时,.get方法会返回None,然后你就可以处理而不至于使程序崩溃

遍历Vector中的元素

如果想要依次访问 vector 中的每一个元素,我们可以遍历其所有的元素而无需通过索引一次一个的访问。

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

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

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

为了修改可变引用所指向的值,在使用 += 运算符之前必须使用解引用运算符(*)获取 i 中的值。

使用枚举来储存多种类型

在本章的开始,我们提到 vector 只能储存相同类型的值。这是很不方便的;绝对会有需要储存一系列不同类型的值的用例。幸运的是,枚举的成员都被定义为相同的枚举类型,所以当需要在 vector 中储存不同类型值时,我们可以定义并使用一个枚举!

例如,假如我们想要从电子表格的一行中获取值,而这一行的有些列包含数字,有些包含浮点值,还有些是字符串。我们可以定义一个枚举,其成员会存放这些不同类型的值,同时所有这些枚举成员都会被当作相同类型,那个枚举的类型。接着可以创建一个储存枚举值的 vector,这样最终就能够储存不同类型的值了。

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

enum SpreadsheetCell {
Int(i32),
Float(f64),
Text(String),
}

let row = vec![
SpreadsheetCell::Int(3),
SpreadsheetCell::Text(String::from("blue")),
SpreadsheetCell::Float(10.12),
];

Rust 在编译时就必须准确的知道 vector 中类型的原因在于它需要知道储存每个元素到底需要多少内存。第二个好处是可以准确的知道这个 vector 中允许什么类型。如果 Rust 允许 vector 存放任意类型,那么当对 vector 元素执行操作时一个或多个类型的值就有可能会造成错误。使用枚举外加 match 意味着 Rust 能在编译时就保证总是会处理所有可能的情况,正如第 6 章讲到的那样。

如果在编写程序时不能确切无遗地知道运行时会储存进 vector 的所有类型,枚举技术就行不通了。相反,你可以使用 trait 对象,第 17 章会讲到它。

现在我们了解了一些使用 vector 的最常见的方式,请一定去看看标准库中 Vec 定义的很多其他实用方法的 API 文档。例如,除了 push 之外还有一个 pop 方法,它会移除并返回 vector 的最后一个元素。

String类型

在 Rust 中,字符串并非单一类型,而是通常指两种主要的 UTF-8 编码类型:str(字符串 slice)String

具体来说,它涵盖了以下几个要点:

  • str (字符串 slice)
    • 这是 Rust 核心语言中唯一的字符串类型。
    • 它通常以 被借用的形式 &str 出现。
    • &str 是对储存在别处(例如程序二进制输出中的字符串字面量)的 UTF-8 编码字符串数据的引用。它们是不可变且固定大小的。
  • String
    • 这是由 Rust 标准库提供的类型,不属于核心语言。
    • 它是一种可增长的、可变的、有所有权的 UTF-8 编码字符串类型。
    • String 类型的数据存储在堆上,可以根据需要进行扩展和修改。
  • “字符串”的通用指代
    • 当 Rust 开发者(Rustacean)提到“字符串”时,通常指的是 String&str 这两种类型,而不是特指其中一个。
    • 这两种类型在 Rust 标准库中都非常常用,且都使用 UTF-8 编码
  • 其他字符串类型
    • 除了 String&str,Rust 标准库还提供了其他字符串类型,如 OsStringOsStrCStringCStr
    • 这些类型通常以 StringStr 结尾,分别对应其所有权(String 变体)和可借用(Str 变体)的特性。
    • 它们可能用于存储不同编码或内存表示形式的文本内容(例如与操作系统或 C 语言交互时)。

官方文档下头这段写得还是很像人话的,我们之间复制过来,需要注意的是,rust中的字符串默认是UTF-8而不是ASCII,所以会出现一些在C语言中不会出现的问题,C语言中与之对应的知识是wchar,即宽字符.

新建字符串

很多 Vec 可用的操作在 String 中同样可用,从 new 函数创建字符串开始,如示例 8-11 所示。

1
let mut s = String::new();

示例 8-11:新建一个空的 String

这新建了一个叫做 s 的空的字符串,接着我们可以向其中装载数据。通常字符串会有初始数据,因为我们希望一开始就有这个字符串。为此,可以使用 to_string 方法,它能用于任何实现了 Display trait 的类型,字符串字面量也实现了它。示例 8-12 展示了两个例子。

1
2
3
4
5
6
let data = "initial contents";

let s = data.to_string();

// 该方法也可直接用于字符串字面量:
let s = "initial contents".to_string();

示例 8-12:使用 to_string 方法从字符串字面量创建 String

这些代码会创建包含 initial contents 的字符串。

也可以使用 String::from 函数来从字符串字面量创建 String。示例 8-13 中的代码等同于使用 to_string

1
let s = String::from("initial contents");

示例 8-13:使用 String::from 函数从字符串字面量创建 String

因为字符串应用广泛,这里有很多不同的用于字符串的通用 API 可供选择。其中一些可能看起来多余,不过都有其用武之地!在这个例子中,String::fromto_string 最终做到了完全相同的事情,所以如何选择,就是风格问题了。

请记住,字符串是 UTF-8 编码的,所以可以包含任何正确编码的数据,如示例 8-14 所示。

1
2
3
4
5
6
7
8
9
10
11
let hello = String::from("السلام عليكم");
let hello = String::from("Dobrý den");
let hello = String::from("Hello");
let hello = String::from("שָׁלוֹם");
let hello = String::from("नमस्ते");
let hello = String::from("こんにちは");
let hello = String::from("안녕하세요");
let hello = String::from("你好");
let hello = String::from("Olá");
let hello = String::from("Здравствуйте");
let hello = String::from("Hola");

示例 8-14:在字符串中储存不同语言的问候语

所有这些都是有效的 String 值。

更新字符串

String 的大小可以增加,其内容也可以改变,就像可以放入更多数据来改变 Vec 的内容一样。另外,可以方便的使用 + 运算符或 format! 宏来拼接 String 值。

使用 push_strpush 附加字符串

可以通过 push_str 方法来附加字符串 slice,从而使 String 变长,如示例 8-15 所示。

1
2
let mut s = String::from("foo");
s.push_str("bar");

示例 8-15:使用 push_str 方法向 String 附加字符串 slice

执行这两行代码之后,s 将会包含 foobarpush_str 方法采用字符串 slice,因为我们并不需要获取参数的所有权。例如,示例 8-16 展示了如果将 s2 的内容附加到 s1 之后,自身不能被使用就糟糕了。

1
2
3
4
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 is {}", s2);

示例 8-16:将字符串 slice 的内容附加到 String 后使用它

如果 push_str 方法获取了 s2 的所有权,就不能在最后一行打印出其值了。好在代码如我们期望那样工作!

push 方法被定义为获取一个单独的字符作为参数,并附加到 String 中。示例 8-17 展示了使用 push 方法将字母 l 加入 String 的代码。

1
2
let mut s = String::from("lo");
s.push('l');

示例 8-17:使用 push 将一个字符加入 String 值中

执行这些代码之后,s 将会包含 lol

使用 + 运算符或 format! 宏拼接字符串

通常你会希望将两个已知的字符串合并在一起。一种办法是像这样使用 + 运算符,如示例 8-18 所示。

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

示例 8-18:使用 + 运算符将两个 String 值合并到一个新的 String 值中

执行完这些代码之后,字符串 s3 将会包含 Hello, world!s1 在相加后不再有效的原因,和使用 s2 的引用的原因,与使用 + 运算符时调用的函数签名有关。+ 运算符使用了 add 函数,这个函数签名看起来像这样:

1
fn add(self, s: &str) -> String {

这并不是标准库中实际的签名;标准库中的 add 使用泛型定义。这里我们看到的 add 的签名使用具体类型代替了泛型,这也正是当使用 String 值调用这个方法会发生的。第 10 章会讨论泛型。这个签名提供了理解 + 运算那微妙部分的线索。

首先,s2 使用了 &,意味着我们使用第二个字符串的 引用 与第一个字符串相加。这是因为 add 函数的 s 参数:只能将 &strString 相加,不能将两个 String 值相加。不过等一下 —— 正如 add 的第二个参数所指定的,&s2 的类型是 &String 而不是 &str。那么为什么示例 8-18 还能编译呢?

之所以能够在 add 调用中使用 &s2 是因为 &String 可以被 强转coerced)成 &str。当 add 函数被调用时,Rust 使用了一个被称为 解引用强制转换deref coercion)的技术,你可以将其理解为它把 &s2 变成了 &s2[..]。第 15 章会更深入的讨论解引用强制转换。因为 add 没有获取参数的所有权,所以 s2 在这个操作后仍然是有效的 String

其次,可以发现签名中 add 获取了 self 的所有权,因为 self 没有 使用 &。这意味着示例 8-18 中的 s1 的所有权将被移动到 add 调用中,之后就不再有效。虽然 let s3 = s1 + &s2; 看起来就像它会复制两个字符串并创建一个新的字符串,而实际上这个语句会获取 s1 的所有权,附加上从 s2 中拷贝的内容,并返回结果的所有权。换句话说,它看起来好像生成了很多拷贝,不过实际上并没有:这个实现比拷贝要更高效。

如果想要级联多个字符串,+ 的行为就显得笨重了:

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

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

这时 s 的内容会是 tic-tac-toe。在有这么多 +" 字符的情况下,很难理解具体发生了什么。对于更为复杂的字符串连接,可以使用 format! 宏:

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

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

这些代码也会将 s 设置为 “tic-tac-toe”。format!println! 的工作原理相同,不过不同于将输出打印到屏幕上,它返回一个带有结果内容的 String。这个版本就好理解的多,并且不会获取任何参数的所有权。

索引字符串

在很多语言中,通过索引来引用字符串中的单独字符是有效且常见的操作。然而在 Rust 中,如果你尝试使用索引语法访问 String 的一部分,会出现一个错误。考虑一下如示例 8-19 中所示的无效代码。

1
2
let s1 = String::from("hello");
let h = s1[0];

示例 8-19:尝试对字符串使用索引语法

这段代码会导致如下错误:

1
2
3
4
5
6
7
error[E0277]: the trait bound `std::string::String: std::ops::Index<{integer}>` is not satisfied
-->
|
3 | let h = s1[0];
| ^^^^^ the type `std::string::String` cannot be indexed by `{integer}`
|
= help: the trait `std::ops::Index<{integer}>` is not implemented for `std::string::String`

错误和提示说明了全部问题:Rust 的字符串不支持索引。那么接下来的问题是,为什么不支持呢?为了回答这个问题,我们必须先聊一聊 Rust 是如何在内存中储存字符串的。

内部表现

String 是一个 Vec<u8> 的封装。让我们看看示例 8-14 中一些正确编码的字符串的例子。首先是这一个:

1
let len = String::from("Hola").len();

在这里,len 的值是 4 ,这意味着储存字符串 “Hola” 的 Vec 的长度是 4 个字节:这里每一个字母的 UTF-8 编码都占用 1 个字节。那下面这个例子又如何呢?(注意这个字符串中的首字母是西里尔字母的 Ze,而不是阿拉伯数字 3 。)

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

当问及这个字符是多长的时候有人可能会说是 12。然而,Rust 的回答是 24。这是使用 UTF-8 编码 “Здравствуйте” 所需要的字节数,这是因为每个 Unicode 标量值需要 2 个字节存储。因此一个字符串字节值的索引并不总是对应一个有效的 Unicode 标量值。作为演示,考虑如下无效的 Rust 代码:

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

answer 的值应该是什么呢?它应该是第一个字符 З 吗?当使用 UTF-8 编码时,З 的第一个字节 208,第二个是 151,所以 answer 实际上应该是 208,不过 208 自身并不是一个有效的字母。返回 208 可不是一个请求字符串第一个字母的人所希望看到的,不过它是 Rust 在字节索引 0 位置所能提供的唯一数据。用户通常不会想要一个字节值被返回,即便这个字符串只有拉丁字母: 即便 &"hello"[0] 是返回字节值的有效代码,它也应当返回 104 而不是 h。为了避免返回意外的值并造成不能立刻发现的 bug,Rust 根本不会编译这些代码,并在开发过程中及早杜绝了误会的发生。

字节、标量值和字形簇!天呐!

这引起了关于 UTF-8 的另外一个问题:从 Rust 的角度来讲,事实上有三种相关方式可以理解字符串:字节、标量值和字形簇(最接近人们眼中 字母 的概念)。

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

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

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

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

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

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

Rust 提供了多种不同的方式来解释计算机储存的原始字符串数据,这样程序就可以选择它需要的表现方式,而无所谓是何种人类语言。

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

字符串 slice

索引字符串通常是一个坏点子,因为字符串索引应该返回的类型是不明确的:字节值、字符、字形簇或者字符串 slice。因此,如果你真的希望使用索引创建字符串 slice 时,Rust 会要求你更明确一些。为了更明确索引并表明你需要一个字符串 slice,相比使用 [] 和单个值的索引,可以使用 [] 和一个 range 来创建含特定字节的字符串 slice:

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

这里,s 会是一个 &str,它包含字符串的头 4 个字节。早些时候,我们提到了这些字母都是 2 个字节长的,所以这意味着 s 将会是 “Зд”。

如果获取 &hello[0..1] 会发生什么呢?答案是:Rust 在运行时会 panic,就跟访问 vector 中的无效索引时一样:

1
thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/libcore/str/mod.rs:2188:4

你应该小心谨慎的使用这个操作,因为这么做可能会使你的程序崩溃。

遍历字符串的方法

幸运的是,这里还有其他获取字符串元素的方式。

如果你需要操作单独的 Unicode 标量值,最好的选择是使用 chars 方法。对 “नमस्ते” 调用 chars 方法会将其分开并返回六个 char 类型的值,接着就可以遍历其结果来访问每一个元素了:

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

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

1
2
3
4
5
6






bytes 方法返回每一个原始字节,这可能会适合你的使用场景:

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

这些代码会打印出组成 String 的 18 个字节:

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

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

从字符串中获取字形簇是很复杂的,所以标准库并没有提供这个功能。crates.io 上有些提供这样功能的 crate。

字符串并不简单

总而言之,字符串还是很复杂的。不同的语言选择了不同的向开发者展示其复杂性的方式。Rust 选择了以准确的方式处理 String 数据作为所有 Rust 程序的默认行为,这意味着开发者们必须更多的思考如何预先处理 UTF-8 数据。这种权衡取舍相比其他语言更多的暴露出了字符串的复杂性,不过也使你在开发生命周期后期免于处理涉及非 ASCII 字符的错误。

哈希map,我在C语言编程时没怎么接触过这个东西,所以不太熟悉,等用到的时候再回来看吧

哈希 map 储存键值对

最后介绍的常用集合类型是 哈希 maphash map)。HashMap<K, V> 类型储存了一个键类型 K 对应一个值类型 V 的映射。它通过一个 哈希函数hashing function)来实现映射,决定如何将键和值放入内存中。很多编程语言支持这种数据结构,不过通常有不同的名字:哈希、map、对象、哈希表或者关联数组,仅举几例。

哈希 map 可以用于需要任何类型作为键来寻找数据的情况,而不是像 vector 那样通过索引。例如,在一个游戏中,你可以将每个团队的分数记录到哈希 map 中,其中键是队伍的名字而值是每个队伍的分数。给出一个队名,就能得到他们的得分。

本章我们会介绍哈希 map 的基本 API,不过还有更多吸引人的功能隐藏于标准库在 HashMap<K, V> 上定义的函数中。一如既往请查看标准库文档来了解更多信息。

新建一个哈希 map

可以使用 new 创建一个空的 HashMap,并使用 insert 增加元素。在示例 8-20 中我们记录两支队伍的分数,分别是蓝队和黄队。蓝队开始有 10 分而黄队开始有 50 分:

1
2
3
4
5
6
use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

示例 8-20:新建一个哈希 map 并插入一些键值对

注意必须首先 use 标准库中集合部分的 HashMap。在这三个常用集合中,HashMap 是最不常用的,所以并没有被 prelude 自动引用。标准库中对 HashMap 的支持也相对较少,例如,并没有内建的构建宏。

像 vector 一样,哈希 map 将它们的数据储存在堆上,这个 HashMap 的键类型是 String 而值类型是 i32。类似于 vector,哈希 map 是同质的:所有的键必须是相同类型,值也必须都是相同类型。

另一个构建哈希 map 的方法是使用一个元组的 vector 的 collect 方法,其中每个元组包含一个键值对。collect 方法可以将数据收集进一系列的集合类型,包括 HashMap。例如,如果队伍的名字和初始分数分别在两个 vector 中,可以使用 zip 方法来创建一个元组的 vector,其中 “Blue” 与 10 是一对,依此类推。接着就可以使用 collect 方法将这个元组 vector 转换成一个 HashMap,如示例 8-21 所示:

1
2
3
4
5
6
use std::collections::HashMap;

let teams = vec![String::from("Blue"), String::from("Yellow")];
let initial_scores = vec![10, 50];

let scores: HashMap<_, _> = teams.iter().zip(initial_scores.iter()).collect();

示例 8-21:用队伍列表和分数列表创建哈希 map

这里 HashMap<_, _> 类型标注是必要的,因为 collect 有可能当成多种不同的数据结构,而除非显式指定否则 Rust 无从得知你需要的类型。但是对于键和值的类型参数来说,可以使用下划线占位,而 Rust 能够根据 vector 中数据的类型推断出 HashMap 所包含的类型。

哈希 map 和所有权

对于像 i32 这样的实现了 Copy trait 的类型,其值可以拷贝进哈希 map。对于像 String 这样拥有所有权的值,其值将被移动而哈希 map 会成为这些值的所有者,如示例 8-22 所示:

1
2
3
4
5
6
7
8
9
use std::collections::HashMap;

let field_name = String::from("Favorite color");
let field_value = String::from("Blue");

let mut map = HashMap::new();
map.insert(field_name, field_value);
// 这里 field_name 和 field_value 不再有效,
// 尝试使用它们看看会出现什么编译错误!

示例 8-22:展示一旦键值对被插入后就为哈希 map 所拥有

insert 调用将 field_namefield_value 移动到哈希 map 中后,将不能使用这两个绑定。

如果将值的引用插入哈希 map,这些值本身将不会被移动进哈希 map。但是这些引用指向的值必须至少在哈希 map 有效时也是有效的。第 10 章 “生命周期与引用有效性” 部分将会更多的讨论这个问题。

访问哈希 map 中的值

可以通过 get 方法并提供对应的键来从哈希 map 中获取值,如示例 8-23 所示:

1
2
3
4
5
6
7
8
9
use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

let team_name = String::from("Blue");
let score = scores.get(&team_name);

示例 8-23:访问哈希 map 中储存的蓝队分数

这里,score 是与蓝队分数相关的值,应为 Some(10)。因为 get 返回 Option<V>,所以结果被装进 Some;如果某个键在哈希 map 中没有对应的值,get 会返回 None。这时就要用某种第 6 章提到的方法之一来处理 Option

可以使用与 vector 类似的方式来遍历哈希 map 中的每一个键值对,也就是 for 循环:

1
2
3
4
5
6
7
8
9
10
use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

for (key, value) in &scores {
println!("{}: {}", key, value);
}

这会以任意顺序打印出每一个键值对:

1
2
Yellow: 50
Blue: 10

更新哈希 map

尽管键值对的数量是可以增长的,不过任何时候,每个键只能关联一个值。当我们想要改变哈希 map 中的数据时,必须决定如何处理一个键已经有值了的情况。可以选择完全无视旧值并用新值代替旧值。可以选择保留旧值而忽略新值,并只在键 没有 对应值时增加新值。或者可以结合新旧两值。让我们看看这分别该如何处理!

覆盖一个值

如果我们插入了一个键值对,接着用相同的键插入一个不同的值,与这个键相关联的旧值将被替换。即便示例 8-24 中的代码调用了两次 insert,哈希 map 也只会包含一个键值对,因为两次都是对蓝队的键插入的值:

1
2
3
4
5
6
7
8
9

use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Blue"), 25);

println!("{:?}", scores);

示例 8-24:替换以特定键储存的值

这会打印出 {"Blue": 25}。原始的值 10 则被覆盖了。

只在键没有对应值时插入

我们经常会检查某个特定的键是否有值,如果没有就插入一个值。为此哈希 map 有一个特有的 API,叫做 entry,它获取我们想要检查的键作为参数。entry 函数的返回值是一个枚举,Entry,它代表了可能存在也可能不存在的值。比如说我们想要检查黄队的键是否关联了一个值。如果没有,就插入值 50,对于蓝队也是如此。使用 entry API 的代码看起来像示例 8-25 这样:

1
2
3
4
5
6
7
8
9
10

use std::collections::HashMap;

let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);

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

println!("{:?}", scores);

示例 8-25:使用 entry 方法只在键没有对应一个值时插入

Entryor_insert 方法在键对应的值存在时就返回这个值的可变引用,如果不存在则将参数作为新值插入并返回新值的可变引用。这比编写自己的逻辑要简明的多,另外也与借用检查器结合得更好。

运行示例 8-25 的代码会打印出 {"Yellow": 50, "Blue": 10}。第一个 entry 调用会插入黄队的键和值 50,因为黄队并没有一个值。第二个 entry 调用不会改变哈希 map 因为蓝队已经有了值 10

根据旧值更新一个值

另一个常见的哈希 map 的应用场景是找到一个键对应的值并根据旧的值更新它。例如,示例 8-26 中的代码计数一些文本中每一个单词分别出现了多少次。我们使用哈希 map 以单词作为键并递增其值来记录我们遇到过几次这个单词。如果是第一次看到某个单词,就插入值 0

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

use std::collections::HashMap;

let text = "hello world wonderful world";

let mut map = HashMap::new();

for word in text.split_whitespace() {
let count = map.entry(word).or_insert(0);
*count += 1;
}

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

示例 8-26:通过哈希 map 储存单词和计数来统计出现次数

这会打印出 {"world": 2, "hello": 1, "wonderful": 1}or_insert 方法事实上会返回这个键的值的一个可变引用(&mut V)。这里我们将这个可变引用储存在 count 变量中,所以为了赋值必须首先使用星号(*)解引用 count。这个可变引用在 for 循环的结尾离开作用域,这样所有这些改变都是安全的并符合借用规则。

哈希函数

HashMap 默认使用一种 “密码学安全的”(“cryptographically strong” )1 哈希函数,它可以抵抗拒绝服务(Denial of Service, DoS)攻击。然而这并不是可用的最快的算法,不过为了更高的安全性值得付出一些性能的代价。如果性能监测显示此哈希函数非常慢,以致于你无法接受,你可以指定一个不同的 hasher 来切换为其它函数。hasher 是一个实现了 BuildHasher trait 的类型。第 10 章会讨论 trait 和如何实现它们。你并不需要从头开始实现你自己的 hasher;crates.io 有其他人分享的实现了许多常用哈希算法的 hasher 的库。

我们依旧先来欣赏一下官方的高速神言,这一节官方主要就是讲了一下 use as还有pub use这三个东西

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

到目前为止,似乎我们编写的用于调用函数的路径都很冗长且重复,并不方便。例如,示例 7-7 中,无论我们选择 add_to_waitlist 函数的绝对路径还是相对路径,每次我们想要调用 add_to_waitlist 时,都必须指定 front_of_househosting。幸运的是,有一种方法可以简化这个过程。我们可以使用 use 关键字将路径一次性引入作用域,然后调用该路径中的项,就如同它们是本地项一样。

在示例 7-11 中,我们将 crate::front_of_house::hosting 模块引入了 eat_at_restaurant 函数的作用域,而我们只需要指定 hosting::add_to_waitlist 即可在 eat_at_restaurant 中调用 add_to_waitlist 函数。

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

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

use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}

示例 7-11: 使用 use 将模块引入作用域

在作用域中增加 use 和路径类似于在文件系统中创建软连接(符号连接,symbolic link)。通过在 crate 根增加 use crate::front_of_house::hosting,现在 hosting 在作用域中就是有效的名称了,如同 hosting 模块被定义于 crate 根一样。通过 use 引入作用域的路径也会检查私有性,同其它路径一样。

你还可以使用 use 和相对路径来将一个项引入作用域。示例 7-12 展示了如何指定相对路径来取得与示例 7-11 中一样的行为。

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

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

use front_of_house::hosting;

pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}

示例 7-12: 使用 use 和相对路径将模块引入作用域

创建惯用的 use 路径

在示例 7-11 中,你可能会比较疑惑,为什么我们是指定 use crate::front_of_house::hosting,然后在 eat_at_restaurant 中调用 hosting::add_to_waitlist,而不是通过指定一直到 add_to_waitlist 函数的 use 路径来得到相同的结果,如示例 7-13 所示。

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

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

use crate::front_of_house::hosting::add_to_waitlist;

pub fn eat_at_restaurant() {
add_to_waitlist();
}

示例 7-13: 使用 useadd_to_waitlist 函数引入作用域,这并不符合习惯

虽然示例 7-11 和 7-13 都完成了相同的任务,但示例 7-11 是使用 use 将函数引入作用域的习惯用法。使用 use 将函数的父模块引入作用域意味着我们必须在调用函数时指定父模块,这样可以清晰地表明函数不是在本地定义的,同时使完整路径的重复度最小化。示例 7-13 中的代码则未表明 add_to_waitlist 是在哪里被定义的。

另一方面,使用 use 引入结构体、枚举和其他项时,习惯是指定它们的完整路径。示例 7-14 展示了将 HashMap 结构体引入二进制 crate 作用域的习惯用法。

1
2
3
4
5
6
7

use std::collections::HashMap;

fn main() {
let mut map = HashMap::new();
map.insert(1, 2);
}

示例 7-14: 将 HashMap 引入作用域的习惯用法

这种习惯用法背后没有什么硬性要求:它只是一种惯例,人们已经习惯了以这种方式阅读和编写 Rust 代码。

其实也就说了这么些内容

使用 use 引入函数时,习惯上是将函数的父模块引入作用域。 这样,在调用函数时,你需要写 父模块::函数名(),比如 hosting::add_to_waitlist()。这样做的好处是,可以清晰地表明这个函数不是当前模块定义的,同时避免了每次都写完整的冗长路径。

使用 use 引入结构体、枚举或其他项时,习惯上是直接引入到该项本身。 这样,你可以直接使用该项的名称,比如 HashMap::new()

这个习惯用法有一个例外,那就是我们想使用 use 语句将两个具有相同名称的项带入作用域,因为 Rust 不允许这样做。示例 7-15 展示了如何将两个具有相同名称但不同父模块的 Result 类型引入作用域,以及如何引用它们。

1
2
3
4
5
6
7
8
9
10
11

use std::fmt;
use std::io;

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

fn function2() -> io::Result<()> {
// --snip--
}

示例 7-15: 使用父模块将两个具有相同名称的类型引入同一作用域

如你所见,使用父模块可以区分这两个 Result 类型。如果我们是指定 use std::fmt::Resultuse std::io::Result,我们将在同一作用域拥有了两个 Result 类型,当我们使用 Result 时,Rust 则不知道我们要用的是哪个。

接下来就是引入 as

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

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

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

 这个其实看两眼也就明白了,不用什么复杂的解释

然后还有pub use,我的理解就是你自己编写的模块中通过use引用了别的模块的内容,想要使这些内容可以被引用你模块的人使用的话,就需要pub use语句

以下是ai给出的解释

pub use 的核心作用,正是你描述的这个场景:当你编写一个模块(或者库)时,你可能会在内部 use 其他模块或类型来方便自己使用。但是,如果你希望使用你这个模块的外部代码也能通过你的模块路径来访问这些被你 use 进来的内容,那么你就需要使用 pub use

简单来说:

  • use 仅仅是你自己模块内部的“快捷方式”,方便你在本模块中使用。外部模块无法通过你的模块来访问这些被 use 进来的东西。
  • pub use 不仅创建了内部的“快捷方式”,还公开了这个快捷方式。这样,外部模块就可以通过你的模块路径来访问这个被重导出的内容,就好像这些内容就是你模块自己定义的一样。

这就像是你开了一家商店(你的模块),你从批发商那里进了些商品(其他模块的内容)。

  • 如果你只是自己用(use),这些商品就在你的库房里,只有你能看到。
  • 但如果你想把这些商品摆在你的店里卖(pub use),那么顾客(外部代码)就可以通过你的商店来购买这些商品了,他们甚至不需要知道你最初是从哪个批发商那里进的货。

pub use 允许你提供一个更清晰、更简洁的 API 给你的用户,同时隐藏了你内部的组织结构,语法如下:

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

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}
fn main() {}

还剩下一些杂七杂八的东西

下面是这段内容讲的三个主要“玩意儿”:

  • 你问得很好!这段内容主要围绕着如何在 Rust 项目中使用外部包(crate)以及如何优化 use 语句来管理这些引入的依赖。

    下面是这段内容讲的三个主要“玩意儿”:

    1. 如何使用外部包(Crate)

    Rust 项目使用 Cargo 这个构建系统和包管理器来管理依赖。

    • 声明依赖: 当你需要使用一个外部包时(比如 rand),你需要在项目的 Cargo.toml 文件中的 [dependencies] 部分添加这个包的名称和版本号。Cargo 会自动从 crates.io(Rust 社区的包注册表)下载这个包及其所有必要的依赖。

      Ini, TOML

      1
      2
      [dependencies]
      rand = "0.8.3" # 示例:声明对rand包的依赖
    • 引入作用域: 仅仅声明依赖还不够。你还需要使用 use 关键字,以 绝对路径 的方式将外部包中你想要使用的特定项(函数、结构体、trait 等)引入到你的代码的作用域中,这样你才能直接使用它们。

      Rust

      1
      use rand::Rng; // 将rand包中的Rng trait引入作用域

      值得注意的是,标准库 (std) 也是一个特殊的外部 crate。虽然你不需要在 Cargo.toml 中声明它,但你仍然需要使用 use 语句来引入 std 中你想要使用的项,例如 use std::collections::HashMap;

    2. 优化 use 语句:嵌套路径

    当你的代码需要从同一个模块或同一个包中引入多个项时,逐行 use 会让代码变得冗长。Rust 提供了 嵌套路径 的语法来简化这个过程。

    • 基本嵌套: 如果多个项共享同一个路径前缀,你可以将它们用大括号 {} 包裹起来。

      Rust

      1
      2
      3
      4
      5
      6
      // 之前:
      // use std::cmp::Ordering;
      // use std::io;

      // 之后:一行搞定
      use std::{cmp::Ordering, io};
    • self 关键字: 当你想引入一个模块本身,同时又想引入这个模块下的某个子项时,可以在嵌套路径中使用 self 关键字。

      Rust

      1
      2
      3
      4
      5
      6
      // 之前:
      // use std::io;
      // use std::io::Write;

      // 之后:一行搞定,同时引入了io模块和io::Write
      use std::io::{self, Write};

    3. 引入所有公有定义:Glob 运算符 *

    如果你想将一个模块下所有 公有 的项都引入到当前作用域,可以使用 glob 运算符 \*

    • 用法: 在路径的末尾加上 *

      Rust

      1
      use std::collections::*; // 引入std::collections模块中所有的公有项
    • 注意事项: 尽管方便,但使用 glob 运算符时需要谨慎。它会使代码的可读性降低,因为你很难一眼看出某个名称是来自哪里,可能会导致名称冲突。它通常在测试模块(为了方便测试所有功能)或特定模式(如 prelude 模式)中使用。

    这里还有,并提供了两种技巧 ({} 嵌套路径和 * glob 运算符) 来更简洁地管理 use 语句,从而提高代码的可读性和维护性。

    这段代码展示了 Rust 中更细粒度的可见性控制,也就是如何使用 pub(in path)pub(self)pub(super) 来限制一个项(比如函数)的可见范围。

    深入理解 Rust 的可见性控制

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    // 一个名为 `my_mod` 的模块
    mod my_mod {
    // 模块中的项默认具有私有的可见性
    fn private_function() {
    println!("called `my_mod::private_function()`");
    }

    // 使用 `pub` 修饰语来改变默认可见性。
    pub fn function() {
    println!("called `my_mod::function()`");
    }

    // 在同一模块中,项可以访问其它项,即使它是私有的。
    pub fn indirect_access() {
    print!("called `my_mod::indirect_access()`, that\n> ");
    private_function();
    }

    // 模块也可以嵌套
    pub mod nested {
    pub fn function() {
    println!("called `my_mod::nested::function()`");
    }

    #[allow(dead_code)]
    fn private_function() {
    println!("called `my_mod::nested::private_function()`");
    }

    // 使用 `pub(in path)` 语法定义的函数只在给定的路径中可见。
    // `path` 必须是父模块(parent module)或祖先模块(ancestor module)
    pub(in crate::my_mod) fn public_function_in_my_mod() {
    print!("called `my_mod::nested::public_function_in_my_mod()`, that\n > ");
    public_function_in_nested()
    }

    // 使用 `pub(self)` 语法定义的函数则只在当前模块中可见。
    pub(self) fn public_function_in_nested() {
    println!("called `my_mod::nested::public_function_in_nested");
    }

    // 使用 `pub(super)` 语法定义的函数只在父模块中可见。
    pub(super) fn public_function_in_super_mod() {
    println!("called my_mod::nested::public_function_in_super_mod");
    }
    }

    pub fn call_public_function_in_my_mod() {
    print!("called `my_mod::call_public_funcion_in_my_mod()`, that\n> ");
    nested::public_function_in_my_mod();
    print!("> ");
    nested::public_function_in_super_mod();
    }

    // `pub(crate)` 使得函数只在当前 crate 中可见
    pub(crate) fn public_function_in_crate() {
    println!("called `my_mod::public_function_in_crate()");
    }

    // 嵌套模块的可见性遵循相同的规则
    mod private_nested {
    #[allow(dead_code)]
    pub fn function() {
    println!("called `my_mod::private_nested::function()`");
    }
    }
    }

    fn function() {
    println!("called `function()`");
    }

    fn main() {
    // 模块机制消除了相同名字的项之间的歧义。
    function();
    my_mod::function();

    // 公有项,包括嵌套模块内的,都可以在父模块外部访问。
    my_mod::indirect_access();
    my_mod::nested::function();
    my_mod::call_public_function_in_my_mod();

    // pub(crate) 项可以在同一个 crate 中的任何地方访问
    my_mod::public_function_in_crate();

    // pub(in path) 项只能在指定的模块中访问
    // 报错!函数 `public_function_in_my_mod` 是私有的
    //my_mod::nested::public_function_in_my_mod();
    // 试一试 ^ 取消该行的注释

    // 模块的私有项不能直接访问,即便它是嵌套在公有模块内部的

    // 报错!`private_function` 是私有的
    //my_mod::private_function();
    // 试一试 ^ 取消此行注释

    // 报错!`private_function` 是私有的
    //my_mod::nested::private_function();
    // 试一试 ^ 取消此行的注释

    // Error! `private_nested` is a private module
    //my_mod::private_nested::function();
    // 试一试 ^ 取消此行的注释
    }

    默认情况下,Rust 中的所有项(函数、结构体、枚举、模块等)都是私有的。要让它们在当前作用域之外可见,你需要使用 pub 关键字。然而,简单的 pub 意味着对所有外部代码都可见,这在某些情况下可能过于宽松。

    pub(in path)pub(self)pub(super) 提供了一种方式,让你能够更精确地控制项的可见性,而不是简单地“完全公开”或“完全私有”。

    1. pub(in crate::my_mod):指定路径可见性

    这个语法允许你将一个项的可见性限制在指定的路径(模块)内部。

    • pub(in crate::my_mod) fn public_function_in_my_mod()

      • 这意味着 public_function_in_my_mod 这个函数只在 crate::my_mod 模块及其子模块内部可见和可调用
      • my_mod 外部的代码,即使 my_mod 本身是公开的,也无法直接调用 public_mod::nested::public_function_in_my_mod
      • path 必须是该项的父模块或祖先模块。你不能指定一个与当前项没有继承关系的模块。

      想象一下: 你有一个家族企业,my_mod 是总公司。这个函数就像是只有总公司内部的员工(包括子公司员工)才能使用的特定工具。外部客户即使知道总公司存在,也无法直接使用这个工具。

    2. pub(self):当前模块可见性

    pub(self) 将可见性限制在定义该项的当前模块内部。

    • pub(self) fn public_function_in_nested()

      • 这意味着 public_function_in_nested 这个函数只在 my_mod::nested 模块内部可见
      • 即使在 my_mod 模块内部(nested 的父模块),也无法直接调用 public_function_in_nested。只有在 nested 模块内部的代码才能调用它。

      想象一下: nested 是公司里的一个特定部门。public_function_in_nested 就像是这个部门内部的专用流程,只有这个部门的员工才能使用。总公司或其他部门的员工都不能直接调用这个流程。

    3. pub(super):父模块可见性

    pub(super) 将可见性限制在定义该项的父模块内部。

    • pub(super) fn public_function_in_super_mod()

      • 这意味着 public_function_in_super_mod 这个函数只在 my_mod 模块内部可见
      • 尽管它定义在 nested 模块中,但它只对 nested 的父模块(即 my_mod)可见。nested 模块内部也能调用它,因为父模块的可见性范围包含了子模块。

      想象一下: 仍然是公司里的一个部门 nestedpublic_function_in_super_mod 就像是这个部门为总公司(my_mod)提供的内部服务,总公司可以直接调用,但其他部门或外部客户不能直接调用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    fn function() {
    println!("called `function()`");
    }

    mod cool {
    pub fn function() {
    println!("called `cool::function()`");
    }
    }

    mod my {
    fn function() {
    println!("called `my::function()`");
    }

    mod cool {
    pub fn function() {
    println!("called `my::cool::function()`");
    }
    }

    pub fn indirect_call() {
    // 让我们从这个作用域中访问所有名为 `function` 的函数!
    print!("called `my::indirect_call()`, that\n> ");

    // `self` 关键字表示当前的模块作用域——在这个例子是 `my`。
    // 调用 `self::function()` 和直接调用 `function()` 都得到相同的结果,
    // 因为他们表示相同的函数。
    self::function();
    function();

    // 我们也可以使用 `self` 来访问 `my` 内部的另一个模块:
    self::cool::function();

    // `super` 关键字表示父作用域(在 `my` 模块外面)。
    super::function();

    // 这将在 *crate* 作用域内绑定 `cool::function` 。
    // 在这个例子中,crate 作用域是最外面的作用域。
    {
    use crate::cool::function as root_function;
    root_function();
    }
    }
    }

    fn main() {
    my::indirect_call();
    }

    总结

    这些细粒度的可见性控制非常有用,它们允许你:

    • 封装内部实现细节: 将一些只应该在特定范围内部使用的函数或数据隐藏起来,避免外部滥用或不当修改。
    • 构建清晰的 API: 明确哪些部分是库的公共接口,哪些是内部辅助功能。
    • 增强代码安全性: 限制了对某些敏感操作的访问。

    RUST的这个模块管理系统真的挺复杂的,估计得在实战中用它两回才能真正学得会.

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

来看一下 Rust 如何在模块树中找到一个项的位置,我们使用路径的方式,就像在文件系统使用路径一样。如果我们想要调用一个函数,我们需要知道它的路径。

路径有两种形式:

  • 绝对路径absolute path)从 crate 根部开始,以 crate 名或者字面量 crate 开头。
  • 相对路径relative path)从当前模块开始,以 selfsuper 或当前模块的标识符开头。

绝对路径和相对路径都后跟一个或多个由双冒号(::)分割的标识符。

让我们回到示例 7-1。我们如何调用 add_to_waitlist 函数?还是同样的问题,add_to_waitlist 函数的路径是什么?在示例 7-3 中,我们通过删除一些模块和函数,稍微简化了一下我们的代码。我们在 crate 根部定义了一个新函数 eat_at_restaurant,并在其中展示调用 add_to_waitlist 函数的两种方法。eat_at_restaurant 函数是我们 crate 库的一个公共 API,所以我们使用 pub 关键字来标记它。在“使用 pub 关键字暴露路径”一节,我们将详细介绍 pub。注意,这个例子无法编译通过,我们稍后会解释原因。

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

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

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

// 相对路径
front_of_house::hosting::add_to_waitlist();
}

示例 7-3: 使用绝对路径和相对路径来调用 add_to_waitlist 函数

第一种方式,我们在 eat_at_restaurant 中调用 add_to_waitlist 函数,使用的是绝对路径。add_to_waitlist 函数与 eat_at_restaurant 被定义在同一 crate 中,这意味着我们可以使用 crate 关键字为起始的绝对路径。

crate 后面,我们持续地嵌入模块,直到我们找到 add_to_waitlist。你可以想象出一个相同结构的文件系统,我们通过指定路径 /front_of_house/hosting/add_to_waitlist 来执行 add_to_waitlist 程序。我们使用 crate 从 crate 根部开始就类似于在 shell 中使用 / 从文件系统根开始。

第二种方式,我们在 eat_at_restaurant 中调用 add_to_waitlist,使用的是相对路径。这个路径以 front_of_house 为起始,这个模块在模块树中,与 eat_at_restaurant 定义在同一层级。与之等价的文件系统路径就是 front_of_house/hosting/add_to_waitlist。以名称为起始,意味着该路径是相对路径。

选择使用相对路径还是绝对路径,还是要取决于你的项目。取决于你是更倾向于将项的定义代码与使用该项的代码分开来移动,还是一起移动。举一个例子,如果我们要将 front_of_house 模块和 eat_at_restaurant 函数一起移动到一个名为 customer_experience 的模块中,我们需要更新 add_to_waitlist 的绝对路径,但是相对路径还是可用的。然而,如果我们要将 eat_at_restaurant 函数单独移到一个名为 dining 的模块中,还是可以使用原本的绝对路径来调用 add_to_waitlist,但是相对路径必须要更新。我们更倾向于使用绝对路径,因为把代码定义和项调用各自独立地移动是更常见的。

官方依旧高速神言不说人话

我们用 C 语言的例子来类比一下 Rust 中“把代码定义和项调用各自独立地移动是更常见的”这句话。

在 C 语言中,我们没有像 Rust 这样的模块系统,但我们可以用头文件(.h)源文件(.c) 来类比“定义”和“调用”以及“路径”。

C 语言中的类比

想象你有两个 C 文件:

  1. math_operations.hmath_operations.c

    • math_operations.h (头文件):声明了 add 函数。这就像 Rust 中一个模块的公开接口,告诉别人有这么一个函数。

      1
      2
      // math_operations.h
      int add(int a, int b); // 这是函数的“定义”或“声明”
    • math_operations.c (源文件):实现了 add 函数。这是函数的具体“定义”所在。

      1
      2
      3
      4
      5
      6
      // math_operations.c
      #include "math_operations.h"

      int add(int a, int b) {
      return a + b;
      }
  2. main.c

    • 这个文件会调用 add 函数。

场景一:类似 Rust 中的绝对路径(使用完整路径/包含完整头文件)

main.c 中,如果你想要使用 add 函数,你会这么做:

1
2
3
4
5
6
7
// main.c
#include "math_operations.h" // 告诉编译器去哪里找 add 函数的声明

int main() {
int result = add(5, 3); // 调用 add 函数
return 0;
}

这里,#include "math_operations.h" 就像 Rust 中的绝对路径。它明确地指明了 add 函数的声明在哪里可以找到。

  • 如果我移动 main.c 文件(调用者):假设你把 main.c 从项目的一个子目录移动到另一个子目录,只要 math_operations.h 的相对位置没有改变,或者你通过编译器的 -I 选项(include path)告诉了编译器 math_operations.h 的新位置,那么 main.cadd(5, 3) 的调用不需要修改。因为 add 函数的定义(和声明)本身没有移动。这和 Rust 中绝对路径的稳定性类似。

场景二:类似 Rust 中的相对路径(如果 C 语言有这种直接模块引用)

C 语言没有像 Rust 这样内建的相对模块路径引用机制。但我们可以想象一种情况,如果你需要在 main.c直接引用 add 函数的定义,而不是通过头文件。这在 C 语言中是做不到的,但为了类比,我们可以假设:

如果 main.c 直接依赖于 math_operations.c 文件的相对位置来找到 add 函数的定义(这是个假设,C 实际编译不是这样做的)。那么:

  • 如果我移动 main.c 文件(调用者)main.c 的位置变了,它与 math_operations.c 的相对位置就可能变了。那么,原先的“相对路径”就失效了,你需要修改 main.c 中引用 add 函数的方式。

“各自独立移动”的含义

回到 Rust 的语境:

“把代码定义和项调用各自独立地移动是更常见的”这句话意味着:

  1. 你更常会移动和重构使用某个功能(add 函数)的代码文件(main.c),而不是改变那个功能本身(math_operations.cmath_operations.h)的存放位置。
  2. 当你移动 main.c 时,你希望它里面的 add(5, 3) 调用能够保持不变,而不需要每次移动都去修改它。

因此,使用 绝对路径(类似 C 语言中 #include <library_header.h> 或者通过 -I 选项找到的头文件)会更稳定。因为它从一个固定的起点(Rust 的 crate 根部)开始寻找,不依赖于你当前调用代码(main.c)的位置。即使你把 main.c 移动到项目的其他地方,只要 add 函数的定义没有从它原来的“绝对位置”移动,那么对它的引用就依然有效。

相对路径,由于它的起点是“当前模块”,一旦你移动了当前模块,相对位置就变了,路径就可能失效,需要你手动更新。

所以,Rust 建议我们倾向于使用绝对路径,因为它在代码重构时,特别是当调用方代码移动时,能够减少你需要修改的引用路径的数量,从而提高代码的维护性和稳定性。

简而言之就是绝对路径更好用,OK,接下来我们继续欣赏官方文档的高速神言

让我们试着编译一下示例 7-3,并查明为何不能编译!示例 7-4 展示了这个错误。

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

$ cargo build
Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: module `hosting` is private
--> src/lib.rs:9:28
|
9 | crate::front_of_house::hosting::add_to_waitlist();
| ^^^^^^^ private module
|
note: the module `hosting` is defined here
--> src/lib.rs:2:5
|
2 | mod hosting {
| ^^^^^^^^^^^

error[E0603]: module `hosting` is private
--> src/lib.rs:12:21
|
12 | front_of_house::hosting::add_to_waitlist();
| ^^^^^^^ private module
|
note: the module `hosting` is defined here
--> src/lib.rs:2:5
|
2 | mod hosting {
| ^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` due to 2 previous errors

示例 7-4: 构建示例 7-3 出现的编译器错误

错误信息说 hosting 模块是私有的。换句话说,我们拥有 hosting 模块和 add_to_waitlist 函数的的正确路径,但是 Rust 不让我们使用,因为它不能访问私有片段。

模块不仅对于你组织代码很有用。他们还定义了 Rust 的 私有性边界privacy boundary):这条界线不允许外部代码了解、调用和依赖被封装的实现细节。所以,如果你希望创建一个私有函数或结构体,你可以将其放入模块。

Rust 中默认所有项(函数、方法、结构体、枚举、模块和常量)都是私有的。父模块中的项不能使用子模块中的私有项,但是子模块中的项可以使用他们父模块中的项。这是因为子模块封装并隐藏了他们的实现详情,但是子模块可以看到他们定义的上下文。继续拿餐馆作比喻,把私有性规则想象成餐馆的后台办公室:餐馆内的事务对餐厅顾客来说是不可知的,但办公室经理可以洞悉其经营的餐厅并在其中做任何事情。

Rust 选择以这种方式来实现模块系统功能,因此默认隐藏内部实现细节。这样一来,你就知道可以更改内部代码的哪些部分而不会破坏外部代码。你还可以通过使用 pub 关键字来创建公共项,使子模块的内部部分暴露给上级模块。

接下来主要就是引出PUB关键字,需要注意PUB关键字对于结构体,枚举,函数的作用是不太一样的,需要分辨一下

使用 pub 关键字暴露路径

让我们回头看一下示例 7-4 的错误,它告诉我们 hosting 模块是私有的。我们想让父模块中的 eat_at_restaurant 函数可以访问子模块中的 add_to_waitlist 函数,因此我们使用 pub 关键字来标记 hosting 模块,如示例 7-5 所示。

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

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

// 相对路径
front_of_house::hosting::add_to_waitlist();
}

示例 7-5: 使用 pub 关键字声明 hosting 模块使其可在 eat_at_restaurant 使用

不幸的是,示例 7-5 的代码编译仍然有错误,如示例 7-6 所示。

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

$ cargo build
Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: function `add_to_waitlist` is private
--> src/lib.rs:9:37
|
9 | crate::front_of_house::hosting::add_to_waitlist();
| ^^^^^^^^^^^^^^^ private function
|
note: the function `add_to_waitlist` is defined here
--> src/lib.rs:3:9
|
3 | fn add_to_waitlist() {}
| ^^^^^^^^^^^^^^^^^^^^

error[E0603]: function `add_to_waitlist` is private
--> src/lib.rs:12:30
|
12 | front_of_house::hosting::add_to_waitlist();
| ^^^^^^^^^^^^^^^ private function
|
note: the function `add_to_waitlist` is defined here
--> src/lib.rs:3:9
|
3 | fn add_to_waitlist() {}
| ^^^^^^^^^^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` due to 2 previous errors

示例 7-6: 构建示例 7-5 出现的编译器错误

发生了什么?在 mod hosting 前添加了 pub 关键字,使其变成公有的。伴随着这种变化,如果我们可以访问 front_of_house,那我们也可以访问 hosting。但是 hosting内容contents) 仍然是私有的;这表明使模块公有并不使其内容也是公有的。模块上的 pub 关键字只允许其父模块引用它。

示例 7-6 中的错误说,add_to_waitlist 函数是私有的。私有性规则不但应用于模块,还应用于结构体、枚举、函数和方法。

让我们继续将 pub 关键字放置在 add_to_waitlist 函数的定义之前,使其变成公有。如示例 7-7 所示。

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

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

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

// 相对路径
front_of_house::hosting::add_to_waitlist();
}

示例 7-7: 为 mod hostingfn add_to_waitlist 添加 pub 关键字使他们可以在 eat_at_restaurant 函数中被调用

现在代码可以编译通过了!让我们看看绝对路径和相对路径,并根据私有性规则,再检查一下为什么增加 pub 关键字使得我们可以在 add_to_waitlist 中调用这些路径。

在绝对路径,我们从 crate,也就是 crate 根部开始。然后 crate 根部中定义了 front_of_house 模块。front_of_house 模块不是公有的,不过因为 eat_at_restaurant 函数与 front_of_house 定义于同一模块中(即,eat_at_restaurantfront_of_house 是兄弟),我们可以从 eat_at_restaurant 中引用 front_of_house。接下来是使用 pub 标记的 hosting 模块。我们可以访问 hosting 的父模块,所以可以访问 hosting。最后,add_to_waitlist 函数被标记为 pub ,我们可以访问其父模块,所以这个函数调用是有效的!

在相对路径,其逻辑与绝对路径相同,除了第一步:不同于从 crate 根部开始,路径从 front_of_house 开始。front_of_house 模块与 eat_at_restaurant 定义于同一模块,所以从 eat_at_restaurant 中开始定义的该模块相对路径是有效的。接下来因为 hostingadd_to_waitlist 被标记为 pub,路径其余的部分也是有效的,因此函数调用也是有效的!

我们来仔细讨论一下上面这段话

front_of_house 在你提供的代码中不是私有的,它是默认公开的,因为它是直接在 crate 根部定义的。

Rust 的访问规则有点绕,我们来具体解释一下:

crate 根部定义的模块默认是公开的

当你直接在 src/lib.rs (或者 src/main.rs) 文件中定义一个模块时,比如 mod front_of_house {},这个模块就是定义在 crate根部

crate 根部定义的项(包括模块),默认情况下是可以在整个 crate 内部被直接访问到的。 它们不需要 pub 关键字就可以被同一 crate 中的其他代码访问。pub 关键字的作用是让该项可以被外部 crate (比如你的库被其他项目引用时) 访问,或者让该项可以被子模块之外的模块通过完整路径访问。

所以,front_of_house 模块虽然没有显式地写 pub mod front_of_house {},但因为它直接位于 crate 根部,所以:

  1. eat_at_restaurant 函数(也在 crate 根部)可以直接看到并访问 front_of_house,因为它们是“兄弟”关系,都在最外层。
  2. crate:: 作为绝对路径的起点,它自然也能直接“看到”它根部定义的所有内容,包括 front_of_house

为什么会产生误解?

你可能误解了“私有”的含义。在 Rust 中,一个模块(或任何项)如果没有 pub 关键字,它通常表示该项只在其直接父模块内部可见和可访问。但是,这个规则对于 crate 根部有点特殊。crate 根部本身就是最高层级,它里面定义的所有东西对于整个 crate 内部的代码来说,都是默认可访问的,除非它们被嵌套在某个私有模块内部。

总结

  • front_of_house 模块:因为它直接定义在 crate 根部(src/lib.rs 的顶层),所以默认情况下,这个 crate 内部的所有代码都可以直接看到并引用它。它不是“私有的”,而是对 crate 内部可见的。
  • pub mod hosting:这里 pub 的作用是让 hosting 模块可以被 front_of_house 之外的代码(比如 eat_at_restaurant,它位于 front_of_house 的父级 crate 根部)访问。如果没有 pub,那么 hosting 默认就是私有的,只有 front_of_house 内部的代码才能访问它。
  • pub fn add_to_waitlist():函数默认是私有的。如果你想从它所在的模块外面调用它,就必须给它加上 pub

所以,crate 可以访问到 front_of_house 是因为 front_of_house 就在 crate 根部,并且对于 crate 内部是默认可见的。

依旧AI大法


是的,默认情况下,模块外部无法直接访问 front_of_house 模块的内容。

让我们用刚才的餐厅例子再来说明一下:

你的 Rust 项目是个大餐厅,front_of_house 是餐厅里“前厅”这个区域。

  • 默认情况下(没有 pub:这个“前厅”是餐厅内部的一个区域,它里面的具体设施(比如某个柜台、某个角落)默认都是“不对外开放”的。只有当你身处“前厅”内部,或者你被“前厅”的主管(父模块)特许,才能使用里面的东西。外部的顾客(其他 crate)或者甚至餐厅内部其他区域的员工(其他模块)都不能直接走进“前厅”去拿里面的东西,除非前厅的某个门是打开的(pub)。
  • 为什么 crate::front_of_house 可以访问到?
    • 因为 front_of_house 是直接定义在你的 crate(也就是整个餐厅)的“大厅”(根部)里的。
    • 所以,当你从“大厅”出发(crate::)时,你自然能看到“大厅”里所有的区域划分,包括 front_of_house。这就像你站在餐厅大堂中央,能看到哪里是“前厅”,哪里是“厨房”。
    • 但是,能看到这个区域,不代表你就能直接使用这个区域里的所有东西。要使用里面的东西,里面的东西本身也必须是公开的。

所以,结论是:

如果你想让 front_of_house 模块内部的 某个函数或结构体被**front_of_house 模块外部**的代码访问(比如被 eat_at_restaurant 访问,或者被另一个 crate 访问),那么:

  1. front_of_house 模块本身需要是可访问的(因为它直接在 crate 根部,所以它对 crate 内部是默认可见的)。
  2. front_of_house 内部的子模块或函数/结构体也必须使用 pub 关键字 来声明,才能让外部通过完整路径访问到它们。

这就是为什么在示例中,你不仅要给 hosting 加上 pub,还要给 add_to_waitlist 加上 pub,才能让 eat_at_restaurant 最终调用到它。

使用 super 起始的相对路径

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

考虑一下示例 7-8 中的代码,它模拟了厨师更正了一个错误订单,并亲自将其提供给客户的情况。fix_incorrect_order 函数通过指定的 super 起始的 serve_order 路径,来调用 serve_order 函数:

1
2
3
4
5
6
7
8
9
10
11

fn serve_order() {}

mod back_of_house {
fn fix_incorrect_order() {
cook_order();
super::serve_order();
}

fn cook_order() {}
}

示例 7-8: 使用以 super 开头的相对路径从父目录开始调用函数

fix_incorrect_order 函数在 back_of_house 模块中,所以我们可以使用 super 进入 back_of_house 父模块,也就是本例中的 crate 根。在这里,我们可以找到 serve_order。成功!我们认为 back_of_house 模块和 serve_order 函数之间可能具有某种关联关系,并且,如果我们要重新组织这个 crate 的模块树,需要一起移动它们。因此,我们使用 super,这样一来,如果这些代码被移动到了其他模块,我们只需要更新很少的代码。

创建公有的结构体和枚举

我们还可以使用 pub 来设计公有的结构体和枚举,不过有一些额外的细节需要注意。如果我们在一个结构体定义的前面使用了 pub ,这个结构体会变成公有的,但是这个结构体的字段仍然是私有的。我们可以根据情况决定每个字段是否公有。在示例 7-9 中,我们定义了一个公有结构体 back_of_house::Breakfast,其中有一个公有字段 toast 和私有字段 seasonal_fruit。这个例子模拟的情况是,在一家餐馆中,顾客可以选择随餐附赠的面包类型,但是厨师会根据季节和库存情况来决定随餐搭配的水果。餐馆可用的水果变化是很快的,所以顾客不能选择水果,甚至无法看到他们将会得到什么水果。

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

mod back_of_house {
pub struct Breakfast {
pub toast: String,
seasonal_fruit: String,
}

impl Breakfast {
pub fn summer(toast: &str) -> Breakfast {
Breakfast {
toast: String::from(toast),
seasonal_fruit: String::from("peaches"),
}
}
}
}

pub fn eat_at_restaurant() {
// 在夏天点一份黑麦面包作为早餐
let mut meal = back_of_house::Breakfast::summer("Rye");
// 更改我们想要的面包
meal.toast = String::from("Wheat");
println!("I'd like {} toast please", meal.toast);

// 如果取消下一行的注释,将会导致编译失败;我们不被允许
// 看到或更改随餐搭配的季节水果
// meal.seasonal_fruit = String::from("blueberries");
}

示例 7-9: 带有公有和私有字段的结构体

因为 back_of_house::Breakfast 结构体的 toast 字段是公有的,所以我们可以在 eat_at_restaurant 中使用点号来随意的读写 toast 字段。注意,我们不能在 eat_at_restaurant 中使用 seasonal_fruit 字段,因为 seasonal_fruit 是私有的。尝试去除那一行修改 seasonal_fruit 字段值的代码的注释,看看你会得到什么错误!

还请注意一点,因为 back_of_house::Breakfast 具有私有字段,所以这个结构体需要提供一个公共的关联函数来构造 Breakfast 的实例(这里我们命名为 summer)。如果 Breakfast 没有这样的函数,我们将无法在 eat_at_restaurant 中创建 Breakfast 实例,因为我们不能在 eat_at_restaurant 中设置私有字段 seasonal_fruit 的值。

与之相反,如果我们将枚举设为公有,则它的所有成员都将变为公有。我们只需要在 enum 关键字前面加上 pub,就像示例 7-10 展示的那样。

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

mod back_of_house {
pub enum Appetizer {
Soup,
Salad,
}
}

pub fn eat_at_restaurant() {
let order1 = back_of_house::Appetizer::Soup;
let order2 = back_of_house::Appetizer::Salad;
}

示例 7-10: 设计公有枚举,使其所有成员公有

因为我们创建了名为 Appetizer 的公有枚举,所以我们可以在 eat_at_restaurant 中使用 SoupSalad 成员。如果枚举成员不是公有的,那么枚举会显得用处不大;给枚举的所有成员挨个添加 pub 是很令人恼火的,因此枚举成员默认就是公有的。结构体通常使用时,不必将它们的字段公有化,因此结构体遵循常规,内容全部是私有的,除非使用 pub 关键字。

还有一种使用 pub 的场景我们还没有涉及到,那就是我们最后要讲的模块功能:use 关键字。我们将先单独介绍 use,然后展示如何结合使用 pubuse

在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语言中的字符数组数组。