你認識的C# foreach語法糖,真的是全部嗎?
本文的知識點其實由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循環變數是相對的全局變數, 也正是這個差異,導致了投票題的結果。
每一個知識點都是比較重要且晦澀難懂,篇幅有限,請適時關注文中給出的幾個永久更新地址。