错误处理:Go 的多返回值之道

🔊

Go 的错误处理哲学是"简单、显式、可控"。每个可能出错的函数都可以返回一个错误值,调用方必须显式处理。没有异常、没有隐式传播,一切都在明面上。

error 接口:一个值、一个方法

Go 的 error 接口极简:只有一个 Error() string 方法。

go
1
2
3
type error interface {
    Error() string
}

这意味着任何实现了 Error() string 方法的类型都是错误。Go 标准库提供了许多内置错误类型,最常见的是 errors.Newfmt.Errorf

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package main

import (
    "errors"
    "fmt"
)

func main() {
    // 创建一个基础错误
    err := errors.New("文件不存在")
    fmt.Println(err) // 输出:文件不存在

    // 用 fmt.Errorf 格式化错误信息
    path := "/data/config.yaml"
    err2 := fmt.Errorf("配置文件 %s 读取失败", path)
    fmt.Println(err2) // 输出:配置文件 /data/config.yaml 读取失败
}

错误本质上就是一个值。你可以返回它、比较它、存储它,甚至把它传到其他函数。

错误包装与展开

在大型程序中,错误链非常重要。Go 1.13 引入了错误包装,让你在不丢失原始错误的情况下添加上下文信息。

fmt.Errorf 与 %w

fmt.Errorf%w 动词会包装原始错误,而不是替换它。

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import (
    "errors"
    "fmt"
)

func readFile() error {
    return errors.New("文件打开失败")
}

func processConfig() error {
    err := readFile()
    // 用 %w 包装原始错误
    return fmt.Errorf("无法读取配置: %w", err)
}

func main() {
    err := processConfig()
    if err != nil {
        fmt.Println(err) // 输出:无法读取配置: 文件打开失败
    }
}

errors.Is:判断错误类型

errors.Is 会沿着错误链检查是否有匹配的错误。

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
    "errors"
    "fmt"
)

var (
    ErrNotFound = errors.New("资源未找到")
)

func findUser(id int) error {
    if id <= 0 {
        return ErrNotFound
    }
    return nil
}

func main() {
    err := findUser(-1)
    if errors.Is(err, ErrNotFound) {
        fmt.Println("处理未找到的错误")
    }
}

errors.As:提取自定义错误类型

如果你的错误是自定义类型,errors.As 可以提取出来。

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
    "errors"
    "fmt"
)

// 自定义错误类型
type NotFoundError struct {
    Resource string
    ID       int
}

func (e NotFoundError) Error() string {
    return fmt.Sprintf("%s %d 未找到", e.Resource, e.ID)
}

func findUser(id int) error {
    if id <= 0 {
        return NotFoundError{Resource: "用户", ID: id}
    }
    return nil
}

func main() {
    err := findUser(-1)
    var notFound NotFoundError
    if errors.As(err, &notFound) {
        fmt.Printf("具体信息:%s,ID:%d\n", notFound.Resource, notFound.ID)
    }
}

errors.Unwrap:展开一层

errors.Unwrap 会移除最外层包装,返回内层错误。

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
    "errors"
    "fmt"
)

func inner() error {
    return errors.New("内部错误")
}

func outer() error {
    return fmt.Errorf("外层包装: %w", inner())
}

func main() {
    err := outer()
    fmt.Println("原始错误:", err)          // 外层包装: 内部错误
    fmt.Println("展开一层:", errors.Unwrap(err)) // 内部错误
}

panic 和 recover:仅用于真正不可恢复的错误

Go 的 panicrecover 类似于其他语言的异常机制,但使用场景非常有限:只有当程序遇到真正无法继续的错误(如空指针解引用)时才使用。绝不要用 panic 处理常规错误。

panic:触发致命错误

panic 会中断当前函数执行,开始栈回溯。

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

import "fmt"

func riskyOperation() {
    fmt.Println("开始危险操作")
    panic("出现致命错误")
    fmt.Println("这行不会执行")
}

func main() {
    riskyOperation()
}

recover:捕获 panic

recover 必须在 defer 函数中调用,才能捕获 panic。

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package main

