迭代器模式
迭代器是C#里面非常非常非常重要的一个概念,它是序列、LINQ等一系列概念的基础。
手写一个迭代器
class MyIEnumerable : IEnumerable<string> { public IEnumerator<string> GetEnumerator() { return new MyEnumerator(); } IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); } internal class MyEnumerator : IEnumerator<string> { private readonly List<string> _list; public string Current { get { if (_state == -1 || _state == _list.Count) { throw new InvalidOperationException(); } return _list[_state]; } } private int _state; object IEnumerator.Current => Current; public MyEnumerator() { _list = new List<string>() { "a", "b", "c", "d", "e", "f" }; _state = -1; } public void Dispose() { Console.WriteLine("dispose"); } public bool MoveNext() { if (_state != _list.Count) { _state++; } return _state < _list.Count; } public void Reset() { throw new NotImplementedException(); } } }
上面手写了一个迭代器,在这个迭代器内部嵌套了一个类实现IEnumerator。这是实现迭代器模式的C#标准代码。看起来很臃肿,如果我只想返回几个值,也必须这么写。
yield
C#利用这个关键字大大的简化了实现IEnumerable的步骤。但是编译器为我们在后台做的事情不少。我们慢慢来看。
C#2是利用一个关键字来告诉编译器去后台创建一个状态机。这个关键字是yield,比如下面的例子:
class Program { static void Main(string[] args) { foreach (int item in GetEnumerable()) { Console.WriteLine(item); } } static IEnumerable<int> GetEnumerable() { for (int i = 0; i < 1000; i++) { yield return i; } } }
program类中声明了一个GetEnumerable的静态方法,返回一个IEnumerable<int>的实例。关键是在方法内部,出现了yield关键字,这就是告诉编译器要在后台生成一个类来实现迭代器模式,用ILSpy来看一下生成的这个类:
可以看到一个名字为“<GetEnumerable>d_1″的类被编译器生成了。因为我并没有声明这个类。这个类是一个嵌套类,在program类中定义。
这个类的声明如下:
private sealed class <GetEnumerable>d__1 : IEnumerable<int>, IEnumerable, IEnumerator<int>, IEnumerator, IDisposable
可以看到这个类实现了5个接口:IEnumerable<T>,IEnumerable,IEnumerator<T>,IEnumerator,IDisposable
这个类的结构如下:
它定义了三个字段并实现了接口的所有方法。关键的代码在MoveNext中,看一下:
可以看到在MoveNext中将GetEnumerable方法的逻辑放到了这里,而在GetEnumerable方法的内部,看一看到之前那一坨包含yield return的代码块不见了,而变成这样:
只有一句,就是将编译器生成的类实例化后返回。
具体在实例化这个<GetEnumerable>d__1实例时会传入-1还是-2取决于返回的是一个IEnumerable(-2)还是一个IEnumeartor(-1).
其他具体的可以用ILSpy来查看。ILSpy的地址在github上。
yield return需要注意的事项
从上面的描述可以得到如下信息:
在返回一个IEnumerable或者IEnumerator或他们的泛型等价物的方法中执行yield,编译器就会在后台生成一个状态机,这个状态机实现了上面说的那5个接口IEnumerable<T>,IEnumerable,IEnumerator<T>,IEnumerator,IDisposable。这个状态机的代码和C#1中我们需要手写的实现迭代器的代码基本一样。
如果方法声明的返回类型是非泛型接口, 那么迭代器块的生成类型(yield type) 是object, 否则就是泛 型接口的类型参数。 例如, 如果方法声明为返回IEnumerable<string>, 那么就会得到string类型的生成类型。
那么,如果要实现一个迭代器,就要考虑以下事情:
它必须具有某个初始状态,比如上面看到的那个生成类中的<>1__state;
每次调用MoveNext时, 在提供下一个值之前( 换句话说, 就是执行到yield return 语句之前), 它需要执行GetEnumerator 方法中的代码;
使用Current属性 时, 它必须返回我们生成的上一个值;
它必须知道何时完成生成值的操作, 以便MoveNext返回false。
对于如何解决第二点提出的问题,从上面的贴图来看,在MoveNext方法中会根据<>1__state的值进入到相应的代码段来执行相应的代码。
迭代器的工作流程
无论返回的是IEnumerable<T>还是IEnumerator<T>,在迭代器的方法里面没有什么区别,区别在于,如果你要枚举这个生成的类,比如说你要使用foreach来遍历,那么最好是返回一个IEnumerable的类型。因为在C#中是否能够遍历一个对象在于这个对象是否实现了一个GetEnumerator的方法(Duck Typing)。而无论它是否实现了那一堆接口。
用一个代码段来说明一下迭代器的执行流程吧:
class Program { private static readonly string Padding = new string(' ', 30); static void Main(string[] args) { IEnumerable<int> iterable = CreateEnumerable(); IEnumerator<int> iterator = iterable.GetEnumerator(); Console.WriteLine("start to iterate"); while (true) { Console.WriteLine("calling MoveNext()...."); bool moveNext = iterator.MoveNext(); Console.WriteLine($"....MoveNext result={moveNext}"); if (!moveNext) { break; } Console.WriteLine("fetching current value...."); Console.WriteLine($"current value={iterator.Current} "); } Console.ReadKey(); } static IEnumerable<int> CreateEnumerable() { Console.WriteLine($"{Padding} start of CreateEnumerable()"); for (int i = 0; i < 3; i++) { Console.WriteLine($"{Padding} about to yield {i}"); yield return i; Console.WriteLine($"{Padding} after yield"); } Console.WriteLine($"{Padding} yielding final value"); yield return -1; Console.WriteLine($"{Padding} End of CreateEnumerable()"); } }
上面的代码会输出以下结果:
start to iterate calling MoveNext().... start of CreateEnumerable() about to yield 0 ....MoveNext result=True fetching current value.... current value=0 calling MoveNext().... after yield about to yield 1 ....MoveNext result=True fetching current value.... current value=1 calling MoveNext().... after yield about to yield 2 ....MoveNext result=True fetching current value.... current value=2 calling MoveNext().... after yield yielding final value ....MoveNext result=True fetching current value.... current value=-1 calling MoveNext().... End of CreateEnumerable() ....MoveNext result=False
下面是结论:
在第一次调用MoveNext之前, CreateEnumerable中的代码不会被调用。也就是说只有调用MoveNext的时候CreateEnumerable中的代码才会被调用。因为CreateEnumerable中的代码全部被搬到了MoveNext中,CreateEnumerable中的代码只剩下一行return new <GetEnumerable>d_1();
所有工作在调用MoveNext时就完成了, 获取Current的值不会执行任何代码;
在yield return的位置, 代码就停止执行, 在下一次调用MoveNext时又继续执行。MoveNext—-yield return—-MoveNext—–yield return循环直到MoveNext返回fanlse。
在一个方法中的不同地方可以编写多个yield return语句;
代码不会在最后的yield return处结束, 而是通过返回false的MoveNext调用来结束方法的执行。
遇到try–catch–final怎么办?
首先需要说明的是不能在try-catch块中使用yield return。编译器会提示两个:①不能在包含catch的try块中生成值②不能在catch块中生成值。这也就是说,出现yield关键字的地方不能有catch块。可以使用try–finally块。那么这个话题就变成了:在try–finally块中的执行情况
要离开作用域时,我们习惯用finally块去执行一些代码,它的语义是”最后无论如何都要执行“。
迭代器行为方式和普通的方法不太一样。yield return只是暂时停止了方法,而不是退出了方法,他的意思是说”我还会回来的“。而yield break语句的行为类似于普通的return–退出代码所在的方法。
说一个题外话:break是跳出当前循环,continue是结束一次循环 ,return是退出代码所在的方法。
在try–finally块中的执行情况的结论是无论是yield return还是yield break,在遇到finally的时候总会最终执行finally中的代码。原因是finally被编译器生成一个单独的方法,这个方法会在正常结束时被调用,也会在中途退出的时候被调用,上两张图:
这个是在实现IDisposable接口的方法,看到最下面那个了”<>m__Finally1“么?那是编译器生成的这个状态机的方法,前提如果我们的yield代码块中包含finally块时,这个finally块会被编译器编译成一个单独的方法。这个方法会在正常结束的地方放置,也会在Dispose方法中放置。而使用foreach调用时编译器会在foreach使用结束的时候调用状态机的Dispose方法。也就是说——只要调用者使用了foreach循环, 迭代器块中的finally将按照你期望的方式工作。如果你要想手动调用MoveNext方法和Current属性,那么请记得要将代码放到using语句中。using语句会帮助你调用Dispose。
现在总结以下编译器在实现状态机的时候一些要点:
在第一次调用MoveNext之前, Current属性总是返回迭代器产生类型的默认值;
在MoveNext返回false之后, Current属性总是返回最后的生成 值;
Reset总是抛出异常, 而不像我们手动实现的Reset过程那样, 为了遵循语言规范, 这是必要的行为;
嵌套类总是实现IEnumerator的泛型形式和非泛型形式(提供给泛型和非泛型的IEnumerable所用)。