← 中文资源

简体中文版本

Go — 带类型的 nil:装着 nil 指针的非 nil 接口

解释 Go 里装进接口的 nil *T 为何不等于 nil,找到返回带类型 nil 的函数,并用显式 nil 返回修复边界。

阅读英文原文

症状

  一条 printf 就能完整暴露悖论:var err error = (*MyErr)(nil) 之后打印 %v | %T | err != nil,输出 | *main.MyErr | non-nil? true——接口打印为 nil、类型是指针、比较却是非 nil,三者同时成立。原因在于接口是 (类型, 值) 二元组:类型字已填充,只有两字皆空的接口才等于 nil。运行期行为随之不一致——nil 接收者方法可以一直正常执行直到解引用那一刻,于是程序一直好好的、直到最深处的调用最终 panic。fmt 打印 而 err != nil 为 true,就是这对矛盾的可视形态。

诊断思路

  先用三种格式打印矛盾三元组完成诊断;再找“生产者”——声明接口或 error 返回类型、却返回具体类型 nil 指针的函数(var e *NotFoundErr; return e 是标准形态);最后在边界写断言测试:空用例断言 err == nil,带类型 nil 会失败、修复后通过,契约被钉死。错误包装链里 return err 把带类型 nil 原样透传,是第二常见入口。

根因

  Go 接口值是“具体类型 + 值”的二元组,只有两部分都为空的接口才等于 nil。存入 nil *MyErr 得到 (类型=*MyErr, 值=nil),类型字被设置,比较即为非 nil;方法调用经类型字分发,这就是 nil 接收者方法能先成功一阵的原因。

修复与验证

  修复属于生产函数:接口边界返回字面量 nil,查找失败路径 if !exists(id) { return nil };每个包装点加守卫——err == nil 时返回 nil,否则带类型 nil 会穿越包装存活下来。nil 接收者方法防御性读字段的做法只会让问题更迷惑:它让带类型 nil 走得更深才引爆。验证时用表驱动测试遍历每个返回接口的构造器与查找函数,对空用例同时断言 err == nil 与 value == nil。不要用 reflect 的 IsNil 检查去容忍带类型 nil——那只是给契约违规打补丁,把失败挪得更深。

代码示例

var err error = (*MyErr)(nil)
fmt.Printf("%v | %T | non-nil? %v\n", err, err, err != nil)
// <nil> | *main.MyErr | non-nil? true
func FindUser(id string) error {
    var e *NotFoundError // stays nil when absent
    // ...
    return e // BUG: (*NotFoundError, nil) — non-nil interface
}

func FindUser(id string) error {
    if !exists(id) {
        return nil // literal nil: both words empty
    }
    return &NotFoundError{ID: id}
}
func Wrap(err error) error {
    if err == nil {
        return nil // without this, a typed nil survives wrapping
    }
    return fmt.Errorf("context: %w", err)
}