卷 IV · 错误CH 15深度 15/24

包装、Is、As 与错误链

本章卸下catch (具体异常类型)

上一章你知道了错误能包成链。这一章讲怎么在链里找东西:判断「根因是不是某个特定错误」用 errors.Is,把错误还原成具体类型读它的字段用 errors.As。加上哨兵错误和自定义错误类型,这就是 Go 错误处理的完整工具箱——也就是 catch (SQLException e) 在 Go 里的样子,只不过它是一次你亲手写的、看得见的函数调用,而不是运行时偷偷做的类型匹配。

哨兵错误errors.Iserrors.As自定义错误%w

哨兵错误:一个可以「认」的错误

有时你需要判断「这个错误是不是某个特定的已知错误」—— 比如「查不到记录」。Go 的做法是预先定义一个哨兵错误(sentinel error),一个包级别的错误变量:

// 标准库里到处是这种哨兵
var ErrNotFound = errors.New("not found")
// sql.ErrNoRows、io.EOF、os.ErrNotExist 都是这类

func FindUser(id int) (*User, error) {
    ...
    return nil, ErrNotFound   // 明确返回这个可被识别的错误
}

errors.New("...") 造一个最简单的错误值。把它存成包级变量 ErrNotFound,调用方就能拿它来比对:「你返回的是不是 ErrNotFound?」这就是「能认出来的错误」。io.EOF 是你最常见的一个 —— 读到文件末尾时返回它,你靠比对它来判断「读完了」。

errors.Is:顺着链比对根因

但上一章说了,错误常常被 %w 包了好几层。这时你不能直接 err == ErrNotFound —— 因为 err 是最外层那个 加载配置: ...,它本身不等于 ErrNotFound,只是链条深处包着它。直接比会漏。

所以要用 errors.Is,它会顺着整条链一层层往里比

err := loadConfig()   // 里面可能包了好几层,最里是 ErrNotFound

if errors.Is(err, ErrNotFound) {
    // Is 会解开每一层 %w,只要链上任何一层是 ErrNotFound 就返回 true
    fmt.Println("配置文件不存在,用默认值")
}

回想第 14 章那台 Demo:它演示的正是 errors.Is 顺着 Unwrap 往里挖的过程 —— 第三个例子里目标不在链上,Is 走到底也没找到,返回 false。记住:判断错误身份,永远用 errors.Is,不要用 ==(除非你 100% 确定没被包装过),因为你不知道下游有没有给它裹上几层 %w

◆ 一条准则:包装 vs 不包装

要不要用 %w 包装,是有讲究的,因为包装等于把内层错误暴露给上层去 Is 判断 —— 这是一种 API 承诺。

%w(包装):当你希望调用方能识别根因时。比如你的函数内部调了 sql.ErrNoRows,而你想让上层能判断「是不是没查到」,就 %w 传出去。

%v(不包装,只拼字符串):当内层错误是你的实现细节、不想让它成为你 API 的一部分时。fmt.Errorf("加载配置: %v", err) 会把 err 的文字拼进来、但把它挂到链上,上层 Is 不到它 —— 你保住了封装。这个 %w%v 的选择,本质是「我要不要把这个内部错误变成公开契约」的决定,别乱包。

自定义错误类型:错误带上结构化数据

哨兵错误只有一句话。但很多时候你想让错误携带数据 —— 出错的字段名、HTTP 状态码、重试次数。因为 error 只是个接口(第 14 章),你随便定义一个满足它的 struct 就行:

// 一个带字段的错误类型
type ValidationError struct {
    Field string
    Msg   string
}

// 满足 error 接口:给它一个 Error() 方法(指针接收者,回想第 10 章)
func (e *ValidationError) Error() string {
    return fmt.Sprintf("字段 %s 非法: %s", e.Field, e.Msg)
}

func validate(u User) error {
    if u.Name == "" {
        return &ValidationError{Field: "Name", Msg: "不能为空"}
    }
    return nil
}

ValidationError 就是个普通 struct,加了个 Error() 方法就成了 error。现在它能带着 FieldMsg 一路传上去。(注意接收者用了指针 *ValidationError,这样它的指针满足 error 接口 —— 第 10 章那个方法集规则又出现了,所以返回时写 &ValidationError{...}。)

errors.As:把错误还原成具体类型

问题来了:一个 *ValidationError 被包进链条深处,上层拿到的是个笼统的 error。怎么把它取回成 *ValidationError、读它的 Field 字段?这就是 errors.As 干的活 —— 它顺着链找到第一个能匹配目标类型的错误,赋给你:

err := doStuff()   // 链条深处可能有个 *ValidationError

var ve *ValidationError
if errors.As(err, &ve) {
    // 找到了!ve 现在是那个 *ValidationError,能读它的字段
    fmt.Println("哪个字段错了:", ve.Field)
}

Is 和 As 的分工要记清:

你想对应 Java 的
判断「是不是这个特定错误errors.Is(err, ErrNotFound)catche == SOME_CONST 或比对
取出某个类型的错误、读它的字段errors.As(err, &target)catch (ValidationException e) { e.field }
◆ errors.As 就是显式版的 catch(SQLException e)

把这两段对照着看,你会豁然开朗:

Kotlin:try { ... } catch (e: ValidationException) { print(e.field) } —— 运行时按类型匹配异常,匹配上就进这个 catch,你能读 e 的字段。

Go:var ve *ValidationError; if errors.As(err, &ve) { print(ve.Field) } —— 你亲手调用一个函数,按类型在错误链里匹配,匹配上就填好 ve,你读它的字段。

做的是同一件事:按类型识别错误、拿到它、读它的数据。区别只在于 —— Kotlin 是运行时替你做的隐式跳转,Go 是你写出来的一次显式函数调用。又一次,同样的能力,Go 把它从「语言魔法」降格成了「普通代码」。

多个错误:errors.Join

最后一个新工具(Go 1.20 加的):当你想一次性报告多个错误时(比如校验一个表单,五个字段全错了,你想都报出来,而不是遇到第一个就返回),用 errors.Join

var errs error
for _, field := range fields {
    if err := field.Validate(); err != nil {
        errs = errors.Join(errs, err)   // 把错误攒在一起
    }
}
return errs   // 一个包含多个错误的 error;Is/As 对里面每个都有效

这解决了「一遇错就 return、后面的错看不到」的痛点。errors.Join 出来的错误,errors.IsAs 会检查它包着的每一个子错误。这在批量校验、并发收集多个 goroutine 的错误(卷 V)时特别有用。

Kotlin 对照

⇄ 异常层级 vs 错误值 + Is/As

Kotlin / Java 用类型层级组织错误:IOException 下面 FileNotFoundExceptionSocketException……你 catch 一个父类就接住一整类,靠的是异常的继承树。识别错误 = 靠类型匹配 + 多态。

Go 用值 + 显式查询组织错误:哨兵值(Is 比对身份)和自定义类型(As 提取),没有异常继承树,全靠你在链上显式 Is/As。想表达「一类错误」,你用哨兵或接口(比如定义一个 type temporary interface { Temporary() bool },凡是「临时错误」都满足它)。

迁移时建立这个对应:你写 catch (SpecificException e) 的地方,Go 里换成 errors.As;你写 catchif (e == KNOWN) 的地方,换成 errors.Is。核心心态转变是:错误不再是「抛出后被某层接住」的东西,而是「一个值,你顺着它的链主动去问它是什么」。更啰嗦,但也更由你掌控 —— 没有任何错误会被你没写的 catch 悄悄漏过。

这一章的一句话

哨兵错误 + errors.Is 判断「是不是某个特定错误」(顺着链比,别用 ==),自定义错误类型 + errors.As 把错误还原成具体类型读字段(就是显式版的 catch(SpecificException)),%w 包装等于把内层错误变成可被 Is 识别的公开契约、要不要包得想清楚,errors.Join 一次报告多个错误——这套就是 Go 完整的错误工具箱。

错误卷还剩最后一块:deferpanicrecover。defer 是 Go 处理「收尾」的独门设计,panic/recover 是那个「几乎不该用的异常」—— 下一章讲清它们,以及那几个人人踩过的 defer 坑。