Intro
切片是我们日常使用的数据结构,是基于数组实现的更加灵活的动态数组。其底层实现很简单,但是在不了解底层实现的时候,可能会遇到奇怪的问题而不知道原因,所以在本章,对slice进行深入,包括创建切片的方式、切片扩容的逻辑。
这章简单地说,需要掌握的并不算很多,了解slice的数据结构以及简单的扩容知识,就已经足够日常使用了。不过想要尽可能弄明白slice扩容的原理,需要了解Go的内存管理。
测试一下
这里简单搬运一下《Go专家编程》中的热身环节,对slice的熟练程度进行测试
Question 1
package main
import (
"fmt"
)
func main() {
var array [10]int
var slice = array[5:6]
fmt.Println("lenth of slice: ", len(slice)) // 1
fmt.Println("capacity of slice: ", cap(slice)) // 5
fmt.Println(&slice[0] == &array[5]) // true
}
Solution:slice与array共享存储空间,len为1,cap为5,从array[5]开始
Question 2
package main
import (
"fmt"
)
func AddElement(slice []int, e int) []int {
return append(slice, e)
}
func main() {
var slice []int
slice = append(slice, 1, 2, 3)
newSlice := AddElement(slice, 4)
fmt.Println(&slice[0] == &newSlice[0]) // true
}
Solution:这里主要考察的是切片扩缩容机制。在《Go专家编程》中,认为扩容的存储空间是原大小的2倍或者1.25倍。但实际情况更加多变,不过这个总结可以涵盖我们平时遇到的大部分问题了。
Question 3
package main
import (
"fmt"
)
func main() {
orderLen := 5
order := make([]uint16, 2 * orderLen)
pollorder := order[:orderLen:orderLen]
lockorder := order[orderLen:][:orderLen:orderLen]
fmt.Println("len(pollorder) = ", len(pollorder)) // 5
fmt.Println("cap(pollorder) = ", cap(pollorder)) // 5
fmt.Println("len(lockorder) = ", len(lockorder)) // 5
fmt.Println("cap(lockorder) = ", cap(lockorder)) // 5
}
Solution:是不是看着很晕?其实并不是很难,在这里解释一下三分索引:[i:j:k]
中,i和j的左右与之前一样,即起点与终点,而k
则表示的是新切片的容量。
声明 & 创建切片
1 声明切片
var s []int // len(s) == 0, s == nil
s := []int(nil) // len(s) == 0, s == nil,这是类型转换声明的切片
s := []int{} // len(s) == 0, s != nil
虽然Go语言支持对slice == nil
的比较,但是从声明切片的方式可以看出,这并不能用来判断切片是否为空,所以应该使用len(slice) == 0
来判断是否是空切片
2 使用make创建切片
make([]T, len)
make([]T, len, cap) // same as make([]T, cap)[:len]
这是我们常用的创建切片的方式,会开辟一块新的内存用以存放切片。这个部分比较简单,引用一张《Go专家编程》中讲述底层结构的图片,就不赘述了。
3 从数组/切片创建
从数组或者切片创建新的切片时,新切片与原切片共享一部分内存,所以在使用的时候需要注意这一点
实现原理
1 slice数据结构
// src/runtime/slice.go:slice
type slice struct {
array unsafe.Pointer // 指向数组的指针(切片第一个元素的地址)
len int // 长度
cap int // 容量
}
非常简单,len(slice)
就能得到slice.len,cap(slice)
就能得到slice.cap。不过值得注意的是,slice的第一个元素并不一定就是数组的第一个元素,Question 1中重叠的切片也证明了这一点
2 slice的append与扩容
append
函数是我们常用给切片追加元素的函数,理解append的实现对于我们理解slice的底层是很有用的,所以这里引用《Go语言圣经》描述的第一版appendInt
函数
func appendInt(x []int, y int) []int {
var z []int
zlen := len(x) + 1
if zlen <= cap(x) {
// There is room to grow. Extend the slice.
z = x[:zlen]
} else {
// There is insufficient space. Allocate a new array.
// Grow by doubling, for amortized linear complexity.
zcap := zlen
if zcap < 2*len(x) {
zcap = 2 * len(x)
}
z = make([]int, zlen, zcap)
copy(z, x) // a built-in function; see text
}
z[len(x)] = y
return z
}
不难看出,在不涉及扩容的情况下,传入的x与返回的z,是共用底层匿名数组的;而如果cap(slice)不足,发生扩容的时候,会重新分配一块更大的内存,则是不同的底层数组。
出于效率的考量,我们应该了解slice的扩容机制是怎么样的,首先看看《Go专家编程》以及《Go语言切片的实现原理》中对扩容规则的基本描述:
- 如果期望容量大于当前容量的两倍,则会使用期望容量
- 如果原Slice容量小于1024,则新Slice容量将扩大为原来的2倍
- 如果原Slice容量大于等于1024,则新Slice容量将扩大为原来的1.25倍
贴一下Go 1.17
中src/runtime/slice.go
的源码:
func growslice(et *_type, old slice, cap int) slice {
// ...
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.cap < 1024 {
newcap = doublecap
} else {
// Check 0 < newcap to detect overflow
// and prevent an infinite loop.
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
newcap = cap
}
}
}
// ...
}
// 主干中该函数的增长策略已经略有改变了,也许在下一版就会改变呢?
这也就是我们上文对于切片扩容规则描述的代码依据。不过,这段代码是用于切片大致容量的。在其后还有代码段,会根据切片元素的大小,进行向上取整的操作。这里推荐看Links中的掘金 & Go语言设计与实现,有比较详细的说明。进行这个操作是为了减少内存碎片,提高内存利用率。具体的原因与Go内存管理的方式有关系,所以想要弄明白这个,需要学习一定的内存管理知识。
其他知识
- 编译优化
- 切片的底层是数组,所以在大部分的时候,编译器会在编译期间优化获取数组大小、读写数组元素等操作
- 使用字面量的方式创建切片,大部分的工作会在编译期间完成,但使用make,许多工作需要runtime的参与,比如检查len与cap的大小等
- 拷贝切片copy
- 不会发生扩容,拷贝数量为min(lenA, lenB)
建议学习第二条链接,对切片的创建工作与编译、运行时结合进行阐述,以及扩容情况下编译器的优化工作,很有价值
Summary
切片扩容的底层实现:
- 估计扩容大小
- 如果期望容量大于当前容量的两倍,则会使用期望容量
- 如果原Slice容量小于1024,则新Slice容量将扩大为原来的2倍
- 如果原Slice容量大于等于1024,则新Slice容量将扩大为原来的1.25倍
- 向上取整
- 根据元素的大小采用不同的逻辑进行向上取整
切片扩容的影响因素: