继探讨“为何在系统编程中选择 Zig 而非 Rust”后,一个自然的问题浮现:C 语言呢?Rust 的讨论焦点在于安全性与代码冗余之间的权衡,而 Zig 与 C 的目标更为一致:两者均为无垃圾回收的小型语言,紧密贴合硬件底层。本文并非主张用 Zig 完全取代 C,而是聚焦于 C 语言的痛点,以及 Zig 如何针对性地解决这些问题。文中代码均基于搭载 Zig 0.17 和 Clang 的 Mac 环境编译。

C 语言的基石地位

C 是 UNIX 世界的通用语。内核、C 标准库及绝大多数系统库均由 C 编写,其 API 原型构成了系统调用的文档基础。C 语言具备极强的向后兼容性,90 年代的代码至今仍可编译,且几乎覆盖所有 CPU 架构。其语法精简,Kernighan 和 Ritchie 的《C 程序设计语言》即可涵盖全貌。Zig 并不试图颠覆这一生态,而是通过 C ABI 直接调用 C 库。作者强调,C 仍是其最喜爱的编程语言,但出于效率与安全考量,不再用于编写新代码。

告别 goto:优雅的资源清理

C 语言中处理多资源分配失败的经典模式是依赖 goto 进行逆序释放。这种方式导致清理逻辑与分配逻辑分离,且标签顺序需人工维护,极易出错。Zig 引入了 errdefer 机制,允许在分配语句旁定义清理操作,仅在函数返回错误时执行。编译器自动按逆序执行这些操作,无需手动管理标签。

此外,Zig 的 std.testing.FailingAllocator 支持模拟任意次分配失败,使开发者能够轻松测试每一条错误路径,检测内存泄漏。而在 C 语言中,实现同等测试覆盖率通常需要复杂的包装器或外部工具。

切片自带长度:杜绝越界隐患

C 语言将数组退化为指针,长度信息丢失,常引发 off-by-one 错误或缓冲区溢出。Clang 即便开启最高警告级别,也难以察觉此类逻辑漏洞。Zig 的切片(Slice)结构内嵌长度信息,在 debug 和 safe 模式下,越界访问将直接触发 panic 并终止程序,而非读取非法内存。

在实际开发中,Zig 推荐使用 for (a) |x| 迭代语法,从根源上避免索引错误。字符串同样被视为带长度的字节切片,无需依赖 strlen() 遍历查找终止符,消除了因缺失空字符导致的缓冲区错误。

强制错误处理:无法忽略的返回值

C 语言缺乏强制错误检查机制,开发者常忽略 close() 或 fclose() 的返回值,编译器亦保持沉默。Zig 将错误作为返回类型的一部分,若调用者未通过 try、catch 或 if 显式处理错误,编译将无法通过。这种设计迫使开发者在源代码层面直面失败路径,提升了代码的健壮性。

Comptime:取代脆弱的预处理器

C 预处理器基于文本替换,缺乏语义理解,易引发参数多次求值等副作用,且大量 #ifdef 导致代码碎片化。Zig 的 comptime 允许在编译期执行普通 Zig 代码。例如,使用 if (builtin.os.tag == .linux) 替代 #ifdef __linux__,编译器会自动剔除无效分支。泛型数据结构也被实现为编译期函数,享受与运行时代码同等的类型检查。

收敛未定义行为

C 语言中有符号整数溢出属于未定义行为,编译器可能优化掉相关代码或产生不可预测结果。Zig 在 debug 和 safe 模式下对所有整数溢出进行检查,一旦触发即抛出 panic。若需特定行为,开发者可显式使用 +% 进行环绕运算,或 +| 进行饱和运算,赋予控制权的同时消除隐形陷阱。

Zig 亦是 C 编译器

zig cc 提供了兼容 GCC/Clang 的 C 编译能力,并原生支持交叉编译。例如,在 macOS 上可直接生成静态链接的 Linux 可执行文件,无需额外配置工具链。在 debug 模式下,zig cc 默认启用未定义行为 sanitizer,能捕获如整数溢出等潜在错误。通过 build.zig,C 文件可与 Zig 文件混合编译,支持渐进式迁移。针对 C 头文件,Zig 0.17 已移除 @cImport(),推荐改用 zig translate-c 进行转换。

理性的权衡

Zig 并非银弹。它无法防止释放后使用(Use-After-Free)或悬垂指针,且在 fast 和 small 优化模式下会关闭大部分安全检查,回归类似 C 的未定义行为风险。相比之下,C 语言拥有更高的稳定性、更丰富的工具链及广泛的硬件支持。对于仅配备厂商专用 C 编译器的微控制器开发,C 仍是唯一选择。

结语

C 语言不会消失,阅读和理解 C 代码仍是系统编程者的必备技能。但对于新项目,Zig 提供了更优的选择:它保持了贴近机器、无运行时开销的特性,同时通过编译器检查和调试模式,捕获了 C 语言中常被忽视的错误。