过去一年半,构建 Roc 编译器的团队一直在将我们 30 万行 Rust 代码重写为 Zig,原因我将在下文回顾。我们最近达到了一个令人兴奋的里程碑:与原编译器实现功能对等!

由于 Bun 项目最近分享了他们反向重写的经验报告(从 Zig 重写到 Rust,尽管这只是我们两次重写之间诸多差异的冰山一角),现在似乎是反思我们从 Rust 转向 Zig 的好时机。

通过功能对等

达到这个里程碑后,我们得以更新 Brendan Hansknecht 2024 年制作的趣味 WASM-4 游戏 Rocci Bird(艺术由 Luke DeVault 提供),使其使用新编译器。这是一个很好的例子,因为整个游戏的 Roc 代码不足一千行,你可以在 itch.io 上玩,或者通过 WebAssembly 直接在这里玩:

点击或轻触游戏,然后按空格键(或轻触)来扇动翅膀。在移动设备上你没有右箭头键,所以刷新页面可重新开始游戏。

Rocci Bird 的更新后源代码原始版本更简洁,roc build --opt=size 现在输出 31KB 的 wasm 二进制文件。(原编译器生成的二进制文件大小超过两倍。)Rocci Bird 绝不是一个大型代码库,但要让它运行起来,需要在新编译器中实现大量功能。当我们最终成功时,看到那些粗壮的紫色像素让我脸上露出了笑容!

需要明确的是,这是一个里程碑,但不是正式发布。(我们计划在今年晚些时候推出 0.1.0 版本。)不过,能达到这个里程碑已经非常美妙,我对所有齐心协力促成这一成果的人深表感谢!我想特别感谢一些对语言和编译器发展起到关键作用的人:

  • Anthony BullardSam Mohr 合作开发了新的解析器
  • Jared Ramirez 负责新的类型检查器(以及许多其他工作!)
  • Ayaz Hafiz 负责新的 lambda set 解析系统,以及原编译器的大量工作
  • Aurélien Geron 在他最初创建的 Roc Exercism 课程中手动更新了 108 个 (!) 入门练习
  • Stephan 让编译器新的“echo”平台在浏览器中运行,因此现在任何人都可以通过 2.5MB 的 WebAssembly 二进制文件在 roc-lang.org 首页编写运行基本的 Roc 程序!
  • Niclas Åhdén,Roc 最多产的生产用户,耐心提交有用的错误报告,并就升级过程提供可操作的反馈
  • JRI98 系统地复现和调查了模糊测试错误及其他错误,关闭了不再复现的问题,等等
  • Jasper Woudenberg 使用新编译器迭代了用户空间包的 API 设计
  • Folkert de VriesBrendan HansknechtBrian CarrollJosh WarnerAgus ZubiagaJelle Teeuwissen 构建了原编译器的基础,没有这些,新编译器就不会存在
  • 我把新编译器毫无争议的最大贡献者留到最后:Anton-4Luke Boswell 做了太多事情,我甚至无法全部记录——编译器工作、内置函数、平台、包、示例、修复错误、在 Roc Zulip 上帮助初学者……列举所有这些可能需要另一整篇文章!看到你们构建了这么多内容,真是令人难以置信。

非常感谢你们所有人!我很荣幸你们把这么多宝贵时间投入到这个项目中。也要感谢我们过去和现在的赞助商——rwxLambda Classohne-maklermartiantweede golfVendrNoRedInk 以及许多慷慨的个人赞助者——他们通过支持我们的贡献者帮助我们走到今天。

说到时间:我们 487 天的重写比 Bun 的 11 天重写(将他们约 50 万行 Zig 代码重写为 Rust)多花了 476 天。这个差异有很多与 Rust 或 Zig 无关的原因,包括他们的重写是直接移植,而我们之所以决定重写,正是因为我们打算做出大量改动。他们使用的技术在我们的情况下行不通。

我们所做的变更清单也意味着,比较我们原来的 Rust 代码库和新的 Zig 代码库并非完全对等。尽管如此,我们已经到了一个很好的阶段,可以反思重写过程,包括它为 Roc 程序员解锁了哪些新功能,以及我们使用 Rust 和 Zig 的体验比较。

让我们深入探讨!

热代码加载 + 交叉编译二进制文件

