错误处理:Go、Rust 之外的第三条路

本文基于 Zig 0.16。

三种错误处理范式

错误处理是编程语言设计中最具争议的话题之一。Go 的多返回值、Rust 的 Result<T, E> 枚举、Zig 的错误联合类型——它们代表了三种截然不同的哲学。本文假设你已有 Go 或 Rust 经验,将以此为参照系来理解 Zig 的设计。

Go:多返回值 + if err != nil

Go 的哲学是"显式胜于隐式"——函数可以返回多个值,约定最后一个返回值是 error。每个调用点都必须处理这个值:

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func readFile(path string) (string, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return "", err
    }
    return string(data), nil
}

func main() {
    content, err := readFile("test.txt")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(content)
}

优点是你永远知道错误就在那里。缺点同样明显:_(blank identifier)让错误可以被无声忽略——这在整个 Go 社区是长期争论的根源。此外,每个可能出错的调用都需要三行 if err != nil,代码膨胀很快。

Rust:Result 枚举 + ? 操作符

Rust 把错误提升到了类型系统层面。一个可能出错的函数必须在返回类型中显式声明:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fn read_file(path: &str) -> Result<String, io::Error> {
    let content = fs::read_to_string(path)?;
    Ok(content)
}

fn main() {
    match read_file("test.txt") {
        Ok(content) => println!("{}", content),
        Err(e) => eprintln!("Error: {}", e),
    }
}

Result<T, E> 是一个枚举,? 操作符在遇到 Err 时提前返回,同时会通过 From trait 做自动错误类型转换。相比 Go,Rust 的优点是错误不可能被忽略——你必须决定如何处理。缺点是 Result 携带 payload,每个 Err 的创建都涉及枚举标签 + 泛型 payload 的构造开销。

Zig:错误联合类型

Zig 选择了第三条路,用错误联合类型(error union type)!T 来表示"可能是 T 类型的值,也可能是一个错误":

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const FileError = error{
    NotFound,
    PermissionDenied,
};

fn readFile(path: []const u8) ![]const u8 {
    // ...
    return FileError.NotFound;
}

pub fn main() void {
    const content = readFile("test.txt") catch |err| {
        std.debug.print("错误: {}\n", .{err});
        return;
    };
    std.debug.print("{s}\n", .{content});
}

Zig 没有 if err != nil 也没有 match,而是用 trycatch 来处理错误。同时,Zig 的错误没有 payload(error has no payload)——错误只是一个名称,不包含描述信息。这是有意为之的零成本设计(zero-cost design)。

错误集(Error Set)

Zig 用 error{...} 定义一组可能的错误,称为错误集(error set):

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const FileError = error{
    NotFound,
    PermissionDenied,
    OutOfMemory,
};

// 错误集可以合并
const IoError = FileError || error{
    BadFormat,
};

每个错误只是一个名称,没有任何附加数据。这和 Go 的 error 接口(可以携带任意值)或 Rust 的 Result<T, E>E 可以是任意类型)截然不同。Zig 的错误传递只是一个整数比较,没有堆分配、接口装箱或字符串拷贝:

zig
1
return error.NotFound; // 就是这个名字,没有描述字符串

需要详细错误信息怎么办? Zig 的哲学是:通过带外方式传递额外上下文。最常见的模式是在调用方包装信息:

zig
1
2
3
4
5
6
7
8
fn processFile(path: []const u8) !void {
    const content = readFile(path) catch |err| {
        // 由调用方添加上下文
        std.debug.print("处理文件 {s} 失败: {}\n", .{ path, err });
        return err;
    };
    _ = content;
}

!T:错误联合类型

!T 是语法糖,等价于 anyerror!T,即全局错误超类型与 T 的联合。这个类型要么是某个错误值,要么是 T 类型的值。

与 Rust Result<T, E> 的核心区别对比:

维度RustZig
类型表示Result<T, E> 枚举!TSpecificError!T
错误 payload可以携带任意类型无 payload,仅名称
运行时开销枚举标签 + payload 构造一个整数(隐式全局错误索引)
错误类型参数需要显式泛型编译器自动推断
类型转换? 通过 From trait无自动转换,错误集名称扁平
zig
1
2
3
4
5
// 显式声明特定错误集
fn readConfig() FileError![]const u8 { ... }

// 简写形式:任何错误(推荐多数场景)
fn readConfig() ![]const u8 { ... }

推荐公开 API 使用 !T 而非限定的 SpecificError!T——因为下层实现可能会引入新的错误类型,用全局错误超类型更灵活。

try:快速失败

