Array
数组(Array)是一个由固定长度的特定类型元素组成的序列,一个数组可以由零个或多个元素组成。因其长度的不可变动,数组在Go中很少直接使用。把一个大数组传递给函数会消耗很多内存。一般采用数组的切片
几种初始化方式
1 | arr1 := [3]int{1, 2, 3} |
Slice
Slice是一种数据结构,描述与Slice变量本身分开存储的Array的连续部分。 Slice不是Array。Slice描述了Array的一部分。
slice底层是一个struct
1 | // runtime/slice.go |
创建slice的几种方法
1 | // 直接通过make创建,可以指定len、cap |
append() 底层逻辑
- 计算追加后slice的总长度n
- 如果总长度n大于原cap,则调用growslice func进行扩容(cap最小为n,具体扩容规则见growslice)
- 对扩容后的slice进行切片,长度为n,获取slice s,用以存储所有的数据
- 根据不同的数据类型,调用对应的复制方法,将原slice及追加的slice的数据复制到新的slice
growslice 计算cap的逻辑
- 原cap扩容一倍,即doublecap
- 如果指定cap大于doublecap则使用cap,否则执行如下
- 如果原数据长度小于1024,则使用doublecap
- 否则在原cap的基础上每次扩容1/4,直至不小于cap
1.18更新
已经不是 doublecap
Go:v1.18对扩容策略进行了优化,主要是在1.17的基础上对第二点进行了优化
- 当期望容量 > 两倍的旧容量时,直接使用期望容量作为新切片的容量
- 增加一个阈值并固定为一个常量(threshold),如果旧容量小于这个阈值,则直接使用两倍的旧容量,如果大于等于阈值,那么会进入一个循环,每次增加大概1.3~2倍(取决于threshold),直到大于期望容量
源码大致如下:
1 | // Go 1.18的扩容实现代码如下,et是切片里的元素类型,old是原切片,cap等于原切片的长度+append新增的元素个数。 |
newcap += (newcap + 3*threshold) / 4
newcap是扩容后的容量,先根据原切片的长度、容量和要添加的元素个数确定newcap大小,最后再对newcap做内存对齐得到最后的newcap。
扩容的整体逻辑(对应上述append()的2)
- 按照原slice的cap及指定cap计算扩容后的cap
- 根据计算出cap申请内存(创建新的数组)
- 将原slice的数据拷贝到新内存中(新数组)
- 返回新slice,新slilce指向新数组,len为原slice的len,cap为扩容后的cap
正常我们使用,因slice的长度相对较小,append是扩容使用的是doublecap。
使用append后会产生新的slice,必须重新赋值到原slice上,才能更新原slice的数据。
典型例题
1 | data := [10]int{} |
1 | //第一次append后结果 |
可以看到第一次append的结果影响到了原data的数据,第二次append的结果并没有影响到了data的数据,这是为什么呢?
未append前,slice的cap是5。第一次append一个元素,未超出cap,因此直接存入数据到数组中。第二次append三个元素,append后的元素长度为7,已大于原slice的cap,因此slice需要扩容,扩容后创建了新的数组,复制了data的数据到新数组内,然后存入append的数据,变动的是新数组,原数组data自然不受影响。
append存在对原数据影响的情况,使用时还是需要注意,如有必要,先copy原数据后再进行slice的操作。
总结
slice本身并非指针,append追加元素后,意味着底层数组数据(或数组)、len、cap会发生变化,因此append后需要返回新的slice。
append在追加元素时,当前cap足够容纳元素,则直接存入数据,否则需要扩容后重新创建新的底层数组,拷贝原数组元素后,再存入追加元素。
cap的扩容意味着内存的重新分配,数据的拷贝等操作,为了提高append的效率,若是能预估cap的大小的话,尽量提前声明cap,避免后期的扩容操作。