Rust 的函数以及 if 控制流

发布时间 2023-03-30 20:59:03作者: 古明地盆

楔子

本篇文章来说一说 Rust 的函数和流程控制,首先 Rust 使用蛇形命名法(snake case)来作为函数和变量的命名风格,蛇形命名法只使用小写的字母进行命名,并以下画线分隔单词。

fn main() {
    another_func();
}

fn another_func() {
    println!("hello world");
}

执行完之后屏幕会打印 hello world,需要注意的是,我们在这个例子中将 another_func 函数定义在了 main 函数之后,这是允许的。Rust 不关心你在何处定义函数,只要这些定义对于使用区域是可见的即可。

函数参数

在函数声明中可以定义参数(parameter),它们是一种特殊的变量,并被视作函数签名的一部分。当函数存在参数时,需要在调用函数时为这些变量提供具体的值。

参数变量和传入的参数值有自己对应的名称,分别是 parameter 和 argument,也就是通常说的形参和实参。但我们很多时候会混用两者,并将它们统一地称为参数而不加以区分。

我们举个例子:

fn main() {
    another_func(3, 4);
}

fn another_func(a: i32, b: i32) {
    println!("a + b = {}", a + b);
}

执行完之后屏幕会打印 a + b = 7,在函数签名中,必须显式地声明每个参数的类型。这是 Rust 设计者经过慎重考虑后做出的决定:由于类型被显式地注明了,因此编译器不需要通过其他部分的代码进行推导就能明确地知道你的意图。

当然函数参数可以是不同类型的,当前只是恰好使用了两个 i32 类型的参数而已。

语句和表达式

函数体由若干条语句组成,并允许以一个表达式作为结尾。由于 Rust 是一门基于表达式的语言,所以它将语句(statement)与表达式(expression)区别为两个不同的概念,这与其它一些语言不同。因此让我们首先来看一看语句和表达式究竟是什么,接着再进一步讨论它们之间的区别会如何影响函数体的定义过程。

语句指那些执行操作但不会返回某个具体值的指令,而表达式则是指会进行计算并产生一个值作为结果的指令。

用大白话解释就是,表达式本质上就是一个值,比如 4 + 6 是一个表达式、5 * 2 也是一个表达式,计算之后它们的结果都是 10,就是一个单纯的值,甚至 10 这个数字本身也是表达式。表达式是可以作为右值的,也就是可以赋值给一个变量,但是语句不行,语句不是一个值,所以它不能赋值给一个变量,比如 let 语句。

fn main() {
    // let x = 6; 就是一个语句
    let x = 6;
    // 但是我们不能把它赋值给一个变量
    // 这么做是不对的
    let y = (let x = 6);
}

因为 let x = 6 没有返回值,所以变量 y 就没有可以绑定的东西,这个和 Python 是类似的。可能用过 Python 的人会觉得好奇,y = x = 6 在里面明明是合法的,没错,只不过这是 Python 的链式赋值,它表示将 y 和 x 都赋值为 6。如果我们改一下,举个例子:

>>> y = x = 6
>>> 
>>> y = (x = 6)
  File "<stdin>", line 1
    y = (x = 6)
           ^
SyntaxError: invalid syntax
>>> 

y = (x = 6) 是会报出语法错误的,因为 x = 6 是一个语句,并且是赋值语句(assignment),而语句不可以作为右值赋给一个变量。

另外表达式本身也可以作为语句的一部分,比如 let a = 3 + 3; let b = 3 * 2; let c = 6; 里面的 3 + 3、3 * 2、6 均是表达式,它们都会返回 6 作为自己的计算结果。

然后在 Rust 中,如果结尾加上了分号,那么就是语句,不加分号,那么就是表达式(或者编译错误)。怎么理解呢?以 3 + 3 为例,这显然是一个表达式,但在 Rust 中只有不加分号才是一个表达式,而加上了分号,它就不再是表达式了,而是会变成语句;再比如 let a = 1,出现了 let 就表明这一定是一个语句,所以它的后面一定要加分号。

总结一下就是:语句的后面一定要加分号,如果不加分号,那么它要能够成为表达式。比如 3 + 3,加了分号是语句,不加分号能够作为表达式,所以没有问题;但是 let a = 3 如果不加分号,那么也会尝试作为表达式存在,但很明显它不可能是一个表达式,因此它一定要有分号。

可能有人好奇我为什么要说这些,因为语句和表达式在 Rust 中非常重要,不理解的话很容易乱。好了,光说的话也不好理解,下面就来看看函数的返回值,这里的内容就是为它做铺垫的。

