迭代器与 yield return
本教程共 100 篇 · 第 61 篇 · 更新于 2026-07-31 · 约 11 分钟阅读
61. 迭代器与 yield return
本节目标:学完你能用
yield return写出自己的惰性序列,并让自定义类支持foreach。
平时我们遍历 List<T>、int[] 时,从没想过「遍历」本身是怎么实现的。C# 给我们一种专门的写法:迭代器(iterator)。它让你像写普通方法一样,逐个「吐出」元素。
什么是迭代器
迭代器是一种方法或属性访问器(accessor),用来对集合做自定义遍历。它的关键标志是 yield return 语句:每执行到一个 yield return,就返回一个元素给调用方,并记住当前位置。
Note迭代器的返回类型通常是
IEnumerable<T>(或老式的IEnumerable)。它代表「一串可以逐个取出的东西」。
为什么需要它?假设你有一千万条数据。一次性全装进 List<T> 既占内存又慢。迭代器可以「要一个给一个」,用到才生成,这叫惰性枚举(lazy evaluation)。
yield return 的工作方式
看一个最简单的例子。注意这里把迭代器写成局部函数(local function),这是顶级语句文件里最自然的写法:
foreach (var n in SomeNumbers())
{
Console.WriteLine(n);
}
IEnumerable<int> SomeNumbers()
{
yield return 3;
yield return 5;
yield return 8;
}
输出:
3
5
8
第一次进入 foreach,代码跑到第一个 yield return 3,返回 3 后暂停。foreach 要下一个元素时,从暂停处继续,跑到 yield return 5。如此反复,直到方法结束。
Tip可以把迭代器想象成「带书签的循环」:每次吐出一个值,就夹上书签,下次从这里接着走。
用循环批量产出
yield return 常写在循环里,按条件产出。下面只返回区间内的偶数:
foreach (var n in EvenSequence(5, 18))
{
Console.WriteLine(n);
}
IEnumerable<int> EvenSequence(int first, int last)
{
for (int number = first; number <= last; number++)
{
if (number % 2 == 0)
{
yield return number;
}
}
}
输出:6 8 10 12 14 16 18。即使范围很大,也只有真正被遍历到的偶数才会被计算。
让自定义类支持 foreach
只要类实现了 IEnumerable<T>,就能用 foreach 遍历它。最常见的方式是在 GetEnumerator 里用 yield return 逐个返回。
NumberBox box = new();
box.Add(10);
box.Add(20);
box.Add(30);
foreach (var value in box)
{
Console.WriteLine(value);
}
class NumberBox : IEnumerable<int>
{
private List<int> _items = [];
public void Add(int n) => _items.Add(n);
public IEnumerator<int> GetEnumerator()
{
foreach (var item in _items)
{
yield return item;
}
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
输出:10 20 30。编译器看到 yield return,会自动帮你生成状态机,省去手写 Current、MoveNext 的麻烦。
Note非泛型版
IEnumerable要求同时实现GetEnumerator(),通常直接转调泛型版本即可,如上例最后一行。
用 yield break 提前结束
有时遍历到一半就该停,比如找到目标就退出。用 yield break 可以立刻终止迭代。
foreach (var n in TakeUntil(1, 10, 5))
{
Console.WriteLine(n);
}
IEnumerable<int> TakeUntil(int start, int end, int stop)
{
for (int i = start; i <= end; i++)
{
if (i == stop)
{
yield break;
}
yield return i;
}
}
输出:1 2 3 4。遇到 5 时 yield break 直接收尾,后面的数不再产出。
惰性枚举的好处
惰性(lazy)是本特性的精髓。对比两段代码:
// 立刻算完,全部存进列表
List<int> eager = [];
for (int i = 0; i < 1000000; i++) eager.Add(i * i);
// 用到才算,不占额外内存
IEnumerable<int> lazy()
{
for (int i = 0; i < 1000000; i++)
{
yield return i * i;
}
}
前者在循环结束前就占满内存;后者只有真正遍历时才会计算。LINQ 的延迟执行(deferred execution)正是建立在迭代器之上。
Tip想强制立刻执行?用
ToList()或ToArray()把迭代结果「落地」成集合即可。
新手容易踩的坑
第一,yield return 不能出现在 catch/finally 块里,也不能出现在「带 catch 子句的 try 主体」中——只有当 try 只配 finally、没有 catch 时,try 主体里才允许放 yield return。这是编译器生成状态机的安全限制。
第二,迭代器方法不能带 ref、out、in 参数。因为它要把状态保存到别处,引用参数没法「记住」。
第三,迭代器是惰性的,这意味着副作用会延迟发生。下面这段代码不会立刻打印,而是等到 foreach 才执行:
var seq = Squares();
foreach (var s in seq) Console.WriteLine(s);
IEnumerable<int> Squares()
{
for (int i = 1; i <= 3; i++)
{
Console.WriteLine($"生成 {i}");
yield return i * i;
}
}
Note每次对同一个迭代器变量重新
foreach,方法体会重新执行一遍。迭代器没有「重置」功能,要重来就重新调用方法拿新序列。
迭代器与 LINQ 是天生一对
迭代器返回的是 IEnumerable<T>,而 LINQ 的标准查询操作(如 Where、Select、Take)接收和返回的也是 IEnumerable<T>。两者接口一致,所以可以无缝拼接。
最妙的是:迭代器可以「无限」产生数据,而 LINQ 只取需要的那部分。下面用迭代器生成自然数平方(理论上是无穷序列),再让 LINQ 只拿小于 50 的前几个:
foreach (var n in Squares().Where(x => x < 50).Take(5))
{
Console.WriteLine(n);
}
IEnumerable<int> Squares()
{
for (int i = 1; ; i++)
{
yield return i * i;
}
}
输出:1 4 9 16 25。Squares() 永远不会自己停,但因为惰性,配合 Where 和 Take(5),实际只计算了五个就收工。这种「无限数据源 + 限量消费」的模式,没有迭代器几乎写不出来。
综合例子:惰性斐波那契
斐波那契数列天然适合用迭代器表达——它也是无限的,但你永远只取前面若干项:
foreach (var n in Fibonacci().Take(8))
{
Console.WriteLine(n);
}
IEnumerable<int> Fibonacci()
{
int prev = 0, curr = 1;
while (true)
{
yield return curr;
(prev, curr) = (curr, prev + curr);
}
}
输出:1 1 2 3 5 8 13 21。注意 while (true) 看起来是死循环,但 yield return 每产出一个就暂停,加上 Take(8) 限制,程序安全结束,不会真的卡死。这生动展示了「惰性」如何把无限过程变成可控消费。
Tip凡是「数据可能很多或无限、但只用到一部分」的场景,优先考虑迭代器。日志流、传感器读数、分页拉取,都是它的主场。
多次遍历的隐藏代价
惰性是一把双刃剑。正因为「用到才生成」,每次重新 foreach 都会把迭代器方法从头跑一遍。如果方法里做了读文件、查数据库、网络请求这类重活,代价会翻倍。
Warning同一个迭代器序列被遍历多次时,方法体也会执行多次。若其中含有昂贵操作,性能会悄悄变差。需要多次使用时,先
ToList()把结果落地,只算一次。
下面用计数器演示。注意每次重新 foreach,「迭代器重新执行了」都会再打印一次:
namespace CSharpDemo;
foreach (var n in Counted())
{
Console.WriteLine($"第一次拿到:{n}");
}
foreach (var n in Counted())
{
Console.WriteLine($"第二次拿到:{n}");
}
IEnumerable<int> Counted()
{
Console.WriteLine("—— 迭代器重新执行了 ——");
yield return 1;
yield return 2;
}
输出:
—— 迭代器重新执行了 ——
第一次拿到:1
第一次拿到:2
—— 迭代器重新执行了 ——
第二次拿到:1
第二次拿到:2
迭代器 vs 直接返回集合:怎么选
两者不是替代关系,看场景而定:
| 场景 | 建议 |
|---|---|
| 数据量巨大、或可能无限 | 迭代器(省内存) |
| 需要多次随机访问、反复遍历 | 直接返回 List<T> |
| 只是把已有集合透传出去 | 返回原集合,不必包一层迭代器 |
| 配合 LINQ 做流式处理 | 迭代器 / IEnumerable<T> |
简单说:一次性流式消费用它最香;又要反复用、又要随机取,落地成列表更实在。
状态机也有一点点成本
yield return 背后是编译器生成的状态机(state machine),比普通循环多了些簿记开销。绝大多数业务代码根本感觉不到。只有在被每秒调用百万次的超热路径里,才需要 profiling 后再决定是否优化。
Note不要为了「理论上更快」提前把迭代器换成手写的
List。可读性优先,等真正测出瓶颈再说。
最佳实践速览
- 数据源可能很大或无限,且只用一部分时,优先迭代器。
- 需要多次遍历,先
ToList()/ToArray()物化,避免重复计算。 - 迭代器方法里别放重副作用(写文件、发请求),或至少让调用方知道会重复执行。
- 想提前结束用
yield break,别用跳出循环之外的花招。
小结
迭代器用 yield return 把「逐个产出元素」这件事变得简单。它背后是编译器生成的状态机,让你专注于「每次返回什么」。惰性执行既省内存又支撑了 LINQ,是 C# 里很底层也很好用的机制。