从 Rust 转到 Zig 是什么感觉

查看原文 HN 讨论

文章摘要

作者 besok 自称做了 7 年 Rust 开发,主要参与开源项目,偏爱 Rust 中偏函数式的那一面——干净的函数、富有表现力的类型。他早年写过 C,而 Zig 作为”C 的继任者候选”在他雷达上挂了很久,于是决定认真比较一次。为了公平,他没有写玩具项目,而是把自己已有的 Rust 库 jsonpath-rust(RFC 9535 规定的 JSON 查询语言 JSONPath 的实现)移植到 Zig,做成 zig-jsonpath。他在开头就声明:这是他的第一个 Zig 项目,很多观察在老 Zig 用户看来可能很幼稚,很多决定更多是被 Rust 习惯而非 Zig 惯用法所塑造。

IDE 支持几乎为零,反而带来了意外收获。 他此前用 RustRover 和各种 JetBrains 产品,而 Zig 除了语法高亮和基础补全几乎什么都没有。这逼他回到命令行,而这成了整个体验里最有意思的部分之一——他发现自己早已忘了单纯依赖 CLI 工具可以多么直接。build.zig 处理这类事情出奇地轻松,他最终配出了 zig build test(全部测试)、zig build test -Dfilter=...(单个测试)、-Ddebug-query=true(带调试输出)、zig build compliance(合规测试套件)、zig build check(单测加合规)这样一套命令。Zig 间接引发了一连串连锁反应:他从完整 IDE 转向了 helix + alacritty + zellij 的组合。

扁平的目录结构。 在 Rust 里他总是花不少时间在”文件大小”和”目录深度”之间反复权衡。Zig 也允许嵌套,但并不鼓励——嵌套会带来一点导入上的摩擦,于是问题变成了”何必呢?”。把相关的东西塞进一个大文件,再按区块浏览,反而有”所有东西都在一处”的真实好处。需要配套文件时,他就在旁边建一个 model_<companion> 文件了事。Rust 版的 src/ 下有 parser、query 两个子目录共近 20 个文件,而 Zig 版只有 root.zig、parser.zig、model.zig、model_query.zig、query.zig 五个文件。他承认这不一定能扩展到大型项目,但”需要层级的阈值比我预期的高得多”,这也让他反思:自己组织文件是因为项目真的需要,还是出于习惯?”如果你第一天就忍不住要建文件夹,也许项目比你感觉的要小。”

测试。 Zig 同样支持在同一文件里内联写测试,但在他已经采用的扁平结构下,要么每个模型配一个 model_test 文件、要么把测试塞进模型文件,两者都显得杂乱。他选了后者,并在 build.zig 里显式配置。整体上他觉得在 Rust 里写测试更容易,但额外的摩擦主要来自 Zig 的手动内存管理,而非测试基础设施本身。

失去函数式范式。 他的 Rust 库重度依赖 FP 惯用法:通过组合子做单子式错误控制、带 map/flat_map/reduce 的 Data<T> 类型、纯的不可变变换、用迭代器组合子代替循环、闭包做局部抽象、声明宏构成的小型 DSL、和类型与积类型。到了 Zig,两者在和类型和递归上还算接近——不过 Rust 用 trait 和 match 直接分派,Zig 则是通过 @typeInfo、@hasDecl 做编译期”鸭子类型”检查方法是否存在。但语言很快就逼你偏离函数式风格:你要直接和分配器打交道,而纯函数式意味着不断构造新结构,这要么在内存上昂贵,要么在避免它的手工簿记上昂贵。于是 Rust 里返回新 Data 的 flat_map 在 Zig 里变成了原地覆写游标、直接从列表里删元素;Rust 的 map(...).reduce(State::reduce) 变成了 Zig 的 iter.fork() 深拷贝游标状态再 defer deinit() 丢掉;Rust 一行 iter().enumerate().filter().map().collect() 变成了 Zig 的 while 循环。他的主观结论是:这反映了两门语言的设计目标,是合理的权衡,但”我觉得最终的 Zig 代码比 Rust 版本更难读”。

