图解Go中for-range原理

249 阅读4分钟

从一道简单的例子开始

package main

import (
    "fmt"
)

func main() {
    dataFromDb := []int{1, 2, 3} //从数据库取出来的数据
    var finalData []*int //目标数据

    for _, v := range dataFromDb {
        finalData = append(finalData, &v)
    }
    for _, final := range finalData {
        fmt.Println(*final)
    }
}

相信大家会脱口而出最后finalData的值是 1,2,3,但是我们实际运行一下,结果输出的却是:

3
3
3

为什么会这样呢?如果想弄清这个原理,首先我们得知道for range到底干了什么

for-range 原理

想了解一个函数的原理,最好的方式就是看源码,编译器对 for range 表达式的解析如下

// a为原始slice
ha := a // 拷贝原有的slice结构。意味着len是不变的,在循环中,不受后续slice长度变化的影响
h1 := 0

// slice长度
hn := len(a)

v1 := 0 // for i,v := range 中的 i
v2 := nil // for i,v := range 中的 v

for ; h1 < hn ; h1++ {
    tmp := ha[h1]
    v1,v2 = h1,tmp // ----- 赋值到 v1,v2 变量 ----
    
    // ... 返回到外部执行代码中...

    // ...跳回该底层代码处,继续执行该for循环...
    
}

for k,v := range slice中:

  1. 会拷贝原有的slice结构,意味着len是不变的。在循环中,不受后续slice长度变化的影响。
  2. go语言会额外创建一个新的 v2 变量存储切片中的元素值

注意!不但整个原有 slice 结构会被拷贝,就连切片内的元素是会被拷贝出来,然后赋值给 v2

v2 由始至终是一个 for 外的变量对象,而不是 for 内的局部变量。前者无论 for 循环多少次,都是一个变量对象(占用同一个内存地址);后者会随着 for 循环的变化产生新的变量对象(不停创建局部变量,内存地址不一样)

关键点:

  1. slice 整个结构会被拷贝。意味着循环中的长度 len 是不变的
  2. slice 中的元素值会被拷贝到 v 中。意味着 v 的改变不会影响原有元素值
  3. range 底层的 v 自始至终都是同一个变量对象。意味着 v 的内存地址一直不变。

无论是数组、切片还是映射(map),for-range都会对其进行拷贝,然后依次迭代每个元素!

分析

有了上面的理论基础作为支撑,那么我们就来进一步分析造成该现象结果的原因。

回到代码中

 for _,v := range dataFromDb{
     finalData = append(finalData, &v)
 }

假设 v 变量地址 0x0030, 那么 finalData 中三个元素的地址都为 0x0030

虽然每次循环都会把 dataFromDb 变量中的元素赋值到 v 中,v被不断刷新着数据,但是 v 的地址是不变的,所以 finalData 最终都存储着相同的地址:0x0030

而这个地址最终将会指向 dataFromDb 的最后一个元素。

解决

其实解决办法很简单,引入**「中间变量」**即可,代码改成下面这个样子

dataFromDb := []int{1,2,3}
 var finalData []*int
 for _,v := range dataFromDb{
  temp := v //引入中间变量,每一次循环都重新开辟了一个temp的空间
  finalData = append(finalData, &temp)
 }
 for _, final := range finalData{
  fmt.Println(*final)
 }

代码加入了**「中间变量temp」**temp:=v等价于

var  temp int 
temp = 1
  • 第一次循环 temp开辟了一块空间,指向了v,temp的值为1
  • 第二次循环 temp**「重新开辟了一块空间」**,指向了v,temp的值为2,因为是重新开辟的空间,所以不会影响到上一次循环
  • 第三次循环 原理同上一步

值拷贝问题

for-range循环中,Go会对集合中的每个元素进行值拷贝(这个我们在前面的 for-range 源码分析也提到了)。

这意味着如果集合元素是大对象,拷贝操作可能会带来性能问题。来看一个例子:

data := []struct{ num int }{{1}, {2}, {3}}
for _, v := range data {
    v.num++
}

在这里,每个v都是data元素的一个拷贝,而不是引用,因此对v.num的修改不会影响data本身。

要解决这个问题,你可以使用指针:

for i := range data {
    data[i].num++
}

总结

更多 for 循环的坑,可以看我前面的文章。

希望这篇文章能帮助你更好地理解for-range,在实际开发中写出更加健壮的代码。