[Rust翻译]对于复杂的应用程序,Rust与Kotlin一样具有生产力

2,264 阅读16分钟

原文地址:ferrous-systems.com/blog/rust-a…

原文作者:contact@ferrous-systems.com

发布时间:2020年10月28日

在这篇文章中,我们将比较一个苹果(IntelliJ Rust)和一个橘子(rust-analyzer)来得出一般的和全面的结论。具体来说,我想提出一个支持以下主张的案例研究。

对于复杂的应用程序,Rust和Kotlin一样富有成效。

对我来说,这是一个不寻常的论点。我一直认为正好相反,但我现在不那么确定了。我是从C++来到Rust的。我认为这是一门出色的低级语言,而对人们用Rust写高级的东西总是感到不解。显然,选择Rust意味着生产力受到打击,如果你能负担得起GC,使用Kotlin、C#或Go就更有意义。我对Rust的批评清单就是从这个反对意见开始的。

使我的立场转向另一个方向的是我作为rust-analyzer和IntelliJ Rust的主要开发者的经历。让我介绍一下这两个项目。

IntelliJ Rust是IntelliJ平台的插件,提供Rust支持。实际上,它是一个Rust编译器的前端,用Kotlin编写,并利用了平台的语言支持功能。这些功能包括无损语法树、分析器生成器、持久性和索引基础设施等。尽管如此,由于编程语言差别很大,分析Rust的大部分逻辑是在插件本身实现的。像完成列表这样的展示功能来自于平台,但大部分的语言语义是手工写的。IntelliJ Rust还包括一点Swing的GUI。

rust-analyzer是Rust的语言服务器协议的一个实现。它是一个从头开始编写的Rust编译器前端,着眼于对IDE的支持。它大量使用了用于增量计算的salsa库。除了编译器本身,Rust-analyzer还包括管理语言服务器本身的长期多线程进程的代码。

这两个项目在范围上基本相当--适合IDE的锈蚀编译器前端。两个最大的区别是。

  • IntelliJ Rust是一个插件,所以它可以重新使用周围平台的代码和设计模式。

  • rust-analyzer是第二个系统,所以它利用了IntelliJ Rust的经验进行从头设计。

这两个项目的内部架构也有很大的不同。就三种架构而言,IntelliJ Rust是map-reduce,而rust-analyzer是基于查询的。

编写一个适合IDE的编译器是一项高层次的任务。你不需要直接与操作系统对话。这里和那里有一些花哨的数据结构和并发性,但它们也是高层的。它不是要实现疯狂的无锁方案,而是要在多线程的世界里维护应用程序的状态和理智。编译器的大部分是符号操作,可以说最适合lisp。为这样的任务选择一种基于虚拟机的语言(例如OCaml),并没有任何内在的缺点。

同时,这个任务是相当复杂和独特的。在实现功能时,"你的代码 "与 "框架代码 "的比例要比典型的CRUD后端高得多。

现在介绍了这些项目,让我们来看看两个大致相当的历史片断。

这两个项目都有2年的历史,都有1-1.5个全职开发人员,都有充满活力和繁荣的开源贡献者社区。Kotlin有52000行,Rust有66000行。

两者在当时都提供了大致相当的功能集。说实话,我还是不太相信:)Rust-analyzer从零开始,它没有价值十年的Java类来引导,而且Kotlin和Rust之间的生产力下降应该是巨大的。但这很难与现实争论。相反,让我试着反思一下我构建两者的经验,并试图解释Rust令人惊讶的生产力。

学习曲线

要描述Kotlin的学习曲线很容易--它几乎是零。我在没有Kotlin经验的情况下开始使用IntelliJ Rust,也从未觉得需要专门学习Kotlin。

当我转到rust-analyzer时,我对Rust已经很有经验了。我想说的是,肯定需要刻意去学习Rust,很难随手拿起它。所有权和别名控制是新的概念(即使你来自C++),采取全面的方法来学习它们会有收获。在经历了最初的学习步骤后,一般来说都很顺利。

顺便说一下,这是为我们的Rust课程和定制培训做宣传的最佳场所。:) 下一次的Rust介绍将在今年12月进行。

模块化

我认为这是最重要的因素。这两个项目在范围上和源代码数量上都是中等规模的。我相信,运送大东西的唯一方法是把它们分成独立的小块,并分别实现这些小块。

我还发现我所熟悉的大多数语言在模块化方面都很糟糕。更广泛地说,我对FP与OO的辩论感到好笑,因为似乎 "为什么没有人做对模块?"是一个更突出的问题。

Rust是少数拥有一流的库概念的语言之一。Rust的代码被组织在两个层面上。

  • 作为一棵树的相互依赖的模块,在一个板条箱中

  • 和板条箱的有向无环图

模块之间可以有循环的依赖关系,但板块之间不允许。板块是重用和隐私的单位:只有板块的公共API是重要的,而板块的公共API是什么是非常清楚的。此外,板条箱是匿名的,所以当你在一个板条箱图中混合使用同一板条箱的几个版本时,不会出现名称冲突和依赖地狱。

