[外文翻译]Rust 语言如何帮助你防止 bug

如果你曾经编写过任何规模大小的程序,你可能会遇到各种错误。你在编码时产生的微小的错误会导致你的程序执行失败。程序越复杂,发生错误的概率越高!

为了修复和防止错误,有很多方法可供程序员使用。其中一个是在运行程序之前确定程序的正确性:静态类型。这种技术是设计编程语言的一部分,并且可以防止简单的错误,例如尝试使用字符串作为整数,或者比较类型不同的对象,例如 CarBook

我的个人观点是,编程语言及其实现应该尽可能地捕获程序员所犯的错误,从而使得他们能构建更好更安全的软件。虽然静态类型使得语言更加复杂和难以学习,但它为程序员提供了一个安全的机制,我相信这是非常值得的。

Rust 语言实现了这样的静态类型系统,并提供了捕获错误的新方法,这在其他语言中运行时会导致崩溃。在这篇文章中,我将会讲解其中一些方法。

Null 返回

子程序由于遇到某种边界情况而无法返回结果并不罕见。想想一个例子:在数组中查找并不包含某项元素的索引,从空堆栈中弹出元素或在很小的数组上的通过索引访问元素。这些处理方式在不同语言之间差别很大。最常见的处理方式似乎是抛出异常或返回 null(或-1)。

如果你忘记检查 -1 或 null 或捕获异常,你的程序将会崩溃。然而,Rust 有一个确保在编译时捕获和处理错误的策略。

Option 类型

使用Rust的标准库Option来处理函数边界情况。这是一个枚举类型(如果你熟悉C语言的话,有点像一个联合体)包含有两个可能的值。这是它的声明:

enum Option<T> {
  None,
  Some(T),
}

例如,Vec::pop 方法,从堆栈向量中弹出最后一个元素,当堆栈向量中至少有一个元素时返回 Some 和元素,如果堆栈向量为空,则返回 None 。

现在,获取一个 Option 的值需要一个match结构。我们不能只是声明一个值已经被返回,并且像返回一个指针的语言一样使用它。那很好!程序员被迫考虑如何处理返回 None 的情况。如果在另一种语言中类似的代码会导致运行时错误,而在 Rust 中,将会阻止程序被编译:

let mut numbers = vec![21];
let maybe_number = numbers.pop(); // Option<i32>
println!("{}", maybe_number * 2); //  编译错误!

这个出现这个错误报告: error[E0369]: binary operation * cannot be applied to type std::option::Option<{integer}>

必须使用 match 来判断是否有返回结果:

let mut numbers = vec![21];
let maybe_number = numbers.pop();
if let Some(my_number) = maybe_number {
  println!("{}", my_number * 2); // 现在正常工作了!
} // 我们还可以添加一个else代码块来处理None情况

Result 类型

Option 类型相似,还有 Result 类型。Result 就像 Option 一样,但不仅仅是 SomeNone,它可以是包含函数返回结果的 Ok,或者如果出现错误,它会有包含一个错误的 Err。这种错误也作为值返回的错误处理方式最好与 Go语言的方式进行比较。和 Rust 不一样,关键的区别在于 Go 语言中结果和 null-able 错误都会返回。这意味着忘记检查是否已经返回错误并使用结果,将导致运行时错误。

Rust 没有这个陷阱,因为像 Option 一样,必须先检查 Result 枚举的内容。因此,在发生错误时,不会误用结果。

强制初始化

大多数其他语言允许程序员将变量的声明和初始化分开,这样的后果是,程序员有时往往会忘记初始化这样的变量,例如在分支中。虽然也有一些语言会在编译器中会终止编译(Java)或发出警告(C, C++),但这些并不会阻止程序员通过将变量初始化为 null 或零值而使得编译器不报错、在运行时引起崩溃或更糟糕的结果以及让程序做错误的处理。

对于未初始化的变量绑定,Rust 将拒绝编译,从而防止运行时错误:

let a: &str;
println!("{}", a.len()); // Compilation error!
a = "Hello";

