详解 Go 语言的切片是怎么实现的,以及相关用法
什么是切片
Go 的数组一旦申请,长度就不可以变了,显然这极大地限制了数组的灵活性。如果我们在存储元素时,数量未知且不固定,那么数组不是一个好的选择,于是 Go 提供了另外一种数据结构,叫做切片。
切片本质上是一个结构体,我们看一下它的底层结构。
// runtime/slice.go
type slice struct {
array unsafe.Pointer // 指向底层数组的指针
len int // 长度
cap int // 容量
}
我们看到切片实际上就是一个结构体实例,有三个字段,分别是指向底层数组的指针、切片的长度、切片的容量。所以切片不过是对数组进行了一个封装,真正存储元素的肯定还是数组。
其实任何一门语言,数组一旦申请,大小就固定了,Go 也不例外。所以切片内部要保存一个指向数组的指针,一旦数组满了,那么就申请一个长度更大的数组,并把老数组的元素拷贝过去,然后让指针指向新数组,最后释放老数组的内存。
所以切片也被称为可变数组。
另外大部分语言都有可变数组,无非叫法不同,比如在 Go 里面叫切片,在 Python 里面叫列表。但它们的原理是一致的,所谓的可变都是基于不可变进行的一个封装,比如 Python 列表是对 C 数组进行的封装,Go 切片是对 Go 数组进行的封装。
在使用切片添加元素的时候,会添加到数组中,切片的容量(cap)就是底层数组的长度,切片的长度(len)则是往底层数组添加了多少个元素。而当我们在添加元素的时候,内部会进行如下判断:
- 如果 len 小于 cap,那么底层数组还有空间,于是会将元素设置在数组中索引为 len 的位置;
- 如果 len 等于 cap,说明底层数组已经满了,于是会申请一个更大的数组,并且将老数组里面的元素都拷贝到新数组中。然后将添加的元素设置在新数组索引为 len 的位置,让切片内部的指针 array 指向新数组,最后释放老数组;
而申请新数组、拷贝老数组的元素到新数组、释放老数组整体被称之为扩容,很明显扩容是一个比较昂贵的操作。为了避免频繁扩容,在申请底层数组的时候,会尽可能申请的长一些。
然后切片对应的结构体内部有三个字段,在 64 位机器上都是 8 字节, 这意味着任何一个切片,大小都是 24 字节。
package main
import (
"fmt"
"unsafe"
)
func main() {
// [] 里面什么都不写的话, 表示创建一个切片
var s1 = []int{1, 2, 3}
var s2 = []string{"1", "2", "3"}
var s3 = []float64{1.1, 2.2, 3.3}
// 查看变量所占内存的话, 可以使用 unsafe.Sizeof
fmt.Println(unsafe.Sizeof(s1)) // 24
fmt.Println(unsafe.Sizeof(s2)) // 24
fmt.Println(unsafe.Sizeof(s3)) // 24
}
因为切片不保存实际数据,并且内部的三个字段的大小都是固定的,所以切片的大小也是固定的。
切片的创建
创建切片有很多种方式,首先是直接声明。
package main
import (
"fmt"
)
func main() {
// 这种方式只是声明了一个切片,如果没有赋值,那么里面的每个成员默认都是零值
// 所以内部的指针是一个空指针、没有指向任何的底层数组,至于长度和容量则都是 0
var s []int
// 如果内部的指针为空,那么 s 和 nil 是相等的
fmt.Println(s == nil) // true
// 但是我们看到指针明明没有指向底层数组,居然也能 append
// 这是因为使用 append 的时候,如果没有分配底层数组的话
// 那么会自动先帮你分配一个大小、容量都为 0 的底层数组
// 然后再把元素 append 进去,此时会有扩容操作
s = append(s, 123)
fmt.Println(s) // [123]
// 当然也可以直接创建,支持索引
var s1 = []int{1, 5:1, 3}
fmt.Println(s1) // [1 0 0 0 0 1 3]
}
还可以使用 new 函数创建,但是不建议用在切片上面。
package main
import "fmt"
func main() {
// new 函数接收一个类型,创建对应的零值,然后返回其指针
var s = new([]int)
// 所以使用这种方式只会创建切片本身,但是切片对应的底层数组是不会创建的
// 内部的指针是一个 nil、长度和容量都是 0,但使用 append 的话会自动创建底层数组
*s = append(*s, 1, 2, 3, 4)
fmt.Println(s) // &[1 2 3 4]
fmt.Println(*s) // [1 2 3 4]
}
像切片、以及后续要介绍的 map 和 channel,它们都是内部维护了一个指针,指针指向的内存才是真正用来存储数据的。而对于这样的数据结构,我们应该使用 make 函数来创建。使用 make 创建的好处就是,它除了会为切片本身申请内存之外,还会为底层数组申请内存。
package main
import "fmt"
func main() {
// 如果使用 []int{} 方式创建,那么长度和容量是一样的,但使用 make 创建,可以显式地指定长度和容量
s := make([]int, 3, 5)
// 创建 []int 类型的切片,长度为 3,容量为 5
// 如果不指定容量, 那么容量和长度一致,此时打印的 s 就是底层数组中的元素
fmt.Println(s) // [0 0 0]
// 虽然底层数组长度为 5,但是打印出来我们能看到的只有 3 个
// 事实上底层数组是 [0 0 0 0 0],默认都是零值
// 但是对于切片而言,它只能看到 3 个元素,因为长度是 3
// 我们可以像操作数组一样操作切片,因为操作切片本质上也是操作底层数组
s[0], s[1], s[2] = 1, 2, 3
// 注意: 如果使用 s[3], 那么会索引越界
// 虽然底层数组有 5 个元素, 但是对于切片而言, 它只能看到 3 个
fmt.Println(s) // [1 2 3]
// 然后我们可以使用 append 函数添加元素,这一步同样是操作底层数组
// 注意: 必须用变量进行接收,该函数会返回新的切片,并且切片的长度加 1
s = append(s, 11)
fmt.Println(s) // [1 2 3 11]
s = append(s, 22)
fmt.Println(s) // [1 2 3 11 22]
// 此时底层数组就变成了 [1 2 3 11 22]
// 因为创建切片时指定的容量是 5, 所以底层数组长度也是 5
// 可现在已经 5 个元素了, 如果继续添加的话
s = append(s, 33)
fmt.Println(s) // [1 2 3 11 22 33]
// 虽然结果和我们想象的一样,但底层数组已经不是原来的底层数组了
// 因为原来的数组长度不够了,所以这个时候会申请一个更大的数组
// 然后把原来数组的元素依次拷贝过去,再让切片内部的指针指向新的数组
// 查看切片长度可以使用 len 函数,当然 len 函数也可以作用于数组、字符串
fmt.Println(len(s)) // 6
// 而查看切片的容量(底层数组的长度),可以使用 cap 函数
// 我们看到变成了 10,不再是原来的 5,证明发生了扩容
fmt.Println(cap(s)) // 10
}
整个过程示意图如下:
最后,创建切片还可以通过截取数组的方式。
package main
import "fmt"
func main() {
// 创建元素个数为 6 的数组
var arr = [...]int{5: 1}
fmt.Println(arr) // [0 0 0 0 0 1]
// 创建切片
s := arr[0: 1]
s[0] = 123
fmt.Println(s) // [123]
fmt.Println(arr) // [123 0 0 0 0 1]
}
从数组中截取一个切片,语法是 arr[start: end]。和其它高级语言类似,start 是开始索引、end 是结束索引(不包含结尾)。
- start 可以省略,表示从头截取;
- end 也可以省略,表示截取到尾;
- 都省略则从头截取到尾;
并且 end - start 就是切片的长度,数组的长度减去 start 就是切片的容量(一会解释)。
最后我们修改切片,还会影响原数组。如果是使用其它方式创建的话,那么 Go 编译器会默认分配一个底层数组,只不过这个数组我们看不到罢了,但它确实分配了。如果是基于已存在的数组创建切片,那么该数组就是切片对应的底层数组。
切片的截取
如果使用 make、或者声明的方式创建切片的话,那么会默认分配一个底层数组,并且后续的维护也不需要开发者关心。但问题就在于,很多时候我们会基于已存在的某个数组创建切片,而这里面隐藏着一些玄机。
package main
import "fmt"
func main() {
// 此时数组共有 8 个元素,元素的最大索引为 7
var arr = [...]string{"a", "b", "c", "d", "e", "f", "g", "h"}
// s1 和 s2 都指向了 arr,只不过它们指向了不同的部分
// 显然 s1 的第一个元素,就是 s2 的第二个元素
s1 := arr[1: 2]
s2 := arr[0: 2]
// 将 s2 的第二个元素改掉
s2[1] = "xxx"
// 我们看到 s1 也被改了,而且底层数组也被改了
fmt.Println(s1) // [xxx]
fmt.Println(arr) // [a xxx c d e f g h]
}
很好理解,因为我们可以把切片看成是底层数组的一个视图,修改切片等价于修改数组,最终的操作都会体现在数组上。而 s1 和 s2 映射同一个底层数组,所以修改任何一个切片都会影响另一个。我们画一张图:
还是很好理解的,再举个例子:
package main
import "fmt"
func main() {
var arr = [...]string{"a", "b", "c", "d", "e", "f", "g", "h"}
s := arr[1: 3]
fmt.Println(s)
fmt.Println(s[3: 6])
fmt.Println("----------我是分界线----------")
fmt.Println(s[3])
/*
[b c]
[e f g]
----------我是分界线----------
panic: runtime error: index out of range [3] with length 1
*/
}
惊了,s 里面只有两个元素,我们居然能够通过 s[3: 6] 访问,但是后面访问 s[3] 却又报错了。原因和切片的可扩展性有关,我们画一张图。
此时切片 s 内部的指针指向了数组的第二个元素(arr[1]),切片 s 的长度为 2,这都是显而易见的。关键是切片的容量是多少?这个问题一会儿再回答。
从图中可以看到,切片实际上是可扩展的,如果对切片进行索引的话,那么最大索引就是切片的长度减去1。但如果对切片进行再次切片的话(reslice),那么是根据底层数组来的。
我们看到 s[3: 6] 对应底层数组的 [e f g],所以是不会报错的。尽管 s 只有两个元素,但它记得自己的底层数组,并且是可扩展的。但这个扩展只能是向后扩展,无法向前扩展。比如 s = arr[m: n],切片 s 可以向后扩展,能看到数组中索引为 n 以及之后的元素。但是无法向前扩展,因为切片 s 是从数组 arr 中索引为 m 的位置开始截取的,所以 s[0] 就是 arr[m],而索引 m 之前的元素就看不到了。
就像当前的这个例子,s = arr[1: 3],切片 s 往前最多只能看到 arr[1],而 arr[0] 就看不到了。
再来看看容量的问题
从数组截取的切片,在向后扩展的时候,默认可以扩展到数组的结束位置。但我们在截取的同时还可以指定容量,比如 s = arr[2: 4: 6],这里的 6 就表示切片 s 最多扩展到 arr 长度为 6 的位置,那么它的容量就是 6 - 2。
package main
import "fmt"
func main() {
var arr = [...]string{"a", "b", "c", "d", "e", "f", "g", "h"}
// 如果是 s[m: n],那么等价于 s[m: n: len(arr)]
// 默认可以向后扩展到数组的结束位置,容量为 len(arr) - m
// 这里是 s[1: 3: 5],容量为 5 - 1
// 表示可以向后扩展到数组长度为 5 的位置
s := arr[1: 3: 5]
fmt.Println(s[2: 4])
fmt.Println("----------我是分界线----------")
fmt.Println(s[3: 5])
/*
[d e]
----------我是分界线----------
panic: runtime error: slice bounds out of range [:5] with capacity 4
*/
}
此时访问 s[2: 4] 是可以的,但是访问 s[3: 5] 就报错了,因为我们这里指定了容量。
s[1: 3: 5] 最多扩展到数组长度为 5 的位置,此时切片的长度是 3 - 1 = 2,容量为 5 - 1 = 4。所以对于切片 s 而言,s[start: end] 里面的 end 最多只能到 4。
数组可以创建出很多的切片,一个切片也可以创建另外的切片,并且修改任意一个切片都会改变底层数组,进而影响其它的切片。
package main
import "fmt"
func main() {
var arr = [...]string{"a", "b", "c", "d", "e", "f", "g", "h"}
s1 := arr[1: 3]
s2 := s1[3: 6]
fmt.Println(s1) // [b c]
fmt.Println(s2) // [e f g]
// [:] 这种方式只会获取当前切片可以看到的元素
// 换句话说可以看到的元素的个数等于切片长度
fmt.Println(s2[:]) // [e f g]
fmt.Println(s2[: 4]) // [e f g h]
}
示意图如下:
此时我们修改 s2[2] = "xxx", 那么底层数组会有何变化呢?显然 arr[6] 也变成了 "xxx"。
s2[2] = "xxx"
fmt.Println(arr) // [a b c d e f xxx h]
相信你已经明白数组和切片之间的关系了,数组的长度是固定的(数组长度也是类型的一部分),一旦申请完毕,那么长度就不能变化了。这极大地限制了数组的表达能力,对于需要动态增加和删除元素的需求来说,数组是无法满足的,于是便有了切片。
切片是一个结构体实例,本质上就是对数组进行了一个封装,切片不存储任何元素,修改切片就是修改底层数组,往切片追加元素依旧是修改底层数组。如果底层数组已满,那么再往切片追加元素的话,就会申请一个更长的新数组,来作为切片的底层数组(让切片内部的指针指向这个新数组)。
所以切片本身是比较简单的,你也完全可以手动实现一个切片。切片稍微有一点点难的地方就在于,切片是可扩展的,我们举个例子再将内容总结一下。比如数组 arr 有 10 个元素,切片 s 为 arr[2: 7]。
因为访问切片本质上是访问底层数组,那么基于索引访问切片 s,索引的范围只能是 [0, 5),对应数组 arr 索引 [2, 7) 的位置。如果对切片 s 进行 append,那么会修改底层数组索引为 7 的元素。
然后我们说切片可扩展,虽然访问切片 s 的索引必须小于 5,但 s[6: 8] 是不报错的。因为 s[6: 8] 对应的底层数组是 arr[8: 10],会返回 arr[8] 和 arr[9],并没有越界。
然后还有一点需要注意,不同的切片可以映射同一个底层数组,比如对同一个数组多次切片,那么这些切片共享同一个数组。或者对切片进行切片,比如对这里的 s 再进行切片得到 s2,那么 s 会和 s2 同样共享同一个数组(arr)。而操作切片本质上就是操作底层数数组(切片不存储元素),那么这种情况下,任何一个切片修改了底层数组,其它切片都会受到影响。
切片的扩容
切片的扩容,实际上就是申请一个新的底层数组,假设我们申请的切片容量是 3,那么对应的底层数组的长度就是3。而切片是可以进行 append 的,如果容量不够的话,怎么办呢?显然就要进行扩容了。
package main
import "fmt"
func main() {
var s = make([]int, 0, 3)
s = append(s, 1)
fmt.Printf("%p\n", &s[0]) // 0xc00000c150
s = append(s, 2)
fmt.Printf("%p\n", &s[0]) // 0xc00000c150
s = append(s, 3)
fmt.Printf("%p\n", &s[0]) // 0xc00000c150
// 如果再 append,那么容量肯定不够了
s = append(s, 4)
fmt.Printf("%p\n", &s[0]) // 0xc00000a360
}
我们看到扩容之前,s[0] 的地址是不变的,但是扩容之后,地址变了。说明切片的扩容是在底层申请一个更大的数组,让切片内部的指针指向这个新的数组,并把对应元素依次拷贝过去,所以 &s[0] 会变。整个过程示意图如下:
会申请一个新的数组,然后让指针指向它。但是原来的底层数组怎么办呢?这个不用担心,Go 的垃圾回收机制会自动销毁它。
再来看看当存在多个切片时,扩容有什么表现。
package main
import "fmt"
func main() {
var arr = []int{1, 2, 3}
s1 := arr[1:]
// 写成 s2 = s1[:] 或者 s2 = s1 也可以
s2 := arr[1:]
fmt.Println(s1, s2) // [2 3] [2 3]
// 因为是同一个数组,所以地址一样
fmt.Println(&s1[0], &s2[0]) // 0xc0000b2020 0xc0000b2020
// 此时 s1 和 s2 都是 [2, 3]
// 下面给 s2 扩容
s2 = append(s2, 4)
// 地址不一样了
fmt.Println(&s1[0], &s2[0]) // 0xc0000b2020 0xc0000ae040
}
第一次打印,s1[0] 和 s2[0] 的地址一样,因为内部的指针指向的都是同一个数组。但是对 s2 添加元素时,发现底层数组满了,那么就申请一个更大的,让 s2 内部的指针重新指向,但 s1 内部的指针还是指向原来的底层数组。所以第二次打印,s1[0] 和 s2[0] 的地址变得不一样了。
而且,既然 s1 内部的指针指向的还是原来的数组,那么原来的数组则不会被 GC 回收,并且接下来我们对 s1 做任何操作都不会影响 s2,因为这两个切片不再共享同一个底层数组。
整个过程示意图如下:
在申请完新数组之后,并不一定会把老数组中所有的元素都拷贝过去,由于切片无法向前扩展,所以前面看不到的元素是不会拷贝的。
切片的拷贝
拷贝切片最简单的方式就是变量赋值:
package main
import "fmt"
func main() {
s1 := []int{1, 2, 3}
s2 := s1
s2[0] = 666
fmt.Println(s1) // [666 2 3]
fmt.Println(s2) // [666 2 3]
}
在 Go 里面没有所谓的引用传递,只有值传递,不管怎么传,都是拷贝一份。但是切片不负责保存数据,它内部只是维护了一个指针,所以在拷贝的时候只会拷贝切片本身,底层数组并不会拷贝。因为底层数组不是切片的一部分,这两者是通过一个指针建立的联系。
切片拷贝之后,两个切片显然共享一个底层数组,一个被修改了,会影响另一个。
然后再补充一点,对于切片、map、channel 这种数据结构来说,在作为参数传递的时候直接采用值传递即可。因为它们不负责存储数据,本质上就是一个 header,就是为了复制而准备的。
- 如果是数组的话,那么作为参数传递的时候,会将数组拷贝一份。而当数组过大时,有可能内存溢出,并且拷贝之后两个数组也没有任何关系。如果不想这样,那么可以将数组的指针作为参数传过去。
- 但换成切片就不一样了,切片相当于一个 header,它永远是 24 字节,里面存储的就是底层数组的指针。所以对于切片来说,直接传值即可,没必要传指针。
然后拷贝切片,还有一个专门的内置函数 copy。
package main
import "fmt"
func main() {
var s1 = []int{1, 2, 3, 4, 5}
var s2 = []int{6, 7, 8}
// 将 s1 拷贝到 s2 中,会从头开始拷贝
copy(s2, s1)
// 因为 s2 长度为 3,因此只会拷贝 3 个
fmt.Println(s2) // [1 2 3]
var s3 = []int{1, 2, 3}
var s4 = []int{4, 5, 6, 7, 8}
// 将 s3 拷贝到 s4 中
copy(s4, s3)
fmt.Println(s4) // [1 2 3 7 8]
var s5 = []int{1, 2, 3, 4, 5}
var s6 = make([]int, 1, 3)
copy(s6, s5)
// 切片拷贝前后的长度和容量都是不变的
// 并且能往切片里面拷贝多少个元素,取决于切片的长度、而不是容量
fmt.Println(s6) // [1]
fmt.Println(s6[: 3]) // [1 0 0]
var s7 = []int{1, 2, 3}
var s8 = []int{3, 4, 5}
// copy 的行为相当于覆盖,如果想追加呢?
s7 = append(s7, s8[1:]...)
fmt.Println(s7) // [1 2 3 4 5]
}
小结
切片是对数组的一个封装,两者都可以通过下标来访问单个元素。
数组是定长的,长度定义好之后不能再更改。所以数组的长度也是类型的一部分,因此限制了它的表达能力,比如 [3]int 和 [4]int 就是不同的类型。
而切片则非常灵活,它可以动态扩容,并且类型和长度无关。
如果觉得文章对您有所帮助,可以请囊中羞涩的作者喝杯柠檬水,万分感谢,愿每一个来到这里的人都生活愉快,幸福美满。
微信赞赏
支付宝赞赏