Zig:一门拒绝隐藏行为的系统编程语言

C 语言从 1972 年诞生至今,依然是系统编程的主导语言。它的优势——接近硬件、可预测的性能、极简的运行时——至今没有完全被取代。但它的问题也同样明显:未定义行为、缓冲区溢出、空指针、宏系统的不可预测性。

Rust 试图用所有权系统从根本上解决内存安全问题,代价是较高的学习曲线和编译器的强约束。

Zig 走了另一条路。它的目标不是"比 C 更安全",而是"比 C 更可预测"。Zig 的核心设计原则只有一条:代码做的事,应该和代码看起来做的事一模一样。没有隐藏的控制流,没有隐藏的内存分配,没有隐藏的操作符重载

从这个原则出发,Zig 的每一个设计决策都有了明确的解释。

一、定位:介于 C 和 Rust 之间

Zig 不是 Rust 的竞争对手,它的定位更接近"现代 C"。两者的取舍不同:

  • Rust:通过编译器强制保证内存安全,代价是所有权系统的学习成本和编写约束
  • Zig:通过清晰的语言设计减少意外行为,给程序员更多控制权,代价是程序员需要自己管理内存

Zig 不追求消灭内存错误,但它让内存操作变得显式和可追踪。在需要最高性能控制权的场景(操作系统内核、游戏引擎、嵌入式、数据库内核)里,Zig 是一个比 C 更现代、比 Rust 更轻量的选项。

Zig 的另一个使用场景常被忽视:它可以作为 C/C++ 项目的构建系统和交叉编译器。只要安装了 Zig,就内置了一个能针对几乎所有主流平台交叉编译的 C 编译器。这让它成为 CI/CD 流水线里非常实用的工具。

二、错误处理:不抛异常,用返回值

Zig 没有异常(Exception)。没有 try/catch,没有可以悄悄向上传播的错误。

替代方案是错误联合类型(Error Union)。函数签名 !T 表示"返回 T 或者一个错误":

// 这个函数可能失败,返回类型是 !u64
fn readFileSize(path: []const u8) !u64 {
    const file = try std.fs.openFileAbsolute(path, .{});
    defer file.close();
    const stat = try file.stat();
    return stat.size;
}

// 调用方必须处理错误,不能忽略
const size = readFileSize("/etc/passwd") catch |err| {
    std.debug.print("error: {}\n", .{err});
    return err;
};

try 在这里是一个语法糖:如果函数返回错误,立刻把这个错误返回给调用方。它和 Go 的 if err != nil { return err } 等价,但更简洁。

关键在于:错误是返回值的一部分,不是控制流机制。你在函数签名上就能看到它可能失败。调用方必须明确处理(catch)或者向上传播(try),不能静默忽略。没有任何东西会在你不知情的情况下展开调用栈。

三、显式分配器:内存分配不是免费的

在 C 里,你调用 malloc,它从全局堆里分配内存。在很多高级语言里,内存分配完全隐藏——你创建一个对象,运行时帮你处理一切。

Zig 的设计是:任何需要分配内存的函数,必须显式接受一个 Allocator 参数

// 标准库的 ArrayList 需要分配器
var list = std.ArrayList(i32).init(allocator);
defer list.deinit();

try list.append(1);
try list.append(2);

这个设计看起来麻烦,但带来了几个重要好处:

可以为不同场景选择不同的分配策略。全局堆分配(std.heap.GeneralPurposeAllocator)、竞技场分配(Arena Allocator,所有分配在一次 deinit 里全部释放)、固定缓冲区分配(不做任何系统调用,在栈上的固定区域分配),可以根据需要自由切换,而不需要修改使用这些数据结构的代码。

测试变得更容易。在测试里传入 std.testing.allocator,它会在测试结束时检测内存泄漏并报告。不需要任何 Valgrind 或 AddressSanitizer。

嵌入式和无操作系统环境友好。在没有操作系统的环境里,你根本无法使用 malloc。Zig 的显式分配器模型让你能精确控制内存来源。

四、comptime:比宏更强大,比模板更简单

这是 Zig 最独特、也最有影响力的特性。

C 用宏做编译期代码生成,但宏不是真正的代码——它是文本替换,无法利用类型系统,调试困难。C++ 用模板做泛型,但模板语法复杂,错误信息难以理解,编译速度慢。

Zig 用 comptime在编译期执行普通的 Zig 代码

// 泛型函数:T 是一个 comptime 参数,类型本身是一等公民
fn max(comptime T: type, a: T, b: T) T {
    return if (a > b) a else b;
}

// 调用时传入类型,编译器在编译期执行这个函数并特化
const result = max(i32, 10, 20);  // 结果是 20

type 在 Zig 里是一个普通的值,可以作为参数传递、作为返回值返回、存在变量里。泛型函数本质上就是接受 type 类型参数的普通函数,编译器在调用时把具体类型替换进去,生成特化版本。

comptime 的威力不止于此。你可以在编译期做任意计算:

