Chapter 04Errors Resource Cleanup

错误与资源清理

概述

第三章为我们提供了塑造数据的工具;现在我们需要严谨的方法来报告操作失败并可预测地释放资源。Zig的错误联合允许你定义精确的失败词汇表,用try传播它们,并在不使用异常的情况下显示信息丰富的名称,具体如#错误集类型#try中所述。

我们还将探讨defererrdefer,这对语句将清理工作置于资源获取的旁边,因此当错误强制提前返回时,你永远不会丢失文件句柄、缓冲区或其他稀缺资源的跟踪;参见#defer#errdefer

学习目标

  • 声明专用的错误集,根据需要合并它们,并用try传播失败,以便调用者明确地承认可能出错的地方。
  • 使用catch将错误转化为可恢复状态,包括日志记录、回退值和结构化控制流退出,具体如#catch中所述。
  • 配对使用defererrdefer来保证确定性的清理,即使你故意用像catch unreachable这样的结构来静默一个错误;参见#unreachable

错误集和传播

在Zig中,具有错误意识的API采用显式的联合:一个可能失败的函数返回E!T,它调用的每个辅助函数都使用try将错误向上冒泡,直到某个地方决定如何恢复。这使得控制流可观察,同时仍然让成功路径看起来很直观,具体如#错误处理中所述。

声明错误集并用try传播

通过命名一个函数可能返回的确切错误,调用者在值出错时可以获得编译时穷尽性和可读的诊断信息。try自动转发这些错误,避免了样板代码,同时对失败模式保持诚实。

Zig
const std = @import("std");

// Chapter 4 §1.1 – this sample names an error set and shows how `try` forwards
// failures up to the caller without hiding them along the way.

const ParseError = error{ InvalidDigit, Overflow };

fn decodeDigit(ch: u8) ParseError!u8 {
    return switch (ch) {
        '0'...'9' => @as(u8, ch - '0'),
        else => error.InvalidDigit,
    };
}

fn accumulate(input: []const u8) ParseError!u8 {
    var total: u8 = 0;
    for (input) |ch| {
        // Each digit must parse successfully; `try` re-raises any
        // `ParseError` so the outer function's contract stays accurate.
        const digit = try decodeDigit(ch);
        total = total * 10 + digit;
        if (total > 99) {
            // Propagate a second error variant to demonstrate that callers see
            // a complete vocabulary of what can go wrong.
            return error.Overflow;
        }
    }
    return total;
}

pub fn main() !void {
    const samples = [_][]const u8{ "27", "9x", "120" };

    for (samples) |sample| {
        const value = accumulate(sample) catch |err| {
            // Chapter 4 §1.2 will build on this pattern, but even here we log
            // the error name so failed inputs remain observable.
            std.debug.print("input \"{s}\" failed with {s}\n", .{ sample, @errorName(err) });
            continue;
        };
        std.debug.print("input \"{s}\" -> {}\n", .{ sample, value });
    }
}
运行
Shell
$ zig run propagation_basics.zig
输出
Shell
input "27" -> 27
input "9x" failed with InvalidDigit
input "120" failed with Overflow

循环之所以能继续进行,是因为每个catch分支都记录了其意图——报告并继续——这反映了生产代码如何跳过格式错误的记录,同时仍然显示其名称。

错误集内部如何工作

在Zig中声明错误集时,你正在创建一个由编译器维护的全局错误注册表的子集。理解这个架构可以阐明为什么错误操作是快速的,以及错误集合并是如何工作的:

graph LR subgraph "全局错误集" GES["global_error_set"] NAMES["错误名称字符串<br/>索引0=空"] GES --> NAMES NAMES --> ERR1["索引1: 'OutOfMemory'"] NAMES --> ERR2["索引2: 'FileNotFound'"] NAMES --> ERR3["索引3: 'AccessDenied'"] NAMES --> ERRN["索引N: 'CustomError'"] end subgraph "错误值" ERRVAL["Value{<br/> err: {name: Index}<br/>}"] ERRVAL -->|"name = 1"| ERR1 end subgraph "错误集类型" ERRSET["Type{<br/> error_set_type: {<br/> names: [1,2,3]<br/> }<br/>}"] ERRSET --> ERR1 ERRSET --> ERR2 ERRSET --> ERR3 end

关键见解:

  • 全局注册表:你整个程序中的所有错误名称都存储在一个具有唯一索引的单一全局注册表中。
  • 轻量级值:错误值只是指向这个注册表的u16标签——比较错误就像比较整数一样快。
  • 错误集类型:当你写error{InvalidDigit, Overflow}时,你正在创建一个引用全局注册表子集的类型。
  • 合并很简单||操作符通过创建一个具有索引并集的新类型来合并错误集——不需要字符串操作。
  • 唯一性保证:错误名称是全局唯一的,所以error.InvalidDigit总是引用同一个注册表条目。

