Golang 中 append 的一些坑

697 阅读4分钟

第一版代码

package main

import "fmt"

func main() {
	list := make([]int, 0)

	testList(list)

	fmt.Println(list)
}

func testList(list []int) {
	for i := 0; i < 10; i++ {
            list = append(list, i)
	}
}

先看第一段代码,在很直观的理解中,main 函数中的打印应该是 0 到 9 的一串数字。 但是我们运行之后就会发现打印的结果是空。

image.png

现在来解释一下上面的代码为啥错误。

  1. main 函数中使用 make 初始化的切片的长度和容量都是 0,所以在 testList 函数中 append 的时候就会发生扩容,扩容时候就会重新申请一片内存空间。然后切片重新指向这片新的内存空间。

  2. golang 中函数的穿参都是值传递。意思就是 testList 内的 list 参数其实是 main 函数中 list 的副本。

基于以上两点,我们就发现了。由于 append 的扩容为切片重新申请了一个新的内存空间。testList 函数修改了副本 list 内的指针指向。但是 main 函数内的并没有修改,指向的仍然是旧的数组地址。

所以最终的打印结果是空。

第二版代码

package main

import "fmt"

func main() {
	list := make([]int, 0, 100)

	testList(list)

	fmt.Println(list)
}

func testList(list []int) {
	for i := 0; i < 10; i++ {
		list = append(list, i)
	}
}

基于第一版中的错误,我初始化的时候将切片容量直接设置为 100,循环 10 次 append 肯定不会扩容了。

image.png

再次运行输出结果还是为空。

现在来解释一下第二版的代码为啥还是错误。

虽然不会扩容了。main 函数中的 list 与 testList 函数中的 list 虽然都指向原始数组。且 testList 函数也确实修改了原始数组。

但是,上方代码并没有解决值传递副本的问题。

切片的结构体为

type slice struct {
	array unsafe.Pointer // 原始数组指针。数据存储在这个指针指向的内存中
	len   int
	cap   int
}

所以在函数传递参数为切片时,会对整个切片进行复制,所以复制的不仅仅是指针,还有 len 和 cap。

在 testList 函数中 append 是对副本进行了修改。main 函数中的 list 虽然指针仍然指向原始数组,但是 len 和 cap 并没有修改。

所以打印的时候仍然为空。

原始数组修改验证

package main

import (
	"fmt"
	"reflect"
	"unsafe"
)

func main() {
	list := make([]int, 0, 100)

	testList(list)
        // 直接打印切片内容
	fmt.Println(list)

	// 获取切片头的指针
	slicePtr := (*reflect.SliceHeader)(unsafe.Pointer(&list))
        fmt.Println(slicePtr)
	// 通过切片指针获取,原始数组
	arrayPtr := (*[10]int)(unsafe.Pointer(slicePtr.Data))
	// 打印数组内容
	fmt.Println(*arrayPtr)
}

func testList(list []int) {
        // 获取切片头的指针
	slicePtr := (*reflect.SliceHeader)(unsafe.Pointer(&list))
        fmt.Println(slicePtr)
        
        for i := 0; i < 10; i++ {
            list = append(list, i)
        }
}

以上代码直接打印切片内容为空。通过 unsafe 包接口获取到的原始数组内容则是 [0 1 2 3 4 5 6 7 8 9]。 由此可以证明 testList 函数确实修改了原始数组,但是由于值传递的参数复制。testList 函数内部修改的是 list 的副本,并未修改 main 函数中的 list。

还有就是 testList 函数打印 slicePtr 与 main 函数中打印 slicePtr 的,原始数组指针一致。也可以证明。

正确修改第一版

package main

import "fmt"

func main() {
	list := make([]int, 0)

	list = testList(list)

	fmt.Println(list)
}

func testList(list []int) []int {
	for i := 0; i < 10; i++ {
		list = append(list, i)
	}

	return list
}

testList 函数修改完成之后,将 list 返回,main 函数重新赋值。

正确修改第二版

package main

import "fmt"

func main() {
	list := make([]int, 100)

	testList(list)

	fmt.Println(list)
}

func testList(list []int) {
	for i := 0; i < 10; i++ {
		list[i] = i
	}

}

初始化时,设置好容量。然后 testList 不再使用 append 而是直接使用索引进行修改。

正确修改第三版

package main

import "fmt"

func main() {
	list := make([]int, 0)

	testList(&list)

	fmt.Println(list)
}

func testList(list *[]int) {
	for i := 0; i < 10; i++ {
		*list = append(*list, i)
	}

}

testList 函数改为指针参数,即使是值传递,参数发生复制,复制的也仅仅是 main list 的指针。而不是像之前一样需要复制切片的头结构。

对比三个正确版本的代码

  • 第一个版本,将结果返回。这个方法的缺点就是 testList 函数的结果需要压入 main 函数栈内。
  • 第二个版本,需要初始化的时候设置好容量,否则容易数组越界。
  • 第三个版本,使用指针,比较直观且代码比较简单。