分配器无处不在。 几乎每个函数都接受一个分配器,每个结构体都持有一个。这是显式的,接受了这个入场费后也不难跟踪,毕竟这是语言的标志性特征。但实践中很繁琐:你必须严格遵守 init/deinit 约定,调用栈一长这种纪律就开始动摇。比起 C 里的静默段错误是明显进步,但对 Rust 程序员来说,你仍然得亲手执行规则:分配、处理失败路径、决定谁负责 deinit,每一次都要。好在 Zig 的测试分配器(std.testing.allocator)和 FailingAllocator 能帮忙,不过你仍得自己写出能覆盖失败路径的测试用例。他列出了四个最痛的案例,每个都和 Rust 对比:

  1. 忘记 deinit 导致泄漏:测试分配器报 MemoryLeakDetected,修法是 init 后立刻 defer iter.deinit()。Rust 的 Drop 在作用域结束时自动运行,这类 bug 根本不存在(虽然 Rc 循环引用、Box::leak 仍可能泄漏)。
  2. 错误路径上跳过 deinit:用 FailingAllocator{ .fail_index = 1 } 让第二次 append 失败即可暴露,修法是 errdefer iter.deinit()。Rust 里 ? 提前返回时 Drop 同样无条件触发,彻底消除。
  3. 两次 deinit 导致内存损坏:一个跨三层函数的所有权 bug——中间层把结果浅拷贝进缓存后又 defer result.deinit(),外层清理缓存时再释放一次,测试分配器报 DoubleFree 并指出两处释放点。修法是只允许一层拥有该值。Rust 里 cache.push(result) 会把 result move 走,这种形状根本编译不过。
  4. move 进结构体失败导致孤儿分配:dupe 出字符串后 append 到数组,若数组扩容失败,那个字符串就成了孤儿,而且 iter.deinit() 照常运行也看不到它。修法是在 dupe 后加 errdefer free。Rust 的 Vec::push 要么成功要么在 OOM 时 abort,标准 API 里没有”已分配但未链接”的中间态。

库与核心 API。 生态还很年轻,库稀缺,连正则这样的基础设施都不成熟——Zig 可用的正则引擎 mvzr 不支持 Unicode 属性转义(\p{...}),这在实现 RFC 9535 的过滤函数时直接成了缺口。标准库 API 也在版本间变化。

总体印象:Zig 与 Rust 不同,但留下了真正的好印象——直接、现代、飞快,他相信它有潜力成为 C 真正的继任者;但它还年轻,语言形态本身有些地方显得未完成,随着成熟会加入更多便利特性和语法糖。文末有一行免责声明:全文的行文风格和错误处理部分借助 AI 做过整理。

HN 评论精华

约 350 条评论,281 分。讨论分成几条主线:一大块是”这篇文章是不是 AI 写的”之争;其次是关于分配器执念、Zig comptime 与 Rust const 求值的技术辩论;还有 pron 与多位 Rust 用户关于”Rust 是否真的解决了内存管理”的超长交锋,以及文中 Rust 迭代器链与 Zig 循环到底谁更易读的吐槽。

AI 写作之争占据了相当篇幅。 diek 开门见山:”我知道作者在底部声明了用 AI 做风格整理,但每天在工作中被这种东西淹没,我的大脑现在会主动排斥散文里的 Claude 腔。”rtpg 认为不只是风格整理,”整篇都是这个味道……为什么大家不直接发一份简短的要点清单?直接把你喂给 AI 的东西贴出来就行”。bbg2401 说得更根本:”当主题是你个人的主观体验、只有你这个人才能表达时,我看不出让 LLM 生成文章有什么意义。”brilee 列出了证据:”It’s young, and it shows”、”holds up”、”not a toy, but not a sprawling project either”、”And that’s the trap”,以及 Ctrl+F 搜 “real” 出现 6 次、”genuine” 出现 4 次。abound 的判断比较细:开头和结尾大概是人写的,读起来也不错,但内存泄漏案例那一段的行文和代码注释”Claude 味非常浓”。genxy 贴出了 Pangram 检测结果:61% AI 生成、12% AI 辅助。kbelder 的一句话颇为犀利:”我见过很多人类写的破折号、牵强的比喻和’不是 X 而是 Y’句式,但我从没见过人类说代码的’shape’。”12_throw_away 则给出了一个意外正面的角度:为了不被当成 AI,他现在更留意自己文字里的陈词滥调和填充语,删掉它们、直白地说话,几乎总是读起来更好。comex 补充说这是近期 Claude 模型的典型口癖,各家实验室正在训练去除这些 AI 写作痕迹(比如 Anthropic 提到 Fable 5.1 的写作更自然),所以这个特征大概会像破折号一样很快过时。

