对于复杂的应用,铁锈与Kotlin一样高效

2020-10-30 01:06:04

在这篇文章中,我们将一个苹果(IntelliJ锈菌)与一个橙子(锈菌分析仪)进行比较,以得出普遍而全面的结论。具体地说,我想给出一个支持以下说法的案例研究:

对我来说,这是一个不同寻常的主张:我总是想着完全相反的想法,但现在我不太确定了。我是从C++来到Rust的。我认为这是一种出色的低级语言,总是对人们用Rust编写更高级别的东西感到困惑。很明显,选择Rust意味着要降低生产率,如果你负担得起的话,使用Kotlin、C#或Go就更有意义了。我的Rust批评清单就是从这个反对开始的。

把我的位置移向另一个方向的是我作为铁锈分析仪和IntelliJ Rust的首席开发人员的经验。让我介绍一下这两个项目。

IntelliJ Rust是IntelliJ平台的插件,提供对Rust的支持。实际上,它是一个Rust编译器前端,使用Kotlin编写,并利用平台的语言支持功能。这些功能包括无损语法树、解析器生成器、持久化和索引基础设施等。然而,由于编程语言的差异很大,分析Rust的大部分逻辑都是在插件中实现的。完成列表等表示功能来自平台,但大多数语言语义都是手写的。IntelliJ Rust还包括一些Swing GUI。

RUST分析器是RUST语言服务器协议的一个实现,它是一个着眼于IDE支持而从头编写的RUST编译器前端,它大量使用SALSA库进行增量计算,除了编译器本身之外,RUST分析器还包含用于管理语言服务器本身的长寿命多线程进程的代码。

这两个项目在适用于IDE的 - RUST编译器前端的范围上基本上是等价的。最大的两个区别是:

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

铁锈分析仪是第二个系统,因此它利用IntelliJ Rust的经验进行从头开始的设计。

这两个项目的内部架构也有很大的不同,在三种架构上,IntelliJ Rust是MAP-Reduce,Rust-Analyzer是基于查询的。

编写支持IDE的编译器是一项高级任务。您不需要直接与操作系统对话。这里和那里有一些奇特的数据结构和并发性,但它们也是高级的。它不是关于实现疯狂的无锁方案,而是关于在多线程世界中维护应用程序状态和健全性。编译器的大部分是符号操作,可以说是最适合LISP的。选择基于VM的语言(例如OCaml)来完成这类任务,没有任何内在的缺点。

同时,这项任务相当复杂和独特,在实现特性时,“您的代码”与“框架代码”的比率比典型的CRUD后端要高得多。

这两家公司都有大约2年的历史,有1-1.5名开发人员全职工作,拥有充满活力和蓬勃发展的开源贡献者社区,有52k行的Kotlin和66k行的Rust。

老实说,当时两者都提供了大致相同的功能集。老实说,我仍然不太相信这一点:)Ruust-Analyzer从零开始,它没有十年的Java类可供引导,Kotlin和Rust之间的生产率下降应该很大。但这很难与现实争辩。相反,让我试着反思一下我构建两者的经验,并试图解释Rust令人惊讶的生产率。

很容易描述科特林的学习曲线, - ,它几乎为零。我在没有科特林经验的情况下创办了“智能锈蚀”,从来没有觉得我需要专门学习科特林。

当我转到铁锈分析器时,我对铁锈相当有经验。我想说,一个人肯定需要刻意学习铁锈,很难在路上学会它。所有权和别名控制是新概念(即使你来自C++),采取整体的方法学习它们是有回报的。经过最初的学习步骤,总体来说是顺利的。

顺便说一句,这里是宣传我们的铁锈课程和量身定做培训的完美地方:-)下一次铁锈介绍将在今年12月进行!

我认为这是最大的因素,这两个项目在范围和源代码数量上都是中等大的,我相信唯一的办法是将大型的东西拆分成独立的块,分别实现这些块。

我还发现我熟悉的大多数语言在模块性方面都相当糟糕,更普遍的是,我对FP和OO的争论很感兴趣,因为似乎“为什么没有人把模块做对呢?”是一个更突出的问题。

Rust是为数不多的具有一流库概念的语言之一,Rust代码分为两个层次:

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

这使得使两段代码不相互依赖(非依赖性是模块性的本质)变得非常容易:只需将它们放在单独的craters中。在代码审查期间,只需仔细监视对Cargo.tomls的更改。

相比之下,IntelliJ Rust是一个单独的Kotlin模块,所有的一切都可以依赖于其他所有东西。虽然IntelliJ Rust的内部组织非常干净,但它没有反映在文件系统布局和构建系统中,需要持续维护。

管理项目的构建需要大量的时间,并且会对其他所有事情产生倍增的影响。

