发现问题

首先说明go中只有值传递,这里的引用传递只是一个相对概念。

问题起源于新生群的讨论,大家说到 go 时有人问 slice 是不是指针传递,我说是,有位学长说他曾经写过一段和 slice 有关的代码,很怪,怀疑不是引用传递。故事就这样开始了。

代码如下:

func main() {
    sliceA := []int{1, 2, 3, 4, 5}
    fmt.Println(sliceA)
    changeSlice(sliceA)
    fmt.Println(sliceA)
}
func changeSlice(slicePass []int) {
    slicePass = slicePass[0:3]
}
// Output
/*
[1 2 3 4 5]
[1 2 3 4 5]
*/

看完这串代码我也惊了,在我曾经的想法里,slice 是会变的,但在这里却还是原样输出。我怀疑是 slice 的问题,于是上网搜寻信息,原来已经有人碰到同样的坑了。

过程有点艰难,看到一些说是 slice 扩容新指针导致的,又有一些说是 len 未被改变导致的,于是研究了一会儿才整理起来。

一些思考

main 中的 slice 没变化有两种情况

cap 够,slice 没扩容

slice 只是引用类型而非“完美”的引用传递。slice 本身是一个结构体

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

底层数组的确用指针表示,但是 slice 中的 lencap 却是 int 类型。

也就是说,len cap 是值传递而底层数组是引用传递,这就导致函数内改变了底层数组却没有改变 len 。所以 len 仍为5,那么在 main 的输出中依旧输出了5个,且由于数组内存连续,输出的第五个数就是原来的5,也就是说输出了已经不属于 slice 的第五个数。

例如下面:

func main() {
    sliceA := []int{1, 2, 3, 4, 5}
    fmt.Println(sliceA)
    fmt.Printf("%d %p pass\n", len(sliceA), sliceA)
    changeSlice(sliceA)
    fmt.Println(sliceA)
    fmt.Printf("%d %p main\n", len(sliceA), sliceA)
}
func changeSlice(slicePass []int) {
    slicePass = slicePass[2:3]
    slicePass[0] = 100
}

//Output
/*
[1 2 3 4 5]
5 0xc00000c690 pass
[1 2 100 4 5]
5 0xc00000c690 main
*/

截取的操作没有改变底层数组,且不需要扩容。所以可以看到切片中的值是被改变了,但是截取后的slicePass的指针header没有变, main 中的 lencap 也没有变。 最终读取的内存范围没有变化,依旧把之前的 1 2 4 5都读出来了。

再看下面这个例子:

func main() {
    sliceA := make([]int, 3, 4)
    sliceA[0] = 0
    sliceA[1] = 1
    sliceA[2] = 2
    fmt.Println(sliceA)
    changeSlice(sliceA)
    fmt.Println(sliceA)
    fmt.Println(sliceA[:4]) //强制输出第四项
}
func changeSlice(slicePass []int) {
    slicePass = append(slicePass, 3)
}

//Output
/*
[0 1 2]
[0 1 2]
[0 1 2 3]
*/

我们指定了一个 len 为3,cap 为4的 sliceappend 完后发现正常输出只会输出前三个数,这就验证了 len 并没有被改变。而当我们强制输出第四项时又发现3是存在的。

也就是说这种情况下 append 对原数组生效,只是由于 len 没有改变而无法呈现append 的项。

cap 不够, slice 扩容

func main() {
    sliceA := []int{1, 2, 3, 4, 5}
    fmt.Println(sliceA)
        fmt.Printf("%d %p main\n", len(sliceA),sliceA)
    changeSliceA(sliceA)
    fmt.Println(sliceA)
}
func changeSliceA(slicePass []int) {
    slicePass = append(slicePass, 6)
    fmt.Printf("%d %p pass\n", len(slicePass),slicePass)
}

// Output
/*
[1 2 3 4 5]
5 0xc00000c690 main
6 0xc000016550 pass
[1 2 3 4 5]
*/

这个例子中我们还是 append 了一个6,但这个例子和上面不同。这个例子中的 cap = len ,当我们 append 时会造成 slice 扩容。

当发生 growslice 时,会给 slice 重新分配一段更大的内存,然后把原来的数据 copy 过去,把 slicearray 指针指向新内存。

所以 slicePass 的指针已经和原来不同,那么这个 append 的新数自然无法在原数组中呈现,而且在原数组中不存在

回到问题

再看学长的代码,可以看出这属于第一种情况, slice 没有扩容,前后指针相同。而且这个片段非常巧合,函数中只是做个了截取的动作而没有改变任何值,本质来讲数据没变动。那么由于len 没改变,输出也就和之前一模一样了,造成了 slice 是值传递的假象(当然 go 中本质上只有值传递)。

解决方案

其实也很简单,为了 lencap 都能变化,我们将切片的指针传入,那么所有属性也就跟着变化了。

func main() {
    sliceA := []int{1, 2, 3, 4, 5}
    fmt.Println(sliceA)
    changeSlice(&sliceA)
    fmt.Println(sliceA)
    fmt.Printf("%d main\n", len(sliceA))
}
func changeSlice(slicePass *[]int) {
    *slicePass = append(*slicePass, 6)
    fmt.Printf("%d pass\n", len(*slicePass))
}

//Output
/*
[1 2 3 4 5]
6 pass
[1 2 3 4 5 6]
6 main
*/

可以看到 len 被改变了,那么6加入了且也能正常被读取输出了。

总结

go 中的 slice 在函数中被 append 时数据呈现不变分为两种情况。

一种是 len 未被改变,由传值导致;

一种是指针发生改变,由 slice 的内部扩容实现导致。

参考

最后修改:2021 年 07 月 16 日 09 : 19 PM