Go 里有一个特别经典、也特别容易在面试和线上代码里同时出现的问题:明明返回的是 nil,为什么外层判断 err != nil 还是成立?

先看一个例子

1type MyError struct{}
2
3func (e *MyError) Error() string { return "boom" }
4
5func f() error {
6    var err *MyError = nil
7    return err
8}

很多人会直觉觉得 f() 返回的是 nil。但实际结果是:返回值不等于 nil

为什么会这样

接口值在底层可以理解成两部分:

  • 动态类型
  • 动态值

只有“类型和值都为 nil”的接口,才等于 nil

在上面的例子里:

  • 动态类型是 *MyError
  • 动态值是 nil

所以它不是一个“空接口值”,而是“携带了具体类型,但具体值为空”的接口值。

这对 error 特别重要

因为 error 本身就是接口,所以最常见的坑就是函数内部返回了一个“typed nil”。

 1func f() error {
 2    var err *MyError
 3    return err
 4}
 5
 6func main() {
 7    if err := f(); err != nil {
 8        fmt.Println("unexpected non-nil")
 9    }
10}

正确姿势是什么

如果函数签名返回 error,那你在表示“没有错误”时,最好直接返回字面量 nil

1func f(ok bool) error {
2    if ok {
3        return nil
4    }
5    return &MyError{}
6}

接口调试时怎么快速定位

当你怀疑掉进这个坑时,可以打印类型和值:

1fmt.Printf("type=%T value=%v\n", err, err)

如果输出看起来像 type=*main.MyError value=<nil>,那几乎就可以确定是 typed nil。

更广一点的理解

这个问题不只出现在 error 上,只要是接口,都可能发生。

例如某个函数返回 io.Readerany、自定义接口,只要你把一个空指针赋给接口变量,都可能出现“值为空但接口不为空”的情况。

我的总结

  • 接口是否为 nil,取决于“动态类型 + 动态值”是否都为空
  • (*T)(nil) 赋给接口后,接口通常不等于 nil
  • 返回 error 时,表示成功就直接 return nil
  • 一旦怀疑有问题,先打印 %T,往往很快就能看清真相