铁锈的构建系统CARADE非常好,虽然不完美,但它是继Java‘s Gradle之后的一股新鲜空气。

Cargo的诀窍在于它没有试图成为一个通用的构建系统,它只能构建Rust项目,并且对项目结构有严格的期望,不可能选择退出核心假设,Configuration是一个静态的、不可扩展的TOML文件。

相比之下,Gradle允许自由格式的项目结构,并通过图灵完全语言进行配置。我觉得我学习Gradle的时间比学习Rust的时间还多!运行WC-w会给出182_817个单词用于Rust图书,280_506个单词用于Gradle的用户指南。

当然,最大的缺点是自定义构建逻辑不能用Cargo表达。这两个项目都需要大量的逻辑,而不仅仅是编译,才能将最终结果交付给用户。对于锈蚀分析器来说,这是由手写的锈蚀脚本处理的,在这种规模下工作得很好。

对库的语言级支持和一流的构建系统/包管理器使生态系统蓬勃发展。锈检分析器比IntelliJ Rust更依赖第三方库。锈检分析器的某些部分也发布到crates.io以供其他项目重用。

此外,Rust编程语言的低级特性通常允许“完美的”库接口,这些接口准确地反映了潜在的问题,而不会强加中间语言级别的抽象。

我觉得当涉及到基本语言的细节时,RUST的效率要高得多。 - 结构、枚举、函数等。这并不是RUST所特有的,任何ML系列语言都有它们。但是,RUST是第一种将这些特性封装在一个很好的包中的工业语言,不受向后兼容性的限制。我想列出一些我认为可以在RUST中更快地生成可维护代码的具体特性。但是,RUST是第一种将这些特性封装在一个很好的包中的工业语言。我想列出一些我认为可以在RUST中更快地生成可维护代码的具体特性。

强调数据重于行为。也就是说,RUST不是一种OOP语言。OOP的核心思想是动态分派 - ,由函数调用调用的代码是在运行时决定的(后期绑定)。这是一个强大的模式,允许灵活和可扩展的系统。问题是,可扩展性是昂贵的!最好只在某些指定的领域应用它。默认情况下设计可扩展性是不划算的。Rust将静态分派放在最前面和中心:只需读取代码就可以很清楚地知道发生了什么,因为它独立于对象的运行时类型。

我喜欢Rust的一个小语法方面是它如何在语法上将字段和方法放到不同的块中:

Struct Person{First_Name:String,Last_Name:String,}Impll Person{fn Full_Name(&;Self)->;String{...}}。

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

SUM类型。Rust的简单命名枚举是全代数数据类型。这意味着您可以表达不相交并集的概念:

这在小的日常编程中非常有用,有时在大的编程中也是如此。举个例子,IDE的核心概念之一是引用和定义。像let foo=92;这样的定义为可以在一行中使用的实体指定一个名称。像foo+90这样的引用指的是一些定义。当你按住ctrl键单击引用时,就会转到定义。

在Kotlin中建模的自然方法是添加接口定义和接口引用。问题是,有些东西两者都有!

Struct S{field:I32}fn进程{匹配s{S{field}=>;println!(";{}";,field+2)}}。

在本例中,第二个字段既是对field:i32定义的引用,也是名称为field的局部变量的定义!类似地,在。

字段在概念上包含两个 - 引用,一个是对局部变量的引用,另一个是对字段定义的引用。

在IntelliJ Rust中,这通常是通过向下预测特殊情况来处理的。在锈蚀分析器中,这是由列出所有特殊情况的枚举来处理的。

防锈分析器是非常繁重的,有很多代码枯燥地匹配N个变量,并且做几乎相同的事情。这段代码比特殊外壳特定奇怪情况的IntelliJ Rust替代方案更冗长,但更容易理解和支持。您不需要在头脑中保持更广泛的上下文来理解哪些特殊情况可能是可能的。

错误处理。当谈到空安全性时,Kotlin和Rust在实践中基本上是等价的。联合类型和SUM类型之间有一些更细微的区别,但根据我的经验,它们在实际代码中是无关的。从语法上讲,Kotlin的?还有?:经常感觉更方便一些。

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

尽管Rust的类型和表达式通常允许您准确地声明您想要什么,但是仍然存在借用检查器遇到障碍的情况。例如,在这里,我们不能返回想要从临时的:utils.rs借用的迭代器。

在学习时,这类锈蚀问题非常频繁。这主要是因为将传统的“指针汤”设计应用于锈蚀是不起作用的。根据经验,与设计相关的借用检查器错误往往会随着组件树的运行而逐渐消失,而且几乎总是一个好的设计。剩余的借用检查器限制是恼人的,但在总体规划中无关紧要。

