Go 和 C 的变量定义异同 nil 值判断 汇编
深度剖析 Go 的 nil https://mp.weixin.qq.com/s/sHLYy_4XA6254-vLmlu0IA
深度剖析 Go 的 nil
![](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093820538-135752983.png)
前几天有小伙伴问我说,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
的秘密。
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093820563-1084018614.png)
Go 里面 nil 到底是什么?
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093820556-2098410693.png)
我们思考的第一个问题是: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 数据。请记住这个最本质的约定。
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093820563-1084018614.png)
怎么理解 nil
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093820556-2098410693.png)
通过上面,我们理解了几个东西:
- Go 的类型定义仅比 C 多做了一件事,把分配的内存块置 0,而已;
- 能够和 nil 值做判断的,仅仅有 6 个类型。如果你用来其他类型来和 nil 比较,那么在编译期间
typecheck
会报错检查到会报错;
就笔者理解,nil
这个概念是更高一层的概念,在语言级别,而这个概念是由编译器带给你的。不是所有的类型都可以和 nil
进行比较或者赋值,只有这 6 种类型的变量才能和 nil 值比较,因为这是编译器决定的。
同样的,你不能赋值一个 nil
变量给一个整型,原理也很简单,仅仅是编译器不让,就这么简单。
所以,nil
其实更准确的理解是一个触发条件,编译器看到和 nil
值比较的写法,那么就要确认类型在这 6 种类型以内,如果是赋值 nil
,那么也要确认在这 6 种类型以内,并且对应的结构内存为全 0 数据。
所以,记住这句话,nil
是编译器识别行为的一个触发点而已,看到这个 nil
会触发编译器的一些特殊判断和操作。
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093820563-1084018614.png)
和 nil 打交道的 6 大类型
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093820556-2098410693.png)
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 。
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093820563-1084018614.png)
总结
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093820556-2098410693.png)
下面总结一些上述分享:
- 请撇开死记硬背的语法和玄学,变量仅仅是绑定到一个指定内存块的名字;
- 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
也是判断管理结构的第一个指针字段,指针和函数变量本身就是指针;
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093820563-1084018614.png)
后记
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093820556-2098410693.png)
推荐使用 gdb 进行对上面的 demo 程序进行调试,加深自己理解。重点关注内存分配和内部代码的生成(反汇编),比如类似 makechan 这样的函数,如果你不调试,你根本不会知道竟然还有这个,我明明没有写过这函数呀?这个是编译器帮你生成的。
~完~
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093820563-1084018614.png)
往期推荐
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093820556-2098410693.png)
往期推荐
Go并发编程 — sync.Once 单实例模式的思考
Go 并发编程 — sync.Pool 源码级原理剖析 [2] 终结篇
Golang最细节篇— struct{} 空结构体究竟是啥?
Go 最细节篇 — chan 为啥没有判断 close 的接口 ?
Golang 最细节篇之 — Reader 和 ReaderAt 的区
Go 的 nil 值判断,千方百计,还是踩坑! https://mp.weixin.qq.com/s/BwqHMhc2WtAY_R-UffNQ4w
Go 的 nil 值判断,千方百计,还是踩坑!
![](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093840765-1758244571.png)
今天奇伢给大家分享一个实际踩坑的一个示例,以为的 nil 并不是 nil。众所周知,Go 编程中 nil 值的判断可谓是随处可见:
v := findSomething()
if v != nil {
// do something
}
nil 值究竟是什么?这个在之前的文章也分析过( 深度剖析 Go 的 nil ),并且也提到了在 interface 相关赋值的时候有差异。但是在实际的项目开发的时候,由于流程过于复杂,代码量过于多,还是不可避免的踩到坑。什么坑呢?
以为某个 interface 判断应该是 nil ,但其实判断是 非 nil 的。其实这个知识点以前也理解过,但是又踩到了,太隐蔽了,所以专门分享一次。
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093840752-1620857627.png)
复习 interface 的 nil 知识
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093840778-276157310.png)
它的定义长这样:
type iface struct {
tab *itab
data unsafe.Pointer
}
type eface struct {
_type *_type
data unsafe.Pointer
}
- interface 变量定义是一个 16 个字节的结构体,首 8 字节是类型字段,后 8 字节是数据指针。普通的 interface 是 iface 结构,interface{} 对应的是 eface 结构;
- interface 变量新创建的时候是 nil ,则这 16 个字节是全 0 值;
- interface 变量的 nil 判断,汇编逻辑是判断首 8 字节是否是 0 值;
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093840752-1620857627.png)
踩坑记录
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093840778-276157310.png)
奇伢的踩坑操作很简单,但也还是把我下了一身冷汗。在一个函数里明明返回了 nil 值,但是到了外面判断却是非 nil 。
type Worker interface {
Work() error
}
type Qstruct struct{}
func (q *Qstruct) Work() error {
return nil
}
// 返回一个 nil
func findSomething() *Qstruct {
return nil
}
func main() {
// 定义好接口
var v Worker
v = findSomething()
if v != nil {
// 走的是这个分支
fmt.Printf("v(%v) != nil\n", v)
} else {
fmt.Printf("v(%v) == nil\n", v)
}
}
震惊!findSomething 这个函数返回的明确是 nil,但是 if 判断的分支走的是 != nil 这个分支。当时我确实愣了 10 秒,然后才反应过来。虽然知识点以前知道,但是实际编程还是不免踩坑。
1 深究下这里的原因是啥?
回到 v = findSomething()
这行代码。这个是关键。这个是一个赋值操作,左边是一个接口变量,函数 findSomething 返回的是一个具体类型指针。所以,它一定会把接口变量 iface 前 8 字节设置非零字段的,因为有具体类型呀(无论具体类型是否是 nil 指针)。而判断 interface 是否是 nil 值,则是只根据 iface 的前 8 字节是否是零值判断的。
划重点:具体类型到接口的赋值一定会导致接口非零(不考虑编译不过等问题)。
这个问题的原理就这样简单。
2 那么怎么改呢?
记住一个原则:如果任何地方有判断接口是否为 nil 值的逻辑,那我建议你一定不要写任何有 接口 = 具体类型(nil)
逻辑的代码。如果是 nil 值就直接赋给接口,而不要过具体类型的转换。
所以上面的改动很简单:
// 如果 findSomething 需要返回 nil 值,那么直接返回 nil 的 interface
func findSomething() Worker {
return nil
}
这样,findSomething 需要返回 nil 的时候,则是直接返回 nil 的 interface,这是一个 16 个字节全零的变量。而在外面赋值给 v 的时候,则是 interface 到 interface 的赋值,所以 v = findSomething()
的赋值之后,v 还是全 0 值。
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093840752-1620857627.png)
总结
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093840778-276157310.png)
- 千万要注意。如果 interface 被具体类型变量赋值过,变量的类型会被赋值到首 8 字节。从而导致 interface 非 nil 。无论具体类型变量本身是不是 nil;
- 如果函数返回具体类型,然后在其他地方又要赋值给接口,那还不如函数直接返回接口类型;
- 过一遍 interface 的原理,背诵:千万不要写任何可能存在
接口 = 具体类型(nil)
的代码,如果有 nil 值,直接赋给接口吧,不要再过中间商了; - 再背一遍;
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093840752-1620857627.png)
后记
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093840778-276157310.png)
点赞、在看 是对奇伢最大的支持。
~完~
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093840752-1620857627.png)
往期推荐
![图片](https://img2022.cnblogs.com/blog/1006507/202206/1006507-20220620093840778-276157310.png)
往期推荐
深度细节 | Go 的 panic 的三种诞生方式
深度细节 | Go 的 panic 的秘密都在这
深度剖析 Go 的 nil