type
status
date
summary
tags
category
icon

一、迭代器

1.1 迭代器简介

迭代器是包含迭代器块的方法或者属性。迭代器块本质上是包含yield returnyield break语句的代码,只能用于返回类型是以下的方法或属性:
  • IEnumerable
  • IEnumerable<T>(T可以是类型形参,也可以是普通类型)
  • IEnumerator
  • IEnumerator<T>(T可以是类型形参,也可以是普通类型)
根据迭代器的返回类型,每个迭代器都有一个yield类型。
  • 如果返回类型是非泛型接口,那么生成类型是object
  • 如果返回类型是泛型接口,那么生成类型是该泛型接口实参的类型。例如IEnumerator<string>的生成类型是string
下面展示一个简单的迭代器,来进一步探究迭代器的原理
这段代码看上去有更简单的实现:使用一个List<int>,向其中添加元素然后循环打印。两种实现方式打印的结果相同,但是执行过程却天差地别:迭代器是延迟执行的

1.2 延迟执行

延迟执行基本思想十分简单:只在需要获取计算结果时执行代码。为了阐释清楚代码是如何执行的,如下所示扩展以上代码:采用while循环来重新实现与foreach循环大致相同的逻辑。简单起见,还是用了using语法糖来保证Dispose方法的自动调用
上面的代码与延迟执行有何关系呢?当CreateSimpleIterator()被调用时,方法体中的代码都没有执行。如果在yield return 10这一行插入断点后开始调试,就会发现方法被调用之后根本不会触发断点。调用GetEnumerator()方法同样不会触发断点。只有MoveNext()被调用时方法才会真正开始执行,然后会怎么样呢?
💡
IEnumerable是可用于迭代的序列,IEnumerator则像是序列的一个游标。多个IEnumerator可以遍历同一个IEnumerable,并且不会改变IEnumerable的状态,而IEnumerator本身就是多状态的:每次调用MoveNext(),当前游标都会向前移动一个元素。IEnumerable.GetEnumerator()方法如同一个启动过程,它请求序列来创建一个IEnumerator用于迭代。

1.3 执行yield语句

之后方法是根据需要执行的。当如下几种情形之一发生时,代码会终止执行:
  • 抛出异常
  • 方法执行完毕
  • 遇到yield break语句
  • 执行到yield return语句,迭代器准备返回值
如果抛出异常,那么该异常会正常流转;如果执行到了方法末尾,或者遇到了yield break语句,MoveNext()方法就会返回false来表示已经到达序列的末尾;如果遇到了yield return语句,Current属性会被赋以当前迭代值,然后MoveNext()返回true。
在本例中,MoveNext()开始迭代之后,它遇到一条yield return 10语句,于是Current赋值为10,然后返回true。
第一次调用还比较好理解,之后呢?实际上,但MoveNext()返回时,当前方法彷佛就被暂停了。生成的代码会追踪当前的语句执行进度,还会记录一些相关状态信息,比如循环中局部变量i的值。但MoveNext()再次被调用,就会从之前的位置继续执行,这就是延迟执行名称的由来。
📕
这里需要澄清的是,前面说的异常正常流转的前提是迭代器的代码已经在执行了。请牢记,直到调用代码开始迭代序列之后,迭代器的代码才开始执行。抛出异常的是MoveNext()调用,而不是最初的迭代器方法调用。

1.4 延迟执行的重要性

接下来借用一段打印斐波那契数列的代码来展示延迟执行的重要性。下面的Fibonacci()方法将返回一个无限长度的序列,然后由另一个方法迭代该数列,直至到达某个预先设定的上限值(1000)
可以想象,如果不使用迭代器,较为理想的方法是创建一个List<int>,然后向该列表添加值,直到预设的上限值。然而,如果这个上限值很大,那么相应的列表也会变得很大。在这种情境下,我们应该只关注斐波那契数列本身的信息,根本不应该关心何时停止这样的外部信息。假如我们不是要打印这些值,而是要把他们相加呢?难道再写一个方法吗?这种做法显然违背了“关注点分离”原则。
最终只有迭代器方案才更优:用迭代器来表示一个无限长的序列,仅此而已。

1.5 处理finally块

在实际工作中,通常会使用using语句而不是直接用finally块,但是using语句是基于finally块实现的,因此二者在行为上具有一致性。
下面的代码展示了具体的执行流程,代码中的迭代器在try块中生成了两个元素,并逐个打印到控制台。
在运行这段代码之前,先预测一下迭代该序列会输出什么结果。
  • 如果认为在执行到yield return语句时,执行就暂停了,逻辑上讲执行还停留在try块中,那么就不会执行到finally块
  • 如果认为当执行到yield return时,代码实际上返回到了MoveNext()调用,感觉应该已经退出了try块,那么就应该正常执行finally块的代码