IntelliJ Rust和Rust-Analyzer使用类似的并发方法,有一个全局读写锁来保护基本应用程序状态,还有大量用于派生数据的线程安全缓存。

在科特林管理这件事很难。更重要的是,我曾经问自己“我应该把这件事标记为不稳定吗?”在没有明确的方法得到答案的情况下,要确定某个东西在Kotlin中是否应该是线程安全的,方法是阅读文档并查找所有用法。

相反,“该类型是线程安全的吗?”是反映在Rust的类型系统中的属性(通过Send和Sync特征)。编译器自动派生线程安全性,并检查非线程安全类型是否被意外共享。

在IntelliJ Rust和Rust-Analyzer中都发生了一个bug,这是一个很好的案例。在这两个项目中,我都利用了线程之间共享的缓存。在这两个项目中,我曾经设计了一个智能优化,不幸的是(无意中)将线程不安全的数据放到了这个共享的Cache中。在IntelliJ Rust中,我们花了很长时间才发现有问题,并进行了更多的调查来确定根本原因。在Ruust-Analyzer中,我只是浪费了时间来实现优化本身。在我修复了我认为是最后一个编译错误之后,我发现了一个错误。在IntelliJ Rust中,我们首先发现了一些问题,然后进行了更多的调查来找出根本原因。在Ruust-Analyzer中,我只浪费了时间来实现优化本身。在我修复了我认为是最后一个编译错误之后,编译器严肃地指出,将包含B的A和包含非线程安全D的C放入跨线程共享的结构中可能不是最好的主意!

我开发IntelliJ Rust的一般经验是“无论我做什么,它都不像我希望的那样快”,而我使用铁锈分析仪的经验正好相反,“无论我做什么,它都足够快”。

作为一则轶事,我早期在锈蚀分析器中实现定点迭代名称解析算法。这是一个IDE的敌意之处。它需要在每一次击键上重做相当多的工作,如果做得很天真的话。当我用这个更改构建锈蚀分析器时,我终于看到完成明显滞后。“就是这样”,我想,“我应该停止只使用幼稚的算法,开始应用一些优化”。好了,原来我拿了一个调试版本的锈蚀分析器作为测试驱动!用-发布-重建解决了这个问题。

旁白:对于Rust来说,调试构建通常非常慢的事实是一个大问题。

拥有基准良好的性能绝对有助于提高生产力 - 针对性能优化代码通常会使重构变得更加困难。您可以在低级别的性能优化上投入的时间越长(与体系结构级别的性能工作相反),您需要做的总工作就会越少。

更重要的是,Rust的性能是可预测的。通常,运行一个程序N次会得到大致相同的结果。这与JVM形成鲜明对比,在JVM中,您需要做大量热身才能稳定甚至是微基准测试。在IntelliJ Rust中,我从未成功地进行过可重现的宏基准测试。

更广泛地说,没有运行时,程序行为的变化要小得多,这使得追逐回归更加有效。

需要说明的是,有一件事没有什么不同,那就是内存安全:两个项目都没有段错误或堆损坏,同样,空指针取消引用也不是问题。

与其他系统语言相比,这些是Rust最重要的优点,但是对于手头的应用程序来说,它们是无关紧要的。

我认为许多讨论点的统一主题是“大规模编程”。模块化、构建过程、可预测性只有在代码的数量、年龄和贡献者的数量增长时才开始重要。我喜欢Ttes Winters的表述,即“软件工程是随着时间的推移进行集成的编程”。Rust擅长这类工作,它是一种可伸缩的语言。

我更欣赏的另一件事是,Rust可能是一种几乎通用的语言的一个看似合理的候选者。引用另一句很棒的话(John Carmack):“适合这项工作的正确工具通常是你已经在使用的工具”。上下文切换和连接不同技术需要很多Effort。使用Rust,你通常不需要!它自然可以缩减到裸金属。正如本文所探索的,它也可以很好地用于应用程序级编程。Rust甚至在某种程度上也适用于脚本编写!Rust-Analyzer的构建基础在理论上更适合bash和Python,但在实践中,它可以很自然地缩减到裸露的金属。正如本文所探索的那样,它也可以很好地用于应用程序级编程。在某种程度上,Rust甚至还可以用于脚本编写!Rust-Analyzer的构建基础在理论上更适合bash和Python,但在实践中,铁锈可以很好地工作,并且可以很好地跨平台。

最后,我想重申,本案例研究只涉及两个相似但不是孪生的项目。上下文也很重要:核心功能不依赖第三方库对于应用程序编程来说有点不寻常。因此,虽然我认为这种经验和分析指向了定性正确的方向,但您的定量结果可能会有很大差异!