用Rust语言写好代码

207 阅读8分钟

用Rust语言写好代码

本文将介绍Rust所提供的许多有趣的功能,但并没有围绕如何用Rust编写程序进行详细介绍。如果你正在寻找一个好的资源来开始学习Rust,请参考Learn Rust指南,它对初学者很有帮助。

在谈论Rust的特性时,我将定期谈论C/C++如何处理这些相同的事情作为比较。Rust以相当优雅的方式修复了C中固有的很多问题。(注意:还有一些语言也可以和Rust的特性相提并论,我就不谈了)。

本文的主要讨论点是Rust如何 "强迫 "你写好代码,这反过来又消除了所有可能导致segfaults的未定义行为。现在,你不能只是把C语言代码,改变编译器,然后说你可以保证没有segfaults;这些改变必须发生在代码中,因为segfaults来自代码,而不是编译器。这也并不意味着编译器不能帮助你写出好的代码。

为什么是Rust?

Rust如何迫使你写出好的代码?这主要发生在两个部分。

  • 强大的类型系统(和错误处理)
  • 所有权/借贷规则

Rust的类型系统

重要的是要知道,Rust是强类型和静态类型的。

展示类型系统有多强大的最好的例子,就是展示Rust是如何处理原始指针的,更确切地说,是空指针。

现在,你可能在想,没有原始指针的系统编程语言是什么?希望Rust可以挑战你在这方面的成见。

既然如此,让我们看看下面的C代码。

int * can_return_null() { ... }
int main()
{
    printf("%d\n", *can_return_null());
}

这里有一个问题。我们没有检查以确保这个指针不是空的。这个错误会导致这段代码发生故障。此外,编译器没有做任何事情来帮助防止我们这样做。现在,令人惊讶的是,Rust对此有一个解决方案;用其他东西替换原始指针。

fn can_return_null() -> Option<&'static i32> { ... } // i32 is the same as an int
fn main()
{
    println!("{:?}", can_return_null()); // will print the Option not the data
}

这分两部分进行。首先是Option<T> 类型。这是一个代数数据类型的例子,可以取值为Some(data)None

在这个例子中,data 部分是对被返回的int的引用。这意味着如果数据为空,你可以返回一个没有附加数据的None 。如果返回值不是空的,那么你可以返回一个Some(data) ,在访问data 之前,你必须首先检查它是否是Some

第二个是底层的返回类型&i32static 指的是寿命,这不在本文的范围之内),它实际上是一个引用,而不是一个原始指针。

从功能上讲,在汇编级别,这些与原始指针是一样的。区别在于你如何创建它们。正如null指针所示,原始指针可以取任何值(例如:int *x = 0x0; )。

在Rust中,你不能真正使用原始指针(好吧,你可以,但它更复杂,也超出了本文的范围)。相反,你必须使用一个引用。Rust中的引用必须从现有的值中创建(例如:let ref_x = &x; 而不是let ref_x = 0x0; )。

现在回到代码中,因为我们不能创建一个空的引用,所以当我们不能创建一个指针时,我们 "被迫 "使用Option<T> 类型,返回一个None

总而言之,当我们试图访问这个返回值时,我们必须首先检查它是否是Some(data) ,然后再使用它。

if let Some(data) = can_return_null() { // if Some, grab data from Some
    println!("{}", *data);
}

试试吧

不用说得太详细,Rust中的很多错误处理都是以同样的方式进行的。有一种类型叫做 Result<T, E>的类型,它可以是Ok(T) ,也可以是Err(E) 。你不能假设它是其中之一。

Rust的所有权模型

在Rust中,有一个叫做所有权的概念。它是迄今为止与其他语言最明显不同的东西之一。这个想法是,每个值都由一个变量拥有。例如,在let x = 5; 中,值5 是由x 所拥有。现在,让我们来看看被称为移动所有权的东西。

let s1 = String::from("hello"); // Makes a String object from a string literal
let s2 = s1;
println!("{}, section.", s1);

"hello" 最初由s1 拥有,但在第二行,它被移到了s2 (现在由s2 拥有)。

在Rust中,一个值在同一时间只能被一个变量拥有。这意味着,当我们试图打印s1 ,我们会得到一个错误,因为这个值被移动了。

