Day 22 | 包装错误别丢链:%w、errors.Is 与 errors.As
本文是《100天精通Go基础入门》专栏第 22 篇,订阅读到这里就算跟昨天接上了。
摘要 :本文基于 Go 1.27,终端与报错截图全部由本机go run/go build真实抓取。昨天我们立了"错误即值"的规矩,也把哨兵错误用==认了出来------可一旦中间层给错误套了句上下文,==当场失灵。今天补上缺的那一环:用fmt.Errorf的%w把底层错误挂进链里,再靠errors.Is隔着包装认出哨兵、errors.As顺着链捞出具体类型的字段,并用errors.Unwrap手动剥一层看个究竟。顺带踩一个更硬的坑:把errors.New当成fmt.Errorf传格式参数,编译直接过不去。读完你手上会有一条上下文齐全、又随时能被识别的错误链,和三件套该用哪个的判断直觉。
本文目录
-
昨天结尾那句"链断了",今天先复现,再顺带踩个编译错误
-
换 %w:上下文照样加,一条链都不落
-
三层链 + Unwrap:错误上下文怎么一层层攒齐
-
为什么非要有 Is/As/Unwrap,而不是一个 catch
-
回头看:给包装错误串链做个小结
-
给你留的活儿
-
预告:明天 Day 23,才轮到 panic
昨天 Day 21 我们说死了两件事:error 就是个普通值,哨兵错误靠包级变量给"这一类错误"起个名,调用方在没被包装过 时能用 == 认出来(id==404 走"不存在"分支那段)。但那节末尾我故意留了个尾巴:中间层一写 errors.New("读取配置失败: " + err.Error()),错误被拼成字符串再抛上去,== ErrNotFound 就永远比不中了------值已经不是同一个。今天就来收这笔账。你就得到一条既能加上下文、又能被认出来的错误链嘛。
先把今天的主图摆出来:错误像洋葱,一层 %w 裹一层,外层有外层的上下文,最里层还是那个原始哨兵。

前置条件 :Go 1.13 以上(Is/As/Unwrap 1.13 起进标准库 errors),示例目录 D:\go\day22,下面每张终端图都是本机 go1.27.0 真跑抓的。

昨天结尾那句"链断了",今天先复现,再顺带踩个编译错误
先把"字符串还在、链没了"这个现象坐实。你可以跑一段最直觉的包装:fmt.Errorf("查询用户失败: %v", err)------注意是 %v。它把底层错误格式化进来了,读着挺全,可链在这一刻已经断:errors.Is 对着它一头雾水。这属于逻辑坑,程序照跑不误,原地就把 false 打印给你看(下一节我们把它和 %w 摆一起对比)。
比它更硬的是另一类新手手滑:errors.New 长得像 fmt.Errorf,于是就有人顺手写 errors.New("包装: %w", err)。这个不是逻辑问题,是编译期就"咣"地直接给你怼回来:
Plaintext
// 《100天精通Go基础入门》Day 22 · 平头哥 · 技术改变世界,勤奋改变人生
package main
import (
"errors"
"fmt"
)
var ErrBase = errors.New("底层出错")
func main() {
// 坑:errors.New 只收一个 string,不能像 fmt.Errorf 那样带格式参数
err := errors.New("包装一下: %w", ErrBase) // too many arguments
fmt.Println(err)
}

看现象:终端直接甩出 # command-line-arguments 打头,再一行 too many arguments in call to errors.New / have (string, error) / want (string),go run 替你带回退出码 1(非 0 即失败)。这里有个边界必须先钉死:errors.New**** 的前置条件就是"只有一个纯字符串",它压根不解析格式动词 ;想带 %w/%v 去包装,唯一正当的门是 fmt.Errorf。注意适用场景------一次性、无格式的错误消息用 errors.New 更省;需要拼上下文或挂链,fmt.Errorf 才够。风险在于:把两者当同一个函数混用,轻则编译不过,重则 %w 写成 %v 时静默断链,后面 errors.Is 全线失灵。
换 %w:上下文照样加,一条链都不落
先说最要紧的那一步------把动词从 %v 换成 %w,一个字母之差,命运两样。跑一段对照实验,同一个底层哨兵,分别用 %v 和 %w 包一层,再各问一次 errors.Is:
Plaintext
// 《100天精通Go基础入门》Day 22 · 平头哥 · 技术改变世界,勤奋改变人生
package main
import (
"errors"
"fmt"
)
// 底层抛一个哨兵:数据库连不上
var ErrDBDown = errors.New("database is down")
func query() error { return ErrDBDown }
func main() {
base := query()
// %v 只把内容格式化进字符串,底层那个 error 值没有挂上链
wv := fmt.Errorf("查询用户失败: %v", base)
// %w 同样打印这句话,却把 base 记进了错误链,Is 能顺藤摸到它
ww := fmt.Errorf("查询用户失败: %w", base)
fmt.Printf("%%v 包装后 Is 认出来: %v\n", errors.Is(wv, ErrDBDown))
fmt.Printf("%%w 包装后 Is 认出来: %v\n", errors.Is(ww, ErrDBDown))
fmt.Println("两者打印字符串一样吗?", wv.Error() == ww.Error())
}


运行结果三行,现象一目了然:%v 包装后 Is 认出来: false、%w 包装后 Is 认出来: true、两者打印字符串一样吗? true。退出码 0。第三行才是今天的题眼:两种写法 .Error() 打出来的字符串一模一样 ,都是 查询用户失败: database is down------差别全在看不见的链上。为什么 errors.Is 能隔着包装认出哨兵?机制是:fmt.Errorf 遇到 %w,会让返回的错误多带一个 Unwrap() error 方法,errors.Is 就顺着这个 Unwrap 一层层往里比,比到最里层那个 ErrDBDown 就点头;而 %v 根本不产生 Unwrap,链"啪"一下就断,Is 自然扑空。这三段代码我自己在 D:\go\day22 挨个跑过,退出码全是 0,截图就是凭据。
一句"是不是":errors.Is 而不是 ==
这里顺带补昨天 Day 21 留的坑:哨兵被包装过之后,== 失灵,errors.Is(err, ErrNotFound) 才对。对比一下就清楚------== 比的是"这一个 error 值本身",errors.Is 比的是"这条链上有没有一个值等价于目标"。所以只要错误可能被 %w 包过,判断分支一律用 errors.Is,别再赌 ==。

errors.Is 认哨兵,errors.As 把状态码捞出来
Is 回答"是不是这个哨兵",那要是我压根不关心等不等、就想把当初那个带字段的具体错误类型 取回来呢?这就是 errors.As 的活。定义一个结构化错误 *HTTPError,里面带个 Code,底层把它 %w 一层层包上来,顶层用 errors.As 一捞就还原成强类型:
Plaintext
// 《100天精通Go基础入门》Day 22 · 平头哥 · 技术改变世界,勤奋改变人生
package main
import (
"errors"
"fmt"
)
// 结构化错误:实现 error 接口,额外带业务字段
type HTTPError struct {
Code int
Msg string
}
func (e *HTTPError) Error() string { return fmt.Sprintf("HTTP %d: %s", e.Code, e.Msg) }
func fetch() error { return &HTTPError{Code: 404, Msg: "not found"} }
func main() {
err := fmt.Errorf("拉取远程配置失败: %w", fetch()) // %w 保住链
var he *HTTPError
if errors.As(err, &he) { // 沿链找到 *HTTPError,把值写进 he
fmt.Printf("As 拿到具体类型, 状态码 = %d\n", he.Code)
fmt.Println("可以按状态码分支:", he.Code == 404)
} else {
fmt.Println("As 没匹配到 *HTTPError")
}
}


预期输出两行,验证完全对上:As 拿到具体类型, 状态码 = 404、可以按状态码分支: true,退出码 0。看现象:errors.As(err, &he) 顺着链往里找,找到第一个能赋给 *HTTPError 的值,把最底层那个 &HTTPError{Code:404,...} 原封不动塞进 he------于是外层那句"拉取远程配置失败"的上下文你留着,里层的 Code 字段也够得着。这里有个前置条件别踩:As 的第二个参数必须是指向"实现了 error 的指针类型"的指针,也就是 &he 而 he 是 *HTTPError,少一个 & 就编译不过。Is 与 As 的分工,画一张对照:

一句"是什么":为什么要 As 不用裸断言
有人会问:直接 err.(*HTTPError) 类型断言不就行了?为什么?因为裸断言只作用在"最外面这一层"的接口值上------现在 err 是 fmt.Errorf 造的那个包装类型,不是 *HTTPError,断言当场 panic(还记得 Day 19 那个从下午追到晚上的裸断言吗)。errors.As 是沿链 找,哪怕 *HTTPError 被裹在第三层,它也能摸出来并安全写进目标,找不到就走 else 分支,不会炸。
三层链 + Unwrap:错误上下文怎么一层层攒齐
把前面串起来,做一个最贴近真实工程的形状:三层调用,每层只加一句"我这层在干嘛"的上下文,统一用 %w 挂链,最后 main 一次看到全部,还能用 errors.Unwrap 手动剥一层。
Plaintext
// 《100天精通Go基础入门》Day 22 · 平头哥 · 技术改变世界,勤奋改变人生
package main
import (
"errors"
"fmt"
"os"
)
var ErrNotExist = errors.New("文件不存在")
// 最底层:只说"哪儿坏了",不加戏
func openConfig() error { return ErrNotExist }
// 中层:加一句本层场景,用 %w 把底层挂进链
func readConfig() error {
if err := openConfig(); err != nil {
return fmt.Errorf("读取配置失败: %w", err)
}
return nil
}
// 上层:再加一句启动场景,链继续往上传
func boot() error {
if err := readConfig(); err != nil {
return fmt.Errorf("启动失败: %w", err)
}
return nil
}
func main() {
wd, _ := os.Getwd()
fmt.Println("工作目录:", wd)
err := boot()
fmt.Println("攒齐上下文的错误:", err)
fmt.Println("剥一层 Unwrap:", errors.Unwrap(err))
fmt.Println("最底层的哨兵还在吗?", errors.Is(err, ErrNotExist))
}


运行结果四行(完整见终端图):第一行 工作目录: D:\go\day22,和正文教的目录一字不差,这张图顺带当了"示例目录"的验证信号;第二行是攒齐上下文的整条链 启动失败: 读取配置失败: 文件不存在;第三行 Unwrap 只剥掉最外层,露出 读取配置失败: 文件不存在;第四行 errors.Is 一路摸到最里层,true。退出码 0。对比昨天 Day 21 那条用 errors.New 手工拼出来的链------字符串看着一样,今天这条能被 Is 认出哨兵、能被 Unwrap 剥开,差别就在这。为什么用 Unwrap?因为它是 Is/As 共同的地基:两个高层函数内部都是反复调 Unwrap 顺链下钻,你手动调它只是把这套动作显式演一遍。
对了,这套东西别只停在 go run 里,拿 go build 编成能分发的 exe 验一遍:
Plaintext
// 编译今天最后一个示例(三层链 + Unwrap),独立成二进制
go build -o day22.exe
// go build 成功零输出,追一条列目录命令,把产物体积落进截图当凭据
powershell -NoProfile -Command "Get-ChildItem day22.exe | Format-Table Name,Length"


命令会话图里两条命令退出码都是 0:go build 安安静静产出 day22.exe,evidence 记下的产物体积是 2,534,912 字节(约 2.5 MB,精确以截图为准)。这一步顺带把"上下文齐全的链"从 demo 升格成能拷走的程序:三层 boot → readConfig → openConfig、Is/As/Unwrap 全编进二进制,不靠 Go 源码也能跑。
为什么非要有 Is/As/Unwrap,而不是一个 catch
Java 派到这大概要拍桌子:整这么三个函数干嘛,一个 catch 按类型捕获不就完了?之所以 Go 不这么设计,是因为它把"错误"当成了值 而不是跳转 。值要能被比较、能被提取,那就得给"值之间的等价关系"和"值的真实类型"各配一把钥匙:Is 管等价(是不是同一个哨兵,哪怕隔着包装),As 管类型(到底是不是这个具体结构),Unwrap 管链的结构(上一层到下一层怎么走)。这么拆开的好处是------错误如何流动全程摆在明面上,和昨天 Day 21 讲的"每一步分支都写出来"是同一套哲学,编译器不替你跳,你也就不会在半夜被一条看不见的 throw 背刺。
三件套的地基是 Unwrap,别用错门
还有个有意思的点:Is 和 As 都不是凭空认得链的,它俩内部靠的就是 Unwrap。所以只要链在(用了 %w),Is/As 自动就好使;链一断(用了 %v,或者像昨天那样 errors.New(s + err.Error()) 手工拼),两个一起失灵。这也是为什么今天的口诀里,"用什么动词包装"要排在"怎么判断"前面------先把链留住,再谈认不认得出。
早年那些绕法,现在算替代方案的坑
顺带交代条时间线:Go 1.13 之前,想"隔着包装认错误"要么自己一层层手剥 interface{ Unwrap() error },要么引扩展库 golang.org/x/xerrors。1.13 把 Is/As/Unwrap 合进标准库 errors 之后,xerrors 就成了过时的替代方案,如今新代码不再推荐引它。这套三件套的机制从 1.13 稳到今天没动过,属于经典原理,学一次长期适用。
先把今天几个易混动作钉成一张表哈:
| 你想干什么 | 用哪个 | 会不会留住链 |
|---|---|---|
| 造一条一次性错误 | errors.New("...") |
无链可言 |
| 加上下文还要让 Is/As 认得 | fmt.Errorf + %w |
留住 |
| 只是拼字符串、不求认得 | fmt.Errorf + %v |
断链 |
| 判断链上有没有某哨兵 | errors.Is(err, 哨兵) |
依赖有链 |
| 取出某个具体类型的字段 | errors.As(err, &目标) |
依赖有链 |
回头看:给包装错误串链做个小结
把今天压成三句带走的话。第一句,包装一律 %w ,别 ****%v :同一个 fmt.Errorf,换个动词,字符串分毫不差、链却一条在一条断------errors.Is 一验就露馅(今天实测 false / true)。第二句,判"是不是"用 errors.Is ,取"是什么"用 ****errors.As ,两者都顺链往里摸,裸 == 和裸断言只在最外层作对。第三句,每层只加自己那层才知道的上下文,处理仍放最顶层 :这条正是昨天 Day 21"错误即值、往上抛、有信息的那层收尾"的续集,今天你只是给"往上抛"接上了一根不断的链。这三句合起来,恰好补齐了接口三部曲之后的错误处理闭环:Day 19 取值、Day 20 判 nil、Day 21 抛值、Day 22 串链。我 review 代码时的必查项也顺手加了一条:凡是 fmt.Errorf 里出现 %v 且后面传了 error 变量的,先停下来问一句------"这条链,你确定要断?"
【待补:你的真实经历------你或你带的学员被"%v 包装后 errors.Is 认不出"坑过的一次,当时哪个分支静默吞了错误,填完可删本占位。】
给你留的活儿
三件事,都在 D:\go\day22 里做。第一,把今天那段对照程序里的 %w 改回 %v,重跑,亲眼看 errors.Is 从 true 翻成 false,截图存进你 Day 15 的错题本"错误处理"那栏。第二,把三层链改成四层(boot → readConfig → parseLine → openConfig),每层加一句只有这层知道 的上下文,别抄我的文案呀,再用 errors.Unwrap 从最外往回剥,数一数能剥几次才见 nil。第三,故意给 errors.As 传一个不带 & 的目标,看编译器怎么怼你------把报错原文也留进错题本。
预告:明天 Day 23,才轮到 panic
今天我们把"错误链"接顺了,Is/As/Unwrap 三件套该用哪个也门儿清。那 panic 呢?昨天 Day 21 说了它管"程序自己的 bug",可到底什么时候该 panic、recover 又该摆在哪一层兜底、为什么 goroutine 里一个 panic 能把整个进程带崩------明天 Day 23 专门拆这块。睡前留道小题:fmt.Errorf("x: %w", nil) 会怎样?想通它,你对"链"的理解就到位了。
参考资料