枚举器和迭代器

思考并回答以下问题:

  • 可以使用foreach语句来遍历数组中的元素。可以使用foreach来遍历对象吗?如何可以要怎么做?每次这样做麻烦吗?怎么办?
  • 枚举器的英文是什么?可枚举类型的英文是什么?迭代器呢?
  • 枚举器和枚举类型是什么关系?
  • 什么是枚举器,什么是迭代器?两者之间有什么区别和联系?
  • 想要遍历对象就要创建一个枚举器太麻烦了。怎么办?
  • 为什么foreach一个List的时候调用RemoveAt()方法会报错?

本章涵盖:

  • 枚举器和可枚举类型
  • IEnumerator接口
  • IEnumerable接口
  • 泛型枚举接口
  • 迭代器
  • 常见迭代器模式
  • 产生多个可枚举类型
  • 将迭代器作为属性
  • 迭代器的实质

枚举器和可枚举类型

我们已经知道可以使用foreach语句来遍历数组中的元素。在本章中,我们会进一步探讨数组,来看看为什么它们可以被foreach语句处理。我们还会研究如何用迭代器为用户自定义的类增加这个功能。

**使用foreach语句**

当我们为数组使用foreach语句时,这个语句为我们依次取出了数组中的每一个元素,允许我们读取它的值。例如,如下的代码声明了一个有4个元素的数组,然后使用foreach来循环打印这些项的值:

1
2
3
4
int[] arr1 = { 10, 11, 12, 13}; // 定义数组

foreach (int item in arr1) // 枚举元素
Console.WriteLine("Item value: {0}", item);

这段代码产生了如下的输出:

1
2
3
4
Item value: 10
Item value: 11
Item value: 12
Item value: 13

为什么数组可以这么做?原因是数组可以按需提供一个叫做枚举器(enumerator)的对象。枚举器可以依次返回请求的数组中的元素。枚举器“知道”项的次序并且跟踪它在序列中的位置,然后返回请求的当前项。

对于有枚举器的类型而言,必须有一个方法来获取它。获取一个对象枚举器的方法是调用对象的GetEnumerator方法。实现GetEnumerator方法的类型叫做可枚举类型(enumerable type或enumerable),数组是可枚举类型。

图1演示了可枚举类型和枚举器之间的关系。

图1 枚举器和可枚举类型概览

foreach结构设计用来和可枚举类型一起使用。只要给它的遍历对象是可枚举类型,比如数组,它就会执行如下行为:

  • 通过调用GetEnumerator方法获取对象的枚举器;
  • 从枚举器中请求每一项并且把它作为迭代变量(iteration variable),代码可以读取该变量但不可以改变。
1
2
3
4
5
// EnumerableObject必须是可枚举类型
foreach( Type VarName in EnumerableObject)
{
...
}

IEnumerator接口

实现了IEnumerator接口的枚举器包含3个函数成员:Current,MoveNext以及Reset。

  • Current是返回序列中当前位置项的属性。
    • 它是只读属性。
    • 它返回object类型的引用,所以可以返回任何类型。
  • MoveNext是把枚举器位置前进到集合中下一项的方法。它也返回布尔值,指示新的位置是有效位置还是已经超过了序列的尾部。
    • 如果新的位置是有效的,方法返回true。
    • 如果新的位置是无效的(比如当前位置到达了尾部),方法返回false。
    • 枚举器的原始位置在序列中的第一项之前,因此MoveNext必须在第一次使用Current之前调用。
  • Reset是把位置重置为原始状态的方法。

图2左边显示了3个项的集合,右边显示了枚举器。在图2中,枚举器是一个叫做ArrEnumerator类的实例。

图2 小集合的枚举器

枚举器与序列中的当前项保持联系的方式完全取决于实现。可以通过对象引用、索引值或其他方式来实现。对于内置的一维数组来说,就使用项的索引。

图3演示了有3个项的集合的枚举器的状态。这些状态标记了1到5。

  • 注意,在状态1中,枚举器的原始位置是-1(也就是在集合的第一个元素之前)。
  • 状态的每次切换都由MoveNext进行,它提升了序列中的位置。每次调用MoveNext时,状态1到状态4都返回true,然而,在从状态4到状态5的过程中,位置最终超过了集合的最后一项,所以方法返回false。
  • 在最后一个状态中,任何进一步的调用MoveNext总是会返回false。

图3 枚举器状态

有了集合的枚举器,我们就可以使用MoveNext和Current成员来模仿foreach循环遍历集合中的项。例如,我们已经知道了数组就是可枚举类型,所以下面的代码手动做foreach语句自动做的事情。事实上,在编写foreach循环的时候,C#编译器将生成与下面十分类似的代码(当然,是以CIL的形式)。

1
2
3
4
5
6
7
8
9
10
11
12
static void Main()
{
int[] MyArray = {10, 11, 12, 13 }; // 创建数组

IEnumerator ie = MyArray.GetEnumerator(); // 获取枚举器

while (ie.MoveNext()) // 移到下一项
{
int i = (int) ie.Current; // 获取当前项
Console.WriteLine("{0}", i); // 输出
}
}

这段代码产生了如下的输出,与使用内嵌的foreach语句的结果一样:

1
2
3
4
10
11
12
13

图4演示了代码示例中的数组结构

图4 .NET数组类实现了IEnumerable

**IEnumerable接口**

可枚举类是指实现了IEnumerable接口的类。IEnumerable接口只有一个成员——GetEnumerator方法,它返回对象的枚举器。

图5演示了一个有3个枚举项的类MyClass,通过实现GetEnumerator方法来实现IEnumerable接口。

图5 GetEnumerator方法返回类的一个枚举器对象

如下代码演示了可枚举类的声明形式:

1
2
3
4
5
6
7
8
9
10
11
using System.Collections;

class MyClass : IEnumerable // 实现IEnumerable接口
{
// 返回IEnumerator类型的对象
public IEnumerator GetEnumerator
{
...
}
...
}

下面的代码给出了一个可枚举类的示例,使用实现了IEnumerator的枚举器类ColorEnumerator。

将在下一节展示ColorEnumerator的实现。

1
2
3
4
5
6
7
8
9
10
11
12
using System.Collections;

class MyColors : IEnumerable
{
string[] Colors = {"Red", "Yellow", "Blue"};

public IEnumerator GetEnumerator()
{
// 返回枚举器类型的实例
return new ColorEnumerator(Colors);
}
}

**使用IEnumerable和IEnumerator的示例**

下面的代码展示了一个可枚举类的完整示例,该类叫做Spectrum,它的枚举器类为ColorEnumerator,Program类在Main方法中创建了一个Spectrum实例,并用于foreach循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
using System;
using System.Collections;

class ColorEnumerator : IEnumerator
{
string[] _colors;
int _position = -1;

// 构造函数
public ColorEnumerator(string[] theColors)
{
_colors = new string[theColors.Length];
for(int i = 0; i< theColors.Length; i++)
_colors[i] = theColors[i];
}

// 属性,实现Current
public object Current
{
get
{
if(_position == -1)
throw new InvalidOperationException();
if (_position >= _colors.Length)
throw new InvalidOperationException();
return _colors[_position];
}
}

// 实现MoveNext
public bool MoveNext()
{
if(_position < _colors.Length - 1)
{
_position ++;
return true;
} else
return false;
}

// 实现Reset
public void Reset()
{
_position = -1;
}
}

class Spectrum : IEnumerable
{
string[] Colors = {"violet", "blue", "cyan", "green", "yellow", "orange", "red"};

public IEnumerator GetEnumerator()
{
return new ColorEnumerator(Colors);
}
}

class Program
{
static void Main()
{
Spectrum spectrum = new Spectrum();
// 直接遍历对象
foreach(string color in spectrum)
Console.WriteLine(color);
}
}

这段代码产生了如下的输出:

1
2
3
4
5
6
7
violet
blue
cyan
green
yellow
orange
red

泛型枚举接口

目前我们描述的枚举接口都是非泛型版本。实际上,在大多数情况下你应该使用泛型版本IEnumerable\和IEnumerator\,它们叫做泛型是因为使用了C#泛型(参见第17章),其使用方式和非泛型形式差不多。