这种设计使得Zig中的错误处理极其高效,同时为调试保留了信息丰富的错误名称。基于标签的表示意味着错误联合与普通值相比,增加的开销极小。

用catch塑造恢复

catch块可以针对特定错误进行分支,选择回退值,或者决定失败结束当前迭代。标记循环澄清了在处理超时与断开连接后我们恢复哪个控制路径。

Zig
const std = @import("std");

// Chapter 4 §1.2 – demonstrate how `catch` branches per error to shape
// 章节4 §1.2 – 演示如何为每个错误分支使用`catch`来塑造
// recovery strategies without losing control-flow clarity.
// 恢复策略,同时不失去控制流的清晰性。

const ProbeError = error{ Disconnected, Timeout };

fn readProbe(id: usize) ProbeError!u8 {
    return switch (id) {
        0 => 42,
        1 => error.Timeout,
        2 => error.Disconnected,
        else => 88,
    };
}

pub fn main() !void {
    const ids = [_]usize{ 0, 1, 2, 3 };
    var total: u32 = 0;

    probe_loop: for (ids) |id| {
        const raw = readProbe(id) catch |err| handler: {
            switch (err) {
                error.Timeout => {
                    // Timeouts can be softened with a fallback value, allowing
                    // 超时可以通过回退值来缓解,允许
                    // the loop to continue exercising the "recover and proceed" path.
                    // 循环继续执行"恢复并继续"路径。
                    std.debug.print("probe {} timed out; using fallback 200\n", .{id});
                    break :handler 200;
                },
                error.Disconnected => {
                    // A disconnected sensor demonstrates the "skip entirely"
                    // 断开的传感器演示了"完全跳过"
                    // recovery branch discussed in the chapter.
                    // 章节中讨论的恢复分支。
                    std.debug.print("probe {} disconnected; skipping sample\n", .{id});
                    continue :probe_loop;
                },
            }
        };

        total += raw;
        std.debug.print("probe {} -> {}\n", .{ id, raw });
    }

    std.debug.print("aggregate total = {}\n", .{total});
}
运行
Shell
$ zig run catch_and_recover.zig
输出
Shell
probe 0 -> 42
probe 1 timed out; using fallback 200
probe 1 -> 200
probe 2 disconnected; skipping sample
probe 3 -> 88
aggregate total = 330

超时降级为缓存的数字,而断开连接则完全放弃样本——这是两种在代码中明确表示的不同恢复策略。

将错误集合并到稳定的API中

当可重用的辅助函数来自不同领域——解析、网络、存储——你可以用||联合它们的错误集来发布一个单一的契约,同时仍然让内部代码对每一步都使用try。保持合并后的集合窄小意味着下游调用者只需考虑你实际打算暴露的失败。

推断错误集

通常你不需要显式列出函数可能返回的每一个错误。Zig支持使用!T语法的推断错误集,编译器通过分析你的函数体来自动确定可以返回哪些错误:

graph TB subgraph "推断错误集结构" IES["InferredErrorSet"] FUNC["func: Index<br/>所属函数"] ERRORS["errors: NameMap<br/>直接错误"] INFERREDSETS["inferred_error_sets<br/>依赖的IES"] RESOLVED["resolved: Index<br/>最终错误集"] end subgraph "错误来源" DIRECTRET["return error.Foo<br/>直接错误返回"] FUNCALL["foo() catch<br/>被调用函数的错误"] IESCALL["bar() catch<br/>IES函数调用"] end subgraph "解析过程" BODYANAL["分析函数体"] COLLECTERRS["收集所有错误"] RESOLVEDEPS["解析依赖的IES"] CREATESET["创建错误集类型"] end DIRECTRET --> ERRORS FUNCALL --> ERRORS IESCALL --> INFERREDSETS BODYANAL --> COLLECTERRS COLLECTERRS --> ERRORS COLLECTERRS --> INFERREDSETS RESOLVEDEPS --> CREATESET CREATESET --> RESOLVED FUNC --> BODYANAL ERRORS --> COLLECTERRS INFERREDSETS --> RESOLVEDEPS

它是如何工作的:

  1. 在分析期间:当编译器分析你的函数体时:

    • 每个return error.Name都会添加到直接的errors集合中
    • 每次调用一个带有其自己的推断错误集的函数,都会向inferred_error_sets添加一个依赖项
    • 调用具有显式错误集的函数会将这些错误添加到errors
  2. 在函数体分析之后:一旦函数体被完全分析:

    • 所有直接错误都从errors中收集
    • 递归地解析依赖的推断错误集
    • 创建一个结合所有可能错误的最终错误集类型
    • 此类型存储在resolved中,并成为函数的错误集
  3. 特殊情况

    • 内联和编译时调用使用不与任何特定函数绑定的“临时”推断错误集
    • 你在前面章节中看到的!void返回类型使用了这个机制

