迭代器与Unity协程

1.迭代器

  首先我们要谈的就是迭代器,很多情况下我们都使用了迭代器,并不仅仅是因为协程,当我们使用foreach循环遍历一个list时其实也是在使用迭代器。

  要实现迭代器最主要的是实现IEnumerable和IEnumerator这两个接口,当然实际上它们还有泛型形式IEnumerable<T>和IEnumerator<T>,而且这两个泛型形式的接口也是继承自IEnumerable和IEnumerator这两个接口。我们暂时先不管他们的泛型形式。

  我们先来看看这两个接口的样子。

  IEnumerable接口:

[ComVisible(true)]
[Guid("496B0ABE-CDEE-11d3-88E8-00902754C43A")]
public interface IEnumerable
{
    [DispId(-4)]
    IEnumerator GetEnumerator();
}

  IEnumerator接口:

[ComVisible(true)]
[Guid("496B0ABF-CDEE-11d3-88E8-00902754C43A")]
public interface IEnumerator
{
    object Current { get; }
    bool MoveNext();
    void Reset();
}

  先说IEnumerable接口,我们能看到它需要实现一个GetEnumerator方法,而这个方法的返回值正好是 IEnumerator 类型。在IEnumerator接口中,我们能看到有一个属性,两个方法要实现,而且从名字上来看也能一目了然他们的作用。Current表示当前项的内容,因为这里IEnumerator 不是泛型的所以,Current的类型为object。接下来是MoveNext方法,他返回一个bool类型的值,这个方法是个关键,因为这个方法是来判断迭代或者说循环遍历是否要往下执行的,同时一般会在这个方法中进行数组下标值的增加。而在Current中我们一般是不处理下标值的增减,而是通过下标值和数组返回当前对应的值。最后是Reset方法,一般是在这里重置下标,不过一般这个方法使用的比较少,而且后面还会提到这个方法在有些情况下是不能调用的。

  这里我们注意到一点,我们可以看到这里有两个接口,这里可能就会有个疑问,为什么需要使用两个接口,而不是在IEnumerable中就把需要在IEnumerator中实现的接口就实现了呢。当然首先都塞到一个接口中是可以的,但需要注意的是你如果希望对同一个序列进行多次相互独立的迭代,那么你需要注意每次要进行一次新的迭代时最好都创建一个新的实例,不管是嵌套循环还是非嵌套的。这样不好的地方在于数据和操作没有分离,一般来说在IEnumerable中我们只放入数组保存数据,然后通过GetEnumerator方法返回IEnumerator 的实例,然后把所有迭代操作交给IEnumerator的实现类,像诸如下标的记录等内容我们也会放在IEnumerator的实现类中,这样即使对同一个序列进行多次迭代,我们只需要一个IEnumerable对象就行,通过GetEnumerator方法生成多个IEnumerator的实例就行,他们之间将互不干扰,同时我们又可以只在IEnumerable第一次初始化的时候告诉他序列是什么,之后就可以不用在关心这个序列的内容了,除非你需要一个新的序列。当然上面所谓的好处一方面是我所认为的,一方面也是一些书中这么提到的,不过事实上,C#的设计既然鼓励我们这么做,我们照做就可以,当然你可以用自己的方式进行实现,不过像foreach这样语法糖性质的功能你可能就无法直接使用了。

  下面举一个例子:

class Program
{
    static void Main(string[] args)
    {
        object[] values = { "a", "b", "c", "d", "e"};
        IterationSample collection = new IterationSample(values, 3);
        foreach (object item in collection)
        {
            Console.WriteLine(item);
        }
    }
}
public class IterationSample : IEnumerable
{
    object[] values;
    int startingPoint;
    public object[] Values
    {
        get {
            if (values == null)
                throw new NullReferenceException();
            return values;
        }
    }
    public int StartingPoint
    {
        get {
            return startingPoint;
        }
    }
    public IterationSample(object[] values, int startPoint)
    {
        this.values = values;
        this.startingPoint = startPoint;
    }
    public System.Collections.IEnumerator GetEnumerator()
    {
        return new IterationSampleIterator(this);
    }
}
public class IterationSampleIterator : IEnumerator
{
    IterationSample parent;
    int position;
    public IterationSampleIterator(IterationSample parent)
    {
        this.parent = parent;
        position = -1;
    }
       
