第6章 | 循环控制流,return,loop,函数,字段,运算符,类型转换,闭包

2024-05-08 15:22:41 浏览数 (2)

6.6 循环中的控制流

break 表达式会退出所在循环。(在 Rust 中,break 只能用在循环中,不能用在 match 表达式中,这与 switch 语句不同。)

loop 的循环体中,可以在 break 后面跟一个表达式,该表达式的值会成为此 loop 的值:

代码语言:javascript复制
// 对`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 表达式会跳转到循环的下一次迭代:

代码语言:javascript复制
// 读取某些数据,每次一行
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 会退出这层循环,而不是退出内部循环:

代码语言:javascript复制
'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 可以同时具有标签和值表达式:

代码语言:javascript复制
// 找到此系列中第一个完全平方数的平方根
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 表达式会退出当前函数,并向调用者返回一个值。

不带值的 returnreturn () 的简写:

代码语言:javascript复制
fn f() {     // 省略了返回类型:默认为()
    return;  // 省略了返回值:默认为()
}

函数不必有明确的 return 表达式。函数体的工作方式类似于块表达式:如果最后一个表达式后没有分号,则它的值就是函数的返回值。事实上,这是在 Rust 中提供函数返回值的首选方式。

但这并不意味着 return 是无用的,或者仅仅是对不熟悉表达式语言的用户做出的让步。与 break 表达式一样,return 可以放弃进行中的工作。例如,第 2 章就使用过 ? 运算符在调用可能失败的函数后检查错误:

代码语言:javascript复制
let output = File::create(filename)?;

我们曾解释说这是 match 表达式的简写形式:

代码语言:javascript复制
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 可能拒绝某些安全程序:

代码语言:javascript复制
fn wait_for_process(process: &mut Process) -> i32 {
    while true {
        if process.wait() {
            return process.exit_code();
        }
    }
}  // 错误:类型不匹配:期待i32,实际找到了()

这里的错误是假警报。此函数只会通过 return 语句退出,因此 while 循环无法生成 i32 这个事实无关紧要。

loop 表达式就是这个问题的“有话直说”式解决方案。

Rust 的类型系统也会受到控制流的影响。前面说过,if 表达式的所有分支都必须具有相同的类型。但是,在可能以 breakreturn 表达式、无限 loop,或者调用 panic!()std::process::exit() 等多种方式结束的块上强制执行此规则是不现实的。这些表达式的共同点是它们永远都不会以通常的方式结束并生成一个值。breakreturn 会突然退出当前块、无限 loop 则根本不会结束,等等。

所以,在 Rust 中,这些表达式没有正常类型。不能正常结束的表达式属于一个特殊类型 !,并且它们不受“类型必须匹配”这条规则的约束。可以在 std::process::exit() 的函数签名中看到 !

代码语言:javascript复制
fn exit(code: i32) -> !

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

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

代码语言:javascript复制
fn serve_forever(socket: ServerSocket, handler: ServerHandler) -> ! {
    socket.listen();
    loop {
        let s = socket.accept();
        handler.handle(s);
    }
}

当然,如果此函数正常返回了,那么 Rust 就会认为它能正常返回反而是一个错误。

有了这些大规模控制流的构建块,就可以继续处理该流中常用的、更细粒度的表达式(比如函数调用和算术运算符)了。

6.9 函数与方法调用

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

代码语言:javascript复制
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()

代码语言:javascript复制
let mut numbers = Vec::new();  // 类型关联函数调用

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

自然,也支持链式方法调用:

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

Rust 语法的怪癖之一就是,在函数调用或方法调用中,泛型类型的常用语法 Vec<T> 是不起作用的:

代码语言:javascript复制
return Vec<i32>::with_capacity(1000);  // 错误:是某种关于“链式比较”的错误消息

let ramp = (0 .. n).collect<Vec<i32>>();  // 同样的错误

这里的问题在于,在表达式中 < 是小于运算符。Rust 编译器建议用 ::<T> 代替 <T>。这样就解决了问题:

代码语言:javascript复制
return Vec::<i32>::with_capacity(1000);  // 正确,改用::<

let ramp = (0 .. n).collect::<Vec<i32>>();  // 正确,改用::<

符号 ::<...> 在 Rust 社区中被亲切地称为比目鱼(turbofish)。

笔记 比目鱼,很形象呀

或者,通常可以删掉类型参数,让 Rust 来推断它们:

代码语言:javascript复制
return Vec::with_capacity(10);  // 正确,只要fn的返回类型是Vec<i32>

let ramp: Vec<i32> = (0 .. n).collect();  // 正确,前面已给定变量的类型

只要类型可以被推断,就省略类型,这是一种很好的代码风格。

笔记 同时也保证了代码足够简洁

6.10 字段与元素

你可以使用早已熟悉的语法访问结构体的字段。元组也一样,不过它们的字段是数值而不是名称:

代码语言:javascript复制
game.black_pawns   // 结构体字段
coords.1           // 元组元素

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

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

代码语言:javascript复制
pieces[i]          // 数组元素

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

像下面这样的 3 个表达式叫作左值,因为赋值时它们可以出现在左侧:

代码语言:javascript复制
game.black_pawns = 0x00ff0000_00000000_u64;
coords.1 = 0;
pieces[2] = Some(Piece::new(Black, Knight, coords));

当然,只有当 gamecoordspieces 声明为 mut 变量时才允许这样做。

