← 全部文章 C# 语言

C# 迭代器(二):yield

2021 年 9 月 16 日 · 2 分钟读完

目录
  1. yield 是什么
  2. 例子:一个手写的链表
  3. 执行顺序
  4. yield break
  5. 不能用 yield 的地方
  6. 延伸

上一篇手写了 IEnumerator,一个能 foreach 的集合要写几十行。yield 可以把这件事交给编译器。

yield 是什么

yield 不是保留字,只有紧挨着 return 或 break 时才有特殊意义,在别处可以当普通标识符。

yield_statement
    : 'yield' 'return' expression ';'
    | 'yield' 'break' ';'
    ;

包含 yield 的方法叫迭代器方法,返回类型必须是 IEnumerable、IEnumerable<T>、IEnumerator 或 IEnumerator<T>(C# 8 起还有 IAsyncEnumerable<T>)。

例子:一个手写的链表

为了完全不依赖数组和 List,写一个双向链表:

public class Node<T>
{
    public T Value { get; }
    public Node<T> Next { get; set; }
    public Node<T> Prev { get; set; }

    public Node(T value) => Value = value;
}

public class MyLinkedList<T> : IEnumerable<T>
{
    public Node<T> First { get; private set; }
    public Node<T> Last { get; private set; }

    public void Add(T value)
    {
        var node = new Node<T>(value);
        if (First == null)
        {
            First = Last = node;
        }
        else
        {
            Last.Next = node;
            node.Prev = Last;
            Last = node;
        }
    }

    public IEnumerator<T> GetEnumerator()
    {
        var item = First;
        Console.WriteLine("首次进入 GetEnumerator");
        while (item != null)
        {
            Console.WriteLine("我在 yield 上方");
            yield return item.Value;   // 交出一个值,方法在这里「暂停」
            Console.WriteLine("我在 yield 下方"); // 下一次 MoveNext 时从这里继续
            item = item.Next;
        }
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
var list = new MyLinkedList<int>();
for (int i = 0; i < 12; i += 2) list.Add(i);

Console.WriteLine("准备进入 foreach");
foreach (var item in list)
{
    Console.WriteLine(item);
}

对比上一篇,整个 Enumerator 类都不用写了。

执行顺序

看输出就能理出顺序:

  1. foreach 调用 GetEnumerator(),但方法体一行都还没执行,只是拿到了一个迭代器对象;
  2. 第一次 MoveNext():方法体从头执行,打印「首次进入」「yield 上方」,遇到 yield return 0,把 0 交给 Current,MoveNext 返回 true,方法暂停在这里;
  3. foreach 循环体打印 0;
  4. 第二次 MoveNext():从暂停处继续,打印「yield 下方」,item 后移,回到 while,打印「yield 上方」,遇到 yield return 2,再次暂停;
  5. ……直到 while 结束,方法自然退出,MoveNext 返回 false,foreach 结束。

yield return 不是 return,它是「交出一个值,然后暂停」。

yield break

yield break 用来提前结束迭代,相当于迭代器里的 return:

public IEnumerator<T> GetEnumerator()
{
    var item = First;
    int i = 0;
    while (item != null)
    {
        if (++i > 5) yield break; // 只给出前 5 个
        yield return item.Value;
        item = item.Next;
    }
}

链表里有 6 个数,这次只打印了 5 个。yield break 之后 MoveNext 返回 false,foreach 结束并调用迭代器的 Dispose(),方法里 finally 块中的代码会在这时执行。

不能用 yield 的地方

IEnumerator<int> GetEnumerator()
{
    try
    {
        yield return 1;   // 可以:try...finally 的 try 里
    }
    finally
    {
        yield return 2;   // 错误:finally 里不能 yield
    }

    try
    {
        yield return 3;   // 错误:带 catch 的 try 里不能 yield return
        yield break;      // 可以
    }
    catch
    {
        yield return 4;   // 错误
    }

    Func<IEnumerable<int>> d = delegate
    {
        yield return 5;   // 错误:匿名方法和 lambda 里不能 yield
    };
}

int MyMethod()
{
    yield return 1;       // 错误:返回类型不是可迭代类型
}

另外,迭代器方法不能有 ref、out 参数。详细规则见微软文档。

延伸

  • 编译器做了什么:它把迭代器方法改写成一个私有的嵌套类,实现 IEnumerator<T>,用一个整数 state 记录停在哪个 yield,局部变量都变成这个类的字段,MoveNext 里是一个按 state 跳转的 switch。在 SharpLab 里看一眼反编译结果就明白了。这种「状态机」写法和 async/await 是同一个思路。
  • 延迟执行的坑:迭代器方法里的参数检查,要等第一次 MoveNext 才会执行。想调用时立刻报错,把检查放在外层普通方法里,再调用内部的迭代器方法。LINQ 的 Where、Select 都是这么写的。
  • 多次遍历就多次执行:IEnumerable 每被 foreach 一次,方法体就从头跑一次。如果里面是读文件或查数据库,遍历两遍就读两遍,需要时先 .ToList()。
  • 读大文件很省内存:File.ReadLines 就是一个迭代器,一行一行给你,而 File.ReadAllLines 一次全读进内存。处理几百 MB 的日志时差别很大。
  • 异步版本:C# 8 的 IAsyncEnumerable<T> 配合 await foreach,可以一边从网络或 PLC 读数据一边处理。

# C# # 迭代器

原文首发于 blog.csdn.net