这将报错:error[E0381]: use of possibly uninitialized variable: *a

当然,null 初始化技巧仍然是可用的,现在需要使用 Option 类型,如前所述,需要对 None 类型进行显式处理。

如果可以,一旦声明变量就初始化你的绑定:

let a = "hello";
println!("{}", a.len());

Traits

Traits(类似于其他语言中的接口)可以描述为可执行某些操作的类型的抽象定义。例如,Rust 标准库定义了一个 fmt::Display trait 表示它们自己为字符串的类型。Traits 让 Rust 看起来像静态类型语言,可以用作通用函数和类型的约束。

思考下面的函数:使用字符串表示将一段整数写入文件:

fn write_list(out: &mut fs::File, numbers: &[i32]) -> io::Result<()> {
  for num in numbers {
    writeln!(out, "{}", num)?;
  }
  Ok(())
}

简单吧? 但是如果是无符号整数呢?字符串呢?浮点数? 自定义类型? 我们需要为所有类型的类型创建同样一个函数!

但是 Rust 可以使用一个类型参数为我们做到这一点:

fn write_list<W, T>(mut out: W, things: &[T]) -> io::Result<()>
  where W: io::Write,
        T: fmt::Display {
  for thing in things {
    writeln!(out, "{}", thing)?;
  }
  Ok(())
}

现在,此函数接受实现Display trait的任何类型。

我也可以使用W替换了一个类型参数&mut fs::File,并且必须实现 io::Write trait。 这使得更容易编写单元测试,因为不用使用fs api和临时文件,我们只是使用一个vector,因为它实现了io::Write。 请注意,W参数是所有而不是引用(&mut W),因为W的io::Write的实现是所有可变引用的类型的io::Write实现!

引用

如果你以前用过C/C++,那你或许理解指针。引用(References),是一种可以通过其读写一块内存而不用关心其如何分配的东西。然而Rust中的引用和C中的指针不太一样,因为引用不能为null。

在我们了解引用存在的必要性以及工作模式之前,我想先解释一下所属(ownership)的概念。

所属

Rust和C相同的是,其程序中的数据被存在heap或者stack中,而且没有垃圾回收机制。其不同点是,Rust规定了特定的内存管理方式:其子过程(subroutine)的所属者(owner)负责分配和释放子过程的内存。

这种内存管理模式防止了忘记释放内存、释放两次以及释放后再次使用内存的情况,进而消除了烦人的安全bug。这是Rust语言内置的机制,并且用这个替换了垃圾回收器,这是一种在编译期间安全管理内存的方式。

安全引用

Rust中的函数(Functions)是通过引用(References)来操作数据的。这是因为一个函数要读/写一些东西的话,那么就要对这这些数据拥有所有权。

在Rust中,有两种类型的引用:

  1. 不可变引用(Immutable References), &T, 对象访问权限为可读
  2. 可变对象(Mutable References), &mut T, 被引用对象可以被修改(mutated)

引用必须要满足一些规则:

  1. 多个不可变引用可以同时指向同一个对象
  2. 一个对象在一个时刻只能有一个可变引用
  3. 一个对象,不可能同时既存在可变引用又存在不可变引用
  4. 只能从一个可被拥有(owned)的可变对象中获得一个可变引用

这些规则的存在防止了一些例如并发修改的bug,例如:一个数组在读取的过程中被修改。

并发修改

ArrayList<String> list = new ArrayList<String>();
list.push("hello");
list.push("world");
for (String item : list) {
  if (item.equals("hello")) {
    list.add(", "); // throws a ConcurrentModificationException!
  }
}

在 RUST 中,类似于上面的代码看起来如下:

let mut list = vec!["hello", "world"];
for item in list.iter() {
  if *item == "hello" {
    list.push(", "); // Compilation error!
  }
}

这两个代码段都包含相同的错误:修改正在读取中的数组。在上面的第一段代码中,错误会在运行时出现,而在 Rust 中则可以在编译时发现,并抛出 error[E0502]: cannot borrow list as mutable because it is also borrowed as immutable错误提示,下面说明一下 Rust 是如何发现错误的:

list.iter() 为 Vector 中的所有项目创建了一个迭代器。它不会使用 vector 的 ownership,所以它对 vector 使用一个不可变的引用(&Vec)来访问它的内容。list.push() 会修改 vector 的状态,因此必须获取一个可变的引用(&mut Vec)。但是因为迭代器已经使用了不可变的引用,而循环和 push() 必须有一个可变的引用,这就违反了规则三,导致编译被中止。

掺杂不同的操作顺序

掺杂两个或两个以上修改同一对象的操作也可能导致错误。例如,考虑如下假设的一段代码,把一个文件通过HTTP发送到某输出。

struct HttpHeaderWriter<W: io::Write> {
  out: W,
  // ...
}

impl<W: io::Write> HttpHeaderWriter<W> {
  fn finish(self) -> io::Result<()> {
    // ...
  }
  // new(), content_type(), content_length() ...
}

fn send_file<W: io::Write>(mut out: W, mut file: fs::File) -> io::Result<()> {
  let mut header = HttpHeaderWriter::new(&mut out)?;
  header.content_type("application/octet-stream")?;
  header.content_length(file.metadata()?.len())?;
  io::copy(&mut file, &mut out)?;
  header.finish()?;
  Ok(())
}

会提示如下错误: error[E0499]: cannot borrow out as mutable more than once at a time

在这里,HttpHeaderWriter 被认为是一个简单的 writer 封装,以便于编写 HTTP 头更容易。它被给予 out 一个可变的引用。在所有 HTTP 头被设置之后,finish() 被用于结束写 HTTP 头,在这之后,内容可被发送。

这段代码错在使用 io::copy() 进行复制之后调用 header.finish()。Rust 能够检测到这个错误,因为此时 out 同时需要两个可变的引用,这违法了规则二。其中一个引用为 HttpHeaderWriter 所持有,另一个则被用于调用 io::copy()

解决这个问题很简单,我们需要确保在尝试复制 HTTP 体之前中终止 header 的生命周期。可以通过将 header 封装在块中来限制其生命周期,如下:

{
  let mut header = HttpHeaderWriter::new(&mut out)?;
  // ..
  header.finish()?;
}
io::copy(&mut file, &mut out)?;

这确保了它不再被使用,也有助于防止在 HTTP 体已经启动后意外地发送更多的头信息。

生命周期

如果你是一名C程序员,你会关心这个优秀的特性。在C中,你可以这样做:

char *nth_char(char *str, int n) {
  if (n >= strlen(str)) {
    return 0;
  }
  return &str[n];
}

如果n不超过字符串的边界,那么函数将返回一个指针,指向字符串str的下标n处的字符。但是,当使用返回的指针时,无法确定它指向的字符是否仍然被分配。因此,可能会引入一个”use-after-free”的错误。

Rust 不允许这种情况的发生。Rust擅长于在编译时确定被引用对象是否存活久于他们的引用。以下是上面C代码的Rust实现:

fn nth_byte<'a>(st: &'a mut [u8], n: usize) -> Option<&'a mut u8> {
  if n >= st.len() {
    None
  } else {
    Some(&mut st[n])
  }
}

注意:在Rust中获取指向字符串中某个字符的指针是不可能的,所以我用了字节切片而不是字符串。同时,我们可以忽略这整个函数而代之以 st.get_mut(n)。

要求返回引用时指定生命周期,因此Rust知道被引用的对象将保持分配多久。Rust 会尝试从用作参数的其他引用来进行这种推断,但我已经加了生命周期,所以你可以看到它如何起作用。 ‘a 是字符串st的生命周期,同时也用作被返回引用的生命周期,要求引用的存活时间不长于它指向的字符串。

再见,摇摆不定的指针!

并发

编写多线程运行的代码带来可能会发生的一系列全新的错误。Rust 官方网站大胆宣称“没有存在数据竞争的线程”。那么它是如何工作的呢?

