错误处理:Go、Rust 之外的第三条路
本文基于 Zig 0.16。
三种错误处理范式
错误处理是编程语言设计中最具争议的话题之一。Go 的多返回值、Rust 的 Result<T, E> 枚举、Zig 的错误联合类型——它们代表了三种截然不同的哲学。本文假设你已有 Go 或 Rust 经验,将以此为参照系来理解 Zig 的设计。
Go:多返回值 + if err != nil
Go 的哲学是"显式胜于隐式"——函数可以返回多个值,约定最后一个返回值是 error。每个调用点都必须处理这个值:
| |
优点是你永远知道错误就在那里。缺点同样明显:_(blank identifier)让错误可以被无声忽略——这在整个 Go 社区是长期争论的根源。此外,每个可能出错的调用都需要三行 if err != nil,代码膨胀很快。
Rust:Result 枚举 + ? 操作符
Rust 把错误提升到了类型系统层面。一个可能出错的函数必须在返回类型中显式声明:
| |
Result<T, E> 是一个枚举,? 操作符在遇到 Err 时提前返回,同时会通过 From trait 做自动错误类型转换。相比 Go,Rust 的优点是错误不可能被忽略——你必须决定如何处理。缺点是 Result 携带 payload,每个 Err 的创建都涉及枚举标签 + 泛型 payload 的构造开销。
Zig:错误联合类型
Zig 选择了第三条路,用错误联合类型(error union type)!T 来表示"可能是 T 类型的值,也可能是一个错误":
| |
Zig 没有 if err != nil 也没有 match,而是用 try 和 catch 来处理错误。同时,Zig 的错误没有 payload(error has no payload)——错误只是一个名称,不包含描述信息。这是有意为之的零成本设计(zero-cost design)。
错误集(Error Set)
Zig 用 error{...} 定义一组可能的错误,称为错误集(error set):
| |
每个错误只是一个名称,没有任何附加数据。这和 Go 的 error 接口(可以携带任意值)或 Rust 的 Result<T, E>(E 可以是任意类型)截然不同。Zig 的错误传递只是一个整数比较,没有堆分配、接口装箱或字符串拷贝:
| |
需要详细错误信息怎么办? Zig 的哲学是:通过带外方式传递额外上下文。最常见的模式是在调用方包装信息:
| |
!T:错误联合类型
!T 是语法糖,等价于 anyerror!T,即全局错误超类型与 T 的联合。这个类型要么是某个错误值,要么是 T 类型的值。
与 Rust Result<T, E> 的核心区别对比:
| 维度 | Rust | Zig |
|---|---|---|
| 类型表示 | Result<T, E> 枚举 | !T 或 SpecificError!T |
| 错误 payload | 可以携带任意类型 | 无 payload,仅名称 |
| 运行时开销 | 枚举标签 + payload 构造 | 一个整数(隐式全局错误索引) |
| 错误类型参数 | 需要显式泛型 | 编译器自动推断 |
| 类型转换 | ? 通过 From trait | 无自动转换,错误集名称扁平 |
| |
推荐公开 API 使用 !T 而非限定的 SpecificError!T——因为下层实现可能会引入新的错误类型,用全局错误超类型更灵活。
try:快速失败
try 是 Zig 最常用的错误处理关键字。它等价于 Rust 的 ? 操作符——如果结果是错误,整个函数立即返回该错误;否则解包出正常值。
| |
一行 try 完成了 Go 中需要三行 if err != nil { return err } 做的事。和 Rust 的 ? 不同,try 不做自动错误类型转换——它只是"出错就返回",没有隐藏语义。
注意:try 只能在返回 !T 的函数中使用。在 void 函数中使用 try 会触发编译错误。
catch:现场处理
catch 是 try 的替代方案,让你在原地处理错误而非传播出去。有三种用法:
提供默认值:
| |
捕获错误并处理:
| |
带 blk 标签的 fallback——在 catch 中执行复杂逻辑后仍提供默认值:
| |
blk 是一个块表达式(block expression),break :blk 会将后面的值作为整个 catch 表达式的结果返回。这比 Rust 的 unwrap_or_else(|| { ... }) 和 Go 的 if err != nil { return default } 都更简洁。
errdefer:出错了才执行
errdefer 是 Zig 错误处理中最具独创性的特性。它和 defer 一样在作用域结束时执行,但仅当函数返回错误时才会触发:
| |
这个模式解决了资源管理中一个非常实际的问题:函数中途获取多个资源,如果后续操作失败,已获取的资源需要回滚。
典型配对模式:
| 操作 | 成功时 | 出错时 |
|---|---|---|
alloc 内存 | defer free(无条件) | defer free(无条件) |
acquire 资源 | 显式 release | errdefer release(仅回滚) |
对比 Go:Go 只有 defer,没有 errdefer。你需要在每个可能失败的点手动检查和回滚:
| |
Zig 的 errdefer 让"失败时清理"的语义更加清晰——成功路径和失败路径完全分开,不再需要在错误分支中手动编写回滚逻辑。
多语言实战对比
让我们用一个读文件的例子,将三种语言的代码并排对比:
Go
| |
Rust
| |
Zig
| |
范式总结
| 维度 | Go | Rust | Zig |
|---|---|---|---|
| 返回类型 | (T, error) 多返回值 | Result<T, E> 枚举 | !T 错误联合类型 |
| 错误传播 | if err != nil { return } | ? 操作符 | try |
| 现场处理 | if err != nil { ... } | match / unwrap_or | catch / `catch |
| 错误类型 | error 接口 | 泛型 E | error{...} 具名集 |
| 错误含数据 | 是(任意值) | 是(泛型 payload) | 否(仅名称) |
| 清理机制 | defer | Drop trait | defer + errdefer |
| 能否忽略 | _ 可忽略 | 必须处理 | 必须处理 |
Zig 最独特的点在"无 payload"(no payload)。Go 的 error 接口可以携带任意数据;Rust 的 Err 分支可以包含任意类型。Zig 选择让错误的传递成本为零——它只是一个全局唯一的整数索引。需要详细上下文时,通过日志或其他方式显式传递。这正是零成本抽象(zero-cost abstraction)的体现:不为不需要的特性付费。
下期预告
下一篇将探讨 Zig 的内存管理——为什么 Zig 说"我们不内建内存管理,我们只是提供一个好用的 allocator"。