Rust实战
上QQ阅读APP看书,第一时间看更新

1.6 Rust语言是什么?

作为一门编程语言,Rust与众不同的一个特点就是,它能够在编译时就防止对无效数据的访问。微软安全响应中心的研究项目和Chromium浏览器项目都表明了,与无效数据访问相关的问题约占全部严重级安全漏洞(serious security bug)的70%。[12] Rust消除了此类漏洞。它能保证程序是内存安全(memory safe)的,并且不会引入额外的运行时开销。

其他语言可以提供这种级别的安全性(safety),但它们需要在程序的执行期添加额外检查,这无疑会减慢程序的运行速度。Rust设法突破了这种持续已久的状况,开辟出了属于自己的空间,如图1.1所示。

图1.1 Rust兼具安全性和可控性,其他语言则倾向于在这两者之间进行权衡和取舍

就像Rust专业社区所认可的那样,Rust的与众不同之处是“愿意将价值观明确纳入其决策流程中”。这种包容精神无处不在。来自互联网用户的互动消息很受欢迎。Rust社区内的所有互动均受其行为准则约束,甚至Rust编译器的错误信息都是非常有帮助的。

早在2018年年底之前,浏览Rust网站主页的访问者还会看到这样的(更偏向技术性的)宣传语——Rust是一门运行速度极快,能防止出现段错误并能保证线程安全的系统编程语言。后来,社区修改了措辞,从更改后的内容(见表1.1)可以看出,措辞方面已经是以用户(和潜在用户)为中心的了。

表1.1 Rust宣传语的变更。随着对Rust的发展越来越有信心,社区越来越多地接受了这样一种观念,就是可以作为每个希望实现其编程愿望的人的促进者和支持者

人们给Rust打上了系统编程语言的印记,通常将其视为一个相当专业的、深奥的编程语言分支。但是,许多Rust程序员发现该语言还适用于许多其他领域。安全性、生产力和控制,在软件工程项目中都很有用。Rust社区的“包容性”也意味着,该语言将源源不断地从来自不同利益群体的“新声音”中汲取营养。

接下来,让我们分别来看这3个目标——安全性、生产力和控制,具体指什么,以及为什么它们如此重要。

1.6.1 Rust的目标:安全性

Rust程序能避免以下几种异常情况出现。

悬垂指针:引用了在程序运行过程中已经变为无效的数据(见清单1.3)。

数据竞争:由于外部因素的变化,无法确定程序在每次运行时的行为(见清单1.4)。

缓冲区溢出:例如一个只有6个元素的数组,试图访问其中的第12个元素(见清单1.5)。

迭代器失效:在迭代的过程中,迭代器中值被更改而导致的问题(见清单1.6)。

如果程序是在调试模式下编译的,那么Rust还可以防止整数溢出。什么是整数溢出呢?整数只能表示数值的一个有限集合,它在内存中具有固定的宽度。比如,整数的上溢出就是指,如果整数的值超出了它的最大值的限制,就会发生溢出,并且它的值会再次变回该整数类型的初始值。

清单1.3所示的是一个悬垂指针的例子。注意,此示例的源代码文件存储路径为ch1/ ch1-cereals/src/main.rs。

清单1.3 试图创建一个悬垂指针

 1 #[derive(Debug)]    ⇽---  允许使用println! 宏来输出枚举体Cereal(谷类)。
2 enum Cereal {    ⇽---  enum(枚举体,是enumeration的缩写)是一个具有固定数量的合法变体的类型。
3     Barley, Millet, Rice,
4     Rye, Spelt, Wheat,
5 }
6
7 fn main() {
8     let mut grains: Vec <Cereal> = vec![];    ⇽---  初始化一个空的动态数组,其元素类型为Cereal。
9     grains.push(Cereal::Rye);    ⇽---  向动态数组grains(粮食)中添加一个元素。
10     drop(grains);    ⇽---  删除grains和其中的数据。
11     println!("{:?}", grains);    ⇽---  试图访问已删除的值。
12 }

