Go 和 C 的变量定义异同 nil 值判断 汇编
深度剖析 Go 的 nil https://mp.weixin.qq.com/s/sHLYy_4XA6254-vLmlu0IA
深度剖析 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也是判断管理结构的第一个指针字段,指针和函数变量本身就是指针;
 
后记
 
推荐使用 gdb 进行对上面的 demo 程序进行调试,加深自己理解。重点关注内存分配和内部代码的生成(反汇编),比如类似 makechan 这样的函数,如果你不调试,你根本不会知道竟然还有这个,我明明没有写过这函数呀?这个是编译器帮你生成的。
~完~
 
往期推荐
 
往期推荐
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 值判断,千方百计,还是踩坑!


今天奇伢给大家分享一个实际踩坑的一个示例,以为的 nil 并不是 nil。众所周知,Go 编程中 nil 值的判断可谓是随处可见:
v := findSomething()
if v != nil {
    // do something
}
nil 值究竟是什么?这个在之前的文章也分析过( 深度剖析 Go 的 nil ),并且也提到了在 interface 相关赋值的时候有差异。但是在实际的项目开发的时候,由于流程过于复杂,代码量过于多,还是不可避免的踩到坑。什么坑呢?
以为某个 interface 判断应该是 nil ,但其实判断是 非 nil 的。其实这个知识点以前也理解过,但是又踩到了,太隐蔽了,所以专门分享一次。
 
复习 interface 的 nil 知识
 
它的定义长这样:
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 值;
 
踩坑记录
 
奇伢的踩坑操作很简单,但也还是把我下了一身冷汗。在一个函数里明明返回了 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 值。
 
总结
 
- 千万要注意。如果 interface 被具体类型变量赋值过,变量的类型会被赋值到首 8 字节。从而导致 interface 非 nil 。无论具体类型变量本身是不是 nil;
- 如果函数返回具体类型,然后在其他地方又要赋值给接口,那还不如函数直接返回接口类型;
- 过一遍 interface 的原理,背诵:千万不要写任何可能存在 接口 = 具体类型(nil)的代码,如果有 nil 值,直接赋给接口吧,不要再过中间商了;
- 再背一遍;
 
后记
 
点赞、在看 是对奇伢最大的支持。
~完~
 
往期推荐
 
往期推荐
深度细节 | Go 的 panic 的三种诞生方式
深度细节 | Go 的 panic 的秘密都在这
深度剖析 Go 的 nil
 
                    
                
 
                
            
         浙公网安备 33010602011771号
浙公网安备 33010602011771号