C++ 玩家的 Rust 学习笔记(中)

函数声明

Rust 使用 fn关键字来声明和定义函数,用来取代 C++ 中继承自 C 的函数声明语法。在 C 中这个方式完全没有问题,但是 C++ 中的函数声明和变量定义很容易让人混淆,所以才在 C++11 引入大括号初始化来避免混淆,举个例子,对 C++ 而言:

#include <thread>
int main() {
    std::thread I_am_a_function_declaration();
    std::thread I_am_an_instance_defination{};
}

这就很容易让人崩溃。C++ 之后的很多语言都采用了关键字用来注明函数声明,就像 deffunfunc 之类的,所以 Rust 采用新的标记注明函数也是很合理的,不过 Rust 的这个关键字大概是我见过的最短的。

fn main() {
    fn I_am_a_function_declaration() -> std::thread::Thread {
        // ...
    }
    let I_am_an_instance_definination : std::thread::Thread;
}

函数的返回类型在尾部注明,这个格式倒是和 C++11 引入的尾置返回类型一样,虽然也不知道 C++ 向哪里学的。因为 Rust 对于 tuple 有原生的语言支持,所以可以很自然地用 tuple 实现函数的多返回值,相对而言 C++ 的 std::tuple 是 C++11 的库特性,使用的时候总有顾虑。

Rust 的函数不需要声明再使用,反正编译器会全文搜索函数,我觉得这样才算一个正常的现代编程语言, C/C++ 的那一套实在是积重难返,都是历史包袱。

Rust 的大部分函数不允许重载,只有部分运算符函数可以重载。普通函数可以用 trait 提供不太强大的重载能力,不过意义不大。 Rust 认为重载的意义不大,不如对于接受不同参数的函数提供不同的名称以提高程序的可读性。虽然就我个人而言很喜欢重载的,但是重载也有它的弊端,比如说 ABI 会很丑陋……大部分平台下 C 的 ABI 就很简单, C++ 的实在是不忍直视。话虽如此, Rust 有 trait , ABI 也简单不到哪里去。

Rust 的函数声明大概有这么几种模式:

  • fn name() { /* ... */ } ,无返回类型无参数的函数 name
  • fn name(arg: i32) { /* ... */ } ,无返回类型接受一个 i32 参数 arg 的函数 name
  • fn name(arg0: i32, arg1: u32) { /* ... */ } ,无返回类型接受 arg0 arg1 作为参数的函数。
  • fn name() -> i32 { /* ... */ } ,返回一个 i32 不接受参数的函数 name
  • fn name() -> ! { /* ... */ } ,一个永远不会返回的函数。

匿名函数

Rust 中的匿名函数相比 C++ 的简单一点,采用 |arguments| 开头作为匿名函数的标志,大概以下几种模式

  • || { } ,一个什么都不干什么都不接受的空匿名函数。
  • |x: i32| -> i32 { x + 1 } ,一个接受一个 i32 作为参数返回一个 i32 的匿名函数。
  • |x| -> i32 { x + 1 } ,一个接受暂时未知类型 x 返回 i32 的函数, x 类型会在第一次调用的时候绑定,其后不能用其它类型调用这个匿名函数。
  • |x| { x + 1 } ,一个接受暂时未知类型 x 返回未知的函数,返回类型会由编译器推导。
  • |x| x + 1 ,一个接受暂时未知类型 x 返回 x + 1 的函数。
  • |x| x + y ,一个接受暂时未知类型的 x ,捕获环境中 y ,返回 x + y 的函数。
  • move |x| x + y ,一个接受暂时未知类型的 x ,捕获并转移环境中的 y ,返回 x + y 的函数。

闭包的捕获还是有点难受的,捕获的变量相当于是传了一个借用进去,如果需要修改的话就是一个可变借用(编译器自行推导),那么在其它地方就不能再创造更多借用了。因此如果外部的数据出了生命周期了,带着借用的匿名函数也不能继续生存,也就是匿名函数的生存周期必须小于捕获的变量的生存周期。如果需要延长生命周期,那就必须要转移变量所有权,在前面加一个 move 将捕获到的变量的所有权移动进匿名函数中。

