美文网首页
重新认识Golang的Slice

重新认识Golang的Slice

作者: 麻瓜镇 | 来源:发表于2020-03-23 18:02 被阅读0次

    开篇语

    大多数时候我们都忘记了或者压根不知道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

    相关文章

      网友评论

          本文标题:重新认识Golang的Slice

          本文链接:https://www.haomeiwen.com/subject/yutgyhtx.html