深度剖析 Go 的 nil
前几天有小伙伴问我说,golang 里面很多类型使用 nil
来赋值和做条件判断,总是混淆记不住。你可能见过::
很多文章和书会教你:Go 语言默认定义的类型赋值会被 nil
;error
返回值经常用return nil
的写法;多种类型都可以使用 if
是否!= nil
;
上面的事情在 Go 编程里随处可见,下面思考几个问题,看自己对 nil
这个知识点是否做到了知其所以然 :
nil
是一个关键字?还是类型?还是变量?并非所有类型都跟 nil
有关系,有哪些类型可以使用!= nil
的语法?这些不同的类型和 nil
打交道又有什么异同?为什么有些复合结构定义了变量还不够,还必须要 make(Type)
才能使用 ?否则会出panic
;很多书里讲 slice
也要make
之后才能用,但其实不必要,其实slice
只要定义了就能用。但map
结构却光定义还不行,一定要make(Type)
才能使用?
下面我们就这几个思考题展开,剖析 nil
的秘密。
Go 里面 nil 到底是什么?
我们思考的第一个问题是:nil
是一个关键字?还是类型?还是变量?
答案自然是:变量
。具体是什么样的变量,我们可以点进去 Go 的源码看下:
一窥 Go 官方定义和解释
// nil is a predeclared identifier representing the zero value for a
// pointer, channel, func, interface, map, or slice type.
var nil Type // Type must be a pointer, channel, func, interface, map, or slice type
// Type is here for the purposes of documentation only. It is a stand-in
// for any Go type, but represents the same type for any given function
// invocation.
type Type int
// pointer, channel, func, interface, map, or slice type.
var nil Type // Type must be a pointer, channel, func, interface, map, or slice type
// Type is here for the purposes of documentation only. It is a stand-in
// for any Go type, but represents the same type for any given function
// invocation.
type Type int
从类型定义得到两个关键点:
nil
本质上是一个Type
类型的变量而已;Type
类型仅仅是基于int
定义出来的一个新类型;
从 nil
官方的注释中,我们可以得到一个重要信息:
划重点:nil
适用于 指针,函数,interface
,map
,slice
,channel
这 6 种类型。
Go 和 C 的变量定义异同
相同点:
Go 和 C 的变量定义回归最本质原理:分配变量指定大小的内存,确定一个变量名称。
不同点:
Go 分配内存是置 0 分配的。置 0 分配的意思是:Go 确保分配出来的内存块里面是全 0 数据; C 默认分配的内存则仅仅是分配内存,里面的数据不能做任何假设,里面是未定义的数据,可能是全 0 ,可能是全 1,可能是 0101
等;
Go 置 0 分配的原理:
栈上变量的内存编译阶段由编译器就保证了置 0 分配,这种反汇编看下就知道了; 堆上变量的内存由 runtime
保证,可以仔细观察下mallocgc
这个函数参数有一个needzero
的参数,用户变量定义触发的入口(比如newobject
等等 )这个参数为true
,而该参数就是显式指定置 0 分配的。
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
// ...
}
思考一个小问题:Go 既然所用的类型定义都是置 0 分配的,那为什么 mallocgc
需要 needzero
这么一个参数来控制呢?
首先,Go 的类型定义一定确保是置 0 分配的,这个是 Go 语言给到 Go 程序员的语义。Go runtime
众多的内部的流程(对 Go 程序员不感知的层面)是没有这个规定的。其次,置 0 分配是有性能代价的,如果在确保语义的情况下,能不做自然是最好的。
划重点:Go 的变量定义由语言层面确保置 0 分配,确保内存块全 0 数据。请记住这个最本质的约定。
怎么理解 nil
通过上面,我们理解了几个东西:
Go 的类型定义仅比 C 多做了一件事,把分配的内存块置 0,而已; 能够和 nil 值做判断的,仅仅有 6 个类型。如果你用来其他类型来和 nil 比较,那么在编译期间 typecheck
会报错检查到会报错;
就笔者理解,nil
这个概念是更高一层的概念,在语言级别,而这个概念是由编译器带给你的。不是所有的类型都可以和 nil
进行比较或者赋值,只有这 6 种类型的变量才能和 nil 值比较,因为这是编译器决定的。
同样的,你不能赋值一个 nil
变量给一个整型,原理也很简单,仅仅是编译器不让,就这么简单。
所以,nil
其实更准确的理解是一个触发条件,编译器看到和 nil
值比较的写法,那么就要确认类型在这 6 种类型以内,如果是赋值 nil
,那么也要确认在这 6 种类型以内,并且对应的结构内存为全 0 数据。
所以,记住这句话,nil
是编译器识别行为的一个触发点而已,看到这个 nil
会触发编译器的一些特殊判断和操作。
和 nil 打交道的 6 大类型
slice 类型
变量定义
创建 slice
的本质上是 2 种:
var
关键字定义;make
关键字创建;
// 方式一
var slice1 []byte
var slice2 []byte = []byte{0x1, 0x2, 0x3}
// 方式二
var slice3 = make([]byte, 0)
var slice4 = make([]byte, 3)
首先,slice 变量本身占多少个字节?
答案是:24 个字节。1 个指针字段,2 个 8 字节的整形字段。
思考:var
和 make
这两种方式有什么区别?
第一种 var
的方式定义变量纯粹真的是变量定义,如果逃逸分析之后,确认可以分配在栈上,那就在栈上分配这 24 个字节,如果逃逸到堆上去,那么调用newobject
函数进行类型分配。第二种 make
方式则略有不同,如果逃逸分析之后,确认分配在栈上,那么也是直接在栈上分配 24 字节,如果逃逸到堆上则会导致调用makeslice
函数来分配变量。
变量本身
定义的变量本身分配了多少内存?
上面已经说过了,无论多大的 slice ,变量本身占用 24 字节。这 24 个字节其实是动态数组的管理结构,如下:
type slice struct {
array unsafe.Pointer // 管理的内存块首地址
len int // 动态数组实际使用大小
cap int // 动态数组内存大小
}
该结构体定义在 src/runtime/slice.go
里。
划重点:我们看到无论是 var
声明定义的 slice
变量,还是 make(xxx,num)
创建的 slice
变量,slice
管理结构是已经分配出来了的(也就是 struct slice
结构 )。
所以, 对于 slice
来说,其实并不需要 make
创建的才能使用,直接用 var
定义出来的 slice
也能直接使用。如下:
// 定义一个 slice
var slice1 []byte
// 使用这个 slice
slice1 = append(slice1, 0x1)
定义的时候,slice
结构本身就已经置 0 分配了,这个 24 字节的 slice
结构就是管理动态数组的核心。有这个在 append
函数就能正常处理 slice
变量。
思考:append
又是怎么处理的呢?
本质是调用 runtime.growslice
函数来处理。
nil
赋值
如果把一个已经存在的 slice
结构赋值 nil
,会发生什么事情?
var slice2 []byte = []byte{0x1, 0x2, 0x3}
// slice 赋值 nil
slice2 = nil
发生什么事?
事情在编译期间就确定了,就是把 slice2 变量本身内存块置 0 ,也就是说 slice2 本身的 24 字节的内存块被置 0。
nil
值判断
编译器认为 slice
做可以做 nil
判断,那么什么样的 slice
认为是 nil
的?
指针值为 0 的,也就是说这个动态数组没有实际数据的时候。
思考:仅判断指针?对 len 和 cap 两个字段不做判断吗?
只对首字段 array
做非 0 判断,len,cap 字段不做判断。
如下:
var a []byte = []byte{0x1, 0x2, 0x3}
if a != nil {
}
对应的部分汇编代码如下:
// 赋值 array 的值
0x00000000004587cd <+93>: mov %rax,0x20(%rsp)
// 赋值 len 的值
0x00000000004587d2 <+98>: movq $0x3,0x28(%rsp)
// 赋值 cap 的值
0x00000000004587db <+107>: movq $0x3,0x30(%rsp)
// 判断 slice 是否是 nil
=> 0x00000000004587e4 <+116>: test %rax,%rax
不信 Go 只判断首字段?为了验证,自己思考下一下的程序的输出:
package main
import (
"unsafe"
)
type sliceType struct {
pdata unsafe.Pointer
len int
cap int
}
func main() {
var a []byte
((*sliceType)(unsafe.Pointer(&a))).len = 0x3
((*sliceType)(unsafe.Pointer(&a))).cap = 0x4
if a != nil {
println("not nil")
} else {
println("nil")
}
}
答案是:输出 nil
。
map 类型
变量定义
// 变量定义
var m1 map[string]int
// 定义 & 初始化
var m2 = make(map[string]int)
和 slice 类似,上面也是两种差别的方式:
第一种方式仅仅定义了 m1 变量本身; 第二种方式则是分配 m2 的内存,还会调用 makehmap
函数(不一定是这个函数,要看逃逸分析的结果,如果是可以栈上分配的,会有一些优化)来创建某个结构,并且把这个函数的返回值赋给 m2;
变量本身
map
的变量本身究竟是什么?比如上面的 m1
,m2
?
m1, m2 变量本身是一个指针,内存占用 8 字节。这个指针指向的结构才大有来头,指向一个 struct hmap
结构。
type hmap struct {
count int // # live cells == size of map. Must be first (used by len() builtin)
flags uint8
B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
hash0 uint32 // hash seed
buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)
extra *mapextra // optional fields
}
所以,回到思考问题:为什么 map
结构却光定义还不行,一定要 make(XXMap)
才能使用?
因为,map
结构的核心在于 struct hmap
结构体,这个结构体是很大的一个结构体。map
的操作核心都是基于这个结构体之上的。而 var
定义一个 map
结构的时候,只是分配了一个 8 字节的指针,只有调用 make
的时候,才触发调用 makemap
,在这个函数里面分配出一个庞大的 struct hmap
结构体。
nil
赋值
如果把一个 map
变量赋值 nil
那就很容易理解了,仅仅是把这个变量本身置 0 而已,也就是这个指针变量置 0 ,hmap
结构体本身是不会动的。
当然考虑垃圾回收的话,如果这个 m1
是唯一的指向这个 hmap
结构,那么 m1
赋值 nil
之后,那么这个 hmap
结构体之后就可能被回收。
nil
值判断
搞懂了变量本身和管理结构的区别就很简单了,这里的 nil
值判断也仅仅是针对变量本身的判断,只要是非 0 指针,那么就是非 nil
。也就是说 m1
只要是一个非 0 的指针,就不会是非nil
的。
package main
func main() {
var m1 map[string]int
var m2 = make(map[string]int)
if m1 != nil {
println("m1 not nil")
} else {
println("m1 nil")
}
if m2 != nil {
println("m2 not nil")
} else {
println("m2 nil")
}
}
如上示例程序,m1 是一个 0 指针,m2 被赋值了的。
interface 类型
变量定义
// 定义一个接口
type Reader interface {
Read(p []byte) (n int, err error)
}
// 定义一个接口变量
var reader Reader
// 或者一个空接口
var empty interface{}
变量本身
interface 稍微有点特殊,有两种对应的结构体,如下:
type iface struct {
tab *itab
data unsafe.Pointer
}
type eface struct {
_type *_type
data unsafe.Pointer
}
其中,iface
就是通常定义的 interface 类型,eface
则是通常人们常说的空接口
对应的数据结构。
不管内部怎么样,这两个结构体占用内存是一样的,都是一个正常的指针类型和一个无类型的指针类型( Pointer
),总共占用 16 个字节。
也就是说,如果你声明定义一个 interface
类型,无论是空接口,还是具体的接口类型,都只是分配了一个 16 字节的内存块给你,注意是置 0 分配哦。
nil
赋值
和上面类似,如果对一个 interface
变量赋值 nil
的话,发生的事情也仅仅是把变量本身这 16 个字节的内存块置 0 而已。
nil
值判断
判断 interface
是否是 nil
?这个跟 slice
类似,也仅仅是判断首字段(指针类型)是否为 0 即可。因为如果是初始化过的,首字段一定是非 0 的。
channel 类型
变量定义
// 变量本身定义
var c1 chan struct{}
// 变量定义和初始化
var c2 = make(chan struct{})
区别:
第一种方式仅仅定义了 c1 变量本身; 第二种方式则是分配 c2 的内存,还会调用 makechan
函数来创建某个结构,并且把这个函数的返回值赋给 c2;
变量本身
定义的 channel
变量本身是什么一个表现?
答案是:一个 8 字节的指针而已,意图指向一个 channel
管理结构,也就是 struct hchan
的指针。
程序员定义的 channel
变量本身内存仅仅是一个指针,channel
所有的逻辑都在 hchan
这个管理结构体上,所以,channel
也是必须 make(chan Xtype)
之后才能使用,就是这个道理。
nil
赋值
赋值 nil 之后,仅仅是把这 8 字节的指针置 0 。
nil
值判断
简单,仅仅是判断这 channel 指针是否非 0 而已。
指针 类型
指针和函数类型比较好理解,因为之前的 4 种类型 slice
,map
,channel
,interface
是复合结构。
指针本身来说也只是一个 8 字节的整型,函数变量类型则本身就是个指针。
变量定义
var ptr *int
变量本身
变量本身就是一个 8 字节的内存块,这个没啥好讲的,因为指针都不是复合类型。
nil
赋值
ptr = nil
这 8 字节的指针置 0。
nil
值判断
判断这 8 字节的指针是否为 0 。
函数 类型
变量定义
var f func(int) error
变量本身
变量本身是一个 8 字节的指针。
nil
赋值
本身就是指针,只不过指向的是函数而已。所以赋值也仅仅是这 8 字节置 0 。
nil
值判断
判断这 8 字节是否为 0 。
总结
下面总结一些上述分享:
请撇开死记硬背的语法和玄学,变量仅仅是绑定到一个指定内存块的名字; Go 从语言层面对程序员做了承诺,变量定义分配的内存一定是置 0 分配的; 并不是所有的类型能够赋值 nil
,并且和nil
进行对比判断。只有slice
、map
、channel
、interface
、指针、函数 这 6 种类型;不要把 nil
理解成一个特殊的值,而要理解成一个触发条件,编译器识别到代码里有nil
之后,会对应做出处理和判断;channel
,map
类型的变量必须要make
才能使用的原因(否则会出现空指针的 panic )在于 var 定义的变量仅仅是分配了一个指向hchan
和hmap
的指针变量而已,并且还是置 0 分配的。真正的管理结构只有 make 调用才能分配出来,对应的函数分别是makechan
和makemap
等;slice
变量为什么var
就能用是因为struct slice
核心结构是定义的时候就分配出来了;以上 6 种变量赋值 nil
的行为都是把变量本身置 0 ,仅此而已。slice
的 24 字节管理结构,map
的 8 字节指针,channel
的 8 字节指针,interface
的 16 字节,8 字节指针和函数指针也是如此;以上 6 种类型和 nil
进行比较判断本质上都是和变量本身做判断,slice
是判断管理结构的第一个指针字段,map
,channel
本身就是指针,interface
也是判断管理结构的第一个指针字段,指针和函数变量本身就是指针;
后记
~完~
往期推荐
往期推荐
Go并发编程 — sync.Once 单实例模式的思考
Go 并发编程 — sync.Pool 源码级原理剖析 [2] 终结篇
Golang最细节篇— struct{} 空结构体究竟是啥?
Go 最细节篇 — chan 为啥没有判断 close 的接口 ?
Golang 最细节篇之 — Reader 和 ReaderAt 的区别
坚持思考,方向比努力更重要。关注我:奇伢云存储