对比学习 Go 和 Rust:基本控制流

2022年3月30日 // Go Rust 编程

各种编程语言中,进行流程控制的结构无非是选择和循环,Go 和 Rust 亦是如此。在 Go 和 Rust 中,他们分别属于语句和表达式。Go 中的选择语句包括 if 语句和 switch 语句,Rust 中对应有 if 表达式、if let 表达式和 match 表达式;Go 中的循环语句只有一个 for 语句,Rust 中则有 loop 表达式、while 表达式、while let 表达式和 for 表达式。

条件结构

条件结构大都用 if 语句(或表达式)表示,它是最常见的选择结构。Go 和 Rust 中的 if 结构差不多。

下面先给出一个 Go 中的 if 语句示例:

if x := 11; x%3 == 0 {
    fmt.Printf("%v 能被 3 整除!\n", x)
} else if x%2 == 0 {
    fmt.Printf("%v 能被 2 整除!\n", x)
} else {
    fmt.Printf("%v 不能被 2、3 整除!\n", x)
}

以下是用 Rust 实现上面相同的功能:

let x = 11;
if x % 3 == 0 {
    println!("{} 能被 3 整除!", x)
} else if x % 2 == 0 {
    println!("{} 能被 2 整除!", x)
} else {
    println!("{} 不能被 2、3 整除!", x)
}

可以看出,两种语言的 if 结构看起来很相似。不过,可以在 Go 的 if 后面添加一个初始化语句(如上例中的 x := 11;,该语句只能是简单语句,通常是短变量声明语句),Rust 却不能这么做。

另外,由于 if 结构在 Rust 中属于表达式,其使用要更加灵活:

let a = 27;
let b = 33;
let max = if a > b { a } else { b };

分支结构

分支(或称开关)是另外一种选择结构,它根据变量值或表达式,使控制流被导入到各个相应的分支。通过分支结构可以用更加简洁的方式表示 if-else-if-else 链。

Go 的 switch 语句

Go 的 switch 语句可译作开关语句,它将一个 switch 表达式或类型与各个 case 表达式相对比,从而判断该执行哪个分支。存在两种形式的分支语句:表达式分支语句和类型分支语句。在表达式分支语句中,要对比的双方是 switch 表达式的值和各个 case 表达式的值;在类型分支语句中,要对比的双方是特别标注的 switch 表达式和各个 case 中的类型。

以下是一个表达式分支语句示例:

var x uint = 3
switch x {
default:
    fmt.Println("x > 5")
case 0:
    fmt.Println("x = 0")
case 1, 2:
    fmt.Println("1 <= x <= 2")
case 3, 4, 5:
    fmt.Println("3 <= x <= 5")
}

其中 x 是 switch 表达式,而 1, 23, 4, 5 则分别是逗号分割的多个 case 表达式的列表。其中 default 分支可以放在任何位置,但都只在其他分支没有执行时执行。每个分支中可以有多个语句,这些语句属于一个隐含的代码块,不需要用大括号括起来。

switch 表达式将被看作是一个有特定类型的值,即便该表达式的值本身是无类型常量,它将被隐式地转换为默认类型,因此该表达式不能是无类型的 nil。switch 表达式的类型还必须是可比较的。另外,如果 case 表达式是无类型常量,它将被隐式地转换为 switch 表达式的类型;如果无法完成转换,将会报错。

switch 后面也可以没有 switch 表达式,这时该表达式相当于 true,将匹配 case 表达式中的 true。如下所示:

// TimePeriodName 函数根据输入的一天内的时间确定该时间点属于何种时间段,他将对应返回
// "早上"、"上午"、"晚上" 这些中文字符串,其中 hour 的整数部分取值范围应为 [0, 23),
// 小数部分对应一个小时内十进制的时间,如 8.75 对应的实际时间是 8:45。
func TimePeriodName(hour float64) string {
    switch {
    case hour >= 1 && hour < 5:
        return "凌晨"
    case hour >= 5 && hour < 8:
        return "早上"
    case hour >= 8 && hour < 11:
        return "上午"
    case hour >= 11 && hour < 13:
        return "中午"
    case hour >= 13 && hour < 17:
        return "下午"
    case hour >= 17 && hour < 19:
        return "傍晚"
    case hour >= 19 && hour < 23:
        return "晚上"
    case (hour >= 23 && hour <= 24) || (hour >= 0 && hour < 1):
        return "子夜"
    default:
        return ""
    }
}

如同 if 语句一样,switch 语句中也可以有个初始化语句,该语句必须是简单语句,通常是短变量声明语句。如下所示:

switch score := 82; {
default:
    fmt.Println("无效的分数")
case score >= 0 && score < 60:
    fmt.Println("不及格")
case score >= 60 && score <= 100:
    fmt.Println("及格")
}

即便多个分支都满足要求,但由于 switch 语句不能向下穿透,它将只执行第一个满足要求的分支。要使某个分支向下穿透,需要添加 fallthrough 语句。如下所示:

switch score := 82; {
default:
    fmt.Println("无效的分数")
case score >= 0 && score < 60:
    fmt.Println("不及格")
case score >= 60 && score <= 100:
    fmt.Println("及格")
    fallthrough
case score >= 80 && score <= 100:
    fmt.Println("优秀")
}

以上代码片段将分两行打印 及格优秀

可以在 switch 语句的分支中使用 break,这将终止 switch 的执行。

类型分支语句与表达式分支语句类似,不过它对类型进行比较。其 switch 表达式使用放在圆括号内的关键字 type 实现类型断言。以下是一个示例:

func main() {
    findType(8)
    findType(false)
    findType(nil)
    findType(5.0)
    findType("hello")
}

func findType(x interface{}) {
    switch v := x.(type) {
    default:
        fmt.Printf("未预测到的类型 %T\n", v) // %T 打印 v 的类型
    case bool:
        fmt.Printf("布尔类型 %v\n", v) // v 的类型为 bool
    case int:
        fmt.Printf("整数类型 %d\n", v) // v 的类型为 int
    case *bool:
        fmt.Printf("指向布尔类型值的指针 %v\n", *v) // v 的类型为 *bool
    case *int:
        fmt.Printf("指向整数类型值的指针 %d\n", *v) // v 的类型为 *int
    case nil:
        fmt.Printf("nil 值\n")
    }
}

该段代码将打印:

整数类型 8
布尔类型 false
nil 值
未预测到的类型 float64
未预测到的类型 string

上例中的 x 必须是接口类型,并且不能是一个类型参数,每个 case 分支中列出的类型必须实现了 x 所属的接口,且所列出的每种类型必须是不同的。case 后面也可以是 nil,但至多有一个这样的分支。以上 v := x.(type) 是一个短变量声明,在每个 case 分支中,v 被转换为对应的类型,而不再像 x 那样是一个接口值。在类型分支语句中不允许使用 fallthrough 语句。

Rust 的 match 表达式

Rust 中的 match 表达式可译作匹配表达式,它将一个检验对象(scrutinee)表达式与多个模式(pattern)相比较,从而判断该执行哪个分支。检验对象表达式和模式分别相当于 Go 的 switch 语句中的 switch 表达式和 case 表达式。在 Go 中,一般只是简单地判断 switch 表达式的值与 case 表达式的一个或多个值中的某个是否相等,但 Rust 则专门定义了模式用于判断两个事物是否匹配,它是一个非常有表现力的东西,使用模式能创建强大而简洁的代码。

模式基于给定数据结构去匹配值,并可选地将变量和这些结构中匹配到的值绑定起来。除了 match 分支,在其他许多位置都要用到模式匹配,如 if let 条件表达式、while let 条件循环、for 循环、let 语句、函数参数。例如,let 语句的形式实际上是:

let 模式 = 表达式;

具体示例如下:

let (x, y, z) = (3.5, 4.2, 10.0);

该行代码将一个元组与模式匹配,由于正好能够匹配,从而解构了该元组,即将元组元素与模式中的变量分别绑定起来。

模式有多种形式:字面量模式、标识符模式、通配符模式、剩余模式、区间模式、引用模式、结构体模式、元组结构体模式、元组模式、分组模式、切片模式、路径模式、或模式。通俗来讲,通过模式匹配,判断表达式的值和模式是否相等、是否存在、是否在某个范围内、是否为真等。

先看第一个例子:

let x: u32 = 3;
match x {
    0 => println!("x = 0"),
    1 | 2 => println!("1 <= x <= 2"),
    3 | 4 | 5 => println!("3 <= x <= 5"),
    _ => println!("x > 5"),
}

其中 0 模式只有一个字面量构成。1 | 23 | 4 | 5 属于或模式,它使用 | 将多个由字面量构成的模式组合起来,x 的值若匹配模式中的任何一个值,该分支将会被执行。最后一个 _ 属于通配符模式,_ 能与任何值匹配,因此相当于 Go 的 switch 中的 default 分支,但该分支必须放置在所有分支的最后。该段代码也可写为如下形式:

let x: u32 = 3;
match x {
    0 => println!("x = 0"),
    1 | 2 => println!("1 <= x <= 2"),
    3 | 4 | 5 => println!("3 <= x <= 5"),
    t => println!("x = {}, x > 5", t),
}

这里 t 是一个简单的标识符模式,由于检验对象 x 和模式 t 匹配,x 的值被赋值给模式中的变量 t 了。

再将前面给出的打印一天中时间段名称的 Go 代码转变为 Rust 代码,如下所示:

fn time_period_name(hour: f64) -> String {
    match hour {
        x if (1.0..5.0).contains(&x) => String::from("凌晨"),
        x if (5.0..8.0).contains(&x) => String::from("早上"),
        x if (8.0..11.0).contains(&x) => String::from("上午"),
        x if (11.0..13.0).contains(&x) => String::from("中午"),
        x if (13.0..17.0).contains(&x) => String::from("下午"),
        x if (17.0..19.0).contains(&x) => String::from("傍晚"),
        x if (19.0..23.0).contains(&x) => String::from("晚上"),
        x if (23.0..24.0).contains(&x) || (0.0..1.0).contains(&x) => String::from("晚上"),
        _ => String::from(""),
    }
}

以上代码中的 x 同样属于标识符模式,一旦匹配,hour 的值将赋值给 x。但 x 的后面还跟着 if (1.0..5.0).contains(&x) 形式的内容,这叫作匹配守卫(match guard)。匹配守卫是一个指定于 match 分支模式之后的额外 if 条件,它也必须被满足才能选择此分支。其中的 (1.0..5.0).contains(&x) 等价于 (x >= 1.0 && x < 5.0),有关 contains 方法的文档请参见此页

再把前面成绩区间判断的 Go 代码转写为 Rust 代码:

let score: u32 = 82;
match score {
    0..=59 => println!("不及格"),
    60..=100 => println!("及格"),
    _ => println!("无效的分数"),
}

其中的 0..=5960..=100 属于区间模式,它匹配处于由给定的上下界构成的闭区间内的值。

同 Go 的 switch 一样,Rust 的 match 也是不能向下穿透的,但 Rust 中不存在类似 Go 中的 fallthrough 来强制向下穿透。实际上,Rust 也不提倡各个分支的模式相互重合,如以下代码:

let score: u32 = 82;
match score {
    0..=60 => println!("不及格"),
    60..=100 => println!("及格"),
    _ => println!("无效的分数"),
}

它虽然能通过编译,但编译器会产生警告:“multiple patterns overlap on their endpoints”,因为第 1、2 个分支都包含 60。

另外,Rust 的 match 与 Go 的 switch 还存在一个重要差别,就是 match 表达式的匹配必须是穷尽的。例如:

let score: u32 = 82;
match score {
    0..=59 => println!("不及格"),
    60..=100 => println!("及格"),
    // _ => println!("无效的分数"),
}

将会产生编译错误:“non-exhaustive patterns: 101_u32..=u32::MAX not covered”。

Go 和 Rust 中都有 break 关键字。当 break 语句处在 Go 的 switch 语句中,且后面不跟标签时,它中断的是当前 switch 语句的执行;而当 break 表达式处在 Rust 的 match 表达式中,同样后面不跟标签时,它中断的是外层包括 match 表达式的循环表达式的执行。

match 表达式常用来检查枚举值,例如:

let x = Some(3);
match x {
    Some(0) => println!("x = 0"),
    Some(1) | Some(2) => println!("1 <= x <= 2"),
    Some(3) | Some(4) | Some(5) => println!("3 <= x <= 5"),
    Some(t) => println!("x = {}, x > 5", t),
    None => println!("null value"),
}

关于以上代码,将在以后介绍枚举类型时再解释。

除了以上模式,还有更多种类的模式就不再一一介绍了,详情请见 Rust 参考手册

借助模式语法,Rust 的 match 通常比 Go 的 switch 更简短,且能完成一些复杂的匹配,尤其是对元组、枚举、结构体等的解构十分方便。不过就简单的分支结构来说,Go 的 switch 通常更容易被掌握和理解。

Rust 的 if let 表达式

当只需要匹配一个模式而忽略其他模式时,可以用 if let 表达式代替 match 表达式,如下所示:

let score: u32 = 82;
match score {
    60..=100 => println!("及格"),
    _ => println!("不及格"),
}

这可改写为:

let score: u32 = 82;
if let 60..=100 = score {
    println!("及格");
} else {
    println!("不及格");
}

