在Rust,Haskell,C ++,Python,Scala和OCaml中比较同一项目

在大学的最后一个学期,我选择了CS444编译器课程 。 在那里,每1至3个人组成的小组不得不从x86中大量Java子集中编写一个编译器。 选择组的语言。 这是一个难得的机会,可以比较功能强大的大型程序的实现,这些程序由非常有能力的程序员用不同的语言编写,并比较设计和语言选择上的差异。 这样的比较引起了很多有趣的想法。 这种语言的受控比较很少见。 它并不完美,但是比人们对编程语言的看法所基于的大多数主观故事要好得多。

我们制作了Rust编译器,首先将它与Haskell团队项目进行了比较。 我希望他们的程序要短得多,但是事实证明它的大小相同或更大。 OCaml也是如此。 然后,我将其与C ++编译器进行了比较,并非常希望编译器的大小大约增加30%,这主要是由于标头,缺少求和类型和模式匹配。 以下比较是与我的朋友进行的,我的朋友凭借元编程和动态类型的强大功能,使用Python自己制作了编译器,并且使用的代码不到我们的一半。 另一个朋友有一个较小的Scala程序。 最让我惊讶的是与另一个也使用Rust的团队的比较,但是由于不同的设计决策,他们的代码量是原来的三倍。 最后,代码量的最大差异在于同一语言内!

我将解释为什么我认为这是一个很好的比较,我将提供有关每个项目的一些信息并解释编译器大小不同的一些原因。 我还将从每次比较中得出结论。 随意使用这些链接转到感兴趣的部分:

目录内容


  • 为什么我觉得它有意义
  • 锈(比较的基础)
  • Haskell :1.0-1.6大小(出于有趣的原因,取决于您的计算方式)
  • C ++ :1.4大小,原因显而易见
  • Python :由于花哨的元编程,大小为0.5!
  • 铁锈(另一组) :由于不同的设计,其尺寸是原来的三倍!
  • Scala :0.7尺寸
  • OCaml :1.0-1.6大小取决于您的计算方式,类似于Haskell

为什么我觉得它有意义


在您说代码量(我比较了字符串和字节)是一个糟糕的指标之前,我想指出,在这种情况下,它可以提供很好的理解。 至少这是控制最充分的示例,其中不同的团队编写了我所听到或阅读的相同大型程序。

  • 没有人(包括我在内)知道我会测量此参数,因此没有人尝试衡量指标,每个人都只是试图快速正确地完成项目。
  • 所有程序(除了Python项目,我将在后面讨论)都实现了该程序,其唯一目的是同时通过相同的自动化测试套件,因此,解决不同问题的小组不能严重扭曲结果。
  • 该项目与团队一起在几个月内完成,并且应该逐步扩展并通过已知和未知的测试。 这意味着编写干净,清晰的代码很有用。
  • 除了通过课程测试外,该代码将不会用于其他任何用途,没有人会阅读它,并且作为将Java的有限子集编译为文本汇编器的编译器,该代码将无用。
  • 除了标准库以外,不允许使用任何库,并且即使它们在标准库中,也不能进行解析的帮助程序。 这意味着,只有某些命令具有的强大编译器库才能使比较不失真。
  • 不仅有公开测试,而且还有秘密测试。 在最终交付后,它们开始了一次。 这意味着有动力编写自己的测试代码,并确保编译器可靠,正确并能够处理复杂的边界情况。
  • 尽管所有参与者都是学生,但我认为他们是有能力的程序员。 他们每个人都至少实习了两年,主要在高科技公司实习,有时甚至从事编译器工作。 他们几乎都已经编程了7至13年,并且是在课程之外在互联网上阅读大量内容的爱好者。
  • 没有考虑生成的代码,但是考虑了语法文件和生成其他代码的代码。

因此,我认为代码量可以很好地理解每个项目(如果是长期的)将需要多少努力来支持。 我认为,项目之间的差异不大也使您可以反驳我阅读的一些非常规语句,例如,由于语言的原因,Haskell编译器的大小将超过C ++的一半。

锈(比较的基础)


我和我的一位同志以前在Rust中写了1万多行,而第三位同事在一些黑客马拉松中大概写了500行。 我们的编译器以6806行wc -l ,5900行源代码(无空格和注释)和220 KB wc -c

