2024-09-05
Go & 后端
00

目录

1 使用方法
2 底层实现

对 slice 的使用方法和底层机制进行记录,以下内容基于 go1.22.1 版本。

slice 是 go 语言中类似 list 的存在,正式名字叫切片。注意切片和数组不是一个东西

1 使用方法

  1. 调用 make 函数进行初始化:a := make([]int, 10, 20),这会创建一个长度为10,容量为20的切片,切片里的每个值会被初始化为对应类型的默认值。如果一个切片变量只声明不 make:var a []int,那么变量值 a 为 nil。
  2. append 函数用于向切片中添加元素,len 函数用于访问切片长度,cap 函数用于访问切片容量。后两个函数全部是 O1O1 的时间复杂度。
  3. 切片支持通过类似a := b[start:end]的,名叫简单表达式的方式得到一个新的切片,两个切片共享内存空间,对 a 的修改也会影响 b(包括没有切到的,但实际还在 b 容量内的部分也会被影响),反之亦然。注意 start 和 end 的取值范围是0到 b 的容量而不是长度。切取的内容是包括b[start],但不包括b[end]的。

另外一提,字符串也支持这样的切取,但是字符串不会共享内存空间。

  1. 切片还支持通过类似a := b[start:end:max]的名叫扩展表达式的方式得到一个新的切片,该新切片和通过a := b[start:end]得到的切片最大的不同是,前者的 cap 被锁死为max - end。如果到达容量上限后再 append,会重新分配内存,而不影响原切片的内容。
  2. 简单表达式中 start 和 end 都是可以被省略的,其默认值为0和 b 的长度。但是扩展表达式中只有 start 可被省略。
  3. 切片还支持 copy,会将老切片的所有内容一个一个拷贝进新切片,拷贝取两个切片长度的最小值。

2 底层实现

切片的底层实现是这样的:

go
type slice struct { array unsafe.Pointer len int cap int }

可以明显看出,切片实质上就是对数组指针的封装,所以函数可以放心传切片而不是切片指针

  1. 在创建一个切片时,会创建一个长度为 cap 的数组,slice 的指针指向该数组,len划定了其中的有效值(这只是一个概念,意思是该值是可以被用户直接操作的)范围。
  2. 在通过表达式获取一个新的切片时,这个新的切片实质上就是其指针指向旧切片的 start 位置,同时指定 len 和 cap 的值。
  3. 注意,如果频繁使用表达式获取新的切片的话,很有可能出现几个切片共同指向内存中的同一个数组,每个切片各自维护各自的 len 和 cap,通过这俩来确定各自的切片有哪些元素。
  4. append 实质上要执行的有这么几条:
    • 尝试向数组写入内容。
    • 如果数组已满,即写入的内容数量已经到达 cap,那么 append 会采用runtime.nextslicemap函数为策略进行扩容(实际扩容函数为runtime.growslice)(扩容的时候也会进行内存对齐)。
    • 向扩容后的数组写入内容。

内存对齐是为了 CPU 访问内存次数更少设置的。因为内存是按字长进行存储和读取的,所以既然每次读写内存都是固定长度的数据,假设字长为8位,一个相同的,长度为10位的变量被切到3个字长里面(1位,8位,1位)和被切到2个字长(8位,2位)里面,当然是后者更有优势。这就是内存对齐的工作。

从这个机制出发,结构体成员变量的排列也能够影响其实际占据内存的大小,当然影响其实微乎其微,这一点会在结构体底层/特性解析中会说。

runtime.nextslicemap函数如下:

go
// nextslicecap 根据需求的新的长度和 slice 旧的容量 计算出扩容的目标容量 // 这个需求的新的长度 即为当前切片的容量加上追加元素的数量 // nextslicecap computes the next appropriate slice length. func nextslicecap(newLen, oldCap int) int { newcap := oldCap doublecap := newcap + newcap if newLen > doublecap { // 如果需求的新的长度超过两倍的现有的容量 直接返回新的长度作为容量 return newLen } const threshold = 256 if oldCap < threshold { return doublecap // 如果现有的容量小于256 那么直接返回两倍现有的容量作为新容量 } for { // Transition from growing 2x for small slices // to growing 1.25x for large slices. This formula // gives a smooth-ish transition between the two. // 对于小切片 容量增长速度应当是两倍 // 对于大切片 容量增长速度应当是1.25倍 // 下面的公式 中和了上面两个原则 并且使容量增长曲线更平滑 // 下面的公式在代码下面会用 LaTex 写出 newcap += (newcap + 3*threshold) >> 2 // We need to check `newcap >= newLen` and whether `newcap` overflowed. // newLen is guaranteed to be larger than zero, hence // when newcap overflows then `uint(newcap) > uint(newLen)`. // This allows to check for both with the same comparison. // 检查 newcap 是否大于 newLen 的同时 检查 newcap 是否发生了溢出 // 因为 newcap 是大于0的 所以当 uint(newcap) > uint(newLen) 时 可以认为 newcap 发生了溢出 // 注意 一个 int 存储的数字如果到达上限 再往上加就会发生溢出 值变为负数 // 溢出是越加 读到的值的绝对值越来越小 // 所以溢出时 用 uint 读到的值会非常非常大 配合下面的检查 newcap 本身是否小于0 即可判断 newcap 是否溢出 if uint(newcap) >= uint(newLen) { break } } // Set newcap to the requested cap when // the newcap calculation overflowed. // 如果 newcap 发生了溢出 直接返回 newLen 作为容量 if newcap <= 0 { return newLen } return newcap }
newcap=newcap+3threshold4newcap = \frac{newcap + 3 * threshold}{4}
  1. copy 的过程不会触发底层数组的扩容。

本文作者:御坂19327号

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!