try 是 Zig 最常用的错误处理关键字。它等价于 Rust 的 ? 操作符——如果结果是错误,整个函数立即返回该错误;否则解包出正常值。

zig
1
2
3
4
5
6
7
fn processFile(path: []const u8) !void {
    // try 等价于:mayFail() catch |err| return err;
    const content = try readFile(path);

    // 处理内容...
    _ = content;
}

一行 try 完成了 Go 中需要三行 if err != nil { return err } 做的事。和 Rust 的 ? 不同,try 不做自动错误类型转换——它只是"出错就返回",没有隐藏语义。

注意try 只能在返回 !T 的函数中使用。在 void 函数中使用 try 会触发编译错误。

catch:现场处理

catchtry 的替代方案,让你在原地处理错误而非传播出去。有三种用法:

提供默认值

zig
1
const content = readFile("test.txt") catch "";

捕获错误并处理

zig
1
2
3
4
const result = readFile("test.txt") catch |err| {
    std.debug.print("错误: {}\n", .{err});
    return;
};

带 blk 标签的 fallback——在 catch 中执行复杂逻辑后仍提供默认值:

zig
1
2
3
4
const value = mayFail() catch |err| blk: {
    // 做一些清理或日志工作
    break :blk default_value;
};

blk 是一个块表达式(block expression),break :blk 会将后面的值作为整个 catch 表达式的结果返回。这比 Rust 的 unwrap_or_else(|| { ... }) 和 Go 的 if err != nil { return default } 都更简洁。

errdefer:出错了才执行

errdefer 是 Zig 错误处理中最具独创性的特性。它和 defer 一样在作用域结束时执行,但仅当函数返回错误时才会触发

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
fn createAndUse(allocator: std.mem.Allocator) !void {
    const buffer = try allocator.alloc(u8, 1024);
    defer allocator.free(buffer); // 总是执行

    const resource = try acquireResource();
    errdefer releaseResource(resource); // 仅在函数返回错误时执行

    // 使用资源...
    try doSomethingDangerous();

    // 成功路径:正常释放
    releaseResource(resource);
}

这个模式解决了资源管理中一个非常实际的问题:函数中途获取多个资源,如果后续操作失败,已获取的资源需要回滚。

典型配对模式:

操作成功时出错时
alloc 内存defer free(无条件)defer free(无条件)
acquire 资源显式 releaseerrdefer release(仅回滚)

对比 Go:Go 只有 defer,没有 errdefer。你需要在每个可能失败的点手动检查和回滚:

go
1
2
3
4
5
6
resource, err := acquireResource()
if err != nil {
    return err
}
defer releaseResource(resource)
// 成功路径还要额外显式释放

Zig 的 errdefer 让"失败时清理"的语义更加清晰——成功路径和失败路径完全分开,不再需要在错误分支中手动编写回滚逻辑。

多语言实战对比

让我们用一个读文件的例子,将三种语言的代码并排对比:

Go

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func readFile(path string) (string, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return "", err
    }
    return string(data), nil
}

func main() {
    content, err := readFile("test.txt")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(content)
}

Rust

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fn read_file(path: &str) -> Result<String, io::Error> {
    let content = fs::read_to_string(path)?;
    Ok(content)
}

fn main() {
    match read_file("test.txt") {
        Ok(content) => println!("{}", content),
        Err(e) => eprintln!("Error: {}", e),
    }
}

Zig

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
fn readFile(path: []const u8) ![]const u8 {
    // 简化示例
    _ = path;
    return error.NotFound;
}

pub fn main() void {
    const content = readFile("test.txt") catch |err| {
        std.debug.print("Error: {}\n", .{err});
        return;
    };
    std.debug.print("{s}\n", .{content});
}

范式总结

维度GoRustZig
返回类型(T, error) 多返回值Result<T, E> 枚举!T 错误联合类型
错误传播if err != nil { return }? 操作符try
现场处理if err != nil { ... }match / unwrap_orcatch / `catch
错误类型error 接口泛型 Eerror{...} 具名集
错误含数据是(任意值)是(泛型 payload)(仅名称)
清理机制deferDrop traitdefer + errdefer
能否忽略_ 可忽略必须处理必须处理

Zig 最独特的点在"无 payload"(no payload)。Go 的 error 接口可以携带任意数据;Rust 的 Err 分支可以包含任意类型。Zig 选择让错误的传递成本为零——它只是一个全局唯一的整数索引。需要详细上下文时,通过日志或其他方式显式传递。这正是零成本抽象(zero-cost abstraction)的体现:不为不需要的特性付费。

下期预告

下一篇将探讨 Zig 的内存管理——为什么 Zig 说"我们不内建内存管理,我们只是提供一个好用的 allocator"。