golang slice internal
golang slice internal
说到 slice 首先绕不开的话题是 array. array 是 go 中的一个基本类型,他拥有两个属性,一个是长度,另一个是实际的数据。和 C 类似,array 的长度在编译期就需要确定,或者说,不同的长度和变量类型的组合可以看作不同的 array 的类型,比如 [4]int, [10]int64, [5]interface{} 都是不同类型的 array. 不同于 C 的是,这里的 array 不是 pointer,他就是一个 value, 在函数传参过程中会被完整的拷贝,我们可以将其理解成一个 struct value, 如 Go Slices: usage and internals 描述的:
Go’s arrays are values. An array variable denotes the entire array; it is not a pointer to the first array element (as would be the case in C). This means that when you assign or pass around an array value you will make a copy of its contents. (To avoid the copy you could pass a pointer to the array, but then that’s a pointer to an array, not an array.) One way to think about arrays is as a sort of struct but with indexed rather than named fields: a fixed-size composite value.
下一个绕不开的话题是 array 的初始化,尽管 go 中提供了不同的初始化方法,但是我们仍然秉承 KISS 原则,介绍两种最常用的方法
1 | // 1. ready-to-use, elements will be init to "zero" in certain context |
下面我们可以进入 slice 的领域了,正如上文所述的,array 有点笨,又要提前确定长度,还不能看成指针,显得过于死板。而 slice 也是 go 的一个基础类型,它基于 array 之上,提供了动态长度,传参不拷贝等功能,他才是我们在 go 语言中经常见到的 container 类型。不同于 array 的类型,slice 的类型中不含有长度,只有 element 类型,表现为 []int, []float64, []interface{}. 我们也来看看 slice 常用的五种初始化方法:
1 | // 1. nil slice, len=0, cap=0, still ready-to-use with append, len, cap |
var a []int 和 d := make([]int, 0) 看起来都是创建了一个 len=0, cap=0 的 slice 但是他们有本质区别,a==nil 为 true, 和 d==nil 为 false, 所以我们将其区别为 nil slice 和 empty slice, 我们将在后文在详述二者的区别,我们先来看看 len 和 cap 是什么. 对于一个 slice 来说,我们可以用内置函数 len(a) 以及 cap(a) 来得到其 length 和 capacity, 注意这里的 length 和 array 的 length 不同,这里是一个运行时的概念。那么为什么还有 capacity 呢?因为 slice 是基于 array 之上的,它可以被视为
a descriptor of an array segment. It consists of a pointer to the array, the length of the segment, and its capacity (the maximum length of the segment)
slice 包括对 array 的一个指针,一段 segment 的长度,以及可供 slice 扩展的 segment 的最大长度。这也就能解释为什么传参一个 slice 不会造成数组元素的拷贝,因为我们仅仅复制了指向数组的一个指针。此时我们也能看到 a 和 d 的区别,a 内部的 pointer 并没有指向任何数组,而 d 内部指向了一个长度为0的数组. 仅仅看前三种初始化方法,似乎 len 和 cap 传达了同样的信息,即所指数组的元素数目,但别忘了我们还有第五种初始化方法,也就是 create from other slice, 此时 len 和 cap 就会产生差异。
go 支持类似 python 的切片语法,譬如 f := b[1:3], 那么一个正常的语义显然是 b 的长度变为2,但此时其指向的数组长度变成了4(如果你对 C 很熟悉,你应该会懂我在说什么),所以 f 的 len 为 2, 而 cap 为 4. 注意,f 的 cap 为 4 并不意味其下标可以访问到3,下标的访问以及 for range 仍由 len 决定。值得一提的是,此时 f 内部指针指向了 f, b 共同 array 的 1th 元素,亦即 f, b 底部的数组是一致的,所以对 f 的任何操作也会同时影响到 b.
尽管下标访问不能超过 len, 但是正如 capacity 的定义 the maximum length of the segment, 一个 slice 可以重新被 re-slice 到其最大长度,f = f[:cap(f)] 后 len(f)=4, cap(f)=4. 但是我们不能超出 capacity 的范围,比如 b[:100] 会造成 runtime panic, 我们也不能用负数来 slice,f[-1:cap(f)] 是非法的,re-slice 是一个单方向买卖。
从上面的描述来看,slice 的 capacity 是一个一锤子买卖,因为不管 slice 怎么玩,其下的 array 都是定死的,而 array 本身就有长度。所以想增加 slice 的 capacity 唯一的方法就是创建一个更大的 cap 的 slice, 然后把原先 slice 的数据拷贝过去. 这实际上就是 append 函数在某些条件下做的事情,所以不要过于依赖 append, 在 capacity 需要增长的 append 下执行效率会很低,涉及大量的拷贝。如果我们自己有拷贝 slice 的需求,也尽量用 内置的 copy 函数,一方面代码简洁,另一方面 copy 可以帮我们 handle 两个 slice 有 overlap 的场景。
1 | a := []int{1} // len=1, cap=1 |
在 cap 不够的情况下 append 会创建新的 slice(基础的 array 是重新分配的),但是 cap 够的情况下,append 实际上直接对 slice 进行下标赋值并返回,此时会造成底部 array 的变动,可能会出现我们意想不到的副作用:
1 | // len, cap, elements |
想要避免这种 subtlety 一个可用的 trick 是 full slice expression. a[low : high : max] 最终 slice 的 capcity 为 max-low. 通过指定 max 使得 slice 下一个 append 一定触发内存的重新分配:
1 | // len, cap, elements |
总结一下,在 golang 中我们可以用 slice 来充当一个支持 re-slice, 无拷贝传参,动态长度的数组,slice 内部包含了对 array 的指针,re-slice 的过程就是对指针的操作 + length 的语义限制
简单来说:
- slice = pointer to array + length + capacity
- re-slice = pointer 移动 + length 语义限制
- append = 原地址赋值 if len < capacity else 重新分配内存
总的来说,我们也能同时总结出一些编程时需要注意的点:
re-slice, slice 作为函数参数是两个高效操作
slice 会导致对底层 array 的引用,GC 就不会回收这个 array, 可能出现一个 tiny slice on top of a huge array 的情况,导致资源的浪费。
利用
copy函数进行两个 slice 的批量拷贝re-slice 后牢记指向的数组是同一个数组
append可能涉及大量的拷贝以及意想不到的副作用- 如果 capacity 足以容纳待 append 的元素,那么返回的 slice 和传入 slice 指向同一个数组
- 否则,返回的 slice 指向的数组是一个新的数组
s = s[0:0]是一个常用用法,我们可以获得一个 len=0, cap 不变的 slice, 此时进行 append 操作是高效的;links