本节摘要:Go 把错误做成普通值——内置 error 接口只有一个返回字符串的方法,函数以"结果加 error"的签名显式传递失败。本节覆盖 errors.New 与格式化构造、if err 检查模式、百分号 w 的错误包装链、errors.Is 与 errors.As 的两种解包、自定义错误类型携带结构化信息,以及错误处理与 panic 的分界线。
阅读完本节,你应当能够:
type error interface { Error() string }
任何实现了返回字符串方法的类型都是 error。函数的惯例是把 error 放最后一个返回值,成功时为 nil。这个"简单到过分"的定义是整个错误处理体系的地基——因为它是接口,你可以在里面塞任何结构化信息(后面自定义错误类型会用到)。
构造错误的两把起子:
errors.New("除数不能为零") // 静态文本 fmt.Errorf("打开文件 %s 失败: %v", name, err) // 带格式化上下文
result, err := divide(10, 2) if err != nil { fmt.Println("发生错误:", err) return } fmt.Println("结果:", result)
这套模式会出现在几乎每个函数里,初学者嫌它啰嗦,维护者爱它诚实:每个可能失败的点都被肉眼看见并当场决策——返回、重试、降级还是记日志,路径全在明面上。异常机制把失败路径藏进调用栈,Go 把它铺在主干道上。
处理姿势速查:
| 场景 | 姿势 |
|---|---|
| 本层能恢复 | 重试、降级、给默认值 |
| 本层加得上下文 | 包装后继续上抛 |
| 无法处理 | 原样上抛,别吞 |
| 绝不该发生 | panic(见第 2 章) |
| 只想记日志后继续 | 记完再显式走分支,别空 if |
⚠️ 常见坑一:
_ = err把错误丢掉。三行代码没事,三千行时它是事故报告里最常见的遗骸。
⚠️ 常见坑二:if 块里 return 后,函数尾部还继续用"失败时的变量"。编译器不拦,逻辑已错。
包装用格式化动词 %w(wrap)而不是 %v。区别在 %w 保留原始 error 的身份,链可以往下追:
func openFile(filename string) (*os.File, error) { file, err := os.Open(filename) if err != nil { return nil, fmt.Errorf("打开文件 %s 失败: %w", filename, err) } return file, nil }
层层包装后,最终的错误信息像洋葱:"处理订单失败: 扣款失败: 打开文件 xx 失败: 权限不足"——每一层加一句当时才知道的上下文。
解包有两个标准函数,分工不同:
// Is:比对哨兵值——"这条链里有没有这个具体的错误实例?" if errors.Is(err, os.ErrNotExist) { // 文件不存在,走创建流程 } // As:找类型——"链里有没有这种类型的错误?取出来读字段。" var pathErr *os.PathError if errors.As(err, &pathErr) { fmt.Println(pathErr.Op, pathErr.Path) // 读出操作与路径 }
记法:Is 问"是不是它",As 问"是不是这类"。Is 用于标准库预定义的哨兵错误(文件不存在、上下文取消等);As 用于提取结构化字段做分支。链条中只要有一环用了 %v 而不是 %w,链就断在那里——Is 与 As 都穿透不过去。
字符串只能给人读,程序要分支就需要结构。自定义类型实现 Error 方法即可:
type ValidationError struct { Field string Reason string } func (e *ValidationError) Error() string { return "字段 " + e.Field + " 校验失败: " + e.Reason } func parseAge(s string) (int, error) { n, err := strconv.Atoi(s) if err != nil { return 0, &ValidationError{Field: "age", Reason: "不是整数"} } if n < 0 || n > 150 { return 0, &ValidationError{Field: "age", Reason: "超出范围"} } return n, nil } // 调用方 _, err := parseAge("abc") var ve *ValidationError if errors.As(err, &ve) { fmt.Println("问题字段:", ve.Field) // 结构化信息可直接用于响应 }
自定义类型的价值:错误从"日志素材"升级为"可编程的数据"。HTTP 层可以把 ValidationError 映射成 400 状态码加字段提示,而其他错误映射成 500——分支依据是类型,不是解析字符串。
工程建议:包对外导出少数哨兵错误(供 Is)与少数错误类型(供 As),别把内部错误类型全导出,否则调用方会依赖你的实现细节。
第 2 章立过规矩,这里补上工程视角:可预期的失败永远 error——网络抖了、文件没了、用户输入错了,这些是正常业务宇宙的一部分;panic 留给不变量崩坏——数组越界、空指针、断言失败,意味着代码有 bug 或数据已腐坏。库作者替调用方 panic 是失礼,服务边缘用 recover 兜底是责任。两者混用的信号是:你开始在 catch 里写业务逻辑。
💡 关键直觉:把 error 当快递单号——每一层经手都签个名(包装加上下文),收件人随时能查全链路;把 panic 当火警——响了就疏散(recover 兜住转 500),没人拿火警做日常通勤。
问答补遗。错误信息该小写还是大写开头? 惯例小写、不以标点收尾——它常被拼接进上层信息。一层包几层合适? 每层加一句"当时在干什么"即可,同一层反复包装同一句是噪音。error 能比较吗? 能 ==,但只对哨兵错误有意义;正确姿势是 Is。goroutine 里的错误怎么传出? 结果通道逐个送出(发送"错误加值"的小结构),主流程收集汇总——错误也是数据,通道照传。
下一节把这些能力装进一个像样的项目结构。