理解Go中的slice
最近闲来无事,深入研究了slice在Golang中的实现并简要阅读了其相关的底层实现代码后,对于实际工作中的一些slice相关代码的写法与Bug有了一种豁然开朗的感觉。故记录下来,与君分享。
数组 vs 切片
对于初学者来说,我们必须分清楚数组与切片的区别。
在Go中,数组与其他语言并无太大区别,都是一段指定长度的连续内存空间。例如如下代码,我们声明一个长度为4,类型为int
的数组a
。
var a [4]int
此时,Go会调用mallocgc
函数为我们申请一段连续的内存空间,如下图所示。
而对于slice,其实际上是一种结构体,基于数组的一种抽象,相当于其他语言中的动态数组。
切片的表示
在runtime
包中,切片的定义如下。
type slice struct {
array unsafe.Pointer // 底层数组
len int // 切片长度
cap int // 切片容量
}
array
属性指向一个底层数组,实际数据存储在其中,对于同一个底层数组,其可以被多个切片引用。
len
属性限制了切片的长度,通过array
与len
属性,我们就能划分出数组上的一段数据。
cap
属性表示切片的容量,其以array
所指向的地址为起始位置,数组末尾为结束位置。两者之间的跨度即为cap
的大小。
举例说明,这里我们创建一个切片。
s1 := make([]byte, 4, 6)
s1[0] = 'a'
s1[1] = 'b'
s1[2] = 'c'
s1[3] = 'd'
其在内存中表示如下所示。
此时,我们再创建一个切片s2 := s1[1:3]
。其表示如下所示。
由于s2是基于s1传建的,因此其指向了同一底层数组,不同的是,s2的array
指向的是b
元素的起始内存地址。显而易见,当我们执行s1[1] = 'x'
语句时,s2[0]
的值也会被改变为x
。
空切片 vs nil切片
nil切片var s []byte
,如下图所示。
空切片s := make([]byte, 0)
,如下图所示。
nil切片array
属性为nil,而空切片array
属性为空数组的地址。
切片的扩容
当我们执行append
语句时,Go会对切片进行动态扩容。那么其大致策略是怎样的呢?
首先,要明确,扩容针对的是切片的容量而非底层数组的实际长度!
情况1,当执行s1 = append(s1, 'e', 'f', 'g')
语句时,由于总的元素个数超过cap
,需要进行扩容。对于扩容,Go会根据实际情况使用一个合适新容量来创建一个新的底层数组,然后将原数组的数据copy到新数组中,最后再返回一个新的切片(详情可查看runtime
包中的growslice
函数)。
情况2,当执行s2 = append(s2, 'e')
语句时,由于len+1
依然小于cap
故其并未扩容,只是将底层数组上的元素d
覆盖为了e
。
显然,s1[4]
的值也会因此而被改变为e
。
切片代码编写建议
在实际工作中,针对切片操作,我们应该注意哪些呢?
-
及时释放大切片。例如下述代码
for i := 0; i < 3; i++ { tmp := make([]byte, 100000000) headers = append(headers, small[:10]) }
虽然我们只使用了tmp的前10个字节,但是其指向的底层数组依然是原来tmp指向的大数组,因此GC不会回收该大数组,从而导致内存占用过大。为了避免此问题,我们可以创建一个小切片,然后将前10个字节复制过去,如下代码所示。
for i := 0; i < 3; i++ { tmp := make([]byte, 100000000) small := make([]byte, 10) copy(small, tmp[:10]) headers = append(headers, small) }
-
append
操作时,需要特别小心,避免因为使用同一底层数组而导致Bug。必要时,可采用append([]int{}, s...)
的形式来创建一个新的切片。 -
使用
for range
语句时,例如for i, name := range names { ... }
其中
name
变量是值拷贝,每一次迭代都将对应i
位置上的值拷贝到变量name
上,因此你无法通过改变name
的值来改变切片names
对应位置上的值。当然,我们都知道在迭代中修改原切片是十分不好的编程习惯。