Skip to content

“错误驱动开发”

" Rust is technology from the past came to save the future from itself. " - Graydon Hoare

建议所有读者去动手编写,不要复制/粘贴 代码示例。这样我们可以从编译器获得的精确而有用的错误消息,Rust社区通常称这种方式为“错误驱动开发”(error-driven development)。 在实际编码中我们也会经常看到这些错误,慢慢地我们会理解编译器是如何看待我们的代码的。

Rust 的发展

Rust 是一种快速、并发、安全、强大的编程语言,最初由 Graydon Hoare 在 2006 年开始开发。它现在是一种开源语言,主要由一个来自 Mozilla 的团队和许多开源人士合作开发。在 2015年5月发布了第一个稳定版本 1.0 。

刚开始该项目的目的是希望能够减轻在 gecko 中使用 C 时出现的内存安全问题。Gecko 是 Mozilla Firefox 浏览器中使用的浏览器引擎。C 语言不是一种容易驯服的语言,它具有容易被滥用的并发抽象。随着 gecko 使用C语言,人们(在2009 年和 2011年)进行了几次尝试,以并行化其层叠样式表(CSS)解析代码,从而利用现代并行cpu。他们失败了,因为并发C代码太难维护和推理了。

由于有大量开发人员在 gecko 庞大的代码基础上协作,使用 C 编写并发代码并不是一件轻松的事。为了逐渐消除 C 语言中令人痛苦的部分,Rust 诞生了,随之,一个从头开始创建浏览器引擎的新研究项目 Servo 也诞生了。Servo 项目通过使用最前沿的语言特性向语言团队提供反馈,这些特性反过来会影响语言的发展。

大约在2017年11月,部分 Servo 项目,特别是 stylo 项目( Rust 中的一个并行 CSS 解析器)开始发布到最新的 Firefox 版本(project Quantum)中,这在如此短的时间内是一项了不起的成就。Servo 的最终目标是逐步取代 gecko 的组件与它的组件。

Rust 受到多种语言的启发,最著名的是 Cyclone(C语言的一种安全方言),借鉴了它的基于区域的内存管理技术;借鉴了 C++ 中的 RAII原理 ,借鉴了 Haskell 中的 类型系统、错误处理类型和 typeclasses

RAII:资源获取即初始化(RAII, Resource Acquisition Is Initialization)是指,当你获得一个资源的时候,不管这个资源是对象、内存、文件句柄或者其它什么,你都会在一个对象的构造函数中获得它,并且在该对象的析构函数中释放它。实现这种功能的类,我们就说它采用了"资源获取即初始化(RAII)"的方式。这样的类常常被称为封装类。

Rust 有一个非常小的运行时,不需要垃圾收集,并且对于程序中声明的任何值,默认情况下倾向于堆栈分配而不是堆分配(堆会产生开销)。 Rust 编译器(rustc),最初是用 Ocaml(一种功能语言)编写的,并在2011年使用 rust 编写后成为一个自托管(self-hosting)的编译器。

self-hosting:是指通过编译自己的源代码来构建编译器。这个过程被称为自展(bootstrapping)编译器。编译器本身的源代码就可以作为一个非常好的编译器的测试用例。

Rust 源码托管在 GitHub 上。任何人都可以通过社区驱动的 RFC(请求注解)过程,提出新的语言特性,向该语言添加新特性。然后寻求 RFC 的一致意见,如果达成一致意见,特性的实现阶段就开始了。实现后的特性会得到社区的审查,经过用户的夜间发布版本(nightly releases)的几次测试后,最终被合并到主分支。从社区获得反馈对语言的发展至关重要。每隔六周,编译器就会发布一个新的稳定版本。除了快速的增量更新之外,Rust 还有版本的概念,它被提议为语言提供统一的更新。这包括工具、文档、它的生态系统,以及对任何破坏性变更的分阶段处理。到目前为止,已经有两个版本:Rust 2015(注重稳定性) 和 Rust 2018 (专注于生产力的)(可能读者看到时,已经超过 2018了)。

作为一种通用的多范式语言,它的目标是在 C 和 C++ 占据主导地位的系统编程领域。这意味着您可以使用它编写操作系统、游戏引擎和许多性能关键的应用程序。同时,它也具有足够的表现力,您可以构建高性能的 web 应用程序、网络服务、类型安全的数据库对象关系映射(ORM)库,还可以通过编译为 WebAssembly 在 web 上运行。Rust 在构建嵌入式平台(如 Arm 的基于 Cortex-M 的微控制器)等安全至关重要的实时应用程序方面也赢得了广泛的关注,该领域目前主要由 C 语言主导。 Rust 在不同领域的广泛适用性在单一编程语言中是非常罕见的。 此外,Cloudflare,Dropbox,Chuckfish,npm等知名公司,还有其他公司已经将其用于高风险(high-stakes)项目的生产中。

Rust 被描述为一种静态的强类型语言。静态属性意味着编译器在编译时拥有关于所有变量及其类型的信息,并且在编译时执行大多数操作,在运行时只进行极少的类型检查。它的强特性意味着它不允许类型之间的自动转换,并且一个指向整数的变量不能在之后的代码中改变为指向字符串。例如,在弱类型语言,如 JavaScript,你可以很容易地做一些事情,如:two = "2"; two = 2 + two;。JavaScript 在运行时将2的类型弱化为字符串,因此将22存储为两个字符串,这完全违背了我们的原本目的,变得毫无意义。在 Rust 中,同样的代码,即让 mut 2 = "2";2 = 2 + 2;,会在编译时被捕获,并抛出以下错误:cannot add '&str' to '{integer}'。此属性可确保代码的安全重构,并在编译时捕获大多数 bug,而不是在运行时引起问题。 用 Rust 编写的程序具有很高的表现力和性能,因为你可以拥有 suc 高级函数风格语言的大多数特性

此属性可确保代码的安全重构,并在编译时捕获大多数错误,而不是在运行时引起问题。Rust编写的程序具有很高的表现力和性能,因为你可以拥有高级函数风格语言(high-level functional style languages)的大多数特性,比如高阶函数(higher-order functions)和 懒性迭代器,但它可以编译成高效的代码,比如 C/C++ 程序。 记住:其许多设计决策所遵循的定义原则是编译时内存安全性无畏并发零成本抽象。 让我们详细说明这些想法。

编译时内存安全性(Compile time memory safety)

编译时内存安全性:Rust 编译器可以在编译时跟踪程序中拥有资源的变量,并且在没有垃圾收集器的情况下完成所有这些工作。

资源:可以是内存地址、保存值的变量、共享内存引用、文件句柄、网络套接字(network sockets)或数据库连接句柄。

这意味着在运行时,在释放,双重释放或悬空指针(free, double free, or dangling pointers)之后使用指针不会出现臭名昭著的问题。在Rust中的引用类型(在它们之前带有&的类型)隐式地与一个生命周期标记('foo)关联,有时由程序员显式地注释。在整个生命周期中,编译器都可以跟踪代码中可以安全使用引用的地方,如果是非法引用了,则在编译时报告错误。 为此,Rust 通过在引用上使用这些生存期的标签来运行借入/引用(borrow/reference)检查算法,以确保您永远无法访问已释放的内存地址。这样做的另一个原因是,当某个指针被其他变量使用时,您无法释放它。

零成本抽象(Zero-cost abstractions)

编程完全是关于管理复杂性的,良好的抽象有助于管理复杂性。让我们来看看 Rust 和 Kotlin(一种面向 Java 虚拟机(JVM)的语言)中抽象的一个很好的例子,该抽象能让我们编写高级代码,并且会易于阅读和推理。我们将比较Kotlin 中的流和 Rust 的迭代器在操作数字列表方面的作用,并对比 Rust 提供的零成本抽象原则。 这里的抽象是能够使用以其他方法作为参数的方法并基于条件去过滤数字,而不使用手动循环。 这里使用 Kotlin 是因为它与 Rust 在代码视觉上的比较相似。这里我们主要是理解零成本属性,其他的我们就一带而过。

以下代码可以在线运行

kotlin
import java.util.stream.Collectors
fun main(args: Array){
    // Create a stream of numbers
    val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10).stream()
    val evens = numbers.filter { it-> it % 2 == 0 }
    val evenSquares = evens.map { it -> it * it }
    val result = evenSquares.collect(Collectors.toList())
    println(result)    // prints [4,16,36,64,100]

    println(evens)
    println(evenSquares)
}

第6行:我们创建一个数字流,并调用方法链(过滤器和映射)来转换元素以只收集偶数的平方。 这些方法可以采用闭包或函数(即,第8行:it -> it * it)来转换集合中的每个元素。 在函数风格的语言中,当我们在流/迭代器上调用这些方法时,对于每个这样的调用,该语言都会创建一个中间对象,以保留与所执行操作有关的任何状态或元数据。 作为结果,evensevenSquares将是分配在JVM堆上的两个不同的中间对象。在堆上分配内容会导致内存开销。 这就是在 Kotlin 语言中必须付出的额外抽象代价!当我们打印evensevenSquares的值时,我们确实得到了不同的对象,如下所示:

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

@后面的十六进制值是 JVM 上对象的哈希码。因为哈希码是不同的,所以它们是不同的对象。

在 Rust 中,我们做同样的事情,以下代码可以在线运行

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

