Day 22 _ 包装错误别丢链_%w、errors.Is 与 errors.As

Day 22 | 包装错误别丢链:%w、errors.Iserrors.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:上下文照样加,一条链都不落

  • errors.Is 认哨兵,errors.As 把状态码捞出来

  • 三层链 + 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 的指针类型"的指针,也就是 &hehe*HTTPError,少一个 & 就编译不过。Is 与 As 的分工,画一张对照:

一句"是什么":为什么要 As 不用裸断言

有人会问:直接 err.(*HTTPError) 类型断言不就行了?为什么?因为裸断言只作用在"最外面这一层"的接口值上------现在 errfmt.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,别用错门

还有个有意思的点:IsAs 都不是凭空认得链的,它俩内部靠的就是 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.Istrue 翻成 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) 会怎样?想通它,你对"链"的理解就到位了。

参考资料


相关推荐
一阵寒风1 小时前
CAM智能化助力PCB 智能制造-培训体系第六章.项目开发学习
学习·制造
传奇开心果编程1 小时前
【Xilem 0.4 基础语法学与练】第15课:状态管理与 memoize 性能优化
学习·rust·前端框架
具身AGI1 小时前
视频即仿真,物理AI 人类学习路线 的下一步
人工智能·学习
聚美智数1 小时前
图片广告检测-图片审核-图片广告识别-图像广告检测
android
新时代牛马2 小时前
PCI与PCIe 硬件原理、配置空间/BAR 与 Linux 驱动完整篇:从 LTSSM、TLP 到 ECAM 与probe
java·linux·服务器
wuyk5552 小时前
从零吃透 MQTT 通信|第 8 章 FreeRTOS 多任务架构下 MQTT 工程架构,任务拆分、队列解耦、临界区保护
c语言·开发语言·stm32·学习·架构
陈年老古董3 小时前
PyTorch食物图像分类实战:从数据集制作到CNN模型训练全流程详解
pytorch·深度学习·学习·机器学习·分类·cnn
明志数科3 小时前
从300克Ego头环看第一人称数据采集趋势:设备轻量化之后,场景端壁垒在哪
数码相机·学习
两点王爷3 小时前
解决 Linux 中 version `CXXABI_1.3.8‘ not found 报错
linux·运维·服务器