首頁 > 軟體

go語言中for range使用方法及避坑指南

2022-09-08 18:04:21

前言

for range語句是業務開發中編寫頻率很高的程式碼,其中會有一些常見的坑,看完這篇文章會讓你少入坑。

for range基本用法

range是Golang提供的一種迭代遍歷手段,可操作的型別有陣列、切片、string、map、channel等

1、遍歷陣列

myArray := [3]int{1, 2, 3}
for i, ele := range myArray {
    fmt.Printf("index:%d,element:%dn", i, ele)
    fmt.Printf("index:%d,element:%dn", i, myArray[i])
}

直接取元素或通過下標取

2、遍歷slice

mySlice := []string{"I", "am", "peachesTao"}
for i, ele := range mySlice {
    fmt.Printf("index:%d,element:%sn", i, ele)
    fmt.Printf("index:%d,element:%sn", i, mySlice[i])
}

直接取元素或通過下標取

3、遍歷string

s:="peachesTao"
for i,item := range s {
   fmt.Println(string(item))
   fmt.Printf("index:%d,element:%sn", i, string(s[i]))
}

直接取元素或通過下標取

注意:迴圈體中string中的元素實際上是byte型別,需要轉換為字面字元

4、遍歷map

myMap := map[int]string{1:"語文",2:"數學",3:"英語"}
for key,value := range myMap {
    fmt.Printf("key:%d,value:%sn", key, value)
    fmt.Printf("key:%d,value:%sn", key, myMap[key])
}

直接取元素或通過下標取

5、遍歷channel

myChannel := make(chan int)
go func() {
  for i:=0;i<10;i++{
     time.Sleep(time.Second)
     myChannel <- i
  }
}()
 
go func() {
 for c := range myChannel {
    fmt.Printf("value:%dn", c)
 }
}()

channel遍歷是迴圈從channel中讀取資料,如果channel中沒有資料,則會阻塞等待,如果channel已被關閉,則會退出迴圈。

for range 和 for的區別

for range可以直接存取目標物件中的元素,而for必須通過下標存取

for frange可以存取map、channel物件,而for不可以

for range容易踩的坑

下面的例子是將mySlice中每個元素的後面都加上字元"-new"

mySlice := []string{"I", "am", "peachesTao"}
for _, ele := range mySlice {
    ele=ele+"-new"
}
fmt.Println(mySlice)

結果:

[I am peachesTao]

列印mySlice發現元素並沒有更新,為什麼會這樣?

原因是for range語句會將目標物件中的元素copy一份值的副本,修改副本顯然不能對原元素產生影響

為了證明上述結論,在遍歷前和遍歷中列印出元素的記憶體地址

mySlice := []string{"I", "am", "peachesTao"}
fmt.Printf("遍歷前首元素記憶體地址:%pn",&mySlice[0])
for _, ele := range mySlice {
    ele=ele+"-new"
    fmt.Printf("遍歷中元素記憶體地址:%pn",&ele)
}
fmt.Println(mySlice)

結果:

遍歷前第一個元素記憶體地址:0xc000054180
遍歷前第二個元素記憶體地址:0xc000054190
遍歷前第三個元素記憶體地址:0xc0000541a0
遍歷中元素記憶體地址:0xc000010200
遍歷中元素記憶體地址:0xc000010200
遍歷中元素記憶體地址:0xc000010200
[I am peachesTao]

可以得出兩個結論:

  • 遍歷體中的元素記憶體地址已經發生了變化,生成了元素副本,至於產生副本的原因在“for range底層原理”段落中會有介紹
  • 遍歷體中的只生成了一個全域性的元素副本變數,不是每個元素都會生成一個副本,這個特點也值得大家注意,否則會踩坑。

比如遍歷mySlice元素生成一個[]*string型別的mySliceNew,要通過一箇中間變數取中間變數的地址(或者通過下標的形式存取元素也可以)加入mySliceNew,如果直接取元素副本的地址會導致mySliceNew中所有元素都是一樣的,如下:

mySlice := []string{"I", "am", "peachesTao"}
var mySliceNew []*string
for _, item := range mySlice {
    itemTemp := item
    mySliceNew = append(mySliceNew, &itemTemp)
    //mySliceNew = append(mySliceNew, &item) 錯誤的做法
}

回到剛才那個問題,如何能在遍歷中修改元素呢?答案是直接通過下標存取slice中的元素對其賦值,如下:

mySlice := []string{"I", "am", "peachesTao"}
for i, _ := range mySlice {
    mySlice[i] = mySlice[i]+"-new"
}
fmt.Println(mySlice)

結果:

[I-new am-new peachesTao-new]

可以看到元素已經被修改

for range和for效能比較

我們定義一個結構體Item,包含int型別的id欄位,對結構體陣列分別使用for、for range item、for range index的方式進行遍歷,下面是測試程式碼(直接參照“Go語言高效能程式設計”這篇文章中的例子,下面的reference中有連結地址)

type Item struct {
  id  int
}
 
