Chapter 57Error Handling Patterns Cookbook

附录C. 错误处理模式手册

概览

第4章介绍了 Zig 的错误联合、tryerrdefer的机制;本附录将这些理念转化为速查手册,供你在设计新 API 或重构现有 API 时参考。每个“菜谱”都强化领域错误词汇与最终呈现给用户的诊断消息之间的联系。

Zig 0.15.2 改进了整数转换与分配器失败相关的诊断,使在 Debug 与 ReleaseSafe 构建中依赖精确的错误传播更容易。v0.15.2

学习目标

  • 在标准 Zig I/O 失败之上分层特定领域错误集,而不丢失精度。
  • errdefer 保护堆支持的转换,使每个退出路径都配对分配和释放。
  • 将内部错误联合转换为日志和用户界面的清晰、可操作消息。

参考文献:见各章节末尾

分层错误词汇

当某子系统引入其专有错误条件时,请细化错误词汇,而非将一切都塞进anyerror。下述模式将解析失败与模拟 I/O 错误组合为配置特定的联合,使调用方不会混淆NotFoundInvalidPort4 采用catch |err| switch惯用法可确保映射准确,并与std.fmt.parseInt呈现解析问题的方式保持一致。fmt.zig

Zig
// ! 演示在加载配置时分层特定领域的错误集。
const std = @import("std");

pub const ParseError = error{
    MissingField,
    InvalidPort,
};

pub const SourceError = error{
    NotFound,
    PermissionDenied,
};

pub const LoadError = SourceError || ParseError;

const SimulatedSource = struct {
    payload: ?[]const u8 = null,
    failure: ?SourceError = null,

    fn fetch(self: SimulatedSource) SourceError![]const u8 {
        if (self.failure) |err| return err;
        return self.payload orelse SourceError.NotFound;
    }
};

fn parsePort(text: []const u8) ParseError!u16 {
    var iter = std.mem.splitScalar(u8, text, '=');
    const key = iter.next() orelse return ParseError.MissingField;
    const value = iter.next() orelse return ParseError.MissingField;
    if (!std.mem.eql(u8, key, "PORT")) return ParseError.MissingField;
    return std.fmt.parseInt(u16, value, 10) catch ParseError.InvalidPort;
}

pub fn loadPort(source: SimulatedSource) LoadError!u16 {
    const line = source.fetch() catch |err| switch (err) {
        SourceError.NotFound => return LoadError.NotFound,
        SourceError.PermissionDenied => return LoadError.PermissionDenied,
    };

    return parsePort(line) catch |err| switch (err) {
        ParseError.MissingField => return LoadError.MissingField,
        ParseError.InvalidPort => return LoadError.InvalidPort,
    };
}

test "successful load yields parsed port" {
    const source = SimulatedSource{ .payload = "PORT=8080" };
    try std.testing.expectEqual(@as(u16, 8080), try loadPort(source));
}

test "parse errors bubble through composed union" {
    const source = SimulatedSource{ .payload = "HOST=example" };
    try std.testing.expectError(LoadError.MissingField, loadPort(source));
}

test "source failures remain precise" {
    const source = SimulatedSource{ .failure = SourceError.PermissionDenied };
    try std.testing.expectError(LoadError.PermissionDenied, loadPort(source));
}
运行
Shell
$ zig test 01_layered_error_sets.zig
输出
Shell
All 3 tests passed.

将原始错误名称一直保留到你的 API 边界——调用者可以显式分支到 LoadError.PermissionDenied,这比字符串匹配或哨兵值更稳健。36

用 errdefer 实现均衡清理

字符串组装和 JSON 整形经常分配临时缓冲;当验证步骤失败时忘记释放它们会直接导致泄漏。通过将 std.ArrayListUnmanagederrdefer 配对,下一个菜谱确保成功和失败路径都正确清理,同时仍返回方便的拥有切片。13 这里使用的每个分配辅助函数都在标准库中提供,因此相同结构可扩展到更复杂的构建器。array_list.zig

Zig
// ! 展示 errdefer 如何在连接用户代码片段时保持分配平衡。
const std = @import("std");

pub const SnippetError = error{EmptyInput} || std.mem.Allocator.Error;

pub fn joinUpperSnippets(allocator: std.mem.Allocator, parts: []const []const u8) SnippetError![]u8 {
    if (parts.len == 0) return SnippetError.EmptyInput;

    var list = std.ArrayListUnmanaged(u8){};
    errdefer list.deinit(allocator);

    for (parts, 0..) |part, index| {
        if (index != 0) try list.append(allocator, ' ');
        for (part) |ch| try list.append(allocator, std.ascii.toUpper(ch));
    }

    return list.toOwnedSlice(allocator);
}