从数组或向量中提取切片的写法很直观:

代码语言:javascript复制
let second_half = &game_moves[midpoint .. end];

这里的 game_moves 可以是数组、切片或向量,无论哪种方式,结果都是已被借出的长度为 end - midpoint 的切片。在 second_half 的生命周期内,game_moves 要被视为已借出的引用。

.. 运算符允许省略任何一个操作数,它会根据存在的操作数最多生成 4 种类型的对象:

代码语言:javascript复制
..      // RangeFull
a ..    // RangeFrom { start: a }
.. b    // RangeTo { end: b }
a .. b  // Range { start: a, end: b }

后两种形式是排除结束值(或半开放)的:结束值不包含在所表示的范围内。例如,范围 0 .. 3 包括数值 012,但不包括 3

..= 运算符会生成包含结束值(或封闭)的范围,其中包括结束值:

代码语言:javascript复制
..= b    // RangeToInclusive { end: b }
a ..= b  // RangeInclusive::new(a, b)

例如,范围 0 ..= 3 包括数值 0123

只有包含起始值的范围才是可迭代的,因为循环必须从某处开始。但是在数组切片中,这 6 种形式都可以使用。如果省略了范围的起点或末尾,则默认为被切片数据的起点或末尾。

因此,经典的分治算法快速排序 quicksort 的实现部分看起来可能像下面这样。

代码语言:javascript复制
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 会自动追踪引用,因此只有想要读取或写入引用所指的整个值时才需要用 * 运算符。

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

代码语言:javascript复制
let padovan: Vec<u64> = compute_padovan_sequence(n);
for elem in &padovan {
    draw_triangle(turtle, *elem);
}

在此示例中,elem 的类型为 &u64,因此 *elemu64

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

Rust 的二元运算符与许多其他语言中的二元运算符类似。为了节省时间,这里假设你熟悉其中某一种语言,并专注于 Rust 与传统语言不同的几个点。

Rust 有一些常用的算术运算符: -*/%。如第 3 章所述,在调试构建中会检测到整数溢出并引发 panic。标准库为此提供了一些非检查(unchecked)的算术方法,比如 a.wrapping_add(b)

整数除法会向 0 取整,而整数除以 0 会触发 panic,即使在发布构建中也是如此。标准库为整数提供了一个 a.checked_div(b) 方法,它将返回一个 Option(如果 b 为 0 则返回 None),并且不会引发 panic。

一元 - 运算符会对一个数取负。它支持除无符号整数之外的所有数值类型。没有一元 运算符。

代码语言:javascript复制
println!("{}", -100);     // -100
println!("{}", -100u32);  // 错误:不能在类型`u32`上使用一元`-`运算符
println!("{}",  100);     // 错误:期待表达式,但发现了` `

与在 C 中一样,a % b 会计算向 0 四舍五入的有符号余数或模数。其结果与左操作数的符号相同。注意,% 既能用于整数,也能用于浮点数:

代码语言:javascript复制
let x = 1234.567 % 10.0;  // 约等于4.567

Rust 还继承了 C 的按位整数运算符 &|^<<>>。但是,Rust 会使用 ! 而不是 ~ 表示按位非:

代码语言:javascript复制
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 支持复合赋值:

代码语言:javascript复制
total  = item.price;

这等效于 total = total item.price;。Rust 也支持其他运算符:-=*= 等。完整列表参见表 6-1。

与 C 不同,Rust 不支持链式赋值:不能编写 a = b = 3 来将值 3 同时赋给 ab。赋值在 Rust 中非常罕见,你是不会想念这种简写形式的。

Rust 没有 C 的自增运算符 和自减运算符 --

6.14 类型转换

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

代码语言:javascript复制
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)的行为尽可能像其底层值。多亏了 DerefBox<Chessboard> 的用法基本上和普通 Chessboard 的用法一样。

用户定义类型也可以实现 Deref 特型。当你需要编写自己的智能指针类型时,请参阅 13.5 节。

6.15 闭包

Rust 也有闭包,即轻量级的类似函数的值。闭包通常由一个参数列表组成,在两条竖线之间列出,后跟一个表达式:

代码语言:javascript复制
let is_even = |x| x % 2 == 0;

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

代码语言:javascript复制
let is_even = |x: u64| -> bool x % 2 == 0;  // 错误

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

调用闭包和调用函数的语法是一样的:

代码语言:javascript复制
assert_eq!(is_even(14), true);

闭包是 Rust 最令人愉悦的特性之一,关于它们还有很多内容可以讲,第 14 章会详细介绍。

笔记 又见闭包,也是有趣的一部分

6.16 前路展望

表达式就是我们心目中的“可执行代码”,它们是 Rust 程序中编译成机器指令的那部分。然而,表达式也只是整个语言的一小部分。

大多数编程语言也是如此。程序的首要任务是执行,但这不是它唯一的任务。程序必须进行通信,必须是可测试的,必须保持组织性和灵活性,这样它们才能持续演进。程序还需要与其他团队构建的代码和服务进行互操作。就算只是为了执行,像 Rust 这样的静态类型语言的程序也需要更多的工具来组织数据,而不能仅仅使用元组和数组。

欢迎大家讨论交流 Rust,如果喜欢本文章或感觉文章有用,动动你那发财的小手点个赞再走呗 ^_^ 微信公众号:草帽Lufei

0 人点赞