Rust 语言学习困难且令人沮丧,但它也是软件开发中一段很长时间以来最激动人心的事情

导航至

我最近决定认真投入学习 Rust 编程语言。我在一些有趣的项目中经常看到它(例如 ripgrep),并且不断听到人们对它的好评。自从 2015 年 Rust 1.0 版本发布以来,我对学习 Rust 的犹豫来自两个方面。首先,我因为 InfluxDB 完全投入了 Go 语言。其次,我听说它不是最容易学习的。虽然我不通常回避困难的任务,但我犹豫是因为我相信许多起飞的开发者工具之所以能够起飞,是因为它们易于使用或给开发者带来显著的生产力提升。更多的时候,我想把时间投入到我认为具有长期性的工具中,这些工具将在市场上获得一些关键量。

然而,关于 Rust 的一些事情让我产生了这样的想法,即使学习它可能很困难,它也可能在编程语言领域开辟一个非常重要的(且需要的)领域。以下是我对那些优势的反思,我学习语言的方法,以及为什么我对 Rust 非常兴奋。但请注意,我还没有在生产环境中运行任何东西,我只写了大约 2,500 行 Rust 代码,我还没有进行任何多线程或网络编程,或者基准测试。这些都是基于我的早期印象。

那么,为什么是 Rust?是什么让我想更深入地研究这门语言?如果我说性能不是其中一个重要因素,那我就是撒谎了。没有垃圾回收,但语言/编译器中内置了原始数据类型来确保您不会忘记释放 malloc 或意外解除无效指针?请签字。还有两个其他的大功能让我感兴趣。能够创建可以链接到其他语言(如 Python、Ruby、Go 等)的库(通过 FFI)是我一直在思考的 Influx 未来工作的一个问题。另外,与 C 和 C++ 库的低(或零)成本集成也是一个很大的动力。有一些大的 C++ 项目我想与之集成,而 Rust 似乎是一个很好的方法。

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

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

我认为,一个小型项目是开始学习语言的最好方式。我看到有人链接到 Thorsten Ball 的关于 《编写解释器》 的书籍,并认为用 Rust 来写这本书是一个很好的起点。在阅读完这本书后,我强烈推荐它,但我会稍后再谈。我以前写过解释器,非常熟悉 Go,而这个项目的优点是它限制了我需要学习的内容范围。它只使用标准库功能,不需要多线程或网络。Thorsten 的书中包含了所有代码和广泛的测试,因此很容易确保事物正确无误。我不需要考虑构建算法的具体方法,只需要考虑如何用 Rust 特殊地实现它。但在开始实现之前,我必须掌握语言的基本知识。

我应该简要介绍一下我的开发者背景,因为我认为不同背景的人可能会发现学习 Rust 更容易(或更难)。自 2013 年中以来,我主要是一名 Go 程序员。在那之前,我花了大约一年时间在单页 JavaScript(Backbone)应用程序上工作,在此之前,我用 Scala 构建了一个“时间序列数据库(API)”。再往前,我编写 Ruby 和 Rails 应用程序,并混入了一些 JavaScript,这是我从 2006 年到 2010 年的关注点。在那之前是 C# 和 Delphi(这不是很多人简历上会出现的语言)。我在 2009 年之前也写过 C 和 C++,但从那以后就没有再写过,甚至在那时我也可能只写了大约 10k 行代码。更重要的是,我几乎整个职业生涯都在使用垃圾回收语言。我也没有很低级,也不是系统专家(除非你把构建数据库算在内)。

我开始阅读 《Rust 编程语言》,这是免费在线文档的一部分。我的学习过程通常涉及多次阅读覆盖我要学习主题的材料。第一次阅读只是为了获取高层次的思想和引入词汇。我将此称为在头脑中建立概念索引的时刻。因此,我快速阅读了这本书,并没有担心是否深刻理解每个部分,因为我打算要么重读它,要么拿起另一本书以略有不同的方式覆盖相同的材料。

通读这本书让我很早就认识到了Rust社区的一个优势:文档已经融入到了所有内容中。标准库的文档可以在网上找到,或者您也可以用一条命令在本地打开它们(当您在飞机上学习时这一点非常好)。文档以注释的形式存在于代码中,这是第三方库的标准做法。Cargo这个包管理系统在整合这一切方面做得相当出色。如果您有一个库,您可以用一条命令打开它的文档以及所有依赖库的文档。关于注释中的文档的另一件令人惊叹的事情是,您放入文档中的示例实际上会在测试期间构建,因此文档中的代码永远不会与实际的库定义脱节(来自我的项目的示例)。这些小细节共同构成了我认为为未来的Rust库作者,更重要的是,用户,打下了一个非常坚实的基石。

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

当到了实现解析器的阶段时,我遇到了障碍。具体来说,我必须弄清楚如何在没有Rust借检器抱怨的情况下做递归和嵌套树结构(AST)。我在Google上搜索了一番,重新阅读了一些书中的章节,但我显然需要更深入地了解借检器。我还阅读了其他人关于在学习Rust过程中“遇到障碍”的描述,所以我认为额外的努力会让我克服这些障碍。

