CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
  • C++语言面试问题集锦
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • 🔥C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 🔥从零用C语言写一个Redis
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go语言特性

    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 🔥使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
  • C++语言面试问题集锦
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • 🔥C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 🔥从零用C语言写一个Redis
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go语言特性

    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 🔥使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • 第1章 系统程序员也能享受美好事物
  • 第2章 Rust概览
  • 第3章 基本类型
  • 第4章 所有权与移动
  • 第5章 引用
  • 第6章 表达式
    • 表达式语言
    • 优先级和结合性
    • 代码块和分号
    • 声明
    • if 和 match
    • if let
    • 循环
    • 循环中的控制流
    • return表达式
    • Rust为什么有loop
    • 函数和方法调用
    • 字段和元素
    • 引用运算符
    • 算术、按位、比较和逻辑运算符
    • 赋值
    • 类型转换
    • 闭包
    • 展望
  • 第7章 错误处理
  • 第8章 包和模块
  • 第9章 结构体
  • 第10章 枚举和模式
  • 第11章 特性与泛型
  • 第12章 运算符重载
  • 第13章 实用特性
  • 第14章 闭包
  • 第15章 迭代器
  • 第16章 集合
  • 第17章 字符串和文本
  • 第18章 输入与输出
  • 第19章 并发
  • 第20章 异步编程
  • Rust编程指南
zhangxf
2025-03-10
目录

第6章 表达式

# 第6章 表达式

LISP程序员知道万物的价值,却不知其代价。

​ ——艾伦·佩利斯(Alan Perlis),警句#55

在本章中,我们将介绍Rust的表达式。表达式是构成Rust函数主体的基本单元,因此也是Rust代码的主要组成部分。在Rust中,大多数内容都是表达式。在本章中,我们将探索这一特性带来的强大功能,以及如何应对其局限性。我们还会介绍控制流,在Rust中,控制流完全以表达式为导向,同时也会探讨Rust的基础运算符单独使用和组合使用时的工作方式。

从技术上讲,有些概念属于这一范畴,比如闭包(closures)和迭代器(iterators),它们的内容较为深入,我们会在后面专门用一整章来讲解。目前,我们的目标是在几页内容中尽可能多地介绍语法知识。

# 表达式语言

Rust在外观上与C语言家族的语言相似,但这有点误导人。在C语言中,表达式和语句有明显的区别,表达式看起来像这样:

5 * (fahr - 32) / 9
1

而语句则更像这样:

for (; begin != end; ++begin) {
    if (*begin == target)
        break;
}
1
2
3
4

表达式有值,语句没有。

Rust是一种所谓的表达式语言。这意味着它遵循一种更古老的传统,其起源可以追溯到Lisp语言,在这种语言里,表达式承担了所有的工作。

在C语言中,if和switch是语句。它们不会产生值,也不能用在表达式中间。在Rust中,if和match可以产生值。我们在第2章中已经见过一个产生数值的match表达式:

pixels[r * bounds.0 + c] =
    match escapes(Complex { re: point.0, im: point.1 }, 255) {
        None => 0,
        Some(count) => 255 - count as u8
    };
1
2
3
4
5

if表达式可以用来初始化变量:

let status =
    if cpu.temperature <= MAX_TEMP { HttpStatus::Ok }
    else {
        HttpStatus::ServerError // server melted
    };
1
2
3
4
5

match表达式可以作为参数传递给函数或宏:

println!("Inside the vat, you see {}.",
    match vat.contents {
        Some(brain) => brain.desc(),
        None => "nothing of interest"
    });
1
2
3
4
5

这就解释了为什么Rust没有C语言中的三元运算符(expr1 ? expr2 : expr3)。在C语言中,它是一种方便的表达式级别的if语句替代品。但在Rust中,它是多余的,因为if表达式可以处理这两种情况。

C语言中的大多数控制流工具都是语句,而在Rust中,它们都是表达式。

# 优先级和结合性

表6-1总结了Rust的表达式语法。我们将在本章中讨论所有这些类型的表达式。

运算符按照优先级从高到低的顺序列出。(和大多数编程语言一样,Rust通过运算符优先级来确定当一个表达式包含多个相邻运算符时的运算顺序。例如,在limit < 2 * broom.size + 1中,.运算符的优先级最高,所以会先进行字段访问。)

表6-1 表达式

