详解go语言中for range的坑

前言

在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

分析

go源码

可以看出,两个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,就出现了上面的测试结果

解决

要解决这个问题,有以下几种方法

  1. 不要直接取vv的属性的地址,用一个辅助函数提取
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有任何关联,可以放心使用

  1. GetStuFromDB返回指针slice,而非结构体slice


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的地址对应的内存块上StuName,每次循环中变量v还是一样,但v指向的地址不一样,v.Name也在不同的位置,因此在结果集中保存的&v.Name也就不会互相影响

如果这样改动,使用到该dal方法的上层可能需要调整

可以在返回业务数据时尽量用slice指针类型,但遇到简单类型slice的遍历,例如[]int,则不能使用这种方式解决

  1. 使用data[i] 取代 v
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/

你可能感兴趣的:(go)