在go的循环流程控制中,除了经典的for三段式循环以外,go还引入了range关键字帮助我们快速遍历slice,map,chan
等结构。本文将介绍for range循环中的一个坑,出现该问题的原因,及解决方法
假设从数据库查出一堆学生数据,用struct slice (非指针)接收
// 学生dal对象
type Stu struct {
Name string
}
// 模拟从db查询学生数据,这里信息只有name
// 注意Stu为非指针类型
func GetStuFromDB() []Stu {
return []Stu{
{
Name: "jerry",
},
{
Name: "tom",
},
}
}
上层需要将该数据转换成DTO返回给调用方
func GetStu() []*StuDTO {
data := GetStuFromDB()
res := []*StuDTO{}
for _, v := range data {
res = append(res, &StuDTO{
Name: &v.Name,
})
}
return res
}
StuDTO结构和Stu不同的是,Name为指针类型
type StuDTO struct {
Name *string
}
下面对结果进行测试,程序预期是,两个Name分别为"jerry",“tom”
但神奇的事情发生了,结果中两个StuDTO.Name
都为"tom"
func main() {
stus := GetStu()
for _, v := range stus {
fmt.Println(*v.Name)
}
}
// 控制台输出:
tom
tom
可以看出,两个StuDTO中的Name都被赋值成了range遍历中最后一个Stu的Name
我们看看编译for i,v := range
代码编译后的结果,
这种遍历方式会在cmd/compile/internal/gc.walkrange
进行处理:
ha := a
hv1 := temp(types.Types[TINT])
hn := temp(types.Types[TINT])
init = append(init, nod(OAS, hv1, nil))
init = append(init, nod(OAS, hn, nod(OLEN, ha, nil)))
n.Left = nod(OLT, hv1, hn)
n.Right = nod(OAS, hv1, nod(OADD, hv1, nodintconst(1)))
tmp := nod(OINDEX, ha, hv1)
tmp.SetBounded(true)
a := nod(OAS2, nil, nil)
a.List.Set2(v1, v2)
a.Rlist.Set2(hv1, tmp)
最终代码会变成如下形式:
// a为原始slice
ha := a
hv1 := 0
// slice长度
hn := len(a)
v1 := 0
v2 := nil // for i,v := range 中的 v
for ; h1 < hn ; h1++ {
tmp := ha[hv1]
v1,v2 := hv1,tmp
}
可以看出,for range中,go语言会额外创建一个新的 v2
变量存储切片中的元素,循环中使用的这个变量 v2 会在每一次迭代被重新赋值而覆盖,赋值时也会触发拷贝, 且循环中每次都使用的v2变量
在之前的代码中:
for _, v := range data {
res = append(res, &StuDTO{
Name: &v.Name,
})
}
由于v是结构体,且v的地址在循环过程中都没变,则v.Name
的地址也没变,则对v.Name
取地址的话,&v.Name
永远指向同一个地址
第一次循环,res[0].Name
保存了&v.Name
的地址,地址指向的内容为jerry
第二次循环,res[1].Name
也保存了&v.Name
的地址,但由于v
被覆盖为第二个Stu
,其地址指向的内容变为tom。由于被覆盖,res[0].Name
的值也变为了tom,就出现了上面的测试结果
要解决这个问题,有以下几种方法
v
及v
的属性的地址,用一个辅助函数提取func GetStu() []*StuDTO {
data := GetStuFromDB()
res := []*StuDTO{}
for _, v := range data {
res = append(res, &StuDTO{
Name: StringPtr(v.Name),
})
}
return res
}
func StringPtr(v string) *string {
return &v
}
这里用StringPtr
工具方法对v
取地址并返回,会使得v
的地址溢出到堆上,而这个地址不再和for rang
中的v
有任何关联,可以放心使用
func GetStuFromDB() []*Stu {
return []*Stu{
{
Name: "jerry",
},
{
Name: "tom",
},
}
}
func GetStu() []*StuDTO {
data := GetStuFromDB()
res := []*StuDTO{}
for _, v := range data {
res = append(res, &StuDTO{
Name: &v.Name,
})
}
return res
}
这次for range
循环中,v
是指针,v.Name
会指向v的地址对应的内存块上Stu
的Name
,每次循环中变量v
还是一样,但v
指向的地址不一样,v.Name
也在不同的位置,因此在结果集中保存的&v.Name
也就不会互相影响
如果这样改动,使用到该dal方法的上层可能需要调整
可以在返回业务数据时尽量用slice指针类型,但遇到简单类型slice的遍历,例如[]int
,则不能使用这种方式解决
res := []*StuDTO{}
for i, _ := range data {
res = append(res, &StuDTO{
Name: &data[i].Name,
})
}
这里用data[i]
取代 v
用于在for循环中对元素的使用。同理,因为每个data[i]
的地址不同,在结果集中保存的 &data[i].Name
也就不会互相影响
使用for i,v := range遍历值类型时,其中的v变量是一个值的拷贝,当使用&获取指针时,实际上是获取到v这个临时变量的地址,而v变量在for range中只会创建一次,之后循环中会被一直重复使用,若使用该变量的地址,会造成不可预知的问题
go官方也将该问题记录到了常见错误中
https://draveness.me/golang/docs/part2-foundation/ch05-keyword/golang-for-range/