Heather

2019年学习编程语言Rust

原文链接: www.ragona.com

我在一月份的空闲时间都在玩Rust编程语言。Rust通常作为一种系统编程语言,的确它是一个很好的选择。Rust是很快的,当你需要很快的时候,你会为了得到它而忍受一切。编程的历史上充满了以快的名义进行邪恶行为的人。

但是作为一个编程语言的呆子,Rust即使慢了很多也很有趣。通过引入借位检查和生存期等新概念,Rust消除了所有类型的内存错误。Rust也有一个非常典型而有表现力的系统,和大量具备影响性的现代功能。(然而功能不太强大;如果您只编写旧式有不良反应诱导循环的东西,它不会有影响。

我最后一次使用Rust是在几年前,当我离开的时候,我对这种体验有点好奇,但又对我所碰到的粗糙之处感到恼火。然而,Rust在2015年才达到1.0,而我在2019年的体验比我第一次接触这门语言时的体验要好得多。一个月后,我感觉很舒服,并期待着写更多。

在这篇文章中,基于我在学习过程中犯过的错误,我曾经使用过的畅通的资源,以及我从这些经验中得到的东西,我将分享一些快速的笔记。我的希望是,通过留档一些我碰到的东西,我可以为其他Rust新手节省一点时间。现在,值得注意的是,我仍然是一个初学者,所以如果我犯了什么错误,请让我知道,我可以更新它。

Rust 正在高速发展

Rust有着令人印象深刻的发行节奏;即使是在2019年1月,我也看到了Rust 1.32版本在语言上的显著进步。(查看新的dbg!()宏;它比println!("{:?}")语法快得多!)这意味着该语言一直在变得更好,而且团队在整个生态系统的可用性方面做得很好,所以Rust变得越来越容易编写。与2015年的Rust1.0首次发布时相比,2019年的语言更加流畅。

这样做的一个副作用是,当你为一个问题寻找答案时,很容易被过时的解决方案绊倒。从某种程度上来说,2015年相关的堆栈溢出已经过时了,如果它是Python的话可能就不一样了。它实际上可能仍然有效,但您可能会有这样的印象,即该语言不符合人体工程学。Rust有很好的文档,但是它的旧版本仍然在流传,所以并不总是能够立即看出惯用的答案是什么。

认真阅读文档

Rust有非常优秀的文档,非常值得一读。除了优秀的Rust书籍(“这本书”),我推荐阅读版本指南,它介绍了Rust 2015年版和2018年版之间的主要区别。这将有助于清除您所阅读的代码之间看到的一些令人困惑的不一致性。(我发现大多数代码示例仍然使用2015风格的示例,例如use vs. extern crate语句。)Rust还使编写自己的代码变得非常容易;这确实是该语言的强项。

社区很棒

Rust有一个非常有用的社区,所以如果您遇到了困难,我建议您寻求帮助。我很欣赏Rust有一个非常合理的行为准则,并且版主通过他们的社区渠道来执行它。社区刚刚接受了这一点,即一开始没有人理解Rust引入的新概念,这真的为编程社区提供了一种令人耳目一新的态度。行为准则有助于确保这种积极的文化能够随着语言的发展而扩展。

我发现 Discord 频道特别有用,因为你可以与某人就语言进行实时(或线上)对话。这是该语言的一个巨大优势,拥有一个良好的、积极的、活跃的社区聊天频道,你可以在那里获得帮助。我喜欢思考 Discord,就像旧的流下载规则;你去寻求帮助(下载),然后一旦你得到你的答案,你想去尝试回答问题(接种)。

我还强烈推荐新的Rustacean播客,它已经运行了好几年了,对于那些喜欢通过听别人说话来学习的人来说是一个非常好的资源。我最喜欢的学习方法之一是和我的同事谈论编码,因此能够听Chris描述Rust的概念对我来说真的很有用。

如果它变得诡异,就退一步

有些时候,你会发现自己的处于这样一处境,在这个处境里,你你传递了一引用,借检查告诉你不明朗的一生,你开始拼命注释在整个代码库,接下来你发现你自己把事情搅成一团在Box和RefCell,事情就变得奇怪。

编译器是如此有用,以至于很容易进入一个热流,当你遇到一个错误,你只需要去做编译器建议的任何事情。这通常是很好的,但有时你只是做了一堆你大致理解的奇怪事情后,就会掉入一个无底洞。

我开始意识到这种情况是一种代码气味,如果我与编译器发生冲突,这通常意味着我需要后退一步,看看是否有更简单的方法来表达一些东西。好消息是,几乎总有一个相对简单和干净的解决方案,出去走走,看看有没有其他的方法来代替你正在做的事情。

错误处理

一个让我挠头的地方是错误处理;有很多看似正确的选择。这是一个进展迅速的领域,正在进行积极的工作以改善事态。(查看这个RFC以获得更详细的解释。)当您研究错误处理时,您会发现这里有几个选项,根据具体情况,它们都很好。

让我简要地回顾一下我在为一个库选择正确选项时所尝试的选项。与其尝试重新创建一堆现有的项目,不如尝试总结各种选项,并建议您阅读Andrew Gallant在《Rust》中的优秀文章《错误处理》。

错误处理选项

  1. 只需使用unwrap或expect。这是您在阅读初学者文档时看到的第一个选项,文档中到处都有unwrap。这对于快速构建原型来说还好,但是它会创建一些混乱的代码。我想你大部分猜你大多情况下会更喜欢?语法,但是为了做到这一点,您必须返回一些不同的结果。我的代码的一个最大的改进是为库创建了一个定制的结果<T, MyError>类型。

  2. 使用外部板条箱来处理错误,比如failure。有一些板条箱的设计是为了减少错误处理的痛苦,比如failure, quick-error, 和error-chain。最常见的似乎是failure。这可能是二进制项目的正确选择;这是一种处理多种错误类型的简单方法,没有太多的样板文件。如果我要做一个快速工具,我会用这个。虽然,对于库作者来说,有时可能是错误的选择,因为它强制依赖于使用您的库的人。它也有一些性能代价,特别是如果使用不当。

  3. 创建自定义错误类型。这通常是图书馆作者的正确选择。它需要一些样板文件,但是它允许您创建一个自定义结果<T, MyError>类型,该类型可以处理库抛出的错误类型。例如,您将在std::io::Error中的std库中看到这种模式。我使用了这个错误以告终。以CSV库中的rs示例为例,当它开始工作时,我对这个解决方案非常满意。实际上我学到了很多关于实施我自己的错误类型的Traits,所以我很高兴我做到了。

我还问自己这个问题:“这个方法会失败吗?”通常“不”是一个合理的答案;你不需要返回选项或结果的一切。返回一个默认值可以使您的库更加符合人体工程学。

不要害怕堆积

Rust中的所有内容默认都是堆栈分配的。这有一些重要的含义,其中之一是,编译器通常需要知道对象的内存大小,以便能够在堆栈上适当地分配内存。这意味着有时需要使用Box或Rc之类的容器类型来传递指向已分配堆的对象的指针。出于某种原因,我的第一反应是避开这些类型。它们看起来很复杂,堆分配是有代价的,一开始我试着反对使用它们。然而,在某些情况下,为了表达某些思想,您必须使用诸如Box之类的容器类型,而实际上,Rust中trait系统的一些最酷的用法是由容器类型实现的。

一个简单的例子是一个包含Trait对象的向量。在我的绘图库中,我有一个Sprite类型,它是其他可绘图对象的容器。您可以将子元素添加到特定精灵的显示列表中,它将通过get_pixel方法将所有子元素合成到一个图像中。

pub struct Sprite {
    children: Vec<Box<Drawable>>,
    ...
}


我希望您能够将实现Drawable 特征t的任何类型的对象添加到Sprite的显示列表中。然而,这带来了一个问题,因为Vec需要知道它应该容纳的对象有多大。我们通过在向量中存储一个Box来解决这个问题。然而,这带来了一个问题,我只是强制用户在使用库时考虑这种内存管理问题,我不喜欢那样。

###对库用户隐藏容器类型

如果不小心,容器类型可能会给API增加一些额外的负担。例如,在上面的Sprite示例中,我希望有一个add_child方法,它将一个新的Box添加到children中。我最初的特征码是这样的:

pub fn add_child(&mut self, child: Box<Drawable>)

库的用户会这样调用这个方法:

parent.add_child(Box::new(Rectangle::new()));

这没错,但是作为库的用户,我不想关心Sprite使用堆分配的事实。Vec和String也是堆分配的,但是我不需要传递Box对象。为了清理API,我在Drawable 特征中添加了一个方法,这个方法可以将对象移动到一个框中,它确实清理了接口,尽管add_child的签名现在变得更复杂了:

pub fn add_child<T: 'static>(&mut self, child: T)
where
    T: Drawable + Sized,
{
    self.children.push(child.into_box());
}

// as a user of the library
parent.add_child(Rectangle::new());

那就好了很多

拥抱Rust的灵活性

Rust有一些非常整洁的功能启发特性,我有时会发现自己陷入了使用功能样式的麻烦中。然而,Rust允许您使用您最喜欢的任何样式,在某些情况下,使用普通的for 循环可以更容易地表达概念。我还发现,在一些情况下,首先使用for循环来实现解决方案更容易一些,然后在让它工作之后,返回并将其更改为iter()链。一个例子是循环遍历位图中的每个像素并返回一个扁平的Vec< pixel >,以便将它转换为PNG的字节。

假设我们有一些这样的代码:

fn pixels(&self) -> Vec<Pixel> {
    let mut pixels: Vec<Pixel> = Vec::with_capacity(self.area());
    for x in 0..self.width() {
        for y in 0..self.height() {
            pixels.push(self.get_pixel(x, y))
        }
    }
    pixels
}

这很容易理解。使用纯迭代器的版本有点奇怪:

fn pixels(&self) -> Vec<Pixel> {
    (0..self.height())
        .flat_map(|y| {
            (0..self.width())
                .map(move |x| {
                    self.get_pixel(x, y)
                })
        })
        .collect()
    }

我不喜欢那样。但是,与Python一样,itertools库非常棒,可以真正地解决这个问题。我在利用iproduct!宏,这是一种很好的方式来迭代两个迭代器的笛卡尔积。

fn points(&self) -> Vec<Point> {
    iproduct!(0..self.width(), 0..self.height()) // iproduct is from itertools
        .map(|(x, y)| Point { x, y })
        .collect()
}

fn pixels(&self) -> Vec<Pixel> {
    self.points()
        .into_iter()
        .map(|point| self.get_pixel(point.x, point.y))
        .collect()
}

这里有很多有用的答案。Rust是灵活的,你可以用任何最简单的方式把想法写下来,然后在将来再回来把它清理干净。

自定义类型转换

这只是一个小技巧,我一直在寻找它的用途。Rust有一个可以为自定义类型实现的From trait (docs),它可以在某些地方真正地清理代码。在我看来,Rust的默认特性有点像Python中的“double under”方法(也称为“魔术措施”),比如可以添加到自定义类中来改变它们的行为。在Rust from允许两种类型进行简单的转换。

例如,我遇到过这样一种情况,我将库中的点类型发送给一个算法(下面代码中的Bresenham)用于绘制直线。然而,行库需要一个元组(isize, isize)。我可以像对set_pixel参数那样内联执行转换,但是我为(isize, isize)块执行了一个From。代码反而更简洁。

impl From<Point> for (isize, isize) {
    fn from(point: Point) -> (isize, isize) {
        (point.x as isize, point.y as isize)
    }
}

pub fn line(&mut self, start: Point, end: Point, color: Pixel) {
    // note that I can use .into() here because of the From trait
    for (x, y) in Bresenham::new(start.into(), end.into()) { 
        self.set_pixel(x as i32, y as i32, color);
    }
}

在很多情况下,这是非常有用的,所以要记住这一点。

接下来是什么?

我只懂一点点皮毛,我还在学习如何最大限度地利用这门语言。这个库的代码中还有几个地方我的解决方案感觉不对,所以我需要在那里做更多的研究。总的来说,我很看好Rust,我对速度和可用性之间的融合感到兴奋。一旦你通过了最初的学习曲线,Rust就会觉得自己是一门非常高效的语言,它的速度很快,可以帮助你解决一些在高级语言中无法解决的问题。