摘要:近日,Roc 编程语言成为许多开发者关注的对象——“从 Rust 到 Zig,Roc 编译器开始全面重写 30+ 万行代码”,这个消息很快就引起了广泛讨论。目前,Roc已在 GitHub 上斩获了 4.7k+ 星标。在本文中,Roc 作者详细解答了关于这个重大
【CSDN 编者按】近日,Roc 编程语言成为许多开发者关注的对象——“从 Rust 到 Zig,Roc 编译器开始全面重写 30+ 万行代码”,这个消息很快就引起了广泛讨论。目前,Roc 已在 GitHub 上斩获了 4.7k+ 星标。在本文中,Roc 作者详细解答了关于这个重大决定的原因及相关计划,并给出了团队从 Rust 转向 Zig 的一个很重要原因:Rust 的编译速度太慢。
作者 | Richard Feldman 翻译 | 郑丽媛
出品 | CSDN(ID:CSDNnews)
通常情况下,用另一种语言从头开始重写代码库被认为是一种风险很大的举动,而且往往以失败告终。然而,对于编译器来说,情况正好相反:大多数成功的编译器(例如 Java、C++、C、C#、TypeScript、Scala、Go、Rust、Zig、OCaml、Haskell 等等)都经历过一次彻底的重写——而且是用它们自己的语言来重写。这个过程被称为“自托管”(self-hosting)。
我们可以将自托管成功的经验总结为两个方面:
对于一门成功的语言来说,其编译器从头开始用另一种语言重写是很常见的。
而“另一种语言”通常就是目标语言本身。
重写不仅仅是将代码库换成一种更具吸引力的语言,这也是一个从头开始的机会:利用第一版所积累的经验教训,摒弃积累下来的冗余代码,并避免上一次犯过的错误(当然,也会犯一些新的错误)。重写也以导致回归问题而闻名,尤其是在边缘情况的功能上。好在我们一直非常明确地传达了 Roc 不够稳定,未来会有很多变化——这也是我们选择不发布版本号的主要原因。
无论目标语言是什么,所有这些关于重写的权衡都很正常。当 Java 的编译器用 Java 重写、Haskell 的编译器用 Haskell 重写时,它们的目标语言虽然不同,但重新开始并利用积累的知识来改进重写版本所带来的好处对两者都是相通的。显然,如果你想加入那些在某个阶段都曾重写过并成功的编译器行列,那么你可以选择任何适合你的目标语言。
Roc 的编译器一直是用 Rust 编写的,但我们并不打算进行自托管。关于这点,我们在常见问题解答(FAQ)中解释了原因,且在这个问题上我们的观点没有任何改变:
“为了确保编译器的高效性和稳定性,我们选择了更为底层、对内存管理有更好控制的语言(如 Rust)来编写 Roc 编译器,而不是使用 Roc 语言本身。这样做不仅有助于优化编译器的性能,还能更好地满足用户的需求。”
如今,我们决定在另一种语言对编译器进行重写——也就是 Zig。我们对此感到非常兴奋!
为什么是现在?
我们最近意识到,由于各种原因,我们路线图中几乎已经包括了完全重写 Roc 编译器(有 30 万行 Rust 代码)的每一部分(除了类型推导)。具体来说:
(1)解析器(Parser):当前解析器的容错性还不够强,而且由于语法已经发展到一定程度,我们认为采用不同的基础解析策略会更合理。因此,我们希望对其进行重新架构。此外,我们还想将其转换为递归下降解析。最初它是用解析器组合器编写的,因为那时我比较熟悉这种方式。目前,Josh 已经在现有的 Rust 代码库中进行了重写实验。
(2)格式化器(Formatter):当前的格式化器不支持强制行宽。我们只想在生成文档时启用行宽限制,所以确实需要这个功能。要切换到一个能够强制执行行宽的系统(例如基于 Wadler 著名的“A prettier printer”技术),就需要进行彻底的重写。关于这点,我们团队已经达成共识,虽然还没有人开始动手。
(3)规范化(Canonicalization,即名称解析):规范化需要将一些名称解析推迟到类型检查之后进行,需要报告乱序定义的错误而不是重新排序,需要停止使用 Symbol,并且需要从递归枚举数据结构改为带有 ID 的平面数组,以便我们能够高效地将其缓存到磁盘上。此外,我们还需要将变量遮蔽(shadowing)视作警告,并添加对 var 的支持……简而言之,就是需要全部重写。目前,Sam 已经开始着手这个工作。
(4)文档生成(Documentation Generation):当前的文档生成器不解析类型别名,不支持自动链接到其他类型的文档(部分原因是它没有对其他包的感知),也无法显示未明确注释的推断类型。解决这些问题所需的改动非常大,因此相比修复这些问题,我宁愿选择重写。
(5)类型推断(Type Inference):类型推断本身并不需要重写,但它需要进行许多改动(例如移除 lambda 集推断、用静态分发替换 Abilities、用名义标签联合体替换不透明类型包装器、移除元组可扩展性和模块参数等),这些改动我们可以逐步进行。不过现在,我们将一次性完成所有这些改动。
(6)单态化(Monomorphization):单态化需要重写以修复 lambda 集的问题。具体来说,它需要被拆分为多个不同的步骤,而且当前实现已经积累了太多冗余代码,我们甚至已经讨论过重写这一部分以使其更易于维护。我们还决定移除 Morphic 求解器,因为其上游依赖已无人维护,而且存在一些我们短期内无法解决的 bug 和编译器性能问题(因为我们对 Morphic 的理解还不够深入)。尽管 Morphic 对编译后的 Roc 程序性能有积极影响,但它并不是关键性的,我们决定只有在有能力解决我们遇到的问题时,才会重新考虑使用它。目前,Agus 而也已经开始着手重写单态化。
(7)LLVM 代码生成(LLVM Code Generation):一个反复出现的痛点是我们希望升级到最新版本的 LLVM,以获取新的性能改进(并解除升级到最新版本 Zig 的限制,我们已经使用 Zig 来构建标准库很多年了),但 LLVM 的升级过程非常耗时,主要是因为 LLVM 的 API 破坏性变更。Zig 找到了一种解决方法:直接编译为 LLVM 位码(bitcode)。LLVM 在其位码上具有很强的向后兼容性,但在其公开的 API 上则没有。这样一来,升级就变得很简单了,因为我们可以保持现有的代码生成不变。据说,这会使 Zig 的 LLVM 代码生成时间增加了大约 5%,但从长远来看,他们(以及我们)可以缓存部分位码,而这可能会带来更好的性能提升。改用位码而不是 LLVM 的 API,需要完全重写 LLVM 代码生成部分。但如果我们用 Rust 来做这件事,我们就必须自己实现位码生成(用 Zig 重写可以复用 Zig 编译器代码库中现有的 LLVM 位码生成逻辑,该代码库是 MIT 许可的)。
(8)开发后端(Development Backend):我们目前直接生成机器代码(绕过 LLVM 以获得更快的反馈循环),但现在我们已经看到了这种方法的实际性能,所以想尝试使用解释器。理论上,解释器在运行时性能方面会更慢(但由于我们当前的机器代码生成非常简单,所以也不会慢太多),但它会整体提升开发反馈循环的速度,因为解释器可以跳过单态化和代码生成——直接从类型检查到运行程序。尝试基于解释器的方法,需要实际编写一个解释器(我们本来就想要一个用于编译时常量求值的解释器),然后将开发后端切换为使用解释器——换句话说,就是需要重写。
一旦我们完成了以上所有这些项目,编译器流水线中唯一没有被重写的部分就是类型推断了。而在这一切结束时,我们仍然会有一个 Rust 代码库,但我们更希望有一个 Zig 代码库。
为什么选择 Zig 而不是 Rust?
Rust 的编译速度较慢,而 Zig 的编译速度较快。这并不是唯一的理由,但却是一个重要因素。
编译速度慢,很大程度上会影响我们的工作效率和工作体验。等待几秒钟才能构建一个测试,这种体验本身就令人不太愉快。
对于这种抱怨,我经常听到的回应是:“几秒钟总比某些语言或项目需要几分钟的构建时间要好!” 但我并不在乎它是否比其他语言慢,我在乎的是,我明明知道有办法避免这种痛苦,却不得不忍受这种情况。据称,Zig(尚未稳定的)x86-64 后端在处理 Zig 30 万行代码库的部分内容时,重新编译的循环只需要几秒钟,且这还没有启用 Zig 的增量构建系统。与此同时,我们在 Rust 解析器(编译器中最容易重编译的部分之一)中进行更改后,重编译时间却需要 20 多秒,而且这还是在 Rust 尽可能支持增量构建的情况下。
除了编译时间,Rust 和 Zig 如今的优缺点,与 2019 年我开始用 Rust 编写 Roc 编译器时相比,已经大不相同。当时,Rust 相对成熟,而 Zig 还远没有达到今天的水平(此外,我当时已有十多年没有进行过底层编程,也没信心自己能够正确管理内存,而这一点如今也已经改变)。
以下是 2025 年在 Roc 编译器的具体背景下,这两种语言的具体对比点:
(1)内存安全性:对于许多项目来说,Rust 的内存安全性是一个巨大优势。但根据我们的经验,Roc 的编译器并不属于此类项目。我们倾向于通过传递分配器来进行内存管理(类似于 Zig,Rust 则不采用这种方式),并且它们的生命周期并不复杂。我们会在编译过程的早期就对所有字符串进行内存驻留,其他数据结构也局限于编译的特定阶段。
(2)内存管理:Zig 围绕传递分配器进行内存管理(这也是我们想要的方式),它还内建了许多优化结构如 MultiArrayList,来简化结构体数组的编程。虽然我们也能在 Rust 中实现这些功能,但用起来相当痛苦。通过查看 Zig 的相关实现,我们发现它的方式显得更为直观和易用。
(3)生态系统:Rust 的生态系统庞大,适合许多项目,然而 Roc 编译器的需求有所不同。我们使用了 Inkwell(LLVM API 的封装),但我们更倾向于切换到 Zig 的直接生成 LLVM 位码的方式,而 Zig 是唯一一种支持这种方式的语言。除此之外,我们使用的少数 Rust 第三方依赖在 Zig 生态系统中也有等效替代。因此总体而言,Zig 的生态系统对我们来说更具吸引力,当我们过滤掉所有我们不感兴趣的包之后,Zig 生态系统中我们实际想要使用的依赖项数量比 Rust 更多。
(4)工具链:Zig 的工具链使我们能够更容易地使用 musl libc 编译静态链接的 Linux 二进制文件,这正是我们长期以来一直希望实现的目标,以便 Roc 可以在任何发行版上运行(包括目前我们 Rust 编译器无法支持的 Alpine 容器)。我们知道这在 Rust 中也可以实现,但在 Zig 中更容易。Zig 的编译器本身就是这样做的,而且它在构建时会从源代码编译 musl libc 并捆绑 LLVM,这正是我们需要的。于是,我们可以直接复用 Zig 的代码,而 Rust 生态系统中则没有等效替代。
(5)性能优化:在 Rust 中,我们曾多次希望使用一些性能优化技术,但由于操作复杂,最终没有实施。例如,我们经常希望将一些元数据打包到数组索引的某些位中。Zig 允许我们将这个索引描述为一个结构体,并指定每个位范围的含义,包括任意整数大小。当然,Rust 也能通过位移操作实现这一点,但在 Zig 中做起来更加方便。无标签联合体(tagless unions)是另一个我们预计会非常有用的 Zig 特性。
(6)编译时间:我提到过编译时间吗?我再强调一遍:编译时间是个大问题。Rust 的编译时间不仅拖慢了我们的开发进度,也让我们不得不以编译时间为主来组织代码,甚至不得不牺牲代码的组织性以换取更快的编译时间。而在 Zig 中,我们期待能在保持良好反馈速度的同时,也能拥有更好的代码组织结构。
总结:Rust 的内存安全性并不是一个至关重要的优点,而其慢编译速度则是一个显著的痛点;Rust 的生态系统相较 Zig 更大,但在我们实际需求下,Zig 的生态系统反而更适合我们;除此之外,Zig 还具备一些 Rust 所不具备的语言特性,而这些特性对我们来说很重要。
设定重写目标
我们希望这次从头重写的编译器能够作为 Roc 0.1.0 发布,这是它的第一个编号版本。
0.1.0 版本计划进行一些重大的语言设计更改。以往,我们通常以向后兼容的方式实现变更,但这次这样做并不值得,我们将直接实现 0.1.0 的设计。
我们希望通过以下方式,提高编译器的可靠性和正确性:
(1)尽早进行模糊测试(Fuzzing):我们在 Rust 代码库中添加模糊测试时学到的一件事是,当问题在增量变更后出现时,修复模糊测试暴露的问题要容易得多。即使问题看起来与变更无关,你也知道问题源于那次更改!在早期保持这种模糊测试水平的缺点是,它会使探索过程变长,但这是一次重写——我们已经在 Rust 代码库中进行了大量探索,现在我们已经足够了解事情该如何运作了。
(2)边做边记录:与模糊测试一样,如果在原始编译器中花时间记录一切是如何工作的,将会极大地减缓探索速度(而且随着事情的变化,文档也需要多次重写)。但文档不仅对新贡献者有帮助,对我们的未来也有帮助。这一次,提前进行这项投资会更有意义。
(3)不要惊慌:我们在 Rust 编译器中的一些地方使用了 unwrap,当时我们认为它在实际中不会出现问题,但最终它总是会以某种方式影响用户体验。这次,我们希望完全通过传递值来进行错误处理。
(4)为了获得一个可靠、可用的实现,有意牺牲短期性能:在 Rust 编译器中,我们做出了一些架构选择——最重要的是,选择最小化编译器传递次数——这些选择提高了性能,但也加大了我们调试的难度。对于这次重写,我们希望在考虑存储数据的内存布局等一般性能因素的同时,优先考虑编写易于理解和调试的代码,即使这意味着会有更多的编译器遍历次数或内存表示可能不那么紧凑。我们可以在 0.1.0 之后,研究合并遍历和使用邻接表存储 IR 节点等问题。
总体来说,这是一项令人兴奋的任务,我们已经开始享受其中的乐趣了!
活动推荐:
《直击 DeepSeek 技术真相,对我们究竟意味着什么?》
在喧嚣的行业热潮中,2月8日(星期六)中午 13:00,我们将深度剖析与还原 DeepSeek 的技术真相!此次对话将深入探讨:
来源:CSDN