Go语言精进之路读书笔记第13条——了解切片实现原理并高效使用
13.1 切片究竟是什么
Go数组是值语义的,这意味着一个数组变量表示的是整个数组,对于元素类型长度较大或元素个数较多的数组,如果直接以数组类型参数传递到函数中会有不小的性能损耗。
这时很多人会使用数组指针来定义函数参数,但在Go语言中,更地道的方式是使用切片。
切片之于数组就像是文件描述符之于文件。
数据结构
//slice的结构表示
//runtime/slice.go
type slice struct {
array unsafe.Pointer //指向起点的地址
len int //切片长度
cap int //切片容量
}
• array:指向了内存空间地址的起点. 由于 slice 数据存放在连续的内存空间中,后续可以根据索引 index,在起点的基础上快速进行地址偏移,从而定位到目标元素
• len:切片的长度,指的是逻辑意义上 slice 中实际存放了多少个元素
• cap:切片的容量,指的是物理意义上为 slice 分配了足够用于存放多少个元素的空间. 使用 slice 时,要求 cap 永远大于等于 len
通过 slice 数据结构定义可以看到,每个 slice header 中存放的是内存空间的地址(array 字段),后续在传递切片的时候,相当于是对 slice header 进行了一次值拷贝,但内部存放的地址是相同的,因此对于 slice 本身属于引用传递操作
切片初始化
//第一种:声明但不初始化
//只是声明了 slice 的类型,但是并没有执行初始化操作,即 s 这个字面量此时是一个空指针 nil,并没有完成实际的内存分配操作,我们得到的是一个**nil切片**(也称作**零值切片**)
var s []int
//第二种:基于 make 进行初始化
//形式:make([]T,len,cap),需要保证 cap >= len
//在 index 为 [len, cap) 的范围内,虽然内存空间已经分配了,但是逻辑意义上不存在元素,直接访问会 panic 报数组访问越界
//但是访问 [0,len) 范围内的元素是能够正常访问到的,只不过会是对应元素类型下的零值
//需要注意,切片的长度一旦被指定了,就代表对应位置已经被分配了元素,尽管设置的会是对应元素类型下的零值
s11 := make([]int, 8) //cap不传的时候,默认等于len,即切片的长度 len 和 容量 cap 都为 8
s12 := make([]int, 8, 16) //表示已经在切片中设置了 8 个元素,会设置为对应类型的零值;cap = 16 代表为 slice 分配了用于存放 16 个元素的空间
//第三种:复合字面量的形式,初始化连带赋值
a := []int{1, 2, 3, 4, 5, 6} //长度和容量均为6
s2 := a[1:2:4] // a[start:end:max], 取到的是下标[start,end),len=end-start,cap=max-start, max不能超过原有切片的容量
fmt.Println(s11, s12, a, s2)
空切片说明
- 空切片是已经被初始化但没有包含任何元素的切片。空切片的长度和容量都是0,但是它已经有了一个指向底层数组的指针,即使这个数组是空的
- 空切片可以通过直接声明和初始化来创建,或者通过对nil切片调用内建的make函数或者切片截取
s := []int{} // 通过字面量创建空切片
s := make([]int, 0) // 通过make函数创建空切片
var arr [3]int
s := arr[:0] // 通过数组切片创建空切片
fmt.Println(s == nil) // 输出:false
切片的截取操作
- 我们可以修改 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)]
13.2 切片的高级特性:动态扩容
切片初始化及赋值操作的正确示例
//例子一:元素追加
s := make([]int,0,5)
for i := 0; i < 5; i++{
s = append(s, i)
}
// 结果为:
// s: [0,1,2,3,4]
//例子二:遍历
s := make([]int,5)
for i := 0; i < 5; i++{
s[i] = i
}
// 结果为:
// s: [0,1,2,3,4]
append的时候,若是底层数组容量不足,会进行自动扩容
// len:4, cap: 4
s := []int{2,3,4,5}
// len:5, cap: 8
s = append(s,6)
切片的扩容流程源码位于 runtime/slice.go 文件的 growslice 方法当中,其中核心步骤如下:
• 倘若扩容后预期的新容量小于原切片的容量,则 panic
• 倘若切片元素大小为 0(元素类型为 struct{}),则直接复用一个全局的 zerobase 实例,直接返回
• 倘若预期的新容量超过老容量的两倍,则直接采用预期的新容量
• 倘若老容量小于 256,则直接采用老容量的2倍作为新容量
• 倘若老容量已经大于等于 256,则在老容量的基础上扩容 1/4 的比例并且累加上 192 的数值,持续这样处理,直到得到的新容量已经大于等于预期的新容量为止
• 结合 mallocgc 流程中,对内存分配单元 mspan 的等级制度,推算得到实际需要申请的内存空间大小
• 调用 mallocgc,对新切片进行内存初始化
• 调用 memmove 方法,将老切片中的内容拷贝到新切片中
• 返回扩容后的新切片
13.3 尽量使用 cap 参数
在创建切片时,尽量使用cap参数
补充1:元素删除
删除首尾元素
//删除首个元素
s := []int{0,1,2,3,4}
// [1,2,3,4]
s = s[1:]
//删除尾部元素
s := []int{0,1,2,3,4}
// [0,1,2,3]
s = s[0:len(s)-1]
删除中间的元素
//先截取待删除元素的左侧部分内容,然后追加上待删除元素后侧部分的内容
s := []int{0,1,2,3,4}
// 删除 index = 2 的元素
s = append(s[:2],s[3:]...)
// s: [0,1,3,4], len: 4, cap: 5
删除所有元素
s := []int{0,1,2,3,4}
s = s[:0]
// s: [], len: 0, cap: 5
补充2:切片拷贝
简单拷贝
对切片的字面量进行赋值传递 或 对切片进行截取
//创建出了一个新的 slice header 实例,但是其中的指针 array、容量 cap 和长度 len 仍和老的 slice header 实例相同
s := []int{0, 1, 2, 3, 4}
s1 := s
//s 和 s1 会使用同一片内存空间,只不过地址起点位置偏移了一个元素的长度. s1 和 s 的地址,刚好相差 8 个 byte
s := []int{0, 1, 2, 3, 4}
s1 := s[1:]
完整拷贝
slice 的完整复制,指的是会创建出一个和 slice 容量大小相等的独立的内存区域,并将原 slice 中的元素一一拷贝到新空间中
//slice 的完整复制可以调用系统方法 copy
//通过日志打印的方式可以看到,s 和 s1 的地址是相互独立的
s := []int{0, 1, 2, 3, 4}
s1 := make([]int, len(s))
copy(s1, s)
t.Logf("s: %v, s1: %v", s, s1)
t.Logf("address of s: %p, address of s1: %p", s, s1)
补充3:值传递还是引用传递?
//todo