“分配器执念”是本帖最热的技术话题。 tialaramex 开了头:”回望 2020 年代,我们会被这种分配器执念震惊。”他指出所有 Handmade 系的”C 继任者”语言——Zig、Odin、C3、Jai——都有这种执念,某些玩具问题上巧妙的分配器技巧能带来巨大性能提升(比如游戏里每帧丢弃一个 arena),但很多真实软件没那么简单,它只是资深开发者工具箱里的千分之一,不配占据头条地位。pton_xd 反驳说 AAA 游戏大量使用分配器,而它们是性能要求最高的软件之一。游戏开发者 simonask 给出了一线视角:99% 的自定义分配器用例是帧内临时分配,其中 95% 其实用一个每帧清空的 std::vector 摊销分配就能更轻松地解决;更复杂的数据结构(比如哈希表)本来就是为了跨帧摊销查找成本,生命周期长于一帧。boomlinde 指出 Zig 并不偏好任何一种策略:要通用堆分配有分配器、要按请求丢弃的 arena 有分配器、要固定缓冲区也有分配器,重点是标准库不做假设。tialaramex 又用一个生动的例子质疑 arena 在服务器上的适用性:给每个 HTTP 请求一个预先定好大小的 10MB arena,检查 CSS 文件的请求绰绰有余,但修改员工假期的 SQL 事务把内存吃光后,排队给两位经理发邮件的”临时”分配失败,整个请求回滚,”500 错误,重写你的 Odin 程序吧”。zbentley 回应说按工作负载池预分配不同大小的 arena、再对大数据操作回退到全局分配器是常见做法,但如果数据在各 arena 间标记和搬运的簿记变得繁重,”就该停下来想想,你是不是倒着走进了一个蹩脚的分代 GC”。

comptime 与 Rust const 的哲学差异。 weinzierl 指出 Rust 对编译期特性有更高的追求:无论代码何时运行,结果都必须一致。这让任务难得多,本质上无法和 Zig 的 comptime 直接比较。被追问例子时,他说 sin(x) 在编译期和运行期可能得到不同结果,所以 Rust 编译期里根本没有 sin。dnautics 和 vlovich123 解释说 Zig comptime 使用软浮点,因此在不同构建机器上有架构无关的确定性,但结果可能与目标机器上的运行时不同。weinzierl 反复强调”确定性”和”宿主/目标一致性”是两个不同的属性,他指的是后者。kibwen 写了一段很有分量的澄清:Rust 类型系统唯一的硬性要求是同一常量在不同编译单元中求值结果一致;除此之外,Rust 力求 const 函数在编译期和运行期结果相同,是因为它希望保留”即使用户没要求也可以自作主张在编译期求值作为优化”的权利;唯一的例外是 NaN 的位模式。相比之下 Zig 甚至有 @inComptime 内建函数,并不排斥函数结果依赖于是否在编译期求值。

