Zig 0.16 深度解剖:comptime 元编程、无隐藏控制流与手动内存管理的工程真相
一句话先说清楚:Zig 不是"更好的 C++",它是"把 C 重新想一遍、顺手把宏、模板、隐藏分配、隐藏控制流全部删掉"的系统语言。它赌的不是语法糖,而是可预测性——你写的每一行代码,编译出来大致就是你想象的那个样子。
作为一个写了十几年 C/C++、又被 Rust 借用检查器折磨过的人,我第一次认真读 Zig 代码时的感受是:安静。没有到处飞的运算符重载,没有藏在析构函数里偷偷跑的逻辑,没有把一次简单赋值编译成三层构造/拷贝/移动的黑魔法。这篇文章不吹 Zig,我们从第一性原理把它拆开,看它到底解决了什么、代价是什么、什么时候该用它、什么时候别碰。
本文覆盖:设计哲学 → comptime 元编程内核 → 内存管理与 allocator 模型 → 错误处理 error union → 无隐藏控制流原则 → C 互操作 → 构建系统 → 性能与工程实战 → 冷静的选型建议。全文配大量可运行代码示例。
一、背景:为什么又冒出来一门系统语言
系统编程这块地,长期是 C 和 C++ 的。这些年 Rust 杀进来,用所有权+借用检查器把"内存安全"这件事做成了编译期保证,代价是陡峭的学习曲线和一定的心智负担。
但有一群人——嵌入式工程师、游戏引擎作者、编译器/操作系统开发者——他们的核心诉求其实不是"编译器帮我证明内存安全",而是:
- 我要完全掌控内存:什么时候分配、在哪块 arena 上分配、什么时候释放,我说了算。
- 我要能读懂机器在干什么:不要有隐藏的控制流、隐藏的分配、隐藏的拷贝。
- 我要 C 的可移植性和 ABI 兼容:能无缝调 C 库,也能被 C 调。
- 我要一个不折磨人的编译期元编程:不要 C 的宏地狱,也不要 C++ 模板的编译错误天书。
Zig 就是冲着这四条来的。它由 Andrew Kelley 从 2016 年开始做,语言核心极小,标准库和编译器都在快速演进。到 2026 年,Zig 版本号还在 0.16.x 徘徊——它至今没到 1.0。这一点必须先讲清楚:Zig 现在仍是一门会破坏向后兼容的语言,每次小版本升级你都可能要改代码。这是选型时绕不过去的风险,我们在最后一节会算这笔账。
先看它长什么样。经典的 "Hello, world":
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("Hello, {s}!\n", .{"world"});
}
注意几个信号:
!void是"返回 void,但可能返回错误"——错误是类型系统的一等公民。try是错误传播,不是异常。.{"world"}是匿名结构体字面量,参数以元组形式传入print。@import是编译期函数,不是预处理器指令。
这门语言的第一印象就是:几乎没有关键字魔法,很多"内建能力"都以 @ 开头的编译期函数形式暴露出来。这是理解 Zig 的第一把钥匙。
二、核心哲学:无隐藏控制流、无隐藏分配
Zig 官网把设计原则写得很直白,我把它翻译成工程师能感知的三句话:
2.1 No hidden control flow(无隐藏控制流)
在 C++ 里,下面这行代码可能触发一堆你看不见的逻辑:
a = b + c; // 可能调用 operator+,可能触发隐式类型转换,可能调用拷贝构造
在 Zig 里,如果一个符号后面没有 (,那它就不是函数调用。没有运算符重载,没有属性 getter/setter,没有析构函数在作用域结束时偷偷跑。控制流只在这几种地方发生:函数调用、try、catch、if/while/for/switch、return。
这意味着代码审查的成本骤降。你读一段 Zig 代码,不需要跳到别的文件去确认某个 + 到底重载成了什么。这对大型代码库的可维护性是实打实的收益。
2.2 No hidden allocations(无隐藏分配)
标准库里任何需要堆内存的函数,都显式要求你传入一个 allocator。没有全局 malloc 藏在背后。举例:
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// 想要一个动态数组?必须把 allocator 给它
var list = std.ArrayList(u8).init(allocator);
defer list.deinit();
try list.appendSlice("hello");
try list.append(' ');
try list.appendSlice("zig");
std.debug.print("{s}\n", .{list.items});
}
ArrayList.init(allocator) 把内存来源变成了参数。这带来一个巨大的工程优势:你可以针对不同场景注入不同的分配策略——测试时用检测泄漏的 allocator,热路径上用 arena 一把梭,嵌入式上用固定缓冲区。这一点我们下一节展开,它是 Zig 最被低估的杀手锏。
2.3 编译期优先,运行期只做必须的事
Zig 把"能在编译期做的事全部搬到编译期"。类型是编译期的值,泛型是编译期的函数,配置分支是编译期的 if。运行期不为这些东西付一分钱。这就引出了 Zig 的灵魂:comptime。
三、comptime:Zig 的元编程内核
如果只能记住 Zig 的一个特性,那就是 comptime。它一个概念同时替代了 C 的宏、C++ 的模板、C++ 的 constexpr、以及一部分反射。
3.1 类型是一等值
在 Zig 里,type 本身是一种类型,类型的值可以像普通变量一样传递——但只能在编译期。
fn makeArray(comptime T: type, comptime n: usize) [n]T {
var arr: [n]T = undefined;
for (&arr, 0..) |*item, i| {
item.* = @as(T, @intCast(i));
}
return arr;
}
pub fn main() void {
const a = makeArray(u8, 4); // 类型 [4]u8
const b = makeArray(i32, 3); // 类型 [3]i32
_ = a; _ = b;
}
comptime T: type 表示 T 是一个编译期参数,值是一个类型。这就是 Zig 的"泛型"——没有专门的泛型语法,泛型只是参数为类型的编译期函数。
3.2 泛型就是返回类型的函数
Zig 里没有 template<typename T>。一个泛型数据结构,本质上是一个"接收类型、返回类型"的编译期函数:
fn Stack(comptime T: type) type {
return struct {
items: []T,
len: usize,
allocator: std.mem.Allocator,
const Self = @This();
pub fn init(allocator: std.mem.Allocator) Self {
return .{ .items = &[_]T{}, .len = 0, .allocator = allocator };
}
pub fn push(self: *Self, value: T) !void {
if (self.len >= self.items.len) {
const new_cap = if (self.items.len == 0) 8 else self.items.len * 2;
self.items = try self.allocator.realloc(self.items, new_cap);
}
self.items[self.len] = value;
self.len += 1;
}
pub fn pop(self: *Self) ?T {
if (self.len == 0) return null;
self.len -= 1;
return self.items[self.len];
}
pub fn deinit(self: *Self) void {
self.allocator.free(self.items);
}
};
}
用法:
var s = Stack(i32).init(allocator);
defer s.deinit();
try s.push(1);
try s.push(2);
std.debug.print("{?}\n", .{s.pop()}); // 2
Stack(i32) 在编译期被求值成一个具体的结构体类型。这跟 C++ 模板实例化在效果上类似,但错误信息天差地别——Zig 的报错是普通的类型错误,指向你写的那行代码,而不是 C++ 那种几百行的模板展开天书。
3.3 comptime if 与死代码消除
comptime 的分支在编译期就决定,未选中的分支根本不会进入生成代码。这让"零成本配置"成为可能:
fn log(comptime level: Level, msg: []const u8) void {
if (comptime level == .debug and !build_options.enable_debug) {
return; // 编译期就删掉,运行期零开销
}
std.debug.print("[{s}] {s}\n", .{ @tagName(level), msg });
}
对比 C 里靠 #ifdef 宏来做条件编译——Zig 用的是同一套语言,同一套类型检查,不存在预处理器那种"文本替换后才发现语法错"的坑。关闭的分支依然会被解析和类型检查(除非它依赖了根本不存在的符号),这比宏安全得多。
3.4 用 comptime + @typeInfo 做反射
Zig 提供 @typeInfo 在编译期拿到类型的结构,配合 inline for 可以写出类似"自动序列化"的代码:
fn printFields(value: anytype) void {
const T = @TypeOf(value);
const info = @typeInfo(T);
inline for (info.@"struct".fields) |field| {
std.debug.print("{s} = {any}\n", .{
field.name,
@field(value, field.name),
});
}
}
const Point = struct { x: i32, y: i32, label: []const u8 };
pub fn main() void {
printFields(Point{ .x = 3, .y = 7, .label = "p0" });
// 输出:
// x = 3
// y = 7
// label = { ... }
}
inline for 会在编译期展开循环,每个字段生成一份专门的代码。这就是 Zig 实现"编译期反射"的方式——没有运行期反射的开销,也不需要注解或代码生成器。很多 JSON、ORM、二进制协议库就是靠这套机制实现的。
冷静提醒:
comptime强大,但会传染。一旦你的数据结构大量依赖编译期展开,编译时间会上升,报错也会变得抽象。它是把双刃剑,不是越多越好。
四、内存管理:allocator 才是 Zig 的真正主角
我认为 Zig 最有工程价值的设计,不是 comptime,而是把 allocator 显式化、可插拔化。这一节值得慢慢读。
4.1 Allocator 是一个接口
std.mem.Allocator 本质是一个 vtable + 上下文指针的结构体。任何实现了 alloc / resize / free 的东西都能当 allocator 用。标准库提供了一整个"分配器动物园",每个对应一种工程场景:
| Allocator | 适用场景 | 特点 |
|---|---|---|
GeneralPurposeAllocator | 通用/开发调试 | 检测 double-free、leak、use-after-free |
ArenaAllocator | 请求级/批处理 | 一次性释放全部,极快 |
FixedBufferAllocator | 嵌入式/无堆环境 | 在栈或静态缓冲区上分配 |
page_allocator | 直接问操作系统要页 | 大块内存、底层 |
c_allocator | 与 C 库互操作 | 直接用 malloc/free |
std.testing.allocator | 单元测试 | 测试结束自动断言无泄漏 |
4.2 Arena:请求级内存的降维打击
考虑一个 HTTP 服务器处理单个请求:解析、路由、拼装响应,中间产生几十个小对象。传统写法里,每个对象都要记得 free,稍不注意就漏。Zig 的 arena 模式是这样的:
fn handleRequest(parent_allocator: std.mem.Allocator, req: Request) !Response {
var arena = std.heap.ArenaAllocator.init(parent_allocator);
defer arena.deinit(); // 请求结束,一次性释放所有中间内存
const a = arena.allocator();
const parsed = try parseBody(a, req.body); // 不用单独 free
const rows = try queryDatabase(a, parsed.id); // 不用单独 free
const json = try renderJson(a, rows); // 不用单独 free
return Response{ .body = try copyToCaller(json) };
}
请求内的所有分配都走 arena,函数结束时 arena.deinit() 一次性回收。这带来两个好处:代码里几乎没有分散的 free 调用(心智负担骤降),并且 arena 的分配是指针 bump,比 malloc 快一个数量级。这套模式在编译器、游戏帧循环、批处理任务里被反复验证有效。
4.3 测试即防漏
Zig 测试里用 std.testing.allocator,如果测试结束时有内存没释放,测试直接失败:
test "stack no leak" {
var s = Stack(u8).init(std.testing.allocator);
defer s.deinit();
try s.push(42);
try std.testing.expectEqual(@as(?u8, 42), s.pop());
}
把内存泄漏检测做进单元测试的默认流程,这是 C/C++ 需要额外挂 valgrind/asan 才能做到的事。Zig 把它变成了"零配置默认行为"。
4.4 代价:安全性是有边界的
必须诚实:Zig 不是内存安全语言。它没有 Rust 那样的借用检查器,use-after-free、悬垂指针、data race 这些问题,Zig 在 ReleaseFast 模式下不会帮你拦截。它提供的是:
- Debug 模式下丰富的运行期检查(越界、溢出、undefined 使用等)。
- GeneralPurposeAllocator 能在开发期捕获很多堆错误。
- 显式的所有权约定(谁分配谁释放,靠
defer表达)。
但这些是"帮你更容易写对",不是"从类型系统上证明你写对了"。如果你的项目对内存安全有强合规要求,Rust 仍然是更稳的选择。这是 Zig 和 Rust 最根本的哲学分歧:Zig 相信工程师,Rust 相信编译器。
五、错误处理:error union 与 try/catch 的真面目
Zig 的错误处理既不是异常,也不是 Go 那种 if err != nil,而是一套基于类型系统的错误联合。
5.1 错误是值,也是类型
const FileError = error{
NotFound,
PermissionDenied,
OutOfMemory,
};
fn openConfig(path: []const u8) FileError![]u8 {
if (path.len == 0) return FileError.NotFound;
// ...
return someBytes;
}
FileError![]u8 是一个"错误联合类型":要么是 []u8(成功值),要么是 FileError 里的某个错误。这个组合信息编码在类型里,编译器强制你处理。
5.2 try 是语法糖,不是异常
fn loadAll() ![]u8 {
const data = try openConfig("app.conf"); // 出错就直接把错误 return 给调用方
return process(data);
}
try expr 等价于:
const data = openConfig("app.conf") catch |err| return err;
关键区别于异常:没有栈展开,没有隐藏的控制流跳转,错误传播就是一次普通的 return。这完美贴合"无隐藏控制流"哲学。它的运行期成本约等于返回一个带 tag 的联合体——几乎为零。
5.3 catch 的多种姿势
// 1. 提供默认值
const port = parsePort(str) catch 8080;
// 2. 处理具体错误
const data = readFile(path) catch |err| switch (err) {
error.NotFound => return createDefault(),
error.PermissionDenied => {
log("no permission");
return err;
},
else => return err,
};
// 3. 断言不会出错(出错则 panic)
const n = parseInt(trusted) catch unreachable;
5.4 errdefer:错误路径上的清理
这是我特别欣赏的一个设计。defer 在作用域结束时总会执行;errdefer 只在因错误提前返回时执行。这让"分配到一半失败要回滚"这种经典难题变得优雅:
fn createThing(allocator: std.mem.Allocator) !*Thing {
const thing = try allocator.create(Thing);
errdefer allocator.destroy(thing); // 只有后面出错才回滚这一步
thing.buffer = try allocator.alloc(u8, 1024);
errdefer allocator.free(thing.buffer);
try thing.initExpensiveResource(); // 如果这里失败,上面两个 errdefer 依次触发
return thing; // 成功路径:errdefer 不执行,资源移交给调用方
}
对比 C 里那种 goto cleanup1; goto cleanup2; 的层层跳转,errdefer 把"部分构造失败的回滚"写成了线性、局部、可读的代码。这是 Zig 在错误处理上真正的工程亮点。
5.5 局限
Zig 的错误集不能携带额外负载(payload)。error.NotFound 就是一个纯枚举值,你不能像 Rust 的 Result<T, MyError> 那样在错误里塞一个结构体(比如"哪个文件、第几行")。想传递上下文,得靠额外的 out 参数或全局诊断结构。这是 Zig 错误模型的已知短板,社区讨论多年,取舍是为了保持错误传播的零成本和简单性。
六、C 互操作:Zig 最实用的"特洛伊木马"
很多人第一次用 Zig,根本不是为了写 Zig,而是为了用 Zig 当 C/C++ 的构建工具和胶水层。这是 Zig 极其务实的一面。
6.1 直接 @cImport C 头文件
Zig 可以在编译期直接解析 C 头文件,无需手写 binding:
const c = @cImport({
@cInclude("stdio.h");
@cInclude("math.h");
});
pub fn main() void {
_ = c.printf("sqrt(2) = %f\n", c.sqrt(2.0));
}
@cImport 会调用内置的 Clang 前端把 C 声明翻译成 Zig 声明。相比 Rust 需要 bindgen、C++ 需要手工封装,Zig 的 C 互操作是内建的、零额外工具的。
6.2 zig cc:一个能到处跑的交叉编译器
Zig 内置了 Clang 和一整套 libc(musl、glibc 多版本、mingw 等),所以 zig cc 本身就是一个开箱即用的交叉编译器:
# 在 macOS ARM 上,直接编译出 Linux x86_64 的二进制
zig cc -target x86_64-linux-gnu -o app main.c
# 编译到 Windows
zig cc -target x86_64-windows-gnu -o app.exe main.c
# 甚至指定 glibc 版本,解决"编译机 glibc 太新导致目标机跑不了"的老大难
zig cc -target x86_64-linux-gnu.2.28 -o app main.c
这一条能力大到什么程度?连 Uber、以及不少 Go/Rust 项目都开始用 zig cc 作为 CGO/交叉编译的后端,因为它比维护一堆 GCC 工具链干净太多。很多人用 Zig,是把它当 CI 里的交叉编译神器,而不是用它写业务代码。 这是一个非常真实的采用路径。
6.3 被 C 调用
反过来,用 export 导出符合 C ABI 的函数,Zig 写的库能被任何 C/Python/Node 程序调用:
export fn add(a: c_int, b: c_int) c_int {
return a + b;
}
编译成静态/动态库,.h 声明手写或用 zig build-lib 生成。这让"用 Zig 逐步替换 C 代码库里的某个热点模块"成为可行的渐进式迁移路径——不用推倒重来。
七、构建系统:build.zig 就是 Zig 代码
Zig 没有单独的构建语言(不像 CMake 那种自成一派的 DSL)。构建脚本 build.zig 就是普通的 Zig 程序:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "myapp",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
// 链接一个 C 库
exe.linkLibC();
exe.linkSystemLibrary("sqlite3");
b.installArtifact(exe);
// 定义 `zig build run`
const run_cmd = b.addRunArtifact(exe);
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run_cmd.step);
// 定义 `zig build test`
const unit_tests = b.addTest(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
const run_tests = b.addRunArtifact(unit_tests);
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_tests.step);
}
好处显而易见:构建逻辑和业务代码用同一门语言、同一套工具链。你不需要学 CMake 的语法、Makefile 的 tab 陷阱、Bazel 的 Starlark。想在构建里做条件判断、循环、读环境变量?就用 Zig 写。
四种优化模式,语义清晰:
| 模式 | 含义 |
|---|---|
Debug | 全部安全检查开启,编译快,运行慢 |
ReleaseSafe | 优化 + 保留安全检查(越界/溢出仍会 panic) |
ReleaseFast | 最大化速度,去掉安全检查 |
ReleaseSmall | 最小化体积,适合嵌入式 |
ReleaseSafe 是个很妙的中间档:你可以在生产环境保留运行期安全检查,用可接受的性能损失换取"越界立刻 panic 而不是静默损坏内存"。这是 C/C++ 里很难优雅做到的。
八、性能实战:一个真实的对比场景
讲了这么多,落到性能上到底如何?我们看一个典型场景——解析一个大文本文件并统计词频。这是能体现"内存管理策略"影响的场景。
8.1 朴素版(每个词单独分配)
fn countWordsNaive(allocator: std.mem.Allocator, text: []const u8) !std.StringHashMap(u32) {
var map = std.StringHashMap(u32).init(allocator);
var it = std.mem.tokenizeAny(u8, text, " \n\t\r");
while (it.next()) |word| {
const key = try allocator.dupe(u8, word); // 每个词都堆分配一份
const gop = try map.getOrPut(key);
if (gop.found_existing) {
allocator.free(key); // 已存在,刚分配的又得释放
gop.value_ptr.* += 1;
} else {
gop.value_ptr.* = 1;
}
}
return map;
}
问题:大量小分配 + 重复词还要立刻释放,malloc 压力大。
8.2 Arena 优化版
fn countWordsArena(parent: std.mem.Allocator, text: []const u8) !void {
var arena = std.heap.ArenaAllocator.init(parent);
defer arena.deinit(); // 结束一次性回收,中间无需 free
const a = arena.allocator();
var map = std.StringHashMap(u32).init(a);
var it = std.mem.tokenizeAny(u8, text, " \n\t\r");
while (it.next()) |word| {
const gop = try map.getOrPut(try a.dupe(u8, word));
if (gop.found_existing) {
gop.value_ptr.* += 1;
} else {
gop.value_ptr.* = 1;
}
}
// 用完 map,处理结果...
// 注意:重复词的 dupe 内存虽然没立即回收,但 arena 结束时统一清理,
// 换来的是全程零 free 调用 + bump 分配的速度
}
工程权衡:arena 版本用"稍微多占一点峰值内存"换"零碎片、零 free 开销、代码更简单"。在批处理、一次性任务里这几乎总是划算的。这正是 Zig allocator 模型的价值——同一份业务逻辑,换个 allocator 就换了一套内存策略,代码几乎不动。
8.3 关于"Zig 比 C 快"的冷思考
网上常有"Zig 比 C 快"的说法,要泼盆冷水:Zig 和 C 都编译到 LLVM IR(或 Zig 自研后端),底层优化能力在同一梯队,纯计算性能不会有本质差距。 Zig 的性能优势不来自"神奇的编译器",而来自:
- 默认更容易写出对 cache 友好的数据布局(显式内存控制)。
- allocator 可插拔让你更容易用上 arena 这类高效策略。
- comptime 把配置分支消除在编译期,运行期无分支开销。
换句话说,Zig 给了你写出高性能代码的顺手工具,但它不会替一个不懂内存的人自动变快。性能永远是工程师的事,语言只是降低或提高门槛。
九、什么时候该用 Zig,什么时候别碰
作为务实派,选型建议我说得直接一点。
适合 Zig 的场景
- 交叉编译 / C 项目的构建工具:哪怕你一行 Zig 都不写,
zig cc也值得进你的工具箱。 - 嵌入式 / 无操作系统环境:
FixedBufferAllocator+ 无隐藏分配,掌控力极强。 - 需要和 C 深度互操作的库:
@cImport零成本,渐进替换 C 代码很自然。 - 编译器、解释器、游戏引擎等对内存布局敏感的系统:arena 模式 + comptime 的组合非常契合。
- 喜欢"读代码即读机器行为"的团队:无隐藏控制流让 code review 成本极低。
别急着上 Zig 的场景
- 对内存安全有强合规要求:Zig 不做借用检查,选 Rust。
- 需要长期稳定、不能频繁改代码:Zig 未到 1.0,破坏性变更是常态。这是最大的现实风险。
- 团队没有系统编程底子:Zig 把内存管理的责任完全交给你,新手容易踩坑。
- 生态依赖重:Zig 的第三方库生态还年轻,很多轮子得自己造或从 C 借。
- 要招人的商业项目:Zig 工程师池子还很小。
一句话总结选型
Rust 是"编译器保证你不犯错",Zig 是"语言不挡你的路、也不替你兜底"。 前者适合团队协作和安全关键系统,后者适合掌控欲强的个人/小团队和系统底层。它们不是替代关系,很多时候是互补的。
十、总结与展望
把 Zig 的核心价值压缩成几条:
- comptime 一统元编程:类型是值,泛型是函数,反射是编译期展开。一个概念干掉了宏、模板、constexpr 三样东西,而且报错人类可读。
- allocator 显式化:内存来源变成参数,让 arena、测试防漏、嵌入式固定缓冲区这些策略变成"换个参数"的事。这是 Zig 被最低估、也最有工程价值的设计。
- error union + errdefer:零成本、无栈展开的错误传播,加上优雅的部分构造回滚。
- 无隐藏控制流/分配:你读到的代码,就是机器要跑的代码。可预测性拉满。
- C 互操作与 zig cc:内建 Clang + 多版本 libc,成为事实上的跨平台交叉编译神器。
它的代价也同样清晰:不是内存安全语言、尚未 1.0、生态年轻、把责任全交给工程师。
展望 2026 及以后,Zig 的两个看点:一是自研后端逐步摆脱对 LLVM 的强依赖,编译速度和分发体积会继续改善;二是增量编译和更快的开发循环,这对大型项目的开发体验是关键。至于 1.0 何时到来,官方一直很谨慎——他们宁可慢,也不愿背上错误设计的历史包袱。
我的态度是:现在就把 zig cc 用起来当交叉编译工具,零风险;但把核心业务押注在 Zig 上,先等它更稳。 这门语言的方向是对的——在一个越来越喜欢"帮你做决定"的时代,Zig 选择把决定权还给工程师。对真正懂内存的人来说,这种"不挡路"的克制,本身就是一种奢侈。
技术选型从来不是追新,而是算清楚收益、代价和风险。Zig 值得你认真看一眼——不是因为它时髦,而是因为它把"系统语言到底该长什么样"这个老问题,重新诚实地回答了一遍。