表达式类型 示例 相关trait
数组字面量 [1, 2, 3] -
重复数组字面量 [0; 50] -
元组 (6, "crullers") -
分组 (2 + 2) -
代码块 { f(); g() } -
控制流表达式 if ok { f() }
if ok { 1 } else { 0 }
if let Some(x) = f() { x } else { 0 }
match x { None => 0, _ => 1 }
for v in e { f(v); }
while ok { ok = f(); }
while let Some(x) = it.next() { f(x); }
loop { next_event(); }
break
continue
return 0
std::iter::IntoIterator等
宏调用 println!("ok") -
路径 std::f64::consts::PI -
结构体字面量 Point {x: 0, y: 0} -
元组字段访问 pair.0 -
结构体字段访问 point.x -
方法调用 point.translate(50, 50) -
函数调用 stdin() -
索引 arr[0] Index, IndexMut
错误检查 create_dir("tmp")? -
逻辑/按位取反 !ok -
取负 -num Neg
解引用 *ptr Deref, DerefMut
借用 &val -
类型转换 x as u32 -
乘法 n * 2 Mul
除法 n / 2 Div
取余(求模) n % 2 Rem
加法 n + 1 Add
减法 n - 1 Sub
左移 n << 1 Shl
右移 n >> 1 Shr
按位与 n & 1 BitAnd
按位异或 n ^ 1 BitXor
按位或 n | 1 BitOr
小于 n < 1 std::cmp::PartialOrd
小于等于 n <= 1 std::cmp::PartialOrd
大于 n > 1 std::cmp::PartialOrd
大于等于 n >= 1 std::cmp::PartialOrd
等于 n == 1 std::cmp::PartialEq
不等于 n != 1 std::cmp::PartialEq
逻辑与 x.ok && y.ok -
逻辑或 x.ok | backup.ok -
半开区间 start..stop -
闭区间 start...=stop -
赋值 x = val -
复合赋值 x *= 1
x /= 1
x %= 1
x += 1
x -= 1
x <<= 1
x >>= 1
x &= 1
x ^= 1
x |= 1
MulAssign
DivAssign
RemAssign
AddAssign
SubAssign
ShlAssign
ShrAssign
BitAndAssign
BitXorAssign
BitOrAssign
闭包 |x, y| x + y -

所有可以有效连接使用的运算符都是左结合的。也就是说,像a - b - c这样的运算链会被分组为(a - b) - c,而不是a - (b - c)。可以这样连接使用的运算符都是你能想到的那些:

*   /   %   +   -   <<   >>   &   ^   |   &&   ||   as
1

比较运算符、赋值运算符以及区间运算符..和...=根本不能连接使用。

# 代码块和分号

代码块是最通用的表达式类型。代码块会产生一个值,并且可以在任何需要值的地方使用:

let display_name = match post.author() {
    Some(author) => author.name(),
    None => {
        let network_info = post.get_network_metadata()?;
        let ip = network_info.client_address();
        ip.to_string()
    }
};
1
2
3
4
5
6
7
8

Some(author) =>后面的代码是简单表达式author.name()。None =>后面的代码是一个代码块表达式。对Rust来说,这没有区别。代码块的值就是其最后一个表达式的值,即ip.to_string()。

注意,在ip.to_string()方法调用后面没有分号。大多数Rust代码行的结尾要么是分号,要么是花括号,就像C或Java一样。如果一个代码块看起来像C代码,在所有熟悉的地方都有分号,那么它的运行方式就和C代码块一样,其值将是()。

正如我们在第2章提到的,当你省略代码块最后一行的分号时,代码块的值就会变成其最后一个表达式的值,而不是通常的()。

在一些语言中,特别是JavaScript,允许省略分号,语言会自动帮你补上,这算是个小便利。但Rust的情况有所不同。在Rust中,分号实际上有特定的含义:

let msg = {
    // let声明:分号始终是必需的
    let dandelion_control = puffball.open();
    // 表达式 + 分号:方法被调用,返回值被丢弃
    dandelion_control.release_all_seeds(launch_codes);
    // 没有分号的表达式:方法被调用,返回值存储在`msg`中
    dandelion_control.get_status()
};
1
2
3
4
5
6
7
8

代码块既能包含声明,又能在最后产生一个值,这是个很棒的特性,很快你就会觉得习以为常。唯一的缺点是,如果你不小心遗漏了分号,会得到一个奇怪的错误信息:

...
if (preferences.changed()) {
    page.compute_size() // oops, missing semicolon
}
...
1
2
3
4
5

如果你在C或Java程序中犯了这个错误,编译器会直接指出你遗漏了分号。但Rust会这样提示:

error[E0308]: mismatched types
^^^^^^^^^^^^^^^^^^^- help: try adding a semicolon:
22 |                              page.compute_size()  // oops, missing semicolon
` `
;
|         |
|                              expected (), found tuple
= note: expected unit type `()`
found tuple `(u32, u32)`
1
2
3
4
5
6
7
8
9

由于遗漏了分号,代码块的值将是page.compute_size()的返回值,但没有else分支的if表达式必须始终返回()。幸运的是,Rust之前见过这种情况,并建议添加分号。

# 声明

除了表达式和分号,代码块还可以包含任意数量的声明。最常见的是let声明,用于声明局部变量:

let name: type = expr;
1

类型和初始化器是可选的,分号是必需的。

let声明可以声明一个变量而不进行初始化,之后可以通过赋值来初始化该变量。这种情况偶尔会很有用,因为有时变量需要在某种控制流结构的中间进行初始化:

let name;
if user.has_nickname() {
    name = user.nickname();
} else {
    name = generate_unique_name();
    user.register(&name);
}
1
2
3
4
5
6
7

这里局部变量name有两种不同的初始化方式,但无论哪种方式,它都只会被初始化一次,所以name不需要声明为mut可变的。

在变量初始化之前使用它是错误的。(这与在值被移动后使用该值的错误密切相关。Rust真的希望你只在值存在的时候使用它们!)

你可能偶尔会看到看起来像是重新声明现有变量的代码,比如这样:

for line in file.lines() {
    let line = line?;
    ...
}
1
2
3
4

这里的let声明创建了一个新的、不同类型的变量。第一个变量line的类型是Result<String, io::Error>,第二个line是String类型。在代码块的剩余部分,第二个line的定义取代了第一个。

这被称为变量遮蔽(shadowing),在Rust程序中非常常见。这段代码等价于:

for line_result in file.lines() {
    let line = line_result?;
    ...
}
1
2
3
4

在本书中,在这种情况下我们会坚持使用_result后缀,以便变量有不同的名字。

代码块还可以包含项声明(item declaration)。项(item)就是任何可以在程序或模块的全局作用域中出现的声明,比如fn(函数)、struct(结构体)或use。

后面的章节会详细介绍项。目前,fn就是一个很好的例子。任何代码块都可以包含一个fn:

use std::io;
use std::cmp::Ordering;

