每个 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 语句的私有迭代器方法。