为什么在系统编程中选择 Zig 而不是 C
Why Zig over C for Systems Programming

原始链接: https://www.mtsoukalos.eu/2026/11/why-zig-over-c-for-systems-programming/

作者欣赏 C 的普遍性、简洁性和稳定性,但现在更喜欢在新系统代码中使用 Zig。两种语言都小巧,没有垃圾回收器,并且贴近硬件。Zig 改善了常见的 C 痛点:`errdefer` 使资源清理在局部自动按逆序进行,而失败的分配器使每个分配失败路径都可测试。切片携带长度,因此数组越界访问和字符串终止符错误会在调试/安全模式下被捕获。错误是返回类型的一部分,必须处理。编译时代码是普通的 Zig 代码,避免了预处理器文本宏,并支持受检的泛型和条件编译。整数溢出默认检查,并提供显式的环绕/饱和运算符。`zig cc` 可以交叉编译并启用 UB 清理。`build.zig` 可以逐步混合 C 和 Zig。然而,Zig 并不能防止所有内存错误,在快速/小模式下会禁用许多检查,并且稳定性和支持度仍低于 C。C 仍然主导系统库和嵌入式目标。对于新代码,Zig 提供类似 C 的控制力,以及更强的编译时和运行时安全性。

围绕一篇主张在系统编程中 Zig 优于 C 的文章的 Hacker News 讨论。评论者争论现代 C 工具链(sanitizer、nodiscard、切片、defer)能否匹敌 Zig 的优势;其他人认为 Zig 的语言设计与文化使安全实践成为默认且一致的行为。讨论话题包括分配器 API、arena 分配、运算符重载、C++ 子集、生态成熟度与交叉编译。有些人因安全/生态更偏好 Rust 或 C++;其他人则看重 Zig 的显式性与 comptime。顾虑包括 Zig 1.0 前的不稳定性、其 libc 重写中的 bug,以及有限的 IDE/工具链。LLM 也出现在辩论中:一些人声称强类型有助于 AI 生成代码,而另一些人认为 Zig 的显式性有助于审查。总体而言,没有共识;Zig 被视为有前景但不成熟,而 C 依然无处不在且缓慢演进。
相关文章

原文

After I wrote Why Zig Over Rust for Systems Programming, the obvious next question was C. The Rust question was about safety and how much ceremony I was willing to accept for it. The C question is different, because Zig and C want the same things. Both are small languages without a Garbage Collector and both stay close to what the machine does. So this post is not about leaving C for something completely different. It is about the parts of C that cost me time and how Zig deals with them. All the code was compiled on a Mac with Zig 0.17 and clang.

C is the language of UNIX. The kernels, the C libraries and most of the system libraries that every other language calls in the end are written in C and the man pages describe them with C prototypes and C examples. A C program from the 1990s still compiles today and there is a C compiler for almost every CPU that was ever made. The language is small enough that one book, The C Programming Language by Kernighan and Ritchie, covers all of it. Zig itself does not try to replace any of that. It talks to C libraries directly and it uses the C ABI when it needs to. I am not writing this post because I think C is bad; C is my favorite programming language! I am writing it because I do not want to write new code in C any more.

The C code that I like the least is a function that acquires several resources and has to release them in reverse order when something fails halfway. The usual answer, which is also the one the Linux kernel uses, is goto:

struct user *user_new(const char *name, size_t n_groups)
{
    struct user *u = malloc(sizeof *u);
    if (u == NULL)
        goto fail;
    u->name = strdup(name);
    if (u->name == NULL)
        goto fail_user;
    u->groups = calloc(n_groups, sizeof *u->groups);
    if (u->groups == NULL)
        goto fail_name;
    return u;

fail_name:
    free(u->name);
fail_user:
    free(u);
fail:
    return NULL;
}

It works, but the cleanup is far away from the allocation and the order of the labels has to match the order of the allocations by hand. The same function in Zig looks like this:

fn newUser(allocator: std.mem.Allocator, name: []const u8, n_groups: usize) !*User {
    const u = try allocator.create(User);
    errdefer allocator.destroy(u);
    u.name = try allocator.dupe(u8, name);
    errdefer allocator.free(u.name);
    u.groups = try allocator.alloc(u32, n_groups);
    return u;
}

Each errdefer sits right below the allocation it undoes and runs only if the function returns an error. Zig runs them in reverse order, so there are no labels to keep in order. What I like even more is that I can test every failure path. The std.testing.FailingAllocator type fails on the first, second or third allocation, depending on the value of fail_index. The testing allocator below it reports any memory that is not freed:

test "newUser does not leak when an allocation fails" {
    for (0..3) |i| {
        var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = i });
        try std.testing.expectError(error.OutOfMemory, newUser(failing.allocator(), "mihalis", 4));
    }
}
$ zig test user.zig
1/1 user.test.newUser does not leak when an allocation fails...OK
All 1 tests passed.

Testing the same three paths in the C version needs a wrapper around malloc() or an external tool.

In C an array that goes to a function becomes a pointer and the length travels separately, if at all. The following C function has an off-by-one error, <= instead of <:

int sum(const int *a, size_t n)
{
    int total = 0;
    for (size_t i = 0; i <= n; i++)
        total += a[i];
    return total;
}

With an array of {1, 2, 3} it prints sum = 7 on my Mac and exits with 0. The extra 1 came from whatever was in memory after the array. Neither clang with -Wall -Wextra nor the program itself complains. In Zig a slice carries its length, so the same mistake stops the program in the debug and safe modes:

fn sum(a: []const i32) i32 {
    var total: i32 = 0;
    for (0..a.len + 1) |i| total += a[i];
    return total;
}
$ zig run sum.zig
thread 1278459 panic: index out of bounds: index 3, len 3
sum.zig:5:38: 0x1023c5b4b in sum (sum)
    for (0..a.len + 1) |i| total += a[i];
                                     ^

In real Zig code you would write for (a) |x| and the mistake cannot happen at all. Strings work the same way. A Zig string is a slice of bytes with a length, so there is no strlen() call that walks the memory looking for a 0 byte and no buffer that is one byte short because somebody forgot the terminator.

In C a function reports an error by returning -1 or NULL and setting errno, or by returning an error code, or in some other way that the man page describes. Nothing forces you to check. A lot of C code calls close() and fclose() without looking at the return value and the compiler is fine with it. In Zig an error is part of the return type and the compiler refuses to compile a call whose error you ignore. You have to use try, catch or if. The failure path is in the source code, right next to the call, which is also what I liked about Go.

comptime instead of the preprocessor