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 包装 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。