前言

上一篇文章,我開始理解不同 Collection 存在的原因。Array、List、Dictionary、Queue、Stack 各自解決不同的資料管理問題,底層實作也不一樣。

理解這件事後,我突然想到另一個問題。

如果每一種 Collection 都長得不一樣,為什麼 foreach 卻能遍歷所有 Collection?

例如:

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

foreach (var item in list)
{
    Console.WriteLine(item);
}

可以正常運作。換成 Dictionary:

Dictionary<int, string> dictionary = new()
{
    { 1, "Jack" },
    { 2, "Amy" }
};

foreach (var item in dictionary)
{
    Console.WriteLine(item);
}

也完全沒問題,甚至 Queue、Stack、HashSet 都能使用 foreach

它們明明是不同的資料結構,foreach 又是怎麼做到的?


如果 foreach 必須認識每一種 Collection

我試著站在 .NET Framework 設計者的角度思考。

假設沒有任何共同規範,那 foreach 要怎麼設計?

最直接的方法,大概會變成:

如果是 List
    就用 List 的方式遍歷

如果是 Dictionary
    就用 Dictionary 的方式遍歷

如果是 Queue
    就用 Queue 的方式遍歷

...

問題很快就出現了:每新增一種 Collection,foreach 就必須修改一次。如果有人自己寫了一個新的 Collection,foreach 也不知道該怎麼遍歷它。

這種設計很難擴充。


foreach 真正需要知道什麼?

既然 foreach 不可能認識每一種 Collection,那它真正需要知道的是什麼?

我發現,foreach 其實不需要知道:

  • 資料怎麼存
  • 底層是不是 Array
  • 有沒有使用 Hash Table
  • 是 Queue 還是 Stack

它真正需要的只有一件事:

要怎麼逐一取得下一個元素?

只要物件能回答這個問題,foreach 就能一直工作。這也是我第一次開始理解 IEnumerable 的設計目的。


IEnumerable 解決了什麼問題?

IEnumerable 不在意 Collection 使用什麼資料結構。List 可以用動態陣列,Dictionary 可以使用 Hash Table,Queue 與 Stack 也能維持各自的順序規則。

它只要求一件事:

透過 GetEnumerator() 提供 Enumerator。

Enumerator 再透過 MoveNext()Current 提供逐一取得元素的方式。每一種 Collection 因此可以保留自己的內部實作,同時讓 foreach 用一致的流程遍歷。

嚴格來說,C# 的 foreach 也支援符合 GetEnumerator()MoveNext()Current 形式的型別,不一定非得宣告實作 IEnumerable。不過 .NET Collection 透過 IEnumerable<T> 建立共同介面,才能讓其他 API 也用一致的型別與它們合作。


foreach 真正合作的對象

以前我一直以為,foreach 知道每一種 Collection 的走法。後來才發現,它只知道怎麼和 Enumerator 合作。

真正的流程比較像這樣:

List
Dictionary
Queue
Stack
HashSet
  IEnumerable
  IEnumerator
 foreach

Collection 負責提供 Enumerator,而 Enumerator 知道如何走訪自己的資料。foreach 只要反覆呼叫 MoveNext(),並透過 Current 取得目前的元素。

因此,不管底層如何實作,foreach 的程式碼都完全不用修改。


IEnumerator 到底做了什麼?

如果把 Enumerator 想像成一個游標,整件事情就容易理解很多。

假設目前有一組資料。

10 → 20 → 30 → 40

Enumerator 會記住目前的位置。一開始,它還沒有指向任何元素。

^

第一次呼叫:

MoveNext();

游標移到第一個元素。

10 → 20 → 30 → 40
^

這時可以透過:

Current

取得目前的值。

第二次呼叫:

MoveNext();

游標往後移。

10 → 20 → 30 → 40
     ^

這個過程會一直重複,直到沒有下一個元素。此時 MoveNext() 回傳 falseforeach 便知道遍歷結束。


原來 foreach 背後一直在做這件事

平常我們只會寫:

foreach (var item in list)
{
    Console.WriteLine(item);
}

編譯器會把它轉換成接近下面的流程:

using var enumerator = list.GetEnumerator();

while (enumerator.MoveNext())
{
    var item = enumerator.Current;

    Console.WriteLine(item);
}

實際展開方式會依 Enumerator 的型別而有些差異,包含如何處理 Dispose();核心流程仍然是取得 Enumerator、呼叫 MoveNext(),再讀取 Currentforeach 把這些細節包裝起來,讓程式碼更容易閱讀。


新的 Collection 也能直接加入合作

假設今天我要自己設計一個 Collection。

public class StudentCollection
{
    ...
}

只要它實作 IEnumerable<T>,提供對應的 Enumerator:

foreach (var student in studentCollection)
{
    ...
}

就可以直接使用。.NET Framework 不需要修改,foreach 也不用知道 StudentCollection 的內部實作。雙方透過共同的介面完成合作。

這也讓我理解了物件導向設計中一個很重要的概念:

面向介面設計,而不是依賴具體實作。


我最大的收穫

以前我學 IEnumerable,只記住一句話:

可以讓 foreach 使用。

但我一直不知道原因。直到開始從 Framework 設計者的角度思考,整件事才串起來。

foreach 不需要了解每一種 Collection,Collection 也不必為 foreach 提供不同版本。雙方只要遵守同一套列舉規範,就能自然合作。這就是 IEnumerable 的價值。


快速複習

不同 Collection
底層實作完全不同
foreach 不可能認識所有 Collection
建立共同規範
IEnumerable
取得 Enumerator
MoveNext() + Current
完成遍歷

結語

以前我會問:

IEnumerable 是什麼?

現在我比較常問另一個問題。

如果沒有 IEnumerableforeach 要怎麼遍歷所有 Collection?

當我從這個角度思考時,IEnumerable 不再只是「可以讓 foreach 使用」的介面,而是 .NET 用來統一列舉方式的設計。

理解這一點後,下一篇要探討的 yield return 就自然多了,因為我開始好奇另一件事。

Enumerator 到底是誰建立的?Compiler 又偷偷幫我們做了哪些事情?