如清单1.3所示,在第8行中创建的grains,其内部包含一个指针。Vec<Cereal>实际上是使用一个指向其底层数组的内部指针来实现的。但是此清单无法通过编译。尝试去编译会触发一个错误信息,信息的大意是“试图去‘借用’一个已经‘被移动’了的值”。学习如何理解该错误信息并修复潜在的错误,是本书后面几页内容的主题。编译清单1.3的代码,输出信息如下所示:

$ cargo run
Compiling ch1-cereals v0.1.0 (/rust-in-action/code/ch1/ch1-cereals)
error[E0382 borrow of moved value: 'grains'
--> src/main.rs:12:22
|
8 |     let mut grains: Vec <Cereal> = vec![];
|         ---------- move occurs because 'grains' has type
'std::vec::Vec <Cereal>', which does not implement
the 'Copy' trait
9 |     grains.push(Cereal::Rye);
10 |     drop(grains);
|          ------ value moved here
11 |
12 |     println!("{:?}", grains);
|                      ^^^^^^ value borrowed here after move
error: aborting due to previous error
For more information about this error, try 'rustc --explain E0382'.
error: could not compile 'ch1-cereals'.

清单1.4(参见ch1/ch1-race/src/main.rs文件)展示了一个Rust防止数据竞态条件的示例。之所以会出现这种情况,是因为外部因素的变化而无法确定程序在每次运行中的行为。

清单1.4 Rust防止数据竞态条件的示例

 1 use std::thread;    ⇽---  把多线程的能力导入当前的局部作用域。
2 fn main() {
3     let mut data = 100;
4
5     thread::spawn(|| { data = 500; });    ⇽---  thread::spawn() 接收一个闭包作为参数。
6     thread::spawn(|| { data = 1000; });
7     println!("{}", data);
8 }

如果你还不熟悉线程这个术语,那么请记住,上述这段代码的要点就是“它的运行结果是不确定的。也就是说,无法知道在main()退出时,data的值是什么样的”。在清单1.4的第5行和第6行中,调用thread :: spawn()会创建两个线程。每次调用都接收一个闭包作为参数——闭包是由竖线和花括号来表示的(例如||{...})。第5行创建的这个线程试图把data变量的值设为500,而第6行创建的这个线程试图把data变量的值设为1000。由于线程的调度是由操作系统决定的,而不是由应用程序决定的,因此根本无法知道先定义的那个线程会不会率先执行。

如果尝试编译清单1.4,就会出现许多错误信息。Rust不允许应用程序中存在多个位置,这些位置都能够对同一数据进行写操作。在此代码中,有3个位置都试图进行这样的访问:一个位置出现在main()中运行的主线程里,另两个位置则出现在由thread :: spawn()创建出的子线程中。编译器的输出信息如下:

$ cargo run
Compiling ch1-race v0.1.0 (rust-in-action/code/ch1/ch1-race)
error[E0373]: closure may outlive the current function, but it
borrows 'data', which is owned by the current function
--> src/main.rs:6:19
|
6 |    thread::spawn(|| { data = 500; });
|                  ^^ ---- 'data' is borrowed here
|                  |
|                  may outlive borrowed value 'data'
|
note: function requires argument type to outlive ''static'
--> src/main.rs:6:5
|
6 |    thread::spawn(|| { data = 500; });
|    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: to force the closure to take ownership of 'data'
(and any other referenced variables), use the 'move' keyword
|
6 |    thread::spawn(move || { data = 500; });
|                  ^^^^^^^
...    ⇽---  此处忽略了其他的3个错误。
error: aborting due to 4 previous errors
Some errors have detailed explanations: E0373, E0499, E0502.
For more information about an error, try 'rustc --explain E0373'.
error: could not compile 'ch1-race'.

清单1.5给出了一个由缓冲区溢出而引发恐慌的示例。缓冲区溢出描述的是“试图访问内存中不存在的或者非法的元素”这样一种情况。在这个例子中,如果尝试访问fruit[4],将导致程序崩溃,因为fruit变量中只有3个fruit(水果)。清单1.5的源代码存放在文件ch1/ch1-fruit/ src/main.rs中。

清单1.5 由缓冲区溢出而引发恐慌的示例

 1 fn main() {
2     let fruit = vec!['1-5-1', '1-5-2', '1-5-3'];
3
4     let buffer_overflow = fruit[4];    ⇽---  Rust会让程序崩溃,而不会把一个无效的内存位置赋值给一个变量。
5     assert_eq!(buffer_overflow,'1-5-4')    ⇽---  assert_eq!() 会测试其参数是否相等。
6 }

如果编译并运行清单1.5,你会看到如下所示的错误信息:

$ cargo run
Compiling ch1-fruit v0.1.0 (/rust-in-action/code/ch1/ch1-fruit)
Finished dev [unoptimized + debuginfo] target(s) in 0.31s
Running 'target/debug/ch1-fruit'
thread 'main' panicked at 'index out of bounds:
the len is 3 but the index is 4', src/main.rs:3:25
note: run with 'RUST_BACKTRACE=1' environment variable
to display a backtrace

清单1.6所示的是一个迭代器失效的例子。也就是说,在迭代过程中,因迭代器中的值被更改而导致出现问题。清单1.6的源代码存放在文件ch1/ch1-letters/src/main.rs中。

清单1.6 在迭代过程中试图去修改该迭代器

 1 fn main() {
2     let mut letters = vec![    ⇽---  创建一个可变的动态数组letters。
3         "a", "b", "c"
4     ];
5
6     for letter in letters {
7         println!("{}", letter);
8         letters.push(letter.clone());    ⇽---  复制每个letter,并将其追加到letters的末尾。
9     }
10 }

如果编译清单1.6的代码,就会出现编译失败的情况,因为Rust不允许在该迭代块中修改letters。具体的错误信息如下:

$ cargo run
Compiling ch1-letters v0.1.0 (/rust-in-action/code/ch1/ch1-letters)
error[E0382]: borrow of moved value: 'letters'
--> src/main.rs:8:7
|
2 |   let mut letters = vec![
|       ----------- move occurs because 'letters' has type
|                   'std::vec::Vec <&str>', which does not
|                   implement the 'Copy' trait
...
6 |   for letter in letters {
|                 -------
|                 |
|                 'letters' moved due to this implicit call
|                 to '.into_iter()'
|                 help: consider borrowing to avoid moving
|                 into the for loop: '&letters'
7 |       println!("{}", letter);
8 |       letters.push(letter.clone());
|       ^^^^^^^ value borrowed here after move
error: aborting due to previous error
For more information about this error, try 'rustc --explain E0382'.
error: could not compile 'ch1-letters'.
To learn more, run the command again with --verbose.

虽然错误信息字里行间满是专业术语(borrowmovetrait等),但Rust保护了程序员,使其不至于踏入许多其他语言中会掉入的陷阱。而且不用担心——当你学完本书的前几章后,这些专业术语将会变得更容易理解。

知道一门语言是安全的,能给程序员带来一定程度的自由。因为他们知道自己的程序不会发生“内爆”,所以会更愿意去做各种尝试。在Rust社区中,这种自由催生出了无畏并发的说法。

1.6.2 Rust的目标:生产力

如果可以,Rust会选择对开发人员来说最容易的选项。Rust有许多可以提高生产力的微妙特性。然而,程序员的生产力很难在书本的示例中得以展示。那就让我们从一些初学者易犯的错误开始吧——在应该使用相等运算符(==)进行测试的表达式中使用了赋值符号(=)。

1 fn main() {
2     let a = 10;
3
4     if a = 10 {
5         println!("a equals ten");
6     }
7 }

在Rust中,这段代码会编译失败。Rust编译器会产生下面的信息:

error[E0308 mismatched types
--> src/main.rs:4:8
|
4 |     if a = 10 {
|        ^^^^^^
|        |
|        expected 'bool', found '()'
|        help: try comparing for equality: 'a == 10'
error: aborting due to previous error
For more information about this error, try 'rustc --explain E0308'.
error: could not compile 'playground'.
To learn more, run the command again with --verbose.

首先,上文中的mismatched types会让人觉得像是一个奇怪的错误信息。我们肯定能够测试变量与整数的相等性。

经过一番思考,你会发现if测试接收到错误的类型的原因。在这里,if接收的不是一个整数,而是赋值表达式的结果。在Rust中,这是一个空的类型(),被称作单元类型[13]

当不存在任何有意义的返回值时,表达式就会返回()。再来看看下面给出的这段代码,在第4行上添加了第二个等号以后,这个程序就可以正常工作了,会输出a equals ten

1 fn main() {
2     let a = 10;
3
4     if a == 10 {    ⇽---  使用一个有效的运算符( == ),让程序通过编译。
5         println!("a equals ten");
6     }
7 }

Rust具有许多工效学特性,如泛型、复杂数据类型、模式匹配和闭包。[14]  用过其他提前编译型语言的人,很可能会喜欢Rust的构建系统,即功能全面的Rust软件包管理器:cargo。

初次接触时,我们看到cargo是编译器rustc的前端,但其实它也为Rust程序员提供了下面这些命令。

cargo new用于在一个新的目录中,创建出一个Rust项目的骨架(cargo init则使用当前目录)。

cargo build用于下载依赖项并编译代码。

cargo run所做的事情和cargo build差不多,但同时会运行生成出来的可执行文件。

cargo doc为当前项目生成HTML文档,其中也包括每个依赖包的文档。


[12] 参见We need a safer systems programming language.

[13] Rust吸收了函数式编程语言的诸多特性,如“单元类型”这个名称就是从函数式编程语言(如Ocaml和F#)家族继承而来的。理论上,单元类型只有一个值,就是它本身。相比之下,布尔类型有两个值(真/假),而字符串可以有无限多个值。

[14] 即使对这些术语不熟悉,也请继续阅读本书。本书其他章节对这些术语进行了解释。

1.6.3 Rust的目标:控制

Rust能让程序员精确控制数据结构在内存中的布局及其访问模式。虽然Rust会用合理的默认值来实施其“零成本抽象”的理念,然而这些默认值并不适合所有情况。

有时,管理应用程序的性能是非常有必要的。让数据存储在中而不是中,有可能是很重要的。有时,创建出一个值的共享引用,再给这个引用添加引用计数,有可能很有意义。偶尔为了某种特殊的访问模式,创建自己的指针类型可能就会很有用。设计空间是很大的,Rust提供的各种工具可以让你实现自己的首选解决方案。


注意 如果你对引用计数等术语不熟悉,也请不要放弃!我们将在本书的其他章节中用大量的篇幅来解释这些内容,以及它们是如何一起工作的。


运行清单1.7中的代码,会输出一行信息,即a: 10, b: 20, c: 30, d: Mutex { data: 40 }。其中的每个变量都表示一种存储整数的方式。在接下来的几章中,我们会讲解与每种级别的表示形式相关的权衡和取舍。就现在而言,要记住的重要一点就是,可供选择的各种类型的选项还是很全面的。欢迎你为特定的使用场景选出合适的使用方式。

清单1.7展示了创建整数值的多种方式。其中的每种形式都提供了不同的语义和运行时特征,但是程序员是可以完全控制自己希望做出的权衡和取舍的。

清单1.7 创建整数值的多种方式

1 use std::rc::Rc;
2 use std::sync::{Arc, Mutex};
3
4 fn main() {
5     let a = 10;    ⇽---  在栈中的整数
6     let b = Box::new(20);    ⇽---  在堆中的整数,也叫作装箱的整数。
7     let c = Rc::new(Box::new(30));    ⇽---  包装在一个引用计数器中的装箱的整数。
8     let d = Arc::new(Mutex::new(40));    ⇽---  包装在一个原子引用计数器中的整数,并由一个互斥锁保护。
9     println!("a: {:?}, b: {:?}, c: {:?}, d: {:?}", a, b, c, d);
10 }

要理解Rust为什么会有这么多种不同的方式,请参考以下3条原则。

该语言的第一要务是安全性。

默认情况下,Rust中的数据是不可变的。

编译时检查是强烈推荐使用的。安全性应该是“零成本抽象”的。