Golang中的for-range趟坑

2021年9月19日 230点热度 0人点赞 0条评论

近日,机缘巧合下入了一个 Golang 语言 for-range 的坑,出于敬畏深入学习过程中又一步步陷入了更深的坑,先上个代码,大家看看应该输出什么吧?

package main

import (
 "fmt"
 "time"
)

func main() {
 slice := []int{1, 2, 3} 
 m := make(map[int]*int)
 var slice2 [3]int
 for index,value := range slice {
   slice = append(slice, value)
   go func(){
    fmt.Println("in goroutine: ",index,value)
   }()
   //time.Sleep(time.Second * 1)
   m[index] = &value
   if index == 0{
      slice[1] = 11
      slice[2] = 22
   }
   slice2[index] = value
 }
 fmt.Println("slice: ",slice)  
 for key,value := range m { 
  fmt.Println("in map: ",key,"->",*value)
 } 
 fmt.Println("slice2: ",slice2)
 time.Sleep(time.Second * 10)
}

考虑输出结果之前呐,先思考以下几个问题:

  1. 循环切片时不停的给被循环的那个切片追加元素会死循环吗?
  2. 循环中改变被循环切片内容,原切片内容会同步发生变化吗?
  3. 循环中通过协程进行循环变量的操作会怎么样呐?
  4. 把循环切片改成循环map会有什么变化吗?
  5. 要想让循环中的协程接受到希望的index和value需要怎么做呐?
  6. 要想让循环中新赋值的切片slice2和原切片slice值保持一致要怎么做呐?

公布下运行结果吧:

图片

完全正确的同学可以直接跳到文末了~~,32个赞送给你呦!其实每次的结果也不完全一致,map 部分 key 的顺序不一致但 value 的值能对的上也算正确哈~

或多或少觉得结果有点诡异的同学,咱们结合这段代码和这几个问题一起往下看看吧~~

range 是 Golang 语言定义的一种语法糖迭代器,1.5版本 Golang 引入自举编译器后 range 相关源码如下,根据类型不同进行不同的处理,支持对切片和数组、map、通道、字符串类型的的迭代。编译器会对每一种 range 支持的类型做专门的 “语法糖还原”。

src/cmd/compile/internal/gc/range.go

// walkrange transforms various forms of ORANGE into
// simpler forms.  The result must be assigned back to n.
// Node n may also be modified in place, and may also be
// the returned node.
func walkrange(n *Node) *Node {
    …………
    switch t.Etype {
        default:
            Fatalf("walkrange")

        case TARRAY, TSLICE:
            ……
        case TMAP:
            ……
        case TCHAN:
            ……
        case TSRTING:
            ……
    }
    ……
    n = walkstmt(n)

 lineno = lno
 return n
}

这里我们主要介绍数组切片和 map 的 for-range 迭代。字符串和通道的 range 迭代平时使用的不多,同时篇幅原因我们就不详细介绍了,感兴趣可以自行查看 Golang 源码和参考文献中自举前 gcc 的源码。

一、for-range 数组和切片

切片和数组的遍历在 Golang 自举后入口是同一个处理逻辑是相同的(1.5版本之前通过 gcc 编译时数组和切片的 range 入口不同,但其实内部逻辑大同小异),我们编码过程中看到的实际表现不同都是数组和切片自身的底层结构不同造成的。

看这样一个例子

func main() {
     var a = [5]int{1, 2, 3, 4, 5} 
     var r [5]int
     for i, v := range a { 
        if i == 0 {
            a[1] = 12
            a[2] = 13 
        }
        r[i] = v 
     }
     fmt.Println("r = ", r) 
     fmt.Println("a = ", a)
   }
   …………
   r = [1,2,3,4,5]
   a = [1,12,13,4,5]

对于所有的 range 循环 Go 语言都会在编译期为遍历对象创造一个副本,所以循环中通过短声明的变量修改值不会影响原循环数组的值。

第一次遍历时修改了 a 的第二个和第三个元素,理论上第二次和第三次遍历时 r 应该能取到 a 修改后的值,但是我们刚说了 range 遍历开始前会创建副本,也就是说 range 的是 a 的副本而不是 a 本身。所以 r 赋值时用的都是 a 的副本的 value 值,所以不变。