if let 后面放置通过等号分隔的一个模式和一个表达式,它是 match 的一个语法糖。

循环结构

循环结构可以使一段只出现一次的程序被连续多次执行,几乎所有编程语言都提供循环指令。Go 和 Rust 的循环结构虽然在使用的关键词、形式有区别,但能实现的功能却是一样的。

Go 的 for 语句

Go 中的循环语句只有一个,就是 for 语句。但该语句可以有多种形式,可以用于不同的情况。最完整的 for 语句形式如下:

for 初始化语句; 条件表达式; 后置语句 {
    // 循环体代码
}

其中:

  • 初始化语句一般是短变量声明语句,它最早执行,只执行一次;
  • 条件表达式在每次迭代之前都被重新计算,如其值为 true,则执行循环体代码,若为 false,则结束执行循环语句;
  • 后置语句在每次迭代执行完循环体代码后执行,它不能是短变量声明语句。

for 语句中的初始化语句、条件表达式、后置语句都可以被省略,这里直接通过示例说明。以下所有示例都是对整数类型切片 a(如令 a := []int{0, 1, 2, 3, 4, 5})的遍历,他们是等价的:

for i := 0; i < len(a); i++ {
    fmt.Println(a[i])
}

for i := 0; i < len(a); {
    fmt.Println(a[i])
    i++
}

for i := 0; ; {
    if i >= len(a) {
        break
    }
    fmt.Println(a[i])
    i++
}

for i := 0; ; i++ {
    if i >= len(a) {
        break
    }
    fmt.Println(a[i])
}

i := 0
for ; i < len(a); i++ {
    fmt.Println(a[i])
}

a := []int{0, 1, 2, 3, 4, 5}
i := 0
for i < len(a) {
    fmt.Println(a[i])
    i++
}

i := 0
for {
    if i >= len(a) {
        break
    }
    fmt.Println(a[i])
    i++
}

对于数组、指向数组的指针、切片、字符串、映射、从信道接收的值,也可以使用带 range 从句的 for 语句进行遍历。使用 for range 语句就不用担心数组或切片的索引越界,相对来说更简洁和安全。分别有如下示例:

// 索引和数值
for i, v := range a {
    fmt.Println(i, v)
}

// 舍弃索引
for _, v := range a {
    fmt.Println(v)
}

// 舍弃值
for i, _ := range a {
    fmt.Println(a[i])
}

// 只要索引
for i := range a {
    fmt.Println(a[i])
}

Rust 的 loop 表达式

loop 循环是无限循环表达式,相当于 Go 中没有初始化语句、条件表达式、后置语句的 for {} 语句。可以通过 ifbreak 表达式终止循环。如下所示:

let a = vec![0, 1, 2, 3, 4, 5];
let mut i: usize = 0;
loop {
    if i >= a.len() {
        break;
    }
    println!("{}", a[i]);
    i += 1;
}

其中 a 是一个向量(Vector),i 表示该向量的索引,其为 usize 类型,但也可以省略此类型,而让编译器确定其类型。

可以使用 break 表达式从循环中返回一个值:

let mut x = 0.0;
let f = loop {
    let v = 0.5 * x * x + 1.0;
    if v > 100.0 {
        break v;
    }
    x += 1.0;
};

Rust 的 while 表达式

以上给出的在 loop 循环中使用 ifbreak 返回的模式非常常见,可以使用 while 表达式替换以上模式,该表达式自带一个条件,只有当条件为 true 才执行循环:

let a = vec![0, 1, 2, 3, 4, 5];
let mut i = 0;
while i < a.len() {
    println!("{}", a[i]);
    i += 1;
}

如同 if let 表达式一样,Rust 同样有一个 while let 表达式用于进行模式匹配,仅当匹配时才执行循环。对于下面的代码:

let a = vec![0, 1, 2, 3, 4, 5];
let mut i = 0;
while i < a.len() {
    if let Some(v) = a.get(i) {
        println!("{}", v);
    }
    i += 1;
}

也可以将其改写为:

let a = vec![0, 1, 2, 3, 4, 5];
let mut i = 0;
while let Some(v) = a.get(i) {
    if i >= a.len() {
        break;
    }
    println!("{}", v);
    i += 1;
}

Rust 的 for 表达式

Rust 中同样有 for 表达式,但不像 Go 中的 for 语句那样灵活多样,它更像是 Go 中 for range 形式的语句,能更方便地对各种集合类型进行遍历。其形式为:

for 模式 in 迭代器表达式 {
    // 循环体代码
}