import "fmt"

func safelyDo() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("捕获到 panic:", r)
        }
    }()

    panic("出错了")
}

func main() {
    safelyDo()
    fmt.Println("程序继续执行")
}

defer 配合 recover 的模式

这是处理 panic 的标准模式:在 defer 中 recover。

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import "fmt"

func doWork() (err error) {
    defer func() {
        if r := recover(); r != nil {
            // 将 panic 转换为 error
            err = fmt.Errorf("panic 转换为错误: %v", r)
        }
    }()

    // 可能触发 panic 的代码
    panic("不可恢复的错误")

    return nil
}

func main() {
    err := doWork()
    if err != nil {
        fmt.Println("捕获到错误:", err)
    }
}

记住:panic/recover 是最后手段。能用 error 处理的就不要用 panic。

errors.Join:合并多个错误(Go 1.20+)

Go 1.20 引入了 errors.Join,可以将多个错误合并为一个。

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package main

import (
    "errors"
    "fmt"
)

func validateName(name string) error {
    if name == "" {
        return errors.New("姓名不能为空")
    }
    return nil
}

func validateAge(age int) error {
    if age < 0 || age > 150 {
        return errors.New("年龄必须在 0-150 之间")
    }
    return nil
}

func main() {
    var errs []error

    if err := validateName(""); err != nil {
        errs = append(errs, err)
    }
    if err := validateAge(-1); err != nil {
        errs = append(errs, err)
    }

    if len(errs) > 0 {
        combinedErr := errors.Join(errs...)
        fmt.Println("验证失败:", combinedErr)
    }
}

errors.Join 返回的错误可以用 errors.Iserrors.As 检查所有合并的错误。

最佳实践

何时用 error、何时用 panic

  • 用 error:文件读取失败、网络请求超时、用户输入无效等可预期的错误。
  • 用 panic:空指针解引用、数组越界、类型断言失败等不可恢复的程序错误。
go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 错误示例:用 panic 处理文件读取
func readFile() string {
    data, err := os.ReadFile("config.yaml")
    if err != nil {
        panic(err) // 不要这样做
    }
    return string(data)
}

// 正确示例:用 error 处理文件读取
func readFile() (string, error) {
    data, err := os.ReadFile("config.yaml")
    if err != nil {
        return "", err
    }
    return string(data), nil
}

错误日志记录

不要在底层函数中记录日志,把错误返回给调用方,由调用方决定是否记录。

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 不要这样做
func process() error {
    err := doSomething()
    if err != nil {
        log.Printf("处理失败:%v", err) // 过早记录日志
        return err
    }
    return nil
}

// 正确做法
func process() error {
    return doSomething() // 只返回错误,不记录
}

func main() {
    if err := process(); err != nil {
        log.Printf("处理失败:%v", err) // 由调用方记录
    }
}

错误包装的层次

包装错误时要提供有意义的上下文,但不要重复包装。

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 不要重复包装
func readFile() error {
    return fmt.Errorf("读取文件失败: %w", fmt.Errorf("打开文件失败: %w", errors.New("权限不足")))
}

// 正确做法:每层包装一层上下文
func openFile() error {
    return errors.New("权限不足")
}

func readFile() error {
    err := openFile()
    return fmt.Errorf("读取文件失败: %w", err)
}

错误处理的一致性

保持项目中错误处理的一致性:统一使用 errors.New 定义错误变量,统一包装方式。

go
1
2
3
4
5
6
// 定义标准错误变量
var (
    ErrNotFound     = errors.New("资源未找到")
    ErrInvalidInput = errors.New("无效输入")
    ErrPermission   = errors.New("权限不足")
)

总结

Go 的错误处理看似简单,但背后蕴含着深刻的设计哲学:显式胜于隐式、简单胜于复杂。多返回值让错误处理变得直观,error 接口提供了足够的灵活性,错误包装让错误链变得清晰,而 panic/recover 则为真正不可恢复的情况提供了最后的保障。

记住:错误是值,不是异常。正确地使用 error,谨慎地使用 panic,你的 Go 程序会更加健壮可靠。