fn show_files() -> io::Result<()> {
    let mut v = vec![];
    ...
    fn cmp_by_timestamp_then_name(a: &FileInfo, b: &FileInfo) -> Ordering {
        a.timestamp.cmp(&b.timestamp) // 首先,比较时间戳
            .reverse() // 最新的文件在前
            .then(a.path.cmp(&b.path)) // 比较路径以区分相同时间戳的情况
    }
    v.sort_by(cmp_by_timestamp_then_name);
    ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

当在代码块内部声明一个fn时,它的作用域是整个代码块,也就是说,它可以在包含它的代码块中被使用。但是,嵌套的fn不能访问碰巧在作用域内的局部变量或参数。例如,函数cmp_by_timestamp_then_name不能直接使用v。(Rust还有闭包(closures),它可以访问外部作用域。详见第14章。)

代码块甚至可以包含整个模块。这可能看起来有点多余——我们真的需要把语言的每个部分都嵌套在其他部分里面吗?但程序员(特别是使用宏的程序员)总有办法利用语言提供的每一点正交性。

# if 和 match

if表达式的形式很常见:

if condition1 {
    block1
} else if condition2 {
    block2
} else {
    block_n
}
1
2
3
4
5
6
7

每个条件都必须是类型为bool的表达式;Rust不会像C语言那样将数字或指针隐式转换为布尔值。

与C语言不同,条件周围不需要括号。实际上,如果存在不必要的括号,rustc会发出警告。不过,花括号是必需的。

else if代码块以及最后的else代码块都是可选的。没有else代码块的if表达式,其行为就如同有一个空的else代码块。

match表达式有点像C语言的switch语句,但更灵活。一个简单的例子:

match code {
    0 => println!("OK"),
    1 => println!("Wires Tangled"),
    2 => println!("User Asleep"),
    _ => println!("Unrecognized Error {}", code)
}
1
2
3
4
5
6

这是switch语句也能做到的。根据code的值,这个match表达式的四个分支中恰好会有一个被执行。通配符模式_可以匹配任何值,这类似于switch语句中的default:分支,不过它必须放在最后;如果将_模式放在其他模式之前,意味着它会优先匹配,而其他模式将永远不会匹配任何值(编译器会对此发出警告)。

编译器可以像C++中的switch语句一样,使用跳转表(jump table)对这种match进行优化。当match的每个分支都产生一个常量值时,也会应用类似的优化。在这种情况下,编译器会构建一个包含这些值的数组,match表达式会被编译成数组访问。除了边界检查,编译后的代码中根本没有分支。

match的多功能性源于它支持各种模式,这些模式可以用在每个分支中=>的左边。在上面的例子中,每个模式只是一个常量整数。我们还展示过区分Option两种值的match表达式:

match params.get("name") {
    Some(name) => println!("Hello, {}!", name),
    None => println!("Greetings, stranger.")
}
1
2
3
4

这只是模式功能的冰山一角。模式可以匹配一个值的范围、解包元组、匹配结构体的各个字段、追踪引用、借用值的一部分等等。Rust的模式本身就是一种小型语言。我们会在第10章用几页的篇幅专门介绍它们。

match表达式的一般形式是:

match value {
    pattern => expr,
    ...
}
1
2
3
4

如果expr是一个代码块,分支后面的逗号可以省略。

Rust会依次将给定的值与每个模式进行匹配,从第一个模式开始。当一个模式匹配时,会计算相应的expr,match表达式就完成了,不再检查其他模式。至少有一个模式必须匹配,Rust不允许match表达式遗漏任何可能的值:

let score = match card.rank {
    Jack => 10,
    Queen => 10,
    Ace => 11
}; // error: nonexhaustive patterns
1
2
3
4
5

if表达式的所有代码块必须产生相同类型的值:

let suggested_pet =
    if with_wings { Pet::Buzzard } else { Pet::Hyena };  // ok
let favorite_number =
    if user.is_hobbit() { "eleventy-one" } else { 9 };  // error
let best_sports_team =
    if is_hockey_season() { "Predators" };  // error
1
2
3
4
5
6

(最后一个例子是错误的,因为在7月,结果会是()。)

类似地,match表达式的所有分支必须具有相同的类型:

let suggested_pet =
    match favorites.element {
        Fire => Pet::RedPanda,
        Air => Pet::Buffalo,
        Water => Pet::Orca,
        _ => None // error: incompatible types
    };
1
2
3
4
5
6
7

# if let

还有一种if形式,即if let表达式:

if let pattern = expr {
    block1
} else {
    block2
}
1
2
3
4
5

给定的expr要么与模式匹配,这种情况下会运行block1;要么不匹配,这种情况下会运行block2。

有时,这是从Option或Result中获取数据的好方法:

if let Some(cookie) = request.session_cookie {
    return restore_session(cookie);
}
if let Err(err) = show_cheesy_anti_robot_task() {
    log_robot_attempt(err);
    politely_accuse_user_of_being_a_robot();
} else {
    session.mark_as_human();
}
1
2
3
4
5
6
7
8
9

使用if let并不是绝对必要的,因为match可以完成if let能做的所有事情。if let表达式是只有一个模式的match的简写形式:

match expr {
    pattern => {
        block1
    },
    _ => {
        block2
    }
}
1
2
3
4
5
6
7
8

# 循环

Rust中有四种循环表达式:

while condition {
    block
}
while let pattern = expr {
    block
}
loop {
    block
}
for pattern in iterable {
    block
}
1
2
3
4
5
6
7
8
9
10
11
12

在Rust中,循环是表达式,但while或for循环的值始终是(),所以它们的值不是很有用。如果指定了返回值,loop表达式可以产生一个值。

while循环的行为与C语言中的while循环完全一样,只是条件的类型必须是精确的bool类型。

while let循环类似于if let。在每次循环迭代开始时,expr的值要么与给定的模式匹配,这种情况下会运行代码块;要么不匹配,这种情况下循环退出。

使用loop可以编写无限循环。它会不断重复执行代码块(直到遇到break、return,或者线程发生恐慌(panics))。

for循环会计算iterable表达式,然后对结果迭代器中的每个值执行一次代码块。许多类型都可以被迭代,包括所有标准集合,如Vec和HashMap。标准C语言的for循环:

for (int i = 0; i < 20; i++) {
    printf("%d\n ", i);
}
1
2
3

在Rust中这样写:

for i in 0..20 {
    println!("{}", i);
}
1
2
3

和C语言一样,最后打印的数字是19。

..运算符生成一个范围(range),这是一个有两个字段的简单结构体:start和end。0..20等同于std::ops::Range { start: 0, end: 20 }。范围可以用于for循环,因为Range是一个可迭代类型,它实现了std::iter::IntoIterator特性(trait),我们会在第15章讨论这个特性。标准集合、数组和切片都是可迭代的。

与Rust的移动语义(move semantics)一致,对一个值进行for循环会消耗这个值:

let strings: Vec<String> = error_messages();
for s in strings {                // 每个String被移动到这里...
    println!("{}", s);
}                                   // ...并在这里被丢弃
println!("{} error(s)", strings.len()); // error: use of moved value
1
2
3
4
5

这可能不太方便。简单的解决方法是对集合的引用进行循环。这样,循环变量将是对集合中每个元素的引用:

for rs in &strings {
    println!("String {:?} is at address {:p}.", *rs, rs);
}
1
2
3

这里&strings的类型是&Vec<String>,rs的类型是&String。

对可变引用进行循环会为每个元素提供一个可变引用:

for rs in &mut strings { // rs的类型是&mut String
    rs.push('\n');  // 给每个字符串添加一个换行符
}
1
2
3

第15章会更详细地介绍for循环,并展示使用迭代器的许多其他方法。

# 循环中的控制流

break表达式会退出包含它的循环。(在Rust中,break只在循环中起作用。在match表达式中不需要它,在这方面match表达式与switch语句不同。)

在循环体中,你可以给break一个表达式,这个表达式的值将成为循环的值:

// 每次调用`next_line`要么返回`Some(line)`,其中`line`是一行输入,
// 要么返回`None`,表示我们已经到达输入的末尾。返回以"answer: "开头的第一行,
// 否则,返回"answer: nothing"。
let answer = loop {
    if let Some(line) = next_line() {
        if line.starts_with("answer: ") {
            break line;
        }
    } else {
        break "answer: nothing";
    }
};
1
2
3
4
5
6
7
8
9
10
11
12

自然地,循环中的所有break表达式必须产生相同类型的值,这个类型将成为循环本身的类型。

continue表达式会跳转到下一次循环迭代:

// 逐行读取一些数据。
for line in input_lines {
    let trimmed = trim_comments_and_whitespace(line);
    if trimmed.is_empty() {
        // 跳回到循环顶部,继续读取下一行输入。
        continue;
    }
    ...
}
1
2
3
4
5
6
7
8
9

在for循环中,continue会移动到集合中的下一个值。如果没有更多的值,循环就会退出。

类似地,在while循环中,continue会重新检查循环条件。如果条件现在为false,循环就会退出。

循环可以用生命周期进行标记。在下面的例子中,'search:是外部for循环的标签。因此,break 'search会退出外部循环,而不是内部循环:

'search:
for room in apartment {
    for spot in room.hiding_spots() {
        if spot.contains(keys) {
            println!("Your keys are {} in the {}.", spot, room);
            break 'search;
        }
    }
}
1
2
3
4
5
6
7
8
9

break可以同时有标签和值表达式:

// 找到数列中第一个完全平方数的平方根。
let sqrt = 'outer: loop {
    let n = next_number();
    for i in 1.. {
        let square = i * i;
        if square == n {
            // 找到了平方根。
            break 'outer i;
        }
        if square > n {
            // `n`不是完全平方数,尝试下一个。
            break;
        }
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

标签也可以与continue一起使用。

# return表达式

return表达式用于退出当前函数,并将一个值返回给调用者。

不带值的return是return ()的简写形式:

fn f() {     // 返回类型省略:默认为()
    return; // 返回值省略:默认为()
}
1
2
3

函数并非必须要有显式的return表达式。函数体的工作方式类似于代码块表达式:如果最后一个表达式后面没有分号,那么它的值就是函数的返回值。实际上,在Rust中,这是提供函数返回值的首选方式。

但这并不意味着return没有用处,也不是仅仅为了照顾那些不熟悉表达式语言的用户。和break表达式一样,return可以中断正在进行的工作。例如,在第2章中,我们在调用一个可能失败的函数后,使用?运算符来检查错误:

let output = File::create(filename)?;
1

我们解释过,这是match表达式的简写形式:

let output = match File::create(filename) {
    Ok(f) => f,
    Err(err) => return Err(err)
};
1
2
3
4

这段代码首先调用File::create(filename)。如果返回Ok(f),那么整个match表达式的值就是f,f会被存储到output中,然后我们继续执行match表达式后面的下一行代码。

否则,我们会匹配到Err(err),并执行return表达式。当这种情况发生时,即便我们正处于计算match表达式以确定变量output值的过程中,也没关系。我们会放弃所有这些操作,退出包含该match的函数,并返回从File::create()得到的任何错误。

我们将在“传播错误”部分更全面地介绍?运算符。

# Rust为什么有loop

Rust编译器的几个部分会分析程序中的控制流:

  • Rust会检查函数的每一条执行路径是否都返回预期返回类型的值。为了正确做到这一点,它需要知道是否有可能到达函数的末尾。
  • Rust会检查局部变量是否从未在未初始化的情况下被使用。这需要检查函数的每一条执行路径,确保不存在在未经过初始化变量的代码就使用该变量的情况。
  • Rust会对无法到达的代码发出警告。如果函数的任何执行路径都无法到达某段代码,那么这段代码就是无法到达的。

这些被称为流敏感分析(flow-sensitive analyses)。这并不是什么新东西,多年来,Java就有类似Rust的“明确赋值”分析。

在执行这类规则时,一种语言必须在简单性和智能性之间取得平衡。简单性有时能让程序员更容易理解编译器在说什么;而智能性则有助于消除误报,以及避免编译器拒绝完全安全的程序的情况。Rust选择了简单性。它的流敏感分析根本不会检查循环条件,而是简单地假设程序中的任何条件都可能为真或为假。

这导致Rust会拒绝一些安全的程序:

fn wait_for_process(process: &mut Process) -> i32 {
    while true {
        if process.wait() {
            return process.exit_code();
        }
    }
} // error: mismatched types: expected i32, found ()
1
2
3
4
5
6
7

这里的错误是不合理的。这个函数只会通过return语句退出,所以while循环不产生i32类型的值这一事实并不相关。

loop表达式就是为解决这个问题而提供的一种“按你所想表达”的方案。

Rust的类型系统也会受到控制流的影响。前面我们说过,if表达式的所有分支都必须具有相同的类型。但是,对于以break、return表达式、无限循环,或者调用panic!()、std::process::exit()结束的代码块,强制执行这条规则就很愚蠢。所有这些表达式的共同点是,它们不会以通常的方式结束并产生一个值。break或return会突然退出当前代码块,无限循环根本不会结束,等等。

所以在Rust中,这些表达式没有普通类型。不会正常结束的表达式被赋予特殊类型!,并且它们不受类型必须匹配规则的约束。你可以在std::process::exit()的函数签名中看到!:

fn exit(code: i32) -> !
1

!表示exit()永远不会返回。它是一个发散函数(divergent function)。

你也可以使用相同的语法编写自己的发散函数,在某些情况下,这是很自然的:

fn serve_forever(socket: ServerSocket, handler: ServerHandler) -> ! {
    socket.listen();
    loop {
        let s = socket.accept();
        handler.handle(s);
    }
}
1
2
3
4
5
6
7

当然,如果这个函数可以正常返回,Rust会认为这是一个错误。

有了这些大规模控制流的构建块后,我们可以继续讨论通常在这种控制流中使用的更细粒度的表达式,比如函数调用和算术运算符。

# 函数和方法调用

在Rust中,调用函数和方法的语法与许多其他语言相同:

let x = gcd(1302, 462); // 函数调用
let room = player.location();  // 方法调用
1
2

在第二个例子中,player是一个虚构类型Player的变量,Player有一个虚构的.location()方法。(在第9章开始讨论用户自定义类型时,我们将展示如何定义自己的方法。)

Rust通常会严格区分引用和它们所引用的值。如果你将一个&i32传递给一个期望i32的函数,这就是一个类型错误。你会注意到,.运算符在一定程度上放宽了这些规则。在方法调用player.location()中,player可以是Player类型的变量、&Player类型的引用,或者Box<Player>、Rc<Player>类型的智能指针。.location()方法可以按值或按引用接收player。在所有情况下,相同的.location()语法都适用,因为Rust的.运算符会根据需要自动解引用player或借用它的引用。

还有第三种语法用于调用类型关联函数(type-associated functions),比如Vec::new():

let mut numbers = Vec::new(); // 类型关联函数调用
1

这些类似于面向对象语言中的静态方法:普通方法在值上调用(比如my_vec.len()),而类型关联函数在类型上调用(比如Vec::new())。

自然地,方法调用可以链式调用:

// 来自第2章基于Actix的Web服务器代码:
server
   .bind("127.0.0.1:3000").expect("error binding server to address")
   .run().expect("error running server");
1
2
3
4

Rust语法的一个特点是,在函数调用或方法调用中,通用类型的常用语法Vec<T>不起作用:

return Vec<i32>::with_capacity(1000); // error: something about chained comparisons
let ramp = (0..n).collect<Vec<i32>>();  // same error
1
2

问题在于,在表达式中,<是小于运算符。Rust编译器会贴心地建议在这种情况下使用::<T>而不是<T>,这样就能解决问题:

return Vec::<i32>::with_capacity(1000); // ok, using ::<
let ramp = (0..n).collect::<Vec<i32>>();  // ok, using ::<
1
2

在Rust社区中,符号::<...>被亲切地称为“涡轮鱼”(turbo fish)。

或者,通常可以省略类型参数,让Rust来推断:

return Vec::with_capacity(10); // ok, if the fn return type is Vec<i32>
let ramp: Vec<i32> = (0..n).collect();  // ok, variable's type is given
1
2

只要类型可以被推断出来,省略类型被认为是一种好的风格。

# 字段和元素

访问结构体的字段使用熟悉的语法。元组也是如此,只是它们的字段用数字而不是名字来标识:

game.black_pawns  // 结构体字段
coords.1         // 元组元素
1
2

如果点号左边的值是引用或智能指针类型,它会像方法调用时一样自动解引用。

方括号用于访问数组、切片或向量的元素:

pieces[i]        // 数组元素
1

方括号左边的值会自动解引用。

像这三个这样的表达式被称为左值(lvalues),因为它们可以出现在赋值语句的左边:

game.black_pawns = 0x00ff0000_00000000_u64;
coords.1 = 0;
pieces[2] = Some(Piece::new(Black, Knight, coords));
1
2
3

当然,只有当game、coords和pieces被声明为可变变量时,才允许这样做。

从数组或向量中提取切片很简单:

let second_half = &game_moves[midpoint..end];
1

这里game_moves可以是数组、切片或向量;无论如何,结果都是一个借用的切片,长度为end - midpoint。在second_half的生命周期内,game_moves被视为被借用。

..运算符允许省略任何一个操作数;根据存在的操作数,它会产生多达四种不同类型的对象:

..      // RangeFull
a..    // RangeFrom { start: a }
..b    // RangeTo { end: b }
a..b // Range { start: a, end: b }
1
2
3
4

后两种形式是半开区间(end-exclusive或half-open):结束值不包含在表示的范围内。例如,范围0..3包含数字0、1和2。

..=运算符产生闭区间(end-inclusive或closed),包含结束值:

..=b    // RangeToInclusive { end: b }
a..=b // RangeInclusive::new(a, b)
1
2

例如,范围0..=3包含数字0、1、2和3。

只有包含起始值的范围是可迭代的,因为循环必须有一个起始点。但是在数组切片中,所有六种形式都很有用。如果范围的起始或结束被省略,它默认为被切片数据的起始或结束。

所以,经典的分治排序算法快速排序(quicksort)的实现,部分代码可能如下:

fn quicksort<T: Ord>(slice: &mut [T]) {
    if slice.len() <= 1 {
        return; // 没有什么需要排序的。
    }
    // 将切片划分为前后两部分。
    let pivot_index = partition(slice);
    // 递归地对`slice`的前半部分进行排序。
    quicksort(&mut slice[..pivot_index]);
    // 对后半部分进行排序。
    quicksort(&mut slice[pivot_index + 1..]);
}
1
2
3
4
5
6
7
8
9
10
11

# 引用运算符

取地址运算符&和&mut在第5章中已经介绍过。

一元*运算符用于访问引用所指向的值。正如我们所看到的,当你使用.运算符访问字段或方法时,Rust会自动解引用,所以只有当我们想要读取或写入引用所指向的整个值时,才需要使用*运算符。

例如,有时迭代器会产生引用,但程序需要底层的值:

let padovan: Vec<u64> = compute_padovan_sequence(n);
for elem in &padovan {
    draw_triangle(turtle, *elem);
}
1
2
3
4

在这个例子中,elem的类型是&u64,所以*elem是u64类型。

# 算术、按位、比较和逻辑运算符

Rust的二元运算符与许多其他语言中的类似。为了节省时间,我们假设你熟悉其中一种语言,并重点介绍Rust与传统语言不同的几点。

Rust有常见的算术运算符+、-、*、/和%。如第3章所述,在调试构建中,整数溢出会被检测到并导致程序恐慌(panic)。标准库提供了像a.wrapping_add(b)这样的方法用于进行不检查溢出的算术运算。

整数除法向零取整,并且在发布版本中,整数除以零也会触发恐慌。整数有一个方法a.checked_div(b),它会返回一个Option(如果b为零则返回None),并且永远不会引发恐慌。

一元-用于对数字取反。除了无符号整数外,所有数字类型都支持该运算符。Rust没有一元+运算符。

println!("{}", -100); // -100
println!("{}", -100u32); // error: can't apply unary `-` to type `u32`
println!("{}", +100); // error: expected expression, found `  `+
1
2
3

和C语言一样,a % b计算带符号的余数或模数,向零取整。结果的符号与左操作数相同。注意,%既可以用于整数,也可以用于浮点数:

let x = 1234.567 % 10.0; // approximately 4.567
1

Rust还继承了C语言的按位整数运算符&、|、^、<<和>>。然而,Rust使用!而不是~进行按位取反:

let hi: u8 = 0xe0;
let lo = !hi; // 0x1f
1
2

这意味着!n不能用于整数n来表示“n为零”。要表示这个意思,应该写成n == 0。

在有符号整数类型上,位移动总是进行符号扩展;在无符号整数类型上,总是进行零扩展。由于Rust有无符号整数,所以它不需要像Java的>>>运算符那样的无符号移位运算符。

与C语言不同,按位运算的优先级高于比较运算,所以如果你写x & BIT != 0,它的意思是(x & BIT) != 0,这可能正是你想要的。这比C语言的解释x & (BIT != 0)有用得多,后者测试的是错误的位!

Rust的比较运算符有==、!=、<、<=、>和>=。被比较的两个值必须具有相同的类型。

Rust还有两个短路逻辑运算符&&和||。两个操作数都必须是精确的bool类型。

# 赋值

=运算符可用于给可变变量及其字段或元素赋值。但在Rust中,赋值不像在其他语言中那么常见,因为变量默认是不可变的。

如第4章所述,如果值的类型不是Copy,赋值会将其移动到目标位置。值的所有权从源转移到目标。目标位置之前的值(如果有)会被丢弃。

Rust支持复合赋值:

total += item.price;
1

这等价于total = total + item.price;。其他运算符也支持复合赋值,如-=、*=等等。完整列表见本章末尾的表6-1。

与C语言不同,Rust不支持链式赋值:你不能写a = b = 3来同时给a和b赋值为3。在Rust中,赋值操作足够少见,所以你不会怀念这种简写方式。

Rust没有C语言的自增和自减运算符++和--。

# 类型转换

在Rust中,将一个值从一种类型转换为另一种类型通常需要显式的类型转换。类型转换使用as关键字:

let x = 17;              // x的类型是i32
let index = x as usize; // 转换为usize
1
2

有几种类型转换是允许的:

  • 数字可以从任何内置数字类型转换为其他任何数字类型。 将一个整数转换为另一个整数类型总是有明确定义的。转换为更窄的类型会导致截断。有符号整数转换为更宽的类型时会进行符号扩展,无符号整数则进行零扩展,等等。简而言之,不会有意外情况。 从浮点类型转换为整数类型时向零取整:-1.99 as i32的值是-1。如果值太大,无法用整数类型表示,类型转换会产生该整数类型能表示的最接近的值:1e6 as u8的值是255。
  • bool、char类型的值,或者类似C语言的枚举类型的值,可以转换为任何整数类型。(我们将在第10章介绍枚举。) 反向转换是不允许的,因为bool、char和枚举类型对其值都有限制,需要在运行时进行检查。例如,将u16转换为char类型是被禁止的,因为有些u16值,如0xd800,对应于Unicode代理码点,因此不能成为有效的char值。有一个标准方法std::char::from_u32(),它会进行运行时检查并返回一个Option<char>;但更重要的是,这种转换的需求已经越来越少。我们通常会一次性转换整个字符串或数据流,并且处理Unicode文本的算法往往很复杂,最好留给库来处理。 作为一个例外,u8可以转换为char类型,因为从0到255的所有整数都是char可以存储的有效Unicode码点。
  • 涉及不安全指针类型的一些转换也是允许的。详见 “裸指针”。

我们说过,转换通常需要进行类型转换。但涉及引用类型的一些转换非常简单,以至于Rust语言会自动进行,无需显式转换。一个简单的例子是将可变引用转换为不可变引用。

不过,还有一些更重要的自动转换:

  • &String类型的值会自动转换为&str类型,无需显式转换。
  • &Vec<i32>类型的值会自动转换为&[i32]。
  • &Box<Chessboard>类型的值会自动转换为&Chessboard。

这些被称为解引用强制转换(deref coercions),因为它们适用于实现了内置Deref特性的类型。解引用强制转换的目的是让像Box这样的智能指针类型尽可能像其底层值一样工作。

多亏了解引用强制转换,使用Box<Chessboard>在很大程度上就像使用普通的Chessboard一样。

用户自定义类型也可以实现Deref特性。当你需要编写自己的智能指针类型时,详见 “Deref和DerefMut”。

# 闭包

Rust有闭包(closures),这是一种轻量级的类似函数的值。闭包通常由放在竖线之间的参数列表和后面的一个表达式组成:

let is_even = |x| x % 2 == 0;
1

Rust会推断参数类型和返回类型。你也可以像定义函数那样显式写出它们。如果你指定了返回类型,那么为了语法合理,闭包的主体必须是一个代码块:

let is_even = |x: u64| -> bool x % 2 == 0;  // 错误
let is_even = |x: u64| -> bool { x % 2 == 0 };  // 正确
1
2

调用闭包的语法和调用函数的语法相同:

assert_eq!(is_even(14), true);
1

闭包是Rust中最令人欣喜的特性之一,关于它还有很多内容值得探讨。我们将在第14章进行讨论 。

# 展望

表达式就是我们所说的 “运行代码”。它们是Rust程序中编译为机器指令的部分。然而,它们只是整个语言的一小部分。

大多数编程语言都是如此。程序的首要任务是运行,但这不是它唯一的任务。程序必须能够进行通信、便于测试。它们必须保持组织有序且灵活,以便能够不断发展。它们还必须能够与其他团队编写的代码和服务进行交互。即使只是为了运行,像Rust这样的静态类型语言编写的程序,也需要一些除了元组和数组之外的工具来组织数据。

接下来,我们将用几章的篇幅讨论这方面的特性:模块(modules)和包(crates),它们可以为你的程序构建结构;结构体(structs)和枚举(enums),它们可以为你的数据实现同样的功能。

首先,我们会用几页内容来讨论一个重要的话题:当出现问题时该怎么办。

编辑 (opens new window)
上次更新: 2025/03/20, 19:44:38
第5章 引用
第7章 错误处理

← 第5章 引用 第7章 错误处理→

最近更新
01
第二章 关键字static及其不同用法
03-27
02
第一章 auto与类型推导
03-27
03
C++语言面试问题集锦 目录与说明
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式