Go 的接口是类型加值,所以赋了 nil 的 err 判断起来不等于 nil

19 阅读6分钟

上周把一个错误封装从业务包里抽出来做成公共模块。抽的时候函数签名保持 *MyError 不变,调用方一行没改,if err != nil 却开始在成功路径上也走进错误分支。

断点里展开 err,类型一栏写着 *MyError,值一栏是空的。看着是空的,判断却说不等于 nil。

「Go 的 nil 有类型」这句解释不够准确。真正的机制是接口变量内部有两个字段,err != nil 判断的是这两个字段的组合状态,不是其中那个指针。

接口值在内存里到底存了什么?

Go 官方 FAQ 对这一点写得很直接:

Under the covers, interfaces are implemented as two elements, a type T and a value V.

一个接口类型的变量在运行时不只是一个指向某个东西的指针,而是打包了一对信息:动态类型 T 和动态值 V。T 记录装进去的具体类型,V 记录那个类型的具体值。

error 是接口,所以任何 error 变量都遵守这个结构。判断 err != nil 时,Go 要求 T 和 V 同时处于未设置状态才算 nil。

只要 T 被填上了具体类型,哪怕 V 是空的,这个接口整体也已经不是 nil 了。

赋值语句类型字段 T值字段 V与 nil 比较
var err error未设置未设置相等
err = (*MyError)(nil)*MyError空不相等
err = &MyError{}*MyError非空指针不相等
err = nil未设置未设置相等
var p *MyError; err = p*MyError空不相等

第一行和第四行是同一个状态,中间三行的差别只在 V。这就是为什么「看着是空的」和「比较等于 nil」是两件完全不同的事。

把 nil 指针装进接口,为什么就不等于 nil 了?

FAQ 里的这段话说得最细:

An interface value is nil only if the V and T are both unset, (T=nil, V is not set), In particular, a nil interface will always hold a nil type. If we store a nil pointer of type *int inside an interface value, the inner type will be *int regardless of the value of the pointer: (T=*int, V=nil). Such an interface value will therefore be non-nil even when the pointer value V inside is nil.

这段话讲了三点:nil 接口的 T 一定是 nil;把 *int 的 nil 指针放进去之后,T 会变成 *int 而 V 是 nil;这样的接口值整体非 nil。

放到错误处理上,FAQ 给的示例几乎是线上代码的缩小版:

func returnsError() error {
    var p *MyError = nil
    if bad() {
        p = ErrBad
    }
    return p // 永远返回一个非 nil 的 error
}

p 的静态类型是 *MyError,返回时被装进 error 接口。无论 bad() 是否成立,装箱那一刻 T 都被写成 *MyError,所以返回值永远非 nil。

要让调用方拿到真正的空值,FAQ 的要求很明确:

To return a proper nil error to the caller, the function must return an explicit nil

也就是在返回语句里直接写 return nil,而不是返回一个类型化的空指针让它自动装箱。

我后来把这类排查步骤固定成一张表,底稿是用 AI墨衍 按「赋值形式 / T 字段 / V 字段 / 判空结果」四列排出来的,几个人共用一份。

函数签名为什么该写 error 而不是 *MyError?

针对这个问题,FAQ 给了一条建议:

It's a good idea for functions that return errors always to use the error type in their signature (as we did above) rather than a concrete type such as *MyError, to help guarantee the error is created correctly.

签名写 error,函数体里就只有两条出口:返回某个实现了 Error 的值,或者返回字面 nil。装箱发生在唯一的地方,出错面被压到最小。

签名写 *MyError 则相反,装箱点散落到每个调用方的赋值语句里,任何一处手滑都会留下一个非 nil 的空值。

标准库就是这么做的。os.Open 的返回值类型是 error,实际构造出来的具体类型是 *os.PathError。调用方只跟接口打交道,不需要知道内部是哪种错误。

还有一种情况天然安全:用 var err error 起手的变量压根没经历过装箱,它本来就是 nil。问题只出现在「把具体值塞进接口」的地方。

已经拿到非 nil 的空值,怎么判断?

最稳的做法是从源头消灭这种值:签名写 error,需要返回空时写 return nil。这一条能覆盖绝大多数场景,成本几乎为零。

如果接口变量已经到手,并且怀疑里面装了个类型化的空指针,可以用类型断言把指针取回来再比较。取回来的是 *MyError,对它判空走的是指针比较,不受接口两个字段影响。

判断写法实际比较的对象能识别类型化的空指针风险
err != nilT 与 V 的组合状态不能成功路径误入错误分支
err.(*MyError) != nil断言取回的指针能类型不符时 panic
带 ok 的断言再判指针断言是否成功,再判指针能代码变长,需要知道具体类型
用 errors 包里的 As 取目标匹配到的目标值能只能处理错误链里的类型

第一行是默认写法,也是出问题的那一行。第二行能用,但它把抽象又拆回了具体实现,耦合度会上来。

第三行和第四行适合边界层。业务代码内部最好别依赖具体错误类型,否则换一种实现就要改所有判空的地方。

这个坑只在 error 上出现吗?

不是。凡是接口都有这两个字段,io.Reader、自定义接口都一样。只要往里塞了某个具体类型的空指针,判断逻辑就会遇到同一个问题。

区别只在于 error 太常见,几乎所有函数都在返回它,所以撞上的概率最高。

一个简单的排查方法:看接口变量的动态类型。如果空值上还挂着一个具体类型名,那就是装箱造成的,不是变量本身没清干净。

空指针和 nil 接口怎么快速分辨?

这两个东西在打印日志时长得几乎一样。空指针打出来是空,nil 接口打出来也是空,光看输出分不出谁是谁。

区别只能从类型那一栏读。空指针的类型栏本身就是空的;nil 接口的类型栏是空的,值栏也是空的,两栏一起空才算真正的 nil。

换句话说,判空失败的时候不要盯着值看,去看类型。类型栏写着具体类型,说明这个接口已经装过东西了,问题出在装的那一刻,不在判断的那一刻。

这个思路也可以反过来用来自查:如果某段代码从没把具体值塞进接口,它就不可能遇到这个坑。把所有装箱点列出来,范围通常比想象的小。

小结

三件事:接口是类型加值两个字段;空指针装进去之后类型字段已经写死;要返回空值就写 return nil。

第一条解释现象,第二条解释 bug 的来路,第三条是修法。而这三条最后都指向同一个更基本的判断:在 Go 里,nil 不是值的一个属性,而是接口两个字段的组合状态。

理解这一点之后,err != nil 这一行就不再是在问「这里空不空」,而是在问「这个接口里到底装过东西没有」。