Zig 常被概括为“更现代的 C”,但它真正有价值的地方,不是语法更新,而是把系统编程里的成本摆到台面上:控制流显式、内存分配显式、错误路径显式,同时提供编译期执行、直接的 C 互操作和内建构建系统。
本文以 Zig 0.16.0 为基准。Zig 尚未发布 1.0,标准库和构建 API 仍可能调整,因此示例应始终配合对应版本的官方文档使用。
先给结论:什么时候考虑 Zig
Zig 适合这些任务:
- 操作系统、嵌入式、编译器、数据库和高性能网络服务等底层软件;
- 在保留 C ABI 与现有库的前提下,逐步替换部分 C/C++ 模块;
- 从一台开发机交叉编译多个目标平台;
- 希望审计每次内存分配、资源释放和错误传播;
- 用同一种语言管理 Zig、C 和 C++ 的构建。
如果团队依赖成熟的 GUI、Web 全栈框架或庞大第三方生态,或者无法跟进 1.0 前的 API 变化,Rust、Go、C++ 或更高层语言通常更稳妥。
第一个 Zig 程序
const std = @import("std");
pub fn main() !void {
const stdout = std.fs.File.stdout().deprecatedWriter();
try stdout.print("hello, Zig {s}!\n", .{
@import("builtin").zig_version_string,
});
}
运行和编译:
zig run src/main.zig
zig build-exe src/main.zig -O ReleaseFast
main 的返回类型 !void 表示函数成功时没有返回值,失败时返回错误。try 在成功时取得结果,失败时立即把错误向上传播;它不是异常,也没有隐式栈展开。
⚠️ 版本说明
上面的输出 API 以 Zig 0.16.0 为基准。Zig 1.0 前标准库接口可能变化,复制示例时请打开与本机编译器一致的版本化语言参考。
数组、切片与可选值
数组长度属于类型,切片则是对连续内存的运行时视图:
const std = @import("std");
test "array and slice" {
var values = [_]u16{ 10, 20, 30, 40 };
const middle: []u16 = values[1..3];
middle[0] = 99;
try std.testing.expectEqual(@as(u16, 99), values[1]);
}
可选类型写作 ?T,明确表示“可能没有值”:
fn findPort(enabled: bool) ?u16 {
return if (enabled) 8080 else null;
}
const port = findPort(true) orelse 3000;
这避免用 0、-1 或空指针承担多重含义。
错误是类型,不是异常
Zig 使用错误集合和错误联合类型。下面的函数要么返回端口,要么返回可匹配的错误:
const std = @import("std");
const PortError = error{ Empty, OutOfRange };
fn parsePort(text: []const u8) (PortError || std.fmt.ParseIntError)!u16 {
if (text.len == 0) return error.Empty;
const value = try std.fmt.parseInt(u16, text, 10);
if (value == 0) return error.OutOfRange;
return value;
}
test "parse port" {
try std.testing.expectEqual(@as(u16, 8080), try parsePort("8080"));
try std.testing.expectError(error.Empty, parsePort(""));
}
调用方通常有三种策略:用 try 继续传播,用 catch 提供降级值,或用 catch |err| 检查并转换具体错误。失败路径存在于类型签名中,不能被调用方无意忽略。
defer、errdefer 与资源生命周期
defer 在当前作用域结束时运行,多个 defer 按后进先出执行。errdefer 只在函数因错误退出时运行,适合初始化失败时回滚。
fn copyFile(source_path: []const u8, target_path: []const u8) !void {
const source = try std.fs.cwd().openFile(source_path, .{});
defer source.close();
const target = try std.fs.cwd().createFile(target_path, .{});
errdefer std.fs.cwd().deleteFile(target_path) catch {};
defer target.close();
// 复制逻辑省略
}
资源获取后立刻声明清理规则,代码评审时更容易确认是否泄漏。
allocator 是接口的一部分
Zig 不替程序选择全局分配器。需要动态内存的函数通常显式接收 std.mem.Allocator:
const std = @import("std");
fn duplicateUpper(
allocator: std.mem.Allocator,
input: []const u8,
) ![]u8 {
const output = try allocator.alloc(u8, input.len);
errdefer allocator.free(output);
for (input, 0..) |character, index| {
output[index] = std.ascii.toUpper(character);
}
return output;
}
test "allocator ownership" {
const allocator = std.testing.allocator;
const result = try duplicateUpper(allocator, "zig");
defer allocator.free(result);
try std.testing.expectEqualStrings("ZIG", result);
}
契约很明确:调用方提供 allocator,函数返回由它管理的切片,调用方负责释放。测试 allocator 还能在测试结束时发现泄漏。不同生命周期可选择通用堆分配、arena 或固定缓冲区;应先明确所有权,再选择分配策略。
comptime:编译期仍然写 Zig
comptime 允许代码在编译期执行。泛型函数通常接收 type 参数,不需要另一套模板语言:
fn max(comptime T: type, left: T, right: T) T {
return if (left > right) left else right;
}
test "generic max" {
try std.testing.expectEqual(@as(i32, 9), max(i32, 4, 9));
try std.testing.expectEqual(@as(f64, 3.5), max(f64, 3.5, 1.0));
}
配合 @typeInfo,可以在编译期检查类型结构,生成序列化、绑定或数据布局代码。但普通函数已经足够清楚时,没有必要为了炫技引入复杂的编译期逻辑。
C 互操作是核心能力
Zig 可以直接导入 C 头文件:
const std = @import("std");
const c = @cImport({
@cInclude("sqlite3.h");
});
pub fn sqliteVersion() []const u8 {
return std.mem.span(c.sqlite3_libversion());
}
Zig 也能作为 C/C++ 编译器驱动:
zig cc hello.c -o hello
zig c++ app.cpp -o app
这使渐进迁移成为现实:让 Zig 模块先与既有 C ABI 共存,再逐步迁移安全边界、构建流程或跨平台部分。
💡 互操作不等于自动安全
@cImport 能减少绑定工作,但 C 指针、生命周期和线程规则仍然存在。边界层应尽量薄,并把裸指针尽早转换为 Zig 中更明确的切片、可选值或封装类型。
内建构建系统
build.zig 是使用 Zig API 编写的构建描述,可以声明目标平台、优化模式、模块、测试和安装步骤。
const std = @import("std");
pub fn build(build_system: *std.Build) void {
const target = build_system.standardTargetOptions(.{});
const optimize = build_system.standardOptimizeOption(.{});
const executable = build_system.addExecutable(.{
.name = "hello-zig",
.root_module = build_system.createModule(.{
.root_source_file = build_system.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});
build_system.installArtifact(executable);
}
常用命令:
zig build
zig build -Doptimize=ReleaseFast
zig build -Dtarget=x86_64-linux-gnu
zig build test
交叉编译是 Zig 最有辨识度的能力之一。目标 triple 与 ABI 显式给出,工具链统一解析、编译和链接流程。但涉及平台 SDK、闭源库或系统框架时,仍需遵守目标平台的工具链与授权要求。
测试与构建模式
测试块与源码放在一起,用 zig test 执行:
const std = @import("std");
fn add(left: i32, right: i32) i32 {
return left + right;
}
test "addition" {
try std.testing.expectEqual(@as(i32, 5), add(2, 3));
}
Debug 和 ReleaseSafe 会保留更多运行时安全检查,例如整数溢出和边界检查;ReleaseFast 与 ReleaseSmall 更偏向性能或体积。生产环境不应机械选择 ReleaseFast,而要根据失败策略、性能测量和威胁模型决定。
一条实际的学习路径
- 安装当前稳定版,掌握数组、切片、结构体、可选值和错误联合类型;
- 用
defer、errdefer和测试 allocator 写一个文件解析器; - 为同一程序分别使用通用 allocator、arena 与固定缓冲区;
- 用
zig build管理可执行文件和测试; - 导入一个小型 C 库,封装安全、窄小的 Zig API;
- 最后学习
comptime反射和复杂构建逻辑。
不要从“写一个操作系统”开始。CLI、二进制格式解析器、网络协议实现或 C 库包装器,更适合建立对切片、错误和所有权的直觉。
Zig、Rust、C 与 Go 怎么选
| 关注点 | Zig | Rust | C | Go |
|---|---|---|---|---|
| 内存管理 | 显式 allocator,手动生命周期 | 所有权与借用检查 | 手动管理 | 垃圾回收 |
| C 互操作 | 直接导入头文件,工具链整合度高 | 通常需要 FFI 声明或绑定生成 | 原生 | 通过 cgo |
| 编译期能力 | comptime |
宏、泛型、const evaluation | 预处理器 | 较少 |
| 学习成本 | 语法直接,生命周期仍需纪律 | 前期较陡,编译器约束强 | 语法小,工程风险高 | 上手快 |
| 生态成熟度 | 仍在成长,1.0 前 | 成熟且快速发展 | 极成熟 | 成熟 |
Zig 的优势不是“比所有语言都安全”,而是用较少的语言机制换取透明、可控和优秀的 C/交叉编译体验。Rust 更偏向用类型系统阻止一大类内存错误;Zig 更强调让工程师直接看到并管理成本。
什么时候不要选 Zig
- 产品依赖 Zig 生态中尚未成熟的框架或 SDK;
- 团队无法持续跟进 1.0 前的破坏性变化;
- 主要矛盾是业务迭代速度,而不是系统边界或性能可预测性;
- 希望尽可能由编译器强制保证内存安全,但团队又无法承担严格评审;
- 目标平台依赖 Zig 工具链尚未完整覆盖的专有能力。
语言选择最终是组织决策。维护年限、招聘、调试工具、依赖供应链和发布环境,往往比微基准更重要。
总结
Zig 的系统编程思路很一致:控制流、分配器和错误都显式,编译期逻辑仍然使用 Zig,构建系统又把 C/C++ 与交叉编译纳入同一工作流。它不是自动安全的 C,也不是简化版 Rust;它选择把更多控制权交给工程师,同时尽量让控制权可读、可测试。
最好的评估方式是选一个边界清晰的小模块:写测试、接入真实 C 依赖、交叉编译两个目标,再记录二进制体积、构建时间、缺陷类型和维护成本。完成这个实验后,团队才真正知道 Zig 是否适合自己。