楔子
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]
}
然后是 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 函数进行添加
//注意: 必须用变量进行接收,该函数会返回新的切片
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]
//虽然结果和我们想象的一样,而且 s 还是原来的 s
//但是底层数组却不是原来的底层数组了
//因为原来的数组长度不够了,所以这个时候会申请一个更大的数组
//然后把原来数组的元素依次拷贝过去,再让切片内部的指针指向新的数组
//查看切片长度可以使用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 就是切片的长度。
然后我们修改切片,还会影响原数组。如果是使用其它方式创建的话,那么 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:2]
fmt.Println(s[3:6])
fmt.Println("----------我是分界线----------")
fmt.Println(s[3])
/*
[e f g]
----------我是分界线----------
panic: runtime error: index out of range [3] with length 1
*/
}
惊了,s 里面只有一个元素,我们居然能够通过 s[3: 6] 访问,但是后面访问 s[3] 却又报错了。原因和切片的可扩展性有关,我们画一张图。
切片实际上是可扩展的,如果对切片进行索引的话,那么最大索引就是切片的长度减去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: 2],切片 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[1:2],那么等价于 s[1:2:len(arr)]
//默认可以向后扩展到数组的结束位置,容量为 len(arr) - 1
//这里是 s[1:2:5],容量为 5 - 1
//表示可以向后扩展到数组长度为 5 的位置
s := arr[1:2: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 而言,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]
切片的扩容
切片的扩容,实际上就是申请一个新的底层数组,假设我们申请的切片容量是 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]) //0xc00000c158 0xc00000c158
//此时 s1 和 s2 都是 [2, 3]
//下面给 s2 扩容
s2 = append(s2, 4)
//地址不一样了
fmt.Println(&s1[0], &s2[0]) //0xc00000c158 0xc00000e1e0
}
第一次打印,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 里面没有所谓的引用传递,只有值传递,不管怎么传,都是拷贝一份。但是切片不负责保存数据,它内部只是维护了一个指针,所以在拷贝的时候只会拷贝切片本身,底层数组并不会拷贝。因为底层数组不是切片的一部分,这两者是通过一个指针建立的联系。
除此之外,还有一个内置函数 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)
//s1长度为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)
//我们看到copy切片不会影响底层数组
fmt.Println(s6) // [1]
fmt.Println(s6[: 3]) // [1 0 0]
var s7 = []int{1, 2, 3}
var s8 = []int{3, 4, 5}
//上面相当于覆盖了,如果想追加呢?
s7 = append(s7, s8[1:]...)
fmt.Println(s7) // [1 2 3 4 5]
}
小结
切片是对数组的一个封装,两者都可以通过下标来访问单个元素。
数组是定长的,长度定义好之后不能再更改。所以数组的长度也是类型的一部分,因此限制了它的表达能力,比如 [3]int 和 [4]int 就是不同的类型。
而切片则非常灵活,它可以动态扩容,并且类型和长度无关。
联系客服