编译器替你写状态机——你只管"产出一条、歇一下",框架负责所有簿记工作
yield return 迭代器——这是理解后续 Lesson 04 异步流 的必要基础。yield return 的设计初衷、编译器实现原理、以及何时使用它。
假设你要写一个方法,返回文件中的所有行。听上去很简单——但在 C# 1.0(没有 yield return)里,你必须手动实现 IEnumerator<T>:
public class FileLineEnumerator : IEnumerator<string>
{
private StreamReader _reader;
private string _current;
private int _state = 0; // 手动状态机!
public FileLineEnumerator(string path)
{
_reader = new StreamReader(path);
}
public string Current => _current;
object IEnumerator.Current => _current;
public bool MoveNext()
{
switch (_state)
{
case 0:
_current = _reader.ReadLine();
if (_current == null)
{
_state = -1; return false;
}
_state = 1; return true;
case 1:
_current = _reader.ReadLine();
if (_current == null)
{
_state = -1; return false;
}
return true;
default:
return false;
}
}
public void Reset() => throw new NotSupportedException();
public void Dispose() => _reader?.Dispose();
}
// 还得写一个 IEnumerable 包装类……
public IEnumerable<string> ReadLines(string path)
{
using var reader = new StreamReader(path);
string line;
while ((line = reader.ReadLine()) != null)
{
yield return line;
}
}
yield return 让编译器替你生成状态机。你只需要用最直观的方式写出"拿一条、吐一条"的逻辑——状态追踪、IEnumerator 实现、IDisposable 管理等所有样板代码由编译器自动完成。
yield return 的核心语义是延迟执行(Deferred Execution)延迟执行——方法的代码体不会在调用时执行,而是等到调用方第一次 foreach(或调用 MoveNext())时才逐条执行。
public static IEnumerable<int> GetNumbers()
{
Console.WriteLine("→ 生成器开始执行");
yield return 1;
Console.WriteLine("→ yield return 1 之后,控制权交还给调用方");
yield return 2;
Console.WriteLine("→ yield return 2 之后");
yield return 3;
Console.WriteLine("→ 最后一次,方法即将结束");
}
// ====== 调用 ======
Console.WriteLine("A. 调用 GetNumbers()——但还没有迭代");
var numbers = GetNumbers(); // 什么都不打印!代码还没有执行
Console.WriteLine("B. 开始 foreach");
foreach (var n in numbers)
{
Console.WriteLine($" 拿到: {n}");
}
Console.WriteLine("C. foreach 结束");
// ====== 输出 ======
// A. 调用 GetNumbers()——但还没有迭代
// B. 开始 foreach
// → 生成器开始执行
// 拿到: 1
// → yield return 1 之后,控制权交还给调用方
// 拿到: 2
// → yield return 2 之后
// 拿到: 3
// → 最后一次,方法即将结束
// C. foreach 结束
yield return 都做两件事——① 把值交给调用方;② 暂停方法执行,把所有局部变量的状态保存下来。下一次 MoveNext() 时从暂停点恢复。这就像一个可以多次"暂停-恢复"的方法——和协程 (coroutine) 是一个道理。
最常见的替代方案是一次性构建整个 List<T> 并返回:
// 不用 yield return:一次性加载全部到 List
public List<string> ReadLinesEager(string path)
{
var result = new List<string>();
using var reader = new StreamReader(path);
string line;
while ((line = reader.ReadLine()) != null)
result.Add(line);
return result; // 全部读完了才返回
}
List<T> 里——内存爆炸。而且调用方必须等全部读取完毕才能处理第一条数据。yield return 版本只存当前行在内存里,调用方拿一条、处理一条、GC 回收一条。
yield return 的性能优势不是"计算速度更快"——事实上,状态机调度本身有微小开销(经过这么多年优化基本可以忽略)。它的真正优势是:
| 维度 | yield return | List<T> 全量返回 |
|---|---|---|
| 内存 | O(1)——一次只持有一条数据 | O(n)——持有全部数据 |
| 首条延迟 | 极低——第一条产出即可处理 | 等全部处理完毕才能开始 |
| 可否提前终止 | 可以——foreach 里 break 就行,剩余数据不产生 | 不能——全部数据已经生成 |
| 适合无限序列 | ✅ 适合(如生成斐波那契数列) | ❌ 不可能——List 必须有限 |
这是整个 yield return 最核心的知识——理解了状态机,你就真正理解了迭代器。
public static IEnumerable<int> SimpleDemo()
{
Console.WriteLine("start");
yield return 10;
Console.WriteLine("middle");
yield return 20;
Console.WriteLine("end");
}
编译器把上面的方法翻译成一个实现了 IEnumerable<int> 和 IEnumerator<int> 的嵌套类,核心是一个 switch-based 状态机:
// ===== 编译器为你生成的类(概念上的等价代码)=====
private sealed class <SimpleDemo>d__0 : IEnumerable<int>,
IEnumerator<int>
{
private int __state; // -2: 初始; 0/1/2: yield位置; -1: 结束
private int __current; // Current 属性返回的值
private int __initialThreadId;
public int Current => __current;
public bool MoveNext()
{
switch (__state)
{
case 0: // 初始状态 / 刚从 start 恢复
Console.WriteLine("start");
__current = 10; // ← 这就是 yield return 10
__state = 1; // 下次 MoveNext 从 case 1 开始
return true; // true = 还有数据
case 1: // 从 yield return 10 之后恢复
Console.WriteLine("middle");
__current = 20; // ← yield return 20
__state = 2; // 下次从 case 2 开始
return true;
case 2: // 从 yield return 20 之后恢复
Console.WriteLine("end");
__state = -1; // 结束——下次 MoveNext 走 default
return false; // false = 迭代结束
default:
return false; // 状态 -1 或异常后,直接返回 false
}
}
// 还有 Reset(), Dispose(), GetEnumerator() 等……
}
SimpleDemo 方法,右下角选择 "C# → IL" 或 "C# → C# (decompiled)",你就能看到编译器生成的真实状态机类。这和上面概念模型的结构几乎一致。
public static IEnumerable<int> WithLocals()
{
int x = 0;
for (int i = 0; i < 3; i++)
{
x += i;
yield return x;
}
// 循环结束,状态机标记为 -1
}
编译器会把 x、i、循环状态等所有在 yield return 之间"存活"的局部变量都提升为状态机类的字段——这样每次 MoveNext() 调用后,变量的值都能在字段中"记住",下次调用时从字段恢复。
async Task 方法被编译成状态机,你会发现 yield return 状态机的结构几乎一样——都是把方法体拆成若干个"断点",在每个 await(或 yield return)之后切开,用 switch 来跳转到正确的恢复点。区别在于:
async Task 状态机——在 await 处暂停,等待 IO 完成yield return 状态机——在 yield return 处暂停,等待调用方取走数据后调用下一次 MoveNextyield break 相当于普通方法里的 return——它告诉状态机"这个迭代器结束了,不要再调用 MoveNext()"。
public static IEnumerable<string> TakeUntilEmpty(IEnumerable<string> source)
{
foreach (var item in source)
{
if (string.IsNullOrEmpty(item))
yield break; // 遇到空字符串就停止——后续数据不再产生
yield return item;
}
// 方法自然结束 = 隐式的 yield break
}
// 调用:
var data = new[] { "a", "b", "", "c", "d" };
foreach (var s in TakeUntilEmpty(data))
Console.WriteLine(s); // 输出: a, b —— 不会输出 c 和 d
try-finally 块里不能用 yield break(C# 的语法限制),但 yield return 可以。这跟 yield return 在 try-finally 中时,finally 块的语义有关——编译器需要保证 finally 在迭代器 Dispose 时执行。
所有 LINQ 方法(Where、Select、Take…)都是用 yield return 实现的延迟迭代器。你可以自己写一个:
// 自己实现的 Where——和 LINQ 的 Enumerable.Where 原理一模一样
public static IEnumerable<T> MyWhere<T>(
this IEnumerable<T> source, Func<T, bool> predicate)
{
foreach (var item in source)
{
if (predicate(item))
yield return item; // 只产出符合条件的
}
}
// 使用:链式调用——每个环节都是延迟的
var result = Enumerable.Range(1, 100)
.MyWhere(x => x % 2 == 0) // 偶数
.MyWhere(x => x > 10) // 大于 10
.Take(3); // 只取 3 个
// 到 foreach 这一刻,上面所有东西才真正开始执行
// 而且只迭代到凑够 3 个为止——不需要处理全部 100 个
yield return 可以表示理论上无限长的序列——因为数据是"按需生成"的:
// 斐波那契数列——本身是无限的,调用方用 Take 来控制取多少
public static IEnumerable<long> Fibonacci()
{
long a = 0, b = 1;
while (true) // 看起来是死循环,但实际上每次都暂停
{
yield return a;
(a, b) = (b, a + b);
}
}
// 取前 20 个斐波那契数——方法在 Take 满足后自动停止
foreach (var f in Fibonacci().Take(20))
Console.WriteLine(f);
// 遍历一棵树的所有节点——递归也可以用 yield return
public static IEnumerable<TreeNode> Flatten(TreeNode root)
{
if (root == null) yield break;
yield return root; // 先返回当前节点
foreach (var child in root.Children)
{
foreach (var node in Flatten(child)) // 递归展开子节点
yield return node;
}
}
Flatten 方法在深层嵌套时会产生大量嵌套的迭代器对象——每一层递归创建一个迭代器,遍历时层层穿透。对于深度超过几百层的树,考虑用 Stack<T> 手动展开(非递归遍历)。
| 场景 | 用? | 理由 |
|---|---|---|
| 大数据量、逐条处理 | ✅ 用 | 省内存,首条延迟低 |
| 链式数据转换(管道) | ✅ 用 | LINQ 风格——多个操作串联,但只遍历一次 |
| 无限序列 / 数学序列 | ✅ 用 | 只能按需生成,不可能全部实例化 |
| 数据源支持提前终止 | ✅ 用 | 调用方 Take(5) 时就只生成 5 条,不多干活 |
| 小数据量(几十条) | ⚠️ 随意 | 用 List 也没差——但 yield return 同样清晰 |
| 需要对全量数据排序 / 分组 | ❌ 不用 | 排序必须先拿到所有数据——yield return 省不了内存 |
| 需要随机访问(按索引取) | ❌ 不用 | IEnumerable 只能向前遍历,适合用 List/Array |
| 方法体内没有循环/迭代逻辑 | ❌ 不用 | 只有一个 yield return 不如直接返回 |
List<T>,用 Add 一点点往里塞,然后返回——考虑改用 yield return。如果数据量小或需要随机访问/排序,返回 List<T> 或 Array 没问题。
C# 8 的 IAsyncEnumerable<T> 和 await foreach(下一课)就是 yield return 的异步版本。两者的结构几乎一样:
| 同步迭代器 | 异步迭代器 (C# 8) | |
|---|---|---|
| 返回类型 | IEnumerable<T> | IAsyncEnumerable<T> |
| 消费语法 | foreach (var x in source) | await foreach (var x in source) |
| 在迭代中做 IO | ❌ 阻塞线程 | ✅ await 不阻塞 |
| 状态机 | yield return 状态机 | yield return + async 双重状态机 |
| 优点 | 省内存、延迟执行 | 省内存 + 不阻塞线程 + 延迟执行 |
// 同步迭代器——如果 ReadLineAsync 是异步的,这里没法 await
public IEnumerable<string> ReadLines(string path)
{
using var reader = new StreamReader(path);
string line;
while ((line = reader.ReadLine()) != null) // 同步阻塞!
yield return line;
}
// 异步版本(Lesson 04)——一切自然得多
public async IAsyncEnumerable<string> ReadLinesAsync(string path)
{
using var reader = new StreamReader(path);
string line;
while ((line = await reader.ReadLineAsync()) != null)
yield return line;
}
MoveNext() 都是同步的——如果你在迭代器内部做 I/O,调用线程就阻塞在那里。异步流(下一课)解决了这个问题:每次 MoveNextAsync() 都是可等待的,I/O 操作不阻塞线程。但两者状态机的结构、延迟执行的语义、链式组合的模式——完全一样。所以理解 yield return 是理解异步流的基础。
GetNumbers() 的"start"会什么时候打印?public static IEnumerable<int> GetNumbers()
{
Console.WriteLine("start");
yield return 1;
}
var nums = GetNumbers();
Console.WriteLine("after call");
foreach (var n in nums)
Console.WriteLine(n);
public static IEnumerable<int> GetData()
{
var data = new List<int> { 1, 2, 3 };
return data; // 直接返回了 List
}
yield break 的作用是什么?Lesson 03 · yield return 同步迭代器 · 下一课预告:Lesson 04 — C# 8 Async Streams
💬 有任何不理解的地方?随时问 agent 老师——追问是学习的正确姿势。