// 编译期计算 Fibonacci,运行时没有任何计算开销
fn fibonacci(comptime n: u32) u32 {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

// 这个值在编译期就确定了,运行时直接是常量 55
const fib10 = comptime fibonacci(10);

comptime 还可以用来做条件编译——在编译期根据目标平台、编译选项等选择不同的代码路径,不需要任何预处理器指令。

更进一步,comptime 可以用来实现在其他语言里需要反射或元编程才能实现的功能:

// 在编译期遍历结构体的字段,生成序列化代码
fn serialize(writer: anytype, value: anytype) !void {
    const T = @TypeOf(value);
    // @typeInfo 在编译期返回类型的元信息
    inline for (@typeInfo(T).Struct.fields) |field| {
        try writer.print("{s}: {any}\n", .{ field.name, @field(value, field.name) });
    }
}

这段代码在编译期展开,为结构体的每个字段生成序列化逻辑。效果相当于 Rust 的 derive macro,但不需要任何宏系统,只是普通的 Zig 代码。

五、可选类型:没有空指针崩溃

在 Zig 里,普通指针不能是 null。如果你想表示"这个值可能不存在",需要用可选类型(Optional),写作 ?T

// 这个函数可能找不到元素,返回 ?i32
fn findFirst(slice: []i32, target: i32) ?i32 {
    for (slice) |item| {
        if (item == target) return item;
    }
    return null;
}

// 调用方必须处理"没找到"的情况
const result = findFirst(&[_]i32{1, 2, 3}, 2);
if (result) |value| {
    std.debug.print("found: {}\n", .{value});
} else {
    std.debug.print("not found\n", .{});
}

// 或者用 orelse 提供默认值
const value = findFirst(&[_]i32{1, 2, 3}, 99) orelse 0;

和 Rust 的 Option<T>、Swift 的 T? 是同样的思路。编译器强制你在使用可选值之前处理"为空"的情况,null 解引用崩溃在编译期就被拦截了。

六、C 互操作:直接导入 C 头文件

Zig 的 C 互操作是其最实用的特性之一:

const c = @cImport({
    @cInclude("stdio.h");
    @cInclude("stdlib.h");
});

pub fn main() void {
    // 直接调用 C 标准库函数
    _ = c.printf("Hello from C: %d\n", @as(c_int, 42));
}

@cImport 在编译期解析 C 头文件,把 C 的类型和函数签名转换成 Zig 类型。不需要手写 FFI 绑定,不需要 bindgen 工具。

反过来也成立——Zig 代码可以被编译成 C 兼容的库,供 C/C++ 代码调用。这让 Zig 非常适合用来逐步替换一个 C 项目的某些模块,而不是全部重写。

Zig 还自带了一个能交叉编译 C 代码的工具链(基于 LLVM 和 musl libc)。安装 Zig 之后,你可以用 zig cc 作为 C 编译器,并轻松编译出针对 Linux、macOS、Windows 和嵌入式平台的二进制,不需要安装任何额外的交叉编译工具链。

七、谁在用 Zig

Bun:一个 JavaScript 运行时,目标是替代 Node.js。Bun 的核心用 Zig 编写,在 HTTP 服务、文件 I/O、包管理器等方面比 Node.js 快数倍。Bun 是 Zig 在生产环境最知名的应用。

TigerBeetle:一个专为金融事务设计的分布式数据库,声称是世界上最快的金融账务数据库。完全用 Zig 编写,极其重视确定性行为和性能。

Ghostty:一个终端模拟器,用 Zig 编写,在渲染性能上有显著优势。

Zig 本身的工具链(编译器、包管理器、构建系统)也在大量项目中被用作 C/C++ 的构建工具,不是因为这些项目在写 Zig 代码,而是因为 Zig 的交叉编译能力。

八、现在适合学习吗

Zig 目前(0.14 版本)还没有发布 1.0。语言规范仍在演化,标准库有不少 API 还在变。如果你用 Zig 写项目,需要做好版本迁移的准备。

但这不代表不值得学。学习 Zig 的价值在于它强迫你直面系统编程里通常被抽象掉的问题:内存分配从哪里来、错误怎么在调用链里传播、编译期和运行期的边界在哪里。这些思考对写任何系统级代码都有益。

如果你:

  • 在写 C/C++,想要更好的错误处理和更现代的语言特性
  • 需要为多个平台交叉编译 C 代码
  • 对 comptime 这类编译期元编程感兴趣
  • 想在不引入 GC 的前提下,用比 Rust 更简单的内存模型写系统代码

Zig 值得认真了解。

九、总结

  • 核心哲学:没有隐藏的控制流、没有隐藏的内存分配、没有隐藏的操作符重载
  • 错误处理:错误联合类型 !Ttry 向上传播,catch 处理,不能静默忽略
  • 显式分配器:所有内存分配需要传入 Allocator,可以自由切换分配策略,测试中自动检测泄漏
  • comptime:在编译期执行普通 Zig 代码,用来实现泛型、条件编译、元编程,不需要任何宏或模板系统
  • 可选类型?T 代替空指针,编译器强制处理 null 情况
  • C 互操作@cImport 直接导入 C 头文件,内置交叉编译工具链
  • 代表用户:Bun(JS 运行时)、TigerBeetle(金融数据库)、大量 C 项目的构建工具链

Zig 不是要取代 Rust,也不是简单地"更好的 C"。它是在系统编程领域里,对"可预测性优先"这个设计哲学的一次认真探索。