Rust 入门 (Rust Rocks)

  • 缘起
  • 实践出真知
    • 快速获取
    • 澄清概念
      • Ownership
      • Move
      • Reference
      • Mutable reference
    • 解释错误
    • 数据竞态条件
    • 构建树状结构
    • 渲染树状结构
  • 总结
  • 源码 Github

TL;DR

下面我对内部分享话题的引言部分

Rust 是一门系统编程语言,也是许多区块链底层编程语言,不论是旧欢 Parity,还是新贵 Libra;不论是微软还是 Linux 的核心开发者都对它青眼有加。

Rust 有一些很迷人的地方,比如:AOT,内存安全性,空指针安全性,还是丰富的类型系统,异或是庞大的社区生态。这些别的语言也有,但是都没有这么彻底。

Rust 的特点也十分突出,Ownership 和 borrow、references 还有 shadow,令人眼花缭乱,最最有趣的就是你写一段程序,编译器会 blame 你很多,你就感觉是在和编译器搏斗。

学习 Rust 不只是一时兴起(早在2015年我就听闻过),也是一种拥抱变化的态度,重要的是可以让你在看众多区块链代码时不会那么心慌。

语言的趋势反映了未来的主流开发群体的预测,这点 Rust 确实算是后起之秀。

本次话题,我会讲解我学习 Rust 的过程,希望指导大家在学习新编程语言时应该怎么做才更高效,同时会用一段小程序 tree 来给大家演示它的与众不同之处。

缘起

做区块链的基本几乎没有人不知道 Rust 这门编程语言,它非常受区块链底层开发人员的青睐。说来也奇怪,Rust 起源于 Mazilla,唯一大规模应用就是 Firefox,作为小众语言却在区块链圈子里火了。这其中应该和以太坊的发起人 Govin Wood 创建的 Parity 项目有关,Parity 是一款用 Rust 编写的以太坊客户端。

最初接触 Rust 的时间大概是 2015 年,当年有同事发了一封“是否对 Rust 编程语言感兴趣的”的邮件。当时年少不懂事热血,觉得这门语言因为它小众很酷,所以特别适合拿来练功,所以就激情地回应了邮件,结果之后就没有了下文,想必那位同事也因为响应的人数太少而兴致缺缺。

第二次关注 Rust 是因为陈天在自己的公众号中提到了这门语言。我比较欣赏陈天,当初学习 Elixir 也是受他影响,所以也跟着他的步伐去听了张汉东的知乎Live,然后加入了他的读者群(魅力Rust),在这个群中潜水了大半年,一直很惊叹这个群的活跃度。

2019年,区块链圈中的一次大事件是 Facebook 要发非主权货币 Libra,随之而来是基于 Rust 之上的 Move 编程语言。这个 Move 说白了就是 Move 的一种 DSL,用比较学术的话说是指称(denotational)语义,用简单的编译器把 Move 的语法翻译成 Rust 的语法然后借助 Rust 的编译器生成二进制码。这个过程没有什么惊喜,不过 Move 语言显然是借鉴了 Rust 中移交(Move)主权(Ownership)的概念,它表征了这样一种事实——数字资产只能有一个主人,一旦移动,就会发生主权转移,以前的主人就丧失了该主权。这种想法和 Rust 中主权管理非常契合,所以不难理解为什么 Libra 的开发团队把名字也照搬过来了。当然,Libra 的底层区块链也用的是 Rust。这个大事件加上以太坊 Parity 的珠玉在前,对于程序员这群天生喜欢新鲜事物的人类而言,学习 Rust 的热情必然水涨船高。

大概就是在这种契机下,我开始学习 Rust 的。依照老规矩,我还是会从 tree 这个命令行程序入手,在试错中逐步学习 Rust 这门语言。包含它的基本数据类型,组合数据类型,控制流,模块(函数)以及文件和集合操作,还有最关键的 Ownership 的应用。

实践出真知

学习 Rust 最深刻的体验莫过于和编译器较劲,这也是我听到过最多的抱怨。我想许多新手看到这么多警告或者错误,嘴上不说,心里应该很不是滋味。但是这也是 Rust 引以为豪的设计哲学。

每一门新进的语言都有自己的本质原因(Rationale)或者设计哲学,比如 Lisp 家族的 Clojure 就有 Elegance and familiarity are orthogonal 的玄言妙语;往远古追溯,Java 的 Write Once, Run Anywhere 豪言壮语;而 Rust 的基本设计哲学是 If it compiles, then it works,这个条件有多苛刻我们稍微想一想就能知道——动态弱类型语言向静态强类型语言的逐步趋同态势,基本已经宣告了类型系统的胜利。

