C# 迭代器(二):yield
2021 年 9 月 16 日 · 2 分钟读完
上一篇手写了 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 类都不用写了。
执行顺序
看输出就能理出顺序:
foreach调用GetEnumerator(),但方法体一行都还没执行,只是拿到了一个迭代器对象;- 第一次
MoveNext():方法体从头执行,打印「首次进入」「yield 上方」,遇到yield return 0,把 0 交给Current,MoveNext返回 true,方法暂停在这里; foreach循环体打印 0;- 第二次
MoveNext():从暂停处继续,打印「yield 下方」,item后移,回到while,打印「yield 上方」,遇到yield return 2,再次暂停; - ……直到
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 读数据一边处理。
原文首发于 blog.csdn.net