yield
关键字允许你创建一个
IEnumerable<T>
,形式为
迭代块(iterator block)。这个迭代块支持
延迟执行,如果您不熟悉这个概念,它可能看起来几乎像是魔法。但是,最终它只是执行代码而已,没有任何奇怪的技巧。
迭代块可以被描述为语法糖,编译器生成一个状态机来跟踪可枚举对象枚举的进度。要枚举一个可枚举对象,通常会使用
foreach
循环。然而,
foreach
循环也是语法糖。因此,您与真实代码相距两层抽象,这就是为什么最初可能很难理解它们如何共同工作的原因。
假设您有一个非常简单的迭代块:
IEnumerable<int> IteratorBlock()
{
Console.WriteLine("Begin");
yield return 1;
Console.WriteLine("After 1");
yield return 2;
Console.WriteLine("After 2");
yield return 42;
Console.WriteLine("End");
}
真实的迭代器块通常包含条件和循环,但当您检查条件并展开循环时,它们仍然最终成为与其他代码交错的yield
语句。
要枚举迭代器块,使用foreach
循环:
foreach (var i in IteratorBlock())
Console.WriteLine(i);
下面是输出结果(没有意外):
开始
1
1后
2
2后
42
结束
如上所述,foreach
是一种语法糖:
IEnumerator<int> enumerator = null;
try
{
enumerator = IteratorBlock().GetEnumerator();
while (enumerator.MoveNext())
{
var i = enumerator.Current;
Console.WriteLine(i);
}
}
finally
{
enumerator?.Dispose();
}
为了梳理这个问题,我创建了一个去除了抽象部分的序列图:
![C# iterator block sequence diagram](https://istack.dev59.com/81KNH.webp)
编译器生成的状态机也实现了枚举器,但为了使序列图更加清晰,我将它们显示为单独的实例。(当状态机从另一个线程枚举时,您确实会获得单独的实例,但该细节在此处并不重要。)
每次调用迭代器块时,都会创建状态机的新实例。但是,在第一次执行 enumerator.MoveNext()
之前,迭代器块中的任何代码都不会被执行。这就是延迟执行的工作原理。以下是一个(相当愚蠢的)示例:
var evenNumbers = IteratorBlock().Where(i => i%2 == 0);
此时迭代器尚未执行。 Where
子句创建一个新的 IEnumerable<T>
,该枚举包装了由 IteratorBlock
返回的 IEnumerable<T>
,但是此可枚举对象尚未被枚举。当您执行 foreach
循环时,就会发生这种情况:
foreach (var evenNumber in evenNumbers)
Console.WriteLine(eventNumber);
如果你多次枚举可枚举对象,那么每次都会创建一个状态机的新实例,并且你的迭代器块将执行相同的代码两次。
请注意,像ToList()
、ToArray()
、First()
、Count()
等LINQ方法将使用foreach
循环来枚举可枚举对象。例如,ToList()
将枚举可枚举对象的所有元素并将它们存储在一个列表中。现在,您可以访问该列表以获取可枚举对象的所有元素,而不需要再次执行迭代器块。当使用ToList()
等方法时,使用CPU多次生成可枚举对象的元素和使用内存存储枚举的元素以便多次访问之间存在权衡。