这使得让两段代码互不依赖变得非常容易(不依赖是模块化的本质):只要把它们放在不同的板条箱中就可以了。在代码审查期间,只有对Cargo.tomls的修改需要仔细监控。

在比较的时候,rust-analyzer被分成了23个内部的crates,还有一些一般用途的crates.io上发布。相比之下,IntelliJ Rust是一个单一的Kotlin模块,所有东西都可以依赖其他东西。虽然IntelliJ Rust的内部组织非常干净,但它并没有反映在文件系统布局和构建系统中,因此需要不断维护。

构建系统

管理项目的构建需要花费大量的时间,并且对其他一切都有倍增的影响。

Rust的构建系统,Cargo,是非常好的。它并不完美,但在Java的Gradle之后,它是一股清新的空气。

Cargo的诀窍在于,它并不试图成为一个通用的构建系统。它只能构建Rust项目,而且对项目结构有严格的要求。它不可能选择脱离核心假设。配置是一个静态的不可扩展的TOML文件。

相比之下,Gradle允许自由形式的项目结构,并通过图灵完整语言进行配置。我觉得我花在学习Gradle上的时间比学习Rust上的时间还要多。运行wc -w后,Rust书的字数为182_817,而Gradle的用户指南为280_506。

此外,Cargo在大多数情况下都比Gradle快。

当然,最大的缺点是自定义构建逻辑在Cargo中无法表达。这两个项目都需要大量的逻辑,而不是单纯的编译,以提供给用户最终的结果。对于rust-analyzer来说,这是由手写的Rust脚本来处理的,在这种规模下,它可以完美地工作。

生态系统

语言级别的库支持和一流的构建系统/包管理器使得生态系统蓬勃发展。rust-analyzer的一些部分也被发布到crates.io,供其他项目重用。

此外,Rust编程语言的低级性质往往允许 "完美 "的库接口。这些接口完全反映了底层的问题,而没有强加中间的语言级抽象。

基本的便利性

我觉得当涉及到基本的语言坚果和螺栓--结构、枚举、函数等时,Rust的生产力明显更高。这并不是Rust所特有的,任何ML家族的语言都有这些东西。然而,Rust是第一种将这些功能封装在一个很好的包里的工业语言,不受向后兼容的限制。我想列出我认为可以在Rust中更快地生成可维护代码的具体特征

重视数据而非行为。也就是说,Rust不是一种OOP语言。OOP的核心思想是动态调度--哪个代码被函数调用调用是在运行时决定的(后期绑定)。这是一个强大的模式,它允许灵活和可扩展的系统。问题是,可扩展性的成本很高! 最好只在某些指定的领域应用它。默认情况下为可扩展性而设计是不符合成本效益的。Rust将静态调度放在了最前面和中心位置:只需阅读代码就可以清楚地知道发生了什么,因为它与对象的运行时间类型无关。

我喜欢Rust的一个小语法,就是它如何在语法上把字段和方法放到不同的块中。

struct Person {
  first_name: String,
  last_name: String,
}

impl Person {
    fn full_name(&self) -> String {
        ...
    }
}

能够一目了然地看到所有的字段使理解代码变得更加简单。字段传达的信息比方法多得多。

总和类型。Rust的谦逊的枚举是完全的代数数据类型。这意味着你可以表达不相连的联合的想法。

enum Either<A, B> { A(A), B(B) }

这在小的日常编程中非常有用,在大的编程中也有一些时候。举一个例子,IDE的核心概念之一是引用和定义。像let foo = 92; 这样的定义为一个实体指定了一个名字,可以在下一行使用。像foo + 90这样的引用指的是某个定义。当你ctrl点击引用时,你会进入定义。

在Kotlin中建模的自然方法是添加接口Definition和接口Reference。问题是,有些东西两者都是

struct S { field: i32 }

fn process(s: S) {
    match s {
        S { field } => println!("{}", field + 2)
    }
}

在这个例子中,第二个字段既是对field: i32定义的引用,也是对一个名字为field的局部变量的定义! 同样地,在

let field = 92;
let s = S { field };

field中,概念上持有两个引用--一个是对局部变量的引用,一个是对字段定义的引用。

在IntelliJ Rust中,这通常是通过降级的特殊情况来处理。在rust-analyzer中,这是由一个列举所有特殊情况的枚举来处理的。

rust-analyzer的枚举非常多,有很多代码都是无聊地匹配N个变体,做几乎相同的事情。这段代码比IntelliJ Rust的特殊情况的替代方案更加冗长,但更容易理解和支持。你不需要在你的头脑中保持更广泛的上下文来理解哪些特殊情况是可能的。

错误处理。当涉及到null安全时,Kotlin和Rust在实践中大多是等同的。在union类型与sum类型之间有一些更精细的区别,但根据我的经验,它们在实际代码中是不相关的。在语法上,Kotlin对?和?:的处理更多时候感觉更方便。

然而,当涉及到错误处理(Result<T, E>而不是Option<T>)时,Rust胜出一筹。在调用站点上有一个注释错误的路径是非常有价值的。在函数的返回类型中对错误进行编码,这种方式适用于高阶函数,使代码更加健壮。我害怕在Kotlin和Python中调用外部进程,因为这正是异常常见的地方,而且我每次都会忘记处理至少一种情况。

