Rust 可能难以学习且令人沮丧,但它也是软件开发领域在很长一段时间以来最令人兴奋的事情

导航至

我最近决定认真努力学习 Rust 编程语言。我经常在有趣的项目中看到它(例如 ripgrep),并且不断听到关于它的好评。自从 Rust 于 2015 年发布 1.0 版本以来,我一直犹豫不决,原因有两方面。首先,由于 InfluxDB,我完全投入了 Go。其次,我听说它不是最容易学习的东西。虽然我通常不回避困难的任务,但我犹豫不决,因为我认为许多流行的开发者工具之所以流行,是因为它们易于使用或为开发者带来显著的生产力提升。通常,我希望将时间投入到我认为具有持久性并且将在市场上获得一定关键规模的工具中。

然而,关于 Rust 的一些事情让我隐约感觉到,即使它可能难以学习,它也可能在编程语言领域开辟一个非常重要(且必要)的利基市场。接下来是我对我认为的那些优势、我如何学习这门语言以及为什么我对 Rust 感到非常兴奋的思考。但请注意,我还没有在生产环境中运行任何东西,我只编写了大约 2,500 行 Rust 代码,而且我还没有进行任何多线程或网络编程,或者基准测试。这一切都基于我的初步印象。

那么,为什么选择 Rust?是什么原因让我想要更深入地研究这门语言?如果我说它的性能不是重要因素之一,那我就在撒谎了。没有垃圾回收,但在语言/编译器中内置了原始类型以确保您不会忘记释放 mallocs 或意外取消引用无效指针?我报名了。清单上还有另外两个我感兴趣的重要功能。创建可以在其他语言(如 Python、Ruby、Go 等)中通过 FFI 链接的库的能力是我一直在为 InfluxData 的一些未来工作考虑的事情。此外,与 C 和 C++ 库的低(或零?)成本集成是一个很大的动力。有一些大型 C++ 项目我想集成,而 Rust 似乎是实现这一目标的不错方法。

至于 Rust 与 Influx 的相关性,我梦想创建一个基于 Rust 的 Flux(我们的新脚本和查询语言) 实现,它使用 C++ Apache Arrow 库,可以嵌入到其他系统(如 Spark、Kafka Streams 或其他地方)。此外,我们云平台的新版本是基于服务的架构,因此 Rust 可能会通过特定的服务进入我们的基础设施,这些服务可以从其性能和防止数据竞争的保证中受益(Rust 的另一个巨大优势)。这些就是促使我最终迈出这一步的原因,但我很快发现,这些并不是使 Rust 成为一个引人注目的语言的唯一因素。

通过实现词法分析器、Pratt 解析器和树遍历解释器来学习 Rust

我决定一个小项目是开始学习这门语言的最佳方式。我看到有人链接到 Thorsten Ball 关于 用 Go 编写解释器 的书,并认为用 Rust 编写它将是一个好的开始。在阅读完这本书后,我强烈推荐它,但我稍后会再谈。我以前写过解释器,我对 Go 非常熟悉,而且这个项目的好处在于它限制了我需要学习的范围。它只使用标准库功能,不需要任何多线程或网络。Thorsten 的书附带了所有代码和广泛的测试,因此很容易确保事情是正确的。我不必考虑我正在构建的东西的算法,只需考虑如何在 Rust 中专门实现它。但在我深入实现之前,我必须掌握语言基础知识。

我应该简单介绍一下我作为开发者的背景,因为我认为具有不同背景的人可能会发现学习 Rust 更容易(或更难)。自 2013 年年中以来,我一直主要是一名 Go 程序员。在此之前,我花了一年多的时间在一个单页 Javascript (Backbone) 应用程序上工作,再之前,我用 Scala 构建了一个“时间序列数据库(API)”。再往前追溯,我写了 Ruby 和 Rails 应用程序,其中混合了大量的 Javascript,这是我从 2006 年到 2010 年左右关注的重点。再之前是 C#,再之前是 Delphi(我在很多简历上都看不到它)。我以前写过 C 和 C++,但那是在 2009 年之前,即使在那时,我可能也只用这两种语言编写了大约 1 万行代码。更重要的是,我几乎整个职业生涯都在使用垃圾回收语言。我也不是很底层,也不是系统人员(除非你算上构建数据库)。

