1.1 Rust是什么,以及为何需要关注它

“Rust是一种采用过去的知识解决将来的问题的技术。”

——Graydon Hoare

Rust是一种快速、高并发、安全且具有授权性的编程语言,最初由Graydon Hoare于2006年创造和发布。现在它是一种开源语言,主要由Mozilla团队和许多开源社区成员共同维护和开发。它的第一个稳定版本于2015年5月发布,该项目开发的初衷是希望解决使用C++编写的Gecko中出现的内存安全问题。Gecko是Mozilla Firefox浏览器采用的浏览器引擎。C++不是一种容易驾驭的语言,并且存在并发抽象容易被误用的问题。针对C++的Gecko,开发人员在2009年和2011年进行了几次尝试来并行化它的层叠样式表(Cascading Style Sheets,CSS)解析代码,以便充分利用当前流行的并行CPU架构,但他们失败了,因为C++的并发代码难以理解和维护。由于大量开发人员在拥有庞大代码库的Gecko上进行协作,因此使用C++在其中编写并发代码的体验非常糟糕。随着希望消除C++“不良”部分的呼声日渐高涨,Rust诞生了,随之而来的是Servo—— 一个从头开始创建浏览器引擎的新研究项目。Servo项目利用前沿编程语言的特性向语言开发团队提供反馈,这反过来又影响了语言的演变。

2017年11月左右,部分Servo项目,特别是stylo(Rust中的并行CSS解析器)项目,开始发布最新的Firefox版本(Quantum项目),在如此短的时间内完成新版本的发布是一项伟大的成就。Servo的最终目标是用其组件逐步取代Gecko中的组件。

Rust的灵感来自多种语言的知识,其中值得一提的是Cyclone(一种安全的C语言变体)的基于区域的内存管理技术、C++的RAII原则、Haskell的类型系统、异常处理类型和类型类。


 

C:\Users\Administrator\Desktop\书.tif 

注意

资源获取时初始化(Resource Acquisition Is Initialization,RAII)是一种范式,表明必须在对象初始化期间获取资源,并且必须在调用其析构函数或解除分配时释放资源。


 

该语言的运行时非常小,不需要垃圾收集,并且对于程序中声明的任何值,默认情况下更倾向于堆栈(stack)分配,而不是堆(heap)分配(开销),我们将在第5章中详细解释这些内容。Rust编译器rustc最初是用Ocaml(一种函数式编程语言)编写的,并且于2011年由自身重新编译后成为自托管版本。


 

C:\Users\Administrator\Desktop\书.tif 

注意

自托管是指通过编译自己的源代码构建编译器,该过程被称为编译器自举。编译器自己的源代码可以作为编译器的一个非常好的测试用例。


 

Rust在GitHub上有开源开发的网址,它的发展势头非常迅猛。通过社区驱动的请求注解过程(Request For Comments,RFC)将新功能添加到语言中,并且任何人都可以在其中提交新的功能特性,然后在RFC文档中详细描述它们。之后就RFC寻求共识,如果达成共识,则该功能特性进入实施阶段。然后,社区会对已实现的功能进行审核,经过用户在每晚发布的版本中进行的几次测试后,这些功能最终被整合到主分支中。从社区获得反馈对语言的发展至关重要。每隔6周,社区就会发布一个新的稳定版本的编译器。除了快速变化的增量更新之外,Rust还具有版本的概念,这个概念被标记为该语言提供统一的更新。这包括工具、文档、相关的生态系统,以及逐步实现的任何重大改进。到目前为止,Rust包括两个版本,其中Rust 2015专注于稳定性,Rust 2018专注于提高生产力(这是本书在编写时的版本情况)。

虽然Rust是一种通用的多范式语言,但它的目标是C和C++占主导地位的系统编程领域。这意味着你可以使用Rust编写操作系统、游戏引擎和许多性能关键型应用程序。同时,它还具有足够的表现力,你可以使用它构建高性能的Web应用程序、网络服务,类型安全的数据库对象关系映射(Object Relational Mapping,ORM)库,还可以将程序编译成WebAssembly在Web浏览器上运行。Rust还在为嵌入式平台构建安全性优先的实时应用程序方面获得了相当大的关注,例如Arm基于Cortex-M的微控制器,目前该领域主要由C语言主导。Rust因其广泛的适用性在多个领域都表现良好,这在单一编程语言中是非常罕见的。

此外,Cloudflare、Dropbox、Chuckfish、npm等公司和机构都已经将它应用到多个高风险项目的产品中。

Rust作为一门静态和强类型语言而存在。静态属性意味着编译器在编译时具有所有相关变量和类型的信息,并且在编译时会进行大量检查,在运行时只保留少量的类型检查。它的强类型属性意味着不允许发生诸如类型之间自动转换的事情,并且指向整数的变量不能在代码中更改为指向字符串。例如在JavaScript等弱类型语言中,你可以轻松地执行类似“two = "2"; two = 2 + two;”这样的操作。JavaScript在运行时将2的类型弱化为字符串,因此会将22作为字符串存储到变量two中,这与你的意图完全相反并且毫无意义。在Rust中,与上述代码意义相同的代码是“let mut two = "2"; two = 2 + two;”,该代码将会在编译时捕获异常,并提示信息:“cannot add '&str' to '{integer}'”。因此,强类型属性使Rust可以安全地重构代码,并在编译时捕获大多数错误,而不是在运行时出错。

用Rust编写的程序表现力和性能都非常好,因为使用它你可以拥有高级函数式语言的大部分特性,例如高阶函数和惰性迭代器,这些特性使你可以编译像C/C++程序这样高效的程序。它的很多设计决策中强调的首要理念是编译期内存安全、零成本抽象和支持高并发。让我们来详细说明这些理念。

编译期内存安全:Rust编译期可以在编译时跟踪程序中资源的变量,并在没有垃圾收集器(Garbage Collectors,GC)的情况下完成所有这些操作。


 

C:\Users\Administrator\Desktop\书.tif 

注意

资源可以是内存地址,包含某个值的变量、共享内存引用、文件句柄、网络套接字或数据库连接句柄等。


 

这意味你不会遇到在free、double free命令之后调用指针,或者运行时挂起指针等“臭名昭著”的问题。Rust中的引用类型(类型名称前面带有&标记的类型)与生命周期标记隐式关联('foo),有时由程序员显式声明。在生命周期中,编译器可以跟踪代码中可以安全使用的位置,如果它是非法的,那么会在编译期报告异常。为了实现这一点,Rust通过这些引用上的生命周期标签来运行借用/引用检查算法,以确保你永远不能访问已释放的内存地址。这样做也可以防止你释放被其他某些变量调用的任何指针。我们将在第5章详细介绍这一主题。

零成本抽象:编程的目的就是管理复杂性,这是通过良好的抽象来实现的。接下来让我们来看一个Rust和Kotlin的良好抽象示例。抽象让我们能够编写高级并且易于阅读和推断的代码。我们将比较Kotlin的流和Rust的迭代器在处理数字列表时的性能,并参照Rust提供的零成本抽象原则。这里的抽象是指能够使用以其他方法作为参数的方法,根据条件过滤数字而不使用手动循环。在这里引入Kotlin是因为它看上去和Rust存在相似性。代码很容易理解,我们的目标是给出更高层面的解释,并对代码中的细节进行详细阐述,因为这个示例的重点是理解零成本特性。

首先,我们来看Kotlin中的代码:

1. import java.util.stream.Collectors
2. 
3. fun main(args: Array<String>) 
4. {
5.     //创建数字流
6.     val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10).stream()
7.     val evens = numbers.filter { it -> it % 2 == 0 }
8.     val evenSquares = evens.map { it -> it * it }
9.     val result = evenSquares.collect(Collectors.toList())
10.    println(result)       // prints [4,16,36,64,100]
11.
12.    println(evens)
13.    println(evenSquares)
14. }

我们创建了一个数字流(第6行)并调用了一系列方法(filter和map)来转换元素,以收集仅包含偶数的序列。这些方法可以采用闭包或函数(第8行中的“ it -> it * it”)来转换集合中的元素。在函数式编程语言中,当我们在流/迭代器上调用这些方法时,对于每个这样的调用,该语言会创建一个中间对象来保存与正在执行的操作有关的任何状态或元数据。因此,evens和evenSquares将在JVM堆上分配两个不同的中间对象。在堆上分配资源将会产生内存开销,这是我们在Kotlin中为抽象必须额外付出的代价。

当我们输出evens和evenSquares的值时,确实得到了两个不同的对象,如下所示:

java.util.stream.ReferencePipeline$Head@51521cc1
java.util.stream.ReferencePipeline$3@1b4fb997

@之后的十六进制值是JVM对象的哈希值。由于哈希值不同,所以它们是不同的对象。

在Rust中,我们会做相同的事情:

1. fn main() {
2.     let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10].into_iter();
3.     let evens = numbers.filter(|x| *x % 2 == 0);
4.     let even_squares = evens.clone().map(|x| x * x);
5.     let result = even_squares.clone().collect::<Vec<_>>();
6.     println!("{:?}", result);      // 输出 [4,16,36,64,100]
7.     println!("{:?}\n{:?}", evens, even_squares);
8. }

接下来将解释上述代码的细节。在第2行中,我们调用vec![]创建一个数字列表,然后调用into_iter()方法使其成为一个数字的迭代器/流。使用into_iter()方法从集合中创建了一个包装器的迭代器类型(这里Vec<i32>是一个有符号的32位整数列表),即IntoIter([1,2,3,4,5,6, 7,8,9,10]),此迭代器类型引用原始的数字列表。然后我们执行filter和map转换(第3行和第4行),就像我们在Kotlin中所做的那样。第7行输出evens和even_squares的类型,如下所示(为了简洁,省略了一些细节):

evens:        Filter { iter: IntoIter( <numbers> ) }
even_squares: Map { iter: Filter { iter: IntoIter( <numbers> ) }}

中间对象Filter和Map是基础迭代器结构上的包装器类型(未在堆上分配),它本身是一个包装器,包含对第2行的原始数字列表的引用。第4行和第5行的包装器结构在分别调用filter和map时创建,它们之间没有任何指针解引用,并且不会像Kotlin那样产生堆分配的开销。所有这些可归结为高效的汇编代码,这相当于使用循环(语句)的手动编写版本。

支持高并发:当我们说Rust是并发安全的时,其含义是该语言具有应用程序接口(Application Programming Interface,API)和抽象能力,使得编写正确和安全的并发代码变得非常容易。而在C++中,并发代码出错的可能性非常大。在C++中同步访问多个线程的数据时,需要在每次进入临界区时调用mutex.lock(),并在退出它时调用mutex.unlock():

// C++

mutex.lock();                    // 互斥锁锁定
 // 执行某些关键操作
mutex.unlock();                  // 执行完毕

 

C:\Users\Administrator\Desktop\书.tif 

注意

临界区:这是一组需要以原子方式执行的指令/语句。这里的原子意味着没有其他线程可以中断临界区中正在执行的线程,并且在临界区执行代码期间,任何线程都无法感知其中的中间值。


 

在大量开发人员共同协作的大型代码库中,你可能会忘记在多线程访问共享对象之前调用mutex.lock(),这可能导致数据访问冲突。在其他情况下,你可能忘记解开互斥锁(Mutex),并使其他想要访问数据的线程一直处于等待状态。

Rust对此有不同的处理方式。在这里,你将数据包装成Mutex类型,以确保来自多个线程的数据进行同步可变访问:

// Rust

use std::sync::Mutex;

fn main() {
    let value = Mutex::new(23);
    *value.lock().unwrap() += 1;      // 执行一些修改
}                                     // 这里自动解锁

在上述代码中,我们能够在变量value调用lock()方法之后修改数据。Rust采用了保护共享数据自身,而不是代码的概念。Rust与Mutex和受保护的数据的交互并不是独立的,这和C++中的情况一样。你无法在Mutex类型不调用lock()方法的情况下访问内部数据。那么lock()方法的作用是什么?调用lock()方法之后会返回一个名为MutexGuard的东西,它会在变量超出作用域范围之后自动解除锁定,它是Rust提供的众多安全并发抽象之一。另一个新颖的想法是标记特征的概念,它在编译期验证,并确保在并发代码中同步和安全地访问数据,第4章详细介绍了该特征。类型会被称为Send和Sync的标记特征进行注释标记,以指示它们是否可以安全地发送到线程或者在线程之间共享。当程序向线程发送值时,编译器会检查该值是否实现了所需的标记特征,如果没有,则禁止使用该值。通过这种方式,Rust允许你毫无顾虑地编写并发代码,编译器在编译时会捕获多线程代码中的异常。编写并发代码已经很难了,使用C/C++会让它变得更加困难和神秘。当前CPU没有获得更多的时钟频率;相反,我们添加了更多内核。因此,并发编程是正确的发展方向。Rust使得编写并发代码变得轻而易举,并且降低了编写安全的并发代码的门槛。

Rust还借鉴了C++的RAII原则用于资源初始化,这种技术的本质是将资源的生命周期和对象的生命周期绑定,而堆分配类型的解除分配是通过执行drop特征上的drop()方法实现的。当变量超出作用域时,程序会自动调用此方法。它还用Result和Option类型替代了空指针的概念,我们将在第6章对此进行详细介绍。这意味着Rust不允许代码中出现null/undefined的值,除非通过外部函数接口与其他语言交互,以及使用不安全代码时。该语言还强调组合,而不是继承,并且有一套特征系统,它由数据类型实现,类似于Haskell的类型类,也被称为加强型的Java接口。Rust中的特征属性是很多其他特性的基础,我们将在后续的章节逐一介绍。

但同样重要的是,Rust社区非常活跃和友好。该语言包含非常全面的文档,可以在Rust官网中找到。Rust在Stack Overflow的开发者调查上连续3年(2016年、2017年和2018年)被评为最受欢迎的编程语言,因此编程社区对它非常青睐。总而言之,如果你希望编写具有较少错误的高性能软件,又希望感受当前流行语言的特性和极佳的社区文化,那么Rust应该是一个不错的选择。