在很多编程语言中都有数组,而切片类型却不常见。实际上,Golang 语言中的切片的底层存储也是基于数组。因为数组是固定长度的,而切片比数组更加灵活,所以在 Golang 语言中,数组使用的并不多,切片使用更加广泛。
- 数组的零值是元素类型的零值,切片的零值是nil;
- 数组是固定长度,切片是可变的长度
- 数组是值类型,切片是引用类型
数组:
func main () {
var arr1 [4]int
fmt.Printf("arr1 val:%d arr1 len:%d arr1 cap:%d\n", arr1, len(arr1), cap(arr1))
arr := [4]int{
}
fmt.Printf("val:%d len:%d cap:%d\n", arr, len(arr), cap(arr)) // val:[0 0 0 0] len:4 cap:4
arr[0] = 1
arr[1] = 2
arr[2] = 3
arr[3] = 4
// arr[4] = 5 // invalid array index 4 (out of bounds for 4-element array)
fmt.Printf("val:%d len:%d cap:%d\n", arr, len(arr), cap(arr)) // val:[1 2 3 4] len:4 cap:4
arr2 := arr
fmt.Printf("arr2 val:%d len:%d cap:%d ptr:%p\n", arr2, len(arr2), cap(arr2), &arr2) // arr2 val:[1 2 3 4] len:4 cap:4 ptr:0xc0001980a0
fmt.Printf("arr val:%d len:%d cap:%d ptr:%p\n", arr, len(arr), cap(arr), &arr) // arr val:[1 2 3 4] len:4 cap:4 ptr:0xc000198040
ss := arr[:]
ssPtr := (*reflect.SliceHeader)(unsafe.Pointer(&ss)).Data
fmt.Printf("ss val:%d len:%d cap:%d ptr:%v\n", ss, len(ss), cap(ss), ssPtr) // ss val:[1 2 3 4] len:4 cap:4 ptr:824635392064
ss2 := arr[:]
ss2Ptr := (*reflect.SliceHeader)(unsafe.Pointer(&ss2)).Data
fmt.Printf("ss2 val:%d len:%d cap:%d ptr:%v\n", ss2, len(ss2), cap(ss2), ss2Ptr) // ss2 val:[1 2 3 4] len:4 cap:4 ptr:824635392064
}
输出:
arr1 val:[0 0 0 0] arr1 len:4 arr1 cap:4
val:[0 0 0 0] len:4 cap:4
val:[1 2 3 4] len:4 cap:4
arr2 val:[1 2 3 4] len:4 cap:4 ptr:0xc000010400
arr val:[1 2 3 4] len:4 cap:4 ptr:0xc0000103a0
ss val:[1 2 3 4] len:4 cap:4 ptr:824633787296
ss2 val:[1 2 3 4] len:4 cap:4 ptr:824633787296
切片:
func main () {
var s []int
if s == nil {
fmt.Println("nil")
}
fmt.Printf("s val:%d len:%d cap:%d\n", s, len(s), cap(s)) // s val:[] len:0 cap:0
s = append(s, 1)
fmt.Printf("s val:%d len:%d cap:%d\n", s, len(s), cap(s)) // s val:[1] len:1 cap:1
s = append(s, 2)
fmt.Printf("s val:%d len:%d cap:%d\n", s, len(s), cap(s)) // s val:[1 2] len:2 cap:2
s = append(s, 3)
fmt.Printf("s val:%d len:%d cap:%d\n", s, len(s), cap(s)) // s val:[1 2 3] len:3 cap:4
}
输出:
nil
s val:[] len:0 cap:0
s val:[1] len:1 cap:1
s val:[1 2] len:2 cap:2
s val:[1 2 3] len:3 cap:4
阅读上面这两段代码,我们可以发现数组的零值是元素类型的零值,而切片的零值是 nil,同时,nil 也是唯一可以和切片类型作比较的值。
数组中元素超越边界会引发错误,切片中元素超越边界会自动扩容,切片的扩容规则将在 Part 03 介绍。
数组是值类型,切片是引用类型。arr2 和 arr 的内存地址不同,它们是两块不同的内存空间;ss 和 ss2 的内存地址相同,它们指向同一个底层数组。
在 Golang 语言中传递数组属于值拷贝,如果数组的元素个数比较多或者元素类型的大小比较大时,直接将数组作为函数参数会造成性能损耗,可能会有读者想到使用数组指针作为函数参数,这样是可以避免性能损耗,但是在 Golang 语言中,更流行使用切片,关于这块内容,阅读完 Part 04 的切片数据结构,会有更加深入的理解。
通过阅读 Part 02 关于切片的这段代码,我们还可以看出切片的扩容规则,当一个切片的容量无法存储更多元素时,切片会自动扩容,它会生成一个容量更大的新切片,然后把原切片的元素和新元素一起拷贝到新切片中。
在原切片长度小于 1024 时,新切片的容量会按照原切片的 2 倍扩容,否则,新切片的容量会按照原切片的 1.25 倍扩容,此时需要注意的是,如果新切片的容量按照原切片的 1.25 倍扩容一次仍然无法存储新元素时,将会不断按照原切片的 1.25 倍扩容,直到新切片的容量可以存储原切片的元素和新元素为止。一般最终扩容后的新切片,它的容量会大于或等于原切片的容量。
需要注意的是,当切片的零值是 nil 时,切片此时还没有指向底层数组。但是切片的零值是可用的,当使用 append 向零值切片追加元素时,将会先给切片分配一个底层数组。
切片扩容实际是创建一个新的底层数组,把原切片的元素和新元素一起拷贝到新切片的底层数组中,原切片的底层数组将会被垃圾回收。
注意:切片的容量可以根据元素的个数的增多自动扩容,但是不会根据元素的个数的减少自动缩容。
在 Golang 语言中,切片实际是一个结构体,源码如下所示:
// /usr/local/go/src/runtime/slice.go
type slice struct {
array unsafe.Pointer
len int
cap int
}
阅读源码,我们可以发现先,slice 结构体包含 3 个字段:
- array - 指向底层数组
- len - 切片的长度
- cap - 切片的容量
在 Golang 语言运行时中,一个切片类型的变量实际上就是 runtime.slice 结构体的实例,其中 arrray 字段是指针类型,指向切片的底层数组,len 是切片的长度,cap 是切片的容量,当使用 make 函数创建切片时,如果不指定 cap 参数的值,cap 的值就等于 len 的值。
如果已经认真阅读完以上内容,我们应该已经知道切片在每次扩容时都会将原切片底层数组的元素和新元素一起拷贝到新切片的底层数组,这种操作在元素比较多或者元素的类型大小比较大时,内存分配和拷贝的代价还是比较大的。
为了降低或避免内存分配和拷贝的代价,我们通常会为新创建的切片指定 cap 参数的值,比如:
s := make([]T, 0, cap)
但是,这种使用方式的前提是,我们可以预估切片的元素个数。
通过使用 for range
遍历切片,每次遍历操作实际上是对遍历元素的拷贝。而使用 for 遍历切片,每次遍历是通过索引访问切片元素,性能会远高于通过 for range
遍历。
因此想要优化使用 for range
遍历切片的性能,可以使用空白标识符 _ 省略每次遍历返回的切片元素,改为使用切片索引取访问切片的元素。
普通方式:
func main () {
s := make([]int, 0, 10000)
for k, v := range s {
fmt.Println(s, v)
}
}
优化方式:
func main () {
s := make([]int, 0, 10000)
for k, _ := range s {
fmt.Println(k, s[k])
}
}
本文我们先是介绍了数组和切片的区别,然后还介绍了一些关于切片的扩容规则、数据结构和使用技巧等。文中代码比较多,建议读者将代码拷贝到编辑器中,查看运行结果,从而可以更加深刻理解文中的内容。