本文的知識點其實由golang知名的for循環陷阱發散而來,
對應到我的主力語言C#, 其實牽涉到閉包、foreach。爲了便於理解,我重新組織了語言,以倒敘結構行文。
先給大家提煉出一個C#題:觀察for、foreach閉包的差異
左邊輸出 5個5; 右邊輸出0,1,2,3,4, 答對的可以不用看下文了。
閉包是在詞法環境中捕獲自由變量的頭等函數, 題中關鍵是捕獲的自由變量。
這裏面有3個關鍵名詞,希望大家重視,可以圍觀我之前的 👇新來的總監,把C#閉包講得那叫一個透徹。
demo1
- for循環內閉包,局部變量i是被頭等函數引用的自由變量;相對於每個頭等函數,i是全局變量;
- 閉包捕獲變量i的時空和 閉包執行的時空不是一個時空;
- 所有閉包執行時,捕獲的都是變量i,所以執行輸出的都是
i++
最後的5。
這也是C#閉包的陷阱, 通常應對方式是循環內使用一個局部變量解構每個閉包與(相對全局)變量i的關係。
var t1 = new List<Action>();
for (int i = 0; i < 5; i++)
{
// 使用局部變量解綁閉包與全局自由變量i的關係,現在自由變量是局部變量j了。
var j = i;
var func = (() =>
{
Console.WriteLine(j);
});
t1.Add(func);
}
foreach (var item in t1)
{
item();
}
demo2
foreach內閉包,爲什麼能輸出預期的0,1,2,3,4。
聰明的讀者可以猜想,是不是foreach在循環迭代時 ,給我們搞出了局部變量j,幫我們解構了閉包與全局自由變量i多對1的關係。
foreach的底層實現有賴於IEnumerable
和IEnumerator
兩個接口的實現、
這裏也有一個永久更新的原創文,👇IEnumerator、IEnumerable還傻傻分不清楚?
但是怎麼用這個兩個接口,還需要看foreach僞代碼:
C# foreach foreach (V v in x) «embedded_statement»
被翻譯成下面代碼。
{
E e = ((C)(x)).GetEnumerator();
try
{
while (e.MoveNext())
{
V v = (V)(T)e.Current; // 注意這句, 變量v的定義是在循環體內
«embedded_statement»
}
}
finally
{
... // Dispose e
}
}
請注意註釋,變量v的定義是在循環內部, 因此使用foreach迭代時,每個閉包捕獲的都是局部的自由變量, 因此foreach閉包執行時輸出0,1,2,3,4。
如果變量V v定義在while語言上方,那麼效果就和for循環一樣了。
這是for循環/foreach迭代一個很有意思的差異。
以上理解透徹之後,我們再看Golang的for循環陷阱, 也就很容易理解了。
package main
import "fmt"
var slice []func()
func main() {
sli := []int{1, 2, 3, 4, 5}
for _, v := range sli {
fmt.Println(&v, v)
slice = append(slice, func() {
fmt.Println(v)
})
}
for _, val := range slice {
val()
}
}
--- output ---
0xc00001c098 1
0xc00001c098 2
0xc00001c098 3
0xc00001c098 4
0xc00001c098 5
5
5
5
5
5
golang for循環的使用姿勢類似於C#的 foreach, 但是內核卻是for循環。
每個閉包引用的都是(相對全局的)自由變量v,最終閉包執行的是同一個變量。
應對這種陷阱的思路,依舊是使用循環內局部變量去解構閉包與相對全局變量v的關係。
另外 閉包 foreach 還能與多線程結合,又有不一樣的現象。
畫外音
本文其實內容很多:
- 閉包:是在詞法環境中捕獲自由變量的頭等函數
- foreach 語法糖:依賴於IEnumerable和IEnumerator 接口實現,同時 foreach每次迭代使用的是塊內局部變量, for循環變量是相對的全局變量, 也正是這個差異,導致了投票題的結果。
每一個知識點都是比較重要且晦澀難懂,篇幅有限,請適時關注文中給出的幾個永久更新地址。