看完 Zig stage1 源码后我彻底放弃了 C (第2篇)
上一篇文章发出后,有朋友留言问:“你不是说 Zig 只是更好的 C 吗?怎么突然就彻底放弃 C 了?”
其实不是突然,而是在我花了大概两周时间认真读了一遍 Zig stage1 源码之后,心态彻底崩了。
我原本是抱着“再给 C 一次机会”的心态去的,结果看完才发现:C 已经不是“可以忍受的坏”,而是“结构性地坏到没法救”。而 Zig stage1 这个“用 C++ 写的 Zig 编译器前端 + 部分后端”,反而成了压死骆驼的最后一根稻草——它用最残酷的方式告诉我:即使你用现代 C++ 写一个“更好的 C”,也逃不出 C 语言家族的原罪。
一、stage1 到底是个什么怪物?
先简单科普一下 Zig 的编译器阶段(2025~2026 年当前的实际情况):
-
stage1:用 C++ 写的“ bootstrap 编译器”,负责把 Zig 代码编译到一定程度,然后交给 LLVM -
stage2(也叫 self-hosted):用 Zig 自身写的全新编译器(目前主流) -
stage3:用 stage2 再编译一遍自己,验证自举正确性
2022 年底 Zig 就已经把 stage1 作为可选(-fstage1),2023 年后默认彻底切到 self-hosted,stage1 代码在主仓库里已经被大幅删减或标记为 legacy。但我特意 checkout 了一个 0.11 左右的 tag,把 src/stage1/ 目录完整 clone 下来读。
结果呢?这坨代码比我想象中还要“诚实”——它几乎把 C/C++ 能犯的所有设计罪都展示了一遍。
二、让我最想砸键盘的几个点
-
Error handling 像屎一样(真的)
stage1 里错误处理基本是经典 C 风格 + 一点 C++ exception:
// 简化后的伪代码,实际更乱
Error err = sema_analyze_instruction(...);
if (err != Error_OK) {
// 有时直接 return,有时 set context->err
// 有时 fprintf(stderr, ...) 然后 exit(1)
// 有时 longjmp 逃逸
}
同一个文件里能看到四五种不同的错误传播方式:
-
return Error union -
修改全局/上下文的 last_error -
直接 print + abort() -
throw / try-catch(极少) -
longjmp(是的你没看错,stage1 里真的有 longjmp)
而真正的 Zig 代码里,你写:
const ptr = try allocator.create(u8);
编译器在语义分析阶段就帮你把错误路径完整推导出来,永远是结构化的、类型安全的、可穷举的。
stage1 自己却在用最原始、最容易漏、最难 debug 的方式管理错误。这就是“用 C++ 写更好 C”的典型悲剧:语言想表达的东西和实现语言用的工具之间隔了三层地狱。
-
内存管理:new/delete 地狱 + 手动 arena
stage1 大量使用 arena allocator(这点倒是学了 Zig),但实现方式极其原始:
-
每个 arena 自己管理一个链表的 Buffer -
手动计算对齐、手动 cast -
几乎没有 ownership 概念,到处是裸指针 -
delete 经常被忘记,或者 delete 了还在用
我随便翻到一个 semantic analysis 的文件,保守估计有 30+ 处手动 free / delete,而且很多是条件分支深达 5 层才 free。
而现在的 self-hosted 编译器(stage2/stage3)里,内存管理几乎全部交给 std.heap.GeneralPurposeAllocator 或 ArenaAllocator,配合 defer,泄漏点肉眼可见少得多。
-
类型系统和 comptime 的缺失,让代码重复到想吐
stage1 需要自己解析 Zig 的 comptime 语法,但它自己没有 comptime。
于是你会看到大量的:
-
手写字符串匹配来解析 @typeInfo -
针对每一种内置类型写一套重复逻辑(i8/i16/i32/u8/u16…) -
用宏(C 宏!)来减少一点重复,但宏本身又带来新问题
而真正的 Zig 写法是:
fn handleType(comptime T: type) void {
switch (@typeInfo(T)) {
.Int => |info| { ... },
.Float => |info| { ... },
else => @compileError("unsupported"),
}
}
这段代码在 stage1 里得展开成几百行 switch case + if else。
当实现语言缺少目标语言的核心特性时,代码量会指数级爆炸。这就是 stage1 源码行数比 stage2 多一大截的重要原因之一。
-
build system 和 caching 逻辑写得像 2005 年
stage1 的缓存系统基本上是“文件名 + mtime + size”的弱 hash,失效非常频繁。
而 self-hosted 从头重写了缓存逻辑,用 ZIR(Zig Intermediate Representation) + incremental compilation,命中率高到离谱。
我本地测过:改一行注释,stage1 经常全量重编译;stage2 基本 0.3~0.8 秒搞定。
三、看完 stage1,我对 C/C++ 最绝望的结论
-
C 的“简单”其实是最大的谎言
它把所有复杂性推给了每一个使用它的人。stage1 想做“更好的 C”,结果自己先被 C 的债拖垮。 -
没有结构化错误处理 + 没有 comptime + 没有 defer 的语言,写大型项目就是慢性自杀
stage1 十几万行 C++,维护成本已经高到官方想删都舍不得删干净。 -
现代系统编程语言的底线已经不是“能不能写操作系统”,而是“能不能体面地编写和维护编译器本身”
Rust、Zig、Carbon、Vale 都在朝这个方向狂奔,而 C/C++ 还在原地打转。
四、最后:我为什么彻底放弃 C
不是因为 Zig 比 C 快(其实 release-fast 下很多场景差不多)
不是因为 Zig 语法多好看(其实挺朴素的)
而是因为我亲眼看到:用 C/C++ 写一个现代语言的编译器,是一件多么痛苦、多么低效、多么容易出错的事情。
当你用 Zig 写完一个 3000 行的语义分析模块,再回头看 stage1 同样的功能写了 12000 行,而且还更容易出段错误、内存泄漏、未定义行为……那种反差真的会让人绝望。
所以我现在的心态是:
-
能用 Zig 就不用 C -
必须用 C 的地方,用 Zig 的 @cImport+zig cc桥接 -
再也不想亲手写 malloc/free配对、#ifdef地狱、longjmp逃生了
stage1 不是 Zig 的耻辱,它是 C/C++ 时代的墓志铭。
欢迎留言:你读过 Zig stage1 / stage2 源码吗?最让你震撼/崩溃的是哪部分?
夜雨聆风