第07章 - 错误处理机制
错误处理是 Go 语言设计哲学的重要体现。与许多语言使用异常(exception)不同,Go 将错误作为普通的返回值显式处理,使错误流清晰可见。本章系统讲解 Go 的错误处理机制,包括 error 接口、错误包装、panic 与 recover。
7.1 error 接口
Go 的错误以 error 接口表示,它是内置接口,定义极其简单:
type error interface {
Error() string
}
任何实现了 Error() string 方法的类型都是一个 error。
7.2 错误处理的基本模式
Go 的惯用模式是:函数将 error 作为最后一个返回值,调用者立即检查:
func readConfig(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err // 向上传播错误
}
return string(data), nil
}
// 调用方
content, err := readConfig("config.yaml")
if err != nil {
log.Fatal(err) // 处理错误
}
fmt.Println(content)
这种 if err != nil 的检查在 Go 代码中无处不在。虽然略显冗长,但它强制开发者正视每一个可能的错误,避免错误被悄悄忽略。
7.3 创建错误
7.3.1 errors.New
创建简单的字符串错误:
import "errors"
var ErrNotFound = errors.New("记录未找到")
func find(id int) error {
if id < 0 {
return errors.New("id 不能为负数")
}
return nil
}
7.3.2 fmt.Errorf
需要格式化错误信息时使用:
func validate(age int) error {
if age < 0 || age > 150 {
return fmt.Errorf("年龄 %d 不在合法范围内", age)
}
return nil
}
7.3.3 哨兵错误(Sentinel Error)
预先定义的、可供比较的错误变量称为哨兵错误,常用于表示特定的错误状态:
var (
ErrNotFound = errors.New("not found")
ErrPermission = errors.New("permission denied")
)
if err == ErrNotFound {
// 特殊处理
}
标准库中的 io.EOF、sql.ErrNoRows 都是典型的哨兵错误。
7.4 自定义错误类型
当需要携带更多上下文信息时,可定义实现了 error 接口的结构体:
type ValidationError struct {
Field string
Value interface{}
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("字段 %s(值 %v)校验失败: %s", e.Field, e.Value, e.Message)
}
func validateName(name string) error {
if name == "" {
return &ValidationError{
Field: "name",
Value: name,
Message: "不能为空",
}
}
return nil
}
调用者可以通过类型断言提取详细信息:
err := validateName("")
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("出错字段:", ve.Field)
}
7.5 错误包装与解包(Go 1.13+)
Go 1.13 引入了错误包装机制,可以在传播错误时附加上下文,同时保留原始错误,形成错误链。
7.5.1 包装错误:%w
在 fmt.Errorf 中使用 %w 动词包装一个错误:
func loadUser(id int) error {
err := queryDB(id)
if err != nil {
return fmt.Errorf("加载用户 %d 失败: %w", id, err)
}
return nil
}
7.5.2 errors.Is:判断错误链中是否包含特定错误
err := loadUser(1)
if errors.Is(err, sql.ErrNoRows) {
// 即使 err 被多层包装,也能正确判断
fmt.Println("用户不存在")
}
errors.Is 会沿着错误链逐层解包比较,替代了直接用 == 比较的局限。
7.5.3 errors.As:提取错误链中特定类型的错误
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("校验错误字段:", ve.Field)
}
7.5.4 errors.Unwrap:手动解包
wrapped := fmt.Errorf("外层: %w", ErrNotFound)
inner := errors.Unwrap(wrapped) // 返回 ErrNotFound
最佳实践:在跨越函数/模块边界传播错误时,使用
%w添加上下文;在需要根据错误类型决策时,使用errors.Is和errors.As,而非直接比较或类型断言。
7.6 panic 与 recover
panic 和 recover 是 Go 处理不可恢复的、程序级异常的机制,但它们不应用于常规的错误处理。
7.6.1 panic
panic 会立即中断当前函数的正常执行流程,开始向上回溯(unwind)调用栈,执行沿途的 defer,最终若无人捕获则导致程序崩溃并打印堆栈信息:
func mustPositive(n int) {
if n < 0 {
panic("数值不能为负")
}
}
触发 panic 的常见情况还包括:数组越界、空指针解引用、向 nil map 写入、类型断言失败等。
7.6.2 recover
recover 只能在 defer 函数中调用,用于捕获 panic、恢复程序的正常执行:
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("发生 panic: %v", r)
}
}()
return a / b, nil // 当 b 为 0 时触发 panic
}
result, err := safeDivide(10, 0)
fmt.Println(result, err) // 0 发生 panic: runtime error: integer divide by zero
这里利用了命名返回值 err,在 defer 中将 panic 转换为普通错误返回。
7.6.3 何时使用 panic
应当极少使用 panic。合理的使用场景包括:
- 程序启动时的初始化失败(如配置文件缺失、必需的环境变量未设置),此时程序无法继续运行。
- 不可能发生的逻辑错误(编程错误),表示一个 bug。
- 库的内部使用 panic,但在公共 API 边界用 recover 转换为 error 返回给调用者。
原则:对于可预期的、调用者应当处理的错误,返回 error;对于不可恢复的、表明程序进入错误状态的情况,才使用 panic。
7.7 错误处理最佳实践
- 不要忽略错误:避免使用
_丢弃 error,除非你明确知道可以忽略。 - 错误只处理一次:要么处理(如记录日志、返回默认值),要么向上传播,不要既记录又返回(会导致重复日志)。
- 添加上下文:传播错误时用
%w说明”在做什么时出错”,便于排查。 - 错误信息小写、不带标点结尾:因为错误常被包装拼接,如
"open file failed"而非"Open file failed."。 - 在边界处理 panic:库代码不应让 panic 逃逸到调用者,应在公共 API 用 recover 转换。
- 优先使用 errors.Is/As:而非直接
==比较或类型断言。
7.8 本章小结
本章详细讲解了 Go 独特的错误处理哲学:将错误作为显式返回值处理,而非依赖异常。我们学习了 error 接口、哨兵错误、自定义错误类型,以及 Go 1.13 引入的错误包装机制(%w、errors.Is、errors.As)。panic/recover 则用于处理真正不可恢复的异常情况,应谨慎使用。良好的错误处理是编写健壮 Go 程序的核心能力。
下一章我们将进入 Go 的招牌特性——并发编程,学习 goroutine 与 channel。