我首先阅读了 《Rust 编程语言》,它是免费在线文档的一部分。我的学习过程通常包括多次阅读涵盖我尝试学习的主题的材料。第一遍只是为了了解更高级别的想法并介绍词汇。我将此称为我在头脑中构建概念索引的点。因此,我相当快速地通读了这本书,而没有担心我是否深入理解了每个部分,因为我打算重新阅读它,或者拿起另一本书以稍微不同的方式涵盖相同的材料。

通读这本书让我早期就体会到了 Rust 社区的优势之一:文档内置于一切之中。标准库的文档在线提供,或者您可以使用一个命令在本地调出它们(当您在飞机上学习时,这非常棒)。文档以代码注释的形式存在,这对于第三方库来说是标准做法。Cargo,包管理系统,非常擅长将所有这些整合在一起。如果您有一个库,您可以使用一个命令调出其文档以及其所有依赖项的文档。注释中文档的另一个惊人之处在于,您放入文档中的示例实际上会在测试期间构建,因此文档中的代码永远不会与实际库定义脱节(我的项目中的示例)。这些细微之处结合起来,构建了我认为对于未来的 Rust 库作者来说非常坚实的基础,更重要的是,对于用户来说也是如此。

此时,我已经准备好开始实际编写代码了。Thorsten 的书只有四章,但内容相当多(刚刚超过 200 页)。实现一切的顺序是:首先是词法分析器,然后是解析器,然后是解释器,然后再返回到所有三个领域为语言添加功能。将词法分析器转换为 Rust 是一个相当简单的过程,除了最初在借用检查器和编译器上遇到困难之外,没有遇到太多挑战。由于学习新事物的一大部分是死记硬背,因此构建词法分析器和解析器的机械方面实际上是一个很好的重复性任务,可以开始将语法和词汇锤炼到您的大脑中。创建测试、看到它失败,然后编写一些代码使其通过也很有趣和令人满意。Thorsten 的写作风格很棒,他使整个练习非常有趣。

当到了实现解析器的时候,我碰壁了。具体来说,我必须弄清楚如何在没有 Rust 借用检查器 对我大喊大叫的情况下进行递归和嵌套树结构(AST)。我在 Google 上搜索了一下,重新阅读了本书的一些章节,但我显然需要更深入地了解借用检查器。我也阅读了其他人关于在学习 Rust 过程中“碰壁”的描述,所以我认为额外的努力会让我克服它。

事实上,在我的学习过程中,我多次回到了最初尝试学习编程的时候。在小学和中学学习了 Basic 之后,我记得高中时晚上试图让 C 程序编译,但就是搞不懂。我尝试了多种尝试和方法,最终才以任何有意义的方式学会了编程。与流行的看法相反,我不认为伟大的程序员拥有某种使学习编码变得容易的先天能力。我认为他们只是在克服障碍,寻找那些“一切正常”的时刻,你会感到深深的满足感。我的感觉是,如果您要学习 Rust,您需要为这种程度的努力和挫败感做好准备,但如果您这样做,我认为回报是值得的。

我决定是时候回到更结构化的学习了,所以我拿起 《Programming Rust》 并通读了前十章,然后才回到代码。这本书很棒,正是我开始真正掌握 Rust 一些核心概念所需要的。这本书涵盖了一些关于内存如何组织的深入内容,并经常参考 C++ 代码进行比较,但 C++ 知识并不是从中获得重要价值的先决条件。我认为阅读这两本书是一个好方法,我可能会以相同的方式重复它:读一本,写一些代码,然后读另一本。

在阅读完《Programming Rust》的前 10 章后,我能够完成其余的实现。我仍然有未解决的问题,我不确定我使用的结构是否最有意义。我试图尽可能接近 Thorsten 的 Go 实现,但我确实引入了一些 Rust 特有的东西。但总的来说,我不确定一位经验丰富的 Rust 程序员是否会认可这种风格和结构。我稍后会再谈我计划如何解决我知识中的这个差距。