Rust 有两个特殊的特性,当由一个类型实现时,编译器允许有专门的特权:

  • 实现 marker::Send 特性的类型,允许它跨线程边界移动。
  • marker::Sync 表示类型的引用实现了 Send。它意味着多个线程可以访问同一对象,而无需显式的同步机制,就像一个 mutex(互斥体)。

以下代码片段展示了一个例子,其中这些特性阻止了一个非常微妙的缺陷被编译通过:

use std::rc::Rc;
use std::thread;

fn main() {
  let value = Rc::new(1);
  let shared_value = value.clone();
  let handle = thread::spawn(move || {
    println!("{}", shared_value);
  });
  handle.join().unwrap();
}

会提示如下错误: error[E0277]: the trait bound std::rc::Rc<i32>: std::marker::Sync is not satisfied

Rc 是 Rust 的引用计数容器,既不执行 Send 也不执行 Sync。这样做的理由是,在线程之间传递 Rc 实例是不安全的,因为存储实例数量的内部计数器不使用原子操作。

此代码片段可以通过使用原子引用计数器 Arc 来修复。当然,使用原子操作的引用计数增加了一点开销,这也是 Rc 存在的原因。

sync::Mutex

如果一个类型没有实现 Sync(或者你可以通过 Arc 共享该类型),并且你想要一个或多个线程来修改它,你应该使用 sync::Mutex。Rust中的Mutex与其他语言(我使用过的是C++、Go、Java)中的Mutex是不同的,因为它们像容器一样封装要保护的数据,而不是数据之外的松散构造。这样做的好处是当你想访问其包含的数据时,你将被迫锁定Mutex,这意味着你永远不会忘记并引入竞态条件。

小技巧

最后,我想提供一些有关如何有效利用Rust所提供的安全特性的技巧。如本文所述,尽管Rust提供了无数方法来预防错误,但仍然有很多方式会产生错误。

不要使用不安全的代码

当然,除了在与C库交互的时候。

用100%Rust编写的程序是不需要不安全的代码的。为了在你的程序中提供微小的性能提升而放弃本策略可能是很诱人的。但先问一下你自己,这真的值得吗?

变量一定要有意义

如上所述,在“强制初始化”中,当使用没有值的变量绑定时,Rust将中止编译。无论使用任何类型,给变量赋零值以进行初始化,可以让编译器不提示错误。

例如:

let mut username = "";
if foo {
  username = "polyfloyd";
}

在您的程序的上下文中,该默认值是否有意义? 别人读你的代码会误解变量的含义?

这样做:

let username = if foo {
  "polyfloyd"
} else {
  // 对于变量值的表达式,else子句是必需的
  // 您的默认值在这里使用还是有意义的吗?
  // 或者你应该中止一个错误?
};

这种风格提示您考虑要使用的变量。如果你忘记了else子句,它甚至可以帮助你记住。并且不要求变量绑定是可变的。

请勿调用 unwrap

Rust中处理错误的方法是通过Result将错误作为值返回。很容易仅调用unwrap(),取得其中的任何数值,然后继续处理,而不是使用匹配模式。

无论你选择何种返回错误的方式,这都是Rust中的标准作法。运行时出现panic表示程序中存在bug。

结论和其他

Rust的静态类型的实现将防止许多会导致其他语言运行时出现的错误。根据我的个人经验,一个正确编译的程序会按照预期运行。

Rust检查程序中错误及其易用性的能力将在未来得到改善。在撰写本文时,类型系统的进一步改进正在进行中

我想通过这样的一门语言来结束本文 —— 引入了新的有趣的方式通过类型检查来减少出现 bug 的风险:

Perl6

虽然它是一种动态语言,但 Perl6 提供了一个功能,它允许程序员创建自定义派生类型,只有在满足某些条件时才能成功初始化。这将允许对子程序的可接受输入进行非常细粒度的控制。举例来说,你可以引入一个 EvenInt, 一个 NonEmptyList 甚至是一个必须满足某个正则表达式的字符串。

您可以在这里阅读有关 Perl6 Subsets 的更多信息。

本文文字及图片出自 OSchina

余下全文(1/3)
分享这篇文章:

请关注我们:

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注