答案是第一个,因为这种暂停执行的机制更加有效并且符合我们的直观预期。下面验证一下上述结论:
这段代码证明了延迟执行的存在:Main()函数的输出和Iterator()方法的输出穿插出现,因为迭代器在不停地暂停和恢复。如果要求迭代中途停止呢?如果从迭代器获取元素的代码只调用一次MoveNext()呢?这种情况会不会让迭代器一直在try块中暂停,永远都不会去执行finally块?
答案是不会。如果完全手动编写调用IEnumerator<T>方法,然后只调用一次MoveNext()方法,那么最终将不会执行finally块。但如果采用foreach循环,在序列全部迭代完成之前退出循环,那么将执行finally块。例如下面代码执行结果如下:
当退出foreach循环时,finally块自动执行,这是因为foreach循环中隐含了一条using语句。下面的代码展示了如何将foreach循环手动改写成等价的代码
using语句是重点。它保证了不管采用何种方式离开循环,都会调用IEnumerable<string>Dispose方法。在调用Dispose方法时,如果此时迭代器还暂停在try块中也没有关系,Dispose方法会负责最终调用finally块!

1.6 迭代器实现机制概览

虽然只是实现一个迭代器方法,但编译器背后会生成一个全新的类型来实现相关的接口。我们所编写的方法体会被移动到生成类型的MoveNext()方法中,并且调整相关的执行语义。例如对于以下代码:
上面的方法是一个迭代器方法的原始形式,其中蕴涵了5点精心设计:
  • 一个参数
  • 一个需要在yield return语句之间保留的局部变量
  • 一个不需要在yield return语句之间保留的局部变量
  • 两条yield return语句
  • 一个finally块
上述代码经过反编译后,代码主体结构如下所示:
编译器生成了一个状态机,它是一个私有的嵌套类。编译器还会生成一个和原始方法签名相同的方法,调用方会调用这个新方法。新方法所做的工作包括:创建一个状态机实例、复制参数、把状态机返回给调用方。原始代码中的方法都不会被调用,前文提到的延迟执行正与此有关。
状态机中包含了实现迭代器的全部内容
  • 方法当前执行位置指示器。该指示器与CPU的指令计数器类似,但更简单,因为只需要区分若干种状态
  • 所有参数的一份副本,但需要使用参数值时方便获取
  • 方法体中定义的局部变量
  • 最近一次生成的值。调用方可以通过Current属性获取该值
之后调用方会执行以下操作
  1. 调用GetEnumerator()来获得IEnumerator<int>
  1. 反复调用MoveNext()并访问IEnumerator<int> 中的Current属性,直到返回false
  1. 在需要清理资源时调用Dispose方法,无论是否有异常抛出
绝大部分情况下,状态机只能在创建它的线程内被使用一次。GetEnumerator()方法负责检查状态机,比如状态机处于当前线程且为初始态,则返回this,因为状态机需要同时实现IEnumerable<int>IEnumerator<int>这两个接口。如果GetEnumerator()方法被其他线程调用或多次被调用,这些调用会各自创建一个新的状态机实例,并复制初始的参数值。
MoveNext()方法的内容比较复杂。它第一次被调用时,会正常执行其中的代码;在后续调用中,它就需要准确跳转到方法中的指定位置。本地变量都定义为状态机的字段,因为在不同的调用期间需要保留本地变量值。
在一个优化过的构建中,有一些局部变量是不需要复制给字段的。例如下面的代码中,doubled这个局部变量
doubled变量只是做了初始化、打印值、最后生成值。当再次返回到方法时,该变量已经没用了,因此编译器在做发布构建时,会把它优化成真正的局部变量。
MoveNext()方法内部是如何实现的呢?下面给出简化版的大致结构
状态机包含了一个变量(state)用于记录当前执行位置。变量的具体指随不同的实现有所差别。以Roslyn编译器为例,状态值如下所示。
  • -3:MoveNext()当前正在执行
  • -2:GetEnumerator()尚未被调用
  • -1:执行完成(无论成功与否)
  • 0:GetEnumerator()已被调用,但是MoveNext()尚未被调用
  • 1:在第1条yield return语句
  • 2:在第2条yield return语句
当调用MoveNext()时,它利用上述状态在方法的执行位置进行跳转:跳转到执行初始位置或者恢复到上一条yield return语句的位置。
靠近结尾的fault块是一个IL结构,该结构在C#中并没有对等形式。它类似于finally块,在发生异常时会被执行,但并不捕获异常。finally块中的代码被移动到了一个单独的方法中,然后由Dispose()来调用或者由MoveNext()来调用。Dispose()方法会依据当前状态来确定需要执行何种清理操作。
至此,C# 2中最庞大的一个特性就介绍完毕了。
 
Unity 笔记 移动平台应用性能优化Unity 笔记 Android SDK接入
  • Twikoo
  • Cusdis