重新认识Go的Slice

麻瓜镇 · · 443 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

开篇语

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

参考资料有:

  1. Effective Go
  2. 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

上面的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才会释放。

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

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

参考资料

  1. https://golang.org/doc/effective_go.html
  2. https://blog.golang.org/go-slices-usage-and-internals

有疑问加站长微信联系(非本文作者)

本文来自:简书

感谢作者:麻瓜镇

查看原文:重新认识Go的Slice

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

443 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传