2017 年 6 月

第 32 卷,第 6 期

Essential .NET - 使用 Yield 的自定义迭代器

作者 Mark Michaelis

Mark Michaelis在我的上一篇专栏文章 (msdn.com/magazine/mt797654) 中,我深入研究了 C# foreach 语句的工作方式,并解释了 C# 编译器如何通过公共中间语言 (CIL) 实现 foreach 功能。  我还通过举例简单地提了一下 yield 关键字(见图 1),但几乎未做任何解释。

图 1:依序生成一些 C# 关键字

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);
    }
  }
}

本文将在上一篇文章的基础之上,继续详细介绍 yield 关键字及其用法。

迭代器和状态

通过在图 1**** 中的 GetEnumerator 方法开头添加断点,可以看到 GetEnumerator 在 foreach 语句开头处得到调用。  此时,将创建迭代器对象,它的状态会初始化成特殊的“开始”状态,表示迭代器中尚未执行任何代码,因而也尚未生成任何值。至此以后,只要调用站点上的 foreach 语句继续执行,迭代器就会保持其状态(位置)。每当循环请求获取下一个值时,控制权都会授予迭代器,并接着上次的循环进度继续执行;迭代器对象中存储的状态信息用于确定必须在哪里恢复控制权。当调用站点上的 foreach 语句终止时,将不再保存迭代器的状态。图 2 展示了所发生事件的简要序列图。请注意,MoveNext 方法出现在 IEnumerator<T> 接口上。

在图 2**** 中,调用站点上的 foreach 语句对称为关键字的 CSharpBuiltInTypes 实例调用 GetEnumerator。可以看到,再次调用 GetEnumerator 始终都是安全的;将根据需要创建“新的”枚举器。鉴于迭代器引用的迭代器实例,foreach 通过调用 MoveNext 开始每次迭代。在迭代器中,生成返回给调用站点上的 foreach 语句的值。在 yield return 语句之后,GetEnumerator 方法貌似在出现下一个 MoveNext 请求之前一直暂停。再回到循环体,foreach 语句在屏幕上显示生成的值。然后,循环回再次对迭代器调用 MoveNext。请注意,第二次控制权会授予第二个 yield return 语句。foreach 将再次在屏幕上显示 CSharpBuiltInTypes 生成的值,并重新开始循环。这个过程会一直持续下去,直到迭代器中没有其他任何 yield return 语句时为止。这时,调用站点上的 foreach 循环将终止,因为 MoveNext 返回了 false。

含 yield return 语句的序列图
图 2:含 yield return 语句的序列图

另一迭代器示例

以类似示例为例,其中包含我在上一篇文章中介绍过的 BinaryTree<T>。为了实现 BinaryTree<T>,我需要先让 Pair<T> 支持使用迭代器的 IEnumerable<T> 接口。图 3**** 中的示例展示了如何生成 Pair<T> 中的每个元素。

在图 3 中,Pair<T> 数据类型的迭代循环两次:第一次是通过 yield return First,第二次是通过 yield return Second。每当在 GetEnumerator 中遇到 yield return 语句,就会保存状态,而且执行似乎会从 GetEnumerator 方法上下文中“跳出”并进入循环体。当第二次迭代开始时,GetEnumerator 会再次开始执行 yield return Second 语句 。

图 3:使用 Yield 实现 BinaryTree<T>

public struct Pair<T>: IPair<T>,
  IEnumerable<T>
{
  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<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
}

实现 IEnumerable 及 IEnumerable<T>

System.Collections.Generic.IEnumerable<T> 继承自 System.Collections.IEnumerable。因此,实现 IEnumerable<T> 时,还必须实现 IEnumerable。在图 3**** 中,实现为显式完成,仅涉及调用 IEnumerable<T> GetEnumerator 实现代码。由于 IEnumerable<T> 和 IEnumerable 之间的类型兼容性(通过继承),从 IEnumerable.GetEnumerator 调用 IEnumerable<T>.Get­Enumerator 将始终有效。因为两个 GetEnumerator 的签名完全相同(返回类型并不区分签名),所以其中一个或两个实现代码必须为显式。鉴于 IEnumerable<T> 版本提供的附加类型安全性,IEnumerable 实现代码应为显式。

下面的代码使用 Pair<T>.GetEnumerator 方法,并在连续两行中显示“Inigo”和“Montoya”:

var fullname = new Pair<string>("Inigo", "Montoya");
foreach (string name in fullname)
{
  Console.WriteLine(name);
}

将 yield return 语句置于循环内

无需对每个 yield return 语句进行硬编码,就像我在 CSharpPrimitiveTypes 和 Pair<T> 中所做的一样。使用 yield return 语句,可以从循环构造内部返回值。图 4 使用了 foreach 循环。每当在 GetEnumerator 中执行 foreach 时,都会返回下一个值。

图 4:将 yield return 语句置于循环内

public class BinaryTree<T>: IEnumerable<T>
{
  // ...
  #region IEnumerable<T>
  public IEnumerator<T> GetEnumerator()
  {
    // Return the item at this node.
    yield return Value;
    // Iterate through each of the elements in the pair.
    foreach (BinaryTree<T> 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<T>
  #region IEnumerable Members
  System.Collections.IEnumerator
    System.Collections.IEnumerable.GetEnumerator()
  {
    return GetEnumerator();
  }
  #endregion
}

在图 4**** 中,第一个迭代返回二叉树中的根元素。在第二次迭代期间,将遍历这对子元素。如果子元素对包含非 null 值,将遍历相应的子节点并生成其元素。请注意,foreach (T item in tree) 是对子节点的递归调用。

就像使用 CSharpBuiltInTypes 和 Pair<T> 一样,现在可以使用 foreach 循环迭代 BinaryTree<T>。图 5 展示了此过程。

图 5:结合使用 foreach 和 BinaryTree<string>

// JFK
var jfkFamilyTree = new BinaryTree<string>(
  "John Fitzgerald Kennedy");
jfkFamilyTree.SubItems = new Pair<BinaryTree<string>>(
  new BinaryTree<string>("Joseph Patrick Kennedy"),
  new BinaryTree<string>("Rose Elizabeth Fitzgerald"));
// Grandparents (Father's side)
jfkFamilyTree.SubItems.First.SubItems =
  new Pair<BinaryTree<string>>(
  new BinaryTree<string>("Patrick Joseph Kennedy"),
  new BinaryTree<string>("Mary Augusta Hickey"));
// Grandparents (Mother's side)
jfkFamilyTree.SubItems.Second.SubItems =
  new Pair<BinaryTree<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 语言,人们可以更好地理解最终推广的“面向对象的编程”。)

取消进一步迭代: yield break

有时可能希望取消进一步迭代。为此,可以添加 if 语句,从而不再执行代码中的其他任何语句。不过,也可以使用 yield break 让 MoveNext 返回 false,并将控制权立即返回给调用方,同时结束循环。下面的示例展示了此类方法:

public System.Collections.Generic.IEnumerable<T>
  GetNotNullEnumerator()
{
  if((First == null) || (Second == null))
  {
    yield break;
  }
  yield return Second;
  yield return First;
}

如果 Pair<T> 类中的两个元素有一个为 null,那么此方法就会取消迭代。

yield break 语句类似于在确定没有要执行的操作时,将 return 语句置于函数顶部。这样一来,无需使用 if 代码块将所有剩余代码围住,即可退出进一步迭代。因此,可以多次退出。请谨慎使用这种方法,因为随意读取代码可能会忽视早期退出。

迭代器的工作方式

遇到迭代器时,C# 编译器会将代码扩展到相应枚举器设计模式的适当 CIL 中。在生成的代码中,C# 编译器会先创建嵌套的私有类来实现 IEnumerator<T> 接口及其 Current 属性和 MoveNext 方法。Current 属性返回与迭代器的返回类型对应的类型。如图 3**** 所示,Pair<T> 包含返回 T 类型的迭代器。C# 编译器会先检查迭代器中包含的代码,然后在 MoveNext 方法和 Current 属性中创建必要的代码来模仿它的行为。对于 Pair<T> 迭代器,C# 编译器生成大致等效的代码(见图 6)。

图 6:C# 编译器生成的等效迭代器 C# 代码

using System;
using System.Collections.Generic;
public class Pair<T> : IPair<T>, IEnumerable<T>
{
  // ...
  // The iterator is expanded into the following
  // code by the compiler.
  public virtual IEnumerator<T> 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<T> : IEnumerator<T>
  {
    public __ListEnumerator(int itemCount)
    {
      _ItemCount = itemCount;
    }
    Pair<T> _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<T>.Get­Enumerator,即 foreach 隐式搜寻的方法。有时,可能需要使用不同的迭代序列,如反向迭代、筛选结果或迭代除默认值之外的对象投影。可以在类中声明其他迭代器,具体方法是将它们封装在返回 IEnumerable<T> 或 IEnumerable 的属性或方法中。例如,若要反向迭代 Pair<T> 的元素,可以提供 GetReverseEnumerator 方法,如图 7**** 所示。

图 7:在返回 IEnumerable<T> 的方法中使用 yield return 语句

public struct Pair<T>: IEnumerable<T>
{
  ...
  public IEnumerable<T> 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<T>,而不是 IEnumerator<T>。这不同于返回 IEnumerator<T> 的 IEnumerable<T>.GetEnumerator。Main 中的代码展示了如何使用 foreach 循环调用 GetReverseEnumerator。

Yield 语句要求

可以只在返回 IEnumerator<T>/IEnumerable<T> 类型或其非泛型等效类型的成员中使用 yield return 语句。成员主体包括可能没有简单返回的 yield return 语句。如果成员使用 yield return 语句,那么 C# 编译器会生成必要的代码来保持迭代器的状态。相反,如果成员使用 return 语句替代 yield return 语句,那么程序员将负责维护自己的状态机,并返回一个迭代器接口的实例。此外,就像含返回类型的方法中的所有代码路径都必须包含随附值的 return 语句一样(假设不会引发异常),如果要返回任何数据,迭代器中的所有代码路径都必须包含 yield return 语句。

如果违反以下关于 yield 语句的其他限制,则会导致编译器错误生成:

  • yield 语句可能只出现在方法、用户定义的运算符或索引器/属性的 get 访问器中。成员不得使用任何引用或输出参数。
  • yield 语句可能不会出现在匿名方法或 Lambda 表达式内。
  • yield 语句可能不会出现在 try 语句的 catch 和 finally 子句中。此外,只有在没有 catch 代码块时,yield 语句才可能出现在 try 代码块中。

总结

绝大程度上,泛型是 C# 2.0 中推出的一项炫酷功能,但它并不是当时推出的唯一一项集合相关功能。另一项重要补充功能就是迭代器。就像我在本文中概述的一样,迭代器涉及上下文关键字 yield。C# 使用此关键字生成基础 CIL 代码来实现 foreach 循环使用的迭代器模式。  此外,我还详细介绍了 yield 语法,此语法通过 GetEnumerator 实现了 IEnumerable<T>,允许使用 yield break 退出循环,甚至支持返回 IEnumeable<T> 的 C# 方法。

这篇专栏文章的大部分内容都来自我的《Essential C#》一书 (IntelliTect.com/EssentialCSharp),目前我正在撰写此书的最新版本《Essential C# 7.0》。  有关本主题的详细信息,请参阅此书的第 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