第 6 章 表达式
LISP 程序员知道一切的价值(value),但不了解其代价。
——Alan Perlis,警句 #55
本章将介绍 Rust 表达式,它是函数体的组成部分,因而也是大部分 Rust 代码的组成部分。Rust 中的大多数内容是表达式。本章将探索表达式的强大功能以及如何克服它的局限性。我们将介绍在 Rust 中完全面向表达式的控制流,以及 Rust 的基础运算符如何独立工作和组合工作。
某些概念(比如闭包和迭代器)严格来说也属于这一类,但略显深奥,我们稍后将用单独的一章来介绍它们。目前,我们的目标是在这“区区几页”中涵盖尽可能多的语法。
6.1 表达式语言
Rust 乍看起来和 C 家族的语言相似,但这只是假象。在 C 语言中, 表达式 和语句之间有明显的区别,表达式看起来是这样的:
5 * (fahr-32) / 9
而 语句 看起来更像这样:
for (; begin != end; ++begin) {
if (*begin == target)
break;
}
表达式有值,而语句没有。
Rust 是所谓的 表达式语言。这意味着它遵循更古老的传统,可以追溯到 Lisp,在 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
};
if
表达式可用于初始化变量:
let status =
if cpu.temperature <= MAX_TEMP {
HttpStatus::Ok
} else {
HttpStatus::ServerError // 服务程序出错了
};
match
表达式可以作为参数传给函数或宏:
println!("Inside the vat, you see {}.",
match vat.contents {
Some(brain) => brain.desc(),
None => "nothing of interest"
});
这解释了为什么 Rust 没有 C 那样的三元运算符( expr1 ? expr2 : expr3
)。在 C 语言中,三元运算符是一个表达式级别的类似 if
语句的东西。这在 Rust 中是多余的: if
表达式足以处理这两种情况。
C 中的大多数控制流工具是语句。而在 Rust 中,它们都是表达式。
6.2 优先级与结合性
表 6-1 总结了 Rust 的表达式语法。本章将讨论所有这些类型的表达式。这里的运算符已按优先级顺序列出,从最高到最低。(与大多数编程语言一样,当一个表达式包含多个相邻的运算符时,Rust 会根据 运算符优先级 来确定运算顺序。例如,在 limit < 2 * broom.size + 1
中, .
运算符具有最高优先级,因此会最先访问字段。)
表 6-1:表达式
表达式类型
示例
相关特型
数组字面量
[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
元组字段访问
pair.0
Deref
、 DerefMut
结构体字段访问
point.x
Deref
、 DerefMut
方法调用
point.translate(50, 50)
Deref
、 DerefMut
函数调用
stdin()
Fn(Arg0, ...) -> T
、 FnMut(Arg0, ...) -> T
、 FnOnce(Arg0, ...) -> T
索引
arr[0]
Index
、 IndexMutDeref
、 DerefMut
错误检查
create_dir("tmp")?
逻辑非 / 按位非
!ok
Not
取负
-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
DivAssign
RemAssign
AddAssign
SubAssign
ShlAssign
ShrAssign
BitAndAssign
BitXorAssign
BitOrAssign
MulAssign
闭包
|x, y| x + y
所有可以链式书写的运算符都是左结合的。也就是说,诸如 a - b - c
之类的操作链会分组为 (a - b) - c
,而不是 a - (b - c)
。所有可以这样链式书写的运算符都遵循左结合规则:
* / % + - << >> & ^ | && || as
比较运算符、赋值运算符和范围运算符( ..
和 ..=
)则根本无法链式书写。
6.3 块与分号
块是一种最通用的表达式。一个块生成一个值,并且可以在任何需要值的地方使用:
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()
}
};
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()
};
块可以包含多个声明并在末尾生成值,这是一个很好的特性,你很快就会适应它。但它的一个缺点是,如果你不小心遗漏了分号,则会导致奇怪的错误消息:
...
if preferences.changed() {
page.compute_size() // 糟糕,丢了分号
}
...
如果在 C 或 Java 程序中犯了同样的错误,那么编译器会直接指出你漏了一个分号。但 Rust 会这么说:
error: mismatched types
22 | page.compute_size() // 糟糕,丢了分号
| ^^^^^^^^^^^^^^^^^^^- help: try adding a semicolon: `;`
| |
| expected (), found tuple
|
= note: expected unit type `()`
found tuple `(u32, u32)`
由于缺少分号,块的值将是 page.compute_size()
返回的任何值,但是没有 else
的 if
必定返回 ()
。幸好,Rust 已经针对这类错误做出改进,并会建议添加分号。
6.4 声明
除了表达式和分号,块还可以包含任意数量的声明。最常见的是 let
声明,它会声明局部变量:
let name: type = expr;
类型和初始化代码是可选的,分号则是必需的。与 Rust 中的所有标识符一样,变量名必须以字母或下划线开头,并且只能在第一个字符之后包含数字。Rust 中的“字母”是广义的,包括希腊字母、带重音的拉丁字符和更多符号——符合 Unicode 标准中附件 #31 要求的一切字符(也包括中文)。不允许使用表情符号。
let
声明可以在不初始化变量的情况下声明变量,然后再用赋值语句来初始化变量。这在某些情况下很有用,因为有时确实应该在某种控制流结构的中间初始化变量:
let name;
if user.has_nickname() {
name = user.nickname();
} else {
name = generate_unique_name();
user.register(&name);
}
这里有两种初始化局部变量 name
的方式,但无论采用哪种方式,都只会初始化一次,所以 name
不需要声明为 mut
。
在初始化之前就使用变量是错误的。(这与“移动后又使用值”的错误紧密相关。Rust 确实非常希望你只使用存在的值。)
你可能偶尔会看到似乎在重新声明现有变量的代码,如下所示:
for line in file.lines() {
let line = line?;
...
}
这个 let
声明会创建一个不同类型的、新的(第二个)变量。第一个 line
变量的类型是 Result<String, io::Error>
。第二个 line
变量则是 String
。第二个定义会在所处代码块的其余部分代替第一个定义。这叫作 遮蔽(shadowing),在 Rust 程序中很常见。该代码等效于如下内容:
for line_result in file.lines() {
let line = line_result?;
...
}
本书会坚持在这种情况下使用 _result
后缀,以便让不同变量具有不同的名称。
块还可以包含 语法项声明(item declaration)。语法项是指可以在程序或模块中的任意地方出现的声明,比如 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);
...
}
当在块内声明一个 fn
时,它的作用域是整个块,也就是说,它可以在整个封闭块内部 使用。但是嵌套的 fn
无法访问恰好在同一作用域内的局部变量或参数。例如,函数 cmp_by_timestamp_then_name
不能直接使用 v
。(封闭块与闭包不同。Rust 也有闭包,闭包可以看到封闭块作用域内的变量。请参阅第 14 章。)
块甚至可以包含完整的模块。这可能看起来有点儿过分(真的需要把语言的 每一 部分都嵌进任何其他部分吗?),但是程序员(特别是使用宏的程序员)总是有办法为语言提供的每一种独立语法找到用武之地。
6.5 if
与 match
if
表达式的形式我们很眼熟:
if condition1 {
block1
} else if condition2 {
block2
} else {
block_n
}
每个 condition
都必须是 bool
类型的表达式,依照 Rust 的风格,不会隐式地将数值或指针转换为布尔值。
与 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)
}
这类似于 switch
语句的用途。它将执行此 match
表达式的四个分支之一,具体执行哪个分支取决于 code
的值。通配符模式 _
会匹配所有内容。这类似于 switch
语句中的 default:
语句,不过它必须排在最后。将 _
模式放在其他模式之前意味着它会优先于其他模式。这样一来,其他模式将永远没机会匹配到(编译器会发出警告)。
编译器可以使用跳转表来优化这种 match
,就像 C++ 中的 switch
语句一样。当 match
的每个分支都生成一个常量值时,也会应用与 C++ 类似的优化。在这种情况下,编译器会构建出这些值的数组,并将各个 match
项编译为数组访问。除了边界检查,编译后的代码中根本不存在任何分支。
match
的多功能性源于每个分支 =>
左侧支持的多种 模式(pattern)。在上面的例子中,每个模式只是一个常量整数。我们还展示了用以区分两种 Option
值的 match
表达式:
match params.get("name") {
Some(name) => println!("Hello, {}!", name),
None => println!("Greetings, stranger.")
}
对模式的强大能力来说,这还只是“冰山一角”。模式可以匹配一系列值,它可以解构元组、可以匹配结构体的各个字段、可以追踪引用、可以借用部分值,等等。甚至可以说,Rust 的模式定义了自己的迷你语言。第 10 章会用一些篇幅来介绍模式。
match
表达式的一般形式如下所示:
match value {
pattern => expr,
...
}
如果 expr
是一个块,则可以省略此分支之后的逗号。
Rust 会从第一项开始依次根据每个模式检查给定的 value
。当模式能够匹配时,对应的 expr
会被求值,而当这个 match
表达式结束时,不会再检查别的模式。至少要有一个模式能够匹配。Rust 禁止执行未覆盖所有可能值的 match
表达式:
let score = match card.rank {
Jack => 10,
Queen => 10,
Ace => 11
}; // 错误:未穷举所有模式
if
表达式的所有块都必须生成相同类型的值:
let suggested_pet =
if with_wings { Pet::Buzzard } else { Pet::Hyena }; // 正确
let favorite_number =
if user.is_hobbit() { "eleventy-one" } else { 9 }; // 错误
let best_sports_team =
if is_hockey_season() { "Predators" }; // 错误
(最后一个示例之所以是错的,是因为在 7 月份结果将是 ()
。)1
类似地, match
表达式的所有分支都必须具有相同的类型。
let suggested_pet =
match favorites.element {
Fire => Pet::RedPanda,
Air => Pet::Buffalo,
Water => Pet::Orca,
_ => None // 错误:不兼容的类型
};
6.5.1 if let
还有一种 if
形式,即 if let
表达式:
if let pattern = expr {
block1
} else {
block2
}
给定的 expr
要么匹配 pattern
,这时会运行 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();
}
if let
不是 必需 的,因为凡是 if let
可以做到的, match
同样可以做到。 if let
表达式其实是只有一个模式的 match
表达式的简写形式。
match expr {
pattern => { block1 }
_ => { block2 }
}
6.5.2 循环
有 4 种循环表达式:
while condition {
block
}
while let pattern = expr {
block
}
loop {
block
}
for pattern in iterable {
block
}
各种循环都是 Rust 中的表达式,但 while
循环或 for
循环的值总是 ()
,因此它们的值通常没什么用。如果指定了一个值,那么 loop
表达式就能生成一个值。
while
循环的行为与 C 中的等效循环完全一样,只不过其 condition
必须是 bool
类型。
while let
循环类似于 if let
。在每次循环迭代开始时, expr
的值要么匹配给定的 pattern
,这时会运行循环体( block
);要么不匹配,这时会退出循环。
可以用 loop
来编写无限循环。它会永远重复执行循环体(直到遇上 break
或 return
,或者直到线程崩溃)。
for
循环会对可迭代( iterable
)表达式求值,然后为结果迭代器中的每个值运行一次循环体。许多类型可以迭代,包括所有标准库集合,比如 Vec
和 HashMap
。标准的 C 语言的 for
循环如下所示:
for (int i = 0; i < 20; i++) {
printf("%d\n", i);
}
在 Rust 中则是这样的:
for i in 0..20 {
println!("{}", i);
}
与 C 一样,最后打印出的数值是 19
。
..
运算符会生成一个 范围(range),即具有两个字段( start
和 end
)的简单结构体。 0..20
与 std::ops::Range { start: 0, end: 20 }
相同。各种范围都可以与 for
循环一起使用,因为 Range
是一种可迭代类型,它实现了 std::iter::IntoIterator
特型(参见第 15 章)。标准集合都是可迭代的,数组和切片也是如此。
为了与 Rust 的移动语义保持一致,把值用于 for
循环会消耗该值:
let strings: Vec<String> = error_messages();
for s in strings { // 在这里,每个String都会转移给s……
println!("{}", s);
} // ……并在此丢弃
println!("{} error(s)", strings.len()); // 错误:使用了已移动出去的值
这可能很不方便。简单的补救措施是在循环中访问此集合的引用。然后,循环变量也会变成对集合中每个条目的引用:
for rs in &strings {
println!("String {:?} is at address {:p}.", *rs, rs);
}
这里 &strings
的类型是 &Vec<String>
, rs
的类型是 &String
。
遍历(可迭代对象的)可变引用会为每个元素提供一个可变引用:
for rs in &mut strings { // rs的类型是&mut String
rs.push('\n'); // 为每个字符串添加一个换行
}
第 15 章会更详细地介绍 for
循环,并展示使用迭代器的许多其他方式。
6.6 循环中的控制流
break
表达式会退出所在循环。(在 Rust 中, break
只能用在循环中,不能用在 match
表达式中,这与 switch
语句不同。)
在 loop
的循环体中,可以在 break
后面跟一个表达式,该表达式的值会成为此 loop
的值:
// 对`next_line`的每一次调用,或者返回一个`Some(line)`(这里的`line`是
// 输入中的一行),或者当输入已结束时返回`None`。最终会返回以"answer: "
// 开头的第1行,如果没找到,就返回"answer: nothing"
let answer = loop {
if let Some(line) = next_line() {
if line.starts_with("answer: ") {
break line;
}
} else {
break "answer: nothing";
}
};
自然, loop
中的所有 break
表达式也必须生成具有相同类型的值,这样该类型就会成为这个 loop
本身的类型。
continue
表达式会跳转到循环的下一次迭代:
// 读取某些数据,每次一行
for line in input_lines {
let trimmed = trim_comments_and_whitespace(line);
if trimmed.is_empty() {
// 跳转回循环的顶部,并移到输入中的下一行
continue;
}
...
}
在 for
循环中, continue
会前进到集合中的下一个值,如果没有更多值,则退出循环。同样,在 while
循环中, continue
会重新检查循环条件,如果当前条件为假,就退出循环。
循环可以带有生命周期 标签。在以下示例中, '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;
}
}
}
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;
}
}
};
标签也可以与 continue
一起使用。
6.7 return
表达式
return
表达式会退出当前函数,并向调用者返回一个值。
不带值的 return
是 return ()
的简写:
fn f() { // 省略了返回类型:默认为()
return; // 省略了返回值:默认为()
}
函数不必有明确的 return
表达式。函数体的工作方式类似于块表达式:如果最后一个表达式后没有分号,则它的值就是函数的返回值。事实上,这是在 Rust 中提供函数返回值的首选方式。
但这并不意味着 return
是无用的,或者仅仅是对不熟悉表达式语言的用户做出的让步。与 break
表达式一样, return
可以放弃进行中的工作。例如,第 2 章就使用过 ?
运算符在调用可能失败的函数后检查错误:
let output = File::create(filename)?;
我们曾解释说这是 match
表达式的简写形式:
let output = match File::create(filename) {
Ok(f) => f,
Err(err) => return Err(err)
};
上述代码会首先调用 File::create(filename)
。如果返回 Ok(f)
,则整个 match
表达式的计算结果为 f
,因此可以把 f
存储在 output
中,继续执行 match
后的下一行代码。
否则,我们将匹配 Err(err)
并抵达 return
表达式。这时候,对 match
表达式求值的具体结果会决定 output
变量的值。我们会放弃所有这些并退出所在函数,返回从 File::create()
中得到的任何错误。
7.2.4 节会完整讲解 ?
运算符。
6.8 为什么 Rust 中会有 loop
Rust 编译器中有几个部分会分析程序中的控制流。
- Rust 会检查通过函数的每条路径是否返回了预期返回类型的值。为了正确地做到这一点,它需要知道是否有可能抵达函数的末尾。
- Rust 会检查局部变量有没有在未初始化的情况下使用过。这就要检查通过函数的每一条路径,以确保只要不经过初始化此变量的代码,就无法抵达使用它的地方。
- Rust 会对不可达代码发出警告。如果 无法 通过函数抵达某段代码,则这段代码不可达。
以上这些称为 流敏感(flow-sensitive)分析。这不是什么新事物,多年来,Java 一直在采用与 Rust 相似的“显式赋值”分析。
要执行这种规则,语言就必须在简单性和智能性之间取得平衡。简单性使得程序员更容易弄清楚编译器到底在说什么,而智能性有助于消除假警报和编译器拒绝一份完美而安全的程序的情况。Rust 更倾向于简单性,它的流敏感分析根本不会检查循环条件,而会简单地假设程序中的任何条件都可以为真或为假。
这会导致 Rust 可能拒绝某些安全程序:
fn wait_for_process(process: &mut Process) -> i32 {
while true {
if process.wait() {
return process.exit_code();
}
}
} // 错误:类型不匹配:期待i32,实际找到了()
这里的错误是假警报。此函数只会通过 return
语句退出,因此 while
循环无法生成 i32
这个事实无关紧要。
loop
表达式就是这个问题的“有话直说”式解决方案。
Rust 的类型系统也会受到控制流的影响。前面说过, if
表达式的所有分支都必须具有相同的类型。但是,在可能以 break
或 return
表达式、无限 loop
,或者调用 panic!()
或 std::process::exit()
等多种方式结束的块上强制执行此规则是不现实的。这些表达式的共同点是它们永远都不会以通常的方式结束并生成一个值。 break
或 return
会突然退出当前块、无限 loop
则根本不会结束,等等。
所以,在 Rust 中,这些表达式没有正常类型。不能正常结束的表达式属于一个特殊类型 !
,并且它们不受“类型必须匹配”这条规则的约束。可以在 std::process::exit()
的函数签名中看到 !
:
fn exit(code: i32) -> !
此处的 !
表示 exit()
永远不会返回,它是一个 发散函数(divergent function)。
你可以用相同的语法编写自己的发散函数,这在某些情况下是很自然的:
fn serve_forever(socket: ServerSocket, handler: ServerHandler) -> ! {
socket.listen();
loop {
let s = socket.accept();
handler.handle(s);
}
}
当然,如果此函数正常返回了,那么 Rust 就会认为它能正常返回反而是一个错误。
有了这些大规模控制流的构建块,就可以继续处理该流中常用的、更细粒度的表达式(比如函数调用和算术运算符)了。
6.9 函数与方法调用
Rust 中调用函数和方法的语法与许多其他语言中的语法相同:
let x = gcd(1302, 462); // 函数调用
let room = player.location(); // 方法调用
在此处的第二个示例中, player
是虚构类型 Player
的变量,它具有虚构的 .location()
方法。(第 9 章在讨论用户定义类型时会展示如何定义我们自己的方法。)
Rust 通常会在引用和它们所引用的值之间做出明确的区分。如果将 &i32
传给需要 i32
的函数,则会出现类型错误。你会注意到 .
运算符稍微放宽了这些规则。在调用 player. location()
的方法中, player
可能是一个 Player
、一个 &Player
类型的引用,也可能是一个 Box<Player>
或 Rc<Player>
类型的智能指针。 .location()
方法可以通过值或引用获取 player
。同一个 .location()
语法适用于所有情况,因为 Rust 的 .
运算符会根据需要自动对 player
解引用或借入一个对它的引用。
第三种语法用于调用类型关联函数,比如 Vec::new()
:
let mut numbers = Vec::new(); // 类型关联函数调用
这些语法类似于面向对象语言中的静态方法:普通方法会在值上调用(如 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");
Rust 语法的怪癖之一就是,在函数调用或方法调用中,泛型类型的常用语法 Vec<T>
是不起作用的:
return Vec<i32>::with_capacity(1000); // 错误:是某种关于“链式比较”的错误消息
let ramp = (0 .. n).collect<Vec<i32>>(); // 同样的错误
这里的问题在于,在表达式中 <
是小于运算符。Rust 编译器建议用 ::<T>
代替 <T>
。这样就解决了问题:
return Vec::<i32>::with_capacity(1000); // 正确,改用::<
let ramp = (0 .. n).collect::<Vec<i32>>(); // 正确,改用::<
符号 ::<...>
在 Rust 社区中被亲切地称为 比目鱼(turbofish)。
或者,通常可以删掉类型参数,让 Rust 来推断它们:
return Vec::with_capacity(10); // 正确,只要fn的返回类型是Vec<i32>
let ramp: Vec<i32> = (0 .. n).collect(); // 正确,前面已给定变量的类型
只要类型可以被推断,就省略类型,这是一种很好的代码风格。
6.10 字段与元素
你可以使用早已熟悉的语法访问结构体的字段。元组也一样,不过它们的字段是数值而不是名称:
game.black_pawns // 结构体字段
coords.1 // 元组元素
如果 .
左边的值是引用或智能指针类型,那么它就会像方法调用一样自动解引用。
方括号会访问数组、切片或向量的元素:
pieces[i] // 数组元素
方括号左侧的值也会自动解引用。
像下面这样的 3 个表达式叫作 左值,因为赋值时它们可以出现在左侧:
game.black_pawns = 0x00ff0000_00000000_u64;
coords.1 = 0;
pieces[2] = Some(Piece::new(Black, Knight, coords));
当然,只有当 game
、 coords
和 pieces
声明为 mut
变量时才允许这样做。
从数组或向量中提取切片的写法很直观:
let second_half = &game_moves[midpoint .. end];
这里的 game_moves
可以是数组、切片或向量,无论哪种方式,结果都是已被借出的长度为 end - midpoint
的切片。在 second_half
的生命周期内, game_moves
要被视为已借出的引用。
..
运算符允许省略任何一个操作数,它会根据存在的操作数最多生成 4 种类型的对象:
.. // RangeFull
a .. // RangeFrom { start: a }
.. b // RangeTo { end: b }
a .. b // Range { start: a, end: b }
后两种形式是 排除结束值(或 半开放)的:结束值不包含在所表示的范围内。例如,范围 0 .. 3
包括数值 0
、 1
和 2
,但不包括 3
。
..=
运算符会生成 包含结束值(或 封闭)的范围,其中包括结束值:
..= b // RangeToInclusive { end: b }
a ..= b // RangeInclusive::new(a, b)
例如,范围 0 ..= 3
包括数值 0
、 1
、 2
和 3
。
只有包含起始值的范围才是可迭代的,因为循环必须从某处开始。但是在数组切片中,这 6 种形式都可以使用。如果省略了范围的起点或末尾,则默认为被切片数据的起点或末尾。
因此,经典的分治算法快速排序 quicksort
的实现部分看起来可能像下面这样。
fn quicksort<T: Ord>(slice: &mut [T]) {
if slice.len() <= 1 {
return; // 无可排序
}
// 把slice分成两部分:前半片和后半片
let pivot_index = partition(slice);
// 对slice的前半片递归排序
quicksort(&mut slice[.. pivot_index]);
// 对slice的后半片递归排序
quicksort(&mut slice[pivot_index + 1 ..]);
}
6.11 引用运算符
地址运算符 &
和 &mut
已在第 5 章中介绍过。
一元 *
运算符用于访问引用所指向的值。如你所见,当使用 .
运算符访问字段或方法时,Rust 会自动追踪引用,因此只有想要读取或写入引用所指的整个值时才需要用 *
运算符。
例如,有时迭代器会生成引用,但程序需要访问底层值:
let padovan: Vec<u64> = compute_padovan_sequence(n);
for elem in &padovan {
draw_triangle(turtle, *elem);
}
在此示例中, elem
的类型为 &u64
,因此 *elem
为 u64
。
6.12 算术运算符、按位运算符、比较运算符和逻辑运算符
Rust 的二元运算符与许多其他语言中的二元运算符类似。为了节省时间,这里假设你熟悉其中某一种语言,并专注于 Rust 与传统语言不同的几个点。
Rust 有一些常用的算术运算符: +
、 -
、 *
、 /
和 %
。如第 3 章所述,在调试构建中会检测到整数溢出并引发 panic。标准库为此提供了一些非检查( unchecked
)的算术方法,比如 a.wrapping_add(b)
。
整数除法会向 0 取整,而整数除以 0 会触发 panic,即使在发布构建中也是如此。标准库为整数提供了一个 a.checked_div(b)
方法,它将返回一个 Option
(如果 b
为 0 则返回 None
),并且不会引发 panic。
一元 -
运算符会对一个数取负。它支持除无符号整数之外的所有数值类型。没有一元 +
运算符。
println!("{}", -100); // -100
println!("{}", -100u32); // 错误:不能在类型`u32`上使用一元`-`运算符
println!("{}", +100); // 错误:期待表达式,但发现了`+`
与在 C 中一样, a % b
会计算向 0 四舍五入的有符号余数或模数。其结果与左操作数的符号相同。注意, %
既能用于整数,也能用于浮点数:
let x = 1234.567 % 10.0; // 约等于4.567
Rust 还继承了 C 的按位整数运算符 &
、 |
、 ^
、 <<
和 >>
。但是,Rust 会使用 !
而不是 ~
表示按位非:
let hi: u8 = 0xe0;
let lo = !hi; // 0x1f
这意味着对于整数 n
,不能用 !n
来表示“ n
为 0”,而是应该写成 n == 0
。
移位总是对有符号整数类型进行符号扩展,对无符号整数类型进行零扩展。由于 Rust 具有无符号整数,因此它不需要诸如 Java 的 >>>
运算符之类的无符号移位运算符。
与 C 不同,Rust 中按位运算的优先级高于比较运算,因此如果编写 x & BIT != 0
,那么就意味着 (x & BIT) != 0
,正如预期的那样。这比在 C 中解释成的 x & (BIT != 0)
有用得多,后者会测试错误的位。
Rust 的比较运算符是 ==
、 !=
、 <
、 <=
、 >
和 >=
,参与比较的两个值必须具有相同的类型。
Rust 还有两个短路逻辑运算符 &&
和 ||
,它们的操作数都必须具有确切的 bool
类型。
6.13 赋值
=
运算符用于给 mut
变量及其字段或元素赋值。但是赋值在 Rust 中不像在其他语言中那么常见,因为默认情况下变量是不可变的。
如第 4 章所述,如果值是非 Copy
类型的,则赋值会将其 移动 到目标位置。值的所有权会从源转移给目标。目标的先前值(如果有的话)将被丢弃。
Rust 支持复合赋值:
total += item.price;
这等效于 total = total + item.price;
。Rust 也支持其他运算符: -=
、 *=
等。完整列表参见表 6-1。
与 C 不同,Rust 不支持链式赋值:不能编写 a = b = 3
来将值 3
同时赋给 a
和 b
。赋值在 Rust 中非常罕见,你是不会想念这种简写形式的。
Rust 没有 C 的自增运算符 ++
和自减运算符 --
。
6.14 类型转换
在 Rust 中,将值从一种类型转换为另一种类型通常需要进行显式转换。这种转换要使用 as
关键字:
let x = 17; // x是i32类型的
let index = x as usize; // 转换成usize
Rust 允许进行好几种类型的转换。
-
数值可以从任意内置数值类型转换为其他内置数值类型。
将一种整数类型转换为另一种整数类型始终是明确定义的。转换为更窄的类型会导致截断。转换为更宽类型的有符号整数会进行符号扩展,转换为无符号整数会进行零扩展,等等。简而言之,没有意外。
从浮点类型转换为整数类型会向 0 舍入,比如
-1.99 as i32
就是-1
。如果值太大而无法容纳整数类型,则转换会生成整数类型可以表示的最接近的值,比如1e6 as u8
就是255
。 -
bool
类型或char
类型的值或者类似 C 的enum
类型的值可以转换为任何整数类型。(第 10 章会介绍枚举。)不允许向相反方向转换,因为
bool
类型、char
类型和enum
类型都对其值有限制,必须通过运行期检查强制执行。例如,禁止将u16
转换为char
类型,因为某些u16
值(如0xd800
)对应于 Unicode 的半代用区码点,因此无法生成有效的char
值。有一个标准方法std::char::from_u32()
,它会执行运行期检查并返回一个Option<char>
,但更重要的是,这种转变的需求已经越来越少了。我们通常会一次转换整个字符串或流,Unicode 文本的算法通常比较复杂,最好留给库去实现。作为例外,
u8
可以转换为char
类型,因为从 0 到 255 的所有整数都是char
能持有的有效 Unicode 码点。 -
一些涉及不安全指针类型的转换也是允许的。参见 22.8 节。
我们说过 通常 需要进行强制转换。但一些涉及引用类型的转换非常直观,Rust 甚至无须强制转换就能执行它们。一个简单的例子是将可变引用转换为不可变引用。
不过,还可能会发生几个更重要的自动转换。
&String
类型的值会自动转换为&str
类型,无须强制转换。&Vec<i32>
类型的值会自动转换为&[i32]
。&Box<Chessboard>
类型的值会自动转换为&Chessboard
。
这些称为 隐式解引用,因为它们适用于所有实现了内置特型 Deref
的类型。 Deref
隐式转换的目的是使智能指针类型(如 Box
)的行为尽可能像其底层值。多亏了 Deref
, Box<Chessboard>
的用法基本上和普通 Chessboard
的用法一样。
用户定义类型也可以实现 Deref
特型。当你需要编写自己的智能指针类型时,请参阅 13.5 节。
6.15 闭包
Rust 也有 闭包,即轻量级的类似函数的值。闭包通常由一个参数列表组成,在两条竖线之间列出,后跟一个表达式:
let is_even = |x| x % 2 == 0;
Rust 会推断其参数类型和返回类型。你也可以像写函数一样显式地写出它们。如果确实指定了返回类型,那么为了语法的完整性,闭包的主体必须是一个块:
let is_even = |x: u64| -> bool x % 2 == 0; // 错误
let is_even = |x: u64| -> bool { x % 2 == 0 }; // 正确
调用闭包和调用函数的语法是一样的:
assert_eq!(is_even(14), true);
闭包是 Rust 最令人愉悦的特性之一,关于它们还有很多内容可以讲,第 14 章会详细介绍。
6.16 前路展望
表达式就是我们心目中的“可执行代码”,它们是 Rust 程序中编译成机器指令的那部分。然而,表达式也只是整个语言的一小部分。
大多数编程语言也是如此。程序的首要任务是执行,但这不是它唯一的任务。程序必须进行通信,必须是可测试的,必须保持组织性和灵活性,这样它们才能持续演进。程序还需要与其他团队构建的代码和服务进行互操作。就算只是为了执行,像 Rust 这样的静态类型语言的程序也需要更多的工具来组织数据,而不能仅仅使用元组和数组。
接下来,本书会用几章内容来讨论这些特性:首先是模块与 crate,它们会帮你对程序进行组织;然后是结构体与枚举,它们会帮你对数据进行组织。
不过,在此之前我们先来简单讨论一下“错误处理”这个重要主题。