上周把一个错误封装从业务包里抽出来做成公共模块。抽的时候函数签名保持 *MyError 不变,调用方一行没改,if err != nil 却开始在成功路径上也走进错误分支。
断点里展开 err,类型一栏写着 *MyError,值一栏是空的。看着是空的,判断却说不等于 nil。
「Go 的 nil 有类型」这句解释不够准确。真正的机制是接口变量内部有两个字段,err != nil 判断的是这两个字段的组合状态,不是其中那个指针。
接口值在内存里到底存了什么?
Go 官方 FAQ 对这一点写得很直接:
Under the covers, interfaces are implemented as two elements, a type
Tand a valueV.
一个接口类型的变量在运行时不只是一个指向某个东西的指针,而是打包了一对信息:动态类型 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
nilonly if theVandTare both unset, (T=nil,Vis not set), In particular, anilinterface will always hold aniltype. If we store anilpointer of type*intinside an interface value, the inner type will be*intregardless of the value of the pointer: (T=*int,V=nil). Such an interface value will therefore be non-nileven when the pointer valueVinside isnil.
这段话讲了三点: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
nilerrorto the caller, the function must return an explicitnil
也就是在返回语句里直接写 return nil,而不是返回一个类型化的空指针让它自动装箱。
我后来把这类排查步骤固定成一张表,底稿是用 AI墨衍 按「赋值形式 / T 字段 / V 字段 / 判空结果」四列排出来的,几个人共用一份。
函数签名为什么该写 error 而不是 *MyError?
针对这个问题,FAQ 给了一条建议:
It's a good idea for functions that return errors always to use the
errortype 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 != nil | T 与 V 的组合状态 | 不能 | 成功路径误入错误分支 |
err.(*MyError) != nil | 断言取回的指针 | 能 | 类型不符时 panic |
| 带 ok 的断言再判指针 | 断言是否成功,再判指针 | 能 | 代码变长,需要知道具体类型 |
| 用 errors 包里的 As 取目标 | 匹配到的目标值 | 能 | 只能处理错误链里的类型 |
第一行是默认写法,也是出问题的那一行。第二行能用,但它把抽象又拆回了具体实现,耦合度会上来。
第三行和第四行适合边界层。业务代码内部最好别依赖具体错误类型,否则换一种实现就要改所有判空的地方。
这个坑只在 error 上出现吗?
不是。凡是接口都有这两个字段,io.Reader、自定义接口都一样。只要往里塞了某个具体类型的空指针,判断逻辑就会遇到同一个问题。
区别只在于 error 太常见,几乎所有函数都在返回它,所以撞上的概率最高。
一个简单的排查方法:看接口变量的动态类型。如果空值上还挂着一个具体类型名,那就是装箱造成的,不是变量本身没清干净。
空指针和 nil 接口怎么快速分辨?
这两个东西在打印日志时长得几乎一样。空指针打出来是空,nil 接口打出来也是空,光看输出分不出谁是谁。
区别只能从类型那一栏读。空指针的类型栏本身就是空的;nil 接口的类型栏是空的,值栏也是空的,两栏一起空才算真正的 nil。
换句话说,判空失败的时候不要盯着值看,去看类型。类型栏写着具体类型,说明这个接口已经装过东西了,问题出在装的那一刻,不在判断的那一刻。
这个思路也可以反过来用来自查:如果某段代码从没把具体值塞进接口,它就不可能遇到这个坑。把所有装箱点列出来,范围通常比想象的小。
小结
三件事:接口是类型加值两个字段;空指针装进去之后类型字段已经写死;要返回空值就写 return nil。
第一条解释现象,第二条解释 bug 的来路,第三条是修法。而这三条最后都指向同一个更基本的判断:在 Go 里,nil 不是值的一个属性,而是接口两个字段的组合状态。
理解这一点之后,err != nil 这一行就不再是在问「这里空不空」,而是在问「这个接口里到底装过东西没有」。