C#高级编程(第10版) C# 6 & .NET Core 1.0 (.NET开发经典名著)
上QQ阅读APP看书,第一时间看更新

7.7 枚举

在foreach语句中使用枚举,可以迭代集合中的元素,且无须知道集合中的元素个数。foreach语句使用了一个枚举器。图7-7显示了调用foreach方法的客户端和集合之间的关系。数组或集合实现带GetEumerator()方法的IEumerable接口。GetEumerator()方法返回一个实现IEumerator接口的枚举。接着,foreach语句就可以使用IEumerable接口迭代集合了。

图7-7

注意:GetEnumerator()方法用IEnumerable接口定义。foreach语句并不真的需要在集合类中实现这个接口。有一个名为GetEnumerator()的方法,它返回实现了IEnumerator接口的对象就足够了。

7.7.1 IEnumerator接口

foreach语句使用IEnumerator接口的方法和属性,迭代集合中的所有元素。为此,IEnumerator定义了Current属性,来返回光标所在的元素,该接口的MoveNext()方法移动到集合的下一个元素上,如果有这个元素,该方法就返回true。如果集合不再有更多的元素,该方法就返回false。

这个接口的泛型版本IEnumerator<T>派生自接口IDisposable,因此定义了Dispose()方法,来清理给枚举器分配的资源。

注意:IEnumerator接口还定义了Reset()方法,以与COM交互操作。许多.NET枚举器通过抛出NotSupportedException类型的异常,来实现这个方法。

7.7.2 foreach语句

C#的foreach语句不会解析为IL代码中的foreach语句。C#编译器会把foreach语句转换为IEnumerator接口的方法和属性。下面是一条简单的foreach语句,它迭代persons数组中的所有元素,并逐个显示它们:

        foreach (var p in persons)
        {
          WriteLine(p);
        }

foreach语句会解析为下面的代码段。首先,调用GetEnumerator()方法,获得数组的一个枚举器。在while循环中——只要MoveNext()返回true——就用Current属性访问数组中的元素:

        IEnumerator<Person> enumerator = persons.GetEnumerator();
        while (enumerator.MoveNext())
        {
          Person p = enumerator.Current;
          WriteLine(p);
        }

7.7.3 yield语句

自C#的第1个版本以来,使用foreach语句可以轻松地迭代集合。在C# 1.0中,创建枚举器仍需要做大量的工作。C# 2.0添加了yield语句,以便于创建枚举器。yield return语句返回集合的一个元素,并移动到下一个元素上。yield break可停止迭代。

下一个例子是用yield return语句实现一个简单集合的代码。HelloCollection类包含GetEnumerator()方法。该方法的实现代码包含两条yield return语句,它们分别返回字符串Hello和World(代码文件YieldSample/Program.cs)。

        using System;
        using System.Collections;
        namespace Wrox.ProCSharp.Arrays
        {
          public class HelloCollection
          {
            public IEnumerator<string> GetEnumerator()
            {
              yield return "Hello";
              yield return "World";
            }
          }

注意:包含yield语句的方法或属性也称为迭代块。迭代块必须声明为返回IEnumerator或IEnumerable接口,或者这些接口的泛型版本。这个块可以包含多条yield return语句或yield break语句,但不能包含return语句。

现在可以用foreach语句迭代集合了:

        public void HelloWorld()
          {
            var helloCollection = new HelloCollection();
            foreach (var s in helloCollection)
            {
              WriteLine(s);
            }
          }
        }

使用迭代块,编译器会生成一个yield类型,其中包含一个状态机,如下面的代码段所示。yield类型实现IEnumerator和IDisposable接口的属性和方法。在下面的例子中,可以把yield类型看作内部类Enumerator。外部类的GetEnumerator()方法实例化并返回一个新的yield类型。在yield类型中,变量state定义了迭代的当前位置,每次调用MoveNext()时,当前位置都会改变。MoveNext()封装了迭代块的代码,并设置了current变量的值,从而使Current属性根据位置返回一个对象。

        public class HelloCollection
        {
          public IEnumerator GetEnumerator() => new Enumerator(0);
          public class Enumerator: IEnumerator<string>, IEnumerator, IDisposable
          {
            private int _state;
            private string _current;
            public Enumerator(int state)
            {
              _state = state;
            }
            bool System.Collections.IEnumerator.MoveNext()
            {
              switch (state)
              {
                case 0:
                  _current = "Hello";
                  _state = 1;
                  return true;
                case 1:
                  _current = "World";
                  _state = 2;
                  return true;
                case 2:
                  break;
              }
              return false;
            }
            void System.Collections.IEnumerator.Reset()
            {
              throw new NotSupportedException();
            }
            string System.Collections.Generic.IEnumerator<string>.Current => current;
            object System.Collections.IEnumerator.Current => current;
            void IDisposable.Dispose()
            {
            }
          }
        }

