这篇要解决的问题
系统编程这个位置长期被 C 和 C++ 占着,近年 Rust 的方案是”用强类型和借用检查把内存错误挡在编译期”,代价是学习曲线陡。Zig 走的是另一条路:不引入隐藏的控制流(没有异常、没有隐藏的构造/析构、没有运算符重载),内存安全靠显式约定加一批编译期检查,语法上刻意保持接近 C 的直白。它的口号是”一门更好的 C”:既能写操作系统、数据库、编解码器,也能像 C 一样直接编译 C 代码(zig cc 可当作跨平台 C 编译器用)。生态里最出圈的证据是 Bun——那个 Node.js 的竞争运行时就是用 Zig 写的。这篇按”装起来、看懂错误处理和可空类型、见识 comptime、评估 pre-1.0 风险”的顺序走,写作时的最新稳定版是 0.14.1(2025 年 5 月发布),1.0 没有时间表。
安装与 zig run
Zig 是单个可执行文件加标准库,没有中央包服务依赖。官网下载对应平台的压缩包解压即可,或用包管理器(macOS 的 brew、Windows 的 scoop/winget 都有维护);Linux 发行版 apt 源里的版本通常严重滞后,不建议。写第一个程序:
# hello.zig 放哪都行,zig run 直接解释编译执行
zig run hello.zig
1. 要做正经项目时,zig init 生成 build.zig 骨架
zig init
zig build # 编译产物在 zig-out 下
zig build test # 跑测试
下面这段 hello 演示了 Zig 的两个招牌习惯:defer 和显式分配器。标准库里凡是要内存的函数,都要求你把分配器当参数传进去——分配策略是调用方的决定,库不偷偷 malloc:
const std = @import("std");
pub fn main() !void { // !void:可能正常返回,也可能返回错误
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit(); // defer:作用域结束必然执行,清理代码挨着申请代码
const allocator = gpa.allocator();
const buf = try allocator.alloc(u8, 32); // 要内存必须显式申请
defer allocator.free(buf);
@memcpy(buf[0..5], "hello");
const stdout = std.io.getStdOut().writer();
try stdout.print("{s} from Zig!n", .{buf[0..5]});
}
显式错误处理与可选类型
Zig 的错误处理和 Go 解决同一个问题,机制却不同。Go 的 error 是接口值,任何返回值都可能裹着错误;Zig 把”可能怎么失败”写成错误集(error set)放进类型签名,函数返回类型 ConfigError!u32 读作”要么是 u32,要么是那两个错误之一”:
const ConfigError = error{ NotFound, TooSmall };
fn readLimit(raw: []const u8) ConfigError!u32 {
const n = std.fmt.parseInt(u32, raw, 10) catch return error.NotFound;
if (n < 1024) return error.TooSmall;
return n;
}
pub fn main() !void {
// try 是 Go 里 "if err != nil { return err }" 的浓缩写法:出错就继续上抛
const limit = try readLimit("80");
// catch 是就地兜底:出错给默认值,不让错误往上传
const port = readLimit("abc") catch 8080;
std.debug.print("limit={d} port={d}n", .{ limit, port });
}
可空值同理。Zig 没有 null 随处可点的自由,?T 类型想用里面的值,编译器强制你先拆包:
fn findUser(id: u32) ?[]const u8 { // ?T:要么有 T,要么 null
return if (id == 1) "alice" else null;
}
pub fn main() void {
const name = findUser(2) orelse "anonymous"; // orelse 拆包给兜底
std.debug.print("{s}n", .{name});
}
| 维度 | Go | Zig |
|---|---|---|
| 错误表示 | error 接口值,与返回值同居 | 错误集写进返回类型,值与错误互斥 |
| 上抛 | if err != nil 手写返回 | try 一个关键字 |
| 就地兜底 | if err != nil 里赋默认值 | catch 表达式 |
| 可空值 | nil 指针/ok 惯用法 | ?T 类型,编译器强制拆包 |
| 资源清理 | defer | defer / errdefer(仅出错路径执行) |
| 隐藏控制流 | panic 可从深处冒出 | 没有异常,错误只沿返回值走 |
comptime:编译期把泛型问题解决掉
Zig 没有单独的泛型语法、宏系统或模板引擎,取而代之的是 comptime:任何标记为编译期执行的代码就在编译期跑。最常见用途是拿”类型”当参数,为每个类型实例化出专门的代码:
const std = @import("std");
// T 和 N 是编译期参数:Stack(i32, 4) 会在编译期生成一个全新类型
fn Stack(comptime T: type, comptime N: usize) type {
return struct {
items: [N]T = undefined,
len: usize = 0,
pub fn push(self: *@This(), v: T) !void {
if (self.len == N) return error.Overflow; // 满了怎么办由调用方决定,不静默扩容
self.items[self.len] = v;
self.len += 1;
}
};
}
pub fn main() !void {
var s: Stack(i32, 4) = .{};
try s.push(42);
std.debug.print("top={d} cap={d}n", .{ s.items[0], s.items.len });
}
这套机制取代了 C++ 模板的多数用途,且报错发生在编译期你自己的代码里,而不是模板展开三千行的深渊里。代价是没有”运行时多态”,需要接口的地方得用函数指针或 tagged union 自己组合。
pre-1.0 的诚实风险
三条风险写在前面。第一,语言本身还在动:Zig 的版本升级经常带走语法和标准库 API,三年前的教程代码今天大概率编译不过,查资料必须以当前版本文档为准。第二,并发是半成品:async/await 机制长期处于搁置状态,何时以何种形式回归没有时间表,现阶段并发主要靠 std.Thread 和系统原语,写惯 Go channel 的要重新掂量。第三,生态与岗位都小:没有大公司背书(项目由 Zig 软件基金会靠赞助维持),第三方库数量和招聘需求都不能与 Go/Rust 比。
反模式点名:拿 pre-1.0 的语言承载关键生产路径,然后被一次版本升级打崩。现阶段 Zig 合适的位置是个人工具、性能敏感的小组件、替换手写 C 的场景、以及”跟着 Bun/TigerBeetle 这类标杆项目学系统知识”;公司核心业务请留在成熟语言里观望。
延伸阅读:Go 的错误处理对照着看更有感觉,见《Go 错误处理》;两门语言并发的取舍差异见《Go 并发基础》;用 Zig 交叉编译 C 代码时用到的平台常识见《Linux 常用命令》。
版本核实(以官方文档为准):写作时最新稳定版为 0.14.1(2025-05-23 发布,0.14.0 于 2025-03-05 发布),1.0 未发布;async 处于搁置状态是官方公开表态;zig run/init/build、error set、try/catch、?T、comptime、显式分配器均为 0.14 已发布行为,具体 API 以 ziglang.org 当前版本文档为准。