Roc 的新编译器在开发期间会自动进行热代码加载。例如,我可以运行 roc server.roc 来启动 Web 服务器,然后在它运行时更改部分代码。下次该服务器处理请求时,它将自动使用新代码处理。这里展示了它在服务器和简单 2D 游戏中的实际效果:

下载热加载演示视频。

热加载是 Python 等解释型语言的标准行为,但对于 Roc 这样的高性能编译型语言来说并不常见。当我准备部署时,roc build server.roc 会为我生成一个 LLVM 优化的、自包含的二进制文件,我可以将其放到机器上运行。

Roc 还支持交叉编译;构建一个在 Alpine Linux 上运行的静态二进制文件只需 roc build --target=x64musl,该命令在 Mac 或任何其他系统上运行时都会产生相同的输出字节(对于相同的输入源代码字节)——并非所有编译器都能保证这一点

带字符串插值的模式匹配

该视频中的 HTTP 请求处理逻辑如下所示:

match (verb, path) {
    ("GET", "/users/${id}/${page}") => match page {
        "" | "profile" => ok(id)
        "settings" => ok(with_default(user_agent, id))
        "posts/${post_id}" => ok("Post ID: ${post_id}")
        _ => not_found
    }

    ("GET", "/users/${id}") => ok(id)

    ("POST", "/posts/new") => created(with_default(…))

    _ => not_found
}

这使用了我们在新编译器中引入的几个功能。例如,"/users/${id}" 语法不是通过在运行时解析模板字符串实现的,而是通过一个新的语言特性:模式匹配中的字符串插值。

这不仅在编译时是类型安全的,整个代码片段还执行零次 堆分配。我预计,典型的带有热代码加载的语言在这里平均每行代码会接近 1 次分配……但 Roc 在人体工程学、类型安全性能上都追求极致!

你可以在新的 roc-lang.org 首页上尝试这种语法——如果你向下滚动一点,页面上就有一个编译器的 WebAssembly 构建,你可以用它来尝试这门语言。

顺便说一句,如果你对一篇关于我们如何利用新编译器的纯函数编译时执行将 HTTP 请求路由降至零分配的技术细节的文章感兴趣,请在 Roc Zulip 上告诉我。

为什么从头重写?