注意:yield语句会生成一个枚举器,而不仅仅生成一个包含的项的列表。这个枚举器通过foreach语句调用。从foreach中依次访问每一项时,就会访问枚举器。这样就可以迭代大量的数据,而无须一次把所有的数据都读入内存。

1.迭代集合的不同方式

在下面这个比Hello World示例略大但比较真实的示例中,可以使用yield return语句,以不同方式迭代集合的类。类MusicTitles可以用默认方式通过GetEnumerator()方法迭代标题,用Reverse()方法逆序迭代标题,用Subset()方法迭代子集(代码文件YieldSample/MusicTitles.cs):

        public class MusicTitles
        {
          string[] names = { "Tubular Bells", "Hergest Ridge", "Ommadawn", "Platinum" };
          public IEnumerator<string> GetEnumerator()
          {
            for (int i = 0; i < 4; i++)
            {
              yield return names[i];
            }
          }
          public IEnumerable<string> Reverse()
          {
            for (int i = 3; i >= 0; i-)
            {
              yield return names[i];
            }
          }
          public IEnumerable<string> Subset(int index, int length)
          {
            for (int i = index; i < index + length; i++)
            {
              yield return names[i];
            }
          }
        }

注意:类支持的默认迭代是定义为返回IEnumerator的GetEnumerator()方法。命名的迭代返回IEnumerable。

迭代字符串数组的客户端代码先使用GetEnumerator()方法,该方法不必在代码中编写,因为这是foreach语句默认使用的方法。然后逆序迭代标题,最后将索引和要迭代的项数传递给Subset()方法,来迭代子集(代码文件YieldSample/Program.cs):

        var titles = new MusicTitles();
        foreach (var title in titles)
        {
          WriteLine(title);
        }
        WriteLine();
        WriteLine("reverse");
        foreach (var title in titles.Reverse())
        {
          WriteLine(title);
        }
        WriteLine();
        WriteLine("subset");
        foreach (var title in titles.Subset(2, 2))
        {
          WriteLine(title);
        }

2.用yield return返回枚举器

使用yield语句还可以完成更复杂的任务,例如,从yield return中返回枚举器。在Tic-Tac-Toe游戏中有9个域,玩家轮流在这些域中放置一个“十”字或一个圆。这些移动操作由GameMoves类模拟。方法Cross()和Circle()是创建迭代类型的迭代块。变量cross和circle在GameMoves类的构造函数中设置为Cross()和Circle()方法。这些字段不设置为调用的方法,而是设置为用迭代块定义的迭代类型。在Cross()迭代块中,将移动操作的信息写到控制台上,并递增移动次数。如果移动次数大于8,就用yield break停止迭代;否则,就在每次迭代中返回yield类型circle的枚举对象。Circle()迭代块非常类似于Cross()迭代块,只是它在每次迭代中返回cross迭代器类型(代码文件YieldSample/GameMoves.cs)。

        public class GameMoves
        {
          private IEnumerator _cross;
          private IEnumerator _circle;
          public GameMoves()
          {
            _cross = Cross();
            _circle = Circle();
          }
          private int _move = 0;
          const int MaxMoves = 9;
          public IEnumerator Cross()
          {
            while (true)
            {
              WriteLine($"Cross, move {_move}");
              if (++_move >= MaxMoves)
              {
                yield break;
              }
              yield return _circle;
            }
          }
          public IEnumerator Circle()
          {
            while (true)
            {
              WriteLine($"Circle, move {move}");
              if (++_move >= MaxMoves)
              {
                yield break;
              }
              yield return _cross;
            }
          }
        }

在客户端程序中,可以以如下方式使用GameMoves类。将枚举器设置为由game.Cross()返回的枚举器类型,以设置第一次移动。在while循环中,调用enumerator.MoveNext()。第一次调用enumerator.MoveNext()时,会调用Cross()方法,Cross()方法使用yield语句返回另一个枚举器。返回的值可以用Current属性访问,并设置为enumerator变量,用于下一次循环:

        var game = new GameMoves();
        IEnumerator enumerator = game.Cross();
        while (enumerator.MoveNext())
        {
          enumerator = enumerator.Current as IEnumerator;
        }

这个程序的输出会显示交替移动的情况,直到最后一次移动:

        Cross, move 0
        Circle, move 1
        Cross, move 2
        Circle, move 3
        Cross, move 4
        Circle, move 5
        Cross, move 6
        Circle, move 7
        Cross, move 8