Go for和range|青训营笔记

141 阅读5分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第6天

for 和 range

循环是所有编程语言都有的控制结构,除了使用经典的三段式循环之外,Go 语言还引入了另一个关键字 range 帮助我们快速遍历数组、切片、哈希表以及 Channel 等集合类型。

循环

Go 语言中的经典循环在编译器看来是一个 OFOR 类型的节点,这个节点由以下四个部分组成:

  1. 初始化循环的 Ninit
  2. 循环的继续条件 Left
  3. 循环体结束时执行的 Right
  4. 循环体 NBody
 for Ninit; Left; Right {
     NBody
 }

在生成 SSA 中间代码的阶段,cmd/compile/internal/gc.state.stmt方法在发现传入的节点类型是 OFOR 时会执行以下的代码块,这段代码会将循环中的代码分成不同的块:

 func (s *state) stmt(n *Node) {
     switch n.Op {
     case OFOR, OFORUNTIL:
         bCond, bBody, bIncr, bEnd := ...
 ​
         b := s.endBlock()
         b.AddEdgeTo(bCond)
         s.startBlock(bCond)
         s.condBranch(n.Left, bBody, bEnd, 1)
 ​
         s.startBlock(bBody)
         s.stmtList(n.Nbody)
 ​
         b.AddEdgeTo(bIncr)
         s.startBlock(bIncr)
         s.stmt(n.Right)
         b.AddEdgeTo(bCond)
         s.startBlock(bEnd)
     }
 }

一个常见的 for 循环代码会被 cmd/compile/internal/gc.state.stmt转换成下面的控制结构,该结构中包含了 4 个不同的块,这些代码块之间的连接表示汇编语言中的跳转关系,与我们理解的 for 循环控制结构没有太多的差别。

范围循环

与简单的经典循环相比,范围循环在 Go 语言中更常见、实现也更复杂。这种循环同时使用 forrange 两个关键字,编译器会在编译期间将所有 for-range 循环变成经典循环。从编译器的视角来看,就是将 ORANGE 类型的节点转换成 OFOR 节点

节点类型的转换过程都发生在中间代码生成阶段,所有的 for-range 循环都会被转换成不包含复杂结构、只包含基本表达式的语句。接下来,我们按照循环遍历的元素类型依次介绍遍历数组和切片、哈希表、字符串以及管道时的过程。

数组和切片

对于数组和切片来说,Go 语言有三种不同的遍历方式,这三种不同的遍历方式分别对应着代码中的不同条件,它们会在cmd/compile/internal/gc.walkrange函数中转换成不同的控制逻辑,我们会分成几种情况分析该函数的逻辑:

  1. 分析遍历数组和切片清空元素的情况;
  2. 分析使用 for range a {} 遍历数组和切片,不关心索引和数据的情况;
  3. 分析使用 for i := range a {} 遍历数组和切片,只关心索引的情况;
  4. 分析使用 for i, elem := range a {} 遍历数组和切片,关心索引和数据的情况;
 func walkrange(n *Node) *Node {
     switch t.Etype {
     case TARRAY, TSLICE:
         if arrayClear(n, v1, v2, a) {
             return n
         }

cmd/compile/internal/gc.walkrange是一个非常有趣的优化,它会优化 Go 语言遍历数组或者切片并删除全部元素的逻辑:

 // 原代码
 for i := range a {
     a[i] = zero
 }
 ​
 // 优化后
 if len(a) != 0 {
     hp = &a[0]
     hn = len(a)*sizeof(elem(a))
     memclrNoHeapPointers(hp, hn)
     i = len(a) - 1
 }

相比于依次清除数组或者切片中的数据,Go 语言会直接使用 runtime.memclrNoHeapPointers或者 runtime.memclrHasPointers清除目标数组内存空间中的全部数据,并在执行完成后更新遍历数组的索引,这也印证了我们在遍历清空数组一节中观察到的现象。

处理了这种特殊的情况之后,我们可以回到 ORANGE 节点的处理过程了。这里会设置 for 循环的 LeftRight 字段,也就是终止条件和循环体每次执行结束后运行的代码:

         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)))
 ​
         if v1 == nil {
             break
         }

如果循环是 for range a {},那么就满足了上述代码中的条件 v1 == nil,即循环不关心数组的索引和数据,这种循环会被编译器转换成如下形式:

 ha := a
 hv1 := 0
 hn := len(ha)
 v1 := hv1
 for ; hv1 < hn; hv1++ {
     ...
 }

这是 ORANGE 结构在编译期间被转换的最简单形式,由于原代码不需要获取数组的索引和元素,只需要使用数组或者切片的数量执行对应次数的循环,所以会生成一个最简单的 for 循环。

如果我们在遍历数组时需要使用索引 for i := range a {},那么编译器会继续会执行下面的代码:

         if v2 == nil {
             body = []*Node{nod(OAS, v1, hv1)}
             break
         }

v2 == nil 意味着调用方不关心数组的元素,只关心遍历数组使用的索引。它会将 for i := range a {} 转换成下面的逻辑,与第一种循环相比,这种循环在循环体中添加了 v1 := hv1 语句,传递遍历数组时的索引:

 ha := a
 hv1 := 0
 hn := len(ha)
 v1 := hv1
 for ; hv1 < hn; hv1++ {
     v1 = hv1
     ...
 }

上面两种情况虽然也是使用 range 会经常遇到的情况,但是同时去遍历索引和元素也很常见。处理这种情况会使用下面这段的代码:

         tmp := nod(OINDEX, ha, hv1)
         tmp.SetBounded(true)
         a := nod(OAS2, nil, nil)
         a.List.Set2(v1, v2)
         a.Rlist.Set2(hv1, tmp)
         body = []*Node{a}
     }
     n.Ninit.Append(init...)
     n.Nbody.Prepend(body...)
 ​
     return n
 }