两者之间的本质差别如下所示。

  • 对于非泛型接口形式:
    • IEnumerable接口的GetEnumerator方法返回实现IEnumerator枚举器类的实例;
    • 实现IEnumerator的类实现了Current属性,它返回object的引用,然后我们必须把它转化为实际类型的对象。
  • 对于泛型接口形式:
    • IEnumerable\接口的GetEnumerator方法返回实现IEnumator\的枚举器类的实例;
    • 实现IEnumerator\的类实现了Current属性,它返回实际类型的对象,而不是object基类的引用。

需要重点注意的是,我们目前所看到的非泛型接口的实现不是类型安全的。它们返回object类型的引用,然后必须转化为实际类型。

泛型接口的枚举器是类型安全的,它返回实际类型的引用。如果要创建自己的可枚举类,应该实现这些泛型接口。非泛型版本可用于C#2.0以前没有泛型的遗留代码。

尽管泛型版本和非泛型版本一样简单易用,但其结构略显复杂。图6和图7展示了它们的结构。

图6 IEnumrator\接口的实现类的结构

图7 IEnumerable\接口的实现类的结构

迭代器

可枚举类和枚举器在.NET集合类中被广泛使用,所以熟悉它们如何工作很重要。不过,虽然我们已经知道如何创建自己的可枚举类和枚举器了,但我们还是会很高兴听到,C#从2.0版本开始提供了更简单的创建枚举器和可枚举类型的方式。实际上,编译器将为我们创建它们。这种结构叫做迭代器(iterator)。我们可以把手动编码的可枚举类型和枚举器替换为由迭代器生成的可枚举类型和枚举器。

在解释细节之前,我们先来看两个示例。下面的方法声明实现了一个产生和返回枚举器的迭代器。

  • 迭代器返回一个泛型枚举器,该枚举器返回3个string类型的项。
  • yield return语句声明这是枚举中的下一项。
1
2
3
4
5
6
7
// 返回泛型枚举器,它返回的是字符串对象
public IEnumerator<string> BlackAndWhite()
{
yield return "black";
yield return "gray";
yield return "white";
}

下面的方法声明了另一个版本,并输出了相同的结果:

1
2
3
4
5
6
7
8
// 返回泛型枚举器
public IEnumerator<string> BlackAndWhite
{
string[] theColors = {"black", "gray", "white"};

for (int i = 0; i < theColors.Length; i++)
yield return theColors[i];
}

到现在为止,我还没有解释过yield return语句。但是如果仔细看代码,你可能会觉得代码有一些奇怪。它好像不是很正确,那么yield return语句究竟做了什么呢?

例如,在第一个版本中,如果方法在第一个yield return语句处返回,那么后两条语句永远不会到达。如果没有在第一条语句中返回,而是继续后面的代码,在这些值上发生了什么呢?在第二个版本中,如果循环主体中的yield retun语句在第一个迭代中返回,循环永远不会获得其他的后续迭代。

除此之外,枚举器不会一次返回所有元素——每次访问Current属性时便返回一个新值。那么是怎么为我们实现枚举器的呢?很明显,该代码与之前给出的代码很不相同。

迭代器块

迭代器块是有一个或多个yield语句的代码块。下面3种类型的代码块中的任意一种都可以是迭代器块:

  • 方法主体;
  • 访问器主体;
  • 运算符主体。

迭代器块与其他代码块不同。其他块包含的语句被当作是命令式的。也就是说,先执行代码块的第一个语句,然后执行后面的语句,最后控制离开块。

另一方面,迭代器块不是需要在同一时间执行的一串命令式命令,而是描述了希望编译器为我们创建的枚举器类的行为。迭代器块中的代码描述了如何枚举元素。

迭代器块有两个特殊语句。

  • yield return语句指定了序列中返回的下一项。
  • yield break语句指定在序列中没有其他项。

编译器得到有关如何枚举项的描述后,使用它来构建包含所有需要的方法和属性实现的枚举器类。结果类被嵌套包含在迭代器声明的类中。

如图8所示,根据迭代器块的返回类型,你可以让迭代器产生枚举器或可枚举类型。

图8 根据指定的返回类型,可以让迭代器产生枚举器或可枚举类型

使用迭代器来创建枚举器