函数的返回值

函数可以向调用它的代码返回某个值,虽然我们不用为这个返回值命名,但需要声明它的类型。在 Rust 中,函数的返回值等同于函数体最后一个表达式的值。我们可以使用 return 关键字并指定一个值来提前从函数中返回,但大多数函数都隐式地返回了最后的表达式。下面是一个带有返回值的函数示例:

fn six() -> i32 {
    let a = 5;
    a + 1
}

fn main() {
    println!("{}", six());  // 6
}

six 函数中的 a + 1 就是一个表达式,而 Rust 函数中如果没有 return,那么就返回最后一个表达式的值。接下来我们修改一下代码:

fn six() -> i32 {
    let a = 5;
    a + 1;
}

fn main() {
    println!("{}", six()); 
}

注意:我们在 a + 1 后面加上了分号,然后执行之后会报错,提示我们:expected `i32`, found `()`。我们说 a + 1 是一个表达式,但是一旦加上了分号,那么就变成了语句。而在没有 return 的时候,Rust 的函数会返回最后一个表达式的值,如果没有表达式,那么就返回一个空元组。但我们声明函数的返回值类型是 i32,因此类型不匹配。从这里也能看出,一个函数如果不声明返回值类型,那么返回值默认是空元组。

然后再来测试一下语句:

fn six() -> i32 {
    let a = 6
}

fn main() {
    println!("{}", six()); 
}

这段代码也是无法通过编译的,语句的后面必须要有分号,没有分号要能够成为表达式。但 let a = 6 只可能是语句,它无法成为表达式,因此后面必须要加分号。

另外函数只能有一个表达式,并且要在最后面,如果有多个表达式,或者表达式下面还有内容,那么也会编译出错。

fn f1() -> i32 {
    6
    6
}

fn f2() -> i32 {
    6
    6;
}

f1 和 f2 都是不合法的,因为 f1 中出现了多个表达式;f2 中的表达式不在最后,它的后面还有内容。

表达式非常非常非常重要,并且函数调用是一个表达式、宏调用是一个表达式,我们再举几个例子感受一下表达式。

// 下面这几个函数算是总结了表达式在函数中的用途
// 并且这些函数都是正确的

fn f1(){
    // 3 + 4 后面有分号,所以整体是一个语句
    // 如果没有分号,那么它就是一个表达式
    // 这里 f1 没有声明返回值类型,那么应该返回空元组
    // 而函数里面如果没有 return,那么会用最后一个表达式作为返回值
    // 如果没有表达式,则返回空元组,所以此时没有问题
    3 + 4;
}

fn f2() -> i32 {
    // 这里 3 + 4 后面没有分号,它是一个表达式
    // 并且它下面没有内容了,所以会作为函数的返回值
    // 此时返回值和函数签名是匹配的
    3 + 4
}

fn f3() -> i32 {
    // 因为函数调用会返回一个值
    // 所以它可以作为一个表达式
    // 这里会返回函数 f2 的返回值
    f2()
}

// -> () 可以省略,因为默认返回空元组,这里我们故意写出来
fn f4() -> () {
    // 注意这里是宏调用,我们没有加分号
    // 因为宏调用也可以是一个表达式
    // 那么 f4 函数就会返回这个宏调用所返回的值
    // 而 println! 返回的也是一个空元组
    println!("hello world")
}

fn f5() {
    // 相比 f4,我们在这个宏调用后面加上了分号
    // 那么它就不再是表达式,而是语句
    // 而 f5 的最后没有表达式,那么默认也是返回一个空元组
    // 所以 f4 和 f5 是等价的
    println!("hello world");
}

fn f6() -> u8 {
    // 这次的宏调用后面必须有分号
    // 因为一个函数只能有一个表达式,并且在最后面
    println!("hello world");
    33u8
}

需要说明的是,上面的表达式作为返回值都是在没有 return 的前提下进行的,如果有 return,那么以 return 为准。

fn f1() -> f64 {
    // 有 return 则以 return 为准,所以会返回 3.14
    // 但是很明显,return 后面不应该再有东西
    // 不过这个函数是没有问题的
    return 3.14;
    3.15
}

fn f2() -> f64 {
    // return 可以加分号成为语句,也可以不加分号作为表达式
    // 两者等价,只不过当不加分号的时候,下面不可以再有内容
    // 因为函数只能有一个表达式,并且在最后面
    return 3.14
}


