进阶 Steve Klabnik / Carol Nichols 著,KaiserY 中译 2026-09-13 14:34:35 · 0 阅读

第3章 常见编程概念

本章介绍一些几乎出现在所有编程语言中的概念,以及它们在 Rust 中是如何工作的。许多编程语言在其核心层面上都有很多共通之处。本章介绍的概念没有一个是 Rust 独有的,不过我们会在 Rust 的语境下讨论它们,并解释围绕这些概念的使用惯例。

具体来说,你将学习变量、基本类型、函数、注释和控制流。这些基础内容会出现在每一个 Rust 程序中,尽早掌握它们会让你在起步时拥有一个坚实的基础。

关键字

Rust 语言有一组保留的 关键字keywords),就像大部分语言一样,它们只能由语言本身使用。记住,你不能使用这些关键字作为变量或函数的名称。大部分关键字有特殊的意义,你将在 Rust 程序中使用它们完成各种任务;一些关键字目前没有相应的功能,是为将来可能添加的功能保留的。可以在附录 A 中找到关键字的列表。

变量与可变性

变量和可变性

正如第二章中“使用变量储存值” 一节提到的那样,变量默认是不可变的(immutable)。这是 Rust 给你的众多提醒之一,促使你以能充分利用 Rust 所提供的安全性和易于并发的方式来编写代码。不过,你仍然可以把变量设为可变。让我们来探讨 Rust 为何以及如何鼓励你偏向使用不可变性,以及为什么有时你会想要选择不这么做。

当变量不可变时,一旦值被绑定一个名称上,你就不能改变这个值。为了对此进行说明,使用 cargo new variables 命令在 projects 目录生成一个叫做 variables 的新项目。

接着,在新建的 variables 目录中,打开 src/main.rs 并将其中的代码替换为下面这段代码。它现在还不能编译:

文件名:src/main.rs

fn main() {
    let x = 5;
    println!("The value of x is: {x}");
    x = 6;
    println!("The value of x is: {x}");
}

保存并使用 cargo run 运行程序。应该会看到一条与不可变性有关的错误信息,如下输出所示:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     println!("The value of x is: {x}");
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable
  |
help: consider making this binding mutable
  |
2 |     let mut x = 5;
  |         +++

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

这个例子展示了编译器如何帮助你发现程序中的错误。编译错误可能令人沮丧,但它们其实只意味着你的程序还没有以安全的方式完成你希望它完成的工作;这并不意味着你不是一个好程序员!经验丰富的 Rustaceans 也一样会遇到编译错误。

你收到的错误信息 cannot assign twice to immutable variable `x` ,是因为你试图给不可变变量 x 赋第二个值。

当我们尝试修改一个被指定为不可变的值时,能够得到编译时错误是很重要的,因为这种情况可能会导致 bug。如果代码的一部分假设某个值永远不会改变,而另一部分代码却改变了这个值,那么前一部分代码就可能无法按设计那样运行。事后要追踪这类 bug 的根源会非常困难,尤其是当第二段代码只是有时才会修改这个值的时候。

Rust 编译器保证,如果声明一个值不会变,它就真的不会变,所以你不必自己跟踪它。这意味着你的代码更易于推导。

不过,可变性也非常有用,能让代码写起来更方便。尽管变量默认是不可变的,你仍然可以像第二章中那样,在变量名前加上 mut 使其变为可变。添加 mut 也能向未来阅读代码的人传达一种意图:这个变量的值将会被代码的其他部分改变。

例如,让我们将 src/main.rs 修改为如下代码:

文件名:src/main.rs

fn main() {
    let mut x = 5;
    println!("The value of x is: {x}");
    x = 6;
    println!("The value of x is: {x}");
}

现在运行这个程序,会出现如下内容:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/variables`
The value of x is: 5
The value of x is: 6

使用 mut 后,我们就可以把绑定到 x 的值从 5 改成 6。归根结底,是否使用可变性由你自己决定,这取决于在特定场景下你觉得怎样会让代码更清晰。

常量

和不可变变量类似,常量(constants) 也是绑定到某个名称且不允许改变的值,不过常量和变量之间还是有一些区别。

首先,你不能对常量使用 mut。常量不只是默认不可变,它们永远都是不可变的。声明常量时要用 const 关键字,而不是 let,并且 必须 标注值的类型。在下一节“数据类型”中,我们会介绍类型和类型注解,所以现在先不用担心细节;你只要记住:声明常量时必须总是标注类型。

常量可以在任何作用域中声明,包括全局作用域,这在一个值需要被很多部分的代码用到时很有用。

最后一个区别是,常量只能被设置为常量表达式,而不能是那些只能在运行时计算出来的值。

下面是一个声明常量的例子:

#![allow(unused)]
fn main() {
const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;
}

这个常量的名字是 THREE_HOURS_IN_SECONDS,它的值通过 60(一分钟中的秒数)乘以 60(一小时中的分钟数)再乘以 3(我们这个程序中要计算的小时数)得出。Rust 对常量的命名约定是全部大写,并用下划线分隔单词。编译器可以在编译时对一组有限的操作进行求值,因此我们可以选择用更易于理解和验证的方式来写出这个值,而不是将此常量设置为值 10,800。关于声明常量时可以使用哪些操作的更多信息,请参阅 Rust Reference 中关于常量求值的部分

在其声明所在的作用域内,常量在程序运行的整个过程中都有效。这一特性使常量非常适合作为应用领域中的全局值,比如游戏中所有玩家能够获得的最高分,或者光速这样的值。

把散落在应用程序中的硬编码值提取为常量,有助于让以后维护代码的人理解这个值的含义。如果未来需要更新这个硬编码值,也只需要修改一个地方。

遮蔽

正如我们在第二章的猜数字游戏中看到的,我们可以定义一个与之前变量同名的新变量。Rustaceans 把这种情况称为第一个变量被第二个变量 遮蔽(shadowed) 了。这意味着,当你使用这个变量名时,编译器看到的是第二个变量。实际上,第二个变量会遮住第一个变量,使得后续所有对该名称的使用都指向第二个变量,直到它自己又被遮蔽,或者它的作用域结束。我们可以通过重复使用同一个变量名并再次写出 let 关键字来遮蔽一个变量,如下所示:

文件名:src/main.rs

fn main() {
    let x = 5;

    let x = x + 1;

    {
        let x = x * 2;
        println!("The value of x in the inner scope is: {x}");
    }

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

这个程序首先把 x 绑定到值 5。接着,它再次写出 let x =,创建了一个新的变量 x,取原来的值并加上 1,于是 x 的值变成了 6。然后,在由花括号创建的内部作用域中,第三个 let 语句再次遮蔽了 x,并创建了一个新变量,把之前的值乘以 2,因此 x 的值变成了 12。当这个作用域结束时,内部的遮蔽也随之结束,x 又回到 6。运行这个程序时,会得到如下输出:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/variables`
The value of x in the inner scope is: 12
The value of x is: 6