下面代码演示了如何使用迭代器来创建可枚举类。

  • BlackAndwWhite方法是一个迭代器块,可以为MyClass类产生返回枚举器的方法。
  • MyClass还实现了GetEnumerator方法,它调用BlackAndWhite并且返回BlackAndWhite返回的枚举器。
  • 注意Main方法,由于MyClass类实现了GetEnumerator,是可枚举类型,我们在foreach语句中直接使用了类的实例。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class MyClass
{
public IEnumerator<string> GetEnumerator()
{
// 返回枚举器
return BlackAndWhite();
}

// 迭代器
// 返回枚举器
public IEnumerator<string> BlackAndWhite()
{
yield return "black";
yield return "gray";
yield return "white";
}
}

class Program
{
static void Main()
{
MyClass mc = new MyClass();

// 返回MyClass的实例
foreach (string shade in mc)
Console.WriteLine(shade);
}
}

这段代码产生了如下的输出:

1
2
3
black
gray
white

图9在左边演示了MyClass的代码,在右边演示了产生的对象。注意编译器为我们自动做了多少工作。

  • 图中左边的迭代器代码演示了它的返回类型是IEnumerator\
  • 图中右边演示了它有一个嵌套类实现了IEnumerator\

图9 迭代器块产生了枚举器

使用迭代器来创建可枚举类型

之前的示例创建的类包含两部分:产生返回枚举器方法的迭代器以及返回枚举器的GetEnumerator方法。在本节的例子中,我们用迭代器来创建可枚举类型,而不是枚举器。与之前的示例相比,本例有一些重要的不同。

  • 在之前的示例中, BlackAndWhite迭代器方法返回IEnumerator\,MyClass类通过返回由BlackAndWhite返回的对象来实现GetEnumerator方法。
  • 在本例中,BlackAndWhite迭代器方法返回IEnumerable\而不是IEnumerator\。因此,MyClass首先调用BlackAndWhite方法获取它的可枚举类型对象,然后调用对象的GetEnumerator方法来获取它的结果,从而实现GetEnumerator方法。
  • 注意,在Main的foreach语句中,我们可以使用类的实例,也可以直接调用BlackAndWhite方法,因为它返回的是可枚举类型。两种方法如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    class MyClass
    {
    public IEnumerator<string> GetEnumerator()
    {
    // 获取可枚举类型
    IEnumerable<string> myEnumerable = BlackAndWhite();
    // 获取枚举器
    return myEnumerable.GetEnumerator();
    }

    // 返回可枚举类型
    public IEnumerable<string> BlackAndWhite()
    {
    yield return "black";
    yield return "gray";
    yield return "white";
    }
    }

    class Program
    {
    static void Main()
    {
    MyClass mc = new MyClass();

    // 使用类对象
    foreach (string shade in mc)
    Console.WriteLine("{0} ", shade);

    // 使用类枚举器方法
    foreach (string shade in mc.BlackAndWhite())
    Console.WriteLine("{0} ", shade);
    }
    }

这段代码产生了如下的输出:

1
black gray white black gray white

图10演示了在代码中的可枚举迭代器产生了泛型可枚举类型。

图10 编译器生成的类既是IEnumerable的又是IEnumerator的,并且它还生成了一个方法BlackAndWhite,它返回可枚举对象

  • 图中左边的迭代器代码演示了它的返回类型是IEnumerable\
  • 图中右边演示了它有一个嵌套类实现了IEnumerator\和IEnumerable\

常见迭代器模式

前面两节的内容显示了,我们可以创建迭代器来返回可枚举类型枚举器。图11总结了如何使用普通迭代器模式。

图11 常见迭代器模式

  • 当我们实现返回枚举器的迭代器时,必须通过实现GetEnumerator来让类可枚举,它返回由迭代器返回的枚举器。如图11中左部分所示。
  • 如果我们在类中实现迭代器返回可枚举类型,我们可以让类实现GetEnumerator来让类本身可被枚举,或不实现GetEnumerator,让类不可枚举。
    • 如果实现GetEnumerator,让它调用迭代器方法以获取自动生成的实现IEnumerable的类实例。然后,从IEnumerable对象返回由GetEnumerator创建的枚举器,如图11右边所示。
    • 如果通过不实现GetEnumerator使类本身不可枚举,仍然可以使用由迭代器返回的可枚举类,只需要直接调用迭代器方法,如图11中右边第二个foreach语句所示。

产生多个可枚举类型

在下面的示例中,Spectrum类有两个可枚举类型的迭代器——一个从紫外线到红外线枚举光谱中的颜色,而另一个以逆序进行枚举。注意,尽管它有两个方法返回可枚举类型,但类本身不是可枚举类型,因为它没有实现GetEnumerator。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using System;
using System.Collections.Generic;

class Spectrum
{
string[] colors = {"violet", "blue", "cyan", "green", "yellow", "orange", "red"};

// 返回一个可枚举类型
public IEnumerable<string> UVtoIR()
{
for (int i = 0; i < colors.Length; i ++)
yield return colors[i];
}

// 返回一个可枚举类型
public IEnumerable<string> IRtoUV()
{
for (int i = colors.Length - 1; i >= 0; i--)
yield return colors[i];
}
}

class Program
{
static void Main()
{
Spectrum spectrum = new Spectrum();

foreach (string color in spectrum.UVtoIR())
Console.WriteLine("{0} ", color);
Console.WriteLine();

foreach (string color in spectrum.IRtoUV())
Console.WriteLine("{0} ", color);
Console.WriteLine();
}
}

这段代码产生了如下的输出:

1
2
violet blue cyan green yellow orange red
red orange yellow green cyan blue violet

将迭代器作为属性

之前的示例使用迭代器来产生具有两个可枚举类型的类。本例演示两个方面的内容:第一,使用迭代器来产生具有两个枚举器的类;第二,演示迭代器如何能实现为属性而不是方法。

这段代码声明了两个属性来定义两个不同的枚举器。GetEnumerator方法根据_listFromUVtoIR布尔变量的值返回两个枚举器中的一个。如果_listFromUVtoIR为true,则返回UvtoIR枚举器;否则,返回IRtoUv枚举器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
using System;
using System.Collections.Generic;

class Spectrum
{
bool _listFromUVtoIR;

string[] colors = {"violet", "blue", "cyan", "green", "yellow", "orange", "red"};

public Spectrum(bool listFromUVtoIR)
{
_listFromUVtoIR = listFromUVtoIR;
}

public IEnumerator<string> GetEnumerator()
{
return _listFromUVtoIR ? UVtoIR : IRtoUV;
}

public IEnumerator<string> UVtoIR
{
get
{
for (int i = 0; i < colors.Length; i++)
yield return colors[i];
}
}

public IEnumerator<string> IRtoUV
{
get
{
for (int i = colors.Length - 1; i >= 0; i--)
yield return colors[i];
}
}
}

class Program
{
static void Main()
{
Spectrum startUV = new Spectrum( true );
Spectrum startIR = new Spectrum( false );

foreach (string color in startUV)
Console.Write("{0} ", color);

Console.WriteLine();

foreach (string color in startIR)
Console.Write("{0} ", color);

Console.WriteLine();
}
}

这段代码产生了如下的输出:

1
2
violet blue cyan green yellow orange red
red orange yellow green cyan blue violet

迭代器实质

如下是需要了解的有关迭代器的其他重要事项。

  • 迭代器需要System.Collections.Generic命名空间,因此我们需要使用using指令引入它。
  • 在编译器生成的枚举器中,Reset方法没有实现。而它是接口需要的方法,因此调用时总是抛出System.NotSupportedException异常。注意,在图9中Reset方法显示为灰色在后台,由编译器生成的枚举器类是包含4个状态的状态机。
  • Before 首次调用MoveNext的初始状态。
  • Running 调用MoveNext后进入这个状态。在这个状态中,枚举器检测并设置下一项的位置。在遇到yield return、yield break或在迭代器体结束时,退出状态。
  • Suspended 状态机等待下次调用MoveNext的状态。
  • After 没有更多项可以枚举。

如果状态机在Before或Suspended状态时调用了MoveNext方法,就转到了Running状态。在Running状态中,它检测集合的下一项并设置位置。

如果有更多项,状态机会转入Suspended状态,如果没有更多项,它转入并保持在After状态。图12演示了这个状态机。

图12 迭代器状态机

0%