编程语言之争是开发者们热议的永恒话题,在不同语言的选择和设计决定上也都观点不一。那么在面对大型项目时该如何选择具体实现呢?本文的作者借课程项目之机,比较了Rust、Haskell、OCaml、C++、Python、Scala 等语言编写的编译器差异,最终发现,这些语言在代码量和功能实现上简直千差万别!
以下为译文:
我在滑铁卢大学的最后一个学期选了CS444:编译原理这门课程,课程项目是编写一个编译器,将Java语言的子集编译成x86代码,三人结组,语言自由选择。
这是个难得的机会,我可以在同样的大型项目下比较不同的实现,而且我的朋友们的水平也跟我很相近,所以我可以借这个机会看看不同的设计和语言选择。我从这个项目中获得了不少心得,尽管这个比较并不完美,但比那些仅靠个人观点来比较编程语言的人要好多了。
我们的编译器是用Rust写成的,首先与另一个使用了Haskell的组进行了比较。我认为他们的编译器应该更简洁,但实际的代码行数差不多。与另一个使用了OCaml的团队的比较也得到了同样的结果。然后我与一个使用了C++的团队比较,结果如我预料的那样,由于有头文件,以及缺乏汇总类型和模式匹配的支持,导致他们的编译器大了30%。下一个是跟我一个朋友的Python实现进行的比较,他的代码量不到我们的一半,这要归功于元编程和动态类型。另一个朋友的团队使用了Scala,实现的编译器代码量也小于我们。最让我惊讶的比较就是与另一个同样使用Rust的团队的比较,他们的代码量是我们的三倍,因为他们采用了不同的设计决定,这最终导致了同样的功能需要的代码量产生了巨大差异!
本文中首先我会来解释一下此次比较的意义,介绍各个项目的基本情况,然后再解释引发编译器大小差异的部分原因。最后,我会谈一谈从各个比较中学到的东西。
比较的意义
你也许会认为,代码行数(我同时比较了代码行数和字节数)是个很糟糕的度量,但我认为在这个项目中这种度量可以给出很有用的信息。在我看来,至少代码行数是各个不同的团队在同一个大型项目上工作时最可控的一个变数。
- 直到我们的项目完成之前,没有任何人(包括我)知道我会统计代码行数,所以没有人在行数度量上做手脚,每个人都尽最大努力来快速、正确地完成项目。每个人(除了后面我会谈到的使用Python的项目之外)都在实现同一个程序,目的只有一个,就是在同样的截止日期之前通过同样的自动化测试套件,所以也不会有某个组试图解决不同的问题,或者解决更难的问题的情况。每个组都在这个项目上花了数月的时间,大家都在逐步地添加功能,从而通过已知和未知的测试。这意味着代码整洁易读,没有任何取巧的地方。
- 除了要通过的课程测试之外,代码不会被用于任何其他用途,也没人会阅读它,而且由于它只能编译Java语言的一个子集,所以它也没有任何其他用途。除了标准库之外也不允许使用任何库,甚至连辅助解析的库都不允许(如果标准库中没有包含此功能的话)。这意味着也不会出现任何仅有部分团队使用的、强大的编译器库来干扰比较。
- 在最终的提交截止日期之后,会运行一次秘密的测试(我们看不到该测试),也就是说,自己编写测试用例并测试代码,可以保证编译器的健壮、正确,也可以处理边界情况。
- 尽管参与的每个人都是学生,但我讨论的这些团队都是我认为非常优秀的程序员。每个人都至少有两年全职的实习经验,大多数都在高端的科技公司,一些公司甚至还在开发编译器。几乎每个人都有7-13年的编程经验,都十分热衷在网上阅读课程之外的东西。
- 自动生成的代码没有统计在内,但生成的语法文件和代码被统计了。
因此我认为,就各个项目需要花费的精力,以及如果是长期项目的话需要花费多少精力去维护而言,代码量是一个很不错的近似统计。我认为,微小的差异也能反映出巨大的问题,比如上面说过的用Haskell编写的编译器代码量不到C++的一半。
Rust(比较基准)
我和团队里的另一名成员以前分别写过1万多行的Rust代码,另一个成员在某次编程马拉松项目上写过大约500行Rust。我们的编译器用wc -l统计的结果是6806行,其中包括5900代码行(不包括空行和注释),wc -c的结果为220kb。
我发现的一个问题是,这几项度量的比例在其他项目中也是相似的,只有一些微小的差异(过会儿我会介绍)。下文中提到代码行数时,我指的都是wc -l的结果,但上述结论表明,代码行数按照哪个规则进行统计其实是无所谓的(除非特别指出),你可以通过比例进行换算。
我写过另一篇关于设计的文章(http://thume.ca/2019/04/18/writing-a-compiler-in-rust/),这个设计通过了所有公开和秘密的测试。它还包括几个额外的特性,这些特性我们仅仅是出于兴趣而开发,并没有想着通过测试。这些特性大概占用了400行。我们总共的单元测试和测试用的代码大约占了500行。
Haskell
Haskell团队由我的两个朋友组成,他们每个人大概写过几千行Haskel,还阅读过许多网上的Haskell内容,以及许多其他类似的语言,如OCaml和Lean。他们还有另一个我不太熟悉的团队成员,但似乎是个很厉害的程序员,以前也用过Haskell。
他们编译器的wc -l结果是9750行,357kb,7777 SLOC(源代码行数)。这个团队的度量比例的差别也最大,他们的编译器中行数为1.4倍,SLOC为1.3倍,字节数为1.6倍。他们并没有实现任何额外功能,但通过了所有公开和秘密的测试用例。
需要指出的重要的一点是,只有把测试用例统计在内,对这个团队才公平,因为他们的代码是最正确的,包含了1600行测试用例,并且捕获了好几个团队未能捕获的边界情况,只不过是课程提供的测试用例没有覆盖到这些边界情况而已。所以,如果两者都不统计测试用例的话,他们的代码是8.1k行,我们的是6.3k行,仅是我们的1.3倍。
为了让度量更合理,我还统计了字节数,因为Haskell项目平均每行要更长,而且没有许多只有结束括号的行,它的单行函数也不会被rustfmt分解成多行。
在与团队里的另一个朋友深入挖掘了代码大小的问题后,我们找到了以下理由来解释代码大小的差异:
- 我们采用了手写的词法分析器和递归下降分析(recursive descent parsing),他们采用的是NFA到DFA的词法生成器,以及一个LR分析器,然后再扫描一遍将解析树转换成AST(抽象语法树,是更方便的代码表示形式)。这需要占用更多代码,占了2677行,比我们的1705行大约多了1k行。
- 他们使用的是更漂亮的通用AST类型,能转换成不同的类型参数,因为每次解析都会添加更多信息。这需要更多的辅助函数,因此导致了他们的AST代码比我们的实现多了500行——我们在解析并添加信息时使用的只是结构字面量,和可修改的Option<_>字段。
- 他们大约有400多行代码用于实现更高的抽象程度,从而用纯粹的函数式方式来实现代码生成和组合,而我们是直接修改字符串。
这些差异再加上测试用例的差异,就导致了代码行数的差别。实际上,我们的文件在中间解析阶段(如常量折叠、作用域解析等)的大小跟他们的非常接近。但依然产生了字节数上的区别,原因是行的平均长度,我估计原因是他们需要更多的代码,在每次解析时重写整个树,而我们只需要访问并修改即可。
我认为,考虑到Rust和Haskell的设计决定非常相似,都是表达性的,只有细微的差异,如Rust在需要时能够很方便地修改变量等。另一点有意思的是,我们选择采用递归下降分析器和手工编写词法分析器给我们带来了回报。虽然这有点风险,因为教授并没有推荐这一点,我是自学来的,但我发现它很易于使用,是个正确的决定。
我认为,这个团队可能并没有开发出Haskell的全部潜力。如果他们能更善于使用Haskell,他们的代码应该行数更少。我相信,像Edward Kmeet之类的人可以使用更少的Haskell代码就能编写出同样的编译器,从这一点上来说,我朋友的团队并没有使用太多超高级的抽象,而且他们也不允许使用更好的组合库,如lens等。但是,这样做的代价就是理解编译器的难度。团队的成员都是有经验的程序员,他们知道Haskell可以做非常漂亮的事情,但还是决定不这样做,因为他们认为,这样做花费的时间会超过节省的时间,而且会让代码变得难以理解。在我看来这的确是个正确的选择,用“魔法”的方式使用Haskell编写编译器,会产生“Haskell写编译器的门槛非常高,如果你不考虑对于不太了解Haskell的人的可维护性的话”的结果,而这种结果并不是我们想要的。
另一个有趣的发现是,教授在开始时说过,学生可以选择任何能够在学校服务器上运行的语言,但同时针对Haskell提出了警告,说过去使用Haskell的团队的分数的方差是最高的,因为许多选择Haskell的团队都高估了他们的Haskell能力,导致他们的得分比选择其他语言的团队低得多,也有另一部分Haskell团队像我朋友那样做得非常完美。
C++
接下来我与另一个在团队中使用了C++的朋友进行了交谈。那个团队中我只认识这一个人,但由于滑铁卢大学中使用C++的课程非常普遍,所以估计团队中的每个人都有C++经验。
他们的项目代码行数为8733,字节数为280kb,这些数字不包括测试代码,但包括大约500行的额外功能。与我们不含测试的代码(也包含500行的额外功能)相比,他们的代码行数为1.4倍。他们通过了100%的公开测试,但仅通过了90%的秘密测试,很可能是因为它们没有实现项目要求的数组vtable,这个功能需要大约50-100行代码实现。
我并没有深入挖掘代码差异的原因,我感觉最有可能的解释为:
- 他们使用了LR解析器和树重写,而没有采用递归下降分析器;
- C++缺乏汇总类型和模式匹配这两个非常常用的功能;
- 他们需要重复头文件中所有的函数签名,而Rust不需要这样做。
我们比较的另一件事是编译时间。在我的笔记本上,我们的编译器的调试版完整编译需要9.7秒,调试版增量编译需要3.5秒。我的朋友并没有给出他们的C++编译器的构建时间(采用并行make),但说我提供的数字与他们的非常接近,而且说他们把一些常用的小函数的签名放到了头文件中,以增加编译时间为代价来减少函数签名的重复(也正是由于这个原因,我没有办法比较单纯的头文件代码行数)。
Python
我的一位朋友是非常优秀的程序员,她选择使用Python独立完成项目。她还比其他团队多实现了好几个额外功能,包括带有寄存器分配的SSA立即表示,还有其他优化。另一方面,由于她是独立完成的,而且实现了许多额外的功能,因此她在代码质量上只花费了最小限度的经历,例如所有错误都会抛出统一的异常(所以调试时需要进行栈跟踪),而不是像我们一样每种错误都给出特定的错误类型和错误信息。
她的编译器只有4581行,并且通过了所有公开测试和秘密测试。她实现的功能比所有其他团队都多得多,但很难确定那些功能占了多少行代码,因为许多额外功能与每个人都在做的功能都相同,比如常量折叠、代码生成等,但功能却更强大。额外的功能估计至少占用了1000~2000行,所以我很确信她的代码的表达性要比我们至少高两倍。
造成这种差异的最大原因很可能是动态类型。我们的ast.rs中类型定义就占了500行,编译器的其他部分还有更多的类型定义。我们还通过类型系统做了各种类型限制。例如,我们需要基础设施,才能在分析代码过程中向AST中添加信息供以后使用,而Python中只需要给AST结点添加新的域即可。
强大的元编程也是造成差异的原因之一。例如,尽管她用的是LR分析器而不是递归下降分析器,但她的项目代码量更小,因为她不需要进行树重写的过程,而是在LR语法中加入了Python代码片段来构建AST,而生成器可以直接利用eval变成Python函数。我们没有采用LR分析器的部分原因是,不使用树重写来构建AST需要大量的代码(生成的Rust文件或过程式的宏)将语法绑定到Rust代码片段上。
元编程和动态类型的强大之处的另一个例子是,我们有个名为visit.rs的文件有400行,里面大部分是重复性的样板代码,仅为了实现在各种AST结构上的访问。在Python中只需要一个大约10行的函数即可递归地访问AST结点的各个域(通过__dict__属性)。
作为Rust和静态类型语言的爱好者,我需要指出,类型系统非常有助于避免bug和提高性能。强大的元编程同时会让代码更难理解,但是,这个比较结果依然让我非常惊讶,我没想到代码的差异能有如此之大。如果差异真的导致需要写两倍的代码,那我依然认为Rust的付出是值得的,但两倍的差异的确不可忽视,我以后会考虑在独立完成某项工作中的一次性代码时使用Ruby或Python。