Golang中nil用法你学废了吗?

大家好,今天给大家分享的知识点是 Go 中的nil用法详解,在实际工作中,看到很多同学使用nil的时候还是不太理解,导致了很多奇怪的问题发生(尤其是和nil 做比较经常出现走入到奇怪的分支)。

如果觉得一个技术点奇怪,不是我们所期望的效果,原因是我们对其原理不够了解,不够熟悉。 那今天就一起探索下这块的知识。

写作不易,也希望喜欢的同学帮忙点个赞,点个关注。

Go基础定义与类型

Go 语言中经常使用nil,来表示多种类型的零值, 相信很多同学在使用过程中都踩过很多的坑,比如 nil 和其他类型比较,什么时候等,什么时候不等,经常会被用错,进而导致程序出现问题。

这篇文章希望梳理一下 nil 的用法和原理。

两个 nil 值未必相等案例

从工作中遇到的实际问题,如下的一段代码开始:

Golang 复制代码
package main

import (
    "fmt"
    "reflect"
)

type MyError struct{}
func (me *MyError) Error() string {
    return "my error"
}
func SomeThing() error {
    var myError *MyError    // 默认初始化为 nil
    // ...
    if myError == nil { // 条件成立
        fmt.Println("myError is nil")
    }
    return myError
}
func main() {
    err := SomeThing()
    fmt.Println(reflect.TypeOf(err), reflect.ValueOf(err)) // *main.MyError <nil>
    if err != nil {     // 虽然没有返回,这里会被执行,因为 err 的类型不是 nil
        fmt.Println(err)
    }
}

运行结果是:

go 复制代码
myError is nil
*main.MyError my error
my error

error 是一个接口类型, 它包含一个 Error() 方法,返回值为 string。任何实现这个接口的类型都可以作为一个错误使用。 MyError 是实现了error接口的类型。

出现这样结果的原因是:内外两个变量与nil比较时类型不一样(里面是指针的比较,外部的比较是接口类型的比较)。在返回的时候出现了类型转换。 关于Go 类型四种类型转换,这里之前做过总结。Golang中四种类型转换详解

那紧接着疑惑就来了。 如果将返回error接口换成 *MyError,结果怎么样呢? 或者是我使用指针类型返回呢?等等改造。 那哪种方式是推荐的呢?

经过一顿的改造与尝试,似乎懂了,但似乎也没有吃透。 因此希望通过写出来方式梳理下自己思路。

nil 到底是怎么定义的?

下面是 buildin/buildin.go中对于 nil 的定义。

Golang 复制代码
// nil is a predeclared identifier representing the zero value for a
// pointer, channel, func, interface, map, or slice type.
// Type must be a pointer, channel, func, interface, map, or slice type.
var nil Type 

从上面的定义看出,nil 是Go中内置的标识符,表示 pointer , channel, func, interface, map, slice 类型的零值。 nil 并非 Go 中关键字。

小结:nil 是一个内置变量,编译器 遇到 与nil 值比较用户,会确认类型在这6种类型以内。同样赋值 nil,那么也要确认在这6种类型以内,并且对应的结构内存为全0(或者是内存尚未被初始化)。

nil 的比较

在语言级别,nil 概念是由编译器带给你的。不是所有的类型都可以和 nil 进行比较或者赋值,只有这 6 种类型的变量才能和 nil 值比较,因为这是编译器决定的。同样的,不能赋值一个 nil 变量给一个整型,原因也很简单,仅仅是编译器不让,就这么简单。

上面这段引用自xx技术大佬的原文。

nil 其实更准确的理解是一个触发条件,编译器看到和 nil 值比较的写法,那么就要确认类型在这 6 种类型以内,如果是赋值 nil,那么也要确认在这 6 种类型以内。

nil == nil : 这种方式也是不符合的,编辑器会报错 invalid operation: nil == nil

为了一探究竟,因此我们就不得不研究下 可以和nil类型比较的6种类型的数据结构与定义。

nil 与指针比较

