Go 是按值传递的为什么可以修改 slice 的值

GO 刘宇帅 4年前 阅读量: 2209

原文地址:Why are slices sometimes altered when passed by value in Go?

在我的上篇博客中我们讨论了arrayslice 的一些不同之处。换句话说,我们讨论了为什么slice 既有长度也有容量,但是array只有长度。我们也简短地介绍了slice是如何使用的array的底层数据结构的。如果你对这些比较陌生,我建议你去看下上篇文章。

这篇文章里我们继续讨论这个话题关于slice的设计原理是如何影响你的代码的及一些介绍一些意料之外的bug。明确的说,我们就是回答一个问题:

Go 中 slice 是按值传递的,但是为什么有时候可以修改 slice 的值?

老实说,这个答案并不只是针对slice,但是人们在slice上经常会遇到这种情况,所以以slice为例。

我想要验证这个问题最好的方法就是用一个简单的测验。下面有三个例子,我希望你能够都去测试下,结果绝对出乎你的意料。

测验1

下面的代码会输出什么?

func main() {
    var s []int
    for i := 1; i <= 3; i++ {
        s = append(s, i)
    }
    reverse(s)
    fmt.Println(s)
}

func reverse(s []int) {
    for i, j := 0, len(s) - 1; i < j; i++ {
        j = len(s) - (i + 1)
        s[i], s[j] = s[j],s[i]
    }
}

为什么s是按值传递的但是值却被改变了呢?

我们前面已经讨论了slice是一个指向数组的指针,这就是我们传递的slice虽然是按值传递的,但是slice指向的数组还是原来数组的地址。这就是为什么我们对不同的slice调用了reverse()但是他们都指向同一个数组地址,所以修改会影响到原来的slice的内容(因为本来就是同一块内存地址),也就是说我们做的重排结果是持久性的影响。

测验2

我们对我们的代码做一个小小的修改,在reverse()函数里添加一个append调用。这会对输出有什么影响呢?

func main() {
    var s []int
    for i := 1; i <= 3; i++ {
        s = append(s, i)
    }
    reverse(s)
    fmt.Println(s)
}

func reverse(s []int) {
    s = append(s, 999)
    for i,j := 0; len(s) - 1; i++ {
        j = len(s) - (i + 1)
        s[i],s[j] = s[j],s[i]
    }
}

我们打印的s的顺序是反转之后的,但是为什么1没有了呢?

我们调用了append之后会创建一个slice,新的slice有新的length属性所以不是同一个指针,但是他们仍然指向同一个数组。因此,后面的重排代码是重排的同一个数组,但是原来的slice的长度并没有变。

测验3

终极测验,我们同样去做个小的改变,我们在reverse()函数里多加几个数据到slice

func main() {
    var s []int
    for i := 1; i<= 3; i++ {
        s = append(s, i)
    }
    reverse(s)
    fmt.Println(s)
}

func reverse(s []int) {
    s = append(s, 999, 1000, 1001)
    for i,j := 0, len(s) - 1; i < j; i++ {
        j = len(s) - (i + 1)
        s[i],s[j] = s[j],s[i]
    }
}

在我们的终极测试里,既没有影响slice的长度,也没有影响它的顺序,为什么呢?

我上面说了,当我们调用append的时候会创建一个新的slice,在第二个测试里新的slice仍旧指向同一个数组是因为底层数组有足够的容量放新的数据,所以数组本身没有被改变,但是在这个例子里我们往里面新增了3个元素并且slice没有足够的空间了,所以一个新的数组别创建,而新的slice也指向了这个新的数组。所以我们在后面重排数组的时候是在一个新的数组里,并不会影响原来的数组内容。

使用cap函数验证变化

我们可以在reverse()函数调用cap函数来检验slice的容量变化。

func reverse(s []int) {
    newElem := 999
    for len(s) < cap(s) {
        fmt.Println("Adding an element:", newElem, "cap:", cap(s), "len:", len(s))
        s = append(s, newElem)
        newElem++
    }
    for i, j := 0, len(s) - 1; i < j; i++ {
        j = len(s) - (i + 1)
        s[i], s[j] = s[j], s[i]
    }
}

只要我们不超过slice的容量,那么我就可以在main函数看到slicereverse()函数中的调整。我们可以看到长度没有被改变,但是对底层数组的重排是影响到了slice的。

如果我们在s上调用append导致数据数量达到slice的容量大小,那么我们就不能在main()函数里看到在重排函数里做的重排,因为重排是在一个完全不同的数组中做的重排。

从slice或则array切片为新的slice也会影响到原始变量的值

如果我们从已有的slicearray中切片出新的slice,那么如果我们去修改新的slice的值也会影响到原始变量的值。例如,如果你使用s2 := s[:]生成新的变量s2,并把其传递给reverse()函数那么这也会影响到s的值,因为两者其实还是指向了同一个数组。同样的,如果我们往s2里新增数据,那么如果导致了slice扩容,那么重排是不会影响到原始数据的。

严格上来说这并不是什么坏事,直到必须的时候我们才去操作底层的数组这会让我们的程序更加高效,但是这也需要我们花费更多点的精力去编码。

不仅仅slices有这样的表现

不仅仅只有slices会有这样的表现,slice是我们最容易犯错的数据类型,因为我们不知道他底层的数据结构是什么,但是任何指针类型的数据类型都可能受影响。下面是演示代码。

type A struct {
    Ptr1 *B
    Ptr2 *B
    Val B
}
type B struct {
    Str string
}

func main() {
    a := A{
        Ptr1: &B{"ptr-str-1"},
        Ptr2: &B{"ptr-str-2"},
        Val: B{"val-str"},
    }

    fmt.Println(a.Ptr1)
    fmt.Println(a.Ptr2)
    fmt.Println(a.Val)

    demo(a)

    fmt.Println(a.Ptr1)
    fmt.Println(a.Ptr2)
    fmt.Println(a.Val)
}

func demo(a A) {
    a.Ptr1.Str = "new-ptr-str1"
    a.Ptr2 = &B{"new-ptr-str2"}
    a.Val.Str = "new-val-str"
}

和这个例子一样,slice的定义如下:

type slice struct {
    array unsafe.Pointer
    len int
    cap int
}

注意这个array字段实际上是一个指针?所以slice最终表现就像一个内嵌的指针一样,并且并没有什么特殊的。只是又很少人会去关注slice底层的实现而已。

对于Go开发者而言这意味着什么?

最终的意义是开发者必须明确知道自己传递给函数的具体数据类型和函数会造成什么样的影响。当你把slice传递给函数或者方法的时候你应该意思到你可能修改原始slice里的数据。你可以不用关心slice的长度和容量改变,但是底层的数据可能会变。

同样的,你要知道结构体里包含的指针类型的字段也存在同样的问题。任何多指针类型指向数据的修改都会影响到原始数据,除非指针被指向了其他的对象。

这篇文章主要目的就是我希望你花一点时间能够理解这些问题背后发生的原理,如果因为不了解原理而发生bug,那么是很难调试的,但是简单的研究一下就变得比较清晰了如何解决这些问题或则未来更好的测试。

提示

功能待开通!