和 C++ 的匿名函数相比较, C++ 没有什么生命周期的概念,所以也没有不会有 move 来提供转移所有权的操作。 C++ 的匿名函数,捕获值抑或捕获引用是程序员指定的,而 Rust 中只有引用,可变或不可变是编译器自行判断的。之所以不提供捕获值,是因为这种行为实际可以交给程序员,拷贝一份然后传入引用即可,正因如此,很多语言默认都是捕获引用的(不过这些语言函数传参都是直接引用的……)。

结构声明

Rust 使用 struct 关键字声明结构体成员变量,使用 impl 关键字为结构体定义成员函数,成员函数使用 self 指代自身, Self 指代当前类,impl 块中的函数的参数中没有 self 的被视为静态函数。结构体成员默认都是私有的,需要声明为共有需要在声明前使用 pub 关键字。

此外,之前随口一提的 for 的另一个用处就是在为结构体 impl trait 的时候指明是哪个结构体, implement for 嘛。

这里和 C++ 的差别都不大,简单写个例子帮助理解。

pub struct Movable {
    id: usize,
}

impl Movable {
    pub fn new(id: usize) -> Movable {
        println!("construct Movable {}.", id);
        return Movable {
            id,
        };
    }
}

impl Drop for Movable {
    fn drop(&mut self) {
        println!("destruct Movable {}.", self.id);
    }
}

pub struct Copyable {
    id: usize,
}

impl Copyable {
    pub fn new(id: usize) -> Copyable {
        println!("construct Copyable {}.", id);
        return Copyable {
            id,
        };
    }
}

impl Clone for Copyable {
    fn clone(&self) -> Self {
        println!("copy Copyable {}.", self.id);
        return *self;
    }
}

impl Copy for Copyable {
}

不过 Rust 会自己帮你实现 trait std::marker::Copy ,不会去调用 clone ,所以不会被打印出来。

枚举声明

Rust 中的 enum 和 C/C++ 中的 enum 很不一样,应当认为是 C++17 的 std::variant 。这是 Rust 中一个很强力的工具。简单的 enum 声明和 struct 类似。也可以为枚举类型定义成员函数,也可以单独为每一个成员特化成员函数,形式上和结构体类似。

// C-like enum
enum HttpStatus {
    OK = 200,
    Forbidden = 403,
    NotFound = 404,
    Others,
}
// Rust likes enum
enum RequestResult {
    OK(String),
    NotOK {
        statusCode: i32,
        message: String,
    },
}

之所以说 Rust 的 enum 是 Rust 中很强大的工具,首先, Rust 中并没有“空”这个东西,也就是其它语言中的 null NULL nullptr nil 或者什么乱七八糟的 NSNull ,就算 Rust 中有指针,但是对指针的解引用都是 unsafe 的,在我现在讨论的 safe Rust 中几乎没有任何用武之地。 Rust 保存空值使用的就是 std::option::Option ,也就是 C++17 中的 std::optinal ,我们也知道 std::optinal<T> 实际上可以认为是一个 std::variant<T, std::nullopt_t> , Rust 中同理, std::option::Option 就是一个 enum

正如现在很多新兴的语言不建议使用“空”值,比如 Kotlin 中类型就分不可空类型和可空类型(实际上就是普通类型和套了个 std::option::Option 的普通类型), Swift 也从 Rust 中学来、引入了 Optional 和与 Rust 限制同样强的 switch 。这正是最近在各种语言中出现的,用来解决所谓的“数十亿美元损失的发明”、也就是空指针的一种方案。

另一个比较常用的一个内置结构体是 std::result::Result ,常用作函数返回类型。 std::result::Result 中有两种类型,一个是Ok ,保存着如果函数执行正确返回的值,另一个是 Err ,保存着如果函数执行出错返回的错误信息。

enum 可以使用 match 来匹配,同样的, match 作用域内不能遗漏任何情况(虽说可以用 _ 偷懒)这个特性就保证了不会有任何错误的情况被遗漏。当然,为了避免每次都判断太繁琐, Rust 还提供了几个特殊的语法糖来帮助我们缓解关节疲劳。

生命周期

考虑一个接受两个引用(或以上)的函数,如果最终结果是返回一个引用,编译器在没有任何提示的情况下没法确定最后是哪个引用被返回了出来,也就没法确定这些引用的生命周期。为了所有的数据的生命周期都能被确定,必须要显式指明引用的生命周期。 Rust 中采用一个引号开头接一个标识符来声明一个生命周期,比如在函数中:

fn lexicographical_minimum<'a>(lhs: &'a String, rhs: &'a String) -> &'a String {
    if rhs < lhs { rhs } else { lhs }
}

fn lexicographical_maximum<'a>(lhs: &amp;'a String, rhs: &'a String) -> &'a String {
    if rhs < lhs { lhs } else { rhs }
}

(妈呀, PrismJS 处理 Rust 的生命周期标志当作字符串了,这不是挂了吗……算了就将就看吧)

实际上这是泛型的声明语法, 'a 在这里可以看作是一个 trait ,代表生命周期的类型,如果表示一个生命周期在另一个生命周期内,用 'a: 'b 的语法标明,如果表示一个类型属于这个生命周期,用 T: 'a 表示。生命周期还有一个保留类型,就是 'static ,静态生命周期,任何字符串字面量都是一个 &'static str ,不过可以省略 'static 。具体的 trait 介绍在下一部分。

同样可以给结构体和枚举类型指定生命周期,也和 trait 的指定类似,放到下一节略过了。

错误处理

Rust 的错误处理哲学和 C/C++ 不同。在 C 中,函数错误一般返回一个负值(指针则用 NULL )表示出错,由程序员通过其它手段检查错误。 C++ 中,除了 C style 错误检查,还可以使用 try throw catch 来传递错误。即使在新兴语言中我们也仍然可以看到在使用这两种错误处理方式,但是它们都需要手动处理错误,手动回收资源。

但是这两种手段都很容易出现忘记处理错误、遗漏错误的情况,直接导致程序崩溃,打印出个调用栈还容易定位错误,如果程序带着错误往下跑才是最难检查的。 Rust 中,错误类型用 enum 实现,配合 match 必须覆盖所有可能的语法要求,可以相对有效地防止程序员脑残手抽。这个方式相对而言更类似和 C 或者 Go 需要手动处理和传递 error 相比, Rust 提供了语法糖可以直接向下传递错误,或者提供了函数直接转化 Result::Errpanic

另一种情况是panic ,这个是和异常类似的方式,如果不处理会直接层层退栈,但是 Rust 中一般不建议从 panic 恢复,它代表的是致命错误。

学着 Rust 官方教程写了个猜数字例子来做个演示。

extern crate rand;

use rand::Rng;

fn main() {
    println!("Guess the number!");
    println!("Please input your guess.");

    let secret = rand::thread_rng().gen_range(1, 101);

    loop {
        let mut guess = String::new();

        std::io::stdin().read_line(&mut guess)
            .expect("Failed to read line.");  // Turn Err to panic!

        let guess: u32 = match guess.trim().parse() {  // handle Err manually.
            Ok(num) => num,
            Err(e) => {
                println!("Invalid number, retry!");
                continue;
            },
        };

        match guess.cmp(&secret) {
            std::cmp::Ordering::Less      => println!("Too small!"),
            std::cmp::Ordering::Greater   => println!("Too big!"),
            std::cmp::Ordering::Equal     => {
                println!("You win!");
                break;
            }
        }
    }
}

Rust 还为错误处理提供了一点语法糖。如果仅仅是返回一个 Result ,然后每次处理一下往下传,那其实和 C 的错误代码风格没什么两样。 Rust 除了 RAII 可以免去手动回收资源以外,在遇到错误可以用一个 ? 直接外传错误。也就是一个返回 Result 的函数里如果调用了另一个返回 Result 的函数,直接在后面接一个 ? 就可以在里层返回一个 Err 的时候直接返回这个 Err

fn inner_err() -> Result<(), &'static str> {
    Err("inner_err!")
}

fn outer_err() -> Result<(), &'static str> {
    // try to invoke inner_err
    let result = inner_err()?;
    Err("outer_err!")
}

小结

从生命周期和错误处理开始,才是真正的 Rust 风格的编程。 Rust 殚精竭虑为我们提供了内存安全的编程语言,不再饱受 NullPointerException 之苦,多亏了这两个东西啊。

不过说实话,生命周期真的是导致编译错误的、程序员大敌呢,想要处理这种编译错误还要分析逻辑分析个半天,还不如我不管三七二十一一通调试定位错误。

借用知乎上的一句话:

Rust :编译时想撞墙

C++ :运行时想跳楼

关于面向对象和更高级的用法,下一个部分再见喽。

发表评论

电子邮件地址不会被公开。 必填项已用*标注