遮蔽和把变量标记为 mut 是不同的。如果你不小心尝试在没有使用 let 关键字的情况下重新给变量赋值,就会得到编译时错误。而通过再次使用 let,我们可以对这个值做一些变换,同时又能让变量在变换完成后继续保持不可变。

mut 和遮蔽之间的另一个区别是:当我们再次使用 let 时,实际上是在创建一个新变量,因此我们可以改变值的类型,同时继续复用相同的名字。例如,假设程序要求用户输入若干空格,以表示他们希望在某段文本之间显示多少空格,随后我们想把这个输入保存成一个数字:

fn main() {
    let spaces = "   ";
    let spaces = spaces.len();
}

第一个 spaces 变量是字符串类型,而第二个 spaces 变量是数字类型。遮蔽让我们不必想出不同的名字,比如 spaces_strspaces_num;相反,我们可以继续复用更简单的 spaces。不过,如果像下面这样尝试使用 mut,就会得到一个编译时错误:

fn main() {
    let mut spaces = "   ";
    spaces = spaces.len();
}

这个错误说明,我们不能修改变量的类型:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0308]: mismatched types
 --> src/main.rs:3:14
  |
2 |     let mut spaces = "   ";
  |                      ----- expected due to this value
3 |     spaces = spaces.len();
  |              ^^^^^^^^^^^^ expected `&str`, found `usize`

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

现在我们已经了解了变量如何工作,让我们看看变量可以拥有的更多数据类型。

数据类型

数据类型

在 Rust 中,每个值都属于某种特定的 数据类型data type),这会告诉 Rust 当前指定的是什么种类的数据,从而知道该如何处理这些数据。我们将看到两类数据类型的子集:标量(scalar)和复合(compound)。

记住,Rust 是 静态类型statically typed)语言,也就是说,它必须在编译时就知道所有变量的类型。编译器通常可以根据值以及它的使用方式推断出我们想要使用的类型。但在存在多种可能类型的情况下,比如我们在第二章“比较猜测的数字和秘密数字”一节中使用 parseString 转换为数值类型时,就必须加上类型注解,像这样:

#![allow(unused)]
fn main() {
let guess: u32 = "42".parse().expect("Not a number!");
}

如果不按前面的代码那样加上 : u32 类型注解,Rust 就会显示如下错误。这说明编译器需要我们提供更多信息,才能知道我们想使用哪一种类型:

$ cargo build
   Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0284]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("Not a number!");
  |         ^^^^^        ----- type must be known at this point
  |
  = note: cannot satisfy `<_ as FromStr>::Err == _`
help: consider giving `guess` an explicit type
  |
2 |     let guess: /* Type */ = "42".parse().expect("Not a number!");
  |              ++++++++++++

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

你会看到其它数据类型的各种类型注解。

标量类型

标量scalar)类型代表一个单独的值。Rust 有四种基本的标量类型:整型、浮点型、布尔类型和字符类型。你可能在其他语言中见过它们。让我们深入了解它们在 Rust 中是如何工作的。

整型

整型 是没有小数部分的数字。我们在第二章已经用过 u32 这种整数类型。这个类型声明表明,它关联的值应该是一个占用 32 位空间的无符号整数(有符号整数类型以 i 开头,而不是 u)。表格 3-1 展示了 Rust 内建的整数类型。我们可以使用其中任意一种来声明整数值的类型。

表格 3-1: Rust 中的整型

长度有符号无符号
8-biti8u8
16-biti16u16
32-biti32u32
64-biti64u64
128-biti128u128
架构相关isizeusize

每一种变体都可以是有符号或无符号的,并且具有明确的大小。有符号无符号 指的是数字是否可能为负数。换句话说,这个数字是需要带符号的(有符号),还是它永远为正,因此无需符号(无符号)。这有点像我们在纸上写数字:当符号有意义时,数字前面会带上加号或减号;但如果可以安全地假定它是正数,通常就不会写加号。有符号数使用二进制补码(two’s complement)表示。

