一、foreach 與 IEnumerable、IEnumerable<T> 的關係

上篇文章中,和大家說明了 IEnumerable、IEnumerator 與它們的泛型版本 IEnumerable<T>、IEnumerator<T> 的原理與作用後,本篇將再進一步說明,IEnumerable 與 IEnumerator 的用途。

大家在看了列舉後,可能會發現,這種「一個個將資料集合中的元素取出來」的行為,和 C# 語言中的 foreach 行為很接近?假設我們有個整數串列如下︰

List<int> myList = new List<int>() { 1, 2, 3 };

典型的 foreach 用例如下︰

foreach (int item in myList)
{
    Console.WriteLine(item);
}

相同的範例,使用列舉器 (Enumerator) 的方式如下︰

IEnumerator<int> enumerator = myList.GetEnumerator();
while (enumerator.MoveNext())
{
    int item = enumerator.Current;
    Console.WriteLine(item);
}

大家可以比較看看,基本上兩者的結構非常接近,只是使用列舉器的版本,在使用上比較繁瑣一點。

而實際上,foreach 關鍵字其實是 C# 提供的語法糖衣 (Syntactic sugar),foreach 的程式在被編譯後,轉成中間程式碼 (IL) 時,其實編譯器會將 foreach 的程式,轉為列舉器 (Enumerator) 的版本。

為了証明這點,我們可以使用 IL DASM,將上述兩段程式,在編譯後,分別觀看其 IL 程式碼。

foreach 的程式,其 IL 如下︰

image

列舉器的程式,其 IL 如下︰

image

可以看到 foreach 版的 IL 除了多了一些 try cache 的程式片段外,基本結構與列舉器的 IL 程式是相同的。foreach 版本的 IL,會將 in 關鍵字後的變數,轉化為取此變數的列舉器 (GetEnumerator() 方法),並在後續程式中,使用此列舉器取出資料

也因為 foreach 會將 in 後的變數,轉化為使用變數的 GetEnumerator() 方法來取出列舉器,因此編譯器會要求,in 關鍵字後的變數,必需要實作 Getenumerator() 方法。

我們可以試著在 in 關鍵字後,傳入沒有實作 GenEnumerator() 的型別變數︰

int foo = 3;
foreach (int item in foo)
{
    Console.WriteLine();
}

將上述程式進行編譯,編譯器會報以下的錯誤訊息︰

foreach statement cannot operate on variables of type ‘int’ because ‘int’ does not contain a public definition for ‘GetEnumerator’

表示 foreach 無法使用沒有實作 GetEnumerator() 方法來做列舉的動作。

而這也是為什麼 MSDN 文件上,會說明希望可以被 foreach 操作的型別,都要實作 IEnumerable 或是 IEnumerable<T> 界面的原因,雖然我們不實作界面,僅實作 GetEnumerator() 方法,也可以給 foreach 使用。

但我覺得,實作了 IEnumerable 或 IEnumerable<T> 界面,至少在語意上,會讓使用此型別的人了解,此型別是「可列舉的」、「可被用於 foreach 中」的,而不必再去了解該型別下的方法列表,有沒有 GetEnumerator() 方法的實作,會比較簡單一點,也會提高使用上的一致性。

因此,針對上述文章的主程式,我們其實可以改為使用以下的 foreach 用法︰

MyWeekdayList weekdayList = new MyWeekdayList();

//// 逐一印出每日的名稱
foreach (var day in weekdayList)
{
    Console.WriteLine(day);
}
二、Iterator 與 yield

由上篇的說明,我們可以看到,要實作一個資料結構型別,並使它具備可被列舉的能力,所需要的步驟實在太繁瑣了,整理步驟如下︰

  • 此型別需實作 IEnumerable 或 IEnumerable<T> 型別,並實作其 GetEnumerator() 方法
  • GetEnumerator() 內容中,需回傳實作 IEnumerator 或 IEnumerator<T> 界面的列舉器型別物件
  • 最後再實作此列舉器型別,加入其中的 MoveNext()、Reset() 方法與 Current 屬性

因為步驟實在太複雜,所以 C# 提供了更為簡便的方式,也就是使用 Iterator 的做法。

Iterator 是一種 Design Pattern,我摘列「大話設計模式」一書中,對此模式的說明︰

Iterator 提供一種方法依序存取一個聚合物件中各個元素,而又不暴露該物件的內部表示。

而在 MSDN 文件上,針對 Iterator 的說明,摘列其中幾項重點說明如下︰

  • Iterator 是程式碼區段,會傳回相同型別之按順序排列的值。
  • 在為類別或結構 (Struct) 建立 Iterator 時,您並不需要實作整個 IEnumerator 介面。 編譯器會在偵測到您的 Iterator 時,自動產生 IEnumerator 或 IEnumerator<T> 介面的 Current、MoveNext 和 Dispose 方法。
  • yield 傳回陳述式 (Statement) 會使來源序列 (Sequence) 中的項目,在即將存取來源序列中的下一個項目時傳回到呼叫端
  • yield 關鍵字會用來指定所傳回的單一個或多個值。 當到達 yield return 陳述式時,便會儲存目前的位置。 下一次呼叫此 Iterator 時,便會從這個位置重新開始執行。

是不是感覺,yield 關鍵字的作用,兼具了之前我們討論列舉器時,所談到的指標與 Current 屬性、MoveNext() 方法呢?

事實上,yield 關鍵字,在 C# 中也是另一個語法糖衣,Compiler 在編譯時,編譯器其實也會在編譯時,將它轉為,有實作 IEnumerable<T> 等界面的類別,這部份可以看 Dot Net Perls 的 Yield Tutorial 文章。

我們將 MyWeekdayList 中,相關 Iterator 的程式碼,在編譯後,以 dotPeek 對執行檔做 Decompile 的動作,看看編譯器在後面為我們做了什麼事?

節錄抓圖如下︰

image

完整 Decompile 後的程式,我也放置在 Gist 上︰https://gist.github.com/LittleLin/5463721

但事實上 yield 除了可以協助產生 Iterator 之外,它也具備有所謂「延遲執行」的功能,這部份有興趣的朋友,可以閱讀老趙的Why Java Sucks and C# Rocks(6):yield及其作用一文。

References