    public bool MoveNext()
    {
        if (position != parent.Values.Length)
        {
            position++;
        }
        return position < parent.Values.Length;
    }
    public void Reset()
    {
        position = -1;
    }
    public object Current
    {
        get {
            if (position == -1 || position == parent.Values.Length)
            {
                throw new InvalidOperationException();
            }
            int index = position + parent.StartingPoint;
            index = index % parent.Values.Length;
            return parent.Values[index];
        }
    }

}

这里我们实现了一个迭代器,可能比较繁琐,不过基本是遵照了迭代器设计的思路。从C#2开始,这个迭代器就不需要这么复杂的写法了,下面来看看C#2后关于迭代器我们能做什么。

  从C#2开始我们可以使用yield return来简化迭代器的实现,同时又可以让迭代器做更多的事情。我们在协程中经常会写这个东西,不过协程我们放到后面再说,先说这个怎么用。

  我们对上面的例子做一些修改,我们现在使用yield return的形式来实现上述功能。将上面的 GetEnumerator()方法中的内容替换掉,替换为下面的代码:

public System.Collections.IEnumerator GetEnumerator()
{
    for (int index = 0; index < values.Length; index++)
    {
        yield return values[(index + startingPoint) % values.Length];
    }
}

从结果上来说,两种代码是等价的。下面我们来分析一下 yield return。

  yield return这句代码是告诉C#编译器,这个方法不是一个普通的方法,而是一个实现迭代器块的方法。这个方法被声明为返回一个IEnumerator接口,所以就只能使用迭代器块来实现返回类型IEnumerable、IEnumrator或泛型等价物的方法(这里个人的理解是可以直接声明一个返回IEnumerable或IEnumerable<T>的方法,在里面使用yield return,或者在IEnumerable的实现类里面的GetEnumerator()方法中使用yield return这个方法的返回值为IEnumrator,至于泛型等价物我也不太理解可能是这两个接口的泛型类型)。另外匿名方法中不能使用迭代器代码块,这个在编译器中会报错。同时要注意的是如果你已经使用了 yield return来标识该方法是迭代器方法,那么该方法中就不能再使用return字样。

  我尝试使用.NET Reflector对上面代码进行反编译,来观察他们是怎么运作的(其中我选择不对代码进行优化,该反编译软件可以选择不同.NET版本进行代码优化,但有时候代码优化反而会使一些东西看不到了)。我们先看一下反编译后结构:


image.png

先忽视掉IterationSampleIterator这个类,主要看IterationSample和Program这两个类。在IterationSample这个类中我们可以看到一个<GetEnumerator>d__7这样命名的奇怪东西,实际上他是编译器帮我们生成的一个匿名类,他继承了IEnumerator接口,并帮我们自动实现了相关方法。这也是为什么使用yield return这种写法会让我们能省掉很多麻烦的原因,因为编译器已经帮我们做了很多工作了。

  我们暂时先不看<GetEnumerator>d__7里的相关实现,我们先来看一下反编译后的main方法都做了什么。下面是反编译后的代码:

private static void Main(string[] args)
{
    object[] objArray1;
    object[] objArray;
    IterationSample sample;
    IEnumerator enumerator;
    object obj2;
    IDisposable disposable;
    objArray1 = new object[] { "a", "b", "c", "d", "e" };
    objArray = objArray1;
    sample = new IterationSample(objArray, 3);
    enumerator = sample.GetEnumerator();
Label_0040:
    try
    {
        goto Label_0052;
    Label_0042:
        obj2 = enumerator.Current;
        Console.WriteLine(obj2);
    Label_0052:
        if (enumerator.MoveNext() != null)
        {
            goto Label_0042;
        }
        goto Label_0071;
    }
    finally
    {
    Label_005C:
        disposable = enumerator as IDisposable;
        if (disposable == null)
        {
            goto Label_0070;
        }
        disposable.Dispose();
    Label_0070:;
    }
Label_0071:
    return;
}

我们能看到有很多Label标签,这里的标签根据我的理解是为了goto而设置的,如果代码中有goto则跳到对应Label,否则顺序执行。这里从Label_0040开始就是源代码中foreach的部分。它首先先去判断MoveNext是否为true(请先忽略这里bool与空值比较的写法,其原理我也没找到合理的解释),如果MoveNext为true就打印Current,然后继续判断MoveNext是否为true,当MoveNext为false时会进行return操作,然后执行finally的代码,对迭代器进行释放操作。这一点很重要,而且如果你在一个foreach循环中return,他也是会走finally代码的,这个问题下面还会提到。现在我们大体上能明白foreach的原理了,其实就是MoveNext的判断与goto的配合。

  接下来我们再来看看另一个重要的内容,MoveNext里面究竟是怎么实现的。

