6

一起来生锈吧:Rust 里那些有趣的设计

 3 years ago
source link: https://zhuanlan.zhihu.com/p/111177782
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

一起来生锈吧:Rust 里那些有趣的设计

最近突(bei)发(po)奇(wu)想(nai)地想写一篇博客,但是想了半天也不知道写啥。如果深入地讲一些大家都熟悉的技术,可能辛辛苦苦地搜索整理出来后还没大家本来就知道的深入,肯定是会被嘲讽的。没办法,只好来浅浅地谈一谈大家都没了解过的技术,不能在内容上有深度,但是至少可以在形式上看起来有深度啊 :)。

要是提到当前热门的编程语言里哪个最难学,Rust 肯定不会缺席。可能也正是这个原因,很多人只听过 Rust 的大名,却没想去学习它的念头。当然我也没有去深入的学习,只是简单的了解了一下它比较重要的几个特性,感觉比较有趣。所以想在这里分享出来,做一个勤劳的搬运工。我只是带大家显微镜中窥蟹,看完这篇博客并不会让你学会 Rust ,只是对 Rust 有个大概的了解。如果看完觉得有趣,可以进一步深入地学习。如果看完觉得也就这样,那你下次完全可以自信地说:呵!Rust也不过如此(逃。

Rust 社区将 Rust 的用途分成了四个不同的领域,分别是命令行、WebAssembly、网络和嵌入式。并且有各自的团队分别去提升在各个领域中的编程体验。这就意味着,学了 Rust 就有了写客户端、服务器和嵌入式程序的能力了!我喜欢这种万金油语言,它让我有了可以误以为我啥都会的资本。人生苦短,还是要多多地犒劳自己。

作为一个画页面的,我还是比较关注 Rust 在 WebAssembly 方向上的能力。Rust 社区提供的一套工具,可以直接将 Rust 项目编译成 WebAssembly 并直接发布到 npm。然后在 web 项目里直接通过 npm 安装后就可一直接在前端或 node 项目里导入使用了。没错,就是这么的方便!

Rust 官网列出了三个选择 Rust 的理由:高性能、可靠性和生产力。其中高性能里是这样解释的:

Rust 速度惊人且内存利用率极高。由于没有运行时和垃圾回收,它能够胜任对性能要求特别高的服务,可以在嵌入式设备上运行,还能轻松和其他语言集成。

大部分的语言都有运行时,且在运行时里还有垃圾回收机制,可是 Rust 没有。现在国家都在大力推动垃圾分类,其目的也是垃圾回收,Rust 怎么能没有呢,没有的话岂不是会造成严重的污染。所以可靠性这一理由是这样被描述的:

Rust 丰富的类型系统和所有权模型保证了内存安全和线程安全,让您在编译期就能够消除各种各样的错误。

这里说到一个比较陌生的词 所有权 ,说它可以保证内存安全,而不会造成内存污染,而且还是在编译期就能避免,而不用在执行期占用额外的资源。但是什么是 所有权 呢,我以前可从来没听说过呀。这就是 Rust 的一个比较核心的概念:所有权

现在主流的内存管理方式有两种:垃圾回收和手动管理。垃圾回收机制就像实行垃圾分类之前,产生了一个垃圾后直接扔地上或垃圾桶里,保洁人员会定期进行清理。手动管理就像现在执行了垃圾分类,产生了一个垃圾自己归好类后再扔进特定的垃圾桶里。保洁人员过多的话会占用过多的资源,且不能保证垃圾即时地被回收了。而自己每产生一个垃圾就要手动归类放到指定的位置又过于麻烦。那你有没有想过这样一种可能呢:当你产生一个垃圾后直接一扔,它就会自动飞到垃圾处理厂里去进行进一步处理然后回收。对!在 Rust 里就是这样,这就是 所有权 模型 。

  • 所有权 (ownership)

官方文档里描述了所有权有以下规则:

1. Rust 中的每一个值都有一个被称为其 所有者owner)的变量。
2. 值有且只有一个所有者。
3. 当所有者(变量)离开作用域,这个值将被丢弃。

让我们先来看一个代码片段(请忽略语法细节,大概能看懂就行):

fn say_hello() {            // hello 在这里无效, 它尚未声明
    let hello = "hello";    // 从此处起,hello 是有效的

    // 使用 hello
    println!(hello);
}                           // 此作用域已结束,hello 不再有效

毫无疑问,在最后一行的 } 过后,变量 hello 就不再有效了,因为在其他语言里也是如此。既然都不再有效了,还留着他干嘛呢?Rust 说: 那就回收掉吧。但在垃圾回收机制里,也许还在等着GC来发现 "hello" 所在的内存已经没有变量在引用了,那就回收掉它吧。如果是以前那些没有GC的语言,获取就需要手动地调用类似 delete hello 这样的命令来进行回收(当然实际情况不一定,这里只是举个栗子)。GC 检查时并不是即时的,有一定的等待时间。而手动回收在垃圾过多时会特别麻烦,甚至被遗忘。而 Rust 说:既然只是麻烦而已,那就我来替你解决麻烦呗。所以 Rust 会在结尾的 } 处自动即时地释放在此作用域内声明的有效的变量所指向的内存。既然说了有效的变量,那么你肯定猜到了还有无效的变量咯,你猜对了!

  • 移动 (move)

我们再来看看以下代码片段:

fn main() {
    let hello = String::from("hello");
    let another_hello = hello;

    println!("{}", hello);
}

我们声明了两个变量: helloanother_hello ,并且让 anothor_hello 指向了 hello 所指向的地方。然后在结尾的 } 处 Rust 将会释放 helloanother_hello 所指向的内存。一切看起来都那么完美,但总感觉好像有什么地方不对劲?对了!helloanother_hello 都是指向的同一个地方,那岂不是同一块内存会被释放两次?恭喜你发现了盲点,但同时 Rust 也发现了,所以以上的代码在编译时会报错:

error[E0382]: borrow of moved value: `hello`
 --> src/main.rs:5:20
  |
2 |     let hello = String::from("hello");
  |         -- move occurs because `hello` has type `std::string::String`, which does not implement the `Copy` trait
3 |     let another_hello = hello;
  |              -- value moved here
4 | 
5 |     println!("{}", hello);
  |                    ^^ value borrowed here after move

在其他语言里 let another_hello = hello 这个语句可能称为将 hello 浅拷贝(shallow copy)到 another_hello,也就是让 helloanother_hello 指向同一个地方。但上面看到了 Rust 里这样会报错,那么肯定就不是其他语言里的浅拷贝了,在 Rust 里将这种操作称作把 hello 移动(move,真的没收中国移动的钱:()到 another_hello。他们的区别是,将 hello 移动到 another_hello 会使 hello 变为无效变量。移动就相当于你把你不再需要的东西给别人,别人不需要时再扔掉自动回收,而你已经不再有这个东西了,所以你没办法使用也没办法扔掉。

调用函数时所传的参数与函数的返回值也有转移所有权的操作,来看看官方文档里的这个代码片段:

fn main() {
    let s1 = String::from("hello");  // s1 进入作用域

    let s2 = back(s1);                  // s1 被移动到 back 中,
                                     // back 也将返回值移给 s2

} // 这里, s2 移出作用域并被丢弃。s1 也移出作用域,但已被移走,
  // 所以什么也不会发生。

// back 将传入字符串并返回该值
fn back(a_string: String) -> String { // a_string 进入作用域

    a_string  // 返回 a_string 并移出给调用的函数
}

可以从注释中看到,在调用 back(s1) 时将 s1 移动到了 back 函数里,在 back 函数里返回 a_string 时,又将 a_string 移动到了 s2 。也许你发现了,在 let s2 = back(s1); 语句之后 s1 便不再有效,但我们在编写程序时很少会让一个变量只使用一次。于是 Rust 又搞了一个功(bu)能(ding),叫 引用references)。

  • 引用于借用

假如我们要写一个计算字符串长度的函数,并且要使原字符串可用,但又碍于移动操作,那我们只能在函数里把原字符串与字符串长度一并作为返回值移动出去:

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{}' is {}.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() 返回字符串的长度

    (s, length)
}

这样以来,虽然 s1 被移动到 calculate_length 里的 s 后不再可用,但在 calculate_length 里又将 s 移动到外面的 s2 ,所以 s2 便是可用的原字符串。不过还好这个函数是我们自己写的,不然谁知道 s2 就是原来字符串呢,还得去查文档,多不方便。这么不优雅的写法,Rust 肯定也提供了其他解决方案,那就是引用 (referencing)。我们来换个实现:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

上面的代码可以安全地通过编译并且输出 "hello" 及其长度。其中 & 符号就是引用符号。定义函数时我们在参数处用 &String 代替了 String 表示我们要接受一个 String 类型的引用。而调用函数时的 &s1 表示创建一个指向 s1 的引用并传递给函数,但是所创建的引用并不拥有 (own) s1 所指向的值。当函数 calculate_length 的作用域结束时,由于 s 并没有其所指向的值的所有权,所以便不会释放其值。

