Zig 编程语言社区近日迎来了一个重要的里程碑。经过 8 个月的密集开发,来自 244 位贡献者的 1183 次提交,Zig 0.16.0 正式亮相。这一版本不仅带来了大量语言层面的改进,更以一场深刻的标准库重构为核心——将 I/O 操作彻底接口化。对于 Zig 这门以「没有隐藏的控制流」为设计哲学的语言来说,这次变革意义深远,也标志着这门语言在走向 1.0 稳定版本的道路上迈出了关键一步。
I/O 接口化:最大的破坏性变更,也是最重要的架构决策
如果用一句话概括 Zig 0.16.0 最核心的变化,那就是:所有可能阻塞控制流或引入不确定性的操作,现在都必须通过一个 Io 实例来完成。文件系统读写、网络请求、随机数生成、进程管理、时间获取——凡是涉及与外部世界交互的操作,统统纳入这一接口体系之下。
这个设计的出发点并不难理解。在传统语言中,I/O 操作往往是隐式完成的:你调用一个函数,它在内部悄悄打开文件、发出网络请求,而调用者对这些副作用毫不知情。Zig 的哲学一贯反对这种隐藏。将 Io 作为显式参数传递,意味着任何需要执行 I/O 的函数,其签名本身就会告诉你「这个函数会产生副作用」。
目前,Zig 随 0.16.0 提供了两套 Io 实现。Io.Threaded 是基于线程的实现,行为直接且可预期——文件系统操作会直接调用系统的 read、write、open、close 等调用,当升级旧代码时,使用这套实现可以获得与 0.15.x 近乎等价的行为,且已经功能完整、经过充分测试。另一套是 Io.Evented,目前仍处于实验阶段,基于用户态栈切换(即 M:N 线程,也称绿色线程或有栈协程)实现,旗下已有基于 Linux io_uring 的概念验证后端、基于 kqueue 的概念验证,以及基于苹果 Grand Central Dispatch 的实现。
这一架构的美妙之处在于,同一套应用代码可以在不同的 Io 实现下运行。用 Io.Threaded 调试,用 Io.Evented 获取异步 I/O 的性能优势,代码本身无需修改。这是一种正交性极强的设计,与 Zig 的 Allocator 接口如出一辙——后者早已被开发者们证明是一个非常成功的抽象。
伴随 I/O 接口化而来的,是一套完整的并发原语。Future 提供基于函数的任务级抽象,io.async 可以创建一个异步任务,io.concurrent 则强制要求并发执行(但可能分配内存并失败)。Group 适合管理大量共享生命周期的任务,提供 O(1) 的任务生成开销。对于更底层的需求,Batch 提供基于 Operation 的并发机制,性能更高但使用门槛也更高。所有这些原语都原生支持取消操作——当一组并发任务中某个失败时,可以请求取消其余任务,error.Canceled 被内置进了所有可取消 I/O 操作的错误集合中,语言层面对这一模式提供了完整支撑。
值得一提的是,这次重构还顺带解决了 Windows 平台上的一个长期痛点:所有网络 API 现在通过直接访问 AFD(辅助功能驱动程序)实现,彻底绕开了 ws2_32.dll,修复了若干历史 bug,也让取消操作和批处理在 Windows 上得到了高效支持。
「多汁的 main 函数」:从入口点开始的人体工程学革命
与 I/O 接口化紧密相关的,是一个被开发团队戏称为「Juicy Main」(多汁的 main)的新特性。从 0.16.0 起,Zig 程序的 main 函数可以接受一个 std.process.Init 类型的参数,从而开箱即用地获得一套预初始化好的实用工具:进程级 arena 分配器、通用堆分配器(Debug 模式下自动开启泄漏检测)、默认 Io 实例、环境变量映射,以及「preopens」(主要用于 WASI 平台的预打开文件描述符)。
这个改变看似微小,实则意义重大。过去,Zig 程序员需要手动设置分配器、手动获取环境变量、手动初始化 I/O,而且每个步骤都有细节需要注意。现在,一个 init: std.process.Init 参数就把这些全部搞定。更重要的是,环境变量从全局状态变成了局部参数——这直接解决了 Zig 标准库中一个存在已久的设计缺陷:std.os.environ 在不链接 libc 的库中根本无法被填充。函数需要访问环境变量,现在应当接受 *const process.Environ.Map 参数,而不是隐式依赖全局状态。
语言层面:精雕细琢,细节魔鬼
除了标准库的大改动,0.16.0 在语言层面也带来了大量值得关注的变化,其中许多都与一个更大目标相关:改善 Zig 在游戏开发领域的人体工程学。
@Type 内置函数被彻底移除,取而代之的是一批更具针对性的独立类型创建内置函数:@Int、@Struct、@Union、@Enum、@Pointer、@Fn、@Tuple、@EnumLiteral。旧版的 @Type 虽然概念上是 @typeInfo 的对称操作,但实际使用时极为繁琐,需要填写大量样板字段。新版的 @Int(.unsigned, 10) 和旧版的 @Type(.{ .int = .{ .signedness = .unsigned, .bits = 10 } }) 相比,可读性提升了一个数量级。同时,std.meta.Int、std.meta.Tuple 等常用辅助函数也随之宣告废弃。
小整数类型向浮点类型的隐式转换规则得到了放宽。只要整数类型的所有可能值都能无损地表示为对应浮点类型(通过比较整数位数和浮点尾数的精度位数来判断),就允许隐式转换,无需显式调用 @floatFromInt。与此同时,@floor、@ceil、@round、@trunc 现在可以直接将浮点值转换为整数,@intFromFloat 因与 @trunc 重复而被标记为废弃。一元浮点内置函数(如 @sqrt、@sin、@cos 等)现在也会正确地向前传递结果类型,修复了一些令人恼火的类型推断问题。
packed struct 和 packed union 现在可以作为 switch 分支项使用,基于其底层整数值进行比较。同时,打包联合类型获得了显式指定底层整数类型的能力,语法与 packed struct(T) 一致。与此相关的是一条新的限制:在 extern 上下文中,具有隐式底层整数类型的 enum、packed struct 和 packed union 不再合法——调用者必须显式指定整数类型,以避免由于 u8 和 i8 在某些 ABI 中可能具有不同行为而导致的隐患。
禁止在 packed struct 和 packed union 中使用指针类型的新规则也值得关注。原因是大多数二进制格式无法表示非字节对齐的指针常量,而且某些目标平台的指针除地址位外还含有元数据位,将指针打包进整数在这些平台上根本没有意义。需要在打包类型中存储指针的代码,应改用 usize 并在需要时用 @ptrFromInt/@intFromPtr 转换。
在编译器内部,一次重大的类型解析机制重构悄然发生。新机制采用「懒字段分析」——只有当真正需要一个类型的大小或其字段类型时,才会触发完整解析。这意味着可以把类型当作命名空间使用而不引入不必要的代码生成,甚至可以在不解析 T 的情况下使用 *T。这次重构的另一个重要成果是简化了依赖循环的判定规则,使得编译器能够给出更清晰的依赖循环错误信息,明确指出循环链条中的每一个环节。
增量编译:终于变得可用
增量编译是 Zig 长期以来的一个重要但尚未成熟的特性。0.16.0 在这方面取得了实质性突破。类型解析机制的重构使得编译器内部依赖图不再包含环路(除了真正的语言级依赖循环),从而大幅减少了「过度重析」——即编译器在增量更新时重新编译了本不需要重新编译的代码。
数据表现相当亮眼:在 Zig 编译器自身上进行单行改动,使用新 ELF 链接器时,增量更新从 194ms 降至 65ms,缩短了 66%。新 ELF 链接器的性能已经好到「几乎与完全跳过链接步骤(62ms)一样快」,开发团队因此表示,不再有必要提供跳过代码生成的 -Dno-bin 构建选项。
增量编译目前在 0.16.0 中默认仍处于关闭状态,但开发团队鼓励用户主动启用它,命令仅需 zig build -fincremental --watch。即便存在一些已知 bug,单是「近乎即时的编译错误反馈」这一点,就已经能让不少开发者受益匪浅。
工具链与生态:稳步推进
在工具链层面,Zig 0.16.0 升级至 LLVM 21.1.0,但同时也遭遇了一个 LLVM 回归问题——循环向量化在某些配置下会导致编译器自身被错误编译,因此不得不临时全局禁用循环向量化。这一性能退步预计将影响 0.16.x 和 0.17.x,在升级到修复了该 bug 的 LLVM 版本的 0.18.x 中才会解决。
C 语言翻译的实现切换到了基于 arocc 和独立 translate-c 包的方案,彻底告别了对 libclang 的依赖,移除了编译器源码树中剩余的 5940 行 C++ 代码。这是 Zig 长期目标「从库依赖 LLVM 转向进程依赖 Clang」的重要一步。
标准库在压缩方面新增了 deflate 压缩实现(此前只有解压缩),并在与 zlib 的基准测试中表现出色:在默认压缩级别下,Wall time 比 zlib 快 9.7%,分支预测失败次数减少 53.7%,尽管压缩率略低 1%。密码学方面新增了对 AES-SIV、AES-GCM-SIV 以及 NIST 最新轻量级密码学标准 Ascon 系列(Ascon-AEAD、Ascon-Hash、Ascon-CHash)的支持。
包管理系统引入了 --fork 标志,允许开发者在本地临时将依赖树中的某个包替换为本地路径。这对于同时开发多个相互依赖的库来说极为便利,而且作为命令行标志,其临时性也得到了天然保障。依赖包现在被下载到项目本地的 zig-pkg 目录,而非全局缓存,方便开发者直接查看和修改依赖的源代码。
路线图:下一步,完善语言本身
对于 0.17.0,Zig 官方给出了明确且克制的目标:这将是一个短周期版本,主要任务是升级至 LLVM 22,并完成构建运行器与构建脚本的进程分离。
更宏大的工作留给后续版本:完成语言规范的编写与稳定、完善 aarch64 原生后端并将其设为 Debug 模式默认、增强链接器实现以消除对 LLD 的依赖、以及提升内置模糊测试器至可与 AFL 等工具竞争的水准。
Zig 0.16.0 是一个不回避破坏性变更的版本。大量 API 被重命名、移动或删除,现有代码库需要不同程度的迁移工作。但这种阵痛是 Zig 走向 1.0 必须经历的过程——只有在稳定之前把不够好的东西改掉,才能在稳定之后不后悔。从 I/O 接口化的深度重构,到增量编译的实质进展,这个版本展现出的,是一个正在认真雕琢自己的语言的样子。