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

Rust 不是一门很火的语言。

在 TIOBE 上, Rust 处于即将掉出排行榜——只有前 50 才会被列在排行榜上——的位置,说白了,就是一个没人用的语言。话是这么说,但是仅仅依靠这种排行来衡量一门语言的价值是很偏颇的。这种排行榜反应的只是一门语言于时下的热门程度,像 Python 和 R 在机器学习大火的今天就如鱼得水一般往排行榜前蹿。因此在 Rust 找到适合它的应用场景前,很可能一直保持这种惨兮兮的排名,不过就 Rust 对自身的定位来说,注定难以找到一个适合自己的用武之地,就算有那么几个对的上号的,前面还有比它更合适的更热门的语言。怎么说的,比上不足,比下有余,这一点和 C++ 很类似。

我不知道 Rust 的下场会不会和 D 一样,不过就算如此,了解一种新的编程范式、从 C++ 使用者的角度看 Rust 的改进和从 Rust 使用者的角度回去看 C++ 中的缺憾,都是值得一试的(更何况这帮 Rust 程序员特别喜欢拿 Rust 和 C++ 比较)。

前言

fn main() {
    println!("Hello, World");
}

在 Rust 官网上有着对 Rust 基本特性的简介。 Rust 自诩为系统编程语言,速度可以与 C++ 相比拟,可以为安全抛弃一切,所以 Rust 设计的基本目标是完全杜绝段错误,保证线程安全。换句话说, Rust 在尽可能保证速度的情况下保证了绝对的安全性,这正是 Rust 语言设计中最重要的两点,安全 (Safety) 和性能。

本文是我作为一个 C/C++ 狂人在学习 Rust 的时候随心所欲,本着多写点东西能学得更深刻的心态记录的笔记,主要的目的是将 Rust 中的概念与 C/C++ 中的类似概念相对比。Rust 的新概念挺多的,语言复杂度也不低,并且我写作仓促,也没有仔细地打过草稿,想到什么写什么,算是个流水帐一样的东西,所以如果有什么疏忽遗漏还请谅解并指出来。

零开销抽象

首先什么是零开销抽象?从 C++ 之父 Bjarne Stroustrup 在一篇文章中对于C++的零开销抽象 (Zero overhead principle) 的介绍是

In general, C++ implementations obey the zero-overhead principle: What you don’t use, you don’t pay for [Stroustrup, 1994]. And further: What you do use, you couldn’t hand code any better.

也就是对于某个特性来说,如果你用不到,那你就不需要为此付出代价,而如果你需要用到,那你找不出代价更小的实现。

Rust Book (The First Edition) 中提到零开销抽象 (Zero cost abstractions) 是

Rust has a focus on safety and speed. It accomplishes these goals through many ‘zero-cost abstractions’, which means that in Rust, abstractions cost as little as possible in order to make them work. The ownership system is a prime example of a zero-cost abstraction. All of the analysis we’ll talk about in this guide is done at compile time. You do not pay any run-time cost for any of these features.

哪些地方会用到开销呢?比如在 Java 中,实现泛型编程有着巨大的运行时代价。 Java 的泛型是基于继承的,所有的类都继承自一个类 java.lang.Object ,再通过虚函数实现了多态。比如 Java 中的泛型容器 java.util.List 只是在编译的时候会检查一些错误,在编译过后会做类型擦除,运行的时候实际上就是无差别的 java.util.List ,它最终还是依赖于虚函数实现的泛型。

与之相对应的另一种常见的泛型实现方式,在 C++ 中,泛型容器比如说 std::vector<T1>std::vector<T2> 是两个完全不相同的类型。当编译器在源码里检查到多个不同的模板类的实例的时候,会对应地按需生成多个不同类型的模板类的机器码,也就是所谓的实例化,包括用到的成员函数等等。在这一点上, Rust 是和 C++ 一致的。它们将泛型的代价保留在编译期,用二进制文件体积增大为代价换来运行时的高效。

C++ 中实现泛型的主体是 template, Rust 中叫做 trait 。熟悉 C++ 的对 trait 这个名词应该很熟悉,但是两者略有不同,从概念上来说 Rust 的 trait 更贴近 C++20 中的 concept ,是更人性化的 template 。 Rust 的 trait 除了泛型以外,还可以用作 trait object 实现多态,不过这里的多态有运行时代价。总之 trait 还是一个很强大的工具,在下面会介绍。