每一个有符号的变体可以储存包含从 -(2n - 1) 到 2n - 1 - 1 在内的数字,这里 n 是变体使用的位数。所以 i8 可以储存从 -(27) 到 27 - 1 在内的数字,也就是从 -128 到 127。无符号的变体可以储存从 0 到 2n - 1 的数字,所以 u8 可以储存从 0 到 28 - 1 的数字,也就是从 0 到 255。

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

你可以使用表格 3-2 中展示的任意一种形式来编写整数字面值。请注意,那些可能对应多种数值类型的数字字面值可以带上类型后缀,例如 57u8,用来显式指定类型。数字字面值也可以使用 _ 作为视觉分隔符,方便阅读,例如 1_000,它和 1000 的值完全相同。

表格 3-2: Rust 中的整型字面值

数字字面值例子
Decimal(十进制)98_222
Hex(十六进制)0xff
Octal(八进制)0o77
Binary(二进制)0b1111_0000
Byte(字节字面值,仅限 u8b'A'

那么该使用哪种整型呢?如果拿不定主意,Rust 的默认类型通常是一个不错的起点:整型默认是 i32。而 isizeusize 主要用在对某种集合进行索引的场景中。

整型溢出

假设你有一个 u8 类型的变量,它可以保存 0255 之间的值。如果你试图把它改成超出该范围的值,比如 256,就会发生 整型溢出integer overflow),并可能导致两种行为之一。当你在 debug 模式下编译时,Rust 会加入整型溢出的检查,并在发生这种情况时让程序在运行时 panic。Rust 用 panicking 这个术语表示程序因错误而退出;我们会在第九章panic! 与不可恢复的错误”一节中更深入地讨论 panic。

当你使用 --release flag 在 release 模式下编译时,Rust 不会加入会导致 panic 的整型溢出检查。相反,如果发生溢出,Rust 会执行一种叫做 two’s complement wrapping 的行为。简而言之,超过该类型最大值的数会“回绕”到该类型所能表示的最小值。对于 u8 来说,256 会变成 0257 会变成 1,依此类推。程序不会 panic,但变量得到的值很可能不是你原本期望的值。依赖整型溢出的回绕行为通常被认为是一种错误。

为了显式地处理溢出的可能性,可以使用这几类标准库提供的原始数字类型方法:

  • 所有模式下都可以使用 wrapping_* 方法进行 wrapping,如 wrapping_add
  • 如果 checked_* 方法发生溢出,则返回 None
  • overflowing_* 方法返回值和一个布尔值,表示是否出现溢出
  • saturating_* 方法在值的最小值或最大值处进行饱和处理

浮点型

Rust 也有两个原生的 浮点数floating-point numbers)类型,它们是带小数点的数字。Rust 的浮点数类型是 f32f64,分别占 32 位和 64 位。默认类型是 f64,因为在现代 CPU 中,它与 f32 速度几乎一样,不过精度更高。所有的浮点型都是有符号的。

文件名:src/main.rs

fn main() {
    let x = 2.0; // f64

    let y: f32 = 3.0; // f32
}

浮点数按照 IEEE-754 标准表示。

数值运算

Rust 中的所有数字类型都支持基本数学运算:加法、减法、乘法、除法和取余。整数除法会向零舍入到最接近的整数。下面的代码展示了如何在 let 语句中使用各种数值运算:

文件名:src/main.rs

fn main() {
    // addition
    let sum = 5 + 10;

    // subtraction
    let difference = 95.5 - 4.3;

    // multiplication
    let product = 4 * 30;

    // division
    let quotient = 56.7 / 32.2;
    let truncated = -5 / 3; // 结果为 -1

    // remainder
    let remainder = 43 % 5;
}

这些语句中的每个表达式使用了一个数学运算符并计算出了一个值,然后绑定给一个变量。附录 B 包含 Rust 提供的所有运算符的列表。

布尔类型

正如其他大部分编程语言一样,Rust 中的布尔类型有两个可能的值:truefalse。Rust 中的布尔类型使用 bool 表示。例如:

文件名:src/main.rs

fn main() {
    let t = true;

    let f: bool = false; // with explicit type annotation
}

布尔值最主要的使用场景是条件表达式,例如 if 表达式。我们会在“控制流”一节介绍 if 表达式在 Rust 中是如何工作的。

字符类型

Rust 的 char 类型是语言中最原始的字母类型。下面是一些声明 char 值的例子:

文件名:src/main.rs

fn main() {
    let c = 'z';
    let z: char = 'ℤ'; // with explicit type annotation
    let heart_eyed_cat = '😻';
}

注意,我们使用单引号来表示 char 字面值,而字符串字面值使用的是双引号。Rust 的 char 类型大小为 4 个字节,并表示一个 Unicode 标量值(Unicode Scalar Value),这意味着它所能表示的内容远不止 ASCII。带重音符号的字母,中文、日文、韩文字符,emoji,以及零宽空格,都是 Rust 中合法的 char 值。Unicode 标量值的范围包括 U+0000U+D7FF,以及 U+E000U+10FFFF。不过,“字符”并不是 Unicode 中一个严格对应的概念,因此你直觉上认为的“字符”未必和 Rust 中的 char 一一对应。我们会在第八章“使用字符串储存 UTF-8 编码的文本”中更详细地讨论这个主题。

复合类型

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

元组类型