我发现在其他项目中,这些比例得到了大致尊重,但有一些例外,我会指出。 对于本文的其余部分,当我指代字符串或总和时,我的意思是wc -l ,但这并不重要(除非我注意到其中的区别),您可以使用系数进行转换。

我写了另一篇描述我们设计的文章 ,该文章通过了所有公开和秘密测试。 它还包含一些我们为了娱乐而不是通过测试而创建的附加功能,可能增加了约400行。 它还有大约500行的单元测试。

哈斯克尔


Haskell团队包括我的两个朋友,每个朋友可能写了几千行Haskell,并且阅读了很多有关Haskell以及OCaml和Lean等其他类似功能语言的在线内容。 他们还有另一个我不太了解的队友,但是似乎一个强大的程序员以前使用过Haskell。

他们的编译器共有9,750行wc -l ,357 KB和7777行代码(SLOC)。 这个团队在这两个比率之间也只有明显的不同:它们的编译器在行中比我们大1.4倍,在SLOC中是1.3倍,在字节中是1.6倍。 他们没有实现任何其他功能,通过了100%的公开和秘密测试。

重要的是要注意,包括测试在内的所有因素都影响了该团队。 由于他们仔细地检查了代码的正确性,因此他们进行了1,600行测试。 他们发现了我们团队没有发现的几种极端情况,但是这些情况根本没有通过课程测试进行检查。 因此,如果没有双方的测试(6.3千行与8.1千行),它们的编译器仅比我们的编译器多30%。

在这里,我倾向于使用字节作为比较量的一种比较合理的方法,因为在Haskell项目中,平均来说行数更长,因为它从一个结束括号中没有很多行,并且rustfmt不会将单行功能链分成几行。

与我的一个队友一起翻阅后,我们针对这种差异提出了以下解释:

  • 我们使用了手写词法分析器和递归下降方法,他们使用了NFADFA生成器以及LR解析器 ,然后通过了将解析树转换为AST( 抽象语法树 ,更方便地表示代码)。 这给了他们更多的代码:与我们的1705相比,有2677行,多了约1000行。
  • 他们使用了奇特的通用AST,随着每次通过中添加了更多信息,它又转移到各种类型参数上。 此函数以及更多的辅助函数可能会解释为什么它们的AST代码比我们的实现长约500行,在该实现中我们收集结构文字并更改Option<_>字段以添加信息。
  • 它们在生成期间仍然有大约400行代码,这主要与以纯功能方式生成和组合代码所需的更大抽象有关,在这种情况下,我们仅使用变异和编写代码行。

这些差异加上测试说明了体积上的所有差异。 实际上,我们用于折叠常量和上下文解析的文件的大小非常接近。 但是,由于行数较长,字节之间仍存在一些差异:可能是因为每次通过都需要更多代码来重写整个树。

因此,在我看来,撇开设计决策,Rust和Haskell具有同等的表现力,Rust可能具有一点优势,因为Rust可以在方便时轻松使用突变。 有趣的是,我对递归下降方法和手写词法分析器的选择取得了回报:这是与教授的建议和指导相抵触的风险,但我认为这很容易,而且是正确的。

Haskell的支持者会争辩说,团队可能没有充分利用Haskell的功能,如果他们更好地了解该语言,则可以使用更少的代码来开发项目。 我同意,像Edward Kmett这样的人可以用更少的钱编写相同的编译器。 的确,我朋友的团队没有使用许多花哨的超高级抽象和花哨的组合程序库(例如lens) 。 但是,所有这些都会影响代码的可读性。 团队中的所有人都是经验丰富的程序员,他们知道Haskell能够处理非常奇怪的事情,但是决定不使用它们,因为他们认为理解它们将花费比他们节省的时间更多的时间,并使其他人更难以理解代码。 这似乎是一个真正的妥协,并且声称Haskell非常适合编译器,这种说法就变成了“如果您不关心对Haskell也不很熟的人的代码支持,Haskell需要非常熟练的编译器编写能力。”

有趣的是,在每个项目的开始,这位教授说学生可以使用在大学服务器上运行的任何语言,但要警告Haskell上的团队与其他团队不同:他们的成绩分散程度最大。 许多人高估了自己的能力,Haskell团队的成绩最差,尽管其他人的表现和我的朋友一样好。

C ++