另一个在 Rust 中非常常见的类型是 enum 类型。它和 C/C++ 的 enum 关系不是很大,按存储方式来说, Rust 的 enum 更类似于 C/C++ 的 union ,按语义上来说,更类似于 C++17 的 std::variant (虽说 std::variant 本来就是个类型安全的 union )。 std::variant 的所有操作可以被以基本零代价地编译期实现——不需要 RTTI 和反射 ,甚至也不需要在堆上分配空间(我看了一下 libstdc++ 的 std::variant 实现,代价应该只有几个静态函数指针),所以可以大胆地说这个东西也是零开销抽象。我会在接下来的文字中介绍 Rust 中的 enum

内存安全

Rust 编译器通过引入了生命周期的概念,在编译期检查数据的生命周期限制非法的引用行为,从而使得空悬指针成为不可能。而且 Rust 中通过使用特殊的数据结构和语法来避免空指针的出现,使得“数十亿美元的错误”成为了历史。

和大多数现代语言都使用错误码或异常来处理出错不同, Rust 使用一类特殊的数据结构来处理函数错误,而且从语法上保证不会有遗漏,在处理可空类型 std::option::Option 的时候也通过语法保证处理空的情况不会被遗漏。

而数组和有范围的类型一般自带范围检查,尽管这对效率有拖累,不过也算强行避免了内存问题。官方说法是如果 C++ 手写范围检查代码的话, Rust 效率和其差不多。

线程安全

Rust 能保证数据不存在竞争,但是不能保证没有竞态。保证数据不存在竞争是由借用检查和生命周期限制来实现编译期检查的。

说到底, Rust 还是为了防止数据竞争的时候出现未定义行为导致的 Segment fault ,但是对于竞态这种情况,是连上锁都解决不了的问题,就不要苛求编译器能帮忙解决了。

变量绑定

Rust 使用 let 关键字来绑定变量名和值,一句 let 一次只能绑定一个。默认情况下,变量为只读,也就是 C++ 中的 const 变量,可以用 let mut 来绑定可变变量。 Rust 使用 const 来定义编译期常量,也就是 C++11 中的 constexpr (作用在函数上的 const 还在 nightly )。 Rust 中类型声明几乎都可以省略而由编译器推导。当然,如果需要显式指定类型,类型在变量后用冒号隔开。下面是几种声明语法

  • let name; ,声明一个可变的 name ,必须要在后续绑定值编译器才能推导出类型。
  • let name = value; ,声明一个不可变 name 并赋值为 value ,自动类型推导。
  • let name: type; ,声明一个type 类型的不可变 name 。
  • let name: type = value; ,声明一个 type 类型的 name 并赋值为 value 。
  • let mut name = value; ,声明一个可变的初始化为 value 的 name 。

在后面提到了模式匹配之后,还可以用模式匹配直接解构一个 tuple 或者结构体。

Rust 中几种基础类型一目了然,个人而言很喜欢这种命名。包括有符号整型 i8 i16 i32 i64 i128 以及机器相关的 isize 、无符号整型 u8 u16 u32 u64 u128 以及机器相关的 usize (我试了一下, i128u128 目前似乎都没法用)、浮点型 f32 f64 、布尔型 bool 、字符型 char (注意这个 char 对应的是 Unicode 中的一个码位, UTF-8 储存,大小不定),这些类型都是实现了 trait std::marker::Copy 的类型,概念上类似于 C++ 中的 TriviallyCopyAssignable 和 TriviallyCopyConstructible 。除了以上以外还有比较复杂的复合类型:指针、引用、切片、数组、字符串切片、元组、空(类似 void )和函数、闭包 ( lambda 表达式)。 nightly 中还有个 never 类型,应该对应 C/C++ 中的 no return 函数,但是它可以被转换成任何类型,设计它的目的是为了在 if else 语句中可以写出一个返回值一个 panic! 的语句,可以随意 cast 成另一条分支的类型,毕竟转换在运行时 never happens。

let v = if whatever {
    "whatever"          // string slice
} else {
    panic!("whatever")  // never type casts to string slice
}

Rust 中,实现了 trait std::ops::Drop 的类型,可以理解为 C++ 中的实现了析构函数,也就是非 TriviallyDestructible 的类型的绑定操作均为移动,它不允许实现 trait std::marker::Copy ,常见的类型有 std::string::Stringstd::vec::Vector 。这一类没有实现 trait std::marker::Copy 的类型重绑定给其它变量名不仅会转移所有权,也会导致原变量名直接失效(存在被抹杀),除非绑定新的数据,否则变量名不能再被使用,但是原数据会直到退出当前作用域才会被 drop ,也就是析构,这一点和 C++ 的 RAII 一样。

Rust 中的引用类型,也叫做借用,必须引用自某个数据,而且其生命周期必须在被借用对象数据的生命周期内,如果被绑定对象被移动到其它变量名,那么必须要保证没有被引用。这是为了防止垂悬引用的出现,这一点由编译器保证。引用主要的目的是为了在函数传参的时候,对于没有实现 trait Copy 的类型,不至于每次都需要传入所有权,然后再在函数末尾重新交出所有权的重复操作。

let mut s = String::from("1st");
let r = &mut s;
let s = String::from("2nd string");
r.push_str(" string");
println!("{}", r);  // 1st string
println!("{}", s);  // 2nd string

Rust 的数组和 C++ 的一样是在栈上的,声明方法需要带上长度,格式为 [Type; Length] 。 Rust 为数组提供了一些语法糖,比如说类似于 C/C++ 中大括号初始化的是直接用 [v0, v1, v2, v3] 这种格式初始化,以及将一份数据拷贝 len 次的 [init; len] 格式。

  • let arr: [i32; 4]; ,声明未初始的长为 4 的 i32 不可变数组,使用前需要初始化。
  • let arr = [0, 1, 2, 3]; , 初始化类型为 [i32; 4] 的不可变数组为 [0, 1, 2, 3]
  • let arr: [u32; 4] = [0, 1, 2, 3]; ,初始化地声明一个长为 4 的 u32 不可变数组。
  • let arr = [0u32; 4]; 初始化填充为 0 的长为 4 的 u32 不可变数组。

Rust 中虽然有和 C/C++ 类似的内建指针类型,但是在 safe Rust 下基本就是个废物——它不允许被解引用。所以 Rust 为我们提供了多种智能指针,分别是 std::boxed::Box 对应 std::unique_ptrstd::rc::Rc 对应 std::shared_ptrstd::rc::Weak 对应 std::weak_ptr ……呃,还有 std::cell::RefCell 对应 std::unique_ptr 。它们本质上还是用 unsafe + pointer 实现的,但是暴露给我们的接口都是安全的。

std::boxed::Box ,对应的是 std::unique_ptr ,对堆上的数据拥有所有权。

std::rc::Rc ,对应的是 std::shared_ptr ,对堆上的数据享有共享所有权。 std::rc::Weak 是弱引用,不增加被指向对象的引用计数,和 std::weak_ptr 类似。

std::cell::RefCell ,对应的是 std::unique_ptr ,和 std::boxed::Box 类似,区别在于这个智能指针提供了所谓的内部可变性,它的不可变引用可以改变内部对象的值,可以使用成员函数 borrow_mut 获取数据的可变引用。某种意义上来说,这就避免了编译期对于可变借用和不可变借用两者只能存在一个的限制,但是库的内部实现保证在运行时只能有一个从指针中取出来的可变引用,也就是将检查从编译期推迟到了运行时。

流程控制

分支语句和 C/C++ 类似,有着二路分支和多路分支的选择,但是 Rust 的多路分支相比于 C/C++ 的多路分支更强大。

首先是二路分支, Rust 中 if else 后不需要接括号,所以和 Go 或 Swift 类似,大括号是必须的,但是换行换不换随意(我觉得没人会觉得换行好看吧,有的话,先烧死大括号换行的异端)。另外, Rust 的 if else 是一个表达式,所以它有自己的返回值,可以当作是 C/C++ 的三目运算符来用。

let result = if value {
    do_some_thing();
    result_if_true   // no semicolon here
} else {
    do_else_thing();
    result_if_false  // no semicolon here
}

