迭代器模式是设计模式中行为模式(behavioral pattern)的一个例子,他是一种简化对象间通讯的模式,也是一种非常容易理解和使用的模式。简单来说,迭代器模式使得你能够获取到序列中的所有元素 而不用关心是其类型是array,list,linked list或者是其他什么序列结构。这一点使得能够非常高效的构建数据处理通道(data pipeline)--即数据能够进入处理通道,进行一系列的变换,或者过滤,然后得到结果。事实上,这正是LINQ的核心模式。
在.NET中,迭代器模式被IEnumerator和IEnumerable及其对应的泛型接口所封装。如果一个类实现了IEnumerable接 口,那么就能够被迭代;调用GetEnumerator方法将返回IEnumerator接口的实现,它就是迭代器本身。迭代器类似数据库中的游标,他是 数据序列中的一个位置记录。迭代器只能向前移动,同一数据序列中可以有多个迭代器同时对数据进行操作。
在C#1中已经内建了对迭代器的支持,那就是foreach语句。使得能够进行比for循环语句更直接和简单的对集合的迭代,编译器会将 foreach编译来调用GetEnumerator和MoveNext方法以及Current属性,如果对象实现了IDisposable接口,在迭代 完成之后会释放迭代器。但是在C#1中,实现一个迭代器是相对来说有点繁琐的操作。C#2使得这一工作变得大为简单,节省了实现迭代器的不少工作。
接下来,我们来看如何实现一个迭代器以及C#2对于迭代器实现的简化,然后再列举几个迭代器在现实生活中的例子。
1. C#1:手动实现迭代器的繁琐
假设我们需要实现一个基于环形缓冲的新的集合类型。我们将实现IEnumerable接口,使得用户能够很容易的利用该集合中的所有元素。我们的忽 略其他细节,将注意力仅仅集中在如何实现迭代器上。集合将值存储在数组中,集合能够设置迭代的起始点,例如,假设集合有5个元素,你能够将起始点设为2, 那么迭代输出为2,3,4,0,最后是1. 为了能够简单展示,我们提供了一个设置值和起始点的构造函数。使得我们能够以下面这种方式遍历集合:
object[] values = { "a", "b", "c", "d", "e" }; IterationSample collection = new IterationSample(values, 3); foreach (object x in collection) { Console.WriteLine(x); }
由于我们将起始点设置为3,所以集合输出的结果是d,e,a,b及c,现在,我们来看如何实现 IterationSample 类的迭代器:
class IterationSample : IEnumerable { Object[] values; Int32 startingPoint; public IterationSample(Object[] values, Int32 startingPoint) { this.values = values; this.startingPoint = startingPoint; } public IEnumerator GetEnumerator() { throw new NotImplementedException(); } }
我们还没有实现GetEnumerator方法,但是如何写GetEnumerator部分的逻辑呢,第一就是要将游标的当前状态存在某一个地方。一方面 是迭代器模式并不是一次返回所有的数据,而是客户端一次只请求一个数据。这就意味着我们要记录客户当前请求到了集合中的那一个记录。C#2编译器对于迭代 器的状态保存为我们做了很多工作。 现在来看看,要保存哪些状态以及状态存在哪个地方,设想我们试图将状态保存在IterationSample集合中,使得它实现IEnumerator和 IEnumerable方法。咋一看,看起来可能,毕竟数据在正确的地方,包括起始位置。我们的GetEnumerator方法仅仅返回this。但是这 种方法有一个很重要的问题,如果GetEnumerator方法调用多次,那么多个独立的迭代器就会返回。例如,我们可以使用两个嵌套的foreach语 句,来获取所有可能的值对。这两个迭代需要彼此独立。这意味着我们需要每次调用GetEnumerator时返回的两个迭代器对象必须保持独立。我们仍旧 可以直接在IterationSample类中通过相应函数实现。但是我们的类拥有了多个职责,这位背了单一职责原则。因此,我们来创建另外一个类来实现 迭代器本身。我们使用C#中的内部类来实现这一逻辑。代码如下:
class IterationSampleEnumerator : IEnumerator { IterationSample parent;//迭代的对象 #1 Int32 position;//当前游标的位置 #2 internal IterationSampleEnumerator(IterationSample parent) { this.parent = parent; position = -1;// 数组元素下标从0开始,初始时默认当前游标设置为 -1,即在第一个元素之前, #3 } public bool MoveNext() { if (position != parent.values.Length) //判断当前位置是否为最后一个,如果不是游标自增 #4 { position++; } return position < parent.values.Length; } public object Current { get { if (position == -1 || position == parent.values.Length)//第一个之前和最后一个自后的访问非法 #5 { throw new InvalidOperationException(); } Int32 index = position + parent.startingPoint;//考虑自定义开始位置的情况 #6 index = index % parent.values.Length; return parent.values[index]; } } public void Reset() { position = -1;//将游标重置为-1 #7 } }
要实现一个简单的迭代器需要手动写这么多的代码:需要记录迭代的原始集合#1,记录当前游标位置#2,返回元素时,根据 当前游标和数组定义的起始位置设置定迭代器在数组中的位置#6。初始化时,将当前位置设定在第一个元素之前#3,当第一次调用迭代器时首先需要调用 MoveNext,然后再调用Current属性。在游标自增时对当前位置进行条件判断#4,使得即使当第一次调用MoveNext时没有可返回的元素也 不至于出错#5。重置迭代器时,我们将当前游标的位置还原到第一个元素之前#7。 除了结合当前游标位置和自定义的起始位置返回正确的值这点容易出错外,上面的代码非常直观。现在,只需要在IterationSample
类的GetEnumerator
方法中返回我们当才编写的迭代类即可:
public IEnumerator GetEnumerator() { return new IterationSampleEnumerator(this); }
值得注意的是,上面只是一个相对简单的例子,没有太多的状态需要跟踪,不用检查集合在迭代的过程中是否发生了变化。为了 实现一个简单的迭代器,在C#1中我们实现了如此多的代码。在使用Framework自带的实现了IEnumerable接口的集合时我们使用 foreach很方便,但是当我们书写自己的集合来实现迭代时需要编写这么多的代码。在C#1中,大概需要40行代码来实现一个简单的迭代器,现在看看 C#2对这一过程的改进。
2. C#2:通过yield语句简化迭代
2.1 引入迭代块(iterator)和yield return 语句
C#2使得迭代变得更加简单--减少了很多代码量也使得代码更加的优雅。下面的代码展示了再C#2中实现GetEnumerator方法的完整代码:
public IEnumerator GetEnumerator() { for (int index = 0; index < this.values.Length; index++) { yield return values[(index + startingPoint) % values.Length]; } }
简单几行代码就能够完全实现IterationSampleIterator
类所需要的功能。方法看起来很普通,除了使用了yield return
。这条语句告诉编译器这不是一个普通的方法,而是一个需要执行的迭代块(yield block
),他返回一个IEnumerator
对象,你能够使用迭代块来执行迭代方法并返回一个IEnumerable
需要实现的类型,IEnumerator
或者对应的泛型。如果实现的是非泛型版本的接口,迭代块返的yield type
是Object
类型,否则返回的是相应的泛型类型。例如,如果方法实现IEnumerable<string>
接口,那么yield
返回的类型就是String类型。 在迭代块中除了yield return
外,不允许出现普通的return
语句。块中的所有yield return
语句必须返回和块的最后返回类型兼容的类型。举个例子,如果方法定义需要返回IEnumeratble<string>
类型的话,不能yield return
1 。 需要强调的一点是,对于迭代块,虽然我们写的方法看起来像是在顺序执行,实际上我们是让编译器来为我们创建了一个状态机。这就是在C#1中我们书写的那部 分代码---调用者每次调用只需要返回一个值,因此我们需要记住最后一次返回值时,在集合中位置。 当编译器遇到迭代块是,它创建了一个实现了状态机的内部类。这个类记住了我们迭代器的准确当前位置以及本地变量,包括参数。这个类有点类似与我们之前手写 的那段代码,他将所有需要记录的状态保存为实例变量。下面来看看,为了实现一个迭代器,这个状态机需要按顺序执行的操作:
- 它需要一些初始的状态;
-
当MoveNext被调用时,他需要执行
GetEnumerator
方法中的代码来准备下一个待返回的数据; -
当调用
Current
属性是,需要返回yielded
的值; - 需要知道什么时候迭代结束是,
MoveNext
会返回false。
2.2 迭代器的执行流程
如下的代码,展示了迭代器的执行流程,代码输出(0,1,2,-1)然后终止。
class Program { static readonly String Padding = new String(‘ ‘, 30); static IEnumerable<int32> CreateEnumerable() { Console.WriteLine("{0} CreateEnumerable()方法开始", Padding); for (int i = 0; i < 3; i++) { Console.WriteLine("{0}开始 yield {1}", i); yield return i; Console.WriteLine("{0}yield 结束", Padding); } Console.WriteLine("{0} Yielding最后一个值", Padding); yield return -1; Console.WriteLine("{0} CreateEnumerable()方法结束", Padding); } static void Main(string[] args) { IEnumerable<int32> iterable = CreateEnumerable(); IEnumerator<int32> iterator = iterable.GetEnumerator(); Console.WriteLine("开始迭代"); while (true) { Console.WriteLine("调用MoveNext方法……"); Boolean result = iterator.MoveNext(); Console.WriteLine("MoveNext方法返回的{0}", result); if (!result) { break; } Console.WriteLine("获取当前值……"); Console.WriteLine("获取到的当前值为{0}", iterator.Current); } Console.ReadKey(); } }
从输出结果中可以看出一下几点:
- 直到第一次调用
MoveNext
,CreateEnumerable
中的方法才被调用。 - 在调用
MoveNext
的时候,已经做好了所有操作,返回Current
属性并没有执行任何代码。 - 代码在
yield return
之后就停止执行,等待下一次调用MoveNext
方法的时候继续执行。 - 在方法中可以有多个
yield return
语句。 - 在最后一个
yield return
执行完成后,代码并没有终止。调用MoveNext
返回false使得方法结束。
第一点尤为重要:这意味着,不能在迭代块中写任何在方法调用时需要立即执行的代码--比如说参数验证。如果将参数验证放在迭代块中,那么他将不能够很好的起作用,这是经常会导致的错误的地方,而且这种错误不容易发现。 下面来看如何停止迭代,以及finally
语句块的特殊执行方式。
2.3 迭代器的特殊执行流程
在普通的方法中,return
语句通常有两种作用,一是返回调用者执行的结果。二是终止方法的执行,在终止之前执行finally
语句中的方法。在上面的例子中,我们看到了yield return
语句只是短暂的退出了方法,在MoveNext
再次调用的时候继续执行。在这里我们没有写finally
语句块。如何真正的退出方法,退出方法时finnally
语句块如何执行,下面来看看一个比较简单的结构:yield break
语句块。 使用 yield break 结束一个迭代
static IEnumerable<int32> CountWithTimeLimit(DateTime limit) { try { for (int i = 1; i <= 100; i++) { if (DateTime.Now >= limit) { yield break; } yield return i; } } finally { Console.WriteLine("停止迭代!"); Console.ReadKey(); } } static void Main(string[] args) { DateTime stop = DateTime.Now.AddSeconds(2); foreach (Int32 i in CountWithTimeLimit(stop)) { Console.WriteLine("返回 {0}", i); Thread.Sleep(300); } }