【译文】Zig、Rust 和其他语言

我曾在 Zig、Rust、Go 和现在的 C 语言中工作过一段时间,我认为有几个共同的话题值得进行一次全新的对话:自动内存管理、标准库和显式分配。

Zig 并不是一种成熟的语言。但它已经做出了足够有用的选择,让许多公司对其进行投资并在生产中运行。这些有用的选择让 Zig 值得一谈。

Go 和 Rust 都是成熟的语言。但它们都做出了值得商榷的选择。

所有这些语言都是由我个人所仰慕的高智商人士开发的。你选择使用其中的任何一种,当然都没有问题。

不过,当我们考虑 10 年后的系统编程语言会是什么样子时,特定语言所做的正反两方面的选择还是值得讨论的。或者说,这些语言本身在未来 10 年内会如何发展。

我的视角主要是构建分布式数据库。因此,我提出的观点可能与你所从事的工作毫不相关,这也没有关系。此外,我已经意识到这些观点大多不为语言维护者所认同,这也没关系。我写这篇文章不是为了说服任何人。

自动内存管理

我对 Zig 最大的意见之一就是它不支持 RAII。你可以将清理工作推迟到代码块的末尾;这是问题的一半。但只有 RAII 才支持智能指针和自动(而非手动)引用计数。RAII 是一个很好的默认选项,但在 Zig 中却不允许这样做。相比之下,即使是 C 语言也 “支持 “自动清理(通过编译器扩展)。

但在大多数情况下,”竞技场 “是没有问题的。Postgres 是用 C 语言编写的,内存几乎完全是通过嵌套的区域(称为 “内存上下文”)来管理的,当任务的某些子集完成时,这些区域就会被递归地清理掉。Zig 内置了对 arenas 的支持,这非常好。

标准库

一些语言的标准库越来越小,这似乎令人遗憾。较小的标准库似乎会鼓励语言用户安装更多未经审查的第三方库,这就增加了构建时间和构建的不稳定性,而且随着时间的推移,不必要的破坏性更改也会增加 bitrot。

十年来,人们一直在拿 node_modules 开玩笑,但在我见过的 Rust 代码库中,这个问题同样严重。在某种程度上,Java 和 Go 也存在这个问题,尽管它们的标准库更大,可以让你在没有依赖的情况下更进一步。

Zig 有一个很好的标准库,几年后可能会成为 Go 和 Java 的佼佼者。但他们的软件包管理器的一个目标似乎是将标准库拆分,使其变得更小。例如,将 JSON 支持从标准库中移到一个包中。我不知道这是否真的是计划中的方向。希望不是。

拥有一个庞大的标准库并不意味着程序员不能根据需要轻松地更换实现。但标准库只需要定义一个接口和标准库的实现即可。

标准库的小规模不仅会影响使用该语言的开发人员,甚至会鼓励该语言本身的开发人员依赖个人拥有的库。

看看 Node.js 官方软件包(如 node-gyp)的传递依赖关系。鼓励官方库依赖于个人拥有的库,如 env-paths,而这些库已经有 3 年没有修改过了,这真的是一个小型标准库的理想结果吗?68 行代码。比如,把 env-paths 的代码复制到 node-gyp 中。

同样,如果你想在 Rust 中寻找压缩支持,标准库中也没有。但你可能会注意到在 rust-lang GitHub 官方命名空间下的 flate2-rs repo。如果你看一下它的传递依赖关系:flate2-rs 依赖于(某个人的)miniz_oxide,而miniz_oxide 依赖于(某个人的)adler,后者已经 4 年没有更新了。包括测试在内有 300 行代码。为什么不卖掉这些代码?一个小型标准库所养成的习惯似乎在鼓励大家不要这样做。

我不是说这些一定会构成供应链风险。我说的不是left-pad。但这种模式是显而易见的。即使是官方软件包,最终也可能依赖于外部软件包,因为对小型标准库的承诺意味着省略压缩、校验和以及常见操作系统路径等内容。

这是一种权衡,也许会让标准库维护者的工作更轻松。但我不认为这是理想状态。依赖性是有用的,但应保持在合理的最低限度。

