第 14/41 课 · 复合数据类型与面向对象 基础 m02-l05

错误处理:error 接口、错误包装与 sentinel error

本课所属:第二模块:复合数据类型与面向对象 | 课时:m02-l05 | 难度:入门 学完本课你将能够:掌握 Go 语言中错误处理的标准范式,理解错误链机制,并正确使用 panic/recover 处理不可恢复的异常。

一、学习目标

  • 能解释 error 接口的定义及其在 Go 错误处理中的核心地位。
  • 能使用 fmt.Errorf 配合 %w 进行错误包装,并利用 errors.Is 和 errors.As 检查错误链。
  • 能区分哨兵错误(Sentinel Error)与自定义错误类型,并理解 errors.Join 的使用场景。
  • 能正确理解 panic 与 recover 的作用边界,避免滥用导致程序崩溃。

二、知识讲解

2.1 error 接口与自定义错误类型

error 是 Go 语言中用于表示错误状态的标准接口。它是预声明标识符(在 builtin 文档中列出,但不需要 import),仅包含一个方法:

type error interface {
    Error() string
}

为什么这样设计? Go 语言没有像 Java 或 Python 那样的异常类层次结构。错误被视为一种普通的值,可以像其他数据一样被传递、存储和比较。这种设计使得错误处理显式且可控,避免了隐式异常捕获带来的代码流混乱。

自定义错误类型 虽然 errors.New 可以创建简单的错误,但在生产环境中,我们通常需要携带更多信息(如 HTTP 状态码、错误码、堆栈信息等)。为此,我们需要定义实现 error 接口的自定义结构体。

为了支持更精细的错误判断,自定义错误类型可以实现 Is(error) bool 方法。当实现此方法时,errors.Is 在检查该错误类型时会优先调用此方法,从而允许自定义错误与哨兵错误进行逻辑匹配,而不仅仅是指针相等比较。

type MyError struct {
    Code int
    Msg  string
}

func (e *MyError) Error() string {
    return fmt.Sprintf("code=%d, msg=%s", e.Code, e.Msg)
}

// 可选:实现 Is 方法以支持 errors.Is 的逻辑匹配
func (e *MyError) Is(target error) bool {
    // 例如,判断是否匹配特定的哨兵错误
    return target == ErrSpecificError
}

2.2 错误包装与错误链

在多层函数调用中,底层函数返回的错误往往需要向上传递。如果直接返回,调用者将丢失原始的上下文信息(例如“数据库查询失败”背后的具体原因是“连接超时”还是“SQL 语法错误”)。

错误包装(Error Wrapping) Go 1.13 引入了错误包装机制。使用 fmt.Errorf 配合 %w 动词,可以将原始错误包装在一个新的错误中,同时保留原始错误的引用。

// Go 1.13 引入 %w
err := fmt.Errorf("failed to process: %w", originalErr)

Go 1.20 的改进 从 Go 1.20 开始,fmt.Errorf 允许在同一个格式字符串中使用多个 %w,从而支持更复杂的错误链构建。

检查错误链:errors.Is 与 errors.As - errors.Is(err, target error) bool:沿 err 的包装链向上查找,判断链中是否存在与 target 相等的错误。它主要用于检查哨兵错误(Sentinel Error)。 - errors.As(err, target interface{}) bool:沿 err 的包装链查找第一个可以赋值给 target 所指向类型的错误,并将该错误写入 target。target 必须是一个指向某种错误类型或 error 接口的非 nil 指针。它主要用于提取自定义错误中的具体字段。

哨兵错误(Sentinel Error) 哨兵错误是预定义的、全局唯一的错误变量,通常用于表示特定的业务错误状态。由于 errors.Is 对指针类型使用指针相等比较,因此哨兵错误通常定义为包级变量。

errors.Join(Go 1.20+) 当需要同时返回多个错误时(例如批量处理中部分失败),可以使用 errors.Join。它返回一个包含所有错误的复合错误,errors.Is 可以分别命中其中的任意一个错误。

2.3 panic 与 recover 的边界

panic 用于触发程序崩溃,recover 用于恢复。它们通常用于处理不可恢复的异常(如空指针解引用、数组越界),而不是业务逻辑错误。

recover 的两个硬边界 1. 必须直接调用:recover 必须在被 defer 的函数中直接调用才有效。如果 recover 被封装在另一个普通函数中调用,它将返回 nil,无法捕获 panic。 2. 不跨 goroutine:panic 只沿当前 goroutine 的调用栈展开。其他 goroutine 无法 recover 本 goroutine 的 panic。因此,每个可能 panic 的 goroutine 都必须自带 defer/recover。

三、示例代码

示例 1:错误包装与检查

本示例演示如何使用 fmt.Errorf 包装错误,并使用 errors.Is 和 errors.As 检查错误链。

