Menu

C# IEnumerable 与 yield return:惰性序列详解

IEnumerable<T> 是 foreach 和 LINQ 背后的接口,而 yield return 是实现它最简单的方式。了解 IEnumerable 与 ICollection、List 的关系,迭代器方法如何一步步执行,yield break,无限序列,以及惰性求值的陷阱。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

每个 foreach 循环和每个 LINQ 查询都通过同一个小小的接口工作:IEnumerable<T>。理解了它,你就能明白为什么 LINQ 是惰性的,为什么有些序列只能读取一次,以及 yield return 如何让你用一个普通的循环写出自己的序列。

IEnumerable 是什么

IEnumerable<T> 只有一个方法 GetEnumerator()。它返回的枚举器有 MoveNext()(前进一步,并报告是否还有元素)和 Current(当前元素)。foreach 就是驱动这个枚举器的简写:

输出:

red green blue 
red green blue 

枚举器从第一个元素之前开始,所以第一次 MoveNext() 会移动到第一个元素上。当 MoveNext() 返回 false 时,序列就结束了。using 块负责释放枚举器,这对持有打开的文件等资源的迭代器很重要。

IEnumerable、ICollection 与 List 的对比

集合接口构成了一个阶梯,每往上一级,承诺的功能就更多:

类型新增典型用途
IEnumerable<T>只能 foreach只需遍历的参数;LINQ 结果;惰性序列
IReadOnlyCollection<T>Count调用方不应修改的返回值
ICollection<T>Count、Add、Remove、Contains向任意类型的集合中添加元素的代码
IReadOnlyList<T>按索引访问 [i]带位置的只读结果
IList<T>按索引访问、Insert、RemoveAt按位置编辑的代码
List<T>实现以上所有接口的具体类存储数据

只遍历输入的方法应该接收 IEnumerable<T>,这样调用方无需转换,就能传入数组、列表、集合或 LINQ 查询。返回数据的方法通常应该返回一个表明“数据已经在内存中”的类型,例如 List<T> 或 IReadOnlyList<T>,因为返回值是 IEnumerable<T> 时,它可能是一个每次读取都要重新执行的查询。

输出:

30
15
4.0
30

用 yield return 编写迭代器

要生成你自己的序列,就写一个返回 IEnumerable<T> 的方法,并对每个元素使用 yield return。编译器会把这个方法变成一个类,替你实现枚举器。

输出:

0, 2, 4, 6, 8, 10
Mon Tue Wed Thu Fri 

EvenNumbers 中根本没有列表。每个 yield return 把一个值交给调用方并让方法暂停,同时保留它的局部变量(这里是 i),直到调用方请求下一个值。

惰性:观察执行顺序

因为方法会暂停,它的主体和调用方的循环会轮流执行。在两边都打印输出,就能看到它们是如何交替进行的:

输出:

Called Numbers(), nothing printed yet
  start
  yielding 1
Loop got 1
  resumed after 1
  yielding 2
Loop got 2
  resumed after 2
  yielding 3
Loop got 3
  resumed after 3
  end

调用 Numbers() 时并没有打印“start”:它只是创建了这个可枚举对象。方法主体从 foreach 中的第一次 MoveNext() 开始执行,一直运行到第一个 yield return 就停下了。之后的每一步都从 yield 的下一行继续。LINQ 的 Where 和 Select 的行为也是如此(运行时出于性能考虑用手写的枚举器类来实现它们,但效果完全相同),这就是 LINQ 查询会延迟执行的原因。

yield break

yield break 提前结束序列,它是迭代器版本的 return:

输出:

Subject: Invoice
From: shop@example.com

同一个方法中不能混用 return value; 和 yield return。一旦方法中包含 yield,它就是一个迭代器,yield break 是提前离开它的唯一方式。

无限序列

由于只有被请求的元素才会被计算,迭代器可以描述一个没有尽头的序列。由使用方决定取多少:

输出:

0, 1, 1, 2, 3, 5, 8, 13, 21, 34
1597
2026-09-28 Mon
2026-10-05 Mon
2026-10-12 Mon

这里的 while (true) 是安全的,因为 Take 和 First 在拿到所需的元素后就不再请求了。反过来,对无限序列调用 ToList() 或 Count() 永远不会返回,遍历基于它的 OrderBy 也一样,因为排序需要先拿到所有元素。

