我最近向 Rust 的文档提交了一个提案。我通篇提案中一个重要组成部分是对可能听说过 Rust 的人简短而简单的介绍,以便他们能够确定 Rust 是否适合他们。前几天,我看到了一个精彩的演讲,并认为它可以作为这个介绍的一个很好的基础。将此视为此类介绍的 RFC(Request For Comments)。非常欢迎反馈在 rust-dev 或 Twitter 上。
这个教程已经成为官方教程。
Rust 是一种系统编程语言,专注于强大的编译时正确性保证。它通过提供非常强大的编译时保证和对内存生命周期的明确控制,改进了其他系统语言(如 C ++,D 和 Cyclone)的思想。强大的内存保证使编写正确的并发 Rust 代码比使用其他语言更容易。这可能听起来非常复杂,但它比听起来更容易!本教程将让您在大约 30 分钟内了解 Rust。希望你至少模糊地熟悉以前的“大括号”语言。这些概念比语法更重要,所以如果你没有得到每一个细节,请不要担心:本教程可以帮助你解决这个问题。
让我们来谈谈 Rust 中最重要的概念:“所有权”,以及它对并发编程(对程序员来讲通常是非常困难的任务)的启发。
所有权
所有权是 Rust 的核心,也是其更有趣和独特的功能之一。“所有权”是指允许哪部分的代码修改内存。让我们从查看一些 C ++ 代码开始:
int *dangling(void)
{
int i = 1234;
return &i;
}
int add_one(void)
{
int *num = dangling();
return *num + 1;
}
dangling 函数在栈上分配了一个整型,然后保存给一个变量 i,最后返回了这个变量 i 的引用。这里有一个问题:当函数返回时栈内存变成失效。意味着在函数 add_one 第二行,指针 num 指向了垃圾值,我们将无法得到想要的结果。虽然这个一个简单的例子,但是在 C ++ 的代码里会经常发生。当堆上的内存使用 malloc(或 new)分配,然后使用 free(或 delete)释放时,会出现类似的问题,但是您的代码会尝试使用指向该内存的指针执行某些操作。更现代的 C ++ 使用 RAII 和构造函数 / 析构函数,但它们无法完全避免“悬空指针”。这个问题被称为“悬空指针”,并且不可能编写出现“悬空指针”的 Rust 代码。我们试试吧:
fn dangling() -> &int {
let i = 1234;
return &i;
}
fn add_one() -> int {
let num = dangling();
return *num + 1;
}
当你尝试编译这个程序时,你会得到一个有趣和非常长的错误信息:
temp.rs:3:11: 3:13 error: borrowed value does not live long enough
temp.rs:3 return &i;
temp.rs:1:22: 4:1 note: borrowed pointer must be valid for the anonymous lifetime #1 defined on the block at 1:22…
temp.rs:1 fn dangling() -> &int {
temp.rs:2 let i = 1234;
temp.rs:3 return &i;
temp.rs:4 }
temp.rs:1:22: 4:1 note: …but borrowed value is only valid for the block at 1:22
temp.rs:1 fn dangling() -> &int {
temp.rs:2 let i = 1234;
temp.rs:3 return &i;
temp.rs:4 }
error: aborting due to previous error
为了完全理解这个错误信息,我们需要谈谈“拥有”某些东西意味着什么。所以现在,让我们接受 Rust 不允许我们用悬空指针编写代码,一旦我们理解了所有权,我们就会回来看这块代码。
让我们先放下编程一会儿,先聊聊书籍。我喜欢读实体书,有时候我真的很喜欢一本书,并告诉我的朋友他们应该阅读它。当我读我的书时,我拥有它:这本书是我所拥有的。当我把书借给别人一段时间,他们向我“借用”这本书。当你借用一本书时,在特定的一段时间它是属于你的,然后你把它还给我,我又拥有它了。对吗?
这个概念也直接应用于 Rust 代码:一些代码“拥有”一个指向内存的特定指针。它是该指针的唯一所有者。它还可以暂时将该内存借给其他代码:代码“借用”它。借用它一段时间,称为“生命周期”。
这是关于所有权的所有。那似乎并不那么难,对吧?让我们回到那条错误信息:error: borrowed value does not live long enough。我们试图使用 Rust 的借用指针&,借出一个特定的变量 i。但 Rust 知道函数返回后该变量无效,因此它告诉我们:
borrowed pointer must be valid for the anonymous lifetime #1
… but borrowed value is only valid for the block。
优美!
这是栈内存的一个很好的例子,但堆内存呢?Rust 有第二种指针,一个 ’ 唯一 ’ 指针,你可以用〜创建。看看这个:
fn dangling() -> ~int {
let i = ~1234;
return i;
}
fn add_one() -> int {
let num = dangling();
return *num + 1;
}
此代码将成功编译。请注意,我们使用指针指向该值而不是将 1234 分配给栈:~1234。你可以大致比较这两行:
// rust
let i = ~1234;
// C++
int *i = new int;
*i = 1234;
Rust 能够推断出类型的大小,然后分配正确的内存大小并将其设置为您要求的值。这意味着无法分配未初始化的内存:Rust 没有 null 的概念。万岁!Rust 和 C ++ 之间还有另外一个区别:Rust 编译器还计算了 i 的生命周期,然后在它无效后插入相应的 free 调用,就像 C ++ 中的析构函数一样。您可以获得手动分配堆内存的所有好处,而无需自己完成所有工作。此外,所有这些检查都是在编译时完成的,因此没有运行时开销。如果你编写了正确的 C ++ 代码,你将编写出与 C ++ 代码基本上相同的 Rust 代码。而且由于编译器的帮忙,编写错误的代码版本是不可能的。
你已经看到了一种情况,所有权和生命周期有利于防止在不太严格的语言中通常会出现的危险代码。现在让我们谈谈另一种情况:并发。
并发
并发是当前软件世界中一个令人难以置信的热门话题。对于计算机科学家来说,它一直是一个有趣的研究领域,但随着互联网的使用爆炸式增长,人们正在寻求改善给定的服务可以处理的用户数量。并发是实现这一目标的一种方式。但并发代码有一个很大的缺点:它很难推理,因为它是非确定性的。编写好的并发代码有几种不同的方法,但让我们来谈谈 Rust 的所有权和生命周期的概念如何帮助实现正确并且并发的代码。
首先,让我们回顾一下 Rust 中的简单并发示例。Rust 允许你启动 task,这是轻量级的“绿色”线程。这些任务没有任何共享内存,因此,我们使用“通道”在 task 之间进行通信。像这样:
fn main() {
let numbers = [1,2,3];
let (port, chan) = Chan::new();
chan.send(numbers);
do spawn {
let numbers = port.recv();
println!(“{:d}”, numbers[0]);
}
}
在这个例子中,我们创建了一个数字的 vector。然后我们创建一个新的 Chan,这是 Rust 实现通道的包名。这将返回通道的两个不同端:通道 (channel) 和端口 (port)。您将数据发送到通道端(channel),它从端口端(port) 发出。spawn 函数可以开启一个 task。正如你在代码中看到的那样,我们在新任务中调用 port.recv()(’receive’ 的缩写),我们在外面调用 chan.send(),传入 vector。然后我们打印 vector 的第一个元素。
这样做是因为 Rust 在通过 channel 发送时 copy 了 vector。这样,如果它是可变的,就不会有竞争条件。但是,如果我们正在启动很多 task,或者我们的数据非常庞大,那么为每个任务都 copy 副本会使我们的内存使用量膨胀而没有任何实际好处。
引入 Arc。Arc 代表“原子引用计数”,它是一种在多个 task 之间共享不可变数据的方法。这是一些代码:
extern mod extra;
use extra::arc::Arc;
fn main() {
let numbers = [1,2,3];
let numbers_arc = Arc::new(numbers);
for num in range(0, 3) {
let (port, chan) = Chan::new();
chan.send(numbers_arc.clone());
do spawn {
let local_arc = port.recv();
let task_numbers = local_arc.get();
println!(“{:d}”, task_numbers[num]);
}
}
}
这与我们之前的代码非常相似,除了现在我们循环三次,启动三个 task,并在它们之间发送一个 Arc。Arc :: new 创建一个新的 Arc,.clone()返回 Arc 的新的引用,而.get()从 Arc 中获取该值。因此,我们为每个 task 创建一个新的引用,将该引用发送到通道,然后使用引用打印出一个数字。现在我们不 copy vector。
Arcs 非常适合不可变数据,但可变数据呢?共享可变状态是并发程序的祸根。您可以使用互斥锁 (mutex) 来保护共享的可变状态,但是如果您忘记获取互斥锁(mutex),则可能会发生错误。
Rust 为共享可变状态提供了一个工具:RWArc。Arc 的这个变种允许 Arc 的内容发生变异。看看这个:
extern mod extra;
use extra::arc::RWArc;
fn main() {
let numbers = [1,2,3];
let numbers_arc = RWArc::new(numbers);
for num in range(0, 3) {
let (port, chan) = Chan::new();
chan.send(numbers_arc.clone());
do spawn {
let local_arc = port.recv();
local_arc.write(|nums| {
nums[num] += 1
});
local_arc.read(|nums| {
println!(“{:d}”, nums[num]);
})
}
}
}
我们现在使用 RWArc 包来获取读 / 写 Arc。RWArc 的 API 与 Arc 略有不同:读和写允许您读取和写入数据。它们都将闭包作为参数,并且在写入的情况下,RWArc 将获取互斥锁,然后将数据传递给此闭包。闭包完成后,互斥锁被释放。
你可以看到在不记得获取锁的情况下是不可能改变状态的。我们获得了共享可变状态的便利,同时保持不允许共享可变状态的安全性。
但等等,这怎么可能?我们不能同时允许和禁止可变状态。是什么赋予这个能力的?
unsafe
因此,Rust 语言不允许共享可变状态,但我刚刚向您展示了一些允许共享可变状态的代码。这怎么可能?答案:unsafe
你看,虽然 Rust 编译器非常聪明,并且可以避免你通常犯的错误,但它不是人工智能。因为我们比编译器更聪明,有时候,我们需要克服这种安全行为。为此,Rust 有一个 unsafe 关键字。在一个 unsafe 的代码块里,Rust 关闭了许多安全检查。如果您的程序出现问题,您只需要审核您在不安全范围内所做的事情,而不是整个程序。
如果 Rust 的主要目标之一是安全,为什么要关闭安全?嗯,实际上只有三个主要原因:与外部代码连接,例如将 FFI 写入 C 库,性能(在某些情况下),以及围绕通常不安全的操作提供安全抽象。我们的 Arcs 是最后一个目的的一个例子。我们可以安全地分发对 Arc 的多个引用,因为我们确信数据是不可变的,因此可以安全地共享。我们可以分发对 RWArc 的多个引用,因为我们知道我们已经将数据包装在互斥锁中,因此可以安全地共享。但 Rust 编译器无法知道我们已经做出了这些选择,所以在 Arcs 的实现中,我们使用不安全的块来做(通常)危险的事情。但是我们暴露了一个安全的接口,这意味着 Arcs 不可能被错误地使用。
这就是 Rust 的类型系统如何让你不会犯一些使并发编程变得困难的错误,同时也能获得像 C ++ 等语言一样的效率。
总而言之,伙计们
我希望这个对 Rust 的尝试能让您了解 Rust 是否适合您。如果这是真的,我建议您查看完整的教程,以便对 Rust 的语法和概念进行全面,深入的探索。