事实上,在我的学习过程中,有好几次我回到了最初尝试学习编程的时候。我在小学和中学学习Basic之后,记得高中时试图编译C程序,却始终无法解决问题。我尝试了多次和不同的方法,最终以某种有意义的方式掌握了编程。与人们的普遍看法相反,我认为伟大的程序员并没有某种天生的能力使得学习编码变得容易。我认为他们只是克服障碍,寻找那些“事情顺利”的时刻,并从中获得深深的满足感。我的感觉是,如果您要学习Rust,您需要准备好为此付出这样的努力和挫折,但如果你这样做,我认为回报是值得的。

我觉得是时候回到更结构化的学习中去了,所以我选择了《Programming Rust》(https://www.amazon.com/Programming-Rust-Fast-Systems-Development/dp/1491927283/),读完了前十章然后才回来写代码。这本书太棒了,正好是我开始深入理解Rust核心概念所需要的。这本书深入探讨了内存组织方式,并经常与C++代码进行比较,但了解C++并不是从中获得大量价值的前提。我认为阅读这两者都是一种很好的方法,我可能也会以同样的方式重复:读一本,写一些代码,然后再读另一本。

读完《Programming Rust》的前十章后,我能够推进其余的实现。我还有一些未解决的问题,我不确定我使用的结构是否最合理。我尽量接近Thorsten的Go实现,但我也引入了一些Rust特有的东西。但总的来说,我不确定有经验的Rust程序员是否会认同这种风格和结构。我将回来讨论我计划如何填补我的知识空白。

我广泛使用了枚举,并使用Rust的Result模式来返回错误。Rust在错误处理方面的惯用用法非常出色。错误必须被处理(或通过额外的代码和按键显式忽略),“?运算符”非常棒,可以消除大量模板化的错误处理代码(这对从Go转换过来的人来说应该特别有吸引力)。不再需要检查错误并在错误存在时返回。Rust会为你处理这一切。以下是从Thorsten的Go实现和我的Rust实现中解析hash字面量的函数:

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开发者可能会注意到我在将token传递给方法时对moves或borrows的不一致使用(我应该重构)。在Go实现中有三个地方检查某些错误条件,如果发现则返回nil。Thorsten将错误放入进行解析的struct中,而我选择通过函数的返回值传播错误,以便我可以使用Rust的?运算符。我认为Rust是我在使用的任何语言中错误处理最优雅的模式。我从未喜欢过异常,Go的模式更像是一种风格,可以被忽略或滥用。Rust在编译器中强制执行。我喜欢这样,因为我是一个有缺陷的人类,与其他人一起工作,所以如果编译器能迫使我们走上正确的道路,我完全支持。

一旦我克服了实现第一个Parser组件的困难,其余的都是相当直接的。正如我提到的,这是一个相当机械的过程,所以我只是利用这段时间来记忆所有语法,并享受测试、失败、通过的过程。我想我花了点时间学习如何在语言中实现局部作用域和闭包。我必须学习Rc(一个引用计数指针)和RefCell(用于在函数的关联环境中动态修改状态)。

尽管这种设计导致了我的实现中内存泄漏,但这是因为它由于Monkey语言中的闭包而创建了引用计数struct的循环,因此它们没有被释放。我在想是否有方法可以围绕这个问题设计和结构代码,或者我是否需要实现一个基本的垃圾回收器。我已经在GitHub上创建了一个问题来讨论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++ 存储库的 Web 服务。为此,我将最终深入研究网络编程和多线程。我还会接触一些不安全的代码(或使用现有的 Crate)。说到这一点,Rustaceans 是如何编写 gRCP 服务的呢?

我还需要花时间关注的一点是Rust的惯用表达方式。为此,我想花时间阅读其他Rust开发者认为展示了良好风格并涵盖了语言各个方面的Crates(Rust库)。在此方面有任何推荐都将非常感激,请参考Twitter上的(https://twitter.com/pauldix)。此外,如果有任何Rustaceans愿意花一小时和我一起在Zoom上分析我的Monkey Rust代码,我将不胜感激,并愿意在掌握更多技能后回报给新来的开发者。或者,我还在仓库的pull request中留下了一些问题。

最后,我拿起了Thorsten所著的《用Go编写解释器》的续作,名为《用Go编写编译器》(https://compilerbook.com/),我期待着在实现Monkey Rust的过程中阅读它。如果你对此感兴趣,我强烈推荐。

在完成所有这些后,我将能够着手实现我在Influx为Rust规划的一些项目。我认为在Rust中创建一个可嵌入的Flux实现将是一件非常棒的事情。

我还没有想清楚我在Rust中能有多高的生产力,但到目前为止,我很享受学习这门语言,并对我能用它构建的东西感到非常兴奋。我认为在未来五十年里,大量C和C++代码将被重写,这将导致基于Rust保证的更安全的系统软件。我会用Rust编写新的Web服务吗?也许吧,尽管我可能还是会选择Go,但根据需求,Rust可能成为首选。

如果Rust的激进赌注最终成为赢家,它将成为我在服务器和系统方面的首选。