乐于分享
好东西不私藏

震惊!Zig 源码里手动实现了 defer 的灵魂 (第5篇)

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

震惊!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; 时:

  1. 不立即生成 expr 的指令
  2. 把这个 defer 记录到当前 block 的 defer 列表里(栈顶)
  3. 继续生成后面的代码

当 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 编译器黑魔法)


本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » 震惊!Zig 源码里手动实现了 defer 的灵魂 (第5篇)

猜你喜欢

  • 暂无文章