试试吧

顺便提一下,看看Rust的编译器错误有多好。那么为什么这很重要呢?好吧,如果你在C语言中尝试这个,你会得到两个对内存中同一点的引用。这不一定是件坏事,但这两个引用都是可变的,这可能会导致竞赛条件。这就是问题所在,因为C语言让你很容易产生竞赛条件。

有时你想对一个对象有多个引用。这就是借用的作用。为了借用,我们只需得到一个值的引用;你可以有可变的和不可变的引用,并遵循简单的规则。Rust能够在编译时检测到违反规则的情况。

let mut s1 = String::from("hello");
let s2 = &s1; // immutable reference
// let s3 = &mut s1; // mutable reference
// s3.push_str(", section.");
println!("{}, section.", &s1); // another immutable reference
println!("{}, section.", s2);
// println!("{}", s3);

就其本身而言,上面的代码会被编译。这是因为我们只创建过不可变的引用,单独的引用永远不会产生竞赛条件(不管有多少)。现在,如果我们取消对上述代码中s3 的注释,我们将开始得到编译器错误,特别是我们 "不能把s1 作为可变的借用,因为它也被作为不可变的借用"。

这看起来并不重要,但现在的情况是,Rust "强迫 "我们写 "好代码"。这使得Rust能够保证不会出现任何竞赛条件,也不会出现任何内存别名。

试试吧

好吧,但我们仍然想要一个系统编程语言

Rust首先是一种系统编程语言。这意味着什么呢?首先,这意味着不存在运行时

简而言之,Rust所做的一切都使用零成本的抽象。你可能想知道Rust是如何实现抽象数据类型而不给程序增加运行时间成本的?他们通过使用他们的枚举来做到这一点,枚举是通过类似于C的联合来实现的,然后可以进行大量的优化。

下面是Option<T> 的实现。

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

现在,这是一个非常好的零成本抽象的例子,因为它在大多数情况下被完全编译出来。

当涉及到使用Options时,你通常会把它们和nullable值一样对待。编译器会看到这一点,并将None 优化为null,将Some(&T) 优化为*T (这只是在我们使用对类型的引用时,例如Option<&t> )。

在汇编层面,这和原始指针是一样的。真正的区别是Rust "强迫 "你有None 检查(现在是空检查)。

当涉及到所有权和借用时,Rust可以在编译时检查是否违反规则。这不仅意味着Rust的运行速度可以和C一样快,而且有时甚至更快。他们能做到这一点是因为他们制定的规则允许Rust做出C语言无法做到的优化。

下面是一个相当简单的例子

fn compute(input: &u32, output: &mut u32) {
    if *input > 10 {
        *output = 1;
    }
    if *input > 5 {
        *output *= 2;
    }
}

Rust能够将其优化为以下内容。

fn compute(input: &u32, output: &mut u32) {
    let cached_input = *input; // keep *input in a register
    if cached_input > 10 {
        *output = 2;  // x > 10 implies x > 5, so double and exit immediately
    } else if cached_input > 5 {
        *output *= 2;
    }
}

这是因为Rust能够保证inputoutput 不引用相同的东西(即没有内存别名)。这一点是C语言无法做到的。在C语言中,这两段代码做的是不同的事情。

我可以只写好C语言代码吗?

看起来很多例子只是说明了Rust如何防止你写出非常琐碎的错误,甚至是不会真正造成问题的东西。虽然这是事实,但在大项目中,代码会变得非常复杂,一个简单的错误会像大海捞针一样隐藏在代码中。更糟糕的是,错误会在整个程序中传播,使其很难调试。你有没有试过弄清楚你为什么/在哪里得到一个空指针?

很多代码可以是 "安全的",它不可能单独导致segfault(像上面的一些C例子)。然而,这并不意味着它是好代码。归根结底,这就是Rust的帮助所在。

Rust迫使你写出好的代码,这样简单的可避免的错误就不会再来咬你。当你用Rust写代码时,你可以保证你的代码不会有任何未定义的行为,只是通过编译(这是最难的部分);这是其他语言无法提供的(尤其是在编译时)。

根据Stack Overflow的开发者调查,Rust连续4年成为最受喜爱的语言是有原因的。