Golang
中有两种数据结构可以用来存储一组数据:Array
和 Slice
。它们之间有一些重要的区别和使用场景。因为切片(slice
)是更好用、安全,所以在实际开发中更常用切片(slice
)。而其实切片(slice
)是基于数组(array
)实现的。之前开发的过程中也只注重了使用,这次整理一下它们之间的区别和使用场景。
Array(数组)和 Slice(切片)的区别和联系
Go
语言中的切片(slice
)结构的本质是对数组的封装,它描述一个数组的片段。无论是数组还是切片,都可以通过下标来访问单个元素。不同的是,数组的长度是固定的,而切片的长度是动态的。切片可以在运行时改变大小,而数组的大小在编译时就已经确定了。
数组的长度固定
一个很典型的例子就是:
1 | func testArray() [5]int { |
上面的代码中,testArray
函数返回一个数组,而 testSlice
函数返回一个切片。可以看到,数组的长度是固定的,而切片的长度是动态的。
如果将切片作为数组返回或者将数组作为切片返回,编译器都会报错:
1 | func testArray() []int { |
1 | func testSlice() [5]int { |
并且由于数组的长度是固定的,所以长度不同的数组就是不同的类型,比如 [5]int
和 [10]int
是不同的类型,不能直接赋值。
反观切片,切片的长度是动态的,所以不同长度的切片是同一种类型,可以直接赋值,也可以动态扩容和截取。
直接申明创建数组和切片
由于数组的长度是固定的,所以在创建数组时需要指定长度。而切片的长度是动态的,所以在创建切片时不需要指定长度,可以使用 make
函数来创建切片,或者使用字面量来创建切片,而数组则只能使用字面量来创建。
1 | // 创建一个长度为 5 的数组 |
上面的初始化中用到了 make([]int, 5, 10)
,make
函数是 Go
语言中用于创建切片、映射和通道的内置函数。其中第一个参数是切片的类型,第二个参数是切片的长度,第三个参数是切片的容量。make
函数会返回一个切片,长度和容量都为 5 的切片。而创建切片时可以指定长度和容量的原因,只需要看一下切片的底层实现就一目了然。
切片的底层实现
数组的底层其实和 C
语言中的数组类似,都是连续的内存空间。而切片的本质是一个结构体,它包含了一个指向底层数组的指针、切片的长度和切片的容量。切片的长度是当前切片中元素的个数,而切片的容量是底层数组的长度。其数据结构源码如下:
1 | // src/runtime/slice.go |
可以看到切片本身并不直接存储数据,而是通过指针指向底层数组。而底层数组可以被多个切片共享,这就是切片的共享特性。也就是说,切片是对数组的一个视图,它可以动态地改变大小和内容,而不需要复制底层数组的数据。切片的共享特性使得它在性能上比数组更高效,因为它避免了不必要的内存复制和分配。
所以数组是值类型,一个数组变量的值是一块连续的内存空间,而切片是引用类型,一个切片变量的值是一个包含三个成员的切片结构体,分别是指向底层数组的指针、切片的长度和切片的容量。注意,一般我们认为结构体是值类型,但是切片的结构体中包含了一个指向底层数组的指针,所以切片的结构体本身是值类型,但是它的内容是引用类型。而我们说切片是引用类型,指的就是切片在复制的时候不会复制底层数组的数据,而是复制切片的结构体。
当然这也带来了一个问题,就是如果一个切片被修改了,其他共享同一个底层数组的切片也会受到影响。这就是切片的引用特性,比如针对一个数组/切片 x
进行截取,取出一个切片 y
,如果对 y
进行修改,那么 x
也会受到影响。比如:
1 | func main() { |
拷贝赋值创建数组和切片
Go
语言中的数组和切片都可以通过赋值来创建新的变量,但是它们的拷贝方式是不同的。数组是值拷贝,而切片是引用拷贝。因为数组表示的是一段连续的内存空间,而切片表示的是一个指向底层数组的指针、切片的长度和切片的容量。所以在拷贝数组时,实际上是拷贝了整个数组的内容,而在拷贝切片时,实际上是拷贝了切片的结构体,而不是底层数组的内容。
1 | func main() { |
当然当数据作为函数参数传递时,数组也是值拷贝,而切片是引用传递。也就是说,函数内部对切片的修改会影响到外部的切片,而函数内部对数组的修改不会影响到外部的数组。
1 | func modifyArray(arr [5]int) { |
切片的截取
在 Go
语言中,切片可以通过切片操作符 [:]
来截取创建一个新的切片。这个新的切片会共享底层数组的内容,而不是复制底层数组的数据。切片操作符的语法是 slice[start:end]
,表示从 start
到 end
的切片。注意,start
是包含的,而 end
是不包含的。例如:
1 | func main() { |
切片操作符的 start
和 end
可以省略,表示从头到尾的切片。例如 arr[:]
表示整个切片。
这里在创建切片的时候还可以额外指定容量,比如 arr[1:4:5]
,表示从 arr
中截取从 1
到 4
的切片,并且这个切片的容量为 5
。不过个人目前在实际开发中很少用到这个语法。
值得注意的是,新老 slice
或者新 slice
老数组互相影响的前提是两者共用底层数组,如果因为执行 append
操作使得新 slice
或老 slice
底层数组扩容,移动到了新的位置,两者就不会相互影响了。所以,问题的关键在于两者是否会共用底层数组。
切片的扩容
上面提到了切片的扩容,切片的扩容是通过 append
函数来实现的。append
函数会根据需要自动扩容底层数组,并返回一个新的切片。append
函数的语法是 append(slice, elements...)
,表示在 slice
的末尾添加 elements
元素。
1 | func main() { |
使用 append
函数可以向 slice
追加元素,实际上是往底层数组相应的位置放置要追加的元素。但是底层数组的长度是固定的,如果索引 len-1
所指向的元素已经是底层数组的最后一个元素,那就需要重新分配一个更大的底层数组,并将原有元素复制到新的底层数组中。这样,新的 slice
就会指向新的底层数组,而原有的 slice
仍然指向旧的底层数组。同时,为了应对未来可能再次发生的 append
操作,新的底层数组的长度会预留一定的 buffer
,当然这全都是由 Go
语言的编译器和运行时来处理的。
并且在 Go 1.17
及之前的版本和 Go 1.18
及之后的版本中,append
函数的扩容策略是不同的。
Go 1.17
及之前的版本中,append
函数的扩容策略是:- 如果期望容量大于当前容量的两倍,则直接扩容到期望容量;
- else if 当前切片容量小于 1024,则扩容到当前容量的两倍;
- else 每次扩容到当前容量的 1.25 倍,直到达到期望容量。
Go 1.18
及之后的版本中,append
函数的扩容策略是:- 如果期望容量大于当前容量的两倍,则直接扩容到期望容量;
- else if 当前切片容量小于 256,则扩容到当前容量的两倍;
- else 根据公式扩容(
newCap = oldCap+(oldCap+3*256)/4
),直到达到期望容量。
切片作为函数参数
上面说到,切片其实是一个结构体,包含了一个指向底层数组的指针、切片的长度和切片的容量。所以在函数参数中传递切片时,就是传递了一个普通的结构体。也就是说,如果直接把一个切片作为函数参数传递,那么函数内部的对结构体的修改并不会影响到外部的切片。但是这里要注意,该结构体共有三个字段,对这个结构体的三个字段的修改都不会影响外部切片,具体来说:
- 扩容:修改了切片的数组指针、长度和容量;
- 截取:修改了切片的长度和容量;
这些修改都不会影响外部切片。⚠️但是,如果是通过切片的数组指针来访问到底层数组的数据,并且修改了底层数组的数据,那么外部切片也会受到影响。比如:
1 | package main |
上面的代码中,modifySlice
函数对切片进行了修改,但是外部的切片的长度并没有受到影响。因为 append
函数会重新分配一个新的底层数组,而不是修改原有的底层数组。但是在 append
函数之前,对切片的第一个元素进行了修改,所以外部的切片的第一个元素也被修改了。
综上,切片作为函数参数时,虽然传递的是结构体,但由于切片的引用特性,函数内部对切片的修改可能会影响到外部切片。
如果在函数内部要直接修改外部的切片,可以使用指针来传递切片的地址。这样就可以直接修改外部切片的内容了。比如:
1 | package main |
上面的代码中,modifySlice
函数接收一个指向切片的指针,这样就可以直接修改外部切片的内容了。