希望在这方面,语言最终更像 Go 而不是 Rust。

显式分配

当人们讨论 Zig 标准库要求每个分配方法都有一个分配器参数的模式时,他们经常会提到交换分配器的好处,或者能够处理 OOM 故障的好处。

在我看来,这两方面都非常小众。例如,在 Zig 测试中,我们鼓励你传递一个调试分配器,它能告诉你内存泄漏的情况。但这似乎与使用调试分配器编译 C 项目或使用不同的消毒器编译并针对生成的二进制文件运行测试并无太大区别。在这两种情况下,你都要根据运行代码的环境(生产环境还是测试环境)来处理全局级别的分配器。

对我来说,显式分配的真正好处要琐碎得多。在 Zig 中,如果不承认分配,基本上就无法编写方法代码。

这对热路径代码尤其有用。以迭代器为例。它有一个 new() 方法、一个 next() 方法和一个 done() 方法。在大多数语言中,语法或编译器层面基本上不可能知道是否在 next() 方法中进行了分配。你可能知道,因为你对 next() 中所有代码的行为了如指掌。但这种情况并不经常发生。

如果你编写了 next() 方法,但没有向 next() 主体中的任何方法传递分配器,那么 next() 方法中的任何内容都不会分配,这实际上是 Zig 的独到之处。

在其他语言中,可能直到运行剖析器时才会注意到,本应在 new() 中完成一次的分配,却意外地在 next() 中完成了。

另一方面,出于同样的原因,编写 Zig 语言也是一件麻烦事,因为所有东西都需要分配器!

显式分配并不是 Zig 语言的固有特性。它是标准库中的一种约定俗成的做法。全局分配器仍然存在,Zig 的任何用户都可以决定使用全局分配器。这时,你就有了隐式分配。因此,显式分配作为一种约定并不是完美的解决方案。

但默认情况下,它能让你对分配有一定程度的了解,这是一般的 Go、Rust 或 C 代码无法做到的,这取决于项目的实践。或许可以关闭 Go、Rust 和 C 标准库,使用所有分配函数都需要分配器的标准库。

但显式传递分配器仍然是一种可视化的黑客行为。

我认为未来最理想的情况是每种语言都支持将代码块注释为 “不得分配”(must-not-allocate)或类似内容。编译器会强制执行这一点,如果你在标记为 “must-not-allocate “的代码块中进行分配,编译器就会失败;或者编译器会在运行时发生恐慌,这样你就可以在测试中发现这一点。

除了静态编程语言,这一点也很有用。将 JavaScript 或 Python 中的代码块注释为 “must-not-allocate “也同样有趣。

否则,目前的情况是,你通常会在全局级别配置这类东西。说 “在整个程序中不得有任何分配 “在一般情况下似乎没有说 “在这一个代码块中不得有任何分配 “有用。

可选而非必需的分配器参数

Rust 刚开始支持向分配方法传递分配器。但这是可选的。据我所知,C++ STL 也是如此。

这两种方法对于编程扩展都非常有用。这也是我认为 Zig 对于 Postgres 扩展来说非常有意义的原因之一。因为它只是为了在使用别人的分配器的环境中运行而构建的。

对 Zig、Rust 和 Go 工具的赞誉

这三种语言都拥有非常出色的第一方工具,包括构建系统、软件包管理、测试运行程序和格式化工具。语言应提供良好的代码环境(端到端),这一理念让程序员的工作变得更简单、更美好。

非结论

使用你想使用的语言。Zig 和 Rust 都是比编写纯 C 语言更好的选择。

另一方面,我在编写 Postgres C 时感到非常惊喜。它几乎是一门独立的语言,因为你经常要处理面向用户的结构,比如Postgres的Datum对象,它代表了Postgres数据库中的单元格。你可以使用与Postgres SQL相同的函数来处理Datum对象,但使用的是C语言。

最近,我还用 pgrx 在 Rust 中编写了一些 Postgres 扩展,希望能尽快写出来。当我看到用 Zig 编写 Postgres 扩展的 pgzx 时,我也很想花点时间研究一下。

本文文字及图片出自 Zig, Rust, and other languages

余下全文(1/3)
分享这篇文章:

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注