Go语言中的slice传参 | 青训营笔记

78 阅读3分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 1 天,记录一下在学习Go语言中遇到的slice传参的相关问题。

起因

在学习完函数和slice之后,我想把所学的知识实践一下,于是写出了下面的代码。代码很简单,想要实现的功能是向slice的末尾添加一个元素。可这段代码的运行结果却与期望不符。

 func appendIntSlice(a []int) {
     a = append(a, 123)
 }
 ​
 func main() {
     a := make([]int, 1)
     a[0] = 1
     appendIntSlice(a)
     fmt.Println(a)
 }
 ​
 // output
 // [1]
思考

看到代码的输出,我陷入了思考。Slice 是引用类型,传递参数时其实是传递的指针,那为什么在函数内对 slice 进行修改后并没有影响到主函数的 slice 呢?

我猜想问题在 append 函数上。Slice 的底层是数组,在进行 append 时如果数组容量不够,则需要扩容,开辟一块新的内存空间,那么这块内存空间的地址跟之前的地址一定是不相同的,而在函数内是无法改变传入的 slice 的地址的。

为了验证这个猜想,我进行了以下实验:在初始化 slice 时就给出足够的容量,保证 append 时无需扩容,如果本次修改影响到了主函数中的 slice,结合之前的现象,就可以证明猜想是正确的。

实验代码及结果如下,输出结果显示猜想似乎并不正确。

 func appendIntSlice(a []int) {
     a = append(a, 123)
 }
 ​
 func main() {
     a := make([]int, 1, 100) // 给出足够的容量,保证 append 时无需扩容
     a[0] = 1
     appendIntSlice(a)
     fmt.Println(a)
 }
 ​
 // output
 // [1]
查阅资料

我查阅相关资料,得知了 slice 的底层数据结构,这似乎就可以解释上面的现象了。

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

当slice作为参数传递时,函数中会创建 slice 的副本,副本同样包含 array、len、cap 三个成员,而修改副本中的 len 和 cap,对原 slice 是没有任何影响的。因此,就算是在不改变 array 地址的情况下添加了一个元素,原slice的 len 还是不变的,这就给人带来了原 slice 没有改变的错觉。

于是我改进了上面的实验,代码及结果如下。可以看到原 slice 的底层数组已经发生了改变,但由于 len 并没有改变,slice 也就不会感知到新的元素。

 func appendIntSlice(a []int) {
     a = append(a, 123)
 }
 ​
 func main() {
     s := [2]int{0} // 初始化 slice 底层数组,保证 append 时不会扩容
     a := s[0:1]
     appendIntSlice(a)
     fmt.Printf("%v, len=%d\n", a, len(a))
     fmt.Println(s)
 }
 ​
 // output
 // [0], len=1
 // [0 123]
总结
  1. Slice 类型是一个包含 array、len、cap 三个成员的结构体,其中 array 是一个指针,指向底层数组;
  2. 当 slice 作为函数参数传递时,函数中会创建一个 slice 的副本,包含 array、len、cap 三个成员,修改副本的 len 和 cap 不会影响到原 slice;
  3. 如果副本由于扩容等原因重新分配了底层数组的内存地址,即 array 指针发生改变,不会影响原 slice。
功能实现

综上,要实现最开始的功能,则需要向函数中传递一个 slice 的指针,代码以及结果如下。

 func appendIntSlice(a *[]int) {
     *a = append(*a, 123)
 }
 ​
 func main() {
     a := make([]int, 1)
     a[0] = 1
     appendIntSlice(&a)
     fmt.Println(a)
 }
 ​
 // output
 // [1 123]