这是我参与「第五届青训营 」伴学笔记创作活动的第 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]
总结
- Slice 类型是一个包含 array、len、cap 三个成员的结构体,其中 array 是一个指针,指向底层数组;
- 当 slice 作为函数参数传递时,函数中会创建一个 slice 的副本,包含 array、len、cap 三个成员,修改副本的 len 和 cap 不会影响到原 slice;
- 如果副本由于扩容等原因重新分配了底层数组的内存地址,即 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]