乐于分享
好东西不私藏

Zig 编译器源码扒皮:为什么它敢说比 C 更懂你(第1篇)

Zig 编译器源码扒皮:为什么它敢说比 C 更懂你(第1篇)

大家好,我是 Zachel。最近在反复阅读 Zig 主仓库(ziglang/zig)的源码,越看越觉得一句话特别扎心:

“Zig 比 C 更懂你。”

这句话不是营销文案,而是 Andrew Kelley(Zig 作者)在多个场合半开玩笑半认真说过类似意思的话。很多人听到会嗤之以鼻:“C 都用了快50年了,你一个2016年才诞生的语言凭什么?”

但当你真的去翻 src/AstGen.zigsrc/Sema.zigsrc/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.ArrayHashMapstd.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");
}

这带来几个直观的“懂你”点:

  1. 强制错误处理(除非显式忽略),极大减少漏处理返回码的情况
  2. 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;
}
  1. 错误集是编译期已知的,可以做穷举匹配(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 左右主分支,部分伪码为理解方便已大幅简化)


本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » Zig 编译器源码扒皮:为什么它敢说比 C 更懂你(第1篇)

评论 抢沙发

9 + 2 =
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
×
订阅图标按钮