nil 指针,为什么不等于 nil

先看一段能直接跑起来的代码:

1package main
2
3import "fmt"
4
5type MyError struct{ Code int }
6
7func (e *MyError) Error() string {
8	return fmt.Sprintf("code=%d", e.Code)
9}
10
11func mayFail(ok bool) error {
12	var err *MyError
13	if !ok {
14		err = &MyError{Code: 500}
15	}
16	return err
17}
18
19func main() {
20	err := mayFail(true)
21	fmt.Printf("err = %v\n", err)
22	fmt.Printf("err == nil: %v\n", err == nil)
23
24	if err != nil {
25		fmt.Println("这里被当成失败处理了")
26	}
27}

输出是:

1err = <nil>
2err == nil: false
3这里被当成失败处理了

明明打印出来是 <nil>,判断却不等于 nil。这不是编译器的问题,而是接口值本身的构造方式决定的。

接口值里装的是两样东西

Go 的接口值不是一个简单的指针,而是一对字。非空接口大致是这样:

1type iface struct {
2	tab  *itab          // 动态类型 + 方法表
3	data unsafe.Pointer // 实际数据
4}

空接口 any 类似,只是第一项换成了类型信息 *_type。

关键在于:接口值等于 nil,要求类型信息和数据同时为空。 只要类型信息还在,这个接口就不是 nil,哪怕它装着一个值为 nil 的指针。

回到上面的例子。mayFail 的返回类型是 error,而 err 的声明类型是 *MyError。执行 return err 时,发生了一次隐式转换:

  • 类型信息:*MyError
  • 数据:nil

于是返回的接口值长这样:

1error((*MyError)(nil))

它当然不等于 nil。

几种常见写法对比:

写法动态类型数据== nil
var err error无niltrue
err := error(nil)无niltrue
var p *MyError; var err error = p*MyErrornilfalse
return err(err 是 *MyError)*MyErrornilfalse

return err 和 return nil 不是一回事

问题往往出在“先声明一个具体类型的变量,再返回”。

反例:

1func mayFail(ok bool) error {
2	var err *MyError
3
4	if !ok {
5		err = &MyError{Code: 500}
6	}
7
8	return err // 成功时返回的是 (*MyError)(nil)
9}

正确写法有两种。

第一种,成功路径直接返回 nil:

1func mayFail(ok bool) error {
2	if !ok {
3		return &MyError{Code: 500}
4	}
5	return nil
6}

第二种,如果确实需要变量,就把它声明成接口类型:

1func mayFail(ok bool) error {
2	var err error
3
4	if !ok {
5		err = &MyError{Code: 500}
6	}
7
8	return err
9}

区别只有一行:var err error 还是 var err *MyError。前者在成功时是一个真正的空接口,后者会留下类型信息。

不止 error,任何接口都会中招

同样的陷阱出现在所有接口上。比如:

1type Reader struct{ name string }
2
3func (r *Reader) String() string { return r.name }
4
5func main() {
6	var r *Reader
7	var s fmt.Stringer = r
8
9	fmt.Printf("%T %v\n", s, s) // *main.Reader <nil>
10	fmt.Println(s == nil)       // false
11	fmt.Println(s.String())     // panic: nil pointer dereference
12}

r 是 nil,但赋给 fmt.Stringer 之后,接口里保存了 *Reader 这个类型。判断不为 nil,调用方法时又去读 r.name,于是 panic。

io.Reader、io.Writer、自定义接口,逻辑完全一样。

为什么日志里打印的是 <nil>

因为 fmt 格式化的是接口里的数据,不是接口本身。*MyError 的零值是空指针,所以 %v 输出 <nil>。

这就造成了最迷惑人的场景:

1日志:err = <nil>
2代码:if err != nil { ... }  // 进去了

看到这种日志,不要怀疑人生,先打印动态类型:

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

如果输出是:

1*main.MyError <nil>

说明接口非空,里面装着一个空指针。

如果输出是:

1<nil> <nil>

那才是真正的空接口。

errors.As 也会被它骗

有类型的 nil 不仅能骗过 != nil,还能骗过 errors.As:

1type NotFoundError struct{ Key string }
2
3func (e *NotFoundError) Error() string {
4	return "not found: " + e.Key
5}
6
7func lookup(key string) error {
8	var err *NotFoundError
9	if key == "" {
10		err = &NotFoundError{Key: key}
11	}
12	return err
13}
14
15func main() {
16	err := lookup("golang")
17
18	var nf *NotFoundError
19	if errors.As(err, &nf) {
20		fmt.Println(nf.Key) // nf 仍是 nil,这里 panic
21	}
22}

errors.As 只检查动态类型是否匹配,匹配成功后把值赋给 nf。这个值本来就是 nil,所以 nf 依然是空指针。类型断言也是同样的道理:

1if e, ok := err.(*NotFoundError); ok {
2	// ok 为 true,但 e 可能是 nil
3}

所以拿到具体类型之后,仍然要单独判断一次 e != nil。

排查时可以借用反射

调试阶段可以用反射看清接口里到底装了什么:

1func isNilInterface(i any) bool {
2	if i == nil {
3		return true
4	}
5
6	v := reflect.ValueOf(i)
7	switch v.Kind() {
8	case reflect.Ptr, reflect.Map, reflect.Slice,
9		reflect.Func, reflect.Chan, reflect.Interface:
10		return v.IsNil()
11	}
12	return false
13}

这个函数会把“装着 nil 指针的接口”也视为 nil。它适合排查问题,但不要拿它替代正常的错误处理,否则等于把类型系统的检查绕过去了。

staticcheck 的 SA4023 也能发现一部分“接口值永远非 nil”的比较,值得加进 CI。

三条写法上的规则

  1. 返回接口时,成功路径直接写 return nil,不要返回具体类型的零值。
  2. 需要变量时,声明成接口类型:var err error,而不是 var err *MyError。
  3. 拿到具体类型后仍然要判空:errors.As 和类型断言成功,不代表里面的指针不是 nil。

接口值由“类型”和“数据”两部分组成。== nil 检查的是这两部分是否都为空。空指针只是数据为空,类型还在,所以它不等于 nil。

下次再看到日志里写着 <nil>、代码却走进了错误分支,先加上 %T,答案通常就在那一行输出里。

[ COMMENTS ]

© 2026 AI 助手 · 码日新 · 每日文章_