详解C# 迭代器 [转]
详解C# 迭代器
迭代器模式是设计模式中行为模式(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(); } }
为了展示迭代的细节,以上代码使用了while循环,正常情况下一般使用foreach。和上次不同,这次在迭代方法中我们返回的是IEnumerable;对象而不是IEnumerator;对象。通常,为了实现IEnumerable接口,只需要返回IEnumerator对象即可;如果自是想从一个方法中返回一些列的数据,那么使用IEnumerable.以下是输出结果:
从输出结果中可以看出一下几点:
- 直到第一次调用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 结束一个迭代
通常我们要做的是使方法只有一个退出点,通常,多个退出点的程序会使得代码不易阅读,特别是使用try catch finally等语句块进行资源清理以及异常处理的时候。在使用迭代块的时候也会遇到这样的问题,但如果你想早点退出迭代,那么使用yield break就能达到想要的效果。他能够马上终止迭代,使得下一次调用MoveNext的时候返回false。
下面的代码演示了从1迭代到100,但是时间超时的时候就停止了迭代。
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); } }
下图是输出结果,可以看出迭代语句正常终止,yield return语句和普通方法中的return语句一样,下面来看看finally语句块是什么时候以及如何执行的。
Finally语句块的执行
通常,finally语句块在当方法执行退出特定区域时就会执行。迭代块中的finally语句和普通方法中的finally语句块不一样。就像我们看到的,yield return语句停止了方法的执行,而不是退出方法,根据这一逻辑,在这种情况下,finally语句块中的语句不会执行。
但当碰到yield break语句的时候,就会执行finally 语句块,这根普通方法中的return一样。一般在迭代块中使用finally语句来释放资源,就像使用using语句一样。
下面来看finally语句如何执行。
不管是迭代到了100次或者是由于时间到了停止了迭代,或者是抛出了异常,finally语句总会执行,但是在有些情况下,我们不想让finally语句块被执行。
只有在调用MoveNext后迭代块中的语句才会执行,那么如果不掉用MoveNext呢,如果调用几次MoveNext然后停止调用,结果会怎么样呢?请看下面的代码?
DateTime stop = DateTime.Now.AddSeconds(2); foreach (Int32 i in CountWithTimeLimit(stop)) { if (i > 3) { Console.WriteLine("返回中^"); return; } Thread.Sleep(300); }
在forech中,return语句之后,因为CountWithTimeLimit中有finally块所以代码继续执行CountWithTimeLimit中的finally语句块。foreach语句会调用GetEnumerator返回的迭代器的Dispose方法。在结束迭代之前调用包含迭代块的迭代器的Dispose方法时,状态机会执行在迭代器范围内处于暂停状态下的代码范围内的所有finally块,这有点复杂,但是结果很容易解释:只有使用foreach调用迭代,迭代块中的finally块会如期望的那样执行。下面可以用代码验证以上结论:
IEnumerable<Int32> iterable = CountWithTimeLimit(stop); IEnumerator<Int32> iterator = iterable.GetEnumerator(); iterator.MoveNext(); Console.WriteLine("返回 {0}", iterator.Current); iterator.MoveNext(); Console.WriteLine("返回 {0}", iterator.Current); Console.ReadKey();
代码输出如下:
上图可以看出,停止迭代没有打印出来,当我们手动调用iterator的Dispose方法时,会看到如下的结果。在迭代器迭代结束前终止迭代器的情况很少见,也很少不使用foreach语句而是手动来实现迭代,如果要手动实现迭代,别忘了在迭代器外面使用using语句,以确保能够执行迭代器的Dispose方法进而执行finally语句块。
下面来看看微软对迭代器的一些实现中的特殊行为:
2.4 迭代器执行中的特殊行为
如果使用C#2的编译器将迭代块编译,然后使用ildsam或者Reflector查看生成的IL代码,你会发现在幕后编译器回味我们生成了一些嵌套的类型(nested type).下图是使用Ildsam来查看生成的IL ,最下面两行是代码中的的两个静态方法,上面蓝色的<CountWithTimeLimit>d_0是编译器为我们生成的类(尖括号只是类名,和泛型无关),代码中可以看出该类实现了那些接口,以及有哪些方法和字段。大概和我们手动实现的迭代器结构类似。
真正的代码逻辑实在MoveNext方法中执行的,其中有一个大的switch语句。幸运的是,作为一名开发人员没必要了解这些细节,但一些迭代器执行的方式还是值得注意的:
- 在MoveNext方法第一次执行之前,Current属性总是返回迭代器返回类型的默认的值。例如IEnumeratble返回的是Int32类型,那么默认初始值是0,所以在调用MoveNext方法之前调用Current属性就会返回0。
- MoveNext方法返回false后,Current属性总是返回最后迭代的那个值。
- Reset方法一般会抛出异常,而在本文开始代码中,我们手动实现一个迭代器时在Reset中能够正确执行逻辑。
- 编译器为我们产生的嵌套类会同时实现IEnumerator的泛型和非泛型版本(恰当的时候还会实现IEnumerable的泛型和非泛型版本).
没有正确实现Reset方法是有原因的--编译器不知道需要使用怎样的逻辑来从新设置迭代器。很多人认为不应该有Reset方法,很多集合并不支持,因此调用者不应该依赖这一方法。
实现其它接口没有坏处。方法中返回IEnumerable接口,他实现了五个接口(包括IDisposable),作为一个开发者不用担心这些。同时实现IEnumerable和IEnumerator接口并不常见,编译器为了使迭代器的行为总是正常,并且为能够在当前的线程中仅仅需要迭代一个集合就能创建一个单独的嵌套类型才这么做的。
Current属性的行为有些古怪,他保存了迭代器的最后一个返回值并且阻止了垃圾回收期进行收集。
因此,自动实现的迭代器方法有一些小的缺陷,但是明智的开发者不会遇到任何问题,使用他能够节省很多代码量,使得迭代器的使用程度比C#1中要广。下面来看在实际开发中迭代器简化代码的地方。
3.实际开发中使用迭代的例子
3.1 从时间段中迭代日期
在涉及到时间区段时,通常会使用循环,代码如下:
for (DateTime day = timetable.StartDate; day < timetable.EndDate; day=day.AddDays(1)) { …… }
循环有时没有迭代直观和有表现力,在本例中,可以理解为“时间区间中的每一天”,这正是foreach使用的场景。因此上述循环如果写成迭代,代码会更美观:
foreach(DateTime day in timetable.DateRange) { …… }
在C#1.0中要实现这个需要下一定功夫。到了C#2.0就变得简单了。在timetable类中,只需要添加一个属性:
public IEnumerable<DateTime> DateRange { get { for (DateTime day=StartDate ; day < =EndDate; day=day.AddDays(1)) { yield return day; } } }
只是将循环移动到了timetable类的内部,但是经过这一改动,使得封装变得更为良好。DateRange属性只是遍历时间区间中的每一天,每一次返回一天。如果想要使得逻辑变得复杂一点,只需要改动一处。这一小小的改动使得代码的可读性大大增强,接下来可以考虑将这个Range扩展为泛型Range<T>。
3.2迭代读取文件中的每一行
读取文件时,我们经常会书写这样的代码:
using (TextReader reader=File.OpenText(fileName)) { String line; while((line=reader.ReadLine())!=null) { …… } }
这一过程中有4个环节:
- 如何获取TextReader
- 管理TextReader的生命周期
- 通过TextReader.ReadLine迭代所有的行
- 对行进行处理
可以从两个方面对这一过程进行改进:可以使用委托--可以写一个拥有reader和一个代理作为参数的辅助方法,使用代理方法来处理每一行,最后关闭reader,这经常被用来展示闭包和代理。还有一种更为优雅更符合LINQ方式的改进。除了将逻辑作为方法参数传进去,我们可以使用迭代来迭代一次迭代一行代码,这样我们就可以使用foreach语句。代码如下:
static IEnumerable<String> ReadLines(String fileName) { using (TextReader reader = File.OpenText(fileName)) { String line; while ((line = reader.ReadLine()) != null) { yield return line; } } }
这样就可以使用如下foreach方法来读取文件了:
foreach (String line in ReadLines("test.txt")) { Console.WriteLine(line); }
方法的主体部分和之前的一样,使用yield return返回了读取到的每一行,只是在迭代结束后有点不同。之前的操作,先打开文档,每一次读取一行,然后在读取结束时关闭reader。虽然”当读取结束时”和之前方法中使用using相似,但当使用迭代时这个过程更加明显。
这就是为什么foreach迭代结束后会调用迭代器的dispose方法这么重要的原因了,这个操作能够保证reader能够得到释放。迭代方法中的using语句块类似与try/finally语句块;finally语句在读取文件结束或者当我们显示调用IEnumerator<String> 的Dispose方法时都会执行。可能有时候会通过ReadLine().GetEnumerator()的方式返回IEnumerator<String> ,进行手动迭代而没有调用Dispose方法,就会产生资源泄漏。通常会使用foreach语句来迭代循环,所以这个问题很少会出现。但是还是有必要意识到这个潜在的问题。
该方法封装了前三个步骤,这可能有点苛刻。将生命周期和方法进行封装是有必要的,现在扩展一下,假如我们要从网络上读取一个流文件,或者我们想使用UTF-8编码的方法,我们需要将第一个部分暴漏给方法调用者,使得方法的调用签名大致如下:
static IEnumerable<String> ReadLines(TextReader reader)
这样有很多不好的地方,我们想对reader有绝对的控制,使得调用者能够在结束后能进行资源清理。问题在于,如果在第一次调用MoveNext()之前出现错误,那么我们就没有机会进行资源清理工作了。IEnumerable<String>自身不能释放,他存储了某个状态需要被清理。另一个问题是如果GetEnumerator被调用两次,我们本意是返回两个独立的迭代器,然后他们却使用了相同的reader。一种方法是,将返回类型改为IEnumerator<String>,但这样的话,不能使用foreach进行迭代,而且如果没有执行到MoveNext方法的话,资源也得不到清理。
幸运的是,有一种方法可以解决以上问题。就像代码不必立即执行,我们也不需要reader立即执行。我们可以提供一个接口实现“如果需要一个TextReader,我们可以提供”。在.NET 3.5中有一个代理,签名如下:
public delegate TResult Func<TResult>()
代理没有参数,返回和类型参数相同的类型。我们想获得TextReader对象,所以可以使用Func<TextReader>,代码如下:
using (TextReader reader=provider()) { String line; while ((line=reader.ReadLine())!=null) { yield return line; } }
3.3 使用迭代块和迭代条件来对集合进行进行惰性过滤
LINQ允许对内存集合或者数据库等多种数据源用简单强大的方式进行查询。虽然C#2没有对查询表达式,lambda表达及扩展方法进行集成。但是我们也能达到类似的效果。
LINQ的一个核心的特征是能够使用where方法对数据进行过滤。提供一个集合以及过滤条件代理,过滤的结果就会在迭代的时候通过惰性匹配,每匹配一个过滤条件就返回一个结果。这有点像List<T>.FindAll方法,但是LINQ支持对所有实现了IEnumerable<T>接口的对象进行惰性求值。虽然从C#3开始支持LINQ,但是我们也可以使用已有的知识在一定程度上实现LINQ的Where语句。代码如下:
public static IEnumerable<T> Where<T>(IEnumerable<T> source, Predicate<T> predicate) { if (source == null || predicate == null) throw new ArgumentNullException(); return WhereImpl(source, predicate); } private static IEnumerable<T> WhereImpl<T>(IEnumerable<T> source, Predicate<T> predicate) { foreach (T item in source) { if (predicate(item)) yield return item; } } IEnumerable<String> lines = ReadLines("FakeLinq.cs"); Predicate<String> predicate = delegate(String line) { return line.StartsWith("using"); };
如上代码中,我们将整个实现分为了两个部分,参数验证和具体逻辑。虽然看起来奇怪,但是对于错误处理来说是很有必要的。如果将这两个部分方法放到一个方法中,如果用户调用了Where<String>(null,null),将不会发生任何问题,至少我们期待的异常没有抛出。这是由于迭代块的惰性求值机制产生的。在用户迭代的时候第一次调用MoveNext方法之前,方法主体中的代码不会执行,就像在2.2节中看到的那样。如果你想急切的对方法的参数进行判断,那么没有一个地方能够延缓异常,这使得bug的追踪变得困难。标准的做法如上代码,将方法分为两部分,一部分像普通方法那样对参数进行验证,另一部分代码使用迭代块对主体逻辑数据进行惰性处理。
迭代块的主体很直观,对集合中的逐个元素,使用predict代理方法进行判断,如果满足条件,则返回。如果不满足条件,则迭代下一个,直到满足条件为止。如果要在C#1中实现这点逻辑就很困难,特别是实现其泛型版本。
后面的那段代码演示了使用之前的readline方法读取数据然后用我们的where方法来过滤获取line中以using开头的行,和用File.ReadAllLines及Array.FindAll<String>实现这一逻辑的最大的差别是,我们的方法是完全惰性和流线型的(Streaming)。每一次只在内存中请求一行并对其进行处理,当然如果文件比较小的时候没有什么差别,但是如果文件很大,例如上G的日志文件,这种方法的优势就会显现出来了。
4 总结
C#对许多设计模式进行了间接的实现,使得实现这些模式变得很容易。相对来针对某一特定的设计模式直接实现的的特性比较少。从foreach代码中看出,C#1对迭代器模式进行了直接的支持,但是没有对进行迭代的集合进行有效的支持。对集合实现一个正确的IEnumerable很耗时,容易出错也很很枯燥。在C#2中,编译器为我们做了很多工作,为我们实现了一个状态机来实现迭代。
本文还展示了和LINQ相似的一个功能:对集合进行过滤。IEnumerable<T>在LINQ中最重要的一个接口,如果想要在LINQ To Object上实现自己的LINQ操作,那么你会由衷的感叹这个接口的强大功能以及C#语言提供的迭代块的用处。
本文还展示了实际项目中使用迭代块使得代码更加易读和逻辑性更好的例子,希望这些例子使你对理解迭代有所帮助。