GO 函数切片参数陷阱

824 阅读4分钟

最近在写go的时候遇到了一个bug, 简化的代码如下

package main  import "fmt"  func testSlice(innerslice []int) {      // innerslice = append(innerslice, 3)      innerslice[0]=4      innerslice[1]=5      fmt.Println(innerslice)  }  func main() {      outslice := []int{1,2}      testSlice(outslice)      fmt.Println(outslice)  }

原本我的本意数通过传递切片参数给函数修改切片,预期输出均为[4 5 3][4 5 3], 但是实际输出[4 5 3][2,1] ,也就是说函数并没有修改我传递的切片,接下来我们会来谈论一下这是为什么。

切片的底层数据结构

切片和数组很像,数组在声明时必须指定长度(数组长度是类型的一部分,不同长度的数组是属于不同类型的,比如[1]int{} 和[2]int{} 虽然都是int数组但是由于长度不同是两种类型),切片不需要(切片的长度是动态的)。

var [10]string{}   // 声明一个长度为10的string数组
var []string{}     // 声明一个string切片

实际上切片在底层确实引用一个数组对象,一个切片由三个部分构成:指针、长度和容量,是的,切片本身只有这些,它存储的数据有指针指向的数组维护。长度对应切片中元素的数目;长度不能超过容量,容量一般是从切片的开始位置到底层数据的结尾位置。内置的len和cap函数分别返回slice的长度和容量。

内存布局如下,上方是数组,下面是两个切片(同一个数组可以被多个切片引用,而且引用位置不必是数组的第一个元素)

切片参数是如何传递给函数的

在go中,函数是依靠值传递参数的,也就是说当我们传递一个切片时,其实是传递的一份原来值的copy,不过尽管是copy,copy中data部分的指针指向的地址还是一样的,所以通过copy中的data指针修改底层数组数据,还是会反映到原来切片。

如图,当切片s1被传递给函数时,go会生成一份s1的copy s2传入给函数,真正被函数使用的是s2,只不过s1和s2引用的是同一个数组, 故在函数中修改s1的同时也会影响到s2

package main  import "fmt"  func testSlice(innerslice []int) {      // innerslice = append(innerslice, 3)      innerslice[0]=4      innerslice[1]=5      fmt.Println(innerslice)  }  func main() {      outslice := []int{1,2}      testSlice(outslice)      fmt.Println(outslice)  }

这段代码最终打印[4 5][4 5],符合我们的预期。

问题出在哪

哎,既然传递切片函数内部是可以修改函数外部切片的, 那为什么本文开头的第一个例子没有生效?其实观察代码发现了两者不同之处。

innerslice = append(innerslice, 3)

append扩容

内置函数append函数用于向切片追加元素:

innerslice := []int{1,2}
innerslice = append(innerslice, 3)
fmt.Println(innerslice)

结果打印 [1,2,3]

陷阱就隐藏在这里,append函数先检测切片底层数组是否有足够的容量来保存新添加的元素。如果有足够空间的话,直接扩展切片(依然在原有的底层数组之上),将新添加的元素复制到新扩展的空间,并返回切片。因此,切片在经过append前后还是引用的同一个数组。

然而没有足够的增长空间的话,append函数则会先分配一个足够大的数组用于保存新的结果,先将原数组数据复制到新的空间,然后添加新的数据。结果切片在append之后引用的是不同的底层数组!

情况一(不需要扩容):底层数组空间足够,append直接在原有数组基础上进行添加

情况二(需要扩容):底层数组空间不够,append会先创建一个更大的数组,然后copy数据。

答案揭晓

本文第一个例子产生了预期意外的结果是因为在函数内部我们调用了append函数,此时切片引用的底层数组长度为2,也就是说最多只能包含2个元素,调用append之后切片扩容,改变了引用的数组,导致函数内部切片和函数外部切片的底层引用数组分离。所以无论我们怎么修改扩容后的函数内部切片都不会影响外部的切片了。

如何避免

  • 传递切片指针。当我们传递切片指针时,函数内部的切片和函数外部的切片都是同一个切片,无论怎么扩容怎么切换底层数组,都是可以反映到函数外部的。
  • 返回修改后的切片。仿照append函数,在函数最后return修改后的切片,将外部切片重新赋值为内部修改后的切片。