了解 C# foreach 内部语句和使用 yield 实现的自定义迭代器
在本期專欄中,我將介紹我們在編程時經常用到的 C# 核心構造(即 foreach 語句)的內部工作原理。了解 foreach 內部行為后,便可以探索如何使用 yield 語句實現 foreach 集合接口,我將對此進行介紹。
雖然 foreach 語句編碼起來很容易,但很少有開發者了解它的內部工作原理,這讓我感到非常驚訝。例如,你是否注意到 foreach 對數組的運行方式不同于 IEnumberable<T> 集合嗎? 你對 IEnumerable<T> 和 IEnumerator<T> 之間關系的熟悉程度如何? 而且,就算你了解可枚舉接口,是否熟練掌握使用 yield 語句實現此類接口呢??
集合類的關鍵要素
根據定義,Microsoft .NET Framework 集合是至少可實現 IEnumerable<T>(或非泛型 IEnumerable 類型)的類。此接口至關重要,因為至少必須實現 IEnumerable<T> 的方法,才支持迭代集合。
foreach 語句語法十分簡單,開發者無需知道元素數量,避免編碼過于復雜。不過,運行時并不直接支持 foreach 語句。C# 編譯器會轉換代碼,接下來的部分會對此進行介紹。
foreach 和數組: 下面展示了簡單的 foreach 循環,用于迭代整數數組,然后將每個整數打印輸出到控制臺中:
int[] array = new int[]{1, 2, 3, 4, 5, 6};foreach (int item in array) {Console.WriteLine(item); }在此代碼中,C# 編譯器為 for 循環創建了等同的 CIL:
int[] tempArray;int[] array = new int[]{1, 2, 3, 4, 5, 6}; tempArray = array;for (int counter = 0; (counter < tempArray.Length); counter++) {int item = tempArray[counter];Console.WriteLine(item); }在此示例中,請注意,foreach 依賴對 Length 屬性和索引運算符 ([]) 的支持。借助 Length 屬性,C# 編譯器可以使用 for 語句迭代數組中的每個元素。
foreach 和 IEnumerable<T> 集合: 雖然前面的代碼適用于長度固定且始終支持索引運算符的數組,但并不是所有類型集合的元素數量都是已知的。此外,許多集合類(包括 Stack<T>、Queue<T> 和 Dictionary<TKey and TValue>)都不支持按索引檢索元素。因此,需要使用一種更為通用的方法來迭代元素集合。迭代器模式就派上用場了。假設可以確定第一個、第二個和最后一個元素,那么就沒有必要知道元素數量,也沒有必要支持按索引檢索元素。
System.Collections.Generic.IEnumerator<T> 和非泛型 System.Collections.IEnumerator 接口旨在啟用迭代器模式(而不是前面介紹的長度索引模式)來迭代元素集合。它們的關系類圖如圖 1?所示。
圖 1:IEnumerator<T> 和 IEnumerator 接口的類圖
IEnumerator<T> 派生自的 IEnumerator 包含三個成員。第一個成員是布爾型 MoveNext。使用這種方法,可以在集合中從一個元素移到下一個元素,同時檢測是否已枚舉完所有項。第二個成員是只讀屬性 Current,用于返回當前處理的元素。Current 在 IEnumerator<T> 中進行重載,提供按類型分類的實現代碼。借助集合類中的這兩個成員,只需使用 while 循環,即可迭代集合:
System.Collections.Generic.Stack<int> stack =new System.Collections.Generic.Stack<int>();int number;// ...// This code is conceptual, not the actual code.while (stack.MoveNext()) {number = stack.Current;Console.WriteLine(number); }在此代碼中,當移到集合末尾時,MoveNext 方法返回 false。這樣一來,便無需在循環的同時計算元素數量。
(Reset 方法通常會拋出 NotImplementedException,因此不得進行調用。如果需要重新開始枚舉,只要新建一個枚舉器即可。)
前面的示例展示的是 C# 編譯器輸出要點,但實際上并非按此方式進行編譯,因為其中略去了兩個重要的實現細節:交錯和錯誤處理。
狀態為共享: 前面示例中展示的實現代碼存在一個問題,即如果兩個此類循環彼此交錯(一個 foreach 在另一個循環內,兩個循環使用相同的集合),集合必須始終有當前元素的狀態指示符,以便在調用 MoveNext 時,可以確定下一個元素。在這種情況下,交錯的一個循環可能會影響另一個循環。(對于多個線程執行的循環,也是如此。)
為了解決此問題,集合類不直接支持 IEnumerator<T> 和 IEnumerator 接口。而是直接支持另一種接口 IEnumerable<T>,其唯一方法是 GetEnumerator。此方法用于返回支持 IEnumerator<T> 的對象。不必使用始終指示狀態的集合類,而是可以使用另一種類,通常為嵌套類,這樣便有權訪問集合內部,從而支持 IEnumerator<T> 接口,并始終指示迭代循環的狀態。枚舉器就像是序列中的“游標”或“書簽”。可以有多個“書簽”,移動其中任何一個都可以枚舉集合,與其他枚舉器互不影響。使用此模式,foreach 循環的 C# 等同代碼如圖 2?所示。
圖 2:迭代期間始終指示狀態的獨立枚舉器
System.Collections.Generic.Stack<int> stack =new System.Collections.Generic.Stack<int>();int number; System.Collections.Generic.Stack<int>.Enumeratorenumerator;// ...// If IEnumerable<T> is implemented explicitly,// then a cast is required.// ((IEnumerable<int>)stack).GetEnumerator();enumerator = stack.GetEnumerator();while (enumerator.MoveNext()) {number = enumerator.Current;Console.WriteLine(number); }迭代后清除狀態: 由于實現 IEnumerator<T> 接口的類始終指示狀態,因此有時需要在退出循環后清除狀態(因為要么所有迭代均已完成,要么拋出異常)。為此,從 IDisposable 派生 IEnumerator<T> 接口。實現 IEnumerator 的枚舉器不一定實現 IDisposable,-但如果實現了,同樣也會調用 Dispose。這樣可以在退出 foreach 循環后調用 Dispose。因此,最終 CIL 的 C# 等同代碼如圖 3?所示。
圖 3:對集合執行 foreach 的編譯結果
System.Collections.Generic.Stack<int> stack =new System.Collections.Generic.Stack<int>(); System.Collections.Generic.Stack<int>.Enumeratorenumerator; IDisposable disposable; enumerator = stack.GetEnumerator();try{int number;while (enumerator.MoveNext()){number = enumerator.Current;Console.WriteLine(number);} }finally{// Explicit cast used for IEnumerator<T>.? disposable = (IDisposable) enumerator;disposable.Dispose();// IEnumerator will use the as operator unless IDisposable? // support is known at compile time.? // disposable = (enumerator as IDisposable);? // if (disposable != null)? // {? //?? disposable.Dispose();? // }}請注意,由于 IEnumerator<T> 支持 IDisposable 接口,因此 using 語句可以將圖 3?中的代碼簡化為圖 4?中的代碼。
圖 4:使用 using 執行錯誤處理和資源清除
System.Collections.Generic.Stack<int> stack =new System.Collections.Generic.Stack<int>();int number;using(System.Collections.Generic.Stack<int>.Enumeratorenumerator = stack.GetEnumerator()) {while (enumerator.MoveNext()){number = enumerator.Current;Console.WriteLine(number);} }然而,重新調用 CIL 并不直接支持 using 關鍵字。因此,圖 3 中的代碼實際上是用 C# 更精準表示的 foreach CIL 代碼。
在不實現?IEnumerable 的情況下使用 foreach: C# 不要求必須實現 IEnumerable/IEnumerable<T> 才能使用 foreach 迭代數據類型。編譯器改用鴨子類型這一概念;它使用 Current 屬性和 MoveNext 方法查找可返回類型的 GetEnumerator 方法。鴨子類型涉及按名稱搜索,而不依賴接口或顯式方法調用。(“鴨子類型”一詞源自將像鴨子一樣的鳥視為鴨子的怪誕想法,對象必須僅實現 Quack 方法,無需實現 IDuck 接口。) 如果鴨子類型找不到實現的合適可枚舉模式,編譯器便會檢查集合是否實現接口。
迭代器簡介
至此,你已了解 foreach 的內部實現代碼,是時候了解如何使用迭代器創建 IEnumerator<T>、IEnumerable<T> 和自定義集合對應的非泛型接口的自定義實現代碼了。迭代器提供明確的語法,用于指定如何迭代集合類中的數據,尤其是使用 foreach 循環。這樣一來,集合的最終用戶就可以瀏覽其內部結構,而無需知道相應結構。
枚舉模式存在的問題是,手動實現起來不方便,因為必須始終指示描述集合中的當前位置所需的全部狀態。對于列表集合類型類,指示這種內部狀態可能比較簡單;當前位置的索引就足夠了。相比之下,對于需要遞歸遍歷的數據結構(如二叉樹),指示狀態可能就會變得相當復雜。為了減少實現此模式所帶來的挑戰,C# 2.0 新增了 yield 上下文關鍵字,這樣類就可以更輕松地決定 foreach 循環如何迭代其內容。
定義迭代器:迭代器是更為復雜的枚舉器模式的快捷語法,用于實現類的方法。如果 C# 編譯器遇到迭代器,它會將其內容擴展到實現枚舉器模式的 CIL代碼中。因此,實現迭代器時沒有運行時依賴項。由于 C# 編譯器通過生成 CIL 代碼處理實現代碼,因此使用迭代器無法獲得真正的運行時性能優勢。不過,使用迭代器取代手動實現枚舉器模式可以大大提高程序員的工作效率。為了理解這一優勢,我將先思考一下,如何在代碼中定義迭代器。
迭代器語法: 迭代器提供迭代器接口(IEnumerable<T> 和 IEnumerator<T> 接口的組合)的簡單實現代碼。圖 5?通過創建 GetEnumerator 方法,聲明了泛型 BinaryTree<T> 類型的迭代器(盡管還沒有實現代碼)。
圖 5:迭代器接口模式
using System;using System.Collections.Generic;public class BinaryTree<T>:IEnumerable<T> {public BinaryTree ( T value){Value = value;}#region IEnumerable<T>public IEnumerator<T> GetEnumerator(){// ...? }#endregion IEnumerable<T>public T Value { get; }? // C# 6.0 Getter-only Autoproperty? public Pair<BinaryTree<T>> SubItems { get; set; } }public struct Pair<T>: IEnumerable<T> {public Pair(T first, T second) : this(){First = first;Second = second;}public T First { get; }public T Second { get; }#region IEnumerable<T>public IEnumerator<T> GetEnumerator(){yield return First;yield return Second;}#endregion IEnumerable<T>#region IEnumerable MembersSystem.Collections.IEnumeratorSystem.Collections.IEnumerable.GetEnumerator(){return GetEnumerator();}#endregion? // ...}通過迭代器生成值: 迭代器接口類似于函數,不同之處在于一次生成一系列值,而不是返回一個值。如果為 BinaryTree<T>,迭代器會生成一系列為 T 提供的類型參數值。如果使用非泛型版本 IEnumerator,生成的值將改為類型對象。
為了正確實現迭代器模式,必須始終指示某內部狀態,以便在枚舉集合的同時跟蹤當前位置。如果為 BinaryTree<T>,跟蹤樹中哪些元素已枚舉,以及哪些元素尚未枚舉。編譯器將迭代器轉換成“狀態機”,用于跟蹤當前位置,并確定如何“將自身移”到下一個位置。
每當迭代器遇到 yield return 語句,都會生成值;控制權會立即重歸請求獲取此項的調用方。當調用方請求獲取下一項時,之前執行的 yield return 語句后面緊接著的代碼便會開始執行。在圖 6?中,C# 內置數據類型關鍵字依序返回。
圖 6:依序生成一些 C# 關鍵字
using System;using System.Collections.Generic;public class CSharpBuiltInTypes: IEnumerable<string> {public IEnumerator<string> GetEnumerator(){yield return "object";yield return "byte";yield return "uint";yield return "ulong";yield return "float";yield return "char";yield return "bool";yield return "ushort";yield return "decimal";yield return "int";yield return "sbyte";yield return "short";yield return "long";yield return "void";yield return "double";yield return "string";}// The IEnumerable.GetEnumerator method is also required??? // because IEnumerable<T> derives from IEnumerable.? System.Collections.IEnumeratorSystem.Collections.IEnumerable.GetEnumerator(){// Invoke IEnumerator<string> GetEnumerator() above.??? return GetEnumerator();} }public class Program {static void Main(){var keywords = new CSharpBuiltInTypes();foreach (string keyword in keywords){Console.WriteLine(keyword);}} }圖 6?的結果如圖 7?所示,即 C# 內置類型的列表。
圖 7:圖 6 中代碼輸出的一些 C# 關鍵字的列表
object byte uint ulong float char bool ushort decimal int sbyte short long void double string很顯然,這需要有更多說明,但由于本期專欄的空間有限,我將在下一期專欄中對此進行說明,給大家留個懸念。我只想說,借助迭代器,可以神奇般地將集合創建為屬性,如圖圖 8?所示。在此示例中,依賴 C# 7.0 元組只是因為這樣做比較有趣。若要進一步了解,可以查看源代碼,也可以參閱我的“C# 本質論”一書的第 16 章。
圖 8:使用 yield return 實現 IEnumerable<T> 屬性
IEnumerable<(string City, string Country)> CountryCapitals {get? {yield return ("Abu Dhabi","United Arab Emirates");yield return ("Abuja", "Nigeria");yield return ("Accra", "Ghana");yield return ("Adamstown", "Pitcairn");yield return ("Addis Ababa", "Ethiopia");yield return ("Algiers", "Algeria");yield return ("Amman", "Jordan");yield return ("Amsterdam", "Netherlands");// ...? } }總結
在本期專欄中,我回顧了 C# 版本 1.0 及更高版本中的一項功能,此功能在 C# 2.0 中引入泛型后沒有改變太多。雖然此功能使用頻繁,但許多人都不了解它的內部工作原理。然后,我通過舉例泛泛地介紹了利用 yield return 構造的迭代器模式。
本期專欄的大部分內容截取自我的“C# 本質論”一書 (IntelliTect.com/EssentialCSharp),目前我正在修改“C# 7.0 本質論”。 有關詳細信息,請參閱此書的第 14 和 16 章。
Mark Michaelis?是 IntelliTect 的創始人,擔任首席技術架構師和培訓師。在近二十年的時間里,他一直是 Microsoft MVP,并且自 2007 年以來一直擔任 Microsoft 區域總監。Michaelis 還是多個 Microsoft 軟件設計評審團隊(包括 C#、Microsoft Azure、SharePoint 和 Visual Studio ALM)的成員。他在開發者會議上發表了演講,并撰寫了大量書籍,包括最新的“必備 C# 6.0(第 5 版)”(itl.tc/EssentialCSharp)。可通過他的 Facebook?facebook.com/Mark.Michaelis、博客?IntelliTect.com/Mark、Twitter?@markmichaelis?或電子郵件?mark@IntelliTect.com?與他取得聯系。
感謝以下 IntelliTect 技術專家對本文的審閱: Kevin Bost、Grant Erickson、Chris Finlayson、Phil Spokas 和 Michael Stokesbary
原文地址:https://msdn.microsoft.com/en-us/magazine/mt797654
.NET社區新聞,深度好文,微信中搜索dotNET跨平臺或掃描二維碼關注
總結
以上是生活随笔為你收集整理的了解 C# foreach 内部语句和使用 yield 实现的自定义迭代器的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: .NET的一点历史故事:误入歧途,越陷越
- 下一篇: C# 7 中的模范和实践