C#基础の迭代器详解
一、什么是迭代器
迭代器(iterator)有时又称游标(cursor)是程序设计的软件设计模式,可在容器(container,例如链表或阵列)上遍访的接口,设计人员无需关心容器的内容。 迭代器模式是设计模式中行为模式(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使得这一工作变得大为简单,节省了实现迭代器的不少工作。
二、迭代器实现
1、1.0以前的实现
class Program { static void Main(string[] args) { Friends friendcollection = new Friends(); foreach (Friend f in friendcollection) { Console.WriteLine(f.Name); } Console.Read(); } } /// <summary> /// 朋友类 /// </summary> public class Friend { private string name; public string Name { get { return name; } set { name = value; } } public Friend(string name) { this.name = name; } } /// <summary> /// 朋友集合 /// </summary> public class Friends : IEnumerable { private Friend[] friendarray; public Friends() { friendarray = new Friend[] { new Friend("张三"), new Friend("李四"), new Friend("王五") }; } public Friend this[int index] { get { return friendarray[index]; } } public int Count { get { return friendarray.Length; } } // 实现IEnumerable<T>接口方法 public IEnumerator GetEnumerator() { return new FriendIterator(this); } } /// <summary> /// 自定义迭代器,必须实现 IEnumerator接口 /// </summary> public class FriendIterator : IEnumerator { private readonly Friends friends; private int index; private Friend current; internal FriendIterator(Friends friendcollection) { this.friends = friendcollection; index = 0; } #region 实现IEnumerator接口中的方法 public object Current { get { return this.current; } } public bool MoveNext() { if (index + 1 > friends.Count) { return false; } else { this.current = friends[index]; index++; return true; } } public void Reset() { index = 0; } #endregion }
2、2.0以后借助yeild的实现
class Program { static void Main(string[] args) { Friend[] friendArray = new Friend[3] { new Friend("geng", "he" ), new Friend("xie", "tian"), new Friend("li", "si"), }; Friends friends = new Friends(friendArray); foreach (Friend f in friends) { Console.WriteLine(f.FirstName); } Console.ReadLine(); } } public class Friend { public string FirstName { get; set; } public string Nextname { get; set; } public Friend(string firstName, string nextName) { FirstName = firstName; Nextname = nextName; } } public class Friends { Friend[] friends; string FirstName { get; set; } string Nextname { get; set; } public Friends(Friend[] friendArry) { friends = friendArry; } public IEnumerator GetEnumerator() { for (int index = 0; index < friends.Length; index++) { yield return friends[index]; } } }
三、迭代器的执行过程
下面是枚举器接口
namespace System.Collections { // // 摘要: // 支持对非泛型集合的简单迭代。 [ComVisible(true)] [Guid("496B0ABF-CDEE-11d3-88E8-00902754C43A")] public interface IEnumerator { // // 摘要: // 获取集合中的当前元素。 // // 返回结果: // 集合中的当前元素。 object Current { get; } // // 摘要: // 将枚举数推进到集合的下一个元素。 // // 返回结果: // 如果枚举数已成功地推进到下一个元素,则为 true;如果枚举数传递到集合的末尾,则为 false。 // // 异常: // T:System.InvalidOperationException: // 创建枚举器后,已修改该集合。 bool MoveNext(); // // 摘要: // 将枚举数设置为其初始位置,该位置位于集合中第一个元素之前。 // // 异常: // T:System.InvalidOperationException: // 创建枚举器后,已修改该集合。 void Reset(); } }
通过测试(可以自己手动实现接口,如1.0以前的内容,加一些打印输出),我们得出
- 直到第一次调用MoveNext,CreateEnumerable中的方法才被调用。
- 在调用MoveNext的时候,已经做好了所有操作(MoveNext为true继续执行),返回Current属性并没有执行任何代码。
- 代码在yield return之后就停止执行,等待下一次调用MoveNext方法的时候继续执行。
- 在方法中可以有多个yield return语句。
- 在最后一个yield return执行完成后,代码并没有终止。调用MoveNext返回false使得方法结束。
这意味着,不能在迭代块中写任何在方法调用时需要立即执行的代码--比如说参数验证。如果将参数验证放在迭代块中,那么他将不能够很好的起作用,这是经常会导致的错误的地方,而且这种错误不容易发现。
四、迭代器特殊执行
使用 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语句块不一样。就像我们看到的,yield return语句停止了方法的执行,而不是退出方法,根据这一逻辑,在这种情况下,finally语句块中的语句不会执行。
但当碰到yield break语句的时候,就会执行finally 语句块,这根普通方法中的return一样。一般在迭代块中使用finally语句来释放资源,就像使用using语句一样。
在forech中,return语句之后,因为CountWithTimeLimit中有finally块所以代码继续执行CountWithTimeLimit中的finally语句块。foreach语句会调用GetEnumerator返回的迭代器的Dispose方法。在结束迭代之前调用包含迭代块的迭代器的Dispose方法时,状态机会执行在迭代器范围内处于暂停状态下的代码范围内的所有finally块,这有点复杂,但是结果很容易解释:只有使用foreach调用迭代,迭代块中的finally块会如期望的那样执行。
五、迭代器扩展
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>。
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 使用迭代块和迭代条件来对集合进行进行惰性过滤
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的日志文件,这种方法的优势就会显现出来了。