0 前言
切片 slice 是 golang 中一个非常经典的数据结构,其定位可以类比于其他编程语言中的数组. 本文介绍的内容会分为 slice 的使用教程、问题讲解以及源码解析,走读的源码为 go v1.19.
1 几个问题
首先呢,我觉得使用 go 的朋友对于切片这个数据结构不会感到陌生,一些基本的概念和用法应该是可以做到了然于心的. 下面我先抛出一轮问题,大家可以思考并给出自己的答案,然后带着问题进入本文后半段的学习. 在第 2 章中,我们会进行原理补充,把问题涉及的拼图碎片一块块集齐;最后在第 3 章中,我们会正面给出第 1 章中所有问题的答案.
下面,就正式开启灵魂拷问环节:
1.1 问题1
- • 初始化切片 s 长度和容量均为 10
- • 在 s 的基础上追加 append 一个元素
请问经过上述操作后,切片s 的内容、长度以及容量分别是什么?
func Test_slice(t *testing.T){
s := make([]int,10)
s = append(s,10)
t.Logf("s: %v, len of s: %d, cap of s: %d",s,len(s),cap(s))
}
1.2 问题2
- • 初始化切片 s 长度为 0,容量为 10
- • 在 s 的基础上追加 append 一个元素
请问经过上述操作后,切片s 的内容、长度以及容量分别是什么?
func Test_slice(t *testing.T){
s := make([]int,0,10)
s = append(s,10)
t.Logf("s: %v, len of s: %d, cap of s: %d",s,len(s),cap(s))
}
1.3 问题3
- • 初始化切片 s 长度为 10,容量为 11
- • 在 s 的基础上追加 append 一个元素
请问经过上述操作后,切片s 的内容、长度以及容量分别是什么?
func Test_slice(t *testing.T){
s := make([]int,10,11)
s = append(s,10)
t.Logf("s: %v, len of s: %d, cap of s: %d",s,len(s),cap(s))
}
1.4 问题4
- • 初始化切片 s 长度为 10,容量为 12
- • 截取切片 s index = 8 往后的内容赋给 s1
求问 s1 的内容、长度以及容量分别是什么?
func Test_slice(t *testing.T){
s := make([]int,10,12)
s1 := s[8:]
t.Logf("s1: %v, len of s1: %d, cap of s1: %d",s1,len(s1),cap(s1))
}
1.5 问题5
- • 初始化切片 s 长度为 10,容量为 12
- • 截取切片 s index 为 [8,9) 范围内的元素赋给切片 s1
求问 s1 的内容、长度以及容量分别是什么?
func Test_slice(t *testing.T){
s := make([]int,10,12)
s1 := s[8:9]
t.Logf("s1: %v, len of s1: %d, cap of s1: %d",s1,len(s1),cap(s1))
}
1.6 问题6
- • 初始化切片 s 长度为 10,容量为 12
- • 截取切片 s index = 8 往后的内容赋给 s1
- • 修改 s1[0] 的值
请问这个修改是否会影响到 s? 此时,s 的内容是什么?
func Test_slice(t *testing.T){
s := make([]int,10,12)
s1 := s[8:]
s1[0] = -1
t.Logf("s: %v",s)
}
1.7 问题7
- • 初始化切片 s 长度为 10,容量为 12
请问,访问 s[10] 是否会越界?
func Test_slice(t *testing.T){
s := make([]int,10,12)
v := s[10]
// 求问,此时数组访问是否会越界
}
1.8 问题8
- • 初始化切片 s 长度为 10,容量为 12
- • 截取 s 中 index = 8 后面的内容赋给 s1
- • 在 s1 的基础上追加 []int{10,11,12} 3 个元素
请问,经过上述操作时候,访问 s[10] 是否会越界?
func Test_slice(t *testing.T){
s := make([]int,10,12)
s1 := s[8:]
s1 = append(s1,[]int{10,11,12}...)
v := s[10]
// ...
// 求问,此时数组访问是否会越界
}
1.9 问题9
- • 初始化切片 s 长度为 10,容量为 12
- • 截取切片 s index = 8 往后的内容赋给 s1
- • 在方法 changeSlice 中,对 s1[0] 进行修改
求问,经过上述操作之后,s 的内容是什么?
func Test_slice(t *testing.T){
s := make([]int,10,12)
s1 := s[8:]
changeSlice(s1)
t.Logf("s: %v",s)
}
func changeSlice(s1 []int){
s1[0] = -1
}
1.10 问题10
- • 初始化切片 s 长度为 10,容量为 12
- • 截取切片 s index = 8 往后的内容赋给 s1
- • 在方法 changeSlice 中,对 s1 进行 apend 追加操作
请问,经过上述操作后,s 以及 s1 的内容、长度和容量分别是什么?
func Test_slice(t *testing.T){
s := make([]int,10,12)
s1 := s[8:]
changeSlice(s1)
t.Logf("s: %v, len of s: %d, cap of s: %d",s, len(s), cap(s))
t.Logf("s1: %v, len of s1: %d, cap of s1: %d",s1, len(s1), cap(s1))
}
func changeSlice(s1 []int){
s1 = append(s1, 10)
}
1.11 问题11
- • 初始化切片 s,内容为 []int{0,1,2,3,4}
- • 截取 s 中 index = 2 前面的内容(不含s[2]),并在此基础上追加 index = 3 后面的内容
请问,经过上述操作后,s 的内容、长度和内容分别是什么?此时访问 s[4] 是否会越界?
func Test_slice(t *testing.T){
s := []int{0,1,2,3,4}
s = append(s[:2],s[3:]...)
t.Logf("s: %v, len: %d, cap: %d", s, len(s), cap(s))
v := s[4]
// 是否会数组访问越界
}
1.12 问题12
- • 初始化切片 s 长度和容量均为 512
- • 在 s 的基础上追加 append 一个元素
请问经过上述操作后,切片s 的内容、长度以及容量分别是什么?
func Test_slice(t *testing.T){
s := make([]int,512)
s = append(s,1)
t.Logf("len of s: %d, cap of s: %d",len(s),cap(s))
}
2 使用及原理
2.1 基本介绍
go 语言中的切片对标于其他编程语言中通俗意义上的“数组”. 切片中的元素存放在一块内存地址连续的区域,使用索引可以快速检索到指定位置的元素;切片长度和容量是可变的,在使用过程中可以根据需要进行扩容.
2.2 数据结构
type slice struct {
// 指向起点的地址
array unsafe.Pointer
// 切片长度
len int
// 切片容量
cap int
}
切片的类型定义如上,我们称之为 slice header,对应于每个 slice 实例,其中核心字段包括:
- • array:指向了内存空间地址的起点. 由于 slice 数据存放在连续的内存空间中,后续可以根据索引 index,在起点的基础上快速进行地址偏移,从而定位到目标元素
- • len:切片的长度,指的是逻辑意义上 slice 中实际存放了多少个元素
- • cap:切片的容量,指的是物理意义上为 slice 分配了足够用于存放多少个元素的空间. 使用 slice 时,要求 cap 永远大于等于 len
通过 slice 数据结构定义可以看到,每个 slice header 中存放的是内存空间的地址(array 字段),后续在传递切片的时候,相当于是对 slice header 进行了一次值拷贝,但内部存放的地址是相同的,因此对于 slice 本身属于引用传递操作
此外,在这里我们聊到了切片的长度 len 和容量 cap 两个概念,这两个概念很重要,我们需要分清楚两者的区别,这一点会伴随我们研究切片的流程始终.
2.3 初始化
下面先来介绍下切片的初始化操作:
- • 声明但不初始化
下面给出的第一个例子,只是声明了 slice 的类型,但是并没有执行初始化操作,即 s 这个字面量此时是一个空指针 nil,并没有完成实际的内存分配操作.
var s []int
- • 基于 make 进行初始化
make 初始化 slice 也分为两种方式, 第一种方式如下:
s := make([]int,8)
此时会将切片的长度 len 和 容量 cap 同时设置为 8. 需要注意,切片的长度一旦被指定了,就代表对应位置已经被分配了元素,尽管设置的会是对应元素类型下的零值.
第二种方式,是分别指定切片的长度 len 和容量 cap,代码如下:
s := make([]int,8,16)
如上所示,代表已经在切片中设置了 8 个元素,会设置为对应类型的零值;cap = 16 代表为 slice 分配了用于存放 16 个元素的空间. 需要保证 cap >= len. 在 index 为 [len, cap) 的范围内,虽然内存空间已经分配了,但是逻辑意义上不存在元素,直接访问会 panic 报数组访问越界;但是访问 [0,len) 范围内的元素是能够正常访问到的,只不过会是对应元素类型下的零值.
- • 初始化连带赋值
初始化 slice 时还能一气呵成完成赋值操作. 如下所示:
s := []int{2,3,4}
这样操作的话,会将 slice 长度 len 和容量 cap 均设置为 3,同时完成对这 3 个元素赋值.
下面我们来一起过目一下切片初始化的源码,方法入口位于 golang 标准库文件 runtime/slice.go 文件的 makeslice 方法中:
func makeslice(et *_type, len, cap int) unsafe.Pointer {
// 根据 cap 结合每个元素的大小,计算出消耗的总容量
mem, overflow := math.MulUintptr(et.size, uintptr(cap))
if overflow || mem > maxAlloc || len cap {
// 倘若容量超限,len 取负值或者 len 超过 cap,直接 panic
mem, overflow := math.MulUintptr(et.size, uintptr(len))
if overflow || mem > maxAlloc || len [-1,3,4]
changeSlice(s)
}
func changeSlice(s []int){
s[0] = -1
}
如代码所示,将主方法 Test_slice 中声明的切片 s 作为 changeSlice 方法的入参进行传递,同时在 changeSlice 方法中对 s 内的元素进行修改,这样是会直接影响到 Test_slice 中的切片 s 的.
产生这个结果的原因就在于切片的传递是引用传递,而非值传递. 关于这一点,我们可以不用死记硬背,而是可以结合 2.2 小节中我们聊到的 slice header 数据结构,进行逻辑梳理:
每个切片实例对应一个 slice header,其中存储了三个字段:
- • 切片内存空间的起始地址 array;
- • 切片长度 len;
- • 以及切片容量 cap.
综上,每次我们在方法间传递切片时,会对 slice header 实例本身进行一次值拷贝,然后将 slice header 的副本传递到局部方法中.
然而,这个 slice header 副本中的 array 和原 slice 指向同一片内存空间,因此在局部方法中执行修改操作时,还会根据这个地址信息影响到原 slice 所属的内存空间,从而对内容发生影响.
2.5 内容截取
接下来我们聊聊 slice 的截取操作.
我们可以修改 slice 下标的方式,进行 slice 内容的截取,形如 s[a:b] 的格式,其中 a b 代表切片的索引 index,左闭右开,比如 s[a:b] 对应的范围是 [a,b),代表的是取切片 slice index = a ~ index = b-1 范围的内容.
此外,这里我聊到的 a 和 b 是可以缺省的:
- • 如果 a 缺省不填则默认取 0 ,则代表从切片起始位置开始截取. 比如 s[:b] 等价于 s[0:b]
- • 如果 b 缺省不填,则默认取 len(s),则代表末尾截取到切片长度 len 的终点,比如 s[a:] 等价于 s[a:len(s)]
- • a 和 b 均缺省也是可以的,则代表截取整个切片长度的范围,比如 s[:] 等价于 s[0:len(s)]
下面给出一个对 slice 执行截取操作的代码示例:
func Test_slice(t *testing.T){
s := []int{1,2,3,4,5}
// s1: [2,3,4,5]
s1 := s[1:]
// s2: [1,2,3,4]
s2 := s[:len(s)-1]
// s3: [2,3,4]
s3 := s[1:len(s)-1]
// ...
}
在对切片 slice 执行截取操作时,本质上是一次引用传递操作,因为不论如何截取,底层复用的都是同一块内存空间中的数据,只不过,截取动作会创建出一个新的 slice header 实例.
s := []int{2,3,4,5}
s1 := s[1:]
// ...
以下面的代码为例,
s1 = s[1:] 的操作,会创建出一个 s1 的 slice header,其中的字段 array 会在 s.array 的基础上向右偏移一个切片元素大小的数值;s1.len 和 s1.cap 也会以 s1 的起点为起点,以 s 原定的 len 和 cap 终点为终点,最终推算得出 s1.len = s.len - 1;s1.cap = s.cap - 1.
2.6 元素追加
下面介绍的是切片的追加操作. 通过 append 操作,可以在 slice 末尾,额外新增一个元素. 需要注意,这里的末尾指的是针对 slice 的长度 len 而言. 这个过程中倘若发现 slice 的剩余容量已经不足了,则会对 slice 进行扩容. 扩容有关的内容我们放到 2.7 小节再作展开.
func Test_slice(t *testing.T){
s := []int{2,3,4}
s = append(s,5)
// s: [2,3,4,5]
}
结合我个人的经验,一些 go 的初学者在对 slice 进行初始化以及赋值操作时,有可能会因为对 slice 中 len 和 cap 概念的混淆,最终出现错误的使用方式,形如下面这个代码示例:
func Test_slice(t *testing.T){
s := make([]int,5)
for i := 0; i