nil

寒江蓑笠翁大约 9 分钟

nil

引子

在某一次编写代码的过程中,我需要调用Close()方法来关闭多个对象,就像下面的代码一样

type A struct {
	b B
	c C
   	d D
}

func (a A) Close() error {
	if a.b != nil {
		if err := a.b.Close(); err != nil {
			return err
		}
	}

	if a.c != nil {
		if err := a.c.Close(); err != nil {
			return err
		}
	}
    
    if a.d != nil {
        if err := a.d.Close(); err != nil {
            return err
        }
    }

	return nil
}

但写这么多if判断感觉不太优雅,BCD都实现了Close方法,应该可以更简洁一点,于是我把它们放进了一个切片中,然后循环判断

func (a A) Close() error {
	closers := []io.Closer{
		a.b,
		a.c,
		a.d,
	}
    
	for _, closer := range closers {
		if closer != nil {
			if err := closer.Close(); err != nil {
				return err
			}
		}
	}
	return nil
}

这样看起来似乎要更好一点,那么运行一下看看

func main() {
	var a A
	if err := a.Close(); err != nil {
		panic(err)
	}
	fmt.Println("success")
}

结果出乎意料,居然崩了,错误信息如下,意思就是不能对nil接收者调用方法,循环中的if closer != nil似乎没有起到过滤作用,

panic: value method main.B.Close called using nil *B pointer

上面这个例子是笔者曾经遇到过的一个bug的简化版,很多初学者刚开始可能都会和我一样犯这种错误,下面就来讲讲到底是怎么个回事。

接口

在之前的章节提到过,nil是引用类型的零值,比如切片,map,通道,函数,指针,接口的零值。对于切片,map,通道,函数,可以将它们都看作是指针,都是由指针指向具体的实现。

但唯独接口不一样,接口由两个东西组成:类型和值

当试图对一个变量赋值nil时,会无法通过编译,并且提示如下信息

use of untyped nil in assignment

内容大致为不能声明一个值为untyped nil的变量。既然有untyped nil,相对的就肯定会有typed nil,而这种情况往往出现在接口身上。看下面一个简单的例子

func main() {
	var p *int
	fmt.Println(p)
	fmt.Println(p == nil)
	var pa any
	pa = p
	fmt.Println(pa)
	fmt.Println(pa == nil)
}

输出

<nil>
true 
<nil>
false

结果非常奇怪,明明pa的输出就是nil,但它就是不等于nil,我们可以通过反射来看看它到底是什么

func main() {
	var p *int
	fmt.Println(p)
	fmt.Println(p == nil)
	var pa any
	pa = p
	fmt.Println(reflect.TypeOf(pa))
	fmt.Println(reflect.ValueOf(pa))
}

输出

<nil>
true 
*int 
<nil>

从结果可以看到,它实际上是(*int)(nil),也就是说pa存储的类型是*int,而它实际的值是nil,当对一个接口类型的值进行相等运算的时候,首先会判断它们的类型是否相等,如果类型不相等,则直接判定为不相等,其次再去判断值是否相等,这一段的接口判断的逻辑可以参考自cmd/compile/internal/walk.walkCompare函数。

所以,如果想要一个接口等于nil,必须要它的值为nil,并且类型也为nil,因为接口中的类型实际上也是一个指针

type iface struct {
	tab  *itab
	data unsafe.Pointer
}

如果想要绕开类型,直接判断其值是否为nil,可以使用反射,下面是一个例子

func main() {
	var p *int
	fmt.Println(p)
	fmt.Println(p == nil)
	var pa any
	pa = p
	fmt.Println(reflect.ValueOf(pa).IsNil())
}

通过IsNil()可以直接判断其值是否为nil,这样一来就不会出现上述的问题了。所以在平时使用的过程中,假设函数的返回值是一个接口类型,如果你想返回一个零值,最好直接返回nil,不要返回任何具体实现的零值,就算它实现了该接口,但它永远也不会等于nil,这就可能导致例子里面的错误。

小结

解决了上面的问题,接下来看看下面这几个例子

当结构体的接收者为指针接收者时,nil是可用的,看下面一个例子

type A struct {

}

func (a *A) Do()  {

}

func main() {
	var a *A
	a.Do()
}

这段代码可以正常运行,并且不会报空指针错误。

当切片为nil的时候,可以访问它的长度和容量,也可以对其添加元素

func main() {
	var s []int
	fmt.Println(len(s))
	fmt.Println(cap(s))
	s = append(s, 1)
}

当map为nil的时候,还可以对其进行访问

func main() {
	var s map[string]int
	i, ok := s[""]
	fmt.Println(i, ok)
	fmt.Println(len(s))
}

上面例子中的这些有关于nil的特性可能会让人比较困惑,尤其是对于go的初学者而言,nil代表着上面几种类型的零值,也就是默认值,默认值应当表现出默认的行为,这也正是go的设计者所希望看到的:让nil变得更有用,而不是直接抛出空指针错误。这一理念同样也体现在标准库中,比如开启一个HTTP服务器可以这样写

http.ListenAndServe(":8080", nil)

我们可以直接传入一个nil Handler,然后http库就会使用默认的Handler来处理HTTP请求。

提示

感兴趣的可以看看这个视频Understanding nil - Gopher Conference 2016open in new window,讲的非常清晰易懂。