与借贷检查器的斗争

尽管Rust的类型和表达式通常允许人们精确地陈述自己想要的东西,但仍有一些情况下,借贷检查器会碍手碍脚。例如,在这里我们不能返回一个想从临时的:utils.rs中借入的迭代器。

在学习Rust时,这类问题非常频繁。这主要是因为将传统的 "指针汤 "设计应用于Rust是行不通的。随着经验的积累,与设计相关的借贷检查器错误往往会逐渐消失--将软件构建为组件树是可行的,而且它几乎总是一个好的设计。残余的借贷检查器的限制是令人讨厌的,但在事情的大计划中并不重要。

并发性

IntelliJ Rust和rust-analyzer使用类似的方法来处理并发问题。有一个全局读写锁来保护基本的应用状态,还有大量的线程安全缓存来处理衍生数据。

在Kotlin中管理这些是很难的。不止一次,我问自己 "我应该把这个标记为易失性吗?"但却没有一个明确的方法来获得答案。要想知道某样东西在Kotlin中是否应该是线程安全的,就得阅读文档并寻找所有的使用方法。

相比之下,"这个类型是线程安全的吗?"这个属性反映在Rust的类型系统中(通过SendSync特性)。编译器会自动推导出线程安全,并检查非线程安全的类型是否被意外地共享。

IntelliJ Rust和rust-analyzer中出现的一个错误就是一个很好的案例。回顾一下,两者都使用了线程之间共享的缓存。在这两个项目中,我曾经设计了一个智能优化,不幸的是,它涉及到将(无意中)线程不安全的数据放入这个共享缓存。在IntelliJ Rust中,我们花了很长时间才注意到首先出现了问题,甚至花了更多的调查来确定根本原因。在rust-analyzer中,我只浪费了实现优化本身的时间。在我修复了我认为是最后一个编译错误之后,编译器严肃地指出,将包含B的A和包含C的D的非线程安全的结构放入一个跨线程共享的结构中,可能不是一个好主意

性能

我开发IntelliJ Rust的一般经验是 "无论我怎么做,它都没有我希望的那么快"。我对rust-analyzer的体验正好相反,"无论我做什么,它都足够快"。

作为一个轶事,在早期我在rust-analyzer中实现了定点迭代的名字解析算法。这是一个与IDE敌对的位子。如果天真地做,它需要在每个按键上重做相当多的工作。当我用这个改动构建rust-analyzer时,我终于看到完成度明显滞后。"就是这样",我想,"我应该停止只使用天真的算法,开始应用一些优化"。好吧,事实证明,我把rust-analyzer的调试版本拿去测试了一下 用--release重建后,问题就解决了。

顺便说一句:事实上,调试版的速度往往慢得离谱,这对Rust来说是个大问题。

拥有良好的基线性能无疑有助于提高生产力--为性能而优化代码通常会使其更难重构。你能在低级别的性能优化上花费的时间越长(相对于架构级的性能工作),你所做的总工作就越少。

性能可预测性

更重要的是,Rust的性能是可预测的。一般来说,运行一个程序N次,会得到差不多相同的结果。这与JVM形成了鲜明的对比,在JVM中,你需要做大量的预热工作来稳定甚至是微观的基准测试。我从来没有在IntelliJ Rust中成功地进行过可重复的宏观基准测试。

更为普遍的是,在没有运行时的情况下,程序的行为变化要小得多。这使得追寻回归的工作更加有效。

安全性

为了明确起见,有一点是没有区别的,那就是内存安全:在这两个项目中都没有出现segfaults或heap损坏。同样地,空指针解除引用也不是一个问题。

这些都是Rust相对于其他系统语言最重要的优势,但对于目前的应用来说,它们并不重要。

结论

我认为许多讨论点的统一主题是 "在大范围内编程"。模块化、构建过程、可预测性只有在代码的数量、年龄和贡献者的数量增加时才开始变得重要。我喜欢Titus Winters的提法:"软件工程是随着时间推移而整合的编程"。Rust擅长于这种工作,它是一种可扩展的语言。

我更欣赏的另一点是,Rust可能是一种几乎通用的语言的合理候选者。引用另一句名言(John Carmack),"适合工作的工具往往是你已经在使用的工具"。语境切换和连接不同的技术需要大量的努力。有了Rust,你往往不需要这样做。它可以自然地扩展到裸机。正如这篇文章所探讨的,它在应用级编程方面也很好用。Rust甚至在某种程度上也适用于脚本!Rust-analyzer的构建信息在理论上更适合于bash和Python,但在实践中,Rust工作得很好,而且是令人愉快的跨平台。

最后,我想再次重申,本案例研究只涉及两个项目,它们是相似的,但不是双胞胎。背景也很重要:不依赖第三方库来实现核心功能对于应用程序编程来说有点不寻常。因此,虽然我认为这个经验和分析在质量上指向正确的方向,但你的定量结果可能会有很大不同


www.deepl.com 翻译