《Go 语言编程入门》6.1 error 接口与哨兵错误

错误是 Go 里最普通的值。本节为 TaskAPI 定义 ErrNotFound 与 ErrInvalidTitle 两个哨兵错误,讲清 error 接口的极简设计、errors.New 与 fmt.Errorf 的差别、哨兵错误的判等语义、以及为什么错误要显式返回而不是抛出异常。

6.1 error 接口与哨兵错误

第 5 章结束时,TaskStore 已经能创建和查询任务,但它的错误处理还停留在「返回 nil 表示成功」的粗糙阶段。Get 查不到任务时返回 Task{}, false,Create 遇到空标题时只能返回一个笼统的错误。本节引入两个哨兵错误,让调用方能够精确区分「任务不存在」和「标题非法」。

本节把 TaskAPI 推进到「错误可判别」:定义 ErrNotFound 与 ErrInvalidTitle 两个包级哨兵错误,让 TaskStore 的实现用它们表达失败原因,为 6.2 的 %w 包装与 6.3 的 errors.Is 判别做准备。

6.1.1 error 是一个接口

Go 的错误机制简单到近乎极简:错误就是一个实现了 Error() string 方法的普通值。

type error interface {
	Error() string
}

标准库里的 error 接口只有一个方法。任何类型,只要实现了 Error() string,就是错误。这意味着:

  • 错误是值,可以像 int、string 一样传递、返回、比较、存入变量。
  • 错误没有异常语义——没有 throw,没有栈展开,没有 try/catch。
  • 函数把错误作为最后一个返回值显式交给调用方处理。

这套设计的哲学是:错误是程序正常控制流的一部分,不是「异常情况」。查不到任务是预期内的结果,应该被显式处理,而不是抛出一个异常然后期待有人接住。

6.1.2 显式错误 vs 异常

对比其他语言的异常机制,Go 的取舍很明确:

维度Go 的 error异常(Java/Python)
传递方式返回值抛出 + 栈展开
是否强制处理编译器不强制,但代码风格强制部分语言强制 catch
调用点可见性每行 if err != nil 可见抛出处可能很远
控制流普通返回非局部跳转
性能普通值,无开销异常路径通常昂贵

代价是啰嗦:每个可能失败的调用后面都跟着 if err != nil。收益是控制流清晰——读代码时你知道哪些操作可能失败,失败后代码走向哪里,全部写在明面上。

6.1.3 创建错误的三种方式

项目里创建错误有标准做法,按用途选:

1. errors.New:创建一个只带固定消息的错误,最常用。

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

2. fmt.Errorf:需要动态拼接消息时使用。

err := fmt.Errorf("task %d not found", id)

3. 自定义错误类型:需要携带结构化字段(错误码、字段名等)时使用,6.3 会展开。

type ValidationError struct {
	Field string
	Value string
}
func (e *ValidationError) Error() string { return "invalid " + e.Field }

注意 errors.New 返回的是一个指针(内部指向一个 errorString 结构),所以两次 errors.New("x") 得到的是两个不同的值,== 比较为 false。这一点是理解哨兵错误判等的前提。

6.1.4 哨兵错误

哨兵错误(sentinel error)是预先定义的、包级的、用来表示特定失败原因的错误值。命名约定是 Err 前缀:

var (
	ErrNotFound     = errors.New("task not found")
	ErrInvalidTitle = errors.New("invalid title")
)

调用方通过 == 比较来判断具体原因:

if err == ErrNotFound {
	// 处理「任务不存在」
}

哨兵错误适合表达调用方需要区分的、有限的几种失败类别。TaskAPI 里恰好有两类:

哨兵错误含义调用方可能的处理
ErrNotFound指定 ID 的任务不存在返回 404
ErrInvalidTitle标题为空或超长返回 400

哨兵错误的判等依赖同一个值。所以它必须定义成包级变量、导出、且所有实现都返回这同一个实例,而不是各自 errors.New("not found")——后者消息相同但 == 为 false,判别必然失败。这是一个隐蔽的 bug 来源。

6.1.5 用哨兵错误改造 TaskStore

把哨兵错误接入第 5 章的 MemStore。Get 目前返回 (Task, bool),布尔值能表达「有没有」,但表达不了「为什么没有」。改造方案是让写入类操作返回 error:

type TaskStore interface {
	Create(title string) (Task, error)
	Get(id int64) (Task, error)
	Rename(id int64, title string) error
	List() []Task
}

MemStore 的实现:

type MemStore struct {
	nextID int64
	tasks  map[int64]Task
}

func (m *MemStore) Create(title string) (Task, error) {
	if title == "" {
		return Task{}, ErrInvalidTitle
	}
	m.nextID++
	t := Task{ID: m.nextID, Title: title}
	m.tasks[t.ID] = t
	return t, nil
}

func (m *MemStore) Rename(id int64, title string) error {
	if title == "" {
		return ErrInvalidTitle
	}
	t, ok := m.tasks[id]
	if !ok {
		return ErrNotFound
	}
	t.Title = title
	m.tasks[id] = t
	return nil
}

调用方现在能精确分支:

err := store.Rename(99, "新标题")
switch err {
case nil:
	fmt.Println("成功")
case ErrNotFound:
	fmt.Println("任务不存在")
case ErrInvalidTitle:
	fmt.Println("标题非法")
default:
	fmt.Println("其他错误:", err)
}

switch err 直接比较错误值,语法干净。但要提醒一点:这个写法只适用于未包装的哨兵错误。一旦 6.2 用 %w 包装,switch err 就匹配不上了,必须改用 errors.Is。这是本节和下一节之间的关键过渡。

6.1.6 哨兵错误命名与放置

工程约定:

  • 放在使用它们的最小包里,通常是领域层或存储层的包顶部。
  • 用 var (...) 块集中声明,便于审阅。
  • 注释写清「什么情况下返回这个错误」,因为它是一份契约。
  • 导出(首字母大写),让调用方能比较。
// 包级哨兵错误。实现必须原样返回这些值(或包装后返回),
// 调用方用 errors.Is 判别。
var (
	// ErrNotFound 表示指定 ID 的任务不存在。
	ErrNotFound = errors.New("task not found")
	// ErrInvalidTitle 表示标题为空或超出长度限制。
	ErrInvalidTitle = errors.New("invalid title")
)

6.1.7 错误是值:比较与传递

因为错误是普通值,它可以被比较、传递、存入变量。但要理解 errors.New 的比较语义:

e1 := errors.New("x")
e2 := errors.New("x")
fmt.Println(e1 == e2) // false

实测输出 false。两次 errors.New("x") 消息相同,但返回的是两个不同的值(内部是指向不同对象的指针),== 比较的是身份而非内容。这正是哨兵错误必须共享同一个实例的原因——var ErrNotFound = errors.New(...) 只创建一次,所有地方都引用它,== 才成立。

一个推论:永远不要靠消息文本判断错误。e1.Error() == e2.Error() 为 true,但 e1 == e2 为 false,用文本比较既慢又不可靠。判断错误一律用 errors.Is(下一节)或 == 比较哨兵错误实例。

6.1.8 不要忽略错误

Go 里最容易犯的错误是忽略返回值里的 err。编译器不强制你检查,所以必须靠纪律:

// 危险:错误被丢弃
t, _ := store.Create("")
fmt.Println(t) // 空 Task,错误无声消失

// 正确:显式处理
t, err := store.Create("")
if err != nil {
	return fmt.Errorf("create task: %w", err)
}

下划线 _ 丢弃错误只在「你确定这个错误无关紧要」时使用,比如关闭一个只读文件、或者写日志时。绝大多数情况下,错误要么处理,要么向上传递——绝不静默吞掉。第 15 章做 HTTP 层时,这些被正确传递的错误会变成有意义的响应状态码;如果中途被吞,线上就只剩一个 500。

6.1.9 小结与检查清单

  • 错误是实现了 Error() string 的普通值,不是异常
  • 函数把错误作为最后一个返回值显式交给调用方
  • 用 errors.New 建哨兵错误,fmt.Errorf 拼动态消息,自定义类型带结构化字段
  • 哨兵错误必须包级、导出、同一实例,否则 == 判别失败
  • 未包装时可用 switch err 比较;包装后必须用 errors.Is(下一节)
  • 绝不静默吞掉错误,要么处理要么向上传递
  • 判断错误用实例比较或 errors.Is,绝不比较 Error() 文本
  • errors.New 每次返回新值,哨兵错误必须共享同一实例

下一节处理「错误信息不够用」的问题:一个错误往往需要携带上下文(哪个操作、哪个 ID 失败了),同时又要保留原始原因供上层判别。fmt.Errorf 的 %w 动词就是为此而生。

阅读导航:上一节:5.3 接口设计惯用法 · 下一节:6.2 fmt.Errorf 与 %w 包装 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练