这种函数接受一个引用作为参数的形式在 Rust 里有一个专门的术语,叫做 借用 (borrowing)。借用就相当于你把一个暂时用不着的东西借给了他人,让、他人用完了后是要还回来的。而且他人没有权利说不再需要这个东西便把它扔了,扔这个操作只有你(有所有权的那个人)才有权利。

PS: 与引用相对的还有解引用 (dereferencing),引用还分为可变引用 (mutable referencing) 和不可变引用 (immutable referencing),以及可变引用和不可变引用之间会造成的数据竞争等 (data race) 等这些内容本文便不再搬运了,有兴趣的可以自己去官方文档里查看。

枚举与 Match 表达式

Rust 里还有一个比较有趣的东西是 match 表达式,他是类似于其他语言里的 switch 语句的东西,但又有很大的区别(好用很多 :))。不过这个特性不是 Rust 原创的,在其他很多语言里也有出现。 Rust 里的 match 表达式通常是和枚举类型一起使用的。

大部分语言里面都有枚举类型,但不知道是我见识短还是是事实, Rust 里的枚举类型功能特别强大:

enum Fruit { Apple, Banana, Orange, Peach }

fn main() {
    let a_fruit = Fruit::Peach;
}

上面就是最简单的枚举类型形式,和其他语言里差不多。但是 Rust 里的枚举类型可以定义方法,枚举项还可以绑定值:

enum Fruit { 
    // 枚举项可以绑定值,且每一项都可以绑定不同的值
    Apple(usize),
    Banana(usize), 
    Orange(usize), 
    Peach(usize),
}

impl Fruit {
    // 为枚举类型定义方法
    fn eat(&self) {
        println!("eating fruits.");
    }
}

fn main() {
    let peaches = Fruit::Peach(5);
    peaches.eat();
}

值得一提的是,Rust 的可选值的实现和错误机制也是用的枚举:

// 定义在标准库里用于实现可选值的枚举类型
enum Option<T> {
    Some(T),
    None,
}

// Rust 标准库里的 “结果” 枚举类型,可以表示成功或错误
enum Result<T, E> {
    Ok(T),
    Err(E),
}

因为 match 表达式有一个特性是必须要把所有可能的匹配模式都写出来,所以用枚举处理可选值和错误的好处是强制程序员在编译前处理空值和错误的情况。

  • match 表达式

前面看到了枚举项可以绑定值,那绑定了值有啥用呢,又没看到取出来过。这就要祭出经常和枚举搭配使用的 match 表达式啦(还有简单一点的 if 表达式,这里就不提了)。

match 在英文里有中文里 ”匹配“ 的意思 ,顾名思义,它的作用就是匹配一个值是否满足一系列模式使用 match 来完成之前 Fruit 类型里的 eat 方法,输出吃的是啥和吃了多少吧:

enum Fruit { Apple(usize), Peach(usize), None }

impl Fruit {
    fn eat(&self) {
        let message = match self {
            Fruit::Apple(number) => format!("Ate {} apples.", number),
            Fruit::Peach(number) => format!("Ate {} peaches.", number),
            Fruit::None => String::from("No fruit to eat."),
        };

        println!("{}", message);
    }
}

fn main() {
    let peaches = Fruit::Peach(5);

    peaches.eat();
}

由于我们通过 Fruit::Peach(5) 拿到了5个桃子,输出的文本自然是 Ate 5 peaches. 。现在大家都这么有钱,肯定不可能还吃不起水果的,那我们来尝试把 Fruit::None 所对应的匹配分支去掉吧:

let message = match self {
    Fruit::Apple(number) => format!("Ate {} apples.", number),
    Fruit::Peach(number) => format!("Ate {} peaches.", number),
    // 现在没有人吃不起水果的,可以去掉啦
    // Fruit::None => String::from("No fruit to eat."),
};

然后 Rust 就不干了,说现虽然大家都吃得起水果,但是有的人不喜欢吃啊,怎么能强迫别人吃呢。于是报了个错:

error[E0004]: non-exhaustive patterns: `&None` not covered
 --> src/main.rs:5:29
  |
1 | enum Fruit { Apple(usize), Peach(usize), None }
  | -----------------------------------------------
  | |                                        |
  | |                                        not covered
  | `Fruit` defined here
...
5 |         let message = match self {
  |                             ^^^^ pattern `&None` not covered
  |
  = help: ensure that all possible cases are being handled, possibly by adding wildcards or more match arms

从错误提示可以看出,必须把所有可能的情况的分支都列出来。既然在水果的类型里声明了可能没有水果,那就必须处理没有水果的情况。当然 match 也有一个类似 switch 里的 default 的东西,那就是 _ 通配符,它可以在其他分支都没有被匹配到的时候被匹配到。

这里就可以看出用枚举 + match 来处理可选值和错误的好处了:如果说了一个变量可能为空,那就必须要处理为空的情况;如果一个操作的结果可能有错,那就必须要处理错误的情况。

前面提到了官网里写明的选择 Rust 的理由有: 高性能、可靠性和生产力。高性能是因为没有运行时和垃圾回收;可靠性是 Rust 的所有权模型保证了内存安全和线程安全。高性能和可靠性有前面介绍的所有权与借用作保证,那么生产力从哪里来呢,是什么让它年纪轻轻就敢说大话呢。那就是 Rust 集成的工具链,包含了构建工具、包管理器、自动补全和自动格式化代码等。

  • Rustup

Rustup 是 Rust 的工具链安装器,可以用来安装、更新和卸载 Rust 及与之集成的一系列工具链。还可以用来查看 Rust 的文档和切换要使用的 Rust 工具链的发行类型(stable / beta / nightly)等。只需要用官网提供的一条命令安装好 Rustup 之后,其他后续工作就可以全权交给 Rustup 来完成了。不需要额外地配置目录和设置环境变量等(疯狂暗指某些语言)。因为安装和使用 Rust 极其的方便,要是抛开其他不谈,我倒是觉得 Rust 非常适合作为入门语言。

  • Cargo

Cargo 是集 Rust 的包管理器、构建、测试和文档于一身的一个工具,有点类似于 nodejs 里 npm 一样的存在。可以用 cargo build 来构建项目; cargo installcargo publish 来安装和发布第三方包; cargo test 来测试项目; cargo doc 来构建项目本身及其所依赖的包的文档。甚至还可以用 cargo run 构建并执行当前项目的可执行文件,使 Rust 看起来像是一个脚本语言。

除了安装 Rust 时集成的一些工具外,Rust 社区里不同的团队还在开发和维护一些特定方向或行业相关的库。使得使用 Rust 的普通开发者不必关心一些不必要的技术细节。

虽然 Rustup 和 Cargo 这样的工具在其他语言里也都有。但做为一个编译型语言,官方提供了这么完整的一套工具链,使其使用起来和脚本语言一样方便,却又保留着编译型语言的执行效率,感觉还是不错的。

这里只是简单介绍了 Rust 的几个特性,还有更多的像 trait 、生命周期、智能指针和宏等官方文档的目录里有但我也没看的特性这里没有介绍。希望大家看了这篇文章后都能对 Rust 提起兴趣。如果提起了兴趣当然好,但是还是不感兴趣的话请再去看看其他的介绍文章吧,因为肯定是我的锅而不是 Rust 的锅让你提不起兴趣。

总之 Rust 在语言设计上是比较让人耳目一新的(对于我这个井底之蛙来说),许多特性看起来也确实能让我们更好地写程序和写出更好的程序。当然现在因为生态和人力资源等原因,将 Rust 运用在实际的生产项目上肯定还是要冒很大的风险。但多了解了解其他语言里不同的设计,或许能让我们转变一下自己的思维模式。以后带着不同的思维模式去解决其他问题,说不定就轻松多了呢。反正多聊解新事物肯定是没有坏处的,如果你觉得有,那肯定是你了解的还不够多。

最后还是希望 Rust 以后真的能发展起来吧。不要像 Haskell 一样,学过的人都喜欢,大家都说好,就是没什么人用。当然没人用也没什么不好的,但没人用生态就起不来,啥都要自己搞,对于我这种懒得觉都不想睡的人来说肯定不合适。


Makeflow ( makeflow.com ) 是以流程为核心的项目管理工具,让研发团队能更容易地落地和改善工作流,提升任务流转体验及效率。如果你正为了研发流程停留在口头讨论、无法落实而烦恼,Makeflow 或许是一个可以尝试的选项。如果你认为 Makeflow 缺少了某些必要的特性,或者有任何建议反馈,可以通过 GitHub语雀 或页面客服与我们建立连接。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK