乐于分享
好东西不私藏

看完 Zig stage1 源码后我彻底放弃了 C (第2篇)

本文最后更新于2026-03-12,某些文章具有时效性,若有错误或已失效,请在下方留言或联系老夜

看完 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++ 能犯的所有设计罪都展示了一遍

二、让我最想砸键盘的几个点

  1. 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”的典型悲剧:语言想表达的东西和实现语言用的工具之间隔了三层地狱

  1. 内存管理: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,泄漏点肉眼可见少得多。

  1. 类型系统和 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 多一大截的重要原因之一。

  1. 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 源码吗?最让你震撼/崩溃的是哪部分?


本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » 看完 Zig stage1 源码后我彻底放弃了 C (第2篇)

猜你喜欢

  • 暂无文章