Zig 编译器源码扒皮:为什么它敢说比 C 更懂你(第1篇)
大家好,我是 Zachel。最近在反复阅读 Zig 主仓库(ziglang/zig)的源码,越看越觉得一句话特别扎心:
“Zig 比 C 更懂你。”
这句话不是营销文案,而是 Andrew Kelley(Zig 作者)在多个场合半开玩笑半认真说过类似意思的话。很多人听到会嗤之以鼻:“C 都用了快50年了,你一个2016年才诞生的语言凭什么?”
但当你真的去翻 src/AstGen.zig、src/Sema.zig、src/Compilation.zig 这些核心文件后,会发现 Zig 编译器在某些设计决策上,确实体现出一种“它好像真的在认真考虑现代 C 程序员的痛苦”的态度。
今天我们就从源码角度,扒一扒 Zig 编译前端到语义分析阶段的几个关键点,看看它到底在哪些地方“比 C 更懂你”。
1. 没有宏,却能干掉 99% 的 C 宏需求 —— comptime 是真·理解你的元编程痛点
C 程序员最痛苦的事情之一就是:想做点泛型/代码生成,只能靠丑陋的宏。
#define MAX(a,b) ((a) > (b) ? (a) : (b))
#define CONTAINER_OF(ptr, type, member) \
((type *)((char *)(ptr) - offsetof(type, member)))
这些宏要么展开后难以调试,要么有副作用,要么名字污染,要么根本没法做类型检查。
Zig 直接说:我不给你宏,但我给你更强大的东西——comptime。
我们看源码 src/Sema.zig 里大量用到的 comptime_ 前缀逻辑:
// 简化后的伪码,展示 comptime 如何在语义分析阶段执行
if (inst.tag == .comptime_call) {
// 在编译期直接执行用户写的 Zig 代码!
const result = try sema.resolveInst(ctx, inst.data.call.func);
// ... 继续执行、捕获错误、生成常量...
}
这意味着你可以写出这样的代码:
fn ArrayList(comptime T: type) type {
return struct {
items: []T,
capacity: usize,
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator) !@This() { ... }
pub fn append(self: *@This(), item: T) !void { ... }
};
}
// 使用时
var list = ArrayList(i32).init(allocator) catch unreachable;
编译器在编译期就把 ArrayList(i32) 展开成了一个专用的 struct,生成的机器码跟手写专用版本几乎一样。
更狠的是:comptime 可以运行任意 Zig 代码(只要不涉及运行时 side-effect),这比 C 的预处理能力高了几个量级。
C 宏只是文本替换,Zig comptime 是图灵完备的编译期解释器。这意味着你可以:
-
编译期读文件、解析配置、生成 LUT 表 -
根据 target 平台自动选择不同的数据结构实现 -
做真正的泛型特化,而非文本替换
当你写出 std.ArrayHashMap、std.HashMap 这种依赖 comptime 的容器时,你会深刻体会到:Zig 编译器真的在帮你写代码,而不是让你自己去 hack cpp。
2. 错误处理不是附加品,而是语言核心语义 —— 它知道你最怕无声崩溃
C 程序员写代码时永远在和“返回值到底是 -1 还是 NULL 还是正数错误码”作斗争。
Zig 从语言层面把错误变成了一等公民。
看 src/AstGen.zig 里的错误联合类型语法:
!i32 // 可能是 i32,也可能是一个 error
anyerror!void // 最宽松的错误集合
MyErrorSet!Type // 限定错误集合(推荐)
再看语义分析 src/Sema.zig 中对 ! 的处理逻辑(极度简化):
if (return_ty.castTag(.error_union)) |payload| {
// 强制要求调用者处理错误,除非用 catch/try/errdefer
if (!is_error_handled) return sema.fail("error not handled");
}
这带来几个直观的“懂你”点:
-
强制错误处理(除非显式忽略),极大减少漏处理返回码的情况 -
errdefer 像 Go 的 defer + 错误冒泡的完美结合
fn openAndRead() ![]u8 {
const file = try std.fs.cwd().openFile("data.txt", .{});
errdefer file.close(); // ← 这里超级懂你!
const content = try file.readToEndAlloc(allocator, 1<<20);
return content;
}
-
错误集是编译期已知的,可以做穷举匹配(exhaustive switch)
switch (err) {
error.FileNotFound => std.debug.print("文件没了\n", .{}),
error.PermissionDenied => return,
else => |e| return e, // 编译器帮你检查是否漏了新错误
}
C 程序员写到第 5 年才发现自己漏处理了某个 errno,Zig 编译器在你 commit 前就骂你了。
它不是在限制你,而是在替你背负“忘记处理错误”的心理负担。
3. 显式分配器:它知道你最怕“谁偷偷给我分配了内存”
C 程序员另一大噩梦:不知道哪一行偷偷调用了 malloc。
Zig 从标准库到编译器本身,都强制要求分配器显式传递。
看 std/mem/Allocator.zig 和编译器内部的大量代码:
pub fn create(comptime T: type, allocator: Allocator) !*T {
return allocator.create(T);
}
pub fn allocAdvanced(
allocator: Allocator,
comptime T: type,
n: usize,
// ...
) ![]T { ... }
编译器自己的 IR、Module、Decl 等等结构,也全都带着 allocator 参数。
这带来两个很实际的好处:
-
内存泄漏在 debug 模式下几乎必现(内置 leak detector) -
你可以轻松在不同场景使用不同分配器(arena、fixed buffer、c allocator、页分配器、jemalloc……)
而 C 项目里想做这件事,通常要么全局替换 malloc,要么自己搞一套丑陋的上下文传递。
Zig 编译器从根上就告诉你:“兄弟,我知道你怕隐式分配,我帮你把所有分配点都摆到明面上。”
小结:它为什么敢说“比 C 更懂你”?
从源码层面看,Zig 编译器在下面几个维度做了“反 C 痛点”设计:
-
用 comptime 消灭了丑陋宏和重复代码 -
把错误处理从“约定”升级为“类型系统一部分” -
把内存分配从“全局隐患”变成“显式参数” -
去掉了所有隐式控制流(没有运算符重载、没有隐式转换地狱、没有异常)
它不是在否定 C 的哲学,而是把 C 想做但受历史包袱限制做不到的事情,做得更彻底。
如果你也对 Zig 编译器源码感兴趣,欢迎留言告诉我你最想看哪一块,我尽量在下一期安排~
我们下期见!
(本文分析基于 Zig 0.14.0-dev 左右主分支,部分伪码为理解方便已大幅简化)
夜雨聆风
