Go 从 0 到精通 · 第 16 课:错误处理 error

学习定位:这是整套 Go 教程的第 16 课。
前置要求:已经完成第 15 课,理解了组合优于继承的设计哲学,掌握了接口和嵌入的使用。
本课目标:掌握 Go 的显式错误处理机制,理解 error 接口,能创建、返回、检查和包装错误,写出清晰可靠的错误处理逻辑。


1. 本课你要解决的核心问题

你已经见过错误处理很多次了——第 13 课的银行系统示例中:

1
2
3
if err := alice.Deposit(200); err != nil {
fmt.Println("存款失败:", err)
}

Go 没有 try-catch,没有异常机制(严格来说有,但不常用)。Go 的错误处理方式是:函数返回一个 error 值,调用者检查它

你需要搞明白以下问题:

  • error 接口到底是什么
  • 怎么创建错误
  • 怎么返回和检查错误
  • 错误怎么层层传递
  • 什么时候用 panic(下一课讲),什么时候返回 error
  • 错误处理的常见模式和最佳实践

学完这一课,你就能写出可靠的 Go 代码了。


2. error 接口

2.1 error 是一个接口

Go 标准库中的 error 接口定义非常简单:

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

只有一个方法:Error() string。任何实现了 Error() string 方法的类型都是一个 error

2.2 判断错误是否为 nil

1
2
3
4
5
6
7
result, err := someFunction()
if err != nil {
// 出错了,处理错误
fmt.Println("错误:", err)
return
}
// 没出错,使用 result

这是 Go 中最最常见的模式:函数的最后一个返回值是 errornil 表示成功,非 nil 表示失败


3. 创建错误

3.1 errors.New

1
2
3
4
5
6
7
8
9
10
import "errors"

var ErrNotFound = errors.New("记录未找到")

func findUser(id int) (string, error) {
if id <= 0 {
return "", ErrNotFound
}
return "小明", nil
}

errors.New 创建一个简单的错误值。同一个错误值可以复用。

3.2 fmt.Errorf

1
2
3
4
5
6
7
8
import "fmt"

func findUser(id int) (string, error) {
if id <= 0 {
return "", fmt.Errorf("用户 %d 不存在", id)
}
return "小明", nil
}

fmt.Errorf 创建带格式化信息的错误。适合需要包含动态信息的错误消息。

3.3 对比

方式 适用场景 示例
errors.New 固定错误消息 errors.New("权限不足")
fmt.Errorf 需要动态信息 fmt.Errorf("用户 %d 不存在", id)

4. 错误处理的基本模式

4.1 逐层检查

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

import (
"errors"
"fmt"
)

func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("除数不能为零")
}
return a / b, nil
}

func main() {
result, err := divide(10, 0)
if err != nil {
fmt.Println("计算失败:", err)
return
}
fmt.Println("结果:", result)
}

4.2 错误沿调用链传递

实际项目中,错误往往要层层传递:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func readConfig(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, err // 把 os.ReadFile 的错误传递上去
}
// 解析 data...
return config, nil
}

func loadApp() error {
config, err := readConfig("config.json")
if err != nil {
return err // 继续传递
}
// 使用 config...
return nil
}

每一层检查错误,决定是处理还是传递。传递时可以原样传,也可以包装后传。

4.3 错误包装(wrapping)

Go 1.13 引入了 %w 动词来包装错误:

1
2
3
4
5
6
7
8
func readConfig(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("读取配置文件 %s 失败: %w", path, err)
}
// ...
return config, nil
}

%w 把原始错误"包裹"在新错误中,保留了错误链。调用者可以用 errors.Iserrors.Unwrap 检查。

4.4 errors.Iserrors.As

1
2
3
4
5
6
7
8
9
10
11
12
13
import (
"errors"
"os"
)

func main() {
_, err := os.Open("不存在的文件.txt")

// errors.Is:判断错误链中是否包含某个特定错误
if errors.Is(err, os.ErrNotExist) {
fmt.Println("文件不存在")
}
}

errors.Is 沿着错误链查找,判断是否匹配目标错误。

errors.As 用于提取特定类型的错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type NotFoundError struct {
Resource string
ID int
}

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

func main() {
err := fmt.Errorf("查询失败: %w", &NotFoundError{Resource: "用户", ID: 42})

var nfe *NotFoundError
if errors.As(err, &nfe) {
fmt.Printf("资源: %s, ID: %d\n", nfe.Resource, nfe.ID)
}
}

5. 自定义错误类型

5.1 为什么需要自定义错误

errors.Newfmt.Errorf 创建的错误只有字符串信息。有时候需要携带更多上下文:

1
2
3
4
5
6
7
8
9
type ValidationError struct {
Field string
Value string
Message string
}

func (e *ValidationError) Error() string {
return fmt.Sprintf("字段 %s 的值 %q 无效: %s", e.Field, e.Value, e.Message)
}

5.2 使用自定义错误

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
func validateAge(age int) error {
if age < 0 {
return &ValidationError{
Field: "age",
Value: fmt.Sprintf("%d", age),
Message: "年龄不能为负数",
}
}
if age > 150 {
return &ValidationError{
Field: "age",
Value: fmt.Sprintf("%d", age),
Message: "年龄不合理",
}
}
return nil
}

func main() {
err := validateAge(-1)
if err != nil {
fmt.Println(err)
// 字段 age 的值 "-1" 无效: 年龄不能为负数

// 用 errors.As 提取详细信息
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("错误字段: %s\n", ve.Field)
}
}
}

6. Sentinel Error(哨兵错误)

6.1 什么是哨兵错误

用包级别的变量定义的预定义错误叫"哨兵错误":

1
2
3
4
5
package sql

import "errors"

var ErrNoRows = errors.New("sql: no rows in result set")

调用者可以用 errors.Is 检查:

1
2
3
4
err := db.QueryRow("SELECT ...").Scan(&result)
if errors.Is(err, sql.ErrNoRows) {
// 没有找到记录
}

6.2 命名规范

Go 社区约定:包级别的错误变量以 Err 开头:

1
2
3
4
5
var (
ErrNotFound = errors.New("not found")
ErrPermission = errors.New("permission denied")
ErrTimeout = errors.New("timeout")
)

7. 常见错误处理模式

7.1 快速失败

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func process(data string) error {
step1, err := parse(data)
if err != nil {
return fmt.Errorf("解析失败: %w", err)
}

step2, err := validate(step1)
if err != nil {
return fmt.Errorf("验证失败: %w", err)
}

err = save(step2)
if err != nil {
return fmt.Errorf("保存失败: %w", err)
}

return nil
}

每一步都检查错误,出错就立即返回。不要忽略错误。

7.2 收集多个错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func validateAll(name, email, age string) error {
var errs []string

if name == "" {
errs = append(errs, "姓名不能为空")
}
if !strings.Contains(email, "@") {
errs = append(errs, "邮箱格式不正确")
}

if len(errs) > 0 {
return fmt.Errorf("验证失败: %s", strings.Join(errs, "; "))
}
return nil
}

7.3 只处理一次错误

1
2
3
4
5
6
7
8
9
10
11
12
// 不好的写法:处理了两次
result, err := doSomething()
if err != nil {
log.Println("错误:", err) // 第一次处理
return err // 第二次处理(传递)
}

// 好的写法:选择一个处理方式
result, err := doSomething()
if err != nil {
return fmt.Errorf("执行失败: %w", err)
}

8. 一段综合示例

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package main

import (
"errors"
"fmt"
"strconv"
)

// 自定义错误类型
type FieldError struct {
Field string
Message string
}

func (e *FieldError) Error() string {
return fmt.Sprintf("字段 %q: %s", e.Field, e.Message)
}

// 验证函数
func validateName(name string) error {
if name == "" {
return &FieldError{Field: "name", Message: "不能为空"}
}
return nil
}

func validateAge(ageStr string) (int, error) {
age, err := strconv.Atoi(ageStr)
if err != nil {
return 0, fmt.Errorf("字段 \"age\" 不是有效数字: %w", err)
}
if age < 0 || age > 150 {
return 0, &FieldError{Field: "age", Message: fmt.Sprintf("值 %d 不在合理范围", age)}
}
return age, nil
}

// 用户注册
func register(name string, ageStr string) error {
if err := validateName(name); err != nil {
return fmt.Errorf("注册失败: %w", err)
}

age, err := validateAge(ageStr)
if err != nil {
return fmt.Errorf("注册失败: %w", err)
}

fmt.Printf("注册成功: %s, %d岁\n", name, age)
return nil
}

func main() {
// 正常注册
fmt.Println("--- 正常注册 ---")
if err := register("小明", "20"); err != nil {
fmt.Println("错误:", err)
}

// 姓名为空
fmt.Println("\n--- 姓名为空 ---")
if err := register("", "20"); err != nil {
fmt.Println("错误:", err)

// 提取 FieldError
var fe *FieldError
if errors.As(err, &fe) {
fmt.Printf("错误字段: %s\n", fe.Field)
}
}

// 年龄不是数字
fmt.Println("\n--- 年龄非法 ---")
if err := register("小红", "abc"); err != nil {
fmt.Println("错误:", err)
}

// 年龄超出范围
fmt.Println("\n--- 年龄超范围 ---")
if err := register("小刚", "200"); err != nil {
fmt.Println("错误:", err)
}
}

输出:

1
2
3
4
5
6
7
8
9
10
11
12
--- 正常注册 ---
注册成功: 小明, 20岁

--- 姓名为空 ---
错误: 注册失败: 字段 "name": 不能为空
错误字段: name

--- 年龄非法 ---
错误: 注册失败: 字段 "age" 不是有效数字: strconv.Atoi: parsing "abc": invalid syntax

--- 年龄超范围 ---
错误: 注册失败: 字段 "age": 值 200 不在合理范围

9. 常见坑总结

9.1 忽略错误

1
2
// 绝对不要这样做
result, _ := doSomething()

下划线 _ 忽略错误在极少数场景下是合理的,但大多数时候应该处理错误。

9.2 用 == 比较包装过的错误

1
2
3
4
5
// 如果 err 被 fmt.Errorf("...: %w", originalErr) 包装过
err == originalErr // false!

// 应该用
errors.Is(err, originalErr) // true

9.3 返回零值和 nil

1
2
3
4
5
6
func findUser(id int) (User, error) {
if id <= 0 {
return User{}, errors.New("invalid id") // 用零值,不要返回 nil
}
// ...
}

如果返回值是结构体,错误时返回零值 User{},不要返回 nil

9.4 用字符串判断错误类型

1
2
3
4
5
// 不好
if err.Error() == "not found" { ... }

// 好
if errors.Is(err, ErrNotFound) { ... }

错误消息可能变化,用 errors.Is 更可靠。

9.5 过度包装

1
2
3
4
5
// 不好:每层都加无意义的前缀
return fmt.Errorf("处理: %w", fmt.Errorf("执行: %w", fmt.Errorf("调用: %w", err)))

// 好:只在能提供额外上下文时包装
return fmt.Errorf("加载配置 %s 失败: %w", path, err)

10. 本课练习

练习 1:创建和返回错误

要求:

  • 写一个函数 sqrt(n float64) (float64, error)
  • 负数返回错误,非负数返回平方根
  • 测试正常和异常情况

练习 2:自定义错误类型

要求:

  • 定义 NetworkError 类型(含 StatusCode intMessage string
  • 写一个函数模拟 HTTP 请求,返回 NetworkError
  • errors.As 提取错误信息

练习 3:错误包装

要求:

  • 写三层调用链:mainloadAppreadConfig
  • readConfig 调用 os.ReadFile 并包装其错误
  • 每层都添加上下文信息
  • errors.Is 检查原始错误类型

练习 4:哨兵错误

要求:

  • 定义包级别的哨兵错误 ErrInsufficientFundsErrNegativeAmount
  • 在银行账户的 WithdrawDeposit 方法中使用
  • 调用者用 errors.Is 判断具体错误类型

练习 5:表单验证

要求:

  • 实现一个函数验证用户名(非空、长度 3-20)、邮箱(包含 @)、年龄(数字、0-150)
  • 每个字段的错误用 FieldError 自定义类型
  • 收集所有错误后一次性返回

11. 自测题

11.1 概念题

  1. Go 的 error 接口定义了什么方法?
  2. nil error 表示什么?
  3. errors.Newfmt.Errorf 有什么区别?
  4. %w 动词在 fmt.Errorf 中做什么?
  5. errors.Is== 比较错误有什么区别?
  6. errors.As 做什么?
  7. 什么是哨兵错误?
  8. 为什么不应该忽略错误?
  9. Go 的错误处理哲学和 try-catch 有什么不同?
  10. 什么时候应该包装错误?

12. 本课总结

这一课你学到了 Go 的核心错误处理机制。

你现在应该已经理解:

  • error 是一个只有 Error() string 方法的接口
  • errors.Newfmt.Errorf 创建错误
  • 函数返回 error,调用者用 if err != nil 检查
  • %w 包装错误,保留错误链
  • errors.Is 判断特定错误,用 errors.As 提取错误类型
  • 自定义错误类型携带更多上下文信息
  • 错误处理的核心原则:不忽略、快失败、提供上下文

13. 下一课预告

下一课我们学习 Go 中处理异常流程的机制:deferpanicrecover

会重点讲:

  • defer 做什么,执行顺序是什么
  • panic 什么时候触发
  • recover 怎么捕获 panic
  • 什么时候该用 panic,什么时候该返回 error

学完下一课,你就能正确管理资源释放,处理不可恢复的错误了。