Rust 中的多路分支,使用 match 关键字取代了 switchmatch 也和 if else 类似有返回值,和 switch 不一样的是它不会 fall through ,而且有范围匹配等一系列语法糖,多亏了模式匹配的语法。对于 match 而言最重要的特性应该是它要求匹配项涵盖所有可能(尽管这个特性有时候挺麻烦的),它在我们处理函数异常、错误的时候可以防止遗漏特殊情况。 Rust 中函数的错误默认使用类似于其它语言的 Optional (也就是 C++17 的 std::optional )的 enum 来处理,叫做 std::result::Result ,比 std::option::Option 多了个错误信息,在使用 match 的时候可以防止一个脑残忘记处理错误,关于错误处理后面会细说。

match value % 4 {
    -3...-1 =>  println!("negative"),
    0 | 2   =>  println!("positive and even"),
    1 | 3   =>  println!("positive and odd"),
    _ => (),  // even if -3...3 covered all possibility, this line is necessary.
}

另一种二路分支的分支语句是 if let ,它和 match 类似,是在模式匹配中使用的,格式是 if let test = variable { } else { } ,对于不想一直打 match 还要全部情况都覆盖上的时候比较方便。类似的还有个 while let ,是在 loop 里套了个 if let,不匹配就 break

Rust 的循环和其它语言的循环没什么差别,相比 C/C++ ,引入了 loop 关键字来单独表示无限循环, while 循环在 Rust 和 C/C++ 中没区别, for 则只保留了迭代器循环的功能,可以认为是 C++11 的基于范围的循环,而且据说配合各种迭代器再套上高阶函数和匿名函数的效率也挺不错的。(顺便一提, Rust 的 for 关键字还有别的用处)

for i in (-32..32).filter(|value| value % 2 == 0) {
    println!("{}", i);  // all even numbers between -32 and 31.
}

这里的代码在 filter 中用到了一个匿名函数,可以暂时不用理会,但是不难理解含义是如果参数是偶数就返回 true 否则 false 。

模式匹配

模式匹配,可以用来解构一个结构体、元组,甚至数组和切片(然而这两个还是 nightly 的特性)。模式匹配不仅仅可以被用在 match 上,在 let 语句中也可以直接使用模式匹配来解构。

最简单的模式匹配,就是匹配整个变量,只需要单个标识符就可以接受整个值。或者使用 _ 来放弃捕获, _ 会匹配任意值,但是不会夺走所有权。

通过 (t0, t1) 的形式可以匹配一个元组,解构出 t0 和 t1 ,也可以通过 _ 放弃匹配某些值,需要忽略一段值的时候使用 .. 来表示, .. 可以放在任何地方,但是必须能使编译器确认变量和值之间一一对应的关系。

通过 StructName { mem_var0: s0, mem_var1: s1 } 的形式匹配一个结构体,解构出 s0 和 s1,同样可以使用 _.. 来忽略一些值。

enum Ptn {
    Tpl(String, i32, i32, Vec<i32>),
    Cls{s: String, i: i32, j: i32, v: Vec<i32>},
}

match whatever {
    Ptn::Tpl(s, .., v) => println!("Tpl({:?}, .., {:?})", s, v),
    Ptn::Cls{s, ..} => println!("Cls{{s: {:?}, ..}}", s),
};

通过 ... 来匹配一段区间,在匹配整型的时候可以直接匹配范围。

通过 | 来匹配多种可能,在模式匹配中, | 的优先级要比 ... 高。

还可以在匹配的最后尾置一个 if 来增加检查条件。

还可以在匹配到的时候使用 @ 符号重新绑定给另一个变量。如果不希望某次匹配到之后值直接被移动进 match 创建的作用域,可以用 ref 来只绑定一个新的引用,或者 mut ref 绑定可变引用。

下面有一个例子一锅粥糊了一下上面说的三种匹配方式……

match tpl {
    (st @ 0...2, nd @ 3, ..) => println!("1st!"),
    (1, 2, 3, _) | (..)  => println!("2nd!"),
    ref tuple if tuple.3 != vec![0; 0] => println!("{:?}", tuple),
    _ => println!("no!"),
}
println!("{:?}", tpl);

小结

以上内容稍微掌握一下,就已经可以写一些简单的玩具代码了。

然而这些基本操作都不是 Rust 的特产,其中有很多其它语言的影子。真正的 Rust style programming ,会在下一部分开始介绍。

发表评论