Golang 复制代码
var a *context.Context
var b *int
// a == nil  // true
// b == nil  // true
// a == b    // invalid operation: a == b (mismatched types *context.Context and *int)

这里的指针和C中指针很相似,也是一个 8 字节的内存块,其值为指向对象的内存地址。

同样的指针和nil比较,就是判断指针指向的对象是否0值,使用代码表示如下:

Golang 复制代码
func main()  {
   var a = (*int64)(unsafe.Pointer(uintptr(0x0)))
   fmt.Println(a == nil)  //true
}

其他类型的变量也是个指针,和 nil 的比较的逻辑也是一样的。

nil 与 slice 比较

Go 中切片是对数组的抽象。与C相比,Go 提供了一种灵活,功能强悍的内置类型切片("动态数组"),与数组相比切片的长度是不固定的,可以追加元素,在追加时可能使切片的容量增大。 关于slice内容,我们后边会有专门的篇幅进行详细介绍。

Golang 复制代码
var slice1 []int
var slice2 = make([]int, 2)
var slice3 = new([]int)

fmt.Println(slice1 == nil) // ture
fmt.Printf("%v, %v\n",reflect.TypeOf(slice1), reflect.ValueOf(slice1)) // []int, []
fmt.Println(slice2 == nil) // false
fmt.Printf("%v, %v\n",reflect.TypeOf(slice2), reflect.ValueOf(slice2)) // []int, [0 0]
fmt.Println(slice3 == nil) // false
fmt.Printf("%v, %v\n",reflect.TypeOf(slice3), reflect.ValueOf(slice3)) //*[]int, &[]
fmt.Println(*slice3 == nil) // true
fmt.Printf("%v, %v\n",reflect.TypeOf(*slice3), reflect.ValueOf(*slice3)) //[]int, []

slice4 := append(*slice3, 5)
slice3 = nil
fmt.Printf("%v", slice4) //[5]

在解释上面的结果输出之前,我们先看下 src/runtime/slice.go 中的定义:

Golang 复制代码
type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

array:一个指针,指向底层存储数据的数组。 len:切片的长度,在代码中我们可以使用len()函数获取这个值。 cap:切片的容量,即在不扩容的情况下,最多能容纳多少元素。在代码中我们可以使用cap()函数获取这个值。

nil 与 slice类型的比较,其实是 array 字段是否被初始化(或者是指针是否被分配实际空间)。

输出分析:

  1. slice1 == nilslice1 表示定义了一个类型为 int 的切片, 此时并没有对array字段进行内存空间分配,因此与nil 比较结果为 true
  2. slice2 == nilslice2是使用make进行定义, make的语法为:func make(t Type, size ...IntegerType) Type。 返回类型已经对 array字段进行分配内存。 因此返回为false
  3. slice3 == nilslice3 使用 new([]int)定义, new的语法为: func new(Type) *Type;。 返回值是一个指针,指针的类型指向[]int类型。 因此这个比较其实是指针类型的与nil的比较 根据函数定义,可以看出返回的指针已经被初始化。 因此返回为false
  4. *slice3 == nil,经过取值后,变成了slice类型,与nil 的比较。此时的array尚未被初始化,因此结果为true
  5. slice3 = nil, 这个赋值操作可以将对 array 变量的指针引用量减1,方便GC进行垃圾回收。

小结:

  • 如果当前是slice类型与nil比较,判断的其实是 array字段是否为空。
  • 如果当前是指向slice类型指针与nil比较,遵循的其实是ptr类型与nil的比较。

【下面是slice引申,与主题无关可以跳过】 其实这里有个很神奇的变量slice3, 使用new定义他返回的是24字节长度的 slice 类型并不是指针。 这解释了解释为什么slice new后可以直接使用,map就不行。

Golang 复制代码
var aslice = new([]int)
fmt.Println(append(*aslice, 1)) 
var amap = new(map[string]string)
(*amap)["a"] = "a" // panic

nil 与 map 的比较