fn main() {
    println!("{}", f1());  // 3.14
    println!("{}", f2());  // 3.14
}

然后最神奇的来了,大括号也是有返回值的,我们说大括号可以用来创建一个新的作用域:

fn main() {
    let a = 123;
    {
        let a = 234;
        println!("(1) a = {}", a);
    }
    println!("(2) a = {}", a);
    /*
    (1) a = 234
    (2) a = 123
     */
}

相信原因不需要解释,就是一个作用域范围的问题。这在 C 和 Go 里面也是如此,但在 Rust 中它还有其它用法,就是作为返回值。

fn main() {
    // 这个大括号,可以想象成在调用一个没有参数的匿名函数
    // 最后一个表达式的值就是返回值,当然同样要遵循以下规则
    // `只能有一个表达式、并且在最后面`
    // 并且不可以使用 return,如果使用 return
    // 那么 return 针对的是这个大括号当前所在的函数
    let x = {
        let x = 123;
        x + 1
    };
    // 因此最终这个 x 就是 124
    println!("{}", x);  // 124

    // 如果没有表达式,那么返回的是空元组
    // 这个和函数的处理方式是一样的
    let y = {
        let x = 123;
        x + 1;
    };
    println!("{:?}", y);  // ()
}

Rust 的表达式非常有趣且实用,但由于在其它语言中很少会做语句和表达式之间的区分,因此在初次使用的时候可能会有一些不适应。下面介绍流程控制的时候还会遇到,因此我们多花些时间了解它是非常值得的。

if 表达式

通过条件来执行或重复执行某些代码是大部分编程语言的基础组成部分,在 Rust 中用来控制程序执行流的结构主要就是 if 表达式与循环表达式。

if 表达式允许我们根据条件执行不同的代码分支,我们提供一个条件,并且做出声明:假如这个条件满足,则运行这段代码;假如条件没有被满足,则跳过相应的代码。

fn main() {
    let x = 123;
    
    if x > 100 {
        println!("x > 100");
    } else {
        println!("x <= 100");
    }
}

值得注意的是,代码中的条件表达式必须生成一个 bool 类型的值,否则就会触发编译错误。比如我们写成 if x,那么编译的时候会出现如下错误:expected `bool`, found integer

除了 if、else ,我们还可以使用 else if 实现多重条件判断:

fn main() {
    let score = 88;

    if score > 90{
        println!('A');
    } else if score > 80 {
        println!('B');
    } else if score > 60 {
        println!('C');
    } else {
        println!('D');
    }
}

我们说 if 是一个表达式,那么它显然可以作为返回值:

fn f(a: i32, b: i32) -> i32 {
    let res = a + b;
    // 保证 a + b 在 0 到 100 之间
    if res > 100 {
        100
    } else if res < 0 {
        0
    } else {
        res 
    }
}

可能有人感到疑惑了,不是说函数里面只能有一个表达式吗?为什么这里出现了 3 个。原因是这里的 3 个表达式是在一个 if 里面,并且最终只会走一个分支,所以整体还是相当于只有一个表达式。如果我们改一下:

fn f(a: i32, b: i32) -> i32 {
    let res = a + b;
    // 保证 a + b 在 0 到 100 之间
    if res > 100 {
        100
    } else if res < 0 {
        0
    } else {
        res
    }
    100
}

此时就不行了,上面的代码是不合法的,因为 if 表达式下面还有表达式,违反了我们之前说的原则。如果想使代码合法的话:

fn f(a: i32, b: i32) -> i32 {
    let res = a + b;
    if res > 100 {
        100
    } else if res < 0 {
        0
    } else {
        res
    };  // 加个分号
    100
}

我们只需要在 if 表达式的结尾加上分号让它从表达式变成语句即可,假设 res = 105,那么整个 if 逻辑就等价于 100;,假设 res = -5,那么整个 if 逻辑就等价于 0;。它们都是语句,不是表达式,因为结尾有分号,所以此时会返回 if 下面的 100,结果是没有问题的。

或者还有一种做法:

fn f(a: i32, b: i32) -> i32 {
    let res = a + b;
    if res > 100 {
        100;
    } else if res < 0 {
        0;
    } else {
        res;
    }
    100
}

让 if 表达式里面的每一个分支都不要出现表达式,这样整个 if 表达式显然会返回一个空元组。但 Rust 这里会进行特殊处理,当 if 表达式返回空元组时,如果它的下面还有内容,那么该 if 表达式返回的空元组会被忽略掉,所以这里会返回最后的 100。

当然下面的做法也可以:

fn f(a: i32, b: i32) -> i32 {
    let res = a + b;
    if res > 100 {
        100;
    } else if res < 0 {
        0;
    } else {
        res;
    };  // 加上分号
    100
}

在每个分支的表达式后面加上分号,让其变成语句,所以无论 res 为多少,这个 if 逻辑执行之后的结果都等价于 ();。因此如果不希望 if 表达式作为返回值,那么就让 if 表达式里面的每一个分支都不要出现表达式,这样当 if 下面还有内容时,就会忽略掉这个 if 表达式(返回的空元组);或者更直接点,在整个 if 的结尾加上分号,让它成为语句,而语句不会作为返回值。

注意:我们这里说的 if 表达式,指的是 if elif else 整体。

另外,如果 if 表达式作为了返回值,那么一定要出现 else 分支。

fn f(a: i32, b: i32) -> i32 {
    let res = a + b;
    if res > 100 {
        100
    } 
}

此时会出现编译错误,因为一旦这个 if 分支不走的话,那么就会返回空元组,此时和函数的返回值签名就矛盾了。即使我们把条件改成 true 也是如此:

fn f() -> i32 {
    if true {
        100
    }
}

虽然这个分支百分百会走,但是 Rust 编译器却不这么想,它要求 if 表达式作为返回值的时候必须包含 else 分支。

fn f() -> i32 {
    if true {
        100
    } else {
        200
    }
}

上面这个语句是合法的,另外,既然 if 表达式可以作为函数的返回值,那么它也可以赋值给一个变量,因此我们可以轻松地实现三元表达式。

fn f(score: i32) {
    let degree = if score > 90 {
        'A'
    } else if score > 80 {
        'B'
    } else if score > 60 {
        'C'
    } else {
        'D'
    };
    println!("{}", degree);
}

fn main() {
    // 上面的赋值语句就等价于
    // let degree = 'B';
    f(87); 
    // 上面的赋值语句就等价于
    // let degree = 'A';        
    f(95);
    /*
    B
    A
    */
}

if 表达式作为返回值、或者赋值给一个变量,那么每个分支都要返回相同类型的值,看一段错误示例:

fn f(a: i32, b: i32) {
    let res = if a + b > 100 {
        100.0
    } else if a + b < 0 {
        0
    } else {
        a + b
    };
}

因为变量只能拥有单一的类型,而 if 分支返回了浮点数,else if 分支返回了整数,所以这段代码无法通过编译。Rust 需要在编译阶段就确定变量的类型,但是每个分支返回的值的类型是不同的,这就导致了 Rust 只能在运行时才可以确定变量 res 的类型究竟是什么。

显然这么做会降低 Rust 的运行效率,因为这会让 Rust 编译器不得不记录变量可能出现的所有类型,并且还会使编译器的实现更加复杂,并丧失许多代码安全保障,因此 Rust 要求 if 表达式的每个分支返回的值都是同一种类型。

说了这么多,主要是想解释清楚表达式在 Rust 当中的作用,因为 Rust 是基于表达式的语言,而在其它语言中没有特别区分表达式和语句,所以在学习 Rust 的时候可能有稍稍的不适应。因此这里着重介绍表达式,可能有一些绕,但把整个流程控制全部看完之后一定可以理解。

最后再举个例子,总结一下:

fn main() {
    let res = 100;
    let x = {
        if res > 100 {
            // 宏 println! 返回的是空元组
            println!("hello world")
        } else if res < 0 {
            println!("hello world")
        } else {
            ()
        }
        33
    };
    println!("x = {}", x);
    /*
    hello world
    x = 33
     */
}

let x 等于一个大括号,那么大括号里面的最后一个表达式就会赋值给 x。大括号里面的 if 的结尾因为没有加分号,所以它是一个表达式,而该表达式的每一个分支最后返回的都是空元组,所以整个 if 表达式返回的也是空元组。

当 if 表达式返回的是空元组时,Rust 会进行额外处理:如果 if 表达式下面没有内容了,那么显然就直接返回空元组;如果还有内容,那么 if 表达式返回的空元组就会被忽略掉,因此最后会返回 33。

建议:如果不希望 if 表达式作为返回值,那么最好显式地在结尾、也就是 else 分支后面加上一个分号,直接让它变成语句,这样最保险、也最方便。

小结

本篇文章介绍了函数和 if 控制流,但说实话这些内容应该都没什么难度,和其它语言是类似的。但语句和表达式之间的区别非常重要,需要我们花时间去体会。