Go 中的 nil 切片

Go 中的空值是一个永远的坑,感觉比价值十亿美金的空指针还难受,本文将尝试比较一下 nil 切片和空切片

TL;DR

空切片和零切片没什么大区别,大胆用吧

认识 nil

首先我们先明确几件关于 nil 的事情

  1. nil 不同于 null(或是 NULL、nullptr),null 通常代表空值、空指针,而 nil 则有所区别

    • nil 不能在基本类型中使用
    • nil 可以表示空指针、映射、切片、函数、通道、接口
    • nil 不是关键字,只是一个特殊的值,并且可以被重新赋值
  2. 在 Go 中,nil 是一个类型不确定值,其对于不同类型有着不同的「类型确定值」

  3. 因为 nil 是一个类型不确定值,因此对于强类型且静态类型且不会从其他语句推导类型的 Go 语言而言,当定义变量时使用 nil 值必须显示的指明类型

    • var a = nila := nil 都是错误的
    • var a = nil; a = 3 在某些语言(如 rust)是正常的,因为编译器可以从上下文推导类型,而在 Go 是错误的
    • var a int = nil

定义

一个切片有如下的定义方式

1
2
3
4
5
6
7
8
9
10
// 空切片
var emptySlice1 []int // 空切片
var emptySlice2 []int = make([]int, 0) // 零切片
var emptySlice3 []int = []int{} // 零切片
var emptySlice4 []int = *new([]int) // 空切片,与 emptySlice1 基本相同

// 非空切片
var notEmptySlice1 []int = make([]int, 2, 5)
var notEmptySlice2 []SomeStruct = make([]SomeStruct, 2, 5)
var notEmptySlice3 []*SomeStruct = make([]*SomeStruct, 2, 5)

从非空看起

非空切片主要体现的是其底层数据结构

Go 的切片初始化语句是 make([]T, LEN, CAP) ,在初始化时,会生成一个长度为 CAP 的数组,并将数组初始化为零值(对于基本类型初始化为 0、false、空字符串等,对于结构体初始化为空结构体,对于指针、容器等则初始化为 nil)

研究空切片

与 nil 比较、取长度

我们运行一下下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fmt.Println(emptySlice1 == nil)       // true
fmt.Println(emptySlice2 == nil) // false
fmt.Println(emptySlice3 == nil) // false
fmt.Println(emptySlice4 == nil) // true

fmt.Println(len(emptySlice1) == 0) // true
fmt.Println(len(emptySlice2) == 0) // true
fmt.Println(len(emptySlice3) == 0) // true
fmt.Println(len(emptySlice4) == 0) // true

fmt.Println(cap(emptySlice1) == 0) // true
fmt.Println(cap(emptySlice2) == 0) // true
fmt.Println(cap(emptySlice3) == 0) // true
fmt.Println(cap(emptySlice4) == 0) // true

可以得出以下结论

  • 空切片 = nil,零切片 ≠ nil
  • 无论是空切片还是零切片,对其取 len cap 都不会造成恐慌且值为 0

添加值

1
2
3
4
5
6
7
8
9
emptySlice1 = append(emptySlice1, 1, 2, 3)
emptySlice2 = append(emptySlice2, 1, 2, 3)
emptySlice3 = append(emptySlice3, 1, 2, 3)
emptySlice4 = append(emptySlice4, 1, 2, 3)

fmt.Println(emptySlice1) // [1 2 3]
fmt.Println(emptySlice2) // [1 2 3]
fmt.Println(emptySlice3) // [1 2 3]
fmt.Println(emptySlice4) // [1 2 3]

Go 语言和其他语言的一大不同点在于 append 并不一定是修改了原切片值,而可能是将原切片拷贝一份(如果空间不足),其内部原理大概是「先判断是否有容量,没有则复制」,因此无论是空切片还是零切片,都是新开辟了一块空间来存储数据的,也因此,append 运行正常、不会造成运行时恐慌。

我们还可以以此明白,对 nil 调用方法不一定会造成恐慌(除非这个方法内部有对指针解引用的操作)

for-range 遍历值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
for i, x := range emptySlice1 {
fmt.Println(i, x)
}

for i, x := range emptySlice2 {
fmt.Println(i, x)
}

for i, x := range emptySlice3 {
fmt.Println(i, x)
}

for i, x := range emptySlice4 {
fmt.Println(i, x)
}

fmt.Println("Done")

不会有输出(除了最后的 Done)、不会产生恐慌,原理和第一个的取 length 相同,因为容量都是 0 所以不会遍历。

比较

Go 的 slice 为不可比较类型,因此其仅可和 nil 进行比较,因此,下面的是正确的

1
2
3
4
fmt.Println(emptySlice1 == nil)       // true
fmt.Println(emptySlice2 == nil) // false
fmt.Println(emptySlice3 == nil) // false
fmt.Println(emptySlice4 == nil) // true

而下面的一定是编译不通过的

1
2
fmt.Println(emptySlice2 == []int{}) 
fmt.Println(emptySlice3 == []int{})

同时,上面说的规则中的 nil 必须是类型不确定值 nil,如果将其赋值给了某个特定类型那么这个类型就是类型确定值了,也无法和切片比较,因此,下面的也是编译不通过的

1
2
fmt.Println(emptySlice1 == []int{}) 
fmt.Println(emptySlice4 == []int{})

JSON Marshal & Unmarshal