元组是一种将多个不同类型的值组合成一个复合类型的通用方式。元组长度固定:一旦声明,它的大小就不能增长或缩小。

我们通过在圆括号中写一组由逗号分隔的值来创建元组。元组中的每个位置都有一个类型,而且这些不同位置上的值类型不必相同。下面这个例子中加入了可选的类型注解:

文件名:src/main.rs

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

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

文件名:src/main.rs

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

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

文件名:src/main.rs

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)。和元组不同,数组中的每个元素都必须具有相同类型。Rust 中的数组也不同于某些其他语言中的数组:Rust 的数组长度是固定的。

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

文件名:src/main.rs

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

当你希望把数据分配在栈(stack)上而不是堆(heap)上时(第四章会更详细地讨论栈与堆),或者当你想确保始终拥有固定数量的元素时,数组就非常有用。不过,数组不如 vector 类型灵活。vector 是标准库提供的一种类似数组的集合类型,它 允许 长度增长或缩小。如果你不确定该用数组还是 vector,那么很可能你应该用 vector。第八章会更详细地讨论 vector。

不过,当你明确知道元素个数不会变化时,数组就更有用。例如,如果你在程序中使用月份名称,那么你大概会选择数组而不是 vector,因为你知道它始终只有 12 个元素。

#![allow(unused)]
fn main() {
let months = ["January", "February", "March", "April", "May", "June", "July",
              "August", "September", "October", "November", "December"];
}

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

#![allow(unused)]
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
}

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

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

#![allow(unused)]
fn main() {
let a = [3; 5];
}

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

访问数组元素

数组是在栈(stack)上分配的一整块、大小已知且固定的内存。你可以像下面这样使用索引来访问数组中的元素:

文件名:src/main.rs

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

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

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

无效的数组元素访问

让我们看看如果尝试访问数组末尾之后的元素会发生什么。假设你运行下面这段代码,它类似于第 2 章中的猜数字游戏:从用户那里读取一个数组索引。

文件名:src/main.rs

use std::io;

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

    println!("Please enter an array index.");

    let mut index = String::new();

    io::stdin()
        .read_line(&mut index)
        .expect("Failed to read line");

    let index: usize = index
        .trim()
        .parse()
        .expect("Index entered was not a number");

    let element = a[index];

    println!("The value of the element at index {index} is: {element}");
}

这段代码能够成功编译。如果你用 cargo run 运行它,并输入 01234,程序就会打印出数组中对应索引位置的值。相反,如果你输入一个超出数组末尾的数字,比如 10,你就会看到像下面这样的输出:

thread 'main' panicked at src/main.rs:19:19:
index out of bounds: the len is 5 but the index is 10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

程序在索引操作中使用了无效值,因此产生了一个 运行时 错误。程序带着错误信息退出,并且没有执行最后那条 println! 语句。当你尝试通过索引访问元素时,Rust 会检查你指定的索引是否小于数组长度。如果索引大于或等于数组长度,Rust 就会 panic。这种检查必须在运行时完成,尤其是在这种场景下,因为编译器不可能知道用户之后运行代码时会输入什么值。

这是 Rust 内存安全原则在实践中的一个例子。在许多底层语言中,不会进行这种检查,因此如果你提供了错误的索引,就可能访问到无效内存。Rust 通过立即退出,而不是允许这次内存访问继续发生并让程序往下执行,来保护你免受这类错误的影响。第九章会更详细地讨论 Rust 的错误处理机制,以及如何编写既可读又安全的代码,让程序既不会 panic,也不会发生非法内存访问。

函数

函数

函数在 Rust 代码中非常普遍。你已经见过语言中最重要的函数之一:main 函数,它是很多程序的入口点。你也见过 fn 关键字,它用来声明新函数。

Rust 代码中的函数名和变量名通常使用 snake case 风格。在 snake case 中,所有字母都使用小写,并用下划线分隔单词。下面是一个包含函数定义示例的程序:

文件名:src/main.rs

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

    another_function();
}

fn another_function() {
    println!("Another function.");
}

我们在 Rust 中通过输入 fn 后面跟着函数名和一对圆括号来定义函数。大括号告诉编译器哪里是函数体的开始和结尾。

可以使用函数名后跟圆括号来调用我们定义过的任意函数。因为程序中已定义 another_function 函数,所以可以在 main 函数中调用它。注意,源码中 another_function 定义在 main 函数 之后;也可以定义在之前。Rust 不关心函数定义所在的位置,只要函数被调用时出现在调用之处可见的作用域内就行。

让我们新建一个叫做 functions 的二进制项目,来进一步探索函数。把上面的 another_function 示例放到 src/main.rs 中并运行。你应该会看到如下输出:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.28s
     Running `target/debug/functions`
Hello, world!
Another function.

这些代码行会按照它们在 main 函数中出现的顺序执行。首先打印 “Hello, world!”,然后调用 another_function 并打印它的消息。

参数

我们可以定义带有 参数parameters)的函数,参数是特殊变量,是函数签名的一部分。当函数带有参数时,你就可以为这些参数提供具体的值。从严格意义上说,这些具体值叫做 arguments,不过在日常交流中,人们通常会把 parameterargument 混用,用来指函数定义中的变量,或调用函数时传入的具体值。

在这个版本的 another_function 中,我们增加了一个参数:

文件名:src/main.rs

fn main() {
    another_function(5);
}

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

尝试运行程序,将会输出如下内容:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.21s
     Running `target/debug/functions`