package main

import (
    "errors"
    "fmt"
)

// 哨兵错误:表示记录未找到
var ErrRecordNotFound = errors.New("record not found")

// 自定义错误类型:数据库错误
type DatabaseError struct {
    Code int
    Msg  string
}

func (e *DatabaseError) Error() string {
    return fmt.Sprintf("db error: code=%d, msg=%s", e.Code, e.Msg)
}

// queryDatabase 模拟数据库查询,可能返回哨兵错误或自定义错误
func queryDatabase(id int) error {
    if id == 404 {
        return ErrRecordNotFound
    }
    if id == 500 {
        return &DatabaseError{Code: 500, Msg: "internal server error"}
    }
    return nil
}

func main() {
    // 场景 1:检查哨兵错误
    err := queryDatabase(404)
    if errors.Is(err, ErrRecordNotFound) {
        fmt.Println("Caught sentinel error: record not found")
    }

    // 场景 2:检查自定义错误类型
    err = queryDatabase(500)
    var dbErr *DatabaseError
    if errors.As(err, &dbErr) {
        fmt.Printf("Caught custom error: code=%d\n", dbErr.Code)
    }

    // 场景 3:错误包装与链式检查
    wrappedErr := fmt.Errorf("failed to query: %w", err)

    // 正确的做法:使用 errors.As 检查包装链中是否有 DatabaseError
    var targetDBErr *DatabaseError
    if errors.As(wrappedErr, &targetDBErr) {
        fmt.Printf("Wrapped error contains DatabaseError: code=%d\n", targetDBErr.Code)
    }
}

运行结果(预期输出):

Caught sentinel error: record not found
Caught custom error: code=500
Wrapped error contains DatabaseError: code=500

示例 2:panic 与 recover 的边界

本示例演示 recover 的正确用法、直接调用要求以及不跨 goroutine 的特性。

package main

import (
    "fmt"
    "sync"
)

// safeFunc 演示 recover 在 defer 中直接调用有效
func safeFunc() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Printf("Recovered in safeFunc: %v\n", r)
        }
    }()
    panic("panic in safeFunc")
}

// recoverInNormalFunc 演示 recover 不在 defer 中直接调用
func recoverInNormalFunc() interface{} {
    return recover() // 返回 nil,因为当前没有 panic 正在展开
}

// goroutinePanic 演示 panic 不跨 goroutine
func goroutinePanic() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Printf("Recovered in main goroutine: %v\n", r)
        }
    }()

    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        defer func() {
            if r := recover(); r != nil {
                fmt.Printf("Recovered in child goroutine: %v\n", r)
            }
        }()
        panic("panic in child goroutine")
    }()
    wg.Wait()
}

func main() {
    fmt.Println("=== Test 1: Direct defer recover ===")
    safeFunc()

    fmt.Println("\n=== Test 2: Recover in normal function ===")
    // 调用 recoverInNormalFunc 不会捕获任何 panic,因为它不是 defer
    // 这里我们不调用 panic,所以 recover 返回 nil
    r := recoverInNormalFunc()
    fmt.Printf("Recover result: %v\n", r)

    fmt.Println("\n=== Test 3: Goroutine panic ===")
    goroutinePanic()
}

运行结果(预期输出):

=== Test 1: Direct defer recover ===
Recovered in safeFunc: panic in safeFunc

=== Test 2: Recover in normal function ===
Recover result: <nil>

=== Test 3: Goroutine panic ===
Recovered in child goroutine: panic in child goroutine

四、常见错误与避坑指南

  • 错误写法:使用 errors.Is(err, &MyError{}) 检查自定义错误。 原因:errors.Is 对指针类型使用指针相等比较。&MyError{} 每次求值都创建新指针,与被包装的错误指针不相等。 正确做法:使用 errors.As(err, &target) 检查类型,或定义全局哨兵错误变量并使用 errors.Is。

  • 错误写法:在普通函数中调用 recover() 试图捕获 panic。 原因:recover 只有在被 defer 直接调用的函数中才有效。如果 recover 被封装在另一个函数中,它将返回 nil。 正确做法:确保 defer func() { recover() }() 直接写在可能 panic 的函数中。

  • 错误写法:在 if 条件中直接使用复合字面量而不加括号,如 if err == &MyError{Code: 500}。 原因:Go 语法规定,复合字面量出现在 if/for/switch 的条件位置时必须加括号,否则会被解析为语句块,导致语法错误。 正确做法:写成 if err == (&MyError{Code: 500}),但更推荐定义变量后比较。