忽略细节,在第2行:我们调用 vec![] 在堆上创建一个数字列表,然后调用 iter() 使其成为一个迭代器/数字流。into_iter() 方法从一个集合中创建一个包装器 Iterator 类型:IntoIter([1,2,3,4,5,6,7,8,9,10])(这里,Vec<i32> 是一个有符号的32位整数的列表)。 这个迭代器类型引用原始的数字列表。 然后,第 3 行和第 4 行:我们执行 filtermap 转换,就像在 Kotlin 中一样。 第7行和第8行:打印了 evenseven_squares 的类型,如下所示(为简洁起见,省略了一些详细信息)

evensFilter { iter: IntoIter( [numbers] ) }
even_squaresMap { iter: Filter { iter: IntoIter( [numbers] ) } }

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

无畏并发(Fearless concurrency)

当我们说 Rust 是并发安全的时候,我们就知道该语言具有应用程序编程接口(API)和抽象,这使得编写正确和安全的并发代码变得非常容易。 与 C++ 相比,C++ 并发代码中出错的可能性相当高。在 C++ 中同步对多个线程的数据进行访问时,您需要负责在每次进入 临界区(Critical Section) 时调用mutex.lock(),在退出此部分时调用mutex.unlock()

Critical Section(关键区; 关键段; 临界区间; 临界区; 临界段):这是一组需要原子地(atomically)执行的指令/语句,这里 原子性(atomically)意味着没有其他线程可以中断当前 临界区 中正在执行的线程,并且在 临界区 中的代码执行期间任何线程都不会感知到中间值(intermediate value)

在大型代码库中,需要许多的开发人员在代码上进行协作,您可能忘记在从多个线程访问共享库之前调用mutex.lock(),这可能导致数据竞争。 在其他情况下,您可能会忘记解锁mutex导致饿死想要访问数据的其他线程。

Rust 对此有不同的操作。首先,您将数据包装在一个Mutex 类型中,以确保来自多个线程对数据进行的同步的可变访问:

rust
// Rust
use std::sync::Mutex;
fn main() {
    let value = Mutex::new(23);
    *value.lock().unwrap() +=1;    // modify
                                   // unlock shere automatically
}

在前面的代码中,我们能够在对value调用lock()之后修改数据。 Rust 使用保护共享数据本身而不是代码的概念。与Mutex和受保护数据的交互不是独立的,就像 C++ 那样。 您必须在Mutex类型上lock才能访问内部数据。 那释放lock呢? 好吧,调用lock()返回一个称为MutexGuard的东西,当变量超出作用域(scope)时,它将自动释放锁。

它是 Rust 提供的许多安全的并发抽象之一,我们将在后面章节中对其进行详细介绍。标记特征(marker traits)的概念是另一个新颖的概念,它可以在编译时验证并确保对并发代码中的数据进行同步以及安全的访问。 类型分别用称为SendSync的标记特征(marker trait)进行注释,以指示它们是安全发送到线程还是安全在线程之间共享。 当程序将值发送给线程时,编译器会检查该值是否实现了所需的标记特征,如果不是,则禁止使用该值。 通过这种方式,Rust允许您毫无顾虑地编写并发代码,其中编译器在编译时会在多线程代码中捕获错误。 编写并发代码已经很困难。 使用C / C ++,它变得更加困难和神秘。 CPU 的时钟频率没有再提高; 相反,CPU 升级带来了更多核心。 因此,并发编程是前进的方向。 Rust 使编写并发代码变得轻而易举,并降低了许多人编写安全的并发代码的门槛。

Rust 还使用 C++ 的 RAII 习惯用法进行资源初始化。该技术基本上将资源的生存期与对象的生存期联系在一起,而堆分配类型的释放是通过drop特性提供的drop方法执行的。 当变量超出作用域(scope)时,将自动调用此方法。 它还用ResultOption类型替换了空指针的概念。 这意味着 Rust 不允许代码中包含null/undefined值,除非通过外部函数接口与其他语言进行交互以及使用不安全的代码(unsafe code)时除外。 该语言还强调组合而不是继承,并具有一个特征系统(trait system),该特征系统由数据类型实现,类似于 Haskell typeclasses(类型类),也称为更带劲的 Java 接口(Java interfaces on steroids)。Rust 的Traits(特征)是其许多功能的支柱,我们将在接下来的章节中看到。

最后但同样重要的是,Rust 的社区非常活跃且友好,并且该语言具有全面的文档。 连续三年(2016年,2017年和2018年),Stack Overflow 的开发人员调查显示了 Rust 是最受欢迎的编程语言,因此可以说,整个编程社区对此非常感兴趣。 综上所述,如果你的目标是编写出性能高、bug 少的软件,同时享受许多现代语言特性和一个很棒的社区,那么你应该关注 Rust。

Released under the MIT License