写点什么

重新认识 Go 语言中的 slice

用户头像
麻瓜镇
关注
发布于: 2020 年 05 月 06 日

开篇

大多数时候我们都忘记了或者压根不知道slice是怎么工作的。大多数时候我们只是把slice当做动态数组来用。通过这篇翻译的文章,重新认识slice,我们可以一定程度上避免掉入slice的陷阱,并且更好的使用它。

参考资料有:

Effective Go

Go Slices: usage and internal

本文重点是代码例子,边动手边学习

什么是数组

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
in := [5]int{10, 20, 30}
fmt.Printf("contents > in:%v\n", in)
// Output:
// contents > in:[10 20 30 0 0]

上面的x、y、z类型相同,可以比较。element的顺序相同就相等。

a := [4]int{1, 2, 3}
fmt.Printf("x == a: %v\n", x == a) // false

上面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]

https://golang.org/ref/spec#The_zero_value

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{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]
}

如果我们想要函数能够修改它的参数的值,我们应该把数组的地址传进去,当然,函数的参数也应该变为数组的指针。

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不会相互影响

对切片进行切片

先看下面一段程序

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。

清空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才会释放。

理论上这个并不算『内存泄露』,因为那段内存确实还在使用,只不过只是一小部分。

这个目前没有好用的静态分析工具。

参考资料

  1. https://golang.org/doc/effective_go.html

  2. https://blog.golang.org/go-slices-usage-and-internals

用户头像

麻瓜镇

关注

还未添加个人签名 2017.12.29 加入

还未添加个人简介

评论

发布
暂无评论
重新认识Go语言中的slice