查看原文
其他

深度剖析 Go 的 nil

奇伢 奇伢云存储 2021-09-08
坚持思考,就会很酷


前几天有小伙伴问我说,golang 里面很多类型使用 nil 来赋值和做条件判断,总是混淆记不住。你可能见过:

  1. 很多文章和书会教你:Go 语言默认定义的类型赋值会被 nil
  2. error 返回值经常用 return nil 的写法;
  3. 多种类型都可以使用 if 是否 != nil

上面的事情在 Go 编程里随处可见,下面思考几个问题,看自己对 nil 这个知识点是否做到了知其所以然

  1. nil 是一个关键字?还是类型?还是变量?
  2. 并非所有类型都跟 nil 有关系,有哪些类型可以使用 != nil 的语法?
  3. 这些不同的类型和 nil 打交道又有什么异同?
  4. 为什么有些复合结构定义了变量还不够,还必须要 make(Type) 才能使用 ?否则会出 panic
  5. 很多书里讲 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

从类型定义得到两个关键点

  1. nil 本质上是一个 Type 类型的变量而已;
  2. Type 类型仅仅是基于 int 定义出来的一个新类型;

nil 官方的注释中,我们可以得到一个重要信息:

划重点nil 适用于 指针函数interfacemapslicechannel 这 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


通过上面,我们理解了几个东西:

  1. Go 的类型定义仅比 C 多做了一件事,把分配的内存块置 0,而已;
  2. 能够和 nil 值做判断的,仅仅有 6 个类型。如果你用来其他类型来和 nil 比较,那么在编译期间 typecheck 会报错检查到会报错;

就笔者理解,nil 这个概念是更高一层的概念,在语言级别,而这个概念是由编译器带给你的。不是所有的类型都可以和 nil 进行比较或者赋值,只有这 6 种类型的变量才能和 nil 值比较,因为这是编译器决定的。

同样的,你不能赋值一个 nil 变量给一个整型,原理也很简单,仅仅是编译器不让,就这么简单。

所以,nil 其实更准确的理解是一个触发条件,编译器看到和 nil 值比较的写法,那么就要确认类型在这 6 种类型以内,如果是赋值 nil,那么也要确认在这 6 种类型以内,并且对应的结构内存为全 0 数据。

所以,记住这句话,nil 是编译器识别行为的一个触发点而已,看到这个 nil 会触发编译器的一些特殊判断和操作。


和 nil 打交道的 6 大类型



slice 类型


变量定义

创建 slice 的本质上是 2 种:

  1. var 关键字定义;
  2. make 关键字创建;
// 方式一
var slice1 []byte
var slice2 []byte = []byte{0x10x20x3}

// 方式二
var slice3 = make([]byte0)
var slice4 = make([]byte3)

首先,slice 变量本身占多少个字节?

答案是:24 个字节。1 个指针字段,2 个 8 字节的整形字段。

思考:varmake 这两种方式有什么区别?

  • 第一种 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{0x10x20x3}

// 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{0x10x20x3}
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 的变量本身究竟是什么?比如上面的 m1m2 ?

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 种类型 slicemapchannelinterface 是复合结构。

指针本身来说也只是一个 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 。


总结


下面总结一些上述分享:

  1. 请撇开死记硬背的语法和玄学,变量仅仅是绑定到一个指定内存块的名字;
  2. Go 从语言层面对程序员做了承诺,变量定义分配的内存一定是置 0 分配的;
  3. 并不是所有的类型能够赋值 nil,并且和 nil 进行对比判断。只有 slicemapchannelinterface、指针、函数 这 6 种类型;
  4. 不要把 nil 理解成一个特殊的值,而要理解成一个触发条件,编译器识别到代码里有 nil 之后,会对应做出处理和判断;
  5. channelmap 类型的变量必须要 make 才能使用的原因(否则会出现空指针的 panic )在于 var 定义的变量仅仅是分配了一个指向 hchanhmap 的指针变量而已,并且还是置 0 分配的。真正的管理结构只有 make 调用才能分配出来,对应的函数分别是 makechanmakemap 等;
  6. slice 变量为什么 var 就能用是因为 struct slice 核心结构是定义的时候就分配出来了
  7. 以上 6 种变量赋值 nil 的行为都是把变量本身置 0 ,仅此而已。slice 的 24 字节管理结构,map 的  8 字节指针,channel 的 8 字节指针,interface 的 16 字节,8 字节指针和函数指针也是如此;
  8. 以上 6 种类型和 nil 进行比较判断本质上都是和变量本身做判断,slice 是判断管理结构的第一个指针字段mapchannel 本身就是指针,interface 也是判断管理结构的第一个指针字段,指针和函数变量本身就是指针;

后记


推荐使用 gdb 进行对上面的 demo 程序进行调试,加深自己理解。重点关注内存分配和内部代码的生成(反汇编),比如类似 makechan 这样的函数,如果你不调试,你根本不会知道竟然还有这个,我明明没有写过这函数呀?这个是编译器帮你生成的

~完~


往期推荐



往期推荐



Go并发编程 — sync.Once 单实例模式的思考

Go 并发编程 — sync.Pool 源码级原理剖析 [2] 终结篇

Golang最细节篇— struct{} 空结构体究竟是啥?

Go 最细节篇 — chan 为啥没有判断 close 的接口 ?

Golang 最细节篇之 — Reader 和 ReaderAt 的区别





坚持思考,方向比努力更重要。关注我:奇伢云存储

: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存