test "joinUpperSnippets capitalizes and joins input" {
    const allocator = std.testing.allocator;
    const result = try joinUpperSnippets(allocator, &[_][]const u8{ "zig", "cookbook" });
    defer allocator.free(result);

    try std.testing.expectEqualStrings("ZIG COOKBOOK", result);
}

test "joinUpperSnippets surfaces empty-input error" {
    const allocator = std.testing.allocator;
    try std.testing.expectError(SnippetError.EmptyInput, joinUpperSnippets(allocator, &[_][]const u8{}));
}
运行
Shell
$ zig test 02_errdefer_join_upper.zig
输出
Shell
All 2 tests passed.

由于标准测试分配器会自动抓到泄漏,同时覆盖成功与错误分支可兼作后续编辑的回归保障。13

为用户翻译错误

即使最精心制作的错误集也需要以富有同理心的语言呈现。最后一个模式演示如何为程序化调用者保持原始 ApiError,同时为日志或 UI 文本生成人类可读的叙述。36std.io.fixedBufferStream 使输出对测试具有确定性,专用格式化器将消息传递与控制流隔离。log.zig

Zig
// ! 将领域错误桥接到面向用户的日志消息。
const std = @import("std");

pub const ApiError = error{
    NotFound,
    RateLimited,
    Backend,
};

fn describeApiError(err: ApiError, writer: anytype) !void {
    switch (err) {
        ApiError.NotFound => try writer.writeAll("resource not found; check identifier"),
        ApiError.RateLimited => try writer.writeAll("rate limit exceeded; retry later"),
        ApiError.Backend => try writer.writeAll("upstream dependency failed; escalate"),
    }
}

const Action = struct {
    outcomes: []const ?ApiError,
    index: usize = 0,

    fn invoke(self: *Action) ApiError!void {
        if (self.index >= self.outcomes.len) return;
        const outcome = self.outcomes[self.index];
        self.index += 1;
        if (outcome) |err| {
            return err;
        }
    }
};

pub fn runAndReport(action: *Action, writer: anytype) !void {
    action.invoke() catch |err| {
        try writer.writeAll("Request failed: ");
        try describeApiError(err, writer);
        return;
    };
    try writer.writeAll("Request succeeded");
}

test "runAndReport surfaces friendly error message" {
    var action = Action{ .outcomes = &[_]?ApiError{ApiError.NotFound} };
    var buffer: [128]u8 = undefined;
    var stream = std.io.fixedBufferStream(&buffer);

    try runAndReport(&action, stream.writer());
    const message = stream.getWritten();
    try std.testing.expectEqualStrings("Request failed: resource not found; check identifier", message);
}

test "runAndReport acknowledges success" {
    var action = Action{ .outcomes = &[_]?ApiError{null} };
    var buffer: [64]u8 = undefined;
    var stream = std.io.fixedBufferStream(&buffer);

    try runAndReport(&action, stream.writer());
    const message = stream.getWritten();
    try std.testing.expectEqualStrings("Request succeeded", message);
}
运行
Shell
$ zig test 03_error_reporting_bridge.zig
输出
Shell
All 2 tests passed.

保持桥接函数纯净——它只应依赖于错误有效负载和写入器——以便消费者可以交换日志后端或在测试期间在内存中捕获诊断。36

随手可用的模式

  • 将低级错误逐字冒泡到最后一个负责边界,然后在一个地方转换它们以保持不变量明显。4
  • errdefer 视为握手:每个分配或文件打开都应在同一作用域内有匹配的清理。fs.zig
  • 为每个公共错误联合提供专用格式化器,使文档和用户消息永不偏离。36

注意与警示

  • 使用 || 合并错误集会保留标签但不保留有效负载数据;若你需要结构化有效负载,应转向带标签的联合体。
  • 分配器支持的辅助函数应直接暴露 std.mem.Allocator.Error——调用者期望像标准库容器一样 try 分配。
  • 此处“菜谱”假定在 debug 或 release-safe 构建下使用;在 release-fast 下,你可能需要为本应触发unreachable的分支添加额外日志。37

练习

  • 扩展 loadPort 使其返回包含主机和端口的结构化配置对象,然后枚举生成的复合错误集。4
  • 添加 joinUpperSnippets 的流式变体,写入用户提供的写入器而非分配,并比较其人体工程学。Io.zig
  • 教导 runAndReport 通过注入格式化器回调在记录前脱敏标识符——用单元测试验证成功和失败路径都尊重钩子。36

替代方案与边界情况

  • 对于长期运行的服务,考虑用指数退避和抖动包装重试循环;第 29 章重新讨论了并发含义。29
  • 若你的错误桥需要本地化,将消息ID与错误标签一起存储,并让更高层格式化最终字符串。
  • Embedded targets with tiny allocators may prefer stack-based buffers or fixed std.BoundedArray instances over heap-backed arrays to avoid OutOfMemory.10

Help make this chapter better.

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