The value of x is: 5

another_function 的声明中有一个命名为 x 的参数。x 的类型被指定为 i32。当我们将 5 传给 another_function 时,println! 宏会把 5 放在格式字符串中包含 x 的那对花括号的位置。

在函数签名中,必须 声明每个参数的类型。这是 Rust 设计中经过深思熟虑的一个决定:要求在函数定义里提供类型注解,意味着编译器几乎不再需要你在代码的其他地方额外标明类型来表达意图。而且,如果编译器知道函数期望什么类型,就能给出更有帮助的错误信息。

当定义多个参数时,使用逗号分隔,像这样:

文件名:src/main.rs

fn main() {
    print_labeled_measurement(5, 'h');
}

fn print_labeled_measurement(value: i32, unit_label: char) {
    println!("The measurement is: {value}{unit_label}");
}

这个例子创建了一个名为 print_labeled_measurement 的函数,它有两个参数。第一个参数名为 value,类型是 i32。第二个参数是 unit_label ,类型是 char。然后,该函数打印包含 valueunit_label 的文本。

尝试运行代码。使用上面的例子替换当前 functions 项目的 src/main.rs 文件,并用 cargo run 运行它:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/functions`
The measurement is: 5h

因为我们使用 5 作为 value 的值,h 作为 unit_label 的值来调用函数,所以程序输出包含这些值。

语句和表达式

函数体由一系列语句组成,并且可以选择以一个表达式结束。到目前为止,我们讲过的函数还没有包含结尾表达式,不过你已经见过出现在语句中的表达式了。由于 Rust 是一门基于表达式(expression-based)的语言,理解这一点非常重要。其他语言通常没有这种区分,所以让我们看看语句和表达式分别是什么,以及它们的差异会如何影响函数体。

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

让我们看一些例子。

实际上,我们已经使用过语句和表达式。使用 let 关键字创建变量并绑定一个值是一个语句。在示例 3-1 中,let y = 6; 是一个语句。

文件名:src/main.rs

fn main() {
    let y = 6;
}

示例 3-1:包含一个语句的 main 函数定义

函数定义本身也是语句,因此前面的整个例子本身也是一条语句。(不过,正如我们稍后会看到的,调用函数并不是语句。)

语句不返回值。因此,不能把 let 语句赋值给另一个变量,比如下面的例子尝试做的,会产生一个错误:

文件名:src/main.rs

fn main() {
    let x = (let y = 6);
}

当运行这个程序时,会得到如下错误:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error: expected expression, found `let` statement
 --> src/main.rs:2:14
  |
2 |     let x = (let y = 6);
  |              ^^^
  |
  = note: only supported directly in conditions of `if` and `while` expressions

warning: unnecessary parentheses around assigned value
 --> src/main.rs:2:13
  |
2 |     let x = (let y = 6);
  |             ^         ^
  |
  = note: `#[warn(unused_parens)]` on by default
help: remove these parentheses
  |
2 -     let x = (let y = 6);
2 +     let x = let y = 6;
  |

warning: `functions` (bin "functions") generated 1 warning
error: could not compile `functions` (bin "functions") due to 1 previous error; 1 warning emitted

let y = 6 这条语句不会返回值,因此没有什么东西可以绑定到 x 上。这和一些其他语言不同,比如 C 和 Ruby,在那些语言里,赋值语句会返回被赋的值。因此,在那些语言中你可以写 x = y = 6,让 xy 都得到值 6;但 Rust 不是这样。

表达式会计算出一个值,并且你将编写的大部分 Rust 代码是由表达式组成的。考虑一个数学运算,比如 5 + 6,这是一个表达式并计算出值 11。表达式可以是语句的一部分:在示例 3-1 中,语句 let y = 6; 中的 6 是一个表达式,它计算出的值是 6。函数调用是一个表达式。宏调用是一个表达式。用大括号创建的一个新的块作用域也是一个表达式,例如:

文件名:src/main.rs