Go map 底层实现方式是 Hash 表。

Golang 复制代码
// A header for a Go map.
type hmap struct {
   count     int    // 元素的个数。len() 函数返回的就是这个值
   flags     uint8  // 状态标记位。如是否被多线程读写、迭代器在使用新桶、迭代器在使用旧桶等
   B         uint8  // 桶指数,表示 hash 数组中桶数量为 2^B(不包括溢出桶)。最大可存储元素数量为 loadFactor * 2^B
   noverflow uint16 // 溢出桶的数量的近似值。详见函数 incrnoverflow()
   hash0     uint32 // hash种子
   buckets    unsafe.Pointer // 指向2^B个桶组成的数组的指针。可能是 nil 如果 count 为 0
   oldbuckets unsafe.Pointer // 指向长度为新桶数组一半的旧桶数组,仅在增长时为非零
   nevacuate  uintptr        // 进度计数器,表示扩容后搬迁的进度(小于该数值的桶已迁移)
   extra *mapextra // 可选字段, extra.overflow:保存溢出桶链表, extra.oldoverflow:保存旧溢出桶链表, extra.nextOverflow:下一个空闲溢出桶地址
}

下面是map数据结构图:

下面是map的定义与nil值的比较 【如果觉得麻烦的可以直接跳过去看结论,里面写了很多对结构体指针进行探索的内容】

Golang 复制代码
// 第一种定义方法
var map1 map[string]string
fmt.Println(map1 == nil) // true
fmt.Printf("%v, %v, %d\n",reflect.TypeOf(map1), reflect.ValueOf(map1), len(map1)) // map[string]string, map[], 0
// map1["0"] = "1" // assignment to entry in nil map

// 第二种定义方法
map2 := map[string]int{
        "1": 2,
        "3": 4,
        "5": 6,
}
fmt.Println(map2 == nil)// false
fmt.Println(map2, len(map2)) // map[1:2 3:4 5:6] 3
fmt.Printf("%v, %v\n",reflect.TypeOf(map2), reflect.ValueOf(map2)) // map[string]int, map[1:2 3:4 5:6]

// 第二种定义方法,只是初始化值为空
map3 := map[string]int{}
fmt.Println(map3 == nil)// false
fmt.Println(map3, len(map3)) // map[] 0
fmt.Printf("%v, %v\n",reflect.TypeOf(map3), reflect.ValueOf(map3)) // map[string]int, map[]

// 第三种方法
map4 := new(map[string]int)
fmt.Println(map4 == nil) //false
fmt.Printf("%v, %v\n",reflect.TypeOf(map4), reflect.ValueOf(map4)) // *map[string]int, &map[]
// (*map4)["a"] = 1 // panic: assignment to entry in nil map


//!!! 下面的方法是对 map类型的指针的探索与研究,感兴趣的同学可以看下,不感兴趣跳过也也不影响阅读。
var ptr = unsafe.Pointer(&map2)
fmt.Printf("%v, %v\n", reflect.TypeOf(ptr), reflect.ValueOf(ptr)) // unsafe.Pointer, 0xc000056028
var ptrMap = new(map[string]int)
fmt.Printf("%v, %v\n", reflect.TypeOf(ptrMap), reflect.ValueOf(ptrMap)) // *map[string]int, &map[]
ptrMap = (*map[string]int)(ptr)
fmt.Printf("map length is: %d\n", len(*ptrMap))  // map length is: 3

// 尝试对ptr进行操作
fmt.Printf("宽度为: %d\n", unsafe.Sizeof(ptr))
var ptr2 = unsafe.Add(ptr, 8) // 尝试对指针进行偏移计算,因为第一个是count
var ptrint *int = (*int)(ptr2)
fmt.Printf("%d\n", *ptrint)
// invalid operation: cannot indirect ptr (variable of type unsafe.Pointer) Go不支持对指针进行此类算术运算。任何此类操作都将导致编译时错误
// fmt.Printf("xxx: %d", int(*(ptr+8)))