func BenchmarkForStruct(b *testing.B) {
  var items [1024]Item
  for i := 0; i < b.N; i++ {
    length := len(items)
    var tmp int
    for k := 0; k < length; k++ {
      tmp = items[k].id
    }
    _ = tmp
  }
}
 
func BenchmarkRangeIndexStruct(b *testing.B) {
  var items [1024]Item
  for i := 0; i < b.N; i++ {
    var tmp int
    for k := range items {
      tmp = items[k].id
    }
    _ = tmp
  }
}
 
func BenchmarkRangeStruct(b *testing.B) {
  var items [1024]Item
  for i := 0; i < b.N; i++ {
    var tmp int
    for _, item := range items {
      tmp = item.id
    }
    _ = tmp
  }
}

執行基準測試命令:

go test -bench . test/for_range_performance_test.go

測試結果:

goos: darwin
goarch: amd64
BenchmarkForStruct-4             3176875               375 ns/op
BenchmarkRangeIndexStruct-4      3254553               369 ns/op
BenchmarkRangeStruct-4           3131196               384 ns/op
PASS
ok      command-line-arguments  4.775s

可以看出:

for range 通過Index和直接存取元素的方式和for的方式遍歷效能幾乎無差異

下面我們在Item結構體新增一個byte型別長度為4096的陣列欄位val

type Item struct {
  id  int
  val [4096]byte
}

再執行一遍基準測試,結果如下:

goos: darwin
goarch: amd64
BenchmarkForStruct-4             2901506               393 ns/op
BenchmarkRangeIndexStruct-4      3160203               381 ns/op
BenchmarkRangeStruct-4              1088            948678 ns/op
PASS
ok      command-line-arguments  4.317s

可以看出:

  • for range通過下標遍歷元素的效能跟for相差不大
  • for range直接遍歷元素的效能比for慢近1000倍

結論:

  • for range通過下標遍歷元素的效能跟for相差不大
  • for range直接遍歷元素的效能在元素為小物件的情況下跟for相差不大,在元素為大物件的情況下比for慢很多

for range的底層原理

對於for-range語句的實現,可以從編譯器原始碼中找到答案。

編譯器原始碼gofrontend/go/statements.cc/For_range_statement::do_lower()【連結見下方reference方法中有如下注釋。

// Arrange to do a loop appropriate for the type.  We will produce
//   for INIT ; COND ; POST {
//           ITER_INIT
//           INDEX = INDEX_TEMP
//           VALUE = VALUE_TEMP // If there is a value
//           original statements
//   }

可見range實際上是一個C風格的迴圈結構。range支援string、陣列、陣列指標、切片、map和channel型別,對於不同型別有些細節上的差異。

1、range for slice

下面的註釋解釋了遍歷slice的過程:

For_range_statement::lower_range_slice

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

遍歷slice前會先獲得slice的長度len_temp作為迴圈次數,迴圈體中,每次迴圈會先獲取元素值,如果for-range中接收index和value的話,則會對index和value進行一次賦值,這就解釋了對大元素進行遍歷會影響效能,因為大物件賦值會產生gc

由於迴圈開始前回圈次數就已經確定了,所以迴圈過程中新新增的元素是沒辦法遍歷到的。

另外,陣列與陣列指標的遍歷過程與slice基本一致,不再贅述。

2、range for map 

下面的註釋解釋了遍歷map的過程:

For_range_statement::lower_range_map

// The loop we generate:
//   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
//   }

遍歷map時沒有指定迴圈次數,迴圈體與遍歷slice類似。由於map底層實現與slice不同,map底層使用hash表實現,插入資料位置是隨機的,所以遍歷過程中新插入的資料不能保證遍歷到。

3、range for channel

遍歷channel是最特殊的,這是由channel的實現機制決定的:

For_range_statement::lower_range_channel

// The loop we generate:
//   for {
//           index_temp, ok_temp = <-range
//           if !ok_temp {
//                   break
//           }
//           index = index_temp
//           original body
//   }

一直迴圈讀資料,如果有資料則取出,如果沒有則阻塞,如果channel被關閉則退出迴圈

注:

上述註釋中index_temp實際上描述是有誤的,應該為value_temp,因為index對於channel是沒有意義的。

總結

使用index,value接收range返回值會產生一次資料拷貝,視情況考慮不接收,以提高效能

for-range的實現實際上是C風格的for迴圈

到此這篇關於go語言中for range使用方法及避坑指南的文章就介紹到這了,更多相關go語言for range使用內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!

參考資料

【《Go專家程式設計》Go range實現原理及效能優化剖析 https://my.oschina.net/renhc/blog/2396058

【面試官:用過go中的for-range嗎?這幾個問題你能解釋一下原因嗎?】https://zhuanlan.zhihu.com/p/217987219

【Go語言高效能程式設計】https://geektutu.com/post/hpg-range.html

【gofrontend】https://github.com/golang/gofrontend/blob/master/go/statements.cc


IT145.com E-mail:sddin#qq.com