详解 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 就是不同的类型。

而切片则非常灵活,它可以动态扩容,并且类型和长度无关。

posted @ 2019-08-04 02:16  古明地盆  阅读(2652)  评论(0编辑  收藏  举报