fmt.Printf("长度为2: %d\n", unsafe.Sizeof(map2))
var ptr3 = (*int)(unsafe.Pointer(&map2))
fmt.Printf("*ptr3: %d", *ptr3)

结论:

  1. 使用make 方式定义,根据 func makemap(t *maptype, hint int, h *hmap) *hmap 函数 make 返回值可以看出,map 是一个指针,但是指向了一个结构体(hmap)。

  2. var 初始化得到的只是一个指针变量,该指针无法直接操作写(使用的话会报空指针的 panic),必须使用 make 初始化对其结构体进行内存分配后才可以使用。

  3. new 关键字定义,返回的值是 map 类型指针,因此与nil的比较为 true

因此使用make定义的map, 即使map的长度为0,与nil比较,值也是不相同的。 nil 表示是否对map结构是否初始化的标识。

【思考】slicemap 分别作为函数参数时有什么区别?

分别看下slicemap 中 使用make 方法定义的区别:

  • func makeslice(et *_type, len, cap int) slice, 函数返回的是 Slice 结构体。
  • func makemap(t *maptype, hint int, h *hmap) *hmap, 函数返回的是*hmap指针

Go中的参数传递实际都是值传递,将slice作为参数传递时,函数中会创建一个slice参数的副本,这个副本同样也包含array,len,cap这三个成员。

副本中的array指针与原slice指向同一个地址,所以当修改副本slice的元素时,原slice的元素值也会被修改。但是如果修改的是副本slicelencap时,原slicelencap仍保持不变。

如果在操作副本时由于扩容操作导致重新分配了副本slicearray内存地址,那么之后对副本slice的操作则完全无法影响到原slice,包括slice中的元素。

关于slice的使用,如果使用安全与性能下期会讲到。

nil 与 channel 的比较

channel使用make 定义 func makechan(t *chantype, size int) *hchan

Golang 复制代码
var chan1 chan int
fmt.Println(chan1 == nil) // true
chan2 := make(chan int, 0)
fmt.Println(chan2 == nil) // false
chan3 := new(chan int)
fmt.Println(chan3 == nil) // false

nilchannel的比较,结果表现与map类型相似。

nil 与 interface{} 的比较

interface 是一组 method 签名的组合,我们通过 interface 来定义对象的一组行为,只要实现了接口的所有方法,就表示该类型实现了该接口。

例如:下面关于 Animal 的接口。

Golang 复制代码
type Animal interface {
    Eat(string) string
    Drink(string) string
}

interface{}: 表示空接口,空接口可用于保存任何数据。

interface 有两种类型来实现的: runtime.ifaceruntime.eface。区别就是接口中是否包含了方法。 ifaceeface 都是由两个子类型组合而成。

  • eface : _type + data, 其中_type 数据结构主要是对data结构的描述
  • iface : itab + data,其中 itab 里面包含了 _type结构。 并且多了对方法的描述。

对于 interface 是否为 nil 值的判断,必须要两部分都为空,interface 才为 nil其实只要tpyenil, 那么结果就一定为nil

案例分析

学废没学废,用下面的案例验证一下就知道了。

案例1:

go 复制代码
func testInterface() interface{} {
    var c *Cat
    //fmt.Printf("%v, %v \n", reflect.TypeOf(c), reflect.ValueOf(c))
    fmt.Printf("c is nil: %v\n", c == nil) // true
    return c
}

func main(){
    test := testInterface()
    fmt.Printf("%v, %v \n", reflect.TypeOf(test), reflect.ValueOf(test)) // *main.Cat, *main.Cat
    if test == nil { 
        fmt.Println("test is nil")
    } else {
        fmt.Println("test is not nil")
    }
}

执行结果为test is not nil。解析:

  • testInterface 函数中 c == nil进行比较,实际上 指针类型与nil进行比较。
  • main 中的比较实际上 interface{}类型与 nil 比较,此时的 interface{} 类型是*main.Cat,因此结果是false

案例2:

Golang 复制代码
type State struct{}
func testnil1(a, b interface{}) bool {
    return a == b
}
func testnil2(a *State, b interface{}) bool {
    return a == b
}
func testnil3(a interface{}) bool {
    return a == nil
}
func testnil4(a *State) bool {
    return a == nil
}
func testnil5(a interface{}) bool {
    v := reflect.ValueOf(a)
    return !v.IsValid() || v.IsNil() // reflect.IsValid()函数用于检查v是否表示一个值。
}
func main() {
    var a *State
    fmt.Println(testnil1(a, nil)) 
    fmt.Println(testnil2(a, nil))
    fmt.Println(testnil3(a))
    fmt.Println(testnil4(a))
    fmt.Println(testnil5(a))
}

结果为:

false,false,false,true,true

案例3:

go 复制代码
func Foo() error {
    var err *os.PathError = nil
    // ...
    return err
}

func main() {
    err := Foo()
    fmt.Println(err)                        
    fmt.Printf("%v, %v\n", reflect.TypeOf(err), reflect.ValueOf(err))
    fmt.Println(err == nil)
    fmt.Println(err == (*os.PathError)(nil)) 
}

输出结果为:

go 复制代码
<nil>
*fs.PathError
<nil>
false
true

下面一片英文文章中的解释,原文解释的挺好,就不译了。

An interface value is equal to nil only if both its value and dynamic type are nil. In the example above, Foo() returns[*os.PathError, nil] and we compare it with [nil, nil].

You can think of the interface value nil as typed, and nil without type doesn't equal nil with type. If we convert nil to the correct type, the values are indeed equal.

其实:这就是为什么有些同学说,如果 需要返回nil 的时候一定要直接返回 nil。不要使用带类型的nil

案例4:

Golang 复制代码
package main
import "fmt"

type IPeople interface {
    hello()
}
type People struct {
}

func (p *People) hello() {
    fmt.Println("hello")
}

func errFunc1(in int) *People {
    if in == 0 {
        fmt.Println("importantFunc返回了一个nil")
        return nil
    } else {
        fmt.Println("importantFunc返回了一个非nil值")
        return &People{}
    }

}

func main() {
    var i IPeople
    in := 0
    i = errFunc1(in)
    if i == nil {
        fmt.Println("哈,外部接收到也是nil")
    } else {
        fmt.Println("咦,外部接收到不是nil哦")
        fmt.Printf("%v, %T\n", i, i)
    }
}

这段代码的执行结果为:

go 复制代码
importantFunc返回了一个nil
咦,外部接收到不是nil哦
<nil>, *main.People

可以看到在main函数中收到的返回值不是nil, 在errFunc1()函数中返回的是nil,到了main函数为什么收到的不是nil呢?

这是因为:将nil赋值给*People后再将*People赋值给interface*People本身是是个指向nil的指针,但是将其赋给接口时只是接口中的值为nil,但是接口中的类型信息为*main.People而不是nil,所以这个接口不是nil

Golang中的interface类型包含两部分信息------值信息和类型信息,只有interface的值合并类型都为nilinterface才为nilinterface底层实现可以在后面的源码分析看到。

所以正确的方式是:

Golang 复制代码
func rightFunc(in int) IPeople {
    if in == 0 {
        fmt.Println("importantFunc返回了一个nil")
        return nil
    } else {
        fmt.Println("importantFunc返回了一个非nil值")
        return &People{}
    }
}

直接将nil赋给interface类型就可以了。

怎么解决 interface 和 nil 的比较?

先将 interface 值转化为 reflect.Value,然后借用IsNil 来判断是否为空即可。

但事实上,使用reflect包下的方法一定要小心,此处入参 i 的类型为 interface{},也就意味着任何类型的值传进来皆可,贸然使用反射,容易引发 panic

The argument must be a chan, func, interface, map, pointer, or slice value; if it is not, IsNil panics

因此修改后的代码如下:

go 复制代码
func isNilFixed(i interface{}) bool {
   if i == nil {
      return true
   }
   switch reflect.TypeOf(i).Kind() {
   case reflect.Ptr, reflect.Map, reflect.Array, reflect.Chan, reflect.Slice:
      return reflect.ValueOf(i).IsNil()
   }
   return false
}

nil 在 go 中的含义到底是什么?

nil 仅仅可以与下面的6种类型,进行 赋值、比较。

  • Pointer 指向空对象。 Pointer8字节
  • Slice 底层数组为空。 slice24 字节管理结构
  • Map 未初始化。 map8 字节指针
  • Channel 未初始化。 channel8 字节指针
  • Function 未初始化。 function8 字节指针
  • Interface 未赋值。 interface16 字节

nil 对以上 6 种变量赋值 nil 的行为都是把变量本身置 0 ,仅此而已。

nil 进行比较判断本质上都是和变量本身做判断,slice 是判断管理结构的第一个指针字段,mapchannel 本身就是指针,interface 也是判断管理结构的第一个指针字段,指针和函数变量本身就是指针;

提起nil就再次拿出 nil channelclosed channel 的几点规则:

  • 写入 closed channel 的时候会 panic
  • close 一个 nil/closed channelpanic
  • 读写 nil channel 都会造成永远阻塞

为什么需要 nil?

Go 分配内存是置 0 分配的。C 语言默认分配内存的行为则仅仅是分配内存,里面的数据不能做任何假设,可能是全 0 ,可能是全 1

回顾在C语言中我们会使用下方法,动态申请内存:

Golang 复制代码
int *p = (int *)malloc(sizeof(int));
*p = 100;
free(p);
p = NULL; // free函数在释放空间之后,把内存前的标志变为0,确保下次分配内存都是0值
return 0;

但Go中为了降低开发难度,减轻内存释放心智,采用了自动垃圾回收的方式,同时还引入了defer用于资源的释放。 Go语言现在用的三色标记法(属于追踪式垃圾回收算法的一种, 后边会有文章详细介绍)。

因此在Go中,需要将对象赋值为零值(nil),可以辅助 GC 进行内存回收。再比如将 slice 置为 nil,就可以释放其底层引用的数组。

一些特殊的类型,如指针slice,是没有一个明确的零值表示,此时需要借助 nil 进行零值判断。其实就是说 nil 是为 pointer/channel/func/interface/map/slice 类型预先声明的零值。

因此只有 pointer/channel/func/interface/map/slice(我快速识记的方式是 mscfip) 六种类型可以使用 nil 进行比较、赋值 。而这行代码信息过少,类型具有不确定性,编译器无法推断 nil 期望的类型。

如果使用nil赋值,编译器会报错

go 复制代码
var val = nil //"use of untyped nil"。

相关阅读

相关推荐
NiNg_1_23424 分钟前
SpringSecurity入门
后端·spring·springboot·springsecurity
Lucifer三思而后行1 小时前
YashanDB YAC 入门指南与技术详解
数据库·后端
王二端茶倒水2 小时前
大龄程序员兼职跑外卖第五周之亲身感悟
前端·后端·程序员
夜色呦3 小时前
现代电商解决方案:Spring Boot框架实践
数据库·spring boot·后端
爱敲代码的小冰3 小时前
spring boot 请求
java·spring boot·后端
java小吕布4 小时前
Java中的排序算法:探索与比较
java·后端·算法·排序算法
Goboy5 小时前
工欲善其事,必先利其器;小白入门Hadoop必备过程
后端·程序员
李少兄5 小时前
解决 Spring Boot 中 `Ambiguous mapping. Cannot map ‘xxxController‘ method` 错误
java·spring boot·后端
代码小鑫5 小时前
A031-基于SpringBoot的健身房管理系统设计与实现
java·开发语言·数据库·spring boot·后端
Json____6 小时前
学法减分交管12123模拟练习小程序源码前端和后端和搭建教程
前端·后端·学习·小程序·uni-app·学法减分·驾考题库