Go 的数组一旦申请,长度就不可以变了,显然这极大地限制了数组的灵活性。如果我们在存储元素时,数量未知且不固定,那么数组不是一个好的选择,于是 Go 提供了另外一种数据结构,叫做切片。 切片本质上是一个结构体,我们看一下它的底层结构。
// runtime/slice.go type slice struct { // 指向底层数组的指针 array unsafe.Pointer // 长度 len int // 容量 cap int }
我们看到切片实际上就是一个结构体实例,有三个字段,分别是指向底层数组的指针、切片的长度、切片的容量。所以切片不过是对数组进行了一个封装,实际存储元素的肯定还是数组。 任何一门语言,数组一旦申请,大小就固定了,Go 也不例外。所以切片内部要保存一个指向数组的指针,一旦数组满了,那么就申请一个长度更大的数组,并把老数组的元素拷贝过去,然后让指针指向新数组,最后释放老数组的内存。
另外大部分语言都有可变数组,无非叫法不同,比如在 Go 里面叫切片,在 Python 里面叫列表。但它们的原理是一致的,所谓的可变都是基于不可变进行的一个封装,比如 Python 的列表是对 C 数组进行的封装,Go 切片是对 Go 数组进行的封装。
在使用切片添加元素的时候,会添加到数组中,切片的容量(cap)就是底层数组的长度,切片的长度(len)则是往底层数组添加了多少个元素。而当我们在添加元素的时候,内部会进行如下判断:
而申请新数组、拷贝老数组的元素、释放老数组整体被称之为扩容,很明显扩容是一个比较昂贵的操作。为了避免频繁扩容,在申请底层数组的时候,会尽可能申请的长一些。
切片对应的结构体内部有三个字段,在 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 就是不同的类型。 而切片则非常灵活,它可以动态扩容,并且类型和长度无关。
|