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

错误即值:没有 try/catch

本章卸下try / catch / throwchecked exceptions

这是第 1 章账本里最让人不服的一条:Go 没有异常。没有 try、没有 catch、没有 throw。错误在 Go 里就是一个普通的返回值,你靠满屏的 if err != nil 一层层检查它。第一次看,你会觉得这也太原始、太啰嗦了。这一章要说服你:那些 if err != nil 不是啰嗦,是 Go 把「出错这条路」从暗处拽到了明处 —— 而这正是它认为异常做错了的地方。

error 是值if err != nil反对异常错误链

错误长什么样:就是个返回值

Go 里,一个可能失败的函数,把错误作为最后一个返回值交给你(还记得第 2 章「函数能返回多个值」吗,就是为这个铺的路):

// 标准签名:正常结果 + 一个 error
f, err := os.Open("config.yaml")
if err != nil {
    // 出错了,err 里有原因,在这里处理
    return err   // 常见做法:处理不了就往上抛(return 出去)
}
// 到这里 err 一定是 nil,f 一定可用
defer f.Close()
use(f)

这个 x, err := f(); if err != nil { ... } 的节奏,你会在 Go 代码里看到成千上万次。它就是 Go 错误处理的全部骨架 —— 没有别的机制。error 不是什么特殊语法,它就是一个内置接口,简单到只有一个方法:

type error interface {
    Error() string   // 返回一段人能读的错误描述
}

看到了吗,error 就是上一卷讲的普通接口 —— 任何有 Error() string 方法的类型都是一个 error。这意味着错误是一等公民的值:你能把它存进变量、放进切片、传给函数、作为字段挂在 struct 上、比较它、包装它。它没有任何「异常」那种从天而降的特殊地位。

Go 为什么要删掉异常

这是本章的核心争论。异常用了这么多年,凭什么说它不好?Go 团队的理由,主要是两条。

理由一:异常是「隐形的控制流」

看一段 Kotlin:

fun handle() {
    val user = loadUser()      // 这一行会抛异常吗?看不出来
    val order = loadOrder()    // 这一行呢?也看不出来
    process(user, order)       // 它呢?
}

问题来了:这段代码里哪些行可能抛异常、抛了会跳到哪去,你从代码本身完全看不出来。任何一个函数调用都可能突然抛出、让控制流凭空跳到某个 catch(可能在几层调用之外)。错误路径是隐形的 —— 它藏在每个函数调用背后,不在代码的表面。

Go 认为这违背了它最看重的东西:代码应该把重要的事摊在明面上,让读的人一眼看清。而「这里会不会出错、出错了怎么走」是一段代码里最重要的事之一。所以 Go 的选择是:把错误路径变成显式的、看得见的普通控制流。那一堆 if err != nil 虽然占地方,但它们让「哪一步可能失败、失败了往哪走」明明白白地写在代码里,没有任何隐形跳转。

◆ 啰嗦,是把复杂度摆到明面上的代价

异常没有消灭「处理错误」这件事的复杂度,它只是把复杂度藏了起来 —— 藏进看不见的抛出和跳转里。你的代码看着清爽,但错误处理的真相变模糊了。

Go 的 if err != nil 把这份复杂度原样摆回你面前。它没有让错误处理变简单(错误处理本来就不简单),只是拒绝把它藏起来。你觉得它啰嗦,本质上是你在看一份「没有对错误路径撒谎」的代码。这又是全书那个一以贯之的交易:牺牲写代码的一时清爽,换读代码时对真相的一目了然。

理由二:什么该处理、什么真的异常,被混为一谈

「文件不存在」「网络超时」「用户输入不合法」—— 这些在异常语言里都是异常,但它们其实是业务上完全预料之中、必须处理的普通情况,不该和「数组越界」「空指针」这种程序 bug用同一套机制。Java 试图用 checked exceptions 区分,结果搞出了 throws 声明地狱和满屏 catch (Exception e) {} 的吞异常反模式,公认失败。

Go 干脆把两者分开

  • 预料之中、该处理的错误 → 用 error 返回值。文件打不开、请求失败,都走这条,你必须显式面对。
  • 不该发生的程序 bug → 用 panic(第 16 章)。数组越界、解引用 nil,程序直接崩,因为这说明代码写错了,不该「优雅处理」,该修。

