本文转自https://msdn.microsoft.com/zh-cn/magazine/mt797654 和 https://msdn.microsoft.com/zh-cn/magazine/mt809121
虽然 foreach 语句编码起来很容易,但很少有开发者了解它的内部工作原理,这让我感到非常惊讶。例如,你是否注意到 foreach 对数组的运行方式不同于 IEnumberable
根据定义,Microsoft .NET Framework 集合是至少可实现 IEnumerable
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
System.Collections.Generic.IEnumerator
图 1:IEnumerator
IEnumerator
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
System.Collections.Generic.Stack<int> stack = new System.Collections.Generic.Stack<int>(); int number; System.Collections.Generic.Stack<int>.Enumerator enumerator; // ... // If IEnumerableis implemented explicitly, // then a cast is required. // ((IEnumerable)stack).GetEnumerator(); enumerator = stack.GetEnumerator(); while (enumerator.MoveNext()) { number = enumerator.Current; Console.WriteLine(number); }
迭代后清除状态: 由于实现 IEnumerator
System.Collections.Generic.Stack<int> stack = new System.Collections.Generic.Stack<int>(); System.Collections.Generic.Stack<int>.Enumerator enumerator; IDisposable disposable; enumerator = stack.GetEnumerator(); try { int number; while (enumerator.MoveNext()) { number = enumerator.Current; Console.WriteLine(number); } } finally { // Explicit cast used for IEnumerator. 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
System.Collections.Generic.Stack<int> stack = new System.Collections.Generic.Stack<int>(); int number; using( System.Collections.Generic.Stack<int>.Enumerator enumerator = stack.GetEnumerator()) { while (enumerator.MoveNext()) { number = enumerator.Current; Console.WriteLine(number); } }
然而,重新调用 CIL 并不直接支持 using 关键字。因此,图 3 中的代码实际上是用 C# 更精准表示的 foreach CIL 代码。
在不实现 IEnumerable 的情况下使用 foreach: C# 不要求必须实现 IEnumerable/IEnumerable
至此,你已了解 foreach 的内部实现代码,是时候了解如何使用迭代器创建 IEnumerator
枚举模式存在的问题是,手动实现起来不方便,因为必须始终指示描述集合中的当前位置所需的全部状态。对于列表集合类型类,指示这种内部状态可能比较简单;当前位置的索引就足够了。相比之下,对于需要递归遍历的数据结构(如二叉树),指示状态可能就会变得相当复杂。为了减少实现此模式所带来的挑战,C# 2.0 新增了 yield 上下文关键字,这样类就可以更轻松地决定 foreach 循环如何迭代其内容。
定义迭代器:迭代器是更为复杂的枚举器模式的快捷语法,用于实现类的方法。如果 C# 编译器遇到迭代器,它会将其内容扩展到实现枚举器模式的 CIL代码中。因此,实现迭代器时没有运行时依赖项。由于 C# 编译器通过生成 CIL 代码处理实现代码,因此使用迭代器无法获得真正的运行时性能优势。不过,使用迭代器取代手动实现枚举器模式可以大大提高程序员的工作效率。为了理解这一优势,我将先思考一下,如何在代码中定义迭代器。
迭代器语法: 迭代器提供迭代器接口(IEnumerable
using System; using System.Collections.Generic; public class BinaryTree: IEnumerable { public BinaryTree ( T value) { Value = value; } #region IEnumerable public IEnumerator GetEnumerator() { // ... } #endregion IEnumerable public T Value { get; } // C# 6.0 Getter-only Autoproperty public Pair > SubItems { get; set; } } public struct Pair : IEnumerable { public Pair(T first, T second) : this() { First = first; Second = second; } public T First { get; } public T Second { get; } #region IEnumerable public IEnumerator GetEnumerator() { yield return First; yield return Second; } #endregion IEnumerable #region IEnumerable Members System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { return GetEnumerator(); } #endregion // ... }
通过迭代器生成值: 迭代器接口类似于函数,不同之处在于一次生成一系列值,而不是返回一个值。如果为 BinaryTree
为了正确实现迭代器模式,必须始终指示某内部状态,以便在枚举集合的同时跟踪当前位置。如果为 BinaryTree
每当迭代器遇到 yield return 语句,都会生成值;控制权会立即重归请求获取此项的调用方。当调用方请求获取下一项时,之前执行的 yield return 语句后面紧接着的代码便会开始执行。在图 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 IEnumerablederives from IEnumerable. System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { // Invoke IEnumeratorGetEnumerator() above. return GetEnumerator(); } } public class Program { static void Main() { var keywords = new CSharpBuiltInTypes(); foreach (string keyword in keywords) { Console.WriteLine(keyword); } } }
图 6 的结果如图 7 所示,即 C# 内置类型的列表。
object byte uint ulong float char bool ushort decimal int sbyte short long void double string
很显然,这需要有更多说明,但由于本期专栏的空间有限,我将在下一期专栏中对此进行说明,给大家留个悬念。我只想说,借助迭代器,可以神奇般地将集合创建为属性,如图图 8 所示。在此示例中,依赖 C# 7.0 元组只是因为这样做比较有趣。若要进一步了解,可以查看源代码,也可以参阅我的“C# 本质论”一书的第 16 章。
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# foreach 语句的工作方式,并解释了 C# 编译器如何通过公共中间语言 (CIL) 实现 foreach 功能。 我还通过举例简单地提了一下 yield 关键字(见图 1),但几乎未做任何解释。
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 IEnumerablederives from IEnumerable. System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { // Invoke IEnumeratorGetEnumerator() above. return GetEnumerator(); } } public class Program { static void Main() { var keywords = new CSharpBuiltInTypes(); foreach (string keyword in keywords) { Console.WriteLine(keyword); } } }
本文将在上一篇文章的基础之上,继续详细介绍 yield 关键字及其用法。
通过在图 1 中的 GetEnumerator 方法开头添加断点,可以看到 GetEnumerator 在 foreach 语句开头处得到调用。 此时,将创建迭代器对象,它的状态会初始化成特殊的“开始”状态,表示迭代器中尚未执行任何代码,因而也尚未生成任何值。至此以后,只要调用站点上的 foreach 语句继续执行,迭代器就会保持其状态(位置)。每当循环请求获取下一个值时,控制权都会授予迭代器,并接着上次的循环进度继续执行;迭代器对象中存储的状态信息用于确定必须在哪里恢复控制权。当调用站点上的 foreach 语句终止时,将不再保存迭代器的状态。图 2 展示了所发生事件的简要序列图。请注意,MoveNext 方法出现在 IEnumerator
在图 2 中,调用站点上的 foreach 语句对称为关键字的 CSharpBuiltInTypes 实例调用 GetEnumerator。可以看到,再次调用 GetEnumerator 始终都是安全的;将根据需要创建“新的”枚举器。鉴于迭代器引用的迭代器实例,foreach 通过调用 MoveNext 开始每次迭代。在迭代器中,生成返回给调用站点上的 foreach 语句的值。在 yield return 语句之后,GetEnumerator 方法貌似在出现下一个 MoveNext 请求之前一直暂停。再回到循环体,foreach 语句在屏幕上显示生成的值。然后,循环回再次对迭代器调用 MoveNext。请注意,第二次控制权会授予第二个 yield return 语句。foreach 将再次在屏幕上显示 CSharpBuiltInTypes 生成的值,并重新开始循环。这个过程会一直持续下去,直到迭代器中没有其他任何 yield return 语句时为止。这时,调用站点上的 foreach 循环将终止,因为 MoveNext 返回了 false。
图 2:含 yield return 语句的序列图
以类似示例为例,其中包含我在上一篇文章中介绍过的 BinaryTree
在图 3 中,Pair
public struct Pair: IPair , IEnumerable { public Pair(T first, T second) : this() { First = first; Second = second; } public T First { get; } // C# 6.0 Getter-only Autoproperty public T Second { get; } // C# 6.0 Getter-only Autoproperty #region IEnumerable public IEnumerator GetEnumerator() { yield return First; yield return Second; } #endregion IEnumerable #region IEnumerable Members System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { return GetEnumerator(); } #endregion }
System.Collections.Generic.IEnumerable
下面的代码使用 Pair
var fullname = new Pair<string>("Inigo", "Montoya"); foreach (string name in fullname) { Console.WriteLine(name); }
无需对每个 yield return 语句进行硬编码,就像我在 CSharpPrimitiveTypes 和 Pair
public class BinaryTree: IEnumerable { // ... #region IEnumerable public IEnumerator GetEnumerator() { // Return the item at this node. yield return Value; // Iterate through each of the elements in the pair. foreach (BinaryTree tree in SubItems) { if (tree != null) { // Because each element in the pair is a tree, // traverse the tree and yield each element. foreach (T item in tree) { yield return item; } } } } #endregion IEnumerable #region IEnumerable Members System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { return GetEnumerator(); } #endregion }
在图 4 中,第一个迭代返回二叉树中的根元素。在第二次迭代期间,将遍历这对子元素。如果子元素对包含非 null 值,将遍历相应的子节点并生成其元素。请注意,foreach (T item in tree) 是对子节点的递归调用。
就像使用 CSharpBuiltInTypes 和 Pair
// JFK var jfkFamilyTree = new BinaryTree<string>( "John Fitzgerald Kennedy"); jfkFamilyTree.SubItems = new Pairstring>>( new BinaryTree<string>("Joseph Patrick Kennedy"), new BinaryTree<string>("Rose Elizabeth Fitzgerald")); // Grandparents (Father's side) jfkFamilyTree.SubItems.First.SubItems = new Pair string>>( new BinaryTree<string>("Patrick Joseph Kennedy"), new BinaryTree<string>("Mary Augusta Hickey")); // Grandparents (Mother's side) jfkFamilyTree.SubItems.Second.SubItems = new Pair string>>( new BinaryTree<string>("John Francis Fitzgerald"), new BinaryTree<string>("Mary Josephine Hannon")); foreach (string name in jfkFamilyTree) { Console.WriteLine(name); }
生成的结果如下:
John Fitzgerald Kennedy Joseph Patrick Kennedy Patrick Joseph Kennedy Mary Augusta Hickey Rose Elizabeth Fitzgerald John Francis Fitzgerald Mary Josephine Hannon
1972 年,Barbara Liskov 和麻省理工学院的一群科学家开始研究编程方法,将重点放在了用户定义的数据抽象上。为了证明他们完成的大量工作,他们创建了一种叫做 CLU 的语言,提出了名为“群集”的概念(CLU 就是“群集”英文单词的前三个字母)。群集是程序员当今使用的主要数据抽象(即“对象”)的前身。在研究过程中,此团队意识到,虽然他们可以使用 CLU 语言从最终用户的数据类型中抽象出某种数据表示,但经常发现必须揭示数据的内部结构,这样其他人才能智能地使用数据。让他们感到惊愕的结果是,创造了称为“迭代器”的语言构造。(借助 CLU 语言,人们可以更好地理解最终推广的“面向对象的编程”。)
有时可能希望取消进一步迭代。为此,可以添加 if 语句,从而不再执行代码中的其他任何语句。不过,也可以使用 yield break 让 MoveNext 返回 false,并将控制权立即返回给调用方,同时结束循环。下面的示例展示了此类方法:
public System.Collections.Generic.IEnumerableGetNotNullEnumerator() { if((First == null) || (Second == null)) { yield break; } yield return Second; yield return First; }
如果 Pair
yield break 语句类似于在确定没有要执行的操作时,将 return 语句置于函数顶部。这样一来,无需使用 if 代码块将所有剩余代码围住,即可退出进一步迭代。因此,可以多次退出。请谨慎使用这种方法,因为随意读取代码可能会忽视早期退出。
遇到迭代器时,C# 编译器会将代码扩展到相应枚举器设计模式的适当 CIL 中。在生成的代码中,C# 编译器会先创建嵌套的私有类来实现 IEnumerator
using System; using System.Collections.Generic; public class Pair: IPair , IEnumerable { // ... // The iterator is expanded into the following // code by the compiler. public virtual IEnumerator GetEnumerator() { __ListEnumerator result = new __ListEnumerator(0); result._Pair = this; return result; } public virtual System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { return new GetEnumerator(); } private sealed class __ListEnumerator : IEnumerator { public __ListEnumerator(int itemCount) { _ItemCount = itemCount; } Pair _Pair; T _Current; int _ItemCount; public object Current { get { return _Current; } } public bool MoveNext() { switch (_ItemCount) { case 0: _Current = _Pair.First; _ItemCount++; return true; case 1: _Current = _Pair.Second; _ItemCount++; return true; default: return false; } } } }
由于编译器需要使用 yield return 语句,并生成与可能已手动编写的内容对应的类,因此 C# 迭代器与手动实现枚举器设计模式的类的性能特征相同。虽然性能没有得到提升,但程序员的工作效率得到了大幅提升。
上面的迭代器示例实现了 IEnumerable
public struct Pair: IEnumerable { ... public IEnumerable GetReverseEnumerator() { yield return Second; yield return First; } ... } public void Main() { var game = new Pair<string>("Redskins", "Eagles"); foreach (string name in game.GetReverseEnumerator()) { Console.WriteLine(name); } }
请注意,返回的是 IEnumerable
可以只在返回 IEnumerator
如果违反以下关于 yield 语句的其他限制,则会导致编译器错误生成:
绝大程度上,泛型是 C# 2.0 中推出的一项炫酷功能,但它并不是当时推出的唯一一项集合相关功能。另一项重要补充功能就是迭代器。就像我在本文中概述的一样,迭代器涉及上下文关键字 yield。C# 使用此关键字生成基础 CIL 代码来实现 foreach 循环使用的迭代器模式。 此外,我还详细介绍了 yield 语法,此语法通过 GetEnumerator 实现了 IEnumerable