与 Rust、C 和 Zig 不同,Roc 不是一门系统语言;它具有自动内存管理(使用引用计数,既可以避免跟踪收集器暂停,也可以实现 Perceus 优化 和类似 Koka 的机会性变异 like Koka's)。如果 Roc 像大多数非系统语言那样需要为每个闭包捕获分配一次堆内存,那么它的堆分配会多得多,但我们的闭包捕获不会进行堆分配,因为 Roc 是第一个实现 通过 lambda set 特化进行多态去函数化 的非学术语言。

这听起来可能是一种小众优化,但在像 Roc 这样的函数式语言中,去函数化实际上类似于 内联,它解锁了大量后续优化。尽管这个系统对 Roc 的运行时性能极其有益,但我们也发现它极难正确实现。我们在最初的实现中遇到了令人头疼的错误,直到 Ayaz Hafiz 在 OCaml 中原型化了新架构 之后,我们才终于在新编译器中将其实现正确。

Ayaz 的原型表明,我们问题的根源在于多个编译器阶段的架构问题,修复它需要重写大部分编译器。这是我们决定重写的原因之一——此外,还有几位贡献者独立提到他们计划出于其他原因重写编译器的各个部分。我们意识到我们反正即将重写几乎整个编译器,因此将完全重写视为 忒修斯之船 方法的替代方案是合理的。

编译器有一个不同寻常之处:成功的项目中从头重写是常态。这往往是实现 自托管 的唯一方式,尽管并非所有编译器都会重写为自己的语言;例如 TypeScript 重写为 Go。我一直认为 Roc 的编译器不应该自托管,所以有一天重写的好处可能会超过其臭名昭著的成本 这个想法,老实说,我从未想过。

我们讨论得越多,就越觉得应该做当今几乎所有主流编译器都做过的事:从头重写。

为什么选择 Zig?

一旦我们决定从头重写,下一个问题就是是否再次选择 Rust。基于我们对 Rust 和 Zig 的经验(我们已经在标准库中大量使用 Zig),我们决定这次用 Zig 构建整个编译器

我喜欢 Rust,我教过一门关于它的课程,而且我在 Zed 的日常工作中愉快地使用它。尽管互联网评论可能让我们相信,但一种语言对一个项目最合适,而另一种语言对另一个项目最合适,这其实是非常正常的。一种尺寸并不适合所有人!

我已经在其他地方深入讨论了我们选择 Zig 的原因——书面形式播客 等——我们只认真考虑了 Rust 和 Zig,因为这是我们团队唯一足够熟悉的系统语言。我们在 Rust 和 Zig 之间做出决定时的主要考虑因素是:

  • 构建时间。 我们的 cargo 构建时间是一个主要痛点,即使是增量构建也是如此,而且随着代码库的增长而变得更糟。我们预计 Zig 重写的构建时间会快得多。
  • 内存控制。 我们在整个编译过程中使用各种不同的内存分配器,尤其是 arena 分配器,并且到处使用 struct-of-arrays 布局。Rust 的生态系统始终假设有一个全局分配器,包括 soa_rs。Zig 的整个生态系统都假设使用细粒度的分配器,而 struct-of-arrays 支持是标准配置。
  • 生态系统相关性。 Rust 的生态系统总体上比 Zig 大得多……但两个生态系统中的包几乎都没有与我们的特定需求相关。对于我们想直接获取的小众功能——例如比包装 LLVM 的 C++ 库更快地发出 LLVM bitcode 的方法——Zig 中存在的此类代码比 Rust 中更多。
  • 内存不安全辅助。 Rust 的设计是将内存不安全代码隔离在罕见的 unsafe 块中,并使用 miriValgrind 等工具来审查这些代码。然而,对我们来说,内存不安全代码并不罕见(稍后详述),我们的 Rust 代码(30 万行)中大约有 1200 处 unsafe 使用(相比之下,rust 的 350 万行代码中有约 4 万处 unsafe 使用,请记住,对于像 rocrustc 这样会发出机器代码的编译器,执行内存不安全操作是工作的重要组成部分)。Zig 比 Rust 有更多特性来帮助正确编写内存不安全代码,而这正是我们最需要帮助的领域。

经过一年半的重写,我们对 Zig 优势的预期与实际获得的成果是否一致?我们不再使用 Rust 后,又怀念 Rust 的哪些部分?

没有借用检查器的生活

让我们从内存安全开始。有一个著名的 2019 年 Microsoft 演示文稿,在第 10 页上说:

每年通过安全更新解决的漏洞中,约 70% 仍然是内存安全问题。

演示文稿的下一页按内存安全问题的类型进行了细分,这为 Rust 和 Zig 具体呈现了以下情况:

  • 2018 年通过安全更新解决的漏洞中有 83.6% 完全不受 Rust 或 Zig 选择的影响,因为这两种语言以相同的方式处理所有这些场景(越界读/写、不安全类型转换、未初始化读、栈溢出和非内存安全问题)。
  • 16.4% 的漏洞是特定的 use-after-free 错误。这些错误可以通过 Zig 的 ReleaseSafe 运行时内存安全检查、Rust 的借用检查器或 Fil-C 使用的检查来捕获……现代语言有多种方法来帮助捕获 UAF,尽管这些 2018 年的 CVE 几乎肯定来自 C 或 C++ 代码。

ReleaseSafe 通过运行时检查捕获 use-after-free 错误,如果程序尝试使用已释放的内存,就会 panic。与 Rust 的安全子集 相比,Zig 的检查不够全面,有运行时成本,并且可能会 panic。不过,带有 ReleaseSafe 的 Zig 在实践中对 TigerBeetle 数据库效果很好,该数据库最近接受了一份传奇般 meticulous 的 Jepsen 报告,仅发现两个安全漏洞,且均与内存安全无关。

ReleaseFast 在生产构建中跳过这些检查以避免开销,但在调试构建和测试中保留它们以在开发期间捕获内存安全问题。如果你的测试覆盖了每条可能的真实世界代码路径,ReleaseFast 将提供与 ReleaseSafe 相同的安全性,但这种测试覆盖率很少实用;真正的问题是实践中哪些问题会从覆盖缺口中溜走。Bun 谈到了他们在 use-after-free 方面的挣扎,但其他使用 ReleaseFast 构建的广泛使用项目在 Zig 代码中没有因内存不安全导致的 CVE。Ghostty 是其中之一,Zig 编译器本身 是另一个

如果你想了解更多关于这些项目的信息,我已经与它们的创建者进行了深入对话:Joran Greef 谈 TigerBeetleMitchell Hashimoto 谈 GhosttyAndrew Kelley 谈 Zig

Rust 代码有不同的内存安全缺口来源:几乎每个 Rust 程序在其依赖项中的某处都有的 unsafe 部分。Unsafe Rust 具有 ReleaseFast Zig 代码的所有内存不安全风险,但没有在开发期间捕获问题的运行时检查。Rust 生态系统有 miri 来查找非 FFI unsafe 代码中的错误,Valgrind 也可以提供帮助,但很少有 Rust 项目使用其中任何一个。尽管如此,罕见使用 unsafe 并对其进行额外仔细审计的文化规范已经足够好,让 Rust 在实践中赢得了强大的内存安全声誉。

当然,Rust 内存不安全错误仍然可以并确实会从缝隙中溜走。Deno,一个用 Rust 编写的 Bun 竞争对手,曾经有过内存不安全 CVE,包括 越界读 以及 use-after-free,两者都涉及 Unsafe Rust 的使用。Rocket,一个 Rust Web 框架,曾经有过 use-after-free CVE,而 Actix 在其 unsafe 使用异常高的时期有过各种内存不安全 CVE。

当我们在新编译器中决定使用 Rust 还是 Zig 时,我们意识到了所有这些。我们知道 Rust 有当之无愧的内存安全声誉,但内存不安全仍然可能发生,我们在原编译器中也亲身经历了这一切。我们也知道我们会比典型的 Rust 项目更多地使用 unsafe,尽管我们已经在使用 Valgrind,但从 Zig 的额外检查中获得帮助来处理天生内存不安全的代码听起来很有吸引力。我们希望让困难的事情变得更容易,我们不担心编译器中的 use-after-free 问题,因为编译器中的分配绝大多数是在具有简单生命周期的 arena 中完成的。

我们知道高调的 Zig 项目在实践中实现了出色的性能内存安全,我们决定瞄准成为另一个这样的成功案例。

重写后的内存安全

很容易理论化特定技术选择会如何发展,但真正重要的是最终用户在现实世界使用中遇到的情况。那么,带有 ReleaseFast 的 Zig 在实践中对我们来说效果如何?自从将编译器从 Rust 重写为 Zig 以来,我们看到了多少内存损坏事件——无论是由 use-after-free 还是其他原因造成的?

以下是 Roc 的问题跟踪器 中错误报告的分类,由 Claude Opus 4.8 分类:

Roc 编译器中的错误类型 Rust Zig
发生内存损坏的错误 21 10
未发生内存损坏的错误 2,575 421
总计 2,596 431

你可能想知道为什么基于 Rust 的编译器竟然有内存损坏错误,更不用说数量是基于 Zig 的编译器的两倍多。这是因为那些烦人的 Unsafe Rust 吗?

实际上不是。那些 21 个内存损坏错误都没有发生在编译器逻辑本身,这证明了 Rust 的借用检查器按预期工作。我们在基于 Rust 的编译器中遇到内存损坏错误的原因是——它是一个编译器。

编译器发出机器指令。当机器执行这些指令时,它们可能导致内存损坏,从而导致经历这些问题的人报告内存损坏错误。无论哪个进程有错误——编译器或编译后的程序——在这两种情况下,处理器之所以做坏事,都是因为编译器告诉它这样做。在这两种情况下,修复方法都是相同的:必须更改编译器的代码,因为正是该代码导致了内存损坏。

就像每个编译器一样,Roc 也有错误,其中一些是导致内存损坏的错误编译。尽管如此,虽然 Zig 编译器中的 10 个内存损坏错误中有 8 个也是错误编译,但剩下的 2 个是在编译器本身。两者都是错误报告中的 use-after-free 错误,症状相同:错误消息中的文件名(一个在 roc check 中,另一个在 roc bundle 中)渲染为无用的 菱形中的问号字符。Rust 的借用检查器会捕获这两者。

现在假设我们改为选择 Rust 进行重写,或者选择带有 ReleaseSafe 的 Zig。在所有其他条件相同的情况下,实践中会有什么影响?

工具选择 实践中的内存安全影响
Zig ReleaseFast 2 个错误报告:某些错误无法渲染文件名
Zig ReleaseSafe 2 个错误报告:某些错误会 panic 且不渲染
Rust 的借用检查器 这两个错误报告都不会出现

经过 18 个月的开发、数百个错误报告和数十万行代码,回想这张表格,我的主要收获是选择不同的行对项目不会产生明显影响。到目前为止,我们的选择达到了我们所希望的结果。

正如我之前指出的,每个项目都有不同的需求。当 Bun 向相反方向重写——从 Zig 重写到 Rust——时,他们的附带文章指出:

对于 Bun,正确处理 JavaScript 的垃圾回收值和手动管理值的生命周期一直是稳定性的主要来源——最常见的是小的内存泄漏,偶尔也会崩溃。每个内存分配都必须 meticulously 审查。这些字节在哪里被释放?我们如何确保它只被释放一次?我们是否正确检查了 JavaScript 异常?这个垃圾回收指针是否对保守栈扫描器可见?这是垃圾回收内存还是手动管理内存?

Roc 的编译器没有这些特定的挑战,因为它不与 JavaScript 或任何其他跟踪垃圾收集器交互。对于 Bun 来说,“use-after-free、double-free 和‘忘记释放’”错误一直是“大量 bug”的来源,而此类错误在 Roc 的 bug 中只占很小一部分。当然,Roc 的编译器也面临 Bun 没有的其他挑战。不同的项目有不同的需求!

在我们的案例中,我不确定如何回顾实际发生的事情,并得出结论:我们需要更多的工具投资来防止编译器本身中的内存安全错误。更有力的理由是,我们会从更好的工具中受益,以捕获我们编译输出中的内存安全错误,而这从来不在借用检查器的范围内。

构建时间

我们希望从 Zig 获得更快的构建速度。我们做到了吗?

好消息是,zig build --watch -fincremental 可以用大约 35 毫秒重建我们当前约 45 万行 Zig 代码中的一个更改。这甚至比我们将 Zig 的构建速度视为重写卖点时的预期还要快!

坏消息是,Zig 当前稳定的 0.16.0 版本有一个破坏我们代码库中 -fincremental 的错误。修复 已经落地,但要获取它,我们必须构建 nightly 0.17.0 预发布版本(它有破坏性的语言更改),以及供应商化并将受影响的依赖项升级到 0.17.0。我们决定等待下一个稳定版本。

截至我们代码库中最后一次提交包含 Rust 源代码,以下是我在运行 Ubuntu 26 的 Intel 台式机上构建冷构建(无缓存,但包已本地下载)和对解析器进行微不足道的编辑后进行增量重建的时间比较:

Roc 编译器版本 代码行数 冷构建 增量构建
原始 Rust 1.85.0 版本 354K 32.4s 10.0s
原始 Rust 1.97.0 版本 354K 25.4s 3.4s
功能对等时的 Zig 0.16.0 重写版本 320K 39.6s 8.6s
今天的 Zig 0.17.0 重写版本 464K 32.1s 0.035s

请注意,我们在功能对等提交时的 Zig 构建配置会在每次构建时重建很少更改的产物,而我们后来决定仅按需重建。这就是为什么今天的冷构建比 30 万行代码时更快,尽管自那时以来我们的代码行数增加了约 50%。

Rust 1.97 是今天当前的稳定版本,而 1.85 是 487 天前(我们重写达到功能对等的时间)的当前稳定版本。因此,如果我们在这段时间内继续使用 Rust,我们可能会看到增量构建时间从 10 秒减少到 3.4 秒。这是一个巨大的飞跃!我非常感谢 Rust 贡献者为改善构建时间所做的所有辛勤工作。在 18 个月内消除我们增量构建时间的 2/3,如果我们继续使用 Rust,这将是一个非常受欢迎的改变,而且这比我在 18 个月内预期的改进要大。Bravo!

尽管这一改进令人印象深刻,但 Zig 的 35ms 仍然遥遥领先。它不仅是 3.4 秒的 1/100,而且还处于不同的性能类别——而这 35ms 是在比获得 3.4s 的 Rust 代码库多约 50% 代码行的 Zig 代码库上运行的。我预计 Roc 的代码库会继续增长,这个差距也会随之增大;我从未听说过 Rust 路线图上有任何与 -fincremental 相当的举措。

因此,虽然我们决定留在稳定的 0.16.0 版本(加上我们的贡献者中有多少人在运行带有 ARM 处理器的 Mac 笔记本电脑;-fincremental 目前仅在 x86-64 CPU 上工作)意味着我们尚未从选择 Zig 进行重写中获得预期的构建时间回报,但我们当然有理由期待下一个稳定 Zig 版本!

内存控制:零解析反序列化

Roc 的新磁盘缓存系统使用了一种我最初从 Zig 的编译器中学到的技术,Casey Muratori 告诉我这在游戏编程中很常见。它依赖于一个愉快的巧合:如果你以现代硬件上运行最快的方式组织内存,你也可以直接从磁盘加载它到内存中并开始使用,而无需解析任何内容。

其工作原理如下:

  • 我们所有的编译器数据结构都表示为带有 32 位 索引而非指针 的数组(通常采用 structure-of-arrays 形式)。
  • 这不仅节省内存并运行得更快,还意味着我们的数据结构可以直接写入磁盘,而无需先序列化为不同的格式。
  • 更大的好处是,这让我们可以反序列化它们回到内存,而无需以任何方式解析磁盘上的字节。我们将字节加载到内存中,进行一些重定位以将我们现有的数据结构指向新加载的数组,然后我们就可以开始了。
  • 这意味着我们反序列化的速度与从磁盘加载字节到内存的速度相同——因此,实际上 受 I/O 限制。如果这些字节已经在操作系统的磁盘缓存中,这意味着我们以大致 memcpy 的速度加载之前构建的缓存工作。

当你连续运行 roc check 两次时,第一次它会使用此策略将所有输出缓存到磁盘上。第二次,如果输入源代码文件没有更改,所有已解析/类型检查等的数据结构会直接从磁盘跳转到内存中。这非常快。roc test 类似地缓存纯函数测试的结果(这些是确定性的),所有这些都是以文件级粒度完成的,因此如果你更改一个文件,你只需要为重做该文件以及任何依赖它的其他文件的工作付费。

这种零解析反序列化策略之所以有效,是因为我们对所有编译器数据结构都遵循这种 无指针编程 风格。如果我们改为到处使用指针(就像几乎所有编译器那样),反序列化就不可能是零解析的。

然而,这种方法存在安全风险。与内存中的指针可能指向错误的地址(例如导致 use-after-free)类似,任何索引都可能在运行时被用作对错误数组的查找,此时你会得到该位置碰巧存在的任何随机字节。Rust 的借用检查器旨在帮助处理指针生命周期,但它不会尝试回答“哪个索引与哪个数组对应?”这个问题,因为这从来不在其设计范围内。

如果你确切知道你需要多少个这样的数组,Rust crate compact_arena 可以通过使用宏生成类型标签来帮助你避免索引到错误的数组。不幸的是,如果你无法确切知道你需要多少个(例如,因为它因模块数量而异,就像我们的用例那样),这种技术就不起作用。这就是为什么 compact_arena SmallArena::new 标记为 unsafe 的原因。

我个人不会将 SmallArena::new 标记为 unsafe。unsafe 应该标记你代码库中应该额外仔细审计的部分,而创建一个空 arena 不需要审计,因为它不会导致不安全。不幸的是,潜在不安全的操作是索引到数组,这 constantly 出现。“额外仔细审计你代码库的每个部分”不是好的建议,“避免这种能显著提高性能的技术”也不是好的建议,而 Zig 本身已经证明,在做这件事的同时实现完美的内存安全 CVE 记录是可行的。

Safe Rust 在实践中有效,是因为它假设你代码库中的 Unsafe Rust 数量很少且是隔离的,而这个假设对绝大多数 Rust 代码库都成立。但如果 unsafe 将无处不在,就像我们的情况那样,这个假设就不再成立,选择 比 Unsafe Rust 更安全的语言 听起来更有吸引力。

生态系统相关性

Bun 的文章谈到了 Rust 的 Drop 如何帮助他们不寻常的 JavaScript 互操作挑战:

[...] 其他 Zig 用户没有我们遇到的错误,将 GC 与手动管理内存混合是软件需要的一种不常见的东西,没有任何语言真正为此设计。[...] 减少这类问题的一种常见方法是确保需要它的代码的清理代码始终只运行一次。Zig 的设计是简单语言,没有隐藏的控制流,因此它更喜欢在作用域结束时运行代码的显式 defer 关键字,而不是 C++ 的隐式 ~Destructor 或 Rust 的隐式 Drop

我们的情况恰恰相反:Drop 对我们来说是一个痛点,因为 Rust 生态系统是围绕每个人都在使用全局分配器并使用 Drop 进行隐式释放的假设构建的。但我们想做几乎相反的事情:为每个模块和编译阶段使用单独的 arena。Zig 的生态系统始终传递分配器,这正是我们想要的,而现成的 Rust crate 几乎总是假设一个全局分配器。

简而言之,Rust 的生态系统针对 Bun 想要编写的方式进行了优化,而 Zig 的生态系统针对 Roc 想要编写的方式进行了设计。

另外,还有我们可以直接获取的相关代码的问题。LLVM 是我们优化器的关键依赖(我们自己做优化,但 LLVM 在此基础上做更多),但它也是一个定期进行重大破坏性 API 更改的项目。升级到新的 LLVM 版本一直是 Roc 的主要痛点和时间损失来源,但我们继续这样做,因为我们想要新的优化。

事实证明,LLVM 实际上有一个稳定且向后兼容的 API,可以用来绕过这种升级痛点:它的序列化“bitcode”格式。如果你编写自己的 LLVM bitcode 序列化器,那么你可以告诉每个新版本的 LLVM 消费它,然后你就出发了。

当然,要使用这种策略,你需要一个手写的、与 LLVM C++ 库及其破坏性更改解耦的 LLVM bitcode 序列化器。据我所知,野外只有一个这样的实现:Zig 的编译器,当然是用 Zig 编写的。现在野外有两个实现,因为 Roc 的新编译器正在重用相同的 Zig 代码。(感谢 Zig 团队分享!)

你可能已经注意到,我们从 Zig 生态系统感兴趣的最大依赖来源是 Zig 编译器本身。这很不寻常,但 Roc 是一个不寻常的项目,有不寻常的需求。当我在 2019 年编写编译器的第一行代码时,我不会想到以下事实会被证明是正确的:“在未来,这个项目可重用代码的最丰富金矿将是一个用你尚未听说过的语言编写的开源编译器。”

生活充满惊喜!

我怀念 Rust 的地方

尽管我不再为 Roc 使用 Rust,但我仍然沉浸在 Rust 世界中,因为我在 Zed 工作,我们在那里几乎所有事情都使用它。所以当我说我在用 Zig 构建时怀念 Rust 的某些东西(反之亦然)时,这不仅仅是对遥远过去的玫瑰色回忆;这更像是当天早些时候的回忆。

我惊讶地发现自己怀念 Rust 的是在测试中自动分配和释放。

如前所述,对分配和释放有完全控制是我在编译器实现中想要的。在测试中,我也欣赏测试分配器检测泄漏的能力——它甚至可以检测已编译 Roc 代码中的泄漏!不幸的是,要获得这种好处需要在测试中编写大量“init this, defer deinit”代码,这些代码必须正确,否则测试会在内存泄漏时失败。在 Rust 中,所有这些都是不必要的。我更关心编译器的实现是我想要的方式,而不是测试看起来更好,但在完美的世界里,我可以以某种方式两者兼得。

参数多态特设多态 都与 comptime 重叠,因此 Zig 没有它们是有道理的,但我确实怀念它们。例如,Rust 的 Allocator trait 的 allocate 函数在其第一个参数中接受“self”,而在 Zig 中,分配器实现(如 ArenaAllocator)需要接收一个 anyopaque 指针,然后将其转换为自身。

我还怀念私有结构体字段。我理解不设置它们的原因,但我怀念如果我使用被标记为“即使你真的想这样做,也不应该直接访问”的东西时会收到编译错误的情况。在审查 diff 时会出现这种情况,因为在 diff 中我只看到字段访问;我看不到原始结构体定义上的文档,我不想每次都特意去防御性地查找它们。

偶尔我会怀念函数、变量和常量都使用 snake_case

我确实怀念 unsafe 和借用检查器的某些方面,尽管它们的好处伴随着我不怀念的坏处。我不认为 Zig 应该添加其中任何一个,但与此同时,只在 unsafe 块内担心某些类别的问题,有一种令人平静的感觉。即使在这个项目中我不愿意支付相应的成本,我也可以怀念那种感觉。

我不确定这在多大程度上是因为 comptime 的工作方式,但我确实发现自己比在 Rust 中更常惊讶地发现在我们的 Zig 代码库中存在死代码(这既没有被 Zig 的内置工具捕获,也没有被 TigerBeetle 的 tidy.zig 捕获——顺便说一句,感谢 TigerBeetle 团队开源它!)。死 Zig 代码不会影响最终用户,因为编译器甚至不会将其发出到二进制文件中,但显然,如果我们能更早发现它,对我们的代码库会更好。

最后,Rust 团队在发布中的向后兼容性方面做得令人钦佩。升级到新的次要版本几乎不需要任何努力,即使是版本升级也大多是无痛的。向后兼容性不是 Zig 在当前开发阶段的目标,这是我们进入时就知道并预料到的。这对我们来说不是一个大问题,但我怀念我们在 Rust 中拥有的简单升级过程吗?当然!

我喜欢 Zig 的地方

我一直喜欢函数式编程的减法方面。你可能会认为,从我的工具箱中减去我习惯使用的工具(例如变异、无限制的副作用、对象和类)会令人沮丧……但一旦我习惯了不同的技术,我真的开始享受我解锁的新属性(可缓存性、非片状测试、并发便利性、无需担心其输出可能改变而重新排序操作等),并且不再想放弃那些

我对 Zig 有类似的感觉。我喜欢它没有宏。我可能会怀念特设多态,但与此同时,我喜欢有多少问题(包括参数多态)可以通过 comptime 和/或普通函数解决。

我喜欢对数据布局的控制。开箱即用地访问不是 2 的幂的数字类型(如 u7 和 u5)而无需自己进行任何位级工作,这很棒。Packed structs 开箱即用,可以在调用站点而不是声明站点内联函数……这些是你可以通过使用宏的 Rust crate 获得的东西,但我真的喜欢无需单独依赖即可获得它们。

Zig 的构建工具链是首屈一指的,这大概是 Uber 使用它的原因,即使他们不使用 Zig 语言。构建自包含的二进制文件(如 Alpine Linux 和 WebAssembly)进行得非常好,尽管我们正在做一些奇怪的事情,比如将我们代码库的一部分(“内置函数”——基本上是 Roc 的标准库)编译成一个不透明的二进制 blob 并将其包含在最终可执行文件中。

我还非常喜欢 Zig 的错误处理策略,特别是失败的堆分配是正常的用户空间错误。Roc 有类似的“错误自然累积”策略(除了使用可以有负载的匿名和类型),我喜欢这两种策略都胜过 Rust 中的 anyerrorthiserror 或使用 Result 的普通无依赖错误处理。(尽管如此,我确实更喜欢 Rust 的后缀一元 ? 运算符,而不是 Zig 的 try 关键字,这就是为什么我们在 Roc 中采用了后缀一元 ? 运算符。)

当然,还有我之前提到的所有项目特定内容:到处都是基于分配器的 API、我们 elsewhere 找不到的高性能编译器好东西的生态系统,等等。我不会在这里重述它们,但我非常喜欢它们,并感谢它们给项目带来的好处。

我对 Zig 的整体体验非常积极,回首往事,我真的很高兴我们为重写选择了它!

Roc 的下一步

我们计划在今年晚些时候推出新编译器的 0.1.0 版本,这将是 Roc 有史以来的第一个编号版本。你可以在此之前尝试 Nightly 构建,尽管在当前状态下,你仍然可以预期各种错误、不完整的功能和未完成的文档。我现在到那时还有很多文档要写!

顺便说一句,Roc 编程语言基金会 是一个 501(c)(3) 非营利组织,所以如果你想捐款,在美国是可以免税的,我们主要用捐款来补偿贡献者。如果你知道有组织愿意以财务或其他方式赞助我们的工作,请联系我们!(另外,如果你认识 GitHub 的任何人可以让我们进入 GH for Nonprofits,这将对我们的 CI 积压有很大帮助。)

再次感谢所有帮助这门语言达到这个里程碑的人。我对下一个里程碑——我们有史以来的第一个编号版本——感到无比兴奋!如果你想关注、提问或只是打个招呼,欢迎在 Roc Zulip 上与我们聊天。