The following article is from 奇伢云存储 Author 奇伢

前几天有小伙伴问我说,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{0x1, 0x2, 0x3}// 方式二
var slice3 = make([]byte, 0)
var slice4 = make([]byte, 3)

首先,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{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 mainimport ("unsafe"
)type sliceType struct {pdata unsafe.Pointerlen   intcap   int
}func main() {var a []byte((*sliceType)(unsafe.Pointer(&a))).len = 0x3((*sliceType)(unsafe.Pointer(&a))).cap = 0x4if 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     uint8B         uint8  // log_2 of # of buckets (can hold up to loadFactor * 2^B items)noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for detailshash0     uint32 // hash seedbuckets    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 growingnevacuate  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 mainfunc main() {var m1 map[string]intvar 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  *itabdata unsafe.Pointer
}type eface struct {_type *_typedata  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 的 nil相关推荐

  1. hadoop源码分析_Spark2.x精通:Job触发流程源码深度剖析(一)

    , 一.概述  之前几篇文章对Spark集群的Master.Worker启动流程进行了源码剖析,后面直接从客户端角度出发,讲解了spark-submit任务提交过程及driver的启动:集群启动.任务 ...

  2. 老夫带你深度剖析Redisson实现分布式锁的原理

    Redis实现分布式锁的原理 前面讲了Redis在实际业务场景中的应用,那么下面再来了解一下Redisson功能性场景的应用,也就是大家经常使用的分布式锁的实现场景. 引入redisson依赖 < ...

  3. 云原生钻石课程 | 第2课:Kubernetes 技术架构深度剖析

    点击上方"程序猿技术大咖",关注并选择"设为星标" 回复"加群"获取入群讨论资格! 本篇文章来自<华为云云原生王者之路训练营>钻 ...

  4. 深度剖析channel

    深度剖析channel golang     2015-10-29 21:16:25     5740     0     5 channel的用法 channel是golang中很重要的概念,配合g ...

  5. libevent源码深度剖析

    原文地址:http://blog.csdn.net/sparkliang/article/details/4957667 libevent源码深度剖析一 --序幕 张亮 1 前言 Libevent是一 ...

  6. libevent源码深度剖析十一

    libevent源码深度剖析十一 --时间管理 张亮 为了支持定时器,Libevent必须和系统时间打交道,这一部分的内容也比较简单,主要涉及到时间的加减辅助函数.时间缓存.时间校正和定时器堆的时间值 ...

  7. 《AngularJS深度剖析与最佳实践》一第1章 从实战开始

    本节书摘来自华章出版社<AngularJS深度剖析与最佳实践>一书中的第1章,作者 雪狼 破狼 彭洪伟,更多章节内容可以访问云栖社区"华章计算机"公众号查看 第1章 从 ...

  8. 深度剖析:Redis分布式锁到底安全吗?看完这篇文章彻底懂了!

    ‍‍‍‍‍‍‍‍‍‍‍‍阅读本文大约需要 20 分钟. 大家好,我是 Kaito. 这篇文章我想和你聊一聊,关于 Redis 分布式锁的「安全性」问题. Redis 分布式锁的话题,很多文章已经写烂了 ...

  9. [Android] Toast问题深度剖析(二)

    欢迎大家前往云+社区,获取更多腾讯海量技术实践干货哦~ 作者: QQ音乐技术团队 题记 Toast 作为 Android 系统中最常用的类之一,由于其方便的api设计和简洁的交互体验,被我们所广泛采用 ...

最新文章

  1. 重磅!IJCAI 2020 好狠,超四成论文未经全文评审就out!被拒作者:一脸懵逼,反馈意见呢?...
  2. centos中查找某一段时间的文件
  3. python的学习笔记(0)之循环的使用1
  4. c++矩阵类_Python线性代数学习笔记——矩阵的基本运算和基本性质,实现矩阵的基本运算...
  5. SQL Server配置支持中文
  6. Java script生成apk_lua脚本实现自动生成APK包
  7. UVa 439 - Knight Moves
  8. [过年菜谱之]红烧甲鱼
  9. 协助你写 Python,只是 AI 取代程序员的第一步
  10. C语言之数组为参数传递表示指针(三十七)
  11. Atitit 数据库抽象层jdbc pdo ado.net等比较与异常点 目录 1. 应该具有的功能 1 1.1. 元数据 API 1 1.2. 分布式事务 vs事务中使用 Savepoint 1
  12. 钽电容的命名,贴片电解电容耐压,封装
  13. Gmail注册时手机号无法验证
  14. 迷你云服务器怎么开,迷你世界迷你云服怎么开_迷你世界迷你云服打开方法_玩游戏网...
  15. 2048小游戏后端的实现
  16. 桌面、文档、下载等文件夹移动后无法复原或desktop.ini不起作用的修复方法
  17. codeblocks无法找到编译器问题的三个原因
  18. 计算机bios设置论文,玩转电脑必看知识——各种BIOS设置详解 的更多相关文章
  19. csapp实验摘选 I Data Lab ——小小菜下士的第一篇博客
  20. 服务器硬盘做过raid5如何设置初始化,服务器做了raid 5之后硬盘怎么分区呢?

热门文章

  1. 如何通过FAT32 U盘安装Windows10
  2. win10自带虚拟机好用吗_这些 Win10 系统自带的实用工具你知道吗?!
  3. @ControllerAdvice基础介绍
  4. 两次腾讯面试都挂二面了,分享下苦逼面试经历
  5. eureka 与 zookeeper 与 consul的特性以及缺点
  6. 密码学领域重大发现:山东大学王小云教授成功破解MD5
  7. 钕铁硼NdFeB材料磁化曲线的测量
  8. python老鼠打洞问题
  9. RAC查看各个节点ASM实例名
  10. 转载 :【非技术】谈谈简历那些事儿