Chapter 04

错误处理:error sets 与 try/catch

掌握 Zig 无异常的错误处理体系,让每个错误路径都明确可见

错误处理哲学

为什么没有异常?

异常(Exception)是许多语言(Java、Python、C++)的核心特性,但 Zig 有意不支持它。原因是异常会破坏"无隐藏控制流"原则:任何函数调用都可能在没有明显标志的情况下跳转到远处的 catch 块,让控制流变得不可预测,也难以优化。

Zig 的错误处理完全基于返回值,所有可能失败的操作都在类型签名中体现为 !T(错误联合类型)。编译器强制你处理每一个错误,不可能悄悄忽略。

error 关键字与 error sets

定义错误集合

// 定义错误集(类似枚举)
const FileError = error {
    NotFound,
    PermissionDenied,
    IsDirectory,
    TooLarge,
};

// 合并错误集(使用 || 运算符)
const NetworkError = error {
    ConnectionRefused,
    Timeout,
    DnsResolveFailed,
};

const AppError = FileError || NetworkError;

// 返回错误
fn openFile(path: []const u8) FileError![]u8 {
    _ = path;
    return error.NotFound;  // 返回错误
}

错误联合类型 !T

ErrorSet!T
完整错误联合类型,明确指定可能的错误集合。如 FileError![]u8
!T
推断错误集,编译器自动从函数体推断可能的错误。等价于 anyerror!T 但更精确。
anyerror!T
全局错误集,接受任意错误。用于无法提前知道错误集的通用代码(如接口)。
!void
函数可能失败但成功时不返回值。main 函数常用此类型。

try:错误传播

// try expr 等价于 expr catch |err| return err
fn readAndProcess(path: []const u8) ![]u8 {
    const file = try std.fs.cwd().openFile(path, .{});
    defer file.close();

    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const alloc = gpa.allocator();
    // 如果 readToEndAlloc 失败,错误会自动传播给调用者
    return try file.readToEndAlloc(alloc, 1024 * 1024);
}

// 展开 try 等价形式(帮助理解)
fn withoutTry(path: []const u8) ![]u8 {
    const file = std.fs.cwd().openFile(path, .{}) catch |err| return err;
    defer file.close();
    return &[_]u8{};
}

catch:错误捕获与处理

// 基本 catch:提供默认值
const value = std.fmt.parseInt(i32, "abc", 10) catch 0;
// "abc" 不是有效整数,解析失败,value = 0

// 捕获错误并检查类型
const num = std.fmt.parseInt(i32, "42", 10) catch |err| blk: {
    std.debug.print("解析错误: {}\n", .{err});
    break :blk -1;  // 返回默认值 -1
};
_ = num;

// 在 catch 中再次传播
fn parseOrLog(s: []const u8) !i32 {
    return std.fmt.parseInt(i32, s, 10) catch |err| {
        std.debug.print("Failed to parse '{s}': {}\n", .{ s, err });
        return err;  // 记录日志后再传播
    };
}

// switch 分支处理不同错误
fn handleFileError(path: []const u8) void {
    std.fs.cwd().deleteFile(path) catch |err| switch (err) {
        error.FileNotFound => std.debug.print("文件不存在,跳过\n", .{}),
        error.AccessDenied => std.debug.print("权限不足\n", .{}),
        else => |e| std.debug.print("未知错误: {}\n", .{e}),
    };
}

errdefer:错误路径专属清理

errdefer 只在函数以错误返回时执行,正常返回时不执行。这使得"成功路径转移所有权、失败路径清理资源"的模式非常简洁。

fn createResource(allocator: std.mem.Allocator) !*SomeResource {
    const res = try allocator.create(SomeResource);
    errdefer allocator.destroy(res);  // 只在错误时释放
    // 如果正常返回,调用者拥有 res,不会被释放

    try res.init(allocator);           // 如果失败,errdefer 触发
    errdefer res.deinit(allocator);   // 如果后续步骤失败,清理 init 的资源

    try res.connectToDatabase();      // 如果失败,上面两个 errdefer 都触发

    return res;  // 成功:两个 errdefer 都不触发,所有权转移给调用者
}

const SomeResource = struct {
    data: ?[]u8 = null,

    fn init(self: *SomeResource, allocator: std.mem.Allocator) !void {
        self.data = try allocator.alloc(u8, 256);
    }
    fn deinit(self: *SomeResource, allocator: std.mem.Allocator) void {
        if (self.data) |d| allocator.free(d);
    }
    fn connectToDatabase(self: *SomeResource) !void {
        _ = self;
        return error.ConnectionFailed;
    }
};

与 Rust Result 的对比

特性 Zig (!T) Rust (Result<T,E>)
语法 !T(编译器合并错误集) Result<T, E>(泛型枚举)
错误类型 全局错误集(整数标签) 任意类型(可携带数据)
错误载荷 只有错误名,无附加数据 可携带任意数据(如错误消息字符串)
传播 try expr expr?
处理 catch |err| { ... } match result { Ok(v) => ..., Err(e) => ... }
清理 errdefer Drop trait 自动处理
Zig 错误不能携带数据

