Golang中的for-range趟坑
近日,機(jī)緣巧合下入了一個 Golang 語言 for-range 的坑,出于敬畏深入學(xué)習(xí)過程中又一步步陷入了更深的坑,先上個代碼,大家看看應(yīng)該輸出什么吧?
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)
}
考慮輸出結(jié)果之前吶,先思考以下幾個問題:
循環(huán)切片時不停的給被循環(huán)的那個切片追加元素會死循環(huán)嗎? 循環(huán)中改變被循環(huán)切片內(nèi)容,原切片內(nèi)容會同步發(fā)生變化嗎? 循環(huán)中通過協(xié)程進(jìn)行循環(huán)變量的操作會怎么樣吶? 把循環(huán)切片改成循環(huán)map會有什么變化嗎? 要想讓循環(huán)中的協(xié)程接受到希望的index和value需要怎么做吶? 要想讓循環(huán)中新賦值的切片slice2和原切片slice值保持一致要怎么做吶?
公布下運(yùn)行結(jié)果吧:

完全正確的同學(xué)可以直接跳到文末了~~,32個贊送給你呦!其實每次的結(jié)果也不完全一致,map 部分 key 的順序不一致但 value 的值能對的上也算正確哈~
或多或少覺得結(jié)果有點詭異的同學(xué),咱們結(jié)合這段代碼和這幾個問題一起往下看看吧~~
range 是 Golang 語言定義的一種語法糖迭代器,1.5版本 Golang 引入自舉編譯器后 range 相關(guān)源碼如下,根據(jù)類型不同進(jìn)行不同的處理,支持對切片和數(shù)組、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
}
這里我們主要介紹數(shù)組切片和 map 的 for-range 迭代。字符串和通道的 range 迭代平時使用的不多,同時篇幅原因我們就不詳細(xì)介紹了,感興趣可以自行查看 Golang 源碼和參考文獻(xiàn)中自舉前 gcc 的源碼。
一、for-range 數(shù)組和切片
切片和數(shù)組的遍歷在 Golang 自舉后入口是同一個處理邏輯是相同的(1.5版本之前通過 gcc 編譯時數(shù)組和切片的 range 入口不同,但其實內(nèi)部邏輯大同小異),我們編碼過程中看到的實際表現(xiàn)不同都是數(shù)組和切片自身的底層結(jié)構(gòu)不同造成的。
看這樣一個例子
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 循環(huán) Go 語言都會在編譯期為遍歷對象創(chuàng)造一個副本,所以循環(huán)中通過短聲明的變量修改值不會影響原循環(huán)數(shù)組的值。
第一次遍歷時修改了 a 的第二個和第三個元素,理論上第二次和第三次遍歷時 r 應(yīng)該能取到 a 修改后的值,但是我們剛說了 range 遍歷開始前會創(chuàng)建副本,也就是說 range 的是 a 的副本而不是 a 本身。所以 r 賦值時用的都是 a 的副本的 value 值,所以不變。
那為啥 a 變了吶,if 語句中賦值語句是用的 a[1],a[2] 這時候是真的修改 a 的值的,所以 a 變了,這里也是我們推薦的用法。
那如果想要讓 r 和 a 保持一致,修改同時生效吶? 可以range &a,通過引用的方式進(jìn)行循環(huán),這樣遍歷的每個元素雖然創(chuàng)建了副本但副本依舊是一個指向 a 的指針,因此后續(xù)所有循環(huán)中均是 &a 指向的原數(shù)組親自參與的,因此 v 能從 &a 指向的原數(shù)組中取出 a 修改后的值。
接下來把遍歷的對象從數(shù)組改成切片再看下吧
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]
循環(huán)過程中依然創(chuàng)建了原切片的副本,但是因為切片自身的結(jié)構(gòu),創(chuàng)建的副本依然和原切片共享底層數(shù)組,只要沒發(fā)生擴(kuò)容,他們的值發(fā)生變化時就是同步變化的。效果就如同數(shù)組時range &a 一樣了。
到這里我們一起來看下遍歷數(shù)組和切片時源碼是什么樣的吧?源碼比較長,我們大概挑選出來關(guān)鍵的簡單匯總就是如下
ha := a //創(chuàng)建副本
hv1 := 0
hn := len(ha) //循環(huán)前長度已經(jīng)確定
v1 := hv1 //索引變量和取值變量都只在開始時聲明,后面都是復(fù)用
v2 := nil
for ; hv1 < hn; hv1++ {
tmp := ha[hv1]
v1, v2 = hv1, tmp
...
}
這里給的是分析使用 for i, elem := range a {} 遍歷數(shù)組和切片,同時關(guān)心索引和數(shù)據(jù)的情況,只關(guān)心索引或者只關(guān)心數(shù)據(jù)值的代碼稍微不同,也就是關(guān)不關(guān)心 v1 和 v2 ,不關(guān)心直接nil掉。
Golang 1.5版本之前的 gcc 源碼中語法糖擴(kuò)展的 range 源碼我們也貼出來方便大家理解。
// The loop we generate:
// for_temp := range //創(chuàng)建副本,數(shù)組的話重新復(fù)制新數(shù)組,切片的話復(fù)制新切片后,副本切片與原切片共享底層數(shù)組
// len_temp := len(for_temp) //循環(huán)前長度已經(jīng)確定
// for index_temp = 0; index_temp < len_temp; index_temp++ {
// value_temp = for_temp[index_temp]
// index = index_temp
// value = value_temp
// original body
// }
仔細(xì)看這兩段代碼,原來玄機(jī)都藏在這里了~~
1. 循環(huán)次數(shù)在循環(huán)開始前已經(jīng)確定
循環(huán)開始前先計算了數(shù)組和切片的長度,for 循環(huán)用這個長度來限制循環(huán)次數(shù)的,也就是循環(huán)次數(shù)在循環(huán)開始前就已經(jīng)確定了吶,so 循環(huán)中再怎么追加或者刪除元素都不會影響循環(huán)次數(shù),也就不會死循環(huán)了~~
func main() {
v := []int{1, 2, 3}
counter := 0
for i := range v {
counter++
v = append(v, i)
}
fmt.Println(counter) //counter代表循環(huán)次數(shù),3次哦,沒有死循環(huán),也不是6次,雖然v其實已經(jīng)是長度為6的切片
fmt.Println(v) //[1,2,3,0,1,2]
}
2. 循環(huán)的時候會創(chuàng)建每個元素的副本
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 循環(huán)數(shù)組時使用的是數(shù)組 ts 的副本,所以 t.n = 3 的賦值操作不會影響原數(shù)組。但 ts[1].n = 9這種方式操作的確是原數(shù)組的元素值,所以是會發(fā)生變化的。這也是我們推崇的方法。
3. 循環(huán)的時候短聲明只會在開始時執(zhí)行一次,后面都是重用
循環(huán) index 和 value 在每次循環(huán)體中都會被重用,而不是新聲明。for-range 循環(huán)里的短聲明index,value :=相當(dāng)于第一次是 := ,后面都是 =,所以變量地址是不變的,就相當(dāng)于全局變量了。
每次遍歷會把被循環(huán)元素當(dāng)前 key 和值賦給這兩個全局變量,但是注意變量還是那個變量,地址不變,所以如果用的是地址的或者當(dāng)前上下文環(huán)境值的話最后打印出來都是同一個值。
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 進(jìn)行輸出時因為 map 賦值時用的是 val 的地址m[key] = &val,循環(huán)結(jié)束時 val 的值是3,所以最終輸出時4個元素的值都是3。 這里需要注意 map 的遍歷輸出結(jié)果 key 的順序可能會不一致,比如2,0,1,3這樣,那是因為 map 的遍歷輸出是無序的,后面會再說,但是對應(yīng)的 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 循環(huán)中開啟了協(xié)程會怎么樣?
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 循環(huán)結(jié)束后的 i、v 最終值,而不是各個 goroutine 啟動時的 i, v值。因為 goroutine 執(zhí)行是在后面的某一個時間,使用的是執(zhí)行時上下文環(huán)境的變量值,i,v又相當(dāng)于一個全局變量,協(xié)程執(zhí)行時 for-range 循環(huán)已結(jié)束,i 和 v 都是最后一次循環(huán)的值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秒,所以第一次循環(huán)中的協(xié)程有時間執(zhí)行了,開始執(zhí)行時當(dāng)前上下文中 i 和 v 的值還是第一次遍歷的0和1,后面的沒 sleep 就是最后循環(huán)結(jié)束時的2和3了。
這里只是為了講明白環(huán)境上下文,其實我們平時不會這么用的,協(xié)程本來就是為了提升并發(fā)特性的,如果每次都 sleep 那還有什么意義吶。
兩種方法,一種是臨時變量存儲循環(huán)iv值進(jìn)行使用,另外一種是通過函數(shù)參數(shù)進(jìn)行傳遞 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 函數(shù)為切片追加元素繼而在循環(huán)外打印切片時元素值是否發(fā)生變化,取決于切片 append 的原理,容量是否足夠,是否發(fā)生擴(kuò)容生成新的底層數(shù)組,底層數(shù)組值是否發(fā)生改變等,不是本文的重點,這里就不詳細(xì)說了~~
二、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 這個函數(shù)的參數(shù)調(diào)用的是指針 h *hmap,可以看出 ha := a 這個拷貝的是其實是指針,所以后續(xù)對 map 的修改還是會影響到原來的 map,所以與切片的 for-range 不同,map 的 for-range 長度沒有確定,所以遍歷的 counter 次數(shù)不是原始 map 大小3,但是也不會死循環(huán),而是一個不固定的值。
Golang 中 Map 是一種無序的鍵值對,索引順序沒有定義,Golang 不保證使用不同的索引后結(jié)果的順序相同( Golang 有意為之),所以其遍歷是無序的,包括循環(huán)外 println 打印整個 map 也是無序的。
如果 map 中的元素是在迭代過程中被添加的,添加的元素并不一定會在后續(xù)迭代中被遍歷到,可能出現(xiàn)也可能被跳過。
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 是無序的,如果第一次循環(huán)到 A,則輸出 3,否則輸出 2。如果 map 中的元素在還沒有被遍歷到時就被移除了,后續(xù)的迭代中這個元素就不會再出現(xiàn)。
三、for-range 編碼建議
現(xiàn)在相信你對文章開頭的示例代碼的輸出應(yīng)該已經(jīng)明朗了,那么基于不同類型range 的這些特性,我們建議用 for-range 進(jìn)行迭代時最好遵循以下原則。
盡量用 index 來訪問 for-range 中真實的元素 slice[index]。go func()最好通過函數(shù)參數(shù)方式傳遞循環(huán)中的變量 循環(huán)變量在每一次迭代中都被賦值并會復(fù)用,不是每次都重新聲明,地址一樣。所以需要區(qū)分的時候需要重新每次重新聲明臨時變量。 可以在迭代過程中移除一個 map 里的元素或者向 map 里添加元素,添加的元素并不一定會在后續(xù)迭代中被遍歷到。所以最好不要在 range 迭代中修改 map,容易造成不確定性。 遍歷對象是引用類型時要注意副本其實依賴于源對象,合理使用。 數(shù)組和切片因為自身數(shù)據(jù)結(jié)構(gòu)的不同,range 迭代時表現(xiàn)也不一樣,可以根據(jù)實際場景進(jìn)行合理使用。
今天我們通過編碼過程中的一些不那么直觀的坑點一起探討了 Golang 中 for-range 的原理、特殊注意事項,重點介紹了 for-range 切片和 Map。希望能幫助大家繞坑,表述不當(dāng)之處還能請大家見諒并及時指正~~
【參考文獻(xiàn)】
https://github.com/gcc-mirror/gcc/blob/master/gcc/go/gofrontend/statements.cc https://draveness.me/golang/docs/part2-foundation/ch05-keyword/golang-for-range/
推薦閱讀