那为啥 a 变了呐,if 语句中赋值语句是用的 a[1],a[2] 这时候是真的修改 a 的值的,所以 a 变了,这里也是我们推荐的用法。

那如果想要让 r 和 a 保持一致,修改同时生效呐? 可以range &a,通过引用的方式进行循环,这样遍历的每个元素虽然创建了副本但副本依旧是一个指向 a 的指针,因此后续所有循环中均是 &a 指向的原数组亲自参与的,因此 v 能从 &a 指向的原数组中取出 a 修改后的值。

接下来把遍历的对象从数组改成切片再看下吧

func main() {
     var a = []int{1, 2, 3, 4, 5} 
     var r = make([]int,5)
     for i, v := range a { 
        if i == 0 {
            a[1] = 12
            a[2] = 13 
        }
        r[i] = v 
     }
     fmt.Println("r = ", r) 
     fmt.Println("a = ", a)
   }
   …………
   r = [1,12,13,4,5]   //注意变化
   a = [1,12,13,4,5]

循环过程中依然创建了原切片的副本,但是因为切片自身的结构,创建的副本依然和原切片共享底层数组,只要没发生扩容,他们的值发生变化时就是同步变化的。效果就如同数组时range &a 一样了。

到这里我们一起来看下遍历数组和切片时源码是什么样的吧?源码比较长,我们大概挑选出来关键的简单汇总就是如下

ha := a   //创建副本
hv1 := 0
hn := len(ha)   //循环前长度已经确定
v1 := hv1       //索引变量和取值变量都只在开始时声明,后面都是复用
v2 := nil       
for ; hv1 < hn; hv1++ {
    tmp := ha[hv1]  
    v1, v2 = hv1, tmp
    ...
}

这里给的是分析使用 for i, elem := range a {} 遍历数组和切片,同时关心索引和数据的情况,只关心索引或者只关心数据值的代码稍微不同,也就是关不关心 v1 和 v2 ,不关心直接nil掉。

Golang 1.5版本之前的 gcc 源码中语法糖扩展的 range 源码我们也贴出来方便大家理解。

// The loop we generate:
//   for_temp := range    //创建副本,数组的话重新复制新数组,切片的话复制新切片后,副本切片与原切片共享底层数组
//   len_temp := len(for_temp)  //循环前长度已经确定
//   for index_temp = 0; index_temp < len_temp; index_temp++ {
//           value_temp = for_temp[index_temp]
//           index = index_temp
//           value = value_temp
//           original body
//   }

仔细看这两段代码,原来玄机都藏在这里了~~

1. 循环次数在循环开始前已经确定

循环开始前先计算了数组和切片的长度,for 循环用这个长度来限制循环次数的,也就是循环次数在循环开始前就已经确定了呐,so 循环中再怎么追加或者删除元素都不会影响循环次数,也就不会死循环了~~

func main() {
   v := []int{1, 2, 3} 
   counter := 0
   for i := range v {
      counter++
      v = append(v, i) 
   }
   fmt.Println(counter)   //counter代表循环次数,3次哦,没有死循环,也不是6次,虽然v其实已经是长度为6的切片
   fmt.Println(v)   //[1,2,3,0,1,2]
}