这个二分很清爽:error 是「正常业务的一部分」,panic 是「程序坏了」。你日常 99% 的时间只跟 error 打交道,panic 是留给真正的灾难的。

错误能带上下文,包成一条链

光返回 err 还不够 —— 一个底层的「connection refused」冒到最上层,你根本不知道它是在处理哪个请求时发生的。所以 Go 的惯用法是:每往上抛一层,就用 fmt.Errorf 加一句当前层的上下文,把下层错误进来

func loadConfig() error {
    data, err := os.ReadFile("config.yaml")
    if err != nil {
        // %w 把底层错误包进来,同时加上「我这层在干嘛」
        return fmt.Errorf("加载配置: %w", err)
    }
    ...
}

那个 %w(wrap)是关键 —— 它把下层的 err 包裹进新错误里,形成一条错误链:最外层是「加载配置」,往里一层是「打开文件失败」,最里层是「文件不存在」。每一层都保留着,你能顺着这条链往里挖根因。下面这台 Demo 把这条链画出来了,跑一下:

看第二个例子那条两层链:一个 connection refused 被包成 dial db,再被包成 handle /login。最后打印出来是 handle /login: dial db: connection refused —— 一句话告诉你「在处理登录时、连数据库时、连接被拒了」,故障现场一目了然。这就是错误作为「值」的威力:它能携带、能嵌套、能一路把上下文攒起来,而不是像异常那样只有一个孤零零的堆栈。

那怎么在链条深处判断「根因是不是某个特定错误」、怎么把它还原成具体类型读字段?那是 errors.Iserrors.As 的活,正是下一章的主题 —— 这台 Demo 你先感受「链」的存在就够了。

✎ 那 Go 真的不烦这堆 if err != nil 吗

烦,社区也一直有人烦。Go 团队认真考虑过好几版简化提案(比如 try 内建函数、? 操作符),但每一版都因为「会把错误路径重新变隐形」而被否掉 —— 他们宁可忍受啰嗦,也不肯为了少打几个字而牺牲「错误处理显式可见」这个原则。Go 1.13 加的 %w 包装、以及新的 errors.Is/As,是他们愿意做的改进方向:不减少错误处理的可见度,只让它更有结构、更好用。这个取舍你可以不喜欢,但它是深思熟虑、前后一致的。

Kotlin 对照

⇄ 异常的清爽 vs 返回值的诚实

Kotlin 走了另一条路,而且是深思熟虑的:它认定 Java 的 checked exceptions 是失败的,干脆所有异常都是 unchecked —— 你不用声明 throws,不用被迫 catch。快乐路径写得极干净,错误统一往上冒到某个边界处理。配合 runCatchingResult<T>,Kotlin 其实也能把错误当值处理,但主流风格还是异常。

两种哲学的核心分歧就一句话:错误路径该显式还是该隐式?Kotlin 选隐式(快乐路径清爽,错误往上飘),Go 选显式(每步都摊开,代码啰嗦但诚实)。没有绝对优劣 —— 写业务逻辑时 Kotlin 的清爽很香;但在一个大团队维护的系统里,Go 那种「每个错误都被就地看见、决定、包装」的显式,能防住大量「异常被谁悄悄吞了 / 忘了处理」的暗雷。

迁移时最难适应的就是这堆 if err != nil忍住「这也太啰嗦了」的第一反应,试着把它读成「这段代码没有对任何一个可能的失败撒谎」。用一阵子,你会发现自己开始信任 Go 代码 —— 因为你知道,凡是没写 if err != nil 的地方,就真的不会出错,没有隐形的雷。这种确定性,是那点啰嗦买来的。

这一章的一句话

Go 没有异常,错误就是普通返回值,靠 if err != nil 显式检查;Go 认为异常的「隐形控制流」把最该看清的错误路径藏了起来,所以宁可啰嗦也要把它摆到明面上,并用 %w 把错误包成一条能携带上下文的链——error 处理业务错误,panic 留给程序 bug。

下一章我们把错误链挖到底:errors.Is / errors.As 怎么顺着链找根因、哨兵错误和自定义错误类型怎么用 —— 这是 catch (SQLException e) 在 Go 里的样子。