Zig 的错误值只是一个整数标签(错误名称),不能携带额外的字符串或上下文信息。这是为了保持错误在寄存器中传递,避免动态分配。如果需要携带上下文(如"文件 '/tmp/foo.txt' 不存在"),可以通过日志系统或额外的 out 参数传递诊断信息。

测试错误路径

const std = @import("std");
const testing = std.testing;

fn divide(a: f64, b: f64) !f64 {
    if (b == 0.0) return error.DivisionByZero;
    return a / b;
}

// 测试成功路径
test "divide success" {
    const result = try divide(10.0, 2.0);
    try testing.expectApproxEqAbs(result, 5.0, 0.0001);
}

// 测试错误路径
test "divide by zero" {
    const result = divide(10.0, 0.0);
    // expectError 断言函数返回指定错误
    try testing.expectError(error.DivisionByZero, result);
}

// 运行测试
// zig test src/main.zig
// zig build test --summary all

错误追踪:返回错误追踪栈

Zig 在 Debug 模式下支持错误追踪(Error Return Traces),当 try 传播错误时记录调用栈,帮助定位错误来源:

const std = @import("std");

fn level3() !void {
    return error.SomethingWentWrong;
}

fn level2() !void {
    try level3();  // try 在这里记录错误追踪
}

fn level1() !void {
    try level2();  // try 在这里记录错误追踪
}

pub fn main() !void {
    try level1();
}
// 运行输出(Debug 模式):
// error: SomethingWentWrong
// /path/to/main.zig:4:5: 0x10... in level3 (main)
// /path/to/main.zig:8:5: 0x10... in level2 (main)
// /path/to/main.zig:12:5: 0x10... in level1 (main)
catch 会截断错误追踪

使用 catch 捕获错误后,错误追踪栈会在 catch 处截断。如果只是为了记录日志再重新抛出,建议用 catch |err| { log.err("...", .{}); return err; } 而不是 catch unreachable,后者在遇到错误时直接触发未定义行为(Release 模式)。

unreachable 与 @panic

Zig 提供两种"这里不应该到达"的机制:

// unreachable:告诉编译器此处不可达
// Debug/ReleaseSafe:到达时 panic
// ReleaseFast/ReleaseSmall:UB,编译器可假设不到达并优化
fn getDirection(n: u8) []const u8 {
    return switch (n) {
        0 => "north",
        1 => "south",
        2 => "east",
        3 => "west",
        else => unreachable,  // 调用者保证只传 0-3
    };
}

// @panic:总是 panic(所有模式下),打印消息并退出
// 用于明确的不可恢复错误
fn requiresInitialization(state: *State) void {
    if (!state.initialized) {
        @panic("State must be initialized before calling this function");
    }
}

完整实战:配置文件解析器

综合运用错误集、try/catch 和 errdefer,实现一个解析简单 key=value 配置文件的函数:

const std = @import("std");

/// 配置解析可能遇到的错误
const ConfigError = error {
    InvalidFormat,   // 行格式不是 key=value
    EmptyKey,        // 键名为空
    DuplicateKey,    // 键名重复
};

/// 解析 key=value 格式的配置字符串
/// 返回的 map 由调用者负责 deinit
pub fn parseConfig(
    allocator: std.mem.Allocator,
    input: []const u8,
) (ConfigError || std.mem.Allocator.Error)!std.StringHashMap([]const u8) {
    var map = std.StringHashMap([]const u8).init(allocator);
    errdefer map.deinit();  // 如果解析中途出错,自动释放已创建的 map

    var lines = std.mem.splitScalar(u8, input, '\n');
    while (lines.next()) |line| {
        // 跳过空行和注释行
        const trimmed = std.mem.trim(u8, line, " \t\r");
        if (trimmed.len == 0 or trimmed[0] == '#') continue;

        // 查找 '=' 分隔符
        const eq_pos = std.mem.indexOfScalar(u8, trimmed, '=')
            orelse return error.InvalidFormat;

        const key = std.mem.trim(u8, trimmed[0..eq_pos], " \t");
        const value = std.mem.trim(u8, trimmed[eq_pos + 1..], " \t");

        if (key.len == 0) return error.EmptyKey;
        if (map.contains(key)) return error.DuplicateKey;

        try map.put(key, value);
    }

    return map;  // errdefer 不触发,map 所有权转移给调用者
}

// 使用示例
pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const alloc = gpa.allocator();

    const config_str =
        \\# 服务器配置
        \\host = localhost
        \\port = 8080
        \\debug = true
    ;

    var config = try parseConfig(alloc, config_str);
    defer config.deinit();

    if (config.get("host")) |host| {
        std.debug.print("主机: {s}\n", .{host});
    }

    // 测试错误处理
    const bad_input = "invalid-line-without-equals";
    const result = parseConfig(alloc, bad_input);
    try std.testing.expectError(error.InvalidFormat, result);
}

本章小结

本章核心要点