惰性序列的陷阱

参数检查执行得晚。 迭代器的整个主体都会延迟执行,包括写在开头的验证代码。解决办法是把方法拆开:由一个普通方法检查参数,再返回一个私有迭代器的结果。

输出:

LazyRange(-1) returned without an error
...and threw only when enumerated
EagerRange(-1) threw at the call

每次遍历都会重新运行方法。 迭代器不会缓存任何东西。对 Numbers() 循环两次,就会打印两次“start”、读取两次文件,或者调用两次 Web 服务。IDE 会把这种情况标记为“possible multiple enumeration of IEnumerable”(可能多次枚举 IEnumerable)。当你需要多次使用这些元素时,调用一次 ToList()。

使用方停止时 finally 会执行。 如果调用方提前跳出 foreach,枚举器会被释放,迭代器中的 finally 块也会执行,读取文件的迭代器正是靠这一点来关闭文件的:

输出:

open sensor
10
20
30
close sensor
done

编译器对迭代器有几条强制规则:yield return 不能出现在带有 catch 的 try 块中(try/finally 没问题),不能出现在 lambda 或匿名方法中,也不能出现在带有 ref 或 out 参数的方法中。

异步流

对于元素以异步方式到达的序列(来自 API 的分页数据、来自数据库的行),C# 8 及以上版本(配合 .NET Core 3.0 或更高版本)通过 IAsyncEnumerable<T> 把迭代器和 await 结合在一起:

static async IAsyncEnumerable<string> FetchPages()
{
    for (int page = 1; page <= 3; page++)
    {
        await Task.Delay(100);           // simulate a network call
        yield return $"page {page}";
    }
}

await foreach (string p in FetchPages())
{
    Console.WriteLine(p);
}

使用方用 await foreach 遍历,每个元素在产生时被等待。

常见错误

  • 从查询返回 IEnumerable<T>,然后读取很多次。 每次读取都会重新执行查询;请改为返回 List<T>。
  • 在迭代器中验证参数。 检查会被延迟;请拆分成一个包装方法和一个迭代器。
  • 对无限迭代器调用 Count() 或 ToList()。 它永远不会结束;先用 Take。
  • 指望 IEnumerable<T> 参数有 Count 或 [i]。 使用 LINQ 的 Count()(它可能会遍历整个序列),或者改为接收 IReadOnlyList<T>。

常见问题

C# 中的 IEnumerable 是什么?

IEnumerable<T> 是所有可以用 foreach 遍历的东西所实现的接口。它只有一个方法 GetEnumerator(),返回一个带有 MoveNext() 和 Current 的 IEnumerator<T>。数组、列表、字典、集合、LINQ 查询和迭代器方法都实现了它。

C# 中 IEnumerable 和 List 有什么区别?

IEnumerable<T> 只承诺你可以遍历它;它没有 Count 属性、没有索引器、也没有 Add,而且每次循环时都可能被惰性地重新计算。List<T> 则是存储在内存中、具备所有这些功能的具体集合。如果只是循环遍历,参数就接收 IEnumerable<T>;当调用方需要索引或计数时,返回 List<T> 或 IReadOnlyList<T>。

C# 中 yield return 有什么作用?

在返回 IEnumerable<T> 的方法中,yield return value; 把一个元素交给调用方,并让方法在这里暂停。下一次调用方请求元素时,方法会从下一行继续执行。编译器会把这个方法改写成一个状态机,所以你只需写一个循环,就能得到一个惰性序列。

C# 中的 yield break 是什么?

yield break; 结束序列:调用方的 foreach 在已经产生的元素之后正常结束。它相当于迭代器中的 return,用于提前停止,例如达到上限或输入为空时。

为什么调用迭代器方法时它没有抛出异常?

迭代器方法的主体在序列被遍历之前不会执行,所以写在开头的参数检查会在第一次 MoveNext 时运行,而不是在调用时。要立即验证参数,就把检查放在一个普通的公共方法中,再由它调用一个包含 yield 语句的私有迭代器方法。

Coddy 编程语言插图

和 Coddy 一起学编程

开始