为什么使用推断错误集?

  • 更少维护:当你添加try调用时,错误会自动传播
  • 重构友好:添加返回错误的调用不需要更新签名
  • 仍然类型安全:调用者通过类型推断看到完整的错误集

当你想要对你的API契约进行显式控制时,请声明错误集。当内部实现细节应该决定错误时,使用!T并让编译器推断它们。

用defer实现确定性清理

资源生命周期的清晰性来自于将获取、使用和释放在一个词法块中。 defer确保释放以注册的相反顺序发生,而errdefer则为必须在错误中断进度时回滚的部分设置序列提供补充。

defer使释放在获取旁边

在获取资源后立即使用defer,记录了所有权并保证在成功和失败时都能清理,这对于可能提前退出的易出错作业尤其有价值。

Zig
const std = @import("std");

// Chapter 4 §2.1 – `defer` binds cleanup to acquisition so readers see the
// full lifetime of a resource inside one lexical scope.

const JobError = error{CalibrateFailed};

const Resource = struct {
    name: []const u8,
    cleaned: bool = false,

    fn release(self: *Resource) void {
        if (!self.cleaned) {
            self.cleaned = true;
            std.debug.print("release {s}\n", .{self.name});
        }
    }
};

fn runJob(name: []const u8, should_fail: bool) JobError!void {
    std.debug.print("acquiring {s}\n", .{name});
    var res = Resource{ .name = name };
    // Place `defer` right after acquiring the resource so its release triggers
    // on every exit path, successful or otherwise.
    defer res.release();

    std.debug.print("working with {s}\n", .{name});
    if (should_fail) {
        std.debug.print("job {s} failed\n", .{name});
        return error.CalibrateFailed;
    }

    std.debug.print("job {s} succeeded\n", .{name});
}

pub fn main() !void {
    const jobs = [_]struct { name: []const u8, fail: bool }{
        .{ .name = "alpha", .fail = false },
        .{ .name = "beta", .fail = true },
    };

    for (jobs) |job| {
        std.debug.print("-- cycle {s} --\n", .{job.name});
        runJob(job.name, job.fail) catch |err| {
            // Even when a job fails, the earlier `defer` has already scheduled
            // the cleanup that keeps our resource balanced.
            std.debug.print("{s} bubbled up {s}\n", .{ job.name, @errorName(err) });
        };
    }
}
运行
Shell
$ zig run defer_cleanup.zig
输出
Shell
-- cycle alpha --
acquiring alpha
working with alpha
job alpha succeeded
release alpha
-- cycle beta --
acquiring beta
working with beta
job beta failed
release beta
beta bubbled up CalibrateFailed

即使在失败的作业上,释放调用也会触发,这证明了defer在错误到达调用者之前执行。

defer执行顺序如何工作

理解defererrdefer语句的执行顺序对于编写正确的清理代码至关重要。Zig以LIFO(后进先出)的顺序执行这些语句——即与它们的注册顺序相反:

graph TB subgraph "函数执行" ENTER["函数入口"] ACQUIRE1["步骤1:获取资源A<br/>defer cleanup_A()"] ACQUIRE2["步骤2:获取资源B<br/>defer cleanup_B()"] ACQUIRE3["步骤3:获取资源C<br/>errdefer cleanup_C()"] WORK["步骤4:执行工作(可能出错)"] EXIT["函数出口"] end subgraph "成功路径" SUCCESS["工作成功"] DEFER_B["运行 cleanup_B() (defer)"] DEFER_A["运行 cleanup_A() (defer)"] RETURN_OK["返回成功"] end subgraph "错误路径" ERROR["工作出错"] ERRDEFER_C["通过errdefer运行 cleanup_C()"] ERRDEFER_B["通过defer运行 cleanup_B()"] ERRDEFER_A["通过defer运行 cleanup_A()"] RETURN_ERR["返回错误"] end ENTER --> ACQUIRE1 ACQUIRE1 --> ACQUIRE2 ACQUIRE2 --> ACQUIRE3 ACQUIRE3 --> WORK WORK -->|"成功"| SUCCESS WORK -->|"错误"| ERROR SUCCESS --> DEFER_B DEFER_B --> DEFER_A DEFER_A --> RETURN_OK ERROR --> ERRDEFER_C ERRDEFER_C --> ERRDEFER_B ERRDEFER_B --> ERRDEFER_A ERRDEFER_A --> RETURN_ERR RETURN_OK --> EXIT RETURN_ERR --> EXIT