2. 循环的时候会创建每个元素的副本

 type T struct {
     n int
 }
 func main() {
     ts := [2]T{}
     for i, t := range ts {
         switch i {
         case 0:
             t.n = 3
             ts[1].n = 9 
         case 1:
             fmt.Print(t.n, " "
         }
     }
     fmt.Print(ts)
 }
…………
 0 [{0} {9}]

for-range 循环数组时使用的是数组 ts 的副本,所以 t.n = 3 的赋值操作不会影响原数组。但 ts[1].n = 9这种方式操作的确是原数组的元素值,所以是会发生变化的。这也是我们推崇的方法。

3. 循环的时候短声明只会在开始时执行一次,后面都是重用

循环 index 和 value 在每次循环体中都会被重用,而不是新声明。for-range 循环里的短声明index,value :=相当于第一次是 := ,后面都是 =,所以变量地址是不变的,就相当于全局变量了。

每次遍历会把被循环元素当前 key 和值赋给这两个全局变量,但是注意变量还是那个变量,地址不变,所以如果用的是地址的或者当前上下文环境值的话最后打印出来都是同一个值。

 func main() {
     slice := []int{0,1,2,3}
     m := make(map[int]*int)
     for key,val := range slice {
       m[key] = &val
       fmt.Println(key,&key)
       fmt.Println(val,&val)
     }
     for k,v := range m {
      fmt.Println(k,"->",*v)
     }
 }
 …………
 0 0xc0000b4008
 0 0xc0000b4010
 1 0xc0000b4008
 1 0xc0000b4010
 2 0xc0000b4008
 2 0xc0000b4010
 3 0xc0000b4008
 3 0xc0000b4010
 0 -> 3
 1 -> 3
 2 -> 3
 3 -> 3

key0、key1、key2、key3 其实都是短声明中的key变量,所以地址是一致的,val0、val1、val2、val3 其实都是短声明中的val变量,地址也一致

最终遍历 map 进行输出时因为 map 赋值时用的是 val 的地址m[key] = &val,循环结束时 val 的值是3,所以最终输出时4个元素的值都是3。 

这里需要注意 map 的遍历输出结果 key 的顺序可能会不一致,比如2,0,1,3这样,那是因为 map 的遍历输出是无序的,后面会再说,但是对应的 value 的值都是3。

那如果想要新生成的map也输出正确的值怎么做呐?

func main() {
     slice := []int{0,1,2,3}
     m := make(map[int]*int)
     for key,val := range slice {
       value := val    //增加临时变量,每次都是新声明的,地址也就不一样,也就能传过去正确的值
       m[key] = &value
       fmt.Println(key,&key)
       fmt.Println(val,&val)
     }
     for k,v := range m {
      fmt.Println(k,"->",*v)
     }
 }
 …………
 0 0xc00001a080
 1 0xc00001a0a0
 2 0xc00001a0b0
 3 0xc00001a0c0
 0 -> 0
 1 -> 1
 2 -> 2
 3 -> 3

再来看下 for-range 循环中开启了协程会怎么样?

func main() {
     var m = []int{1, 2, 3}
     for i, v := range m {
         go func() {
             fmt.Println(i, v) 
         }()
     }
     time.Sleep(time.Second * 3) 
}
……………
2 3
2 3
2 3

各个 goroutine 中输出的 i、v 值都是 for-range 循环结束后的 i、v 最终值,而不是各个 goroutine 启动时的 i, v值。因为 goroutine 执行是在后面的某一个时间,使用的是执行时上下文环境的变量值,i,v又相当于一个全局变量,协程执行时 for-range 循环已结束,i 和 v 都是最后一次循环的值2和3,所以最后输出都是2和3。

试试改成这样

   func main() {
     var m = []int{1, 2, 3}
     for i, v := range m {
         go func() {
             fmt.Println(i, v) 
         }()
         if i==0 {
             time.Sleep(time.Second*1)
         }
     }
     time.Sleep(time.Second * 3) 
}
……………
0 1
2 3
2 3

第一次遍历后 sleep 了1秒,所以第一次循环中的协程有时间执行了,开始执行时当前上下文中 i 和 v 的值还是第一次遍历的0和1,后面的没 sleep 就是最后循环结束时的2和3了。

这里只是为了讲明白环境上下文,其实我们平时不会这么用的,协程本来就是为了提升并发特性的,如果每次都 sleep 那还有什么意义呐。

两种方法,一种是临时变量存储循环iv值进行使用,另外一种是通过函数参数进行传递 go func(i,v){}(i,v)

for i, v := range m {
     index := i // 这里的 := 会新声明变量,而不是重用 
     value := v
     go func() {
        fmt.Println(index, value) 
     }()
}
for i, v := range m { 
    go func(i,v int) {
      fmt.Println(i, v) 
    }(i,v)
}

至于 for-range 中通过 append 函数为切片追加元素继而在循环外打印切片时元素值是否发生变化,取决于切片 append 的原理,容量是否足够,是否发生扩容生成新的底层数组,底层数组值是否发生改变等,不是本文的重点,这里就不详细说了~~

二、for-range Map

接下来我们看看针对 Map 的 for-range, 还是先用一段代码带入。

func main() {
     var m = map[string]int{ "A": 21,
                             "B": 22,
                             "C": 23, 
     }
     counter := 0
     for k, v := range m {
         counter++
         fmt.Println(k, v) 
         key := fmt.Sprintf("%s%d""D", counter)
         m[key] = 24   //给map增加了新元素
     }
     fmt.Println("counter is ", counter)
     fmt.Println(m)   
 }
 …………
 B 22
 C 23
 D1 24
 D2 24
 D3 24
 D4 24
 D5 24
 A 21
 counter is  8
 map[B:22 C:23 D1:24 D2:24 D3:24 D4:24 D5:24 D6:24 D7:24 D8:24 A:21]

看看还原的源码和语法糖吧,理解的更清楚些。

 ha := a   //副本,but没计算长度
 hit := hiter(n.Type)
 th := hit.Type
 mapiterinit(typename(t), ha, &hit)
 for ; hit.key != nil; mapiternext(&hit) {
     key := *hit.key
     val := *hit.val
 }
 …………
 func mapiterinit(t *maptype, h *hmap, it *hiter) {
     it.t = t
     it.h = h
     it.B = h.B
     it.buckets = h.buckets

     r := uintptr(fastrand())
     it.startBucket = r & bucketMask(h.B)
     it.offset = uint8(r >> h.B & (bucketCnt - 1))
     it.bucket = it.startBucket
     mapiternext(it)
 }
 …………
 //  老版本中的gcc源码
 //   var hiter map_iteration_struct
 //   for mapiterinit(type, range, &hiter); hiter.key != nil; mapiternext(&hiter) {
 //           index_temp = *hiter.key
 //           value_temp = *hiter.val
 //           index = index_temp
 //           value = value_temp
 //           original body
 //   }

从 mapiterinit 这个函数的参数调用的是指针 h *hmap,可以看出 ha := a 这个拷贝的是其实是指针,所以后续对 map 的修改还是会影响到原来的 map,所以与切片的 for-range 不同,map 的 for-range 长度没有确定,所以遍历的 counter 次数不是原始 map 大小3,但是也不会死循环,而是一个不固定的值。     

Golang 中 Map 是一种无序的键值对,索引顺序没有定义,Golang 不保证使用不同的索引后结果的顺序相同( Golang 有意为之),所以其遍历是无序的,包括循环外 println 打印整个 map 也是无序的。 

如果 map 中的元素是在迭代过程中被添加的,添加的元素并不一定会在后续迭代中被遍历到,可能出现也可能被跳过。

func main() {
     var m = map[string]int{ "A": 21,
                             "B": 22,
                             "C": 23, 
     }
     counter := 0
     for k, v := range m {
         if counter == 0 { 
             delete(m, "A")
         }
         counter++
         fmt.Println(k, v) 
         
     }
     fmt.Println("counter is ", counter)  
 }
 …………
 2或者3

for range map 是无序的,如果第一次循环到 A,则输出 3,否则输出 2。如果 map 中的元素在还没有被遍历到时就被移除了,后续的迭代中这个元素就不会再出现。

三、for-range 编码建议

现在相信你对文章开头的示例代码的输出应该已经明朗了,那么基于不同类型range 的这些特性,我们建议用 for-range 进行迭代时最好遵循以下原则。

  1. 尽量用 index 来访问 for-range 中真实的元素slice[index]
  2. go func()最好通过函数参数方式传递循环中的变量
  3. 循环变量在每一次迭代中都被赋值并会复用,不是每次都重新声明,地址一样。所以需要区分的时候需要重新每次重新声明临时变量。
  4. 可以在迭代过程中移除一个 map 里的元素或者向 map 里添加元素,添加的元素并不一定会在后续迭代中被遍历到。所以最好不要在 range 迭代中修改 map,容易造成不确定性。
  5. 遍历对象是引用类型时要注意副本其实依赖于源对象,合理使用。
  6. 数组和切片因为自身数据结构的不同,range 迭代时表现也不一样,可以根据实际场景进行合理使用。

今天我们通过编码过程中的一些不那么直观的坑点一起探讨了 Golang 中 for-range 的原理、特殊注意事项,重点介绍了 for-range 切片和 Map。希望能帮助大家绕坑,表述不当之处还能请大家见谅并及时指正~~

【参考文献】

  1. https://github.com/gcc-mirror/gcc/blob/master/gcc/go/gofrontend/statements.cc
  2. https://draveness.me/golang/docs/part2-foundation/ch05-keyword/golang-for-range/

推荐阅读

福利
我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。

图片

4840Golang中的for-range趟坑

root

这个人很懒,什么都没留下

文章评论