fn main() {
    let y = {
        let x = 3;
        x + 1
    };

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

这个表达式:

{
    let x = 3;
    x + 1
}

是一个代码块,它的值是 4。这个值作为 let 语句的一部分被绑定到 y 上。注意 x + 1 这一行在结尾没有分号,与你见过的大部分代码行不同。表达式的结尾没有分号。如果在表达式的结尾加上分号,它就变成了语句,而语句不会返回值。在接下来探索具有返回值的函数和表达式时要谨记这一点。

具有返回值的函数

函数可以把值返回给调用它的代码。我们不会给返回值命名,但必须在箭头(->)后面声明它的类型。在 Rust 中,函数的返回值等同于函数体中最后一个表达式的值。你也可以使用 return 关键字并指定一个值,从函数中提前返回;不过大多数函数都会隐式返回最后一个表达式的值。下面是一个带有返回值的函数示例:

文件名:src/main.rs

fn five() -> i32 {
    5
}

fn main() {
    let x = five();

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

five 函数中,没有函数调用、没有宏,甚至连 let 语句都没有,只有单独一个数字 5。这在 Rust 中是一个完全合法的函数。注意,我们也指定了函数返回值的类型,即 -> i32。试着运行这段代码;输出应该如下所示:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/functions`
The value of x is: 5

five 函数的返回值是 5,所以返回类型是 i32。让我们更仔细地看看这段代码。有两个重要的点:首先,let x = five(); 这一行表明我们用函数的返回值来初始化一个变量。因为 five 返回的是 5,所以这一行与下面的代码等价:

#![allow(unused)]
fn main() {
let x = 5;
}

其次,five 函数没有参数,但定义了返回值类型,而且函数体里只有单独一个没有分号的 5,因为这是一个表达式,而我们想返回它的值。

让我们看看另一个例子:

文件名:src/main.rs

fn main() {
    let x = plus_one(5);

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

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

运行代码会打印出 The value of x is: 6。但如果在包含 x + 1 的行尾加上一个分号,把它从表达式变成语句,我们将看到一个错误。

文件名:src/main.rs

fn main() {
    let x = plus_one(5);

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

fn plus_one(x: i32) -> i32 {
    x + 1;
}

运行代码会产生一个错误,如下:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error[E0308]: mismatched types
 --> src/main.rs:7:24
  |
7 | fn plus_one(x: i32) -> i32 {
  |    --------            ^^^ expected `i32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
8 |     x + 1;
  |          - help: remove this semicolon to return this value

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

主要的错误信息 mismatched types(类型不匹配)揭示了这段代码的核心问题。plus_one 函数的定义表明它要返回一个 i32,但语句不会求值为某个值,语句对应的是单元类型 ()。因此,函数实际上没有返回值,这就与函数定义相矛盾,从而产生了错误。在这段输出中,Rust 还提供了一条可能有助于修复该问题的提示:它建议删除这个分号,而这么做确实能修复错误。

注释

注释

所有程序员都努力让自己的代码易于理解,不过有时仍然需要额外的解释。在这种情况下,程序员会在源码中留下 注释comments),编译器会忽略它们,但阅读源码的人可能会觉得这些注释很有帮助。

这是一个简单的注释:

#![allow(unused)]
fn main() {
// hello, world
}

在 Rust 中,惯用的注释风格是用两个斜杠开始一条注释,并让注释持续到该行末尾。对于跨越多行的注释,你需要在每一行前面都加上 //,像这样:

#![allow(unused)]
fn main() {
// So we’re doing something complicated here, long enough that we need
// multiple lines of comments to do it! Whew! Hopefully, this comment will
// explain what’s going on.
}

注释也可以放在包含代码的行的末尾:

文件名:src/main.rs

fn main() {
    let lucky_number = 7; // I'm feeling lucky today
}

不过,你更常见到的用法是把注释放在它所解释的代码上一行,像这样:

文件名:src/main.rs

fn main() {
    // I'm feeling lucky today
    let lucky_number = 7;
}

Rust 还有另一种注释,叫作文档注释,我们会在第十四章的“将 crate 发布到 Crates.io”部分讨论它。

控制流

控制流

根据条件是否为 true 来决定是否执行某些代码,以及在条件为 true 时重复执行某些代码的能力,是大多数编程语言的基本构件。Rust 中最常见的控制执行流的结构是 if 表达式和循环。

if 表达式

if 表达式允许根据条件执行不同的代码分支。你提供一个条件并表示 “如果条件满足,运行这段代码;如果条件不满足,不运行这段代码。”

projects 目录中创建一个名为 branches 的新项目,来体验 if 表达式。在 src/main.rs 文件中输入如下内容:

文件名:src/main.rs

fn main() {
    let number = 3;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

所有 if 表达式都以 if 关键字开头,后面紧跟一个条件。在这个例子中,条件会检查变量 number 的值是否小于 5。如果条件为 true,就执行紧跟在条件后面的大括号中的代码块。与 if 表达式中各个条件关联的代码块有时也被称为 arms,就像我们在第二章“比较猜测的数字和秘密数字”一节中讨论过的 match 表达式分支一样。

也可以包含一个可选的 else 表达式来提供一个在条件为 false 时应当执行的代码块,这里我们就这么做了。如果不提供 else 表达式并且条件为 false 时,程序会直接忽略 if 代码块并继续执行下面的代码。

尝试运行代码,应该能看到如下输出:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
condition was true

尝试改变 number 的值使条件为 false 时看看会发生什么:

fn main() {
    let number = 7;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

再次运行程序并查看输出:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
condition was false

还值得注意的是,条件必须bool 值。如果条件不是 bool,我们就会得到一个错误。例如,尝试运行下面这段代码:

文件名:src/main.rs

fn main() {
    let number = 3;

    if number {
        println!("number was three");
    }
}

这里 if 条件的值是 3,Rust 抛出了一个错误:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: mismatched types
 --> src/main.rs:4:8
  |
4 |     if number {
  |        ^^^^^^ expected `bool`, found integer

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

这个错误表明 Rust 期望得到的是一个 bool,却收到了一个整数。不同于 Ruby 或 JavaScript 这样的语言,Rust 不会自动尝试把非布尔类型转换成布尔类型。你必须显式地为 if 提供一个布尔值作为条件。例如,如果我们希望 if 代码块只在某个数字不等于 0 时运行,就可以把 if 表达式改成下面这样:

文件名:src/main.rs

fn main() {
    let number = 3;

    if number != 0 {
        println!("number was something other than zero");
    }
}

运行代码会打印出 number was something other than zero

使用 else if 处理多重条件

可以将 else if 表达式与 ifelse 组合来实现多重条件。例如:

文件名:src/main.rs

fn main() {
    let number = 6;

    if number % 4 == 0 {
        println!("number is divisible by 4");
    } else if number % 3 == 0 {
        println!("number is divisible by 3");
    } else if number % 2 == 0 {
        println!("number is divisible by 2");
    } else {
        println!("number is not divisible by 4, 3, or 2");
    }
}

这个程序有四个可能的执行路径。运行后应该能看到如下输出:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
number is divisible by 3

当执行这个程序时,它按顺序检查每个 if 表达式并执行第一个条件为 true 的代码块。注意即使 6 可以被 2 整除,也不会输出 number is divisible by 2,更不会输出 else 块中的 number is not divisible by 4, 3, or 2。原因是 Rust 只会执行第一个条件为 true 的代码块,并且一旦它找到一个以后,甚至都不会检查剩下的条件了。

使用过多的 else if 表达式会让代码显得杂乱,所以如果你有不止一个 else if,可能就该考虑重构代码了。针对这种情况,第六章会介绍一个强大的 Rust 分支结构(branching construct),叫做 match

let 语句中使用 if

因为 if 是一个表达式,我们可以在 let 语句的右侧使用它,例如在示例 3-2 中:

文件名:src/main.rs

fn main() {
    let condition = true;
    let number = if condition { 5 } else { 6 };

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

示例 3-2:将 if 表达式的返回值赋给一个变量

变量 number 会绑定到 if 表达式结果所产生的那个值。运行这段代码看看会发生什么:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/branches`
The value of number is: 5

记住,代码块的值就是其中最后一个表达式的值,而数字本身也是表达式。在这个例子中,整个 if 表达式的值取决于哪个代码块被执行。这意味着 if 的各个分支可能产生的结果值都必须是相同类型;在示例 3-2 中,if 分支和 else 分支的结果都是 i32 整数。如果类型不一致,就会像下面这个例子一样报错:

文件名:src/main.rs

fn main() {
    let condition = true;

    let number = if condition { 5 } else { "six" };

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

当编译这段代码时,会得到一个错误。ifelse 分支的值类型是不相容的,同时 Rust 也准确地指出在程序中的何处发现的这个问题:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: `if` and `else` have incompatible types
 --> src/main.rs:4:44
  |
4 |     let number = if condition { 5 } else { "six" };
  |                                 -          ^^^^^ expected integer, found `&str`
  |                                 |
  |                                 expected because of this

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

if 代码块中的表达式会求值为一个整数,而 else 代码块中的表达式会求值为一个字符串。这是行不通的,因为变量必须只有一个类型。Rust 需要在编译时就明确知道 number 的类型,这样它才能在编译阶段验证每一处对 number 的使用是否合法。如果 number 的类型只能在运行时确定,Rust 就无法做到这一点;而如果编译器必须为每个变量跟踪多种假设类型,它也会变得更加复杂,并且对代码的保证会更少。

使用循环重复执行

反复执行同一段代码是一件很常见的事,为此 Rust 提供了多种 循环loops)。循环会执行循环体中的代码直到结尾,然后立即回到开头继续执行。为了体验循环,我们来新建一个叫做 loops 的项目。

Rust 有三种循环:loopwhilefor。我们每一个都试试。

使用 loop 重复执行代码

loop 关键字告诉 Rust 反复执行一段代码,要么永远执行下去,要么直到你明确要求它停止。

作为一个例子,将 loops 目录中的 src/main.rs 文件修改为如下:

文件名:src/main.rs

fn main() {
    loop {
        println!("again!");
    }
}

运行这个程序时,我们会看到 again! 被不断重复打印,直到我们手动停止程序。大多数终端都支持使用快捷键 ctrl-C 来中断一个陷入无限循环的程序。试试看:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
     Running `target/debug/loops`
again!
again!
again!
again!
^Cagain!

符号 ^C 表示你在这里按下了 ctrl-C。在 ^C 后面,你可能会看到,也可能不会看到 again!,这取决于代码在收到中断信号时正执行到循环的哪个位置。

幸运的是,Rust 也提供了在代码中跳出循环的方法。你可以在循环中放置 break 关键字,告诉程序何时停止执行该循环。回忆一下,我们曾在第二章猜数字游戏的“猜测正确后退出”一节中使用过它,让程序在用户猜中数字后退出。

我们在猜数字游戏中也使用过 continue。在循环里,continue 关键字会告诉程序跳过本次循环迭代剩余的代码,并直接进入下一次迭代。

从循环返回值

loop 的一个用途是重试那些你知道可能失败的操作,比如检查某个线程是否完成了任务。不过,你也可能希望把这个操作的结果传递给其他代码。为此,你可以在用于停止循环的 break 表达式后面加上想要返回的值;这个值会作为循环的返回值返回出来,因而你就可以使用它,如下所示:

fn main() {
    let mut counter = 0;

    let result = loop {
        counter += 1;

        if counter == 10 {
            break counter * 2;
        }
    };

    println!("The result is {result}");
}

在循环之前,我们声明了一个名为 counter 的变量,并将其初始化为 0。然后,又声明了一个名为 result 的变量,用来保存循环返回的值。在循环的每次迭代中,我们都会给 counter1,然后检查它是否等于 10。当条件满足时,就用 break 关键字返回 counter * 2 的值。循环结束后,我们用分号结束把值赋给 result 的那条语句。最后,打印出 result 的值,也就是 20

如果你在循环内部使用 return,也可以从中返回。不过,break 只会退出当前循环,而 return 总是会退出当前函数。

循环标签:在多个循环之间消除歧义

如果循环中又套了循环,那么 breakcontinue 默认只作用于当前最内层的那个循环。你可以选择给某个循环加上一个 循环标签loop label),然后把这个标签和 breakcontinue 一起使用,这样这些关键字就会作用于被标记的循环,而不是最内层循环。下面是一个包含两层嵌套循环的例子:

fn main() {
    let mut count = 0;
    'counting_up: loop {
        println!("count = {count}");
        let mut remaining = 10;

        loop {
            println!("remaining = {remaining}");
            if remaining == 9 {
                break;
            }
            if count == 2 {
                break 'counting_up;
            }
            remaining -= 1;
        }

        count += 1;
    }
    println!("End count = {count}");
}

外层循环带有标签 'counting_up,它会从 0 数到 2。没有标签的内层循环则从 10 倒数到 9。第一个没有指定标签的 break 只会退出内层循环。语句 break 'counting_up; 则会退出外层循环。这段代码会打印:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running `target/debug/loops`
count = 0
remaining = 10
remaining = 9
count = 1
remaining = 10
remaining = 9
count = 2
remaining = 10
End count = 2

while 条件循环

程序经常需要在循环中计算某个条件:只要条件为 true,循环就继续;当条件不再为 true 时,程序就会调用 break 来停止循环。这种循环类型可以通过组合 loopifelsebreak 来实现;如果你愿意,现在就可以在程序里试试看。不过,这种模式实在太常见了,所以 Rust 为它内置了一个语言结构,叫做 while 循环。在示例 3-3 中,我们使用 while 让程序循环三次,每次计数都减一;之后,在循环结束后打印另一条消息并退出。

文件名:src/main.rs

fn main() {
    let mut number = 3;

    while number != 0 {
        println!("{number}!");

        number -= 1;
    }

    println!("LIFTOFF!!!");
}

示例 3-3: 当条件为 true 时,使用 while 循环运行代码

这种结构消除了使用 loopifelsebreak 时原本需要的大量嵌套,因此代码会更清晰。只要条件求值为 true,代码就会继续执行;否则就退出循环。

使用 for 遍历集合

可以使用 while 结构来遍历集合中的元素,比如数组。例如,示例 3-4 中的循环会打印数组 a 中的每一个元素。

文件名:src/main.rs

fn main() {
    let a = [10, 20, 30, 40, 50];
    let mut index = 0;

    while index < 5 {
        println!("the value is: {}", a[index]);

        index += 1;
    }
}

示例 3-4:使用 while 循环遍历集合中的每一个元素

这里,代码对数组中的元素进行计数。它从索引 0 开始,并接着循环直到遇到数组的最后一个索引(这时,index < 5 不再为 true)。运行这段代码会打印出数组中的每一个元素:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.32s
     Running `target/debug/loops`
the value is: 10
the value is: 20
the value is: 30
the value is: 40
the value is: 50

数组中的所有五个元素都如期出现在终端中。尽管 index 在某一时刻会到达值 5,不过循环在其尝试从数组获取第六个值(会越界)之前就停止了。

不过,这种方式很容易出错;如果索引值或测试条件写错了,就会导致程序 panic。例如,如果你把数组 a 改成只有 4 个元素,却忘了把条件更新成 while index < 4,代码就会 panic。它也会让程序变慢,因为编译器会加入运行时代码,在每次循环迭代时检查索引是否仍然位于数组边界之内。

作为更简洁的替代方案,可以使用 for 循环来对一个集合的每个元素执行一些代码。for 循环看起来如示例 3-5 所示:

文件名:src/main.rs

fn main() {
    let a = [10, 20, 30, 40, 50];

    for element in a {
        println!("the value is: {element}");
    }
}

示例 3-5:使用 for 循环遍历集合中的元素

运行这段代码时,你会看到和示例 3-4 相同的输出。更重要的是,我们提高了代码的安全性,并消除了那种可能因为越过数组末尾,或遍历不够完整而漏掉某些元素所导致的 bug。

例如,在示例 3-4 的代码中,如果你把数组 a 改成只有 4 个元素,却忘了把条件更新为 while index < 4,代码就会 panic。而使用 for 循环时,你就不必记着在修改数组元素个数时还要同步修改其他代码了。

for 循环的安全性和简洁性,使它成为 Rust 中最常用的循环结构。即使是在你只想把某段代码执行特定次数的情况下,比如示例 3-3 里那个使用 while 的倒计时例子,大多数 Rustaceans 也会选择使用 for 循环。实现这种写法的方式是使用 Range,这是标准库提供的一种类型,用来生成从某个数字开始、到另一个数字之前结束的所有数字序列。

下面是一个使用 for 循环来倒计时的例子,它还用到了一个我们尚未讲到的方法 rev,用于反转 range。

文件名:src/main.rs

fn main() {
    for number in (1..4).rev() {
        println!("{number}!");
    }
    println!("LIFTOFF!!!");
}

这段代码是不是更好一些?

总结

你做到了!这是内容相当丰富的一章:你学习了变量、标量和复合数据类型、函数、注释、if 表达式以及循环!如果你想练习本章讨论的概念,可以尝试构建下面这些程序:

  • 相互转换摄氏与华氏温度。
  • 生成第 n 个斐波那契数。
  • 打印圣诞颂歌 “The Twelve Days of Christmas” 的歌词,并利用歌曲中的重复部分(通过编写循环)。

当你准备好继续时,我们将讨论一个在其他编程语言中并不常见的概念:所有权(ownership)。

评论 (0)