你認識的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的底層實現有賴於IEnumerableIEnumerator兩個介面的實現、

這裡也有一個永久更新的原創文,👇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
    }
}

👇 foreach官方信源

請注意注釋,變數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循環變數是相對的全局變數, 也正是這個差異,導致了投票題的結果。

每一個知識點都是比較重要且晦澀難懂,篇幅有限,請適時關注文中給出的幾個永久更新地址。

Tags: