重新认识Golang的Slice
开篇语
大多数时候我们都忘记了或者压根不知道slice是怎么工作的。大多数时候我们只是把slice当做动态数组来用。通过重新认识slice,我们可以一定程度上避免掉入slice的陷阱,并且更好的使用它。
参考资料有:
本文重点是代码例子,边动手边学习
回归本元: 什么是数组?
Go中的数组(array)是一个固定大小的、单一类型的一个序列。
创建数组需要两个参数:size和type。
Array的size是类型的一部分
x := [5]int{1, 2, 3}
y := [5]int{3, 2, 1}
z := [5]int{1, 2, 3}
fmt.Printf("x == y: %v\\n", x == y) // false
fmt.Printf("x == z: %v\\n", x == z) // true
上面的x、y、z类型相同,可以比较。element的顺序相同就相等。
a := [4]int{1, 2, 3}
fmt.Printf("x == a: %v\\n", x == a)
上面a和x类型不同,因为size一个是4,一个是5。它俩比较,会报编译错误
理所当然,Go的数组也不能越界访问。
Array的element会初始化为0
in := [5]int{10, 20, 30}
fmt.Printf("contents > in:%v\\n", in)
// Output:
// contents > in:[10 20 30 0 0]
参考材料
Array是value(不是reference)
把数组赋值给另一个数组,或者把数组传递到函数中的一个参数,都会发生值拷贝。
func passArray(y [5]int) {
fmt.Printf("&y:%p\\n", &y) // &y:0x45e020
y[0] = 90
fmt.Printf("y: %v\\n", y) // y: [90 20 30 0 0]
}
func main() {
x := [5]int{10, 20, 30}
fmt.Printf("&x:%p\\n", &x) // &x:0x45e000
passArray(x)
fmt.Printf("x: %v\\n", x) // x: [10 20 30 0 0]
}
如果我们想要函数能够修改它的参数的值,我们应该把数组的地址传进去,当然,函数的参数也应该变为数组的指针。
func passArray(y *[5]int) {
fmt.Printf("y:%p\\n", y) // &y:0x45e000
y[0] = 90
fmt.Printf("y: %v\\n", y) // y: [90 20 30 0 0]
}
func main() {
x := [5]int{1, 2, 3}
fmt.Printf("&x:%p\\n", &x) // &x:0x45e000
passArray(&x)
fmt.Printf("x: %v\\n", x) // x: [90 20 30 0 0]
}
什么是slice
在Go官方博客中,slice的定义是数组中一段的描述符。slice由一个数组的指针、数组段的长度和slice本身的容量三部分组成。
Slice的底层存储
先用一段程序来看看slice和array的关系
func passSlice(xx []int) {
fmt.Printf("xx> &xx:%p &xx[0]:%p\\n", &xx, &xx[0])
fmt.Printf("xx> len:%d cap:%d\\n", len(xx), cap(xx))
}
func main() {
x := []int{1, 2, 3}
fmt.Printf("x> &x:%p &x[0]:%p\\n", &x, &x[0])
fmt.Printf("x> len:%d cap:%d\\n", len(x), cap(x))
passSlice(x)
}
这段程序我们先创建了一个slice:x。然后打印出来了它的地址、长度和容量。再将它传到了一个函数中,再次打印上述信息。输出如下:
x> &x:0x40a0e0 &x[0]:0x40e020
x> len:3 cap:3
xx> &xx:0x40a0f0 &xx[0]:0x40e020
xx> len:3 cap:3
发现了么?将slice直接传递给一个函数,函数参数是一个新创建的slice,但是slice内部的数据和原始的slice内部的数据还是同一个地址。这说明了x和xx共享了同一个内部数据(也就是同一个数组的同一段存储)
显然,当函数中的xx修改了元素,原来的x中的值也会被修改。这里经常会出现bug,因为多个slice共享同一份数组,可能会相互干扰。
另一个有趣的地方,既然slice会共享底层存储,那么当我们对某一个slice进行append操作,会发生什么?
func sliceAppend(xx []int) {
xx = append(xx, 4)
}
func main() {
x := []int{1, 2, 3}
sliceAppend(x)
fmt.Printf("%v\\n", x)
}
若果你认为输出是[1,2,3,4],那就错了,程序输出还是[1,2,3]。到底发生了什么?看一下地址就一清二楚了。
func sliceAppendAddress(xx []int) {
fmt.Printf("xx before > &[0]:%p len:%d cap:%d\\n", &xx[0], len(xx), cap(xx))
xx = append(xx, 4)
fmt.Printf("xx after > &[0]:%p len:%d cap:%d\\n", &xx[0], len(xx), cap(xx))
}
func main() {
x := []int{1, 2, 3}
fmt.Printf("x before > &[0]:%p len:%d cap:%d\\n", &x[0], len(x), cap(x))
sliceAppendAddress(x)
fmt.Printf("x after > &[0]:%p len:%d cap:%d\\n", &x[0], len(x), cap(x))
}
// Output:
// x before > &[0]:0x40e020 len:3 cap:3
// xx before > &[0]:0x40e020 len:3 cap:3
// xx after > &[0]:0x456020 len:4 cap:8
// x after > &[0]:0x40e020 len:3 cap:3
从上面的输出可以很清楚的看到,在append之后,xx的地址、容量都发生了变化,这些变化并没有影响到原来的x。这个例子很好理解,函数中的xx在append的时候容量不够了,发生了reallocate,这时Go会为它重新创建一个底层存储(也就是一个数组)。
如果,容量足够,会发生什么?我们来看下面的例子
func sliceAppend(xx []int) {
fmt.Printf("xx before > len:%d cap:%d\\n", len(xx), cap(xx))
xx = append(xx, 4)
fmt.Printf("xx after > len:%d cap:%d\\n", len(xx), cap(xx))
}
func main() {
x := make([]int, 0, 5)
x = append(x, 1, 2, 3) // [1 2 3]
fmt.Printf("x before > len:%d cap:%d\\n", len(x), cap(x))
sliceAppend(x)
fmt.Printf("x after > %v\\n", x)
}
// Output:
// x before > len:3 cap:5
// xx before > len:3 cap:5
// xx after > len:4 cap:5
// x after > [1 2 3]
为什么?从容量来看,这里是不会发生reallocate的,可是为什么原来的x还是没有发生变化呢?实际上,这是因为x收到了自己len的限制。我们只要扩展一下它的长度就行了:
func sliceAppend(xx []int) {
fmt.Printf("xx before > len:%d cap:%d\\n", len(xx), cap(xx))
xx = append(xx, 4)
fmt.Printf("xx after > len:%d cap:%d\\n", len(xx), cap(xx))
}
func main() {
x := make([]int, 0, 5)
x = append(x, 1, 2, 3) // [1 2 3]
fmt.Printf("x before > len:%d cap:%d\\n", len(x), cap(x))
sliceAppend(x)
x = x[:4]
fmt.Printf("x after > %v\\n", x)
fmt.Printf("x after > len:%d cap:%d\\n", len(x), cap(x))
}
// Output:
// x before > len:3 cap:5
// xx before > len:3 cap:5
// xx after > len:4 cap:5
// x after > [1 2 3 4]
// x after > len:4 cap:5
总结一下从这几个例子我们学到了什么
- slice是值传递,也就是函数参数会复制一个slice(数组指针、len、cap)
- 只要底层存储没有变化,对函数接收的slice的element修改,会影响到原始的slice
- 如果在函数中发生了reallocate,也就是说底层存储发生了变化,那么receiver和caller的slice不会相互影响
对slice进行切片(Slicing)
先看下面一段程序
s := []int{1, 2, 3, 4, 5, 6, 7}
fmt.Printf("s > len:%d cap:%d\\n", len(s), cap(s))
ss := s[2:4]
fmt.Printf("ss> len:%d cap:%d\\n", len(ss), cap(ss))
// s > len:6 cap:7
// ss> len:? Cap:?
// A. 2 2
// B. 2 5
// C. 2 7
对slice进行切片,语法是
newSlice := s[low:high]
第high个元素是不包含在新的切片中的。所以:
len(newSlice) : high-low
cap(newSlice) : cap(s)-low
如果想指定新切片的最大坐标,还可以这样写
newSlice := s[low:high:max]
注意这种写法不适用于string,此时新切片的cap是max-low
slice还有一个常见的操作,就是对slice进行切片(slicing a slice)
s := []int{10, 20, 30, 40, 50, 60, 70}
fmt.Printf(" &s:%p &s[2]:%p\\n", &s, &s[2])
ss := s[2:4]
fmt.Printf("&ss:%p &ss[0]:%p\\n", &ss, &ss[0])
// &s :0xc00000a0a0 &s[2]:0xc000018250
// &ss:0xc00000a0c0 &ss[0]:0xc000018250
从上面的结果可以看出,sub-slice和原来的slice是共享的相同的底层存储(数组)。那么显然,对sub-slice的修改,也会影响到原有的slice。
Zeroing slice
将一个slice清空的最佳方法是什么呢?直觉上有两种方法
- s = nil
- s = [:0]
我们先来看看s=nil
s := []int{1, 2, 3}
fmt.Printf("s> len:%d cap:%d &[0]:%p\\n", len(s), cap(s), &s[0])
s = nil
fmt.Printf("s> len:%d cap:%d\\n", len(s), cap(s))
s = append(s, 4)
fmt.Printf("s> len:%d cap:%d &[0]:%p\\n", len(s), cap(s), &s[0])
// Output:
// s> len:3 cap:3 &[0]:0xc0000ac040
// s> len:0 cap:0
// s> len:1 cap:1 &[0]:0xc00007e0e8
s=nil之后,s的指针、len、cap全部清零,append之后开辟了新的存储空间(数组)
现在来看第二种方法s=[:0]
s := []int{1, 2, 3}
fmt.Printf("s> len:%d cap:%d &[0]:%p\\n", len(s), cap(s), &s[0])
s = s[:0]
fmt.Printf("s> len:%d cap:%d\\n", len(s), cap(s))
s = append(s, 4)
fmt.Printf("s> len:%d cap:%d &[0]:%p\\n", len(s), cap(s), &s[0])
fmt.Printf(“s> %v\\n”, s)
// s> len:3 cap:3 &[0]:0xc0000144c0
// s> len:0 cap:3
// s> len:1 cap:3 &[0]:0xc0000144c0
// s> [4]
这种情况下,s只是清空了len和cap,底层存储数组的指针还保留,所以append之后还是原来的地址。
综上可以这么说,s=nil是一种类似release的操作,s=s[:0]只是清空数据。
Slice的小陷阱
下面分析一下slice的常见错误
太多的reallcation
连续的多次append()可能会造成reallcation
func doX(in []int) (out []int){
for _, v := range in {
fmt.Printf("before> out len:%d cap:%d\\n", len(out), cap(out))
out = append(out, v)
fmt.Printf("after > out len:%d cap:%d\\n", len(out), cap(out))
}
return out
}
doX([]int{1,2,3,4,5})
--------------------------
// Output: 4 re-allocation
before> out len:0 cap:0
after > out len:1 cap:1
before> out len:1 cap:1
after > out len:2 cap:2
before> out len:2 cap:2
after > out len:3 cap:4
before> out len:3 cap:4
after > out len:4 cap:4
before> out len:4 cap:4
after > out len:5 cap:8
观察这里的cap,变化了4次,也就是发生了4次的reallocation。
如果提前进行一些内存分配,就不会有这样的情况了。
func doX(in []int) (out []int){
out = make([]int, 0, len(in))
for _, v := range in {
out = append(out, v)
}
return out
}
doX([]int{1,2,3,4,5})
-------------------------------
// Output: 1 allocation
before> out len:0 cap:5
after > out len:1 cap:5
before> out len:1 cap:5
after > out len:2 cap:5
before> out len:2 cap:5
after > out len:3 cap:5
before> out len:3 cap:5
after > out len:4 cap:5
before> out len:4 cap:5
after > out len:5 cap:5
可以通过下面的工具来针对这个问题进行静态分析:https://github.com/alexkohler/prealloc
没有释放内存
在re-slicing的时候,并不会复制一份内存,所以整个数组的内存都会因为slicing出来的切片而保留,直到slicing被nil-ed才会释放。
理论上这个并不算『内存泄露』,因为那段内存确实还在使用,只不过只是一小部分。
这个目前没有好用的静态分析工具。