pron 的超长论证是本帖最有思想含量的部分。 plqbfbv 问”我不懂 Zig 的热度在哪?Rust 已经在编译期、无 GC 地基本解决了内存管理”。pron(自称做了 25 年低层编程,在一门主流语言上工作)给出了两个理由:第一,Zig 把部分求值(comptime)作为一个统一的机制,取代了宏、模板/泛型、constexpr 等一堆机制,整个语言围绕它组织,”无论你喜不喜欢,这个设计是革命性的”;第二,”基本解决”这个词承担了太多——高效的专用数据结构通常需要 unsafe,而且 Rust 仍然有 C++ 困扰了几十年的问题:随着程序多年演化,东西会向依赖 malloc/free 的通用机制漂移,程序越来越慢。移动式 GC 就是为了解决这个问题,而低层语言要与之竞争只能靠 arena,Zig 是第一个让 arena 几乎变得用户友好、并有望足够可组合以经受程序演化的语言。treyd 反驳说他用了 10 年 Rust 从没遇到过这种问题,并引用 RustBelt 的形式化证明说明 unsafe 可以被封装。pron 进一步阐述他不喜欢 C++/Rust 那种把高层和低层塞进同一门语言的路线:”有人认为这是两全其美,而我这样的人认为是两头都糟”,他宁愿高层用一门简单的高性能高层语言,低层用一门简单的低层语言。他还举了个例子:一位同事发现 Rust 缓存库 Moka 在同等延迟和内存下吞吐量只有 Java Caffeine 的一半,超过 40% 的 CPU 花在了基于 epoch 的内存回收上。slopinthebag 指责他”为了论证不惜撒谎”,双方为此又交锋了几轮。

关于 Zig 为何比 D 更受关注,pron 的解释是 Zig 提出了一种连贯的新设计,而 D 是很多好点子的集合,”iPhone 的新颖之处不在于它有触摸屏,而在于它几乎没有别的东西”。lerno(C3 作者)则认为 Zig 的营销能力很强、敢说大话,即便”比 C 快”“无色 async”这些说法并不总能兑现。

“更易读”的说法遭到嘲讽。 文中比较了一行 Rust 迭代器链和一段 Zig while 循环后说 Zig 代码更难读,AlienRobot 说”你得是个特别的人才会觉得 Rust 更可读”。sgarland 说正在学 Rust,这种风格是他最大的不满:”我感觉自己在被 PUA。拥护者称赞它的可读性和易用性,然而……不。它看起来很疯狂。简单的循环对任何写过程序的人都一目了然。”waffletower 也说看了上面的代码对比,这句结论”简直笑死”。himata4113 指出那段 Rust 写法本身就不好:enumerate 算出的下标其实没用上,精简后可读性会好很多。csense 则指出”可变 vs 不可变单子是核心差异”的说法不成立:在 Zig 里也可以给 flat_map 传一个分配器实现不可变结构,用可变是程序员的选择。bunderbunder 为作者辩护:作者明说了纯函数式在 Zig 里不划算,而且他尊重作者试图理解”Zig 自己想被怎样使用”,而不是拿 Rust 的价值观去评价 Zig。tialaramex 补充了一个洞察:Rust 表面上是不可变操作,但底层实现(比如 FlatMap 实现了 InPlaceIterable)可能照样是原地修改,”你看到的是水面上优雅前行的天鹅,水下是疯狂的划动”。

关于工具链与内存安全的其他观点。 computerfriend 指出 Zig 其实有语言服务器 ZLS,”大多数 LSP 功能都支持”。但 cztomsik(维护三个 Zig 库、有一个 Zig 项目在生产环境)说半年前的一次破坏性变更后,ZLS 只对标准库和本地代码有效、对依赖失效,”Zig 是好语言,但眼下开发体验是真正的噩梦”,他也在重新评估选择:LLM 擅长生成胶水代码、不知疲倦地排查问题,于是 Zig 的很多优势变得不再重要;而且 Zig 没有接口,comptime 鸭子类型”一直好用,直到它不好用”。Panzerschrek 认为没有自动析构函数是 Zig、Odin 这类语言”最大的错误”;delamon 反驳说析构函数和 arena 分配器配合得不好,delifue 则指出 Rust 的 bumpalo 可以很好地配合 drop。

LLM 时代的失落感。 karmakurtisaani 写道,他怀念前 LLM 时代热爱阅读编程语言文章的日子,”现在一切都显得毫无意义,就像背诵心算规则”。alembic_fumes 引起了很多共鸣:多年来 Rust 一直是他的业余爱好语言,一直没机会全职用它;现在他终于有足够的技术选型话语权,”写代码的却是 AI 而不是我……就好像找到那份’梦想工作’一直有一个真实的截止日期,而我错过了”。frje1400 则持乐观态度:以 Java 的结构化并发为例,概念比以往更重要——agent 多年内不会主动采用新范式,除非你刻意引导,而要引导就得审查,要审查就得理解。