private bool MoveNext()
{
    int num;
    int num2;
    bool flag;
    num = this.<>1__state;
    if (num == null)
    {
        goto Label_0018;
    }
Label_000C:
    if (num == 1)
    {
        goto Label_0065;
    }
    goto Label_0016;
    goto Label_0018;
    goto Label_0065;
Label_0016:
    return 0;
Label_0018:
    this.<>1__state = -1;
    this.<index>5__1 = 0;
    goto Label_007D;
Label_0029:
    this.<>2__current = this.<>4__this.values[(this.<index>5__1 + this.<>4__this.startingPoint) % ((int) this.<>4__this.values.Length)];
    this.<>1__state = 1;
    return 1;
Label_0065:
    this.<>1__state = -1;
    num2 = this.<index>5__1;
    this.<index>5__1 = num2 + 1;
Label_007D:
    if ((this.<index>5__1 < ((int) this.<>4__this.values.Length)) != null)
    {
        goto Label_0029;
    }
    return 0;
}

这段看起来挺长,但实际内容并不复杂,我们先看一下<GetEnumerator>d__7展开的结构,如下图:


image.png

在MoveNext中我们需要关注的是三个类的字段<>1__state,<index>5__1,<>2__current,第一个代表一种状态,第二个代表数组下标,第三个是当前值。<>1__state在代码中能看到三种情况,一种是一开始的0,一种是-1,一种是1,我的理解是这三种分别表示初始,失败和成功(其实不光有1也可能有2,3等等)。一开始<>1__state为0所以num也为0,这里为0会判空(猜测),然后会跳转到Label_0018去赋初始值。在赋初始值的时候我们能看到<>1__state会被赋为-1,我的理解是先假定后面判断下标与数组长度时会失败。之后会去判断下标是否超过数组长度,超过则返回0(通过0为null在外面再进行判断),成功则跳到Label_0029为2__current赋值,同时 1__state 状态置为1,并返回1来表示MoveNext为true。到此一次判断结束,之后再进行判断与此相同。

  到这里我们就能明白一个foreach循环是怎么运作的了,下面我们从另一个角度来观察迭代器的工作流程。

  先来看一个例子:

//main中的
IEnumerable<int> iterable = CreateEnumerable();
IEnumerator<int> iterator = iterable.GetEnumerator();
Console.WriteLine("Starting to iterate");
while (true)
{
    Console.WriteLine("Calling MoveNext()...");
    bool result = iterator.MoveNext();
    Console.WriteLine("...MoveNext result={0}", result);
    if (!result)
    {
        break;
    }
    Console.WriteLine("Fetching Current...");
    Console.WriteLine("...Current result={0}", iterator.Current);
}
//main外面的
static readonly string Padding = new string (' ', 30 );
static IEnumerable<int> CreateEnumerable()
{
    Console.WriteLine("{0}Start of CreateEnumerable()", Padding);
    for (int i = 0; i < 3; i++)
    {
        Console.WriteLine("{0}About to yield {1}", Padding, i);
        yield return i;
        Console.WriteLine("{0}After yield", Padding);
    }
    Console.WriteLine("{0}Yielding final value", Padding);
    yield return -1;
    Console.WriteLine("{0}End of CreateEnumerable()", Padding);
}

下图是运行结果。


image.png

  根据这个结果,有几个重要的事情需要牢记:

  • 在第一次调用MoveNext之前,CreateEnumerble中的代码不会被调用;
  • 所有工作都在调用MoveNext的时候就完成了,获取Current的值不会执行任何代码;
  • 在yield return的位置,代码就停止执行,在下一次调用MoveNext的时候又继续执行;
  • 在一个方法中的不同地方可以书写多个yield return语句;
  • 代码不会在最后的yield return处结束——相反,而是通过返回false的MoveNext调用来结束方法的执行;
  • 如果IEnumerable和IEnumerator为非泛型形式,yield return后面的内容都会被认为时object,而他们实际上也是将要出现的每个Current。

  此外还有一点static IEnumerable<int> CreateEnumerable(ref int a)这种情况是不允许的,迭代器代码块不能实现具有ref或out参数的方法。

  我们从上面已经得知,yield return语句只是临时退出了方法,直到再次调用MoveNext后又继续执行,那在迭代器中我们怎么中途退出呢,这时候就需要使用yield break了;比如我们把上面的例子中yield return i;换成yield break;结果就会变成下图:


image.png

  我们看到yield break前面的内容还是走了,但是到了那里迭代器会结束,而且MoveNext会返回false。

  看完了yield break,我们还要再看看finally。我们在yield return的时候并不像一般方法return时那样如果有finally就会走finally里面的内容。不过如果yield break被执行了的话,finally还是会被执行的。另外其实MoveNext返回false时finally也会被执行。还有几种情况,比如在上面的例子中,在while循环内使用return,那么finally就不会被执行,但是如果是foreach在调用某个迭代器,那么在foreach中return,那么finally会正常工作。那原因是什么呢,因为foreach会在自己的finally代码块中调用IEnumerator所提供的Dispose方法,而这也是迭代器自身的finally会被调用的关键。

  如果Reset方法是编译器帮我们实现的,而不是我们自己实现的,那么请勿显式调用,那样会报错。

2.Unity协程

  上面我们讲了很多与C#迭代器相关的内容了,下面我们就来看看Unity协程。

  我们可能会在Unity中写如下代码:

IEnumerator TestCoroutine()
{
    Debug.LogError("StartTestCoroutine");
    yield return new WaitForEndOfFrame();
    Debug.LogError("EndTestCoroutine");
}

然后StartCoroutine(TestCoroutine());这样调用它。这里我们可以看到TestCoroutine方法就是一个迭代器的写法,通过反编译我们能看到如下的信息:

[DebuggerHidden]
private IEnumerator TestCoroutine()
{
    <TestCoroutine>c__Iterator0 iterator;
    IEnumerator enumerator;
    iterator = new <TestCoroutine>c__Iterator0();
    enumerator = iterator;
Label_000D:
    return enumerator;
}

这里我们能看到编译器帮我们生成了一个<TestCoroutine>c__Iterator0这与我们在前面讲述迭代器时的套路是一样的。那<TestCoroutine>c__Iterator0内部是什么样的呢?

private sealed class <TestCoroutine>c__Iterator0 : IEnumerator, IDisposable, IEnumerator<object>
{
    // Fields
    internal object $current;
    internal bool $disposing;
    internal int $PC;

    // Methods
    [DebuggerHidden]
    public <TestCoroutine>c__Iterator0();
    [DebuggerHidden]
    public void Dispose();
    public bool MoveNext();
    [DebuggerHidden]
    public void Reset();

    // Properties
    object IEnumerator<object>.Current { [DebuggerHidden] get; }
    object IEnumerator.Current { [DebuggerHidden] get; }
}

其实还是在对 IEnumerator接口进行实现,也就是说实质上协程是一个迭代器,只是我们现在无法得知MoveNext是在什么时机被调用的,不过至少我们能看到MoveNext里面长什么样子:

public bool MoveNext()
{
    uint num;
    num = this.$PC;
    this.$PC = -1;
    switch (num)
    {
        case 0:
            goto Label_0021;

        case 1:
            goto Label_004B;
    }
    goto Label_005C;
Label_0021:
    Debug.LogError("StartTestCoroutine5");
    this.$current = new WaitForEndOfFrame();
    if (this.$disposing != null)
    {
        goto Label_005E;
    }
    this.$PC = 1;
    goto Label_005E;
Label_004B:
    Debug.LogError("EndTestCoroutine5");
    this.$PC = -1;
Label_005C:
    return 0;
Label_005E:
    return 1;
}

与上面介绍过的MoveNext基本上也没啥差别。其关键实际是调用MoveNext的时机,这一块内容被Unity封装了,我们无从看到,不过从Unity声明周期中我们也能看到一些端倪


image.png

image.png

从上图中我们能看到许多yield XXX的内容,而且他们出现在不同阶段,这里他们所在的位置实际上是指结束本次yield return准备调下一个MoveNext的时机,在前面的内容也提到了yield return会暂时中止代码向下进行直到下一个MoveNext出现才会继续下面的代码。而且这个图也能说明MoveNext调用的时机是有选择性的,同时也告诉我们在使用yield XXX时要注意时序,尤其是跟生命周期函数有关联时一定要注意。

最后http://www.cnblogs.com/yougoo/p/9565704.html这里有个用C#模拟协程的实现,可以参考一下。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,053评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,527评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,779评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,685评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,699评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,609评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,989评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,654评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,890评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,634评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,716评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,394评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,976评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,950评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,191评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,849评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,458评论 2 342

推荐阅读更多精彩内容