分享

详解 Go 的切片

 古明地觉O_o 2022-12-08 发布于北京

楔子


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{123}
    var s2 = []string{"1, 2, 3"}
    var s3 = []float64{1.12.23.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{15:13}
    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, 1234
    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([]int35)
    //创建[]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] = 123
    //注意: 如果使用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{51}
    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([]int03)
    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{123}
    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{123}
    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{12345}
    var s2 = []int{678}
    //将s1拷贝到s2中,会从头开始拷贝
    copy(s2, s1)
    //s1长度为3,因此只会拷贝3个
    fmt.Println(s2)  // [1 2 3]

    var s3 = []int{123}
    var s4 = []int{45678}
    //将s3拷贝到s4中
    copy(s4, s3)  
    fmt.Println(s4)  // [1 2 3 7 8]

    var s5 = []int{12345}
    var s6 = make([]int13)
    copy(s6, s5)
    //我们看到copy切片不会影响底层数组
    fmt.Println(s6)  // [1]
    fmt.Println(s6[: 3]) // [1 0 0]

    var s7 = []int{123}
    var s8 = []int{345}
    //上面相当于覆盖了,如果想追加呢?
    s7 = append(s7, s8[1:]...)
    fmt.Println(s7)  // [1 2 3 4 5]
}

小结


切片是对数组的一个封装,两者都可以通过下标来访问单个元素。

数组是定长的,长度定义好之后不能再更改。所以数组的长度也是类型的一部分,因此限制了它的表达能力,比如 [3]int 和 [4]int 就是不同的类型。

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

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多