震惊!Zig 源码里手动实现了 defer 的灵魂 (第5篇)
大家好,我是 Zachel ,这是 Zig 黑魔法系列第5篇。
前几期我们聊了 comptime 作弊、@setEvalBranchQuota 暴力拉配额,今天我们把目光转向 Zig 语法里最优雅、最“反人类”的特性之一:
defer 和 errdefer
一句话总结它的灵魂:
它让“资源获取”和“资源释放”写在一起,却能在所有可能的退出路径上自动执行,而且不复制代码,零运行时开销。
你以为这是编译器自动插入 finally 块?错了!
Zig 是单遍编译(single-pass),没有 LLVM IR 那种可以随意 jump 的奢侈环境。它硬生生在 AstGen → ZIR → AIR 的流程里,手动实现了“所有退出路径都跳到同一个清理基本块”的效果。
这才是 defer 的真正“灵魂”——手动构造控制流图。
先看表面用法(大家都会的)
fn processFile() !void {
const file = try std.fs.cwd().openFile("data.txt", .{});
defer file.close(); // 正常返回、return error、panic 都会 close
const content = try file.readToEndAlloc(allocator, 1<<20);
defer allocator.free(content);
// ... 处理 content ...
}
看起来简单,但如果你展开所有退出路径:
-
return Ok -
return Err1 -
return Err2 -
try 传播错误 -
unreachable / panic
你会发现 defer 块必须在每一个可能离开作用域的出口执行,而且顺序是后进先出(LIFO)。
如果编译器每个出口都复制一遍 defer 代码,那代码膨胀得可怕。
Zig 编译器是怎么做到的?
关键源码位置(都在 src/AstGen.zig)
defer 的“魔法”主要发生在 AstGen 阶段(AST → ZIR)。
核心逻辑大概是这样的(简化版,非逐行抄源码):
// 在处理 Block 时,维护一个 Defer 栈(或链表)
const Defer = struct {
index: TrackedIndex, // defer 语句在 ZIR 中的位置
payload: ?Payload, // errdefer 专属的 error payload
// ...
};
// 当前 scope 有一个 defer_stack: std.ArrayList(Defer)
当遇到 defer expr; 时:
-
不立即生成 expr 的指令 -
把这个 defer 记录到当前 block 的 defer 列表里(栈顶) -
继续生成后面的代码
当 block 结束时(scope pop),AstGen 会:
-
为所有 defer 创建一个统一的清理基本块(cleanup block) -
把所有 defer 的表达式倒序(LIFO)生成到这个 cleanup block 里 -
在每一个可能的退出点(return、break、continue、try error、end of block 等)插入一条跳转指令 → cleanup block -
cleanup block 最后再跳回原来的返回/退出路径
更形象一点:
普通代码:
stmt1
defer A()
stmt2
defer B()
stmt3
return x
实际控制流(ZIR 层面):
stmt1
stmt2
stmt3
├─→ cleanup:
│ B()
│ A()
│ └─→ (原返回路径)
├─→ cleanup (从 return x 跳过来)
└─→ cleanup (如果有错误传播也跳)
所有退出路径都跳到同一个地方,清理代码只生成一份。
这就是“手动实现 defer 的灵魂”——编译器自己构造了一个共享的 cleanup 基本块,并让所有 return/try/break 等指令指向它。
errdefer 更狠一级
errdefer 只在出错路径执行。
编译器额外维护了一个 “error union” 状态,在 ZIR 里用特殊标记区分:
-
正常路径 → 跳过 errdefer 的部分 -
错误路径 → 执行 errdefer
源码里会看到类似这样的逻辑(伪码):
if (is_error_path) {
generate errdefer payloads and expressions
}
generate normal defers
为什么说这是“手动”实现的?
因为 Zig 拒绝走传统路子:
-
没有像 C++ 那样每个 scope 塞一堆 destructor 对象(运行时开销) -
没有像 Go 那样在运行时维护 defer 栈(运行时开销 + panic 复杂) -
而是在编译期就把控制流图算好,把跳转全织进去
代价是编译器前端(AstGen)变复杂了,但换来的是:
-
零运行时开销 -
代码不膨胀(cleanup 只一份) -
panic / return / err / break 全部兼容 -
语义清晰、可预测
这才是 defer 的灵魂:用编译器的脑力换运行时的零负担。
小彩蛋:defer 的限制与哲学
源码里还能看到一些有趣的注释和 issue:
-
defer 变量捕获是值捕获(by value),不是引用(早期 issue 讨论过) -
同一个 scope 里多个 defer 是严格 LIFO(后写的先执行) -
errdefer + defer 混合时,errdefer 先压栈(出错时先执行 errdefer)
这些都是在 AstGen 阶段手动维护 defer 栈顺序实现的。
结语
Zig 的 defer 不是语法糖,它是编译器手动编织控制流的艺术品。
一行 defer file.close(); 背后,是 AstGen 在默默构造跳转、合并清理块、处理错误路径……
下期我们继续深挖:Zig 是如何在单遍编译里实现 errdefer 的“条件跳转合并”,以及它和 comptime 结合能玩出什么更离谱的花样。
喜欢这种“看源码揭秘”的,点个在看 / 收藏 / 转发,下期见~
(关注「 Zachel 」,第一时间解锁更多 Zig 编译器黑魔法)
夜雨聆风