这段代码处理的使用者同时关心索引和切片的情况。它不仅会在循环体中插入更新索引的语句,还会插入赋值操作让循环体内部的代码能够访问数组中的元素:

 ha := a
 hv1 := 0
 hn := len(ha)
 v1 := hv1
 v2 := nil
 for ; hv1 < hn; hv1++ {
     tmp := ha[hv1]
     v1, v2 = hv1, tmp
     ...
 }

对于所有 range 循环,Go 语言都会在编译期将原切片或者数组赋值给一个新变量 ha,在赋值的过程中就发生了拷贝,而我们又通过 len 关键字预先获取了切片的长度,所以在循环中追加新的元素也不会改变循环执行的次数,这也就解释了循环永动机一节提到的现象。

而遇到这种同时遍历索引和元素的 range 循环时,Go 语言会额外创建一个新的 v2 变量存储切片中的元素,循环中使用的这个变量 v2 会在每一次迭代被重新赋值而覆盖,赋值时也会触发拷贝

 func main() {
     arr := []int{1, 2, 3}
     newArr := []*int{}
     for i, _ := range arr {
         newArr = append(newArr, &arr[i])
     }
     for _, v := range newArr {
         fmt.Println(*v)
     }
 }

因为在循环中获取返回变量的地址都完全相同,所以会发生神奇的指针一节中的现象。因此当我们想要访问数组中元素所在的地址时,不应该直接获取 range 返回的变量地址 &v2,而应该使用 &a[index] 这种形式。

哈希表

在遍历哈希表时,编译器会使用 runtime.mapiterinit 和 runtime.mapiternext两个运行时函数重写原始的 for-range 循环:

 ha := a
 hit := hiter(n.Type)
 th := hit.Type
 mapiterinit(typename(t), ha, &hit)
 for ; hit.key != nil; mapiternext(&hit) {
     key := *hit.key
     val := *hit.val
 }

上述代码是展开 for key, val := range hash {} 后的结果,在 cmd/compile/internal/gc.walkrange处理 TMAP 节点时,编译器会根据 range 返回值的数量在循环体中插入需要的赋值语句:

这三种不同的情况分别向循环体插入了不同的赋值语句。遍历哈希表时会使用 runtime.mapiterinit函数初始化遍历开始的元素:

 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)
 }

该函数会初始化 runtime.hiter 结构体中的字段,并通过 runtime.fastrand生成一个随机数帮助我们随机选择一个遍历桶的起始位置。Go 团队在设计哈希表的遍历时就不想让使用者依赖固定的遍历顺序,所以引入了随机数保证遍历的随机性。

遍历哈希会使用 runtime.mapiternext,我们在这里简化了很多逻辑,省去了一些边界条件以及哈希表扩容时的兼容操作,这里只需要关注处理遍历逻辑的核心代码,我们会将该函数分成桶的选择和桶内元素的遍历两部分,首先是桶的选择过程:

 func mapiternext(it *hiter) {
     h := it.h
     t := it.t
     bucket := it.bucket
     b := it.bptr
     i := it.i
     alg := t.key.alg
 ​
 next:
     if b == nil {
         if bucket == it.startBucket && it.wrapped {
             it.key = nil
             it.value = nil
             return
         }
         b = (*bmap)(add(it.buckets, bucket*uintptr(t.bucketsize)))
         bucket++
         if bucket == bucketShift(it.B) {
             bucket = 0
             it.wrapped = true
         }
         i = 0
     }

这段代码主要有两个作用:

  1. 在待遍历的桶为空时,选择需要遍历的新桶;
  2. 在不存在待遍历的桶时。返回 (nil, nil) 键值对并中止遍历;

runtime.mapiternext 剩余代码的作用是从桶中找到下一个遍历的元素,在大多数情况下都会直接操作内存获取目标键值的内存地址,不过如果哈希表处于扩容期间就会调用 runtime.mapaccessK 获取键值对:

     for ; i < bucketCnt; i++ {
         offi := (i + it.offset) & (bucketCnt - 1)
         k := add(unsafe.Pointer(b), dataOffset+uintptr(offi)*uintptr(t.keysize))
         v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+uintptr(offi)*uintptr(t.valuesize))
         if (b.tophash[offi] != evacuatedX && b.tophash[offi] != evacuatedY) ||
             !(t.reflexivekey() || alg.equal(k, k)) {
             it.key = k
             it.value = v
         } else {
             rk, rv := mapaccessK(t, h, k)
             it.key = rk
             it.value = rv
         }
         it.bucket = bucket
         it.i = i + 1
         return
     }
     b = b.overflow(t)
     i = 0
     goto next
 }

当上述函数已经遍历了正常桶后,会通过 runtime.bmap.overflow遍历哈希中的溢出桶。

字符串

遍历字符串的过程与数组、切片和哈希表非常相似,只是在遍历时会获取字符串中索引对应的字节并将字节转换成 rune。我们在遍历字符串时拿到的值都是 rune 类型的变量,for i, r := range s {} 的结构都会被转换成如下所示的形式:

 ha := s
 for hv1 := 0; hv1 < len(ha); {
     hv1t := hv1
     hv2 := rune(ha[hv1])
     if hv2 < utf8.RuneSelf {
         hv1++
     } else {
         hv2, hv1 = decoderune(ha, hv1)
     }
     v1, v2 = hv1t, hv2
 }