Go 语言的切片

Go 中的切片

切片又称动态数组,依托数组实现,可以方便的进行扩容、传递等,实际使用中比数组更灵活。

切片与数组很类似,它们的共同点是都属于集合类的类型,并且,它们的值也都可以用来存储某一种类型的值(或者说元素)。不过,它们最重要的不同是:数组类型的值(以下简称数组)的长度是固定的,而切片类型的值(以下简称切片)是可变长的。

数组的长度在声明它的时候就必须给定,并且之后不会再改变。可以说,数组的长度是其类型的一部分。比如,[1]string 和[2]string 就是两个不同的数组类型。而切片的类型字面量中只有元素的类型,而没有长度。切片的长度可以自动地随着其中元素数量的增长而增长,但不会随着元素数量的减少而减小。

先来看看切片的源码:

1
2
3
4
5
6
7
8
9
10
// runtime/slice.go

type slice struct {
// 指向底层数组的指针
array unsafe.Pointer
// 长度
len int
// 容量
cap int
}

我们在程序里使用的切片实际上就是一个结构体示例,有 3 个字段,分别是指向底层数组的指针、切片的长度、切片的容量。所以切片是对数组进行了一个封装,实际存储元素的还是数组,切片也可以被看作是对数组的某个连续片段的引用。

那么该如何正确估算切片的长度和容量呢?
可以使用 len() 函数获得切片长度,cap() 函数获取切片容量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

package main

import "fmt"

func main() {
// 示例1。
s1 := make([]int, 5)
fmt.Printf("The length of s1: %d\n", len(s1))
fmt.Printf("The capacity of s1: %d\n", cap(s1))
fmt.Printf("The value of s1: %d\n", s1)
s2 := make([]int, 5, 8)
fmt.Printf("The length of s2: %d\n", len(s2))
fmt.Printf("The capacity of s2: %d\n", cap(s2))
fmt.Printf("The value of s2: %d\n", s2)
}

// output
The length of s1: 5
The capacity of s1: 5
The value of s1: [0 0 0 0 0]
The length of s2: 5
The capacity of s2: 8
The value of s2: [0 0 0 0 0]

在上述代码中,我用内建函数 make 声明了一个[]int 类型的变量 s1。我传给 make 函数的第二个参数是 5,从而指明了该切片的长度。我用几乎同样的方式声明了切片 s2,只不过多传入了一个参数 8 以指明该切片的容量。

切片的创建

切片的创建分为声明、make()函数创建以及截取;

直接声明

看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {  

    var s []int
    //如果内部的指针为空,那么 s 和 nil 是相等的
    fmt.Println(s == nil)  // 输出true
    
    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]
}

这种方式只是声明了一个切片,如果没有赋值,那么里面的每个成员默认都是零值;所以切片内部结构体的指针是一个空指针、没有指向任何的底层数组,长度和容量都是 0。

既然此时切片结构体指向底层数组的指针是空的,那为什么切片居然可以使用 append() 函数添加元素?这是因为使用 append() 函数时,如果没有分配底层数组的话,那么会自动先帮你分配一个大小、容量都为 0 的底层数组,然后再把元素 append 进取。注意,此时这里会有扩容操作。

什么是扩容操作?
在使用切片添加元素的时候,会添加到数组中,切片的容量(cap)就是底层数组的长度,切片的长度(len)则是往底层数组添加了多少个元素。而当我们在添加元素的时候,内部会进行如下判断:

  • 如果 len 小于 cap,那么底层数组还有空间,于是会将元素设置在数组中索引为 len 的位置;

  • 如果 len 等于 cap,说明底层数组已经满了,于是会申请一个更大的数组,并且将老数组里面的元素都拷贝到新数组中。然后将添加的元素设置在新数组索引为 len 的位置,让切片内部的指针 array 指向新数组,最后释放老数组;

而申请新数组、拷贝老数组的元素、释放老数组整体被称之为扩容,很明显扩容是一个比较昂贵的操作。为了避免频繁扩容,在申请底层数组的时候,会尽可能申请的长一些。

make()函数

make() 函数是创建切片最常用的方式。
先来看看 make() 函数的函数签名

1
2
3
4
5
//  Slice: The size specifies the length. The capacity of the slice is  
// equal to its length. A second integer argument may be provided to
// specify a different capacity; it must be no smaller than the
// length.
func make(t Type, size ...IntegerType) Type

从源代码就可以得知,切片的容量等于长度,也可以提供第二个参数,第二参数指定容量,但是这个容量必须大于等于长度。

1
2
3
4
func main() {  
    s := make([]int, 3, 5)
    fmt.Println(s)  // [0 0 0]
}

所以以上代码就清晰了,使用 make() 函数创建了一个长度为 3 容量为 5 的切片。

而这时可以对该切片进行操作了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
func main() {
s := make([]int, 3, 5)
fmt.Println(s)
fmt.Printf("此时s的指针地址:%p ,长度:%d, 容量:%d\n", s, len(s), cap(s))

s[0], s[1], s[2] = 1, 2, 3
fmt.Println(s)
fmt.Printf("设置3个值后此时s的指针地址:%p ,长度:%d, 容量:%d\n", s, len(s), cap(s))

s = append(s, 4)
fmt.Println(s)
fmt.Printf("添加4后,此时s的指针地址:%p ,长度:%d, 容量:%d\n", s, len(s), cap(s))

s = append(s, 5)
fmt.Println(s)
fmt.Printf("添加5后,此时s的指针地址:%p ,长度:%d, 容量:%d\n", s, len(s), cap(s))

s = append(s, 6)
fmt.Println(s)
fmt.Printf("添加6后触发扩容操作,此时s的指针地址:%p ,长度:%d, 容量:%d\n", s, len(s), cap(s))
}

// output
[0 0 0]
此时s的指针地址:0xc0000161b0 ,长度:3, 容量:5
[1 2 3]
设置3个值后此时s的指针地址:0xc0000161b0 ,长度:3, 容量:5
[1 2 3 4]
添加4后,此时s的指针地址:0xc0000161b0 ,长度:4, 容量:5
[1 2 3 4 5]
添加5后,此时s的指针地址:0xc0000161b0 ,长度:5, 容量:5
[1 2 3 4 5 6]
添加6后触发扩容操作,此时s的指针地址:0xc000100000 ,长度:6, 容量:10

从以上代码可以看出,对切片操作之后,如果追加元素之后容量没有超过切片的容量是不会触发扩容操作的,一旦超过容量上限则会触发扩容操作,扩容后的容量是以往容量的 2 倍。而且扩容前的指针地址是 0xc0000161b0 ,在扩容后是 0xc000100000,底层数组发生了迁移,旧数组被释放了。

截取

如果使用 make、或者声明的方式创建切片的话,那么会默认分配一个底层数组,并且后续的维护也不需要开发者关心。但问题就在于,很多时候我们会基于已存在的某个数组创建切片,而这里面隐藏着一些玄机。

截取指的是对数组、切片的截取,截取操作可以被叫做切片表达式

1
2
// 切片表达式的定义
被截取的数组[low:high:max]

low 代表切片截取的左区间起始位置,high 代表切片截取的右区间终止位置(不包括 high,实际上截取的位置是 high - 1),max 代表截取后产生的切片容量。

直接看定义可能不明所以,来看个实例:

1
2
3
4
5
s3 := []int{1, 2, 3, 4, 5, 6, 7, 8}
s4 := s3[3:6]
fmt.Printf("The length of s4: %d\n", len(s4))
fmt.Printf("The capacity of s4: %d\n", cap(s4))
fmt.Printf("The value of s4: %d\n", s4)

切片表达式中的方括号里的那 2 个整数都代表什么。换一种表达方式也许就清楚了,即:[low, high)。这是数学中的区间表示法,常用于表示取值范围。由此可知,[3:6]要表达的就是透过新窗口能看到的 s3 中元素的索引范围是从 3 到 5(注意,不包括 6)。如下图:

再来看容量。切片的容量代表了它的底层数组的长度,但这仅限于使用 make 函数或者切片值字面量初始化切片的情况。

更通用的规则是:一个切片的容量可以被看作是透过这个窗口最多可以看到的底层数组中元素的个数。由于 s4 是通过在 s3 上施加切片操作得来的,所以 s3 的底层数组就是 s4 的底层数组。又因为,在底层数组不变的情况下,切片代表的窗口可以向右扩展,直至其底层数组的末尾。

所以,s4 的容量就是其底层数组的长度 8, 减去上述切片表达式中的那个起始索引 3,即 5。

我们可以把切片看成是底层数组的一个视图,修改切片等价于修改数组,最终的操作都会体现在数组上。而 s4 基于 s3 数组,所以修改 s4 会影响 s3 数组。

从数组截取的切片,在向后扩展的时候,默认可以扩展到数组的结束位置。但我们在截取的同时还可以指定容量,比如 s4 = s3[3:6: 6],这里的 6 就表示切片 s 最多扩展到 arr 长度为 6 的位置,那么它的容量就是 6-3。

切片的拷贝

拷贝切片最简单的方式就是变量赋值:

1
2
3
4
5
6
7
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 里面没有所谓的引用传递,只有值传递,不管怎么传,都是拷贝一份。但是切片不负责保存数据,它内部只是维护了一个指针,所以在拷贝的时候只会拷贝切片本身,底层数组并不会拷贝。因为底层数组不是切片的一部分,这两者是通过一个指针建立的联系。

注意,Go 语言里不存在像 Java 等编程语言中令人困惑的“传值或传引用”问题。在 Go 语言中,我们判断所谓的“传值”或者“传引用”只要看被传递的值的类型就好了。如果传递的值是引用类型的,那么就是“传引用”。如果传递的值是值类型的,那么就是“传值”。从传递成本的角度讲,引用类型的值往往要比值类型的值低很多。
Go 语言的切片类型属于引用类型,同属引用类型的还有字典类型、通道类型、函数类型等;而 Go 语言的数组类型则属于值类型,同属值类型的有基础数据类型以及结构体类型。

除此之外,还有一个内置函数 copy ,专门用于切片的拷贝,下面是 copy 函数的函数签名:

1
func copy(dst, src []Type) int

将 src 切片复制给 dst 切片,返回复制的元素数

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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 就是不同的类型。

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

切片的创建分为直接声明、make 函数以及截取操作 3 种;

使用直接声明创建切片时,那么里面的每个成员默认都是零值;所以切片内部结构体的指针是一个空指针、没有指向任何的底层数组,长度和容量都是 0。

而使用 make 函数创建切片时,只要指定切片存储元素的类型、长度以及容量,其中类型以及长度是比选,容量是可选的。如果容量没有指定,那么容量则与长度相等,该函数返回一个切片,该切片立即可用。

不推荐使用 new 函数创建切片,因为 new 是申请一小块内存并标记它是用来存放某个值的。它返回的是指向这块内存的指针,而且这块内存并不会被初始化。或者说,对于一个引用类型的值,那块内存虽然已经有了,但还没法用(因为里面没有针对那个值的数据结构)。

使用截取(切片表达式)去创建一个切片时,长度为你截取的切片元素数量,而容量可以通过在切片表达式里面指定:

1
被截取的切片[起始索引:终止索引:容量]

如果在切片表达式里面没有指定容量,那么就由被截取切片的长度减去切片表达式的起始索引;
示例:

1
2
3
// 声明一个长度为 8 的切片
s3 := []int{1, 2, 3, 4, 5, 6, 7, 8}
s4 := s3[3:6]

此时,s4 切片的长度为 3,元素索引为:3、4、5;元素为:4、5、6;容量为 5。

为什么容量是 5 ?因为被截取切片的长度为 8,切片表达式的起始索引为 3,8-3 = 5。就这样!


Go 语言的切片
https://sunshinechen.cn/2022/03/03/Go的切片/
作者
sunshine
发布于
2022年3月3日
许可协议