我广泛使用了枚举,并且我使用了 Rust 的 Result 模式来返回错误。Rust 在错误处理方面的习惯用法非常棒。错误必须处理(或使用额外的代码和击键明确忽略),并且 ? 运算符 在消除大量样板错误处理代码方面非常出色(对于任何来自 Go 的人来说,这应该特别有吸引力)。不再需要检查错误并在存在时返回。Rust 为您处理这个问题。以下是从 Thorsten 的 Go 实现和我的 Rust 实现中解析哈希字面量的函数

Go 实现

func (p *Parser) parseHashLiteral() ast.Expression {
	hash := &ast.HashLiteral{Token: p.curToken}
	hash.Pairs = make(map[ast.Expression]ast.Expression)

	for !p.peekTokenIs(token.RBRACE) {
		p.nextToken()
		key := p.parseExpression(LOWEST)

		if !p.expectPeek(token.COLON) {
			return nil
		}

		p.nextToken()
		value := p.parseExpression(LOWEST)

		hash.Pairs[key] = value

		if !p.peekTokenIs(token.RBRACE) && !p.expectPeek(token.COMMA) {
			return nil
		}
	}

	if !p.expectPeek(token.RBRACE) {
		return nil
	}

	return hash
}

Rust 实现

.   fn parse_hash_literal(parser: &mut Parser) -> ParseResult {
        let mut pairs: HashMap<Expression,Expression> = HashMap::new();

        while !parser.peek_token_is(&Token::Rbrace) {
            parser.next_token();
            let key = parser.parse_expression(Precedence::Lowest)?;

            parser.expect_peek(Token::Colon)?;
            parser.next_token();
            let value = parser.parse_expression(Precedence::Lowest)?;

            pairs.insert(key, value);

            if !parser.peek_token_is(&Token::Rbrace) {
                parser.expect_peek(Token::Comma)?;
            }
        }

        parser.expect_peek(Token::Rbrace)?;

        Ok(Expression::Hash(Box::new(HashLiteral{pairs})))
    }

正如您所看到的,我保持了一个非常忠实的端口,函数名称和基本代码结构基本相同。经验丰富的 Rust 开发者可能会注意到我在将令牌传递给方法时对移动或借用的使用不一致(我应该重构)。Go 实现中有三个地方检查一些错误条件,如果找到则返回 nil。Thorsten 将错误放入正在进行解析的结构中,我选择通过函数的返回值传播错误,以便我可以使用 Rust 的 ? 运算符。我认为 Rust 拥有我使用过的任何语言中最优雅的错误处理模式。我从来不喜欢异常,Go 的模式更侧重于风格,可以被忽略或滥用。Rust 在编译器中强制执行它。我喜欢这一点,因为我是一个有缺陷的人,与其他人一起工作,所以如果编译器可以迫使我们走上正确的道路,我非常赞成。

一旦我克服了实现解析器的第一部分障碍,其他一切都非常简单了。正如我所提到的,这是一个非常机械的过程,所以我只是利用这段时间来记住一切的语法,并享受测试、失败、通过的逐步过程。我想我确实花了一点时间来学习如何在语言中实现局部作用域和闭包。我不得不学习 Rc(引用计数指针)和 RefCell(用于动态改变 与函数关联的环境中的状态)。

虽然这种设计导致我的实现中出现内存泄漏,但这​​是因为它由于 Monkey 语言的闭包而创建了引用计数结构的循环,因此它们不会被释放。我想知道是否有办法围绕这个问题设计和构建代码,或者我是否需要实现一个基本的垃圾回收器。如果有人愿意为我指点迷津,我已经打开了 一个问题来讨论 Monkey Rust 的 GC

Rust 的优势和最佳应用

我估计我还没有完成学习 Rust 过程的一半,但我仍然有一些关于我认为 Rust 在编程领域中的定位的想法。思考这个问题的最简单方法是谈论 Rust 可能取代哪种语言。我认为几乎任何您考虑用 C 或 C++ 完成的项目都可以考虑用 Rust 完成。较低级别的系统项目、需要出色性能的项目或需要更多控制内存操作的项目。负载均衡器、代理、操作系统、数据库、网络队列、分布式系统、机器学习库、更高级别语言的底层实现,以及可能无数其他没有立即想到的东西。我认为所有这些都代表了 Rust 实现的完美候选者。

