在2016年的GopherCon上,Francesc Campoy分享了《Understanding nil》,视频连接:https://www.youtube.com/watch?v=ynoY2xz-F8s&t=587s .网上也有大神对这个视频的内容作了总结,如:https://www.jianshu.com/p/dd80f6be7969 以及 https://blog.keyboardman.me/2018/05/12/nil-in-go/ 。我无法验证他们是否原创,但是其他转发的文章都将原文指向这两篇文章。如果我搞错了,欢迎指正。
本文尝试对视频分享的内容以自己的理解作一次笔记,方便自己加深记忆。如果还能够帮到大家一点点,那是再好不过了。由于英文水平有限,如果文中有错,同样欢迎指正。
nil和null类似,都是表示空/零(zero)或者元(中华文字博大精深,Alpha zero被国人成为阿尔法元),如元年元月元日,即最初始的那个值。下图截自该视频,数据来源是维基百科。图中可以看到,nil和null一样是数值0的另一个称呼。
在Go语言中,布尔类型的"0"(初始值)为false,数值类型的"0"为0,字符串类型的"0"为空字符串"",而指针/切片/映射/通道/函数和接口的"0"即为nil。
当你声明一个结构体变量并未初始化时,该结构体所有的域(Field)都为"0"(初始值):
type Person struct {
AgeYears int
Name string
Friend []Person
}
var p Person //Person{0,"",nil}
这里需要注意的是nil不是Go语言的关键字,即下面的语句可以通过编译,但是你不应该这么做:
var nil = errors.New("my god")
这一部分是该视频的重点,大概占了视频一半的时间,分别从各种变量类型讲解了nil使用上的优势和便捷。
和其他语言稍微不同的是,Go语言的函数接收器(receiver)允许nil的存在,即下面的代码可以编译通过:
func (p *Person) SayHi(){
fmt.Println("Hi")
}
var p *Person
p.SayHi() // print "Hi"
这个特性让我们无需在每次调用方法前判断指针是否为nil,如:
type node struct{
value int
next *node
}
func (n *node) Sum() int{
s:=0
if n.next != nil{
s = n.next.Sum()
}
return n.value + s
}
var n *node
if n != nil {
n.Sum()
}
而是在方法内判断receiver是否nil,可以简省不少代码:
type node struct{
value int
next *node
}
func (n *node) Sum() int{
if n == nil{
return 0
}
return n.value + n.next.Sum()
}
var n *node
n.Sum()
nil切片是长度和容量都为0的切片,在使用中如果没有必要,我们完全可以不初始化nil切片,因为nil切片也有切片的功能,如:
var ss []string // nil切片
len(ss) // 0
cap(ss) // 0
for s := range ss // 迭代0次
ss[i] // panic:index out of range
nil切片还可以直接append数据,如:
var ss []string
ss = append(ss,"hello world") // ss ["hello world"]
视频的演讲者表示Francesc Campoy,在使用切片时可以放心地使用nil切片而不需要担心其容量问题,因为它的每次重分配容量都是倍增的。即nil切片的第一次append,会重分配一个容量为1的切片。而后会分配容量为2/4/8/16这样倍增的数值,所以长度为1000的切片也只是重分配了10次。如果切片的内存重分配确实影响了应用的性能,那可以考虑声明一个具有一定容量的切片,否则,使用nil切片,因为他们通常足够快。
nil映射是指未初始化的映射,其长度为0,可读但不可写,如:
var m map[string]string
len(m) // 0
for key,value := range m // 迭代0次
value,ok := m["key"] // "",false
m["key"]="value" // panic: assignment to entry in nil map
nil映射用在只读的地方非常方便,假设有一个创建Get请求的函数:
func NewGet(url string,headers map[string]string)(*http.Request,error){
req, err := http.NewRequest(http.MethodGet,url,nil)
if err != nil{
return nil,err
}
for k,v := range headers{
req.Header.Set(k,v)
}
return req,nil
}
如果你不想设置该请求的header,那你只需要传入nil:
NewGet("http://google.com",nil)
而不需传入一个空的映射,如:
NewGet("http://google.com",map[string]string{})
nil通道是未初始化的通道,当尝试写入或者读取时,会永久阻塞,且无法被close。如:
var c chan os.Signal
<-c // 永久阻塞
c <- x // 永久阻塞
close(c) // panic close of nil channel
nil通道的永久阻塞的特性在某些场景非常有用。视频举例了一个合并通道的栗子,由于栗子比较长,我这里只简单分析其使用场景。感兴趣的可以看看视频,这一段从第22分钟开始,讲得很风趣幽默,挺搞笑的。
在讲nil通道的使用场景前,我们先看看关闭的通道的特性。关闭的通道无法发送数据,接收数据时值为"0"、false,无法close:
var c chan os.Signal
v, ok <- c // nil, false
c <- v // panic: send on closed channel
close(c) // panic: close of nil channel
视频中讲nil通道的场景是,当我们使用select时:
select {
case v,ok := <-a:
doSomething...
case v,ok := <-b:
doSomething...
}
如果通道a或者b的其中一个被关闭,即close(a)或者close(b),那么,select会陷入死循环,不停地从关闭的通道中读取到"0",false,不停地执行doSomething,最后你们的数据中心烧起来了…
nil通道就能阻止这种事情的发生,当我们希望一个通道停止读写时,我们可以直接设置其为nil,如:
select {
case v,ok := <-a:
if !ok{
a = nil
}
doSomething...
case v,ok := <-b:
if !ok{
b = nil
}
doSomething...
}
这样就能避免数据中心烧成狗了。
由于在Go语言中,函数可以作为结构体的域(Field)存在,所以必须为其设置一个初始值,那就是nil:
type Foo struct{
f func() error // 初始值为nil
}
nil函数可以用于懒加载或者执行默认操作,如:
func NewServer(logger func(string,...interface{})){
if logger == nil{
logger = log.Printf // 使用默认logger
}
logger("init ... ")
}
nil接口最为常用的场景是作为一个信号,想必写过很多Go代码的已经见过无数次了,如:
if err != nil {
...
}
需要注意的是空指针并不等于空接口:
var ps *string // nil
var i interface{} // nil
ps == i // false