这里的迭代器表达式(简称迭代器)是一个新的概念。迭代器都实现了一个 trait:std::iter::IntoIterator,它负责遍历序列中的每一项和决定序列何时结束的逻辑。迭代器只有一个 next 方法,每次调用 next 方法,将返回迭代器序列中的一项,并将迭代器中用来记录序列位置的状态向后移动一次,即所谓消费了一个项。

for 表达式能够很好地和迭代器互动。创建迭代器的最简单的方法是使用区间标记 a..b,这将会生成从 a(包含此值) 到 b(不含此值)的,步长为 1 的一系列值。例如:

let a = vec![0, 1, 2, 3, 4, 5];
for i in 0..a.len() {
    println!("{}", a[i]);
}

对于集合类型数据,可以直接将他们当作迭代器来使用,for 循环会对给出的集合应用 into_iter 函数,把它转换成一个迭代器。例如:

let a = vec![0, 1, 2, 3, 4, 5];
for v in a {
    println!("{}", v);
}

以上代码和下面的代码完全等价:

let a = vec![0, 1, 2, 3, 4, 5];
for v in a.into_iter() {
    println!("{}", v);
}

into_iter 在每次迭代中,将提供集合中的数据本身。该函数会消耗集合,一旦集合被消耗了,之后就无法再使用了,因为它的所有权已经在循环中被移动了。例如:

let langs = vec!["Go", "Rust", "Java"];
for lang in langs.into_iter() {
    match lang {
        "Rust" => println!("{} 语言有点复杂", lang),
        _ => println!("{} 语言比较简单", lang),
    }
}
// println!("langs: {:?}", langs);

如果把上面后一个语句前面的注释取消,将无法通过编译,因为 langs 的所有权在上一个循环中已经被转移了。

还可以使用 iter 函数生成迭代器。iter 在每次迭代中借用集合中的一个元素。这样集合本身不会被改变,循环之后仍可以使用。下面的代码是可以编译和执行的:

let langs = vec!["Go", "Rust", "Java"];
for lang in langs.iter() {
    match lang {
        &"Rust" => println!("{} 语言有点复杂", lang),
        _ => println!("{} 语言比较简单", lang),
    }
}
println!("langs: {:?}", langs);

另外,还可以使用 iter_mut 函数生成迭代器。iter_mut 可变地借用集合中的每个元素,从而允许集合被就地修改。如下所示:

let mut langs = vec!["Go", "Rust", "Java"];
for lang in langs.iter_mut() {
    match lang {
        &mut "Rust" => {
            println!("{} 语言有点复杂", lang);
            *lang = "C++";
        },
        _ => println!("{} 语言比较简单", lang),
    }
}
println!("langs: {:?}", langs);

如果还要同时获得索引,就要使用迭代器的 enumerate 方法,该方法产生迭代器中一项对应的索引和值构成的元组。如下所示:

let a = vec![0, 1, 2, 3, 4, 5];
for (i,v) in a.iter().enumerate() {
    println!("{} {}", i, v);
}

其他结构

除了以上控制流结构外,两种语言都使用 returnbreakcontinue 关键词进行流控制,两种语言也都支持给代码加标签。其主要区别包括:

  • 在 Go 中,这些关键词所表征的为语句,而 Rust 中,这些关键词所表征的为表达式。
  • Go 中的 break 语句可以终止最内层的 forswitchselect 语句的执行;而 Rust 中的 break 则只终止各种循环体的执行。
  • Go 中的 break 后面可以跟一个标签,该标签对应到具体的 forswitchselect 语句;Rust 中的 break 后面除了能跟循环标签外,还能跟表达式。
  • Go 中的标签可以加在任何代码的前方,它可以是 gotobreakcontinue 的跳转目标;Rust 中的标签只能加在循环表达式前面,称为循环标签,是 breakcontinue 的跳转目标,标签名称前面还必须有一个额外的单引号 '

另外,Go 中仍然保留了 goto 语句,这在特殊场合下仍然有用。

讨论

本来觉得用较小的篇幅就能讲清楚本文,然后结果这个篇幅却不算小,并且难度也不低。对于 Go,其各种控制流语句仍然保持简单,不需要涉及太多的知识。但 Rust 就复杂很多,从一开始就涉及到许多较为复杂的概念,如模式匹配、迭代器、所有权等,Rust 巧妙地将各种较为复杂的设计元素编织在一起形成一个语言系统,这固然很强大,但却导致我们难以循序渐进地学习该语言——难怪大家都说 Rust 的学习曲线比较陡峭!