在过去的五年中,Go 已经获得了大量此类项目。Go 的主要优势之一在于语言的简单性以及学习速度。将此与 Rust 进行对比,Rust 具有明显更多的语法,一种与内存管理交互的模型,很少有程序员熟悉,并且编译器可能比最糟糕的纪律执行者还要严格。然而,Rust 可以吹嘘一些令人信服的优势,我认为这些优势值得最初的学习曲线。

我之前提到过,但 Rust 的模型使得在开发安全 Rust 代码时不可能创建数据竞争。并发模型由编译器检查。我们在 InfluxDB 中遇到了许多由于数据竞争导致的生产错误,这些错误仅在重负载下才被捕获。虽然 Go 可能有通道,但它不提供任何编译时保证,保证您不会创建数据竞争。

编译器强制执行错误检查是另一个巨大的胜利。是的,您的开发过程可以强制进行代码审查,这应该可以捕获任何未正确处理错误的代码,但这​​是一个容易出错的过程。使用 Rust,编译器会强制您“做正确的事情”,这很棒,因为这样您就不必担心它会从代码审查中溜走。

没有引用 nil 指针。在过去的五年中,我做了多少次、看到多少次或被它咬伤过?这一切都随着 Rust 的出现而消失了。同样,编译器将迫使您做正确的事情。说到编译器,它给出的消息是我见过的最好的。有帮助,并且经常告诉您确切该怎么做来修复错误。

在 Rust 中,由于编译时保证,整个类别的错误根本不可能创建。因为它是软件,所以会出现错误。哦,是的,会出现错误。但是,我们可以创建全新的错误,而不会被过去四十年以上我们一直在创建的其他错误绊倒。这就是回报,尽管编译器很严格,但 Rust 的赌注是,如果您学会了这种方法,您的生产力将与另一种语言一样高(或更高)。在《Programming Rust》中,Jim Blandy 和 Jason Orendorff 将此称为“Rust 的激进赌注”。

后续步骤与结论

在验证 Blandy 和 Orendorff 的主张之前,我还有更多的 Rust 之旅要走,但我打算为此付出真正的努力。以下是我在获得一定程度的 Rust 专业知识的旅程中的后续步骤。

我打算完成《Programming Rust》并通读 《The Rustinomicon》,这是关于高级和不安全 Rust 的书。在那之后,我将选择一个小型的原型项目来创建一个使用 C++ 存储库的网络服务。为此,我最终将深入研究网络编程和多线程。我还将学习一点不安全的代码(或使用现有的 Crate)。说到这里,Rustaceans 是如何编写 gRPC 服务的?

我还需要花一些时间关注的另一个方面是地道的 Rust 代码应该是什么样的。为此,我想花时间阅读一些 Crates(Rust 库),这些库被其他 Rust 开发者认为是风格良好,并涵盖了该语言的各个方面。如果您有任何建议,我将不胜感激,请在 Twitter 上 @pauldix 我。此外,如果有任何 Rust 爱好者愿意花一个小时通过 Zoom 电话会议与我一起分析我的 Monkey Rust 代码,我将非常感激,并且一旦我更熟悉了,我也很乐意向其他新手提供帮助。或者,我也在 repo 的一个 pull request 中留下了一些 问题

最后,我拿起了 Thorsten 的《Writing an Interpreter in Go》的续作,名为 Writing a Compiler in Go,我期待在阅读它的同时,继续完善我的 Monkey Rust 实现。如果您对这个主题有任何兴趣,我强烈推荐这两本书。

完成这一切之后,我将能够更好地为我在 Influx 设想的一些 Rust 项目做出真正的努力。我认为用 Rust 创建一个可嵌入的 Flux 实现将会是一件非常棒的事情。

我还不确定我在 Rust 中的效率如何,但到目前为止,我非常享受学习这门语言,并且对可以用它构建的东西感到非常兴奋。我认为在未来五十年内,大量的 C 和 C++ 代码很有可能会被重写,从而构建出更多围绕 Rust 保证的更安全的系统软件。我会用它来编写新的 Web 服务吗?也许会,尽管我可能仍然会选择 Go,但根据具体需求,Rust 可能会成为首选。

如果 Rust 的激进赌注最终被证明是成功的,它将成为我在服务器和系统方面的默认首选。