Golang 中的 for-range 趟坑

近日,機緣巧合下入了一個 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 333

各個 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=={
             time.Sleep(time.Second*1)
         }
     }
     time.Sleep(time.Second * 3) 
}
……………
0 133

第一次遍歷後 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/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/nZ2Hbbn-8f-2TMb3Y_MjZw