五、最佳实践

  1. 优先使用哨兵错误:对于常见的、可预测的业务错误(如“用户不存在”、“权限不足”),定义包级哨兵错误变量,便于调用者使用 errors.Is 进行精确判断。
  2. 使用 errors.As 提取自定义错误:当错误需要携带额外信息(如错误码、详情)时,定义自定义错误类型,并使用 errors.As 在调用处提取这些信息,而不是依赖字符串匹配。
  3. 避免滥用 panic:panic 仅用于处理不可恢复的异常(如程序内部逻辑错误、空指针)。业务逻辑错误应通过返回 error 来处理。
  4. 每个 goroutine 自带 recover:如果 goroutine 可能 panic,必须在 goroutine 内部使用 defer/recover 捕获,防止整个程序崩溃。
  5. 使用 errors.Join 处理批量错误:在批量处理场景中,如果希望收集所有错误而不是在第一个错误处停止,使用 errors.Join 返回复合错误。

六、练习题

  1. 概念题:解释 errors.Is 和 errors.As 的区别,并说明在什么场景下分别使用它们。
  2. 编码题:定义一个自定义错误类型 ValidationError,包含 Field 和 Message 字段。编写一个函数 validate,当输入为空时返回 ValidationError。在主函数中,使用 errors.As 提取错误信息并打印。
  3. 进阶/调试题:分析以下代码,指出存在的问题并修复。
package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("not found")

type DBError struct {
    Code int
}

func (e *DBError) Error() string {
    return fmt.Sprintf("db error: %d", e.Code)
}

func query(id int) error {
    if id == 404 {
        return ErrNotFound
    }
    if id == 500 {
        return &DBError{Code: 500}
    }
    return nil
}

func main() {
    err := query(404)

    // 问题 1:这里无法正确检查 ErrNotFound
    if errors.Is(err, &DBError{}) {
        fmt.Println("Found DBError")
    }

    // 问题 2:这里无法正确检查 DBError
    if errors.Is(err, ErrNotFound) {
        fmt.Println("Found NotFound")
    }
}

七、练习参考答案

参考答案(点击展开 / 收起)

第 1 题

errors.Is 用于检查错误链中是否存在与目标错误相等的错误,适用于哨兵错误(全局唯一变量)的检查。errors.As 用于沿错误链查找第一个可以赋值给目标指针类型的错误,适用于提取自定义错误中的具体字段。

第 2 题

package main

import (
    "errors"
    "fmt"
)

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error: field=%s, msg=%s", e.Field, e.Message)
}

func validate(name string) error {
    if name == "" {
        return &ValidationError{Field: "name", Message: "cannot be empty"}
    }
    return nil
}

func main() {
    err := validate("")
    var ve *ValidationError
    if errors.As(err, &ve) {
        fmt.Printf("Field: %s, Message: %s\n", ve.Field, ve.Message)
    }
}

第 3 题

问题分析: 1. errors.Is(err, &DBError{}) 无法匹配,因为 &DBError{} 是新建指针,与 query 返回的指针不相等。 2. errors.Is(err, ErrNotFound) 应该匹配 ErrNotFound,但代码中 err 是 query(404) 返回的 ErrNotFound,所以这一行应该能匹配。但题目要求修复代码中的逻辑错误,即使用 errors.Is 检查自定义错误类型是错误的。

修复后代码:

package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("not found")

type DBError struct {
    Code int
}

func (e *DBError) Error() string {
    return fmt.Sprintf("db error: %d", e.Code)
}

func query(id int) error {
    if id == 404 {
        return ErrNotFound
    }
    if id == 500 {
        return &DBError{Code: 500}
    }
    return nil
}

func main() {
    err := query(404)

    // 修复 1:使用 errors.Is 检查哨兵错误
    if errors.Is(err, ErrNotFound) {
        fmt.Println("Found NotFound")
    }

    // 修复 2:使用 errors.As 检查自定义错误类型
    var dbErr *DBError
    if errors.As(err, &dbErr) {
        fmt.Printf("Found DBError: code=%d\n", dbErr.Code)
    }
}

八、本课小结

  • error 是 Go 的标准错误接口,自定义错误需实现 Error() string。
  • 使用 fmt.Errorf 配合 %w 进行错误包装,利用 errors.Is 检查哨兵错误,errors.As 提取自定义错误。
  • errors.Join(Go 1.20+)用于组合多个错误。
  • panic 和 recover 仅用于不可恢复的异常,且 recover 必须在 defer 中直接调用,不跨 goroutine。

下一课预告:函数与方法:深入理解 Go 的面向对象编程基础。

问题反馈

本站是纯前端静态站,没有后端也没有账号系统,反馈走 GitHub Issues、讨论区或邮件。

粘贴到 issue 或邮件里能帮我更快定位问题,其中不含你输入的任何内容

其它:查看已有反馈 · 邮件反馈(无需 GitHub 账号):278975598@qq.com