然后,我与C ++团队的朋友交谈。 我只认识这个团队中的一个人,但是我们大学的几门课程都使用C ++,所以团队中的每个人都有着C ++的经验。

他们的项目由8733行和280 KB组成,不包括测试代码,但包含大约500行附加功能。 这使其比没有测试的代码大1.4倍,其中还包含约500行附加功能。 他们通过了100%的公开测试,但仅通过了90%的秘密测试。 大概是因为它们没有实现规范要求的精美vtables数组,该数组可能占用50-100行代码。

我并没有深入研究这些大小上的差异。 我想这主要是由于:

  • 他们使用LR解析器和树重写器,而不是递归下降方法。
  • C ++中缺少求和类型和模式比较,这已经被我们广泛使用并且非常有用。
  • 需要复制头文件中的所有签名,Rust中不是这种情况。

我们还比较了编译时间。 在我的笔记本电脑上,我们的编译器的纯调试版本花费9.7 s,纯发行版花费12.5 s,增量调试版花费3.5 s。 我的朋友没有C ++构建的时机(使用并行make),但是他说这些数字是相似的,但要注意的是他们将许多小功能的实现放在头文件中,以减少签名的重复,但花费的时间更长(即因此,我无法测量头文件中的净行开销。

巨蟒


我的朋友,一个非常好的程序员,决定用Python单独制作该项目。 她还实现了比任何其他团队更高的娱乐功能(包括娱乐功能),包括具有寄存器分配和其他优化功能的中间SSA视图。 另一方面,由于它单独工作并实现了许多附加功能,因此它对代码质量的关注最少,例如,对所有错误抛出未区分的异常(依赖于回溯进行调试),而不是实现错误类型和相应的消息,例如我们。

她的编译器由4581行组成,并通过了所有公共和秘密测试。 她还实现了比其他任何命令都高级的功能,但是很难确定需要多少额外的代码,因为许多其他功能是每个人都需要实现的功能更强大的简单事物的版本,例如折叠常量和生成代码。 附加功能至少可能需要1000-2000行,因此,我敢肯定,她的代码的表达力至少是我们代码的两倍。

这种差异的很大一部分可能是动态类型。 仅在我们的ast.rs 500行类型定义,并且在编译器的其他位置定义了更多类型。 我们也总是限于类型系统本身。 例如,我们需要一个基础设施,以便在我们通过并稍后访问它时,按照人机工程学原理向AST中添加新信息。 在Python中,您可以仅在AST节点上设置新字段。

强大的元编程也可以解释部分差异。 例如,尽管她使用LR解析器而不是递归下降方法,但在我的情况下,我认为它花费的代码更少,因为她无需进行树重写,而是使用LR语法包括了一些Python代码来构建AST,生成器可以将其转换为Python函数使用eval 。 我们不使用LR解析器的部分原因是,在不重写树的情况下构建AST将需要大量的仪式(创建Rust文件或过程宏)来将语法与Rust代码的片段相关联。

元编程和动态类型的强大功能的另一个示例是400行的visit.rs文件,该文件基本上是重复的样板代码,可在一堆AST结构上实现访问者。 在Python中,这可能是大约10行的简短函数,该函数递归地反省AST节点的字段并访问它们(使用__dict__属性)。

作为对Rust和静态类型语言的狂热爱好者,我倾向于指出类型系统对于防止错误和提高性能非常有用。 异常的元编程也可能使您难以理解代码的工作方式。 但是,这种比较使我感到惊讶,因为我并不期望代码量的差异会那么大。 如果总体上的差异真的接近于编写两倍的代码,我仍然认为Rust是一个合适的折衷方案,但是仍然有一半的代码是一个参数,并且将来我倾向于在Ruby / Python中做一些事情如果您只需要快速构建一个东西,然后将其丢弃。

锈病(另一组)


对我而言,最有趣的比较是与我的朋友(当时我不认识的那个朋友)一起在Rust中进行项目。 我的朋友有很好的Rust经验。 他为Rust编译器的开发做出了贡献,并且阅读了很多。 我对他的战友一无所知。

他们的项目包括17,211条原始行,15,000条源代码行和637 KB,其中不包括测试代码和生成的代码。 它没有其他功能,并且仅通过了10个秘密测试中的4个以及90%的公共测试进行代码生成,因为它们在截止日期之前没有足够的时间来实现规范中更奇怪的部分。 他们的程序比我们的程序大三倍,用相同的语言编写,并且功能更少!

这个结果对我来说真是太神奇了,并且掩盖了到目前为止我所学习的语言之间的所有差异。 因此,我们比较了wc -l文件大小的列表,还检查了我们每个人如何实现一些导致不同代码大小的特定操作。

似乎所有这些都归因于各种设计决策的一致采用。 例如,他们的前端(词法分析,解析,构建AST)相对于我们的2164占用了7597行。他们使用DFA词法分析器和LALR解析器(1),但其他小组却做了很多类似的事情,而没有那么多代码。 在查看他们的除草文件时,我注意到许多与我们不同的设计决策:

  • 他们决定使用全类型的解析树,而不是标准的,统一的,基于字符串的解析树。 在解析阶段或更复杂的解析器中,这可能需要更多的类型定义和其他转换代码。
  • 他们使用tryfrom tryfrom实现在解析树类型和AST类型之间进行转换以验证它们。 这导致许多10-20行的impl块。 为此,我们使用了返回Result类型的函数,该函数生成较少的行,并且还使我们从类型结构中解放出来,从而简化了参数设置和重用。 对我们来说,有些东西是单行match分支,它们有10行impl块。
  • 我们的类型旨在减少复制粘贴。 例如,他们使用了单独的字段is_abstractis_nativeis_static ,其中约束检查代码必须被复制两次:一次用于void类型的方法,一次用于具有返回类型的方法,并稍作修改。 虽然我们的void只是一种特殊类型,但我们提出了带有modevisibility的修饰符分类法,该修饰符应用了类型级别的约束,并且默认情况下为match运算符生成约束错误,将修饰符集转换为modevisibility

我没有看他们的编译器分析过程的代码,但是它们也很棒。 我和我的朋友聊天,看来他们没有像游客一样实现与访客基础设施类似的东西。 我猜想,连同其他一些较小的设计差异,也可以解释这部分尺寸的差异。 访问者允许我们的分析过程仅将重点放在所需的AST部分上,而不是在整个AST结构中匹配模式。 这样可以节省大量代码。

他们用于代码生成的部分由3594行组成,而我们的则由1560行组成。我查看了他们的代码,似乎几乎所有的不同之处在于,他们为汇编程序指令选择了中间数据结构,在这里我们仅使用字符串格式进行直接汇编程序输出。 他们必须为所有使用的指令和操作数类型定义类型和输出函数。 这也意味着构建汇编指令需要更多代码。 在我们有一个带有简短指令的格式运算符的情况下,例如mov ecx, [edx] ,他们需要一个巨大的rustfmt运算符, rustfmt运算符分为6行,该指令为带有多个中间嵌套类型的操作数构建了一条指令,该中间嵌套类型用于最多包含6级嵌套括号的操作数。 我们还可以在单​​个格式语句中输出相关指令的块,例如函数前导,其中它们必须为每个指令指定完整的构造。

我们的团队正在考虑使用这样的抽象。 既可以输出文本程序集也可以直接发布机器代码,这比较容易,但这不是课程的要求。 使用X86Writer X86Writer和类似push(reg: Register)类的方法,可以用更少的代码和更好的性能来完成同一件事。 我们还考虑到这可以简化调试和测试,但是我们意识到,如果您随意插入注释,使用快照测试查看生成的文本汇编程序实际上更容易阅读和测试。 但是我们(显然是正确的)预测,这将需要很多额外的代码,并且鉴于我们的实际需求,并没有真正的好处,因此我们不必担心。

最好将它与C ++团队用作额外功能的中间表示形式进行比较,该中间表示形式仅使他们多了500行。 他们使用了非常简单的结构(用于简单的类型定义和构建代码),该结构使用的操作接近Java所需的操作。 这意味着它们的中间表示比生成的汇编器小得多(因此所需的构建代码更少),因为许多语言操作(例如调用和强制转换)已扩展为许多汇编器指令。 他们还说,它确实有助于调试,因为它消除了很多垃圾并提高了可读性。 较高级别的演示还允许对其中间表示进行一些简单的优化。 C ++团队提出了一个非常好的设计,它用更少的代码就使它们变得更好。

一般而言,数量上三倍差异的常见原因似乎是由于朝着更多代码的方向一致地采用了各种设计决定,无论大小决定。 他们实现了许多我们没有的抽象-他们添加了更多的代码,并跳过了一些抽象,从而减少了代码量。

这个结果真的让我感到惊讶。 我知道设计决策很重要,但是我不会事先猜测它们会导致如此大的差异,因为我只检查了那些我认为是有能力的程序员的人。 在所有比较结果中,这对我来说是最重要的。在上这门课程之前,我阅读了很多有关如何编写编译器的信息,这可能对我有帮助,因此我可以使用其他人想出的并能很好地工作的智能项目,例如AST访问者和递归下降方法,尽管它们没有被教过在我们的课程上。

我真正想到的是抽象的成本。抽象可以促进将来的扩展或防止某些类型的错误,但是必须加以考虑,因为您可以获得理解和重构的代码数量是原来的三倍,可能出现错误的地方是数量的三倍,而测试和进一步测试的时间却更少发展。我们的培训课程与现实世界有所不同:我们确定知道开发后再也不会接触代码,这消除了主动抽象的好处。但是,如果我必须选择要使用任意函数扩展的编译器(稍后再说),我会选择我们的编译器,而无需考虑自己的熟悉程度。只是因为它的代码少了很多,我可能会为需求选择最佳的抽象(例如我知道具体要求时使用C ++命令的中间表示形式)。

在我看来,抽象的分类法得到了加强:有些简化代码的方法仅考虑当前需求(例如访问者模板),有些抽象添加了代码,但提供了可扩展性,调试或正确性的好处。

斯卡拉


我还与一个上学期在Scala上做过项目的朋友交谈,但是该项目和测试完全相同。他们的编译器包括4141行和〜160 KB的代码,这还不包括测试的次数。他们通过了10个秘密测试中的8个和100%公开测试,并且没有实现任何其他功能。因此,与没有附加功能和测试的5906系列产品相比,它们的编译器要少30%。

较小的设计因素之一是采用不同的解析方法。该课程允许为LR表生成器使用命令行工具。除了这个团队,没人使用它。这使他们不必实施LR表生成器。他们还设法避免使用150行的Python脚本编写LR语法,该脚本会刮擦他们在Internet上找到的Java语法网页并将其转换为生成器输入格式。他们仍然需要在Scala中制作某种树,但是与我们的1443年相比,解析阶段通常达到了1073行,尽管与其他所有团队相比,这里的梯度下降方法在体积上具有优势。

他们的其余编译器也比我们的小,尽管我没有深入研究代码,但在设计上没有任何明显的大差异。我怀疑这是由于Scala和Rust的表达能力不同所致。Scala和Rust具有类似的编程功能,可用于编译器,例如模式匹配,但是Scala的托管内存保存了借位检查器在Rust中工作所需的代码。此外,Scala的语法糖比Rust更加多样化。

OCaml


由于我们团队的所有成员都在Jane Street(技术贸易公司-大约Per​​。)实习,因此,我对查看其他前Jane Street实习生选择OCaml编写编译器的结果特别感兴趣。

他们的编译器为10,914行和377 KB,包括少量测试代码,没有任何其他功能。他们通过了9/10个秘密测试和所有公开测试。

与其他组一样,大小上的主要差异似乎是由于使用LR解析器和树重写进行解析,以及使用regex-> NFA-> DFA转换管道进行词法分析。他们的前端(词法分析,解析,AST构造)为5548行,而我们的前端为2164行,字节的比率相似。他们还对解析器进行了测试,以期它类似于我们的快照测试,从而将预期的输出置于代码之外,因此他们的解析器测试占总数的〜600行,而我们的解析测试约占200行。

这为其余的编译器留出5366行(其中461行是带有类型声明的接口文件),为我们留出4642行,如果我们计算其接口文件,则相差仅15%,如果不计算,则几乎相同。看起来,除了我们的解析设计解决方案之外,Rust和OCaml似乎同样具有表现力,只是OCaml需要前端文件,而Rust不需要。

结论


总的来说,我很高兴自己进行了比较,学到了很多东西,并且感到惊讶很多次。我认为总体结论是设计决策比语言重要得多,但是语言很重要,因为语言为您提供了实现不同设计的工具。

Source: https://habr.com/ru/post/zh-CN456638/


All Articles