关键执行规则:

  • LIFO顺序:Defer以相反的注册顺序执行——最后注册的最先运行。
  • 镜像设置:这自然地镜像了初始化顺序,因此清理发生在获取的相反顺序。
  • 总是运行:常规的defer语句在成功和错误路径上都会执行。
  • 条件性errdefer语句仅在作用域因错误退出时执行。
  • 基于作用域:Defer与其封闭的作用域(函数、块等)绑定。

这个LIFO保证确保资源以与获取相反的顺序被清理。当资源相互依赖时,这尤其重要,因为它可以在清理过程中防止释放后使用的情况。

errdefer回滚部分初始化

errdefer是分阶段设置的理想选择:它仅在周围作用域因错误退出时运行,为你提供了一个单一的地方来撤销在失败之前成功的所有操作。

Zig
const std = @import("std");

// Chapter 4 §2.2 – staged setup guarded with `errdefer` so partially
// initialized channels roll back automatically on failure.

const SetupError = error{ OpenFailed, RegisterFailed };

const Channel = struct {
    name: []const u8,
    opened: bool = false,
    registered: bool = false,

    fn teardown(self: *Channel) void {
        if (self.registered) {
            std.debug.print("deregister \"{s}\"\n", .{self.name});
            self.registered = false;
        }
        if (self.opened) {
            std.debug.print("closing \"{s}\"\n", .{self.name});
            self.opened = false;
        }
    }
};

fn setupChannel(name: []const u8, fail_on_register: bool) SetupError!Channel {
    std.debug.print("opening \"{s}\"\n", .{name});

    if (name.len == 0) {
        return error.OpenFailed;
    }

    var channel = Channel{ .name = name, .opened = true };
    errdefer {
        // If any later step fails we run the rollback block, mirroring the
        // “errdefer Rolls Back Partial Initialization” section.
        std.debug.print("rollback \"{s}\"\n", .{name});
        channel.teardown();
    }

    std.debug.print("registering \"{s}\"\n", .{name});
    if (fail_on_register) {
        return error.RegisterFailed;
    }

    channel.registered = true;
    return channel;
}

pub fn main() !void {
    std.debug.print("-- success path --\n", .{});
    var primary = try setupChannel("primary", false);
    defer primary.teardown();

    std.debug.print("-- register failure --\n", .{});
    _ = setupChannel("backup", true) catch |err| {
        std.debug.print("setup failed with {s}\n", .{@errorName(err)});
    };

    std.debug.print("-- open failure --\n", .{});
    _ = setupChannel("", false) catch |err| {
        std.debug.print("setup failed with {s}\n", .{@errorName(err)});
    };
}
运行
Shell
$ zig run errdefer_recovery.zig
输出
Shell
-- success path --
opening "primary"
registering "primary"
-- register failure --
opening "backup"
registering "backup"
rollback "backup"
closing "backup"
setup failed with RegisterFailed
-- open failure --
opening ""
setup failed with OpenFailed
deregister "primary"
closing "primary"

暂存函数仅清理部分初始化的backup通道,而保留未受影响的空名称,并将成功的primary的真正拆卸推迟到调用者退出时。

有意忽略错误

有时你决定一个错误是不可能的——也许你早些时候已经验证了输入——所以你写try foo() catch unreachable;来在不变量被破坏时立即崩溃。请谨慎使用:在调试和ReleaseSafe构建中,unreachable会捕获这些假设,以便在运行时大声地重新验证它们。

注意和警告

  • 倾向于使用小而描述性的错误集,这样API消费者一读类型就能立即掌握他们必须处理的所有失败分支。
  • 记住defer以相反的顺序执行;将最基本的清理放在最后,这样关闭过程就能镜像设置过程。
  • catch unreachable视为调试断言——而不是静默合法失败的方式——因为安全模式会将其变成运行时陷阱。

练习

  • 扩展propagation_basics.zig,使accumulate通过在乘法前检查溢出,接受任意长度的输入,并为“数字太多”的情况提供一个新的错误变体。
  • 用一个记录发生了多少次超时的结构体来增强catch_and_recover.zig,并从main返回它,以便测试可以断言恢复策略。
  • 修改errdefer_recovery.zig,注入一个由其自己的defer保护的额外配置步骤,然后观察当初始化中途停止时defererrdefer如何协作。

替代方案和边缘情况:

  • 与C互操作时,在边界处一次性将外部错误代码转换为Zig错误集,这样你的其余代码就能保持更丰富的类型。
  • 如果一个清理例程本身也可能失败,优先在defer中记录日志,并保持原始错误为主;否则调用者可能会将清理失败误解为根本原因。
  • 对于延迟分配,考虑使用arena或自有缓冲区:它们通过一次性释放所有内容与defer集成,减少了你需要的单个清理语句的数量。

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.