2017 年 4 月

第 32 卷,第 4 期

.NET 本质论 - 了解 C# foreach 的内部工作原理和使用 yield 的自定义迭代器

作者 Mark Michaelis

Mark Michaelis在本期专栏中,我将介绍我们在编程时经常用到的 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 所示。

IEnumerator 和 IEnumerator 接口的类图
图 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>.Enumerator
  enumerator;
// ...
// 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>.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<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>.Enumerator
    enumerator = 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 Members
  System.Collections.IEnumerator
    System.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.IEnumerator
    System.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