Go 1.21 的 slice 使用问题

最近在研究 CoreDNS 插件,但个人之前又没有 Go 语言基础,于是一边学习一边编写插件。可以说只用了 20% 的时间就摸清楚了 Go 语言 80% 的内容。但剩下的 20% 还没掌握,或者说是深刻理解。这不,遇到了个 for slice 的问题,于是记录下相关的解决办法和原理。

0x01 问题背景

我的CoreDNS插件需要实现从服务器拉取一系列数据,编码方式使用JSON。其中有数据是以JSON数组的方式返回的,但是在运行单元测试时,却出现了与预期不同的情况,所有的列表数据居然都变成了列表末尾的!我在 Go Playground 简单复现了这个问题(https://go.dev/play/p/nVtJGbByoDC?v=goprev):

代码:

package main

import "fmt"

type Top struct {
	Name string
	Nest Nested
}

type Nested struct {
	SubName string
}

func main() {
	top := []Top{
		{"A", Nested{"AA"}},
		{"B", Nested{"BB"}},
		{"C", Nested{"CC"}},
	}

	copy1 := make([]*Top, 0)
	copy2 := make([]*Top, 0)

	for i, obj := range top {
		fmt.Printf("%p\n", &obj)
		fmt.Printf("%p\n", &(obj.Nest))
		fmt.Printf("%p\n", &(top[i].Nest))
		fmt.Printf("TopName: %s, SubName: %s\n", obj.Name, obj.Nest.SubName)

		copy1 = append(copy1, &obj)
		copy2 = append(copy2, &top[i])
	}

	fmt.Println(copy1[0])
	fmt.Println(copy1[1])
	fmt.Println(copy1[2])
	fmt.Println(copy2[0])
	fmt.Println(copy2[1])
	fmt.Println(copy2[2])

}

选择 Go 1.21 版本运行,结果如下:

0xc00009a000
0xc00009a010
0xc000094130
TopName: A, SubName: AA
0xc00009a000
0xc00009a010
0xc000094150
TopName: B, SubName: BB
0xc00009a000
0xc00009a010
0xc000094170
TopName: C, SubName: CC
&{C {CC}}
&{C {CC}}
&{C {CC}}
&{A {AA}}
&{B {BB}}
&{C {CC}}

Program exited.

通过观察,其造成的原因显而易见:每次迭代时,实际上迭代变量 obj 都共享了同样的指针。所以每次复制到 copy1 时,复制的都是相同的指针值。所以每次迭代,指针指向的值被覆盖,导致整个数组都是最后的值。

0x02 解决方法

最简单的解决方法如下,只需在 for 中添加一行代码: obj := obj

	for i, obj := range top {
                fmt.Printf("%p\n", &obj)
		obj := obj
		fmt.Printf("%p\n", &obj)
		fmt.Printf("%p\n", &(obj.Nest))
		fmt.Printf("%p\n", &(top[i].Nest))
		fmt.Printf("TopName: %s, SubName: %s\n", obj.Name, obj.Nest.SubName)

		copy1 = append(copy1, &obj)
		copy2 = append(copy2, &top[i])
	}

为了方便对比,加了一行打印 obj 的语句。再次运行结果如下:

0xc000090020
0xc000090040
0xc000090050
0xc000092190
TopName: A, SubName: AA
0xc000090020
0xc000090060
0xc000090070
0xc0000921b0
TopName: B, SubName: BB
0xc000090020
0xc000090080
0xc000090090
0xc0000921d0
TopName: C, SubName: CC
&{A {AA}}
&{B {BB}}
&{C {CC}}
&{A {AA}}
&{B {BB}}
&{C {CC}}

Program exited.

0x03 原理

在执行 obj := obj 时,就是进行了一次浅复制,将 slice 中每次迭代共享的内容复制了一份,保存到新变量 obj 中。新变量 obj 有自己的内存地址,于是就不受 slice 继续迭代的影响了。

附:Go 1.22 的表现

最开头的代码,选择 Go 1.22 版本运行,结果如下:

0xc000068020
0xc000068030
0xc00006a190
TopName: A, SubName: AA
0xc000068040
0xc000068050
0xc00006a1b0
TopName: B, SubName: BB
0xc000068060
0xc000068070
0xc00006a1d0
TopName: C, SubName: CC
&{A {AA}}
&{B {BB}}
&{C {CC}}
&{A {AA}}
&{B {BB}}
&{C {CC}}

Program exited.

可见最新版已经没有这个问题了,每次迭代都会为迭代变量开辟新的内存空间。

参考文档

[1] Use Pointer of for Range Loop Variable in Go
https://medium.com/swlh/use-pointer-of-for-range-loop-variable-in-go-3d3481f7ffc9

发表评论