但即便如此,现代软件工程也还是处处强调程序员要手写各种测试确保代码运行时的正确性——从单元测试到集成测试,从冒烟测试到回归测试,从 Profiling 到性能测试。这些测试方法和工具已经深入到软件工程的方方面面,然而各类软件还是漏洞百出。Rust 发出这种高调宣言,不免有夜郎自大之嫌疑。不过程序届是个能造概念也能落地概念的神奇圈子,高调的牛吹着吹着也就实现了。况且,Rust 充分诠释了现代编程语言的核心思想——约束程序员,不是劝劝你的意思,是憋死你的意思。

我在《我是如何学习新的编程语言》中说过学习的最好方式是有目的地试错,我时常拿来练手的程序叫tree - list contents of directories in a tree-like format. 这段程序需要用到的 Rust 基本构件有:

基础概念
1. 变量 - let
2. ownership borrow - &
3. 可变性 - mut
4. 可变引用 - &mut


复合数据类型
1. String - String::from("") // 非基本类型
2. Slice - "" or vec[..]
2. struct - struct {}

集合及其操作
1. Vec<_> - Vec::new() // 考虑到集合需要自动扩展
2. iter()
3. .map()
4. .enumerate()
5. .flatten()
6. .collect()
7. .extend() //集合拼接

控制语句
1. if Expressions - if {} else {}
2. recursions

模块
1. fn - fn x(s: String) -> Vec

功能组件
1. Path
2. fs
3. env

当尝试寻找这些元素时,我发现 Rust 或者诸如此类的编译型语言都有一个让人不舒服的地方——验证的前置步骤耗时太长。因为没有repl,所以想去了解一些概念的使用方法,就不得不另外创建一个项目(我可不想污染当前项目的代码),在它的 main 函数里编写试验程序,这比起具有快速反馈能力的repl,着实太慢了。不过这里的慢也是相对的,Rust 也有一个显著的优势,在出现编译错误时,编译器不仅能向你解释原因,还能推荐潜在的修改方式,这就比 Javascript 一类的动态语言要清晰和高明得多。再利用内置的 assert_eq! 等断言函数预判结果,又比单独写测试省事。所以,总体而言,学习的过程还是很愉悦的。

快速获取

这里举个例子,为了解如何拼接两个集合时,需要事先搞明白几个问题:

  1. 集合的构造?
  2. 集合的拼接?
  3. 结果的断言?

在没有repl的条件下,唯一快速上手的工具就是文档,在 https://doc.rust-lang.org/std/ 的官方标准库中,可以搜到Struct std::vec::Vec的详细解释。

通过例子程序,可以很快知道集合的构造方式如下:

let mut v = vec![1, 2, 3];
v.reverse();
assert_eq!(v, [3, 2, 1]);

vec! 宏可以快速构造出一个集合来,顺便试验下它的reverse方法。那么集合如何拼接呢?为了解答这个问题,我一般会用搜索引擎,或者深入文档,查找如 concatappend等关键字,每每总有收获。

在不考虑非功能需求的前提下,我们先用最直接的方式实现,例如:文档中给出的样例extend方法

let v = vec![1, 2, 3];
v.extend([1, 2, 3].iter().cloned()); // 编译错误

注意,这里编译失败。Rust 编译器会直截了当地给出错误信息。

error[E0596]: cannot borrow `v` as mutable, as it is not declared as mutable
  --> src/main.rs:13:5
   |
12 |     let v = vec![1, 2, 3];
   |         - help: consider changing this to be mutable: `mut v`
13 |     v.extend([1, 2, 3].iter().cloned());
   |     ^ cannot borrow as mutable

错误信息中透露出我们的程序在尝试借用(borrow)一个不可变的变量。borrowmutable都是新的概念。对于新的概念,我们会习惯地用熟知的知识去类比。如果套用函数式编程中不可变的特性,大体可以猜到 Rust 中的变量默认是不可变的。但是 cannot borrow as mutableborrow 确实是有点超出认知范围。那么此时弄清定义是非常有必要的。

澄清概念

学习语言的过程中最需要注意的事项就是澄清概念。当遇到崭新的概念时,我们得停下先去补充这部分的知识,然后再回过头来理解和解决实际遇到的问题。因为每一门编程语言都有本门派的哲学原理,它本身就萃取了多种理论和实践的成果,所以必须学习这些概念。学习的过程其实就是逐步澄清概念的过程。

在学习(尝试定义)borrow 的过程中,我又先后接触到了 ownership, move, reference, mutable reference 等概念。所以我定义了这些概念:

Ownership

变量拥有它指称的值的所有权。
在 Rust 当中,变量拥有它指称的值,即变量(variable)是它指称值(value)的主人(owner),值一次只能有一个主人,一旦主人离开作用域它的值就会被销毁。

Move

把一个变量的值重新赋值给另一个变量的行为。
根据 Ownership 的定义,值一次只能有一个主人,所以此时该值的所有权会被转移给另一个变量,原来的变量就丧失了对这个值的所有权,导致的直接影响就是这个变量此后不再可用。

Reference

一个变量指向(refer to)值而非拥有该值的所有权的状态。
在很多赋值的场景,包括变量赋值或者函数参数赋值,我们并不希望之后原来的变量不再可用,此时可以通过&(ampersands创建一个指向值的引用,将引用进行赋值时不会发生 Move,所以原来的变量依旧可用。这种赋值行为被称为borrow(借用)。结合实际,我们拥有的物品可以出借给别人,别人享有该物品的使用权(Possession),而非所有权(Ownership)。

Mutable reference

标识该引用的值是可变的。

很多场景下,我们希望引用传递的值是可以改变的。此时我们就必须通过&mut标识该引用,否则不允许修改操作发生。值得注意的是,&mut标识要求原来的变量也必须是mut的,这很好理解,可变的变量的引用也得可变。而且为了防止数据竞态条件的发生,在同一个作用域下,&mut的引用只能有一个,因为一旦出现多个可变引用,就可能遭遇不可重复读风险(注意,Rust 保证这里没有并行修改的风险)。而且同一个值的&mut&的引用不能共存,因为我们不希望一个只读&的值同时还能被写&mut,这样会导致歧义。

解释错误

澄清了必要概念以后,我们再来回顾上面的代码。先去看一下这个extend函数的定义:

fn extend(&mut self, iter: I)
where
    I: IntoIterator, 
Extends a collection with the contents of an iterator...

原来v.extend只是一个语法糖,真正的方法调用会把self作为第一个参数传递到extend(&mut self, iter: I)当中。可变引用作为函数参数赋值,那么自然原来的变量也必须声明成可变的。

所以我们照着它的指示修正如下:

let mut v = vec![1, 2, 3]; // 加上一个mut修饰符
v.extend([1, 2, 3].iter().cloned());

这回编译器消停了,利用assert_eq!,我们来验证extend操作的正确性。

assert_eq!(v, [1, 2, 3, 1, 2, 3]);

另外,值得注意的是,Rust 和我们熟悉的函数式编程有些不同,集合的拼接不会产生一个新的集合,而是对原有的集合进行修改。一般情况下,我们都会警惕可能会出现数据的竞态条件——多个线程对该集合进行写入操作怎么办?带着这个问题,我们反思一下什么是数据的竞态条件。

数据竞态条件

数据竞态条件发生的必要条件有:

  1. 多个引用同时指向相同的数据;
  2. 至少有一个引用在写数据;
  3. 对于数据的访问没有同步机制。

考察1和2:
假如此处有两个引用指向同一个集合,如下:

let mut v = vec![1, 2, 3];
let r1 = &mut v;
let r2 = &mut v;
assert_eq!(r1, r2);

编译器会立即给出编译错误

error[E0499]: cannot borrow `v` as mutable more than once at a time
--> src/main.rs:13:10
|
12 | let r1 = &mut v;
|          ------ first mutable borrow occurs here
13 | let r2 = &mut v;
|          ^^^^^^ second mutable borrow occurs here
14 | assert_eq!(r1, r2);
| ------------------- first borrow later used here

也就是说,在指定的作用域下只能有一个可变引用。为什么要如此设计呢?在单线程下,这好像并不会出现数据竞争的问题[1]。不过考虑到下面这种场景的语义,我们思考一下。

let mut v = vec![1, 2, 3];
let r1 = &mut v;
let r2 = &mut v;
assert_eq!(r2[1], 2);
*r1 = vec![0]
assert_eq!(r2[1], 2); // 失效

一旦允许r1改变数据,那对于r2而言,它先前持有的数据就已经发生改变甚至失效,再拿来使用就有问题了,在上面这个例子当中,*r1解除引用后被重新赋值,导致v的值随之改变,但是r2并不知情,依旧使用r2[1]导致此处越界。这个问题和数据库中事务的不可重复读(提交读)的隔离级别类似,但是在单线程下这并不能算作充分的理由,只是说在语义层面有细微的不自然,留待后续研究。

蹊跷的是,如果我将两个可变引用放到不同的函数中,同样的逻辑却可以绕过编译器错误。

fn main() {
    let mut v = vec![1, 2, 3];
    mut1(&mut v);
    mut2(&mut v);
}

fn mut1(v: &mut Vec) {
    *v = vec![0];
}

fn mut2(v: &mut Vec) {
    println!("{}", v[1]); // panicked at 'index out of bounds' 运行时错误
}

可见,上述的论述并没有解释清楚在单线程下同一个作用域下限制多个可变引用的根本原因。

对于&mut&其实也可以做同样的解释。所以&mut&在 Rust 同一个作用域中无法共存。

考察3:
至于在多线程的环境下,是否会出现数据竞态条件,我们得看 Rust 在线程使用方面的限制。在 Rust 的上下文里,使用Thread::spawn的线程时必须 Move 所有权[2],因为在 Rust 看来,Thread 的 LifeTime(生命周期)会比调用它的函数的生命周期的长,如果不 Move 所有权,那么线程中数据就会在调用函数结束后释放掉变量的内存,导致线程中的数据无效。所以,这样的限制是很有必要的,但反过来想,一旦数据的所有权发生转移,那么多个线程并行修改同样数据的可能性也就不复存在。

构建树状结构

struct Entry {
    name: String,
    children: Vec 
}

fn tree(path: &Path) -> Entry {
    Entry{
        name: path.file_name()
            .and_then(|name| name.to_str())
            .map_or(String::from("."), |str| String::from(str)),
       
        children: if path.is_dir() {
            children(path)
        } else {
            Vec::new()
        }
    }
}

既然是树状结构,定义的结构体就是递归的。这里的struct Entry {}就是一种递归的结构。我想实现的树状结构大致如下:

entry :: {name, [child]}
child :: entry

Rust 中没有显式的return,最后一个表达式的结果会被当成返回值,所以此处整个Entry结构体会被返回。

path.file_name()
 .and_then(|name| name.to_str())
 .map_or(String::from("."), |str| String::from(str)),

这段代码看上去很复杂,但实现的功能其实很简单,目的是为了获取当前文件的文件名。那么逻辑为何如此绕呢?这是由于 Rust 中的多种字符串表示导致的问题,暂按不表。先去看看各个函数的定义。

Path.file_name 的定义

pub fn file_name(&self) -> Option<&OsStr>

and_then是我们常见的flat_map操作在 Rust 中的命名,其目的是为了在两个Option之间实现转换。

OsStr.to_str 的定义

pub fn to_str(&self) -> Option<&str>

上面的path.file_name().and_then(|name| name.to_str())最终转变成了Option<&str>,在其上调用Option.map_or方法并提供默认值:字符串"."。为什么要提供默认值呢?这和OsStrStr的转换密切相关,当我们传入参数"."时,Path.file_name返回的其实是一个None

构建了父级的树状结构,我们需要把子级的树状结构也一并完成,最终通过递归,构建出一棵内存中的目录树。

fn children(dir: &Path) -> Vec {
    fs::read_dir(dir)
        .expect("unable to read dir")
        .into_iter()
        .map(|e| e.expect("unable to get entry"))
        .filter(|e| is_not_hidden(&e))
        .map(|e| e.path())
        .map(|e| tree(&e))
        .collect()
}

fn is_not_hidden(entry: &DirEntry) -> bool {
    entry
         .file_name()
         .to_str()
         .map(|s| !s.starts_with("."))
         .unwrap_or(false)
}

这里也存在挺多的转换操作,我们一一解释。

fs::read_dir(dir).expect("unable to read dir")

使用expect是因为fs::read_dir返回的是一个Result,在其上调用expect会尝试解开其中的值,如果有错则会抛出错误。解开的结果类型是ReadDir,它是io::Result的迭代器,也就是一个目录下的所有类目,可以在上面调用into_iter()创建出可以被消费的迭代器。

.map(|e| e.expect("unable to get entry"))
.filter(|e| is_not_hidden(e))
.map(|e| e.path())
.map(|e| tree(&e))

接着,解开Result之后,我们把隐藏文件过滤掉,因为filter接收的一个闭包,这个闭包的类型声明是P: FnMut(&Self::Item) -> bool,所以filter接收的所有元素都是引用类型,故调用时无需需声明成is_not_hidden(&e)

然后利用e.path()获取每个文件的全路径,并依次交给tree去递归构建。经过treechildren两个函数的交替递归,内存中的一棵目录树就被构建出来了。

有了内存中的树状结构,我们接下来就可以渲染这个结构了。具体的做法如下:

  1. 对于第一层目录名,如果它是最后一个目录,则前缀修饰为L_branch = "└── ";反之,装饰成 T_branch = "├── "
  2. 对于有子目录,如果是其父目录是父级最后一个目录,则前缀装饰为SPACER = " ";反之,前缀装饰成 I_branch = "│ "

逻辑如下:

fn decorate(is_last: bool, children: Vec) -> Vec {
    const I_BRANCH: &str = "│   ";
    const T_BRANCH: &str = "├── "; 
    const L_BRANCH: &str = "└── ";
    const   SPACER: &str = "    ";

    let prefix_first = if is_last { L_BRANCH } else { T_BRANCH };

    let prefix_rest = if is_last { SPACER } else { I_BRANCH };

    let mut first = vec![format!("{}{}", prefix_first, children[0])];

    first.extend(children[1..].iter().map(|child| format!("{}{}", prefix_rest, child)).collect::>());

    first
}

这里比较好用的字符串拼接操作是format!("{}{}", &str, &str)

渲染树状结构

fn render_tree(tree: &Entry) -> Vec {
    let mut names = vec![tree.name]; // error
    let children = &tree.children;
    let children: Vec<_> = children
        .iter()
        .enumerate()
        .map(|(i, child)| decorate(children.len() - 1 == i, render_tree(child)))
        .flatten()
        .collect();
    
    names.extend(children);

    names
}

这里会有编译错误,错误信息如下:

error[e0507]: cannot move out of `tree.name` which is behind a shared reference
  --> src/main.rs:48:26
   |
48 |     let mut names = vec![tree.name];
   |                          ^^^^^^^^^ move occurs because `tree.name` has type `std::string::string`, which does not implement the `copy` trait

由于tree.name不是标量类型(Scalar Type),它没有实现copy trait(见提示),又因为tree本身是复合类型(Compound Type),tree.name如果发生 Move 的话,包含它的tree就有问题了。为了避免发生这种情况,我们不得不去引用&tree.name。但是一旦加上引用,又会出现类型不匹配的编译错误。

59 |     names
   |     ^^^^^ expected struct `std::string::String`, found reference
      |
         = note: expected type `std::vec::Vec`
                       found type `std::vec::Vec<&std::string::String>`

我们期待的是Vec而不是Vec<&String>,所以需要重新构建出一个String出来。可以使用String::from(&String)方法

let mut names = vec![String::from(&tree.name)];

这样修改下来,才能保证编译完全通过。但事实上,Rust 给我们提供了一个更加便捷的写法

let mut names = vec![tree.name.to_owned()]

使用to_owned()表示重新拷贝了一份数据,和重新构建一个String出来别无二致。

组合调用

use std::env;
use std::path::Path;
use std::fs::{self, DirEntry};

fn main() {
    let args: Vec = env::args().collect();
    println!("{}", render_tree(&tree(Path::new(&args[1]))).join("\n"));
}

render_tree 返回的是Vec,所以为了打印出来,我们将所有元素用"\n" join到一起。

.
├── Cargo.toml
├── Cargo.lock
...
└── src
    └── main.rs

总结

学习下来的一些主观感觉是 Rust 中的概念繁杂,有些地方的设计确实让人有些迷惑。再加上类型众多(如:OsStr, String),代码很难通过直觉判断写出,需要大量查阅文档才能让编译器消停。所以学习曲线相对陡峭。

不过,语言约束的越多,某种程度上讲,对于程序员而言却是福音。If it compiles, then it works. 的哲学理念在前,学习道阻且长,努力加餐饭。


提示
一般标量类型都实现了copy trait.

  • 所有的整型,如:u32
  • 布尔类型,如:true 或 false
  • 字符类型,如:char
  • 浮点数类型,如:f64
  • 当且仅当所有元素都是Copy的元组,如:(i32, i32)是Copy,但是(i32, String)就不是Copy的。

于2019年9月22日


  1. https://www.reddit.com/r/rust/comments/95ky6u/why_arent_multiple_mutable_references_allowed_in/ ↩

  2. http://squidarth.com/rc/rust/2018/06/04/rust-concurrency.html ↩

你可能感兴趣的:(Rust 入门 (Rust Rocks))