最近在调试某 Go 语言源项目的时候,发现某个变量值为 nil,但是 if v != nil 检测居然不通过😮这还是头一回遇到。一番折腾后发现接口变量的空指针跟普通的空指针还是有区别的。本文梳理相关研究内容作个备忘,也分享给有需要的读者。

先给出一段代码:

package main

import "fmt"

func main() {
  var j *int = nil
  var k any = j
  
  fmt.Printf("%v,%v\n", k, k == nil)
}

请问程序执行之后输出什么结果🧐

答案是<nil>,false

慢着,不是变量k的值已经输出是<nil>了,怎么后面的k == nil结果还是false呢?这不是自相矛盾吗?

我们回到代码中。这里的变量k类型为any,即interface{},可以保存任意变量。而变量j为整型指针,它的值为nil

从直觉上看,因为j的值是nil,所以我们自然希望把j赋给k后,k的值也是nil。而前面输了结果中的第一部分为<nil>,在某种程序上「符合」我们的直觉。所以后面的 false才会更显诡异。

回到接口(interface)的本质,接口变量同时持有保存对象的地址和类型。要想让k == nil 结果为true,就需要该接口变量保存的地址和类型同时为空才可以!在前例中,虽然变量 j的值已经是nil了,但赋值给变量k之后,k同时还保存了*int类型信息,所以它不是nil

但为什么前面的代码中还会输出<nil>呢,那是因为fmt.Printf()会自动使用反射提取接口变量指向的变量,再输出目标变量的值,结果自然是nil了。

为此,我们可以运行以下代码来验证:

package main

import "fmt"

func main() {
  var k any = nil
  
  fmt.Printf("%v,%v\n", k, k == nil)
}

不出所料,结果是<nil>,true

一句话总结,对于 interface 变量,只要不是直接将 nil 赋值给他,任何其他赋值后该接口变量的值都不是nil!

好像也没有很麻烦,这有什么用呢?

这里是另外一段示例代码:

package main

import "fmt"

type Animal interface {
  speak()
}

type Dog struct{}

func (d Dog) speak() {
  fmt.Println("Woof!")
}

func print(a Animal) {
  if a != nil {
    a.speak()
  } else {
    fmt.Println("Dog is not present")
  }
}

func main() {
  var a *Dog = nil

  print(a)
}

这是一个典型的接口范例。代码先定义Animal接口,要求实现speak()方法。然后又定义了Dog类型,实现了Animal接口。接着又在print(a Animal)函数中接收动物接口实现,再根据变量值判断是否需要调用speak()方法。

函数print(a Animal)显然希望跳过空指针场景。但实际运行就会报如下错误:

panic: value method main.Dog.speak called using nil *Dog pointer

显然if a != nil {检查没有成功,也就是说变量a的值不为空,它的值是在调用print 函数传参时隐性赋予的,值为空的且类型为*Dog的指针。根据前文的分析,有类型的空指针不是空指针🤦‍♂️所以报错。

那要怎么做才能避免出现这类问题呢?

一种方法是避免使用指针,也就不存在传nil的情况,直接传值变量,简单粗暴!

另一个办法是利用反射,动态检查接口变量底层值是否为指针:

if h == nil || reflect.ValueOf(h).IsNil() {
  // ...
}

但使用反射可能存存性能问题,大家要注意。