Chapter 04Errors Resource Cleanup

错误与资源清理

概述

第3章为我们提供了塑造数据的工具;现在我们需要严格的方法来报告操作失败时的情况,并能够可预测地释放资源。Zig的错误联合允许你定义精确的失败词汇,通过try传播它们,并在不使用异常的情况下提供信息丰富的名称,如#Error-Set-Type#try中所述。

我们还探讨defererrdefer,这对语句使清理操作紧邻资源获取,这样当错误强制提前返回时,你永远不会丢失文件句柄、缓冲区或其他稀缺资源的踪迹;参见#defer#errdefer

学习目标

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

错误集与传播

Zig中的错误感知API采用显式联合:可能失败的函数返回E!T,它调用的每个助手都使用try将错误向上冒泡,直到某个位置决定如何恢复。这保持了控制流的可观察性,同时仍然让成功路径看起来直接明了,如#Error-Handling中所述。

声明错误集并使用try传播

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

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

// 第4章 §1.1 - 此示例命名错误集合并演示`try`如何
// 在不隐藏的情况下将失败向上转发给调用者

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| {
        // 每个数字必须成功解析;`try`重新抛出任何
        // `ParseError`以保持外部函数契约的准确性
        const digit = try decodeDigit(ch);
        total = total * 10 + digit;
        if (total > 99) {
            // 传播第二个错误变体以演示调用者看到
            // 完整的错误词汇表
            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| {
            // 第4章 §1.2将基于此模式构建,但即使在这里我们也记录
            // 错误名称以便失败的输入保持可观察。
            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 "Global Error Set" GES["global_error_set"] NAMES["Error name strings<br/>Index 0 = empty"] GES --> NAMES NAMES --> ERR1["Index 1: 'OutOfMemory'"] NAMES --> ERR2["Index 2: 'FileNotFound'"] NAMES --> ERR3["Index 3: 'AccessDenied'"] NAMES --> ERRN["Index N: 'CustomError'"] end subgraph "Error Value" ERRVAL["Value{<br/> err: {name: Index}<br/>}"] ERRVAL -->|"name = 1"| ERR1 end subgraph "Error Set Type" 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");

// 第4章 §1.2 - 演示如何使用每个错误的`catch`分支来塑造
// 恢复策略,同时不失去控制流的清晰性。

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 => {
                    // 超时可以通过回退值软化,允许
                    // 循环继续执行"恢复并继续"路径。
                    std.debug.print("probe {} timed out; using fallback 200\n", .{id});
                    break :handler 200;
                },
                error.Disconnected => {
                    // 断开的传感器演示了章节中讨论的
                    // "完全跳过"恢复分支。
                    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 "Inferred Error Set Structure" IES["InferredErrorSet"] FUNC["func: Index<br/>Owning function"] ERRORS["errors: NameMap<br/>Direct errors"] INFERREDSETS["inferred_error_sets<br/>Dependent IES"] RESOLVED["resolved: Index<br/>Final error set"] end subgraph "Error Sources" DIRECTRET["return error.Foo<br/>Direct error returns"] FUNCALL["foo() catch<br/>Called function errors"] IESCALL["bar() catch<br/>IES function call"] end subgraph "Resolution Process" BODYANAL["Analyze function body"] COLLECTERRS["Collect all errors"] RESOLVEDEPS["Resolve dependent IES"] CREATESET["Create error set type"] 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. 特殊情况

    • 内联和comptime调用使用"adhoc"推断错误集,不绑定到任何特定函数
    • 你在前面章节中看到的!void返回类型使用此机制

为什么使用推断错误集?

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

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

使用defer进行确定性清理

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

defer保持释放紧邻获取

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

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

// 第4章 §2.1 - `defer`将清理与获取绑定,使读者能够在
// 一个词法作用域内看到资源的完整生命周期。

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 };
    // 在获取资源后立即放置`defer`,确保其释放操作
    // 在每个退出路径(无论是成功还是其他情况)都会触发。
    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| {
            // 即使作业失败,早期的`defer`也已经调度了
            // 保持资源平衡的清理操作。
            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 "Function Execution" ENTER["Function Entry"] ACQUIRE1["Step 1: Acquire Resource A<br/>defer cleanup_A()"] ACQUIRE2["Step 2: Acquire Resource B<br/>defer cleanup_B()"] ACQUIRE3["Step 3: Acquire Resource C<br/>errdefer cleanup_C()"] WORK["Step 4: Do work (may error)"] EXIT["Function Exit"] end subgraph "Success Path" SUCCESS["Work succeeds"] DEFER_C["Step 3: Run cleanup_C()"] DEFER_B["Step 2: Run cleanup_B()"] DEFER_A["Step 1: Run cleanup_A()"] RETURN_OK["Return success"] end subgraph "Error Path" ERROR["Work errors"] ERRDEFER_C["Step 3: Run cleanup_C() via errdefer"] ERRDEFER_B["Step 2: Run cleanup_B() via defer"] ERRDEFER_A["Step 1: Run cleanup_A() via defer"] RETURN_ERR["Return error"] end ENTER --> ACQUIRE1 ACQUIRE1 --> ACQUIRE2 ACQUIRE2 --> ACQUIRE3 ACQUIRE3 --> WORK WORK -->|"success"| SUCCESS WORK -->|"error"| ERROR SUCCESS --> DEFER_C DEFER_C --> 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");

// 第4章 §2.2 - 分阶段设置使用`errdefer`保护,因此
// 部分初始化的通道在失败时会自动回滚。

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 {
        // 如果后续任何步骤失败,我们执行回滚块,镜像
        // "errdefer回滚部分初始化"章节。
        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;,在不变量被破坏时立即崩溃。请谨慎使用此方法:在Debug和ReleaseSafe构建中,unreachable会陷入陷阱,因此在运行时会大声重新验证这些假设。

注意事项

  • 优先使用小型、描述性的错误集,以便API使用者读取类型时能立即理解他们必须处理的所有失败分支。
  • 记住defer语句以相反顺序执行;将最基本的清理操作放在最后,这样关闭过程会镜像设置过程。
  • catch unreachable视为调试断言——而不是静默合法失败的方式——因为安全模式会将其转换为运行时陷阱。

练习

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

替代方案与边缘情况:

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

Help make this chapter better.

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