Lesson 03: yield return 同步迭代器

编译器替你写状态机——你只管"产出一条、歇一下",框架负责所有簿记工作

前置 / Prerequisite:熟悉 C# 基础(方法、循环、集合)。本课讲解 C# 2.0 引入的 yield return 迭代器——这是理解后续 Lesson 04 异步流 的必要基础。
本课目标:理解 yield return 的设计初衷、编译器实现原理、以及何时使用它。

一、痛点:没有 yield return 的世界长什么样

假设你要写一个方法,返回文件中的所有行。听上去很简单——但在 C# 1.0(没有 yield return)里,你必须手动实现 IEnumerator<T>

C# 1.0:手动写状态机(约 50 行样板代码)

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 包装类……

C# 2.0+:yield return(4 行)

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:延迟执行

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) 是一个道理。

如果你不用 yield return 会怎样?

最常见的替代方案是一次性构建整个 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;  // 全部读完了才返回
}
问题:如果文件有 100 万行,你会在方法返回之前把所有行全部加载到 List<T> 里——内存爆炸。而且调用方必须等全部读取完毕才能处理第一条数据。

yield return 版本只存当前行在内存里,调用方拿一条、处理一条、GC 回收一条。

常见误解:"yield return 更快"

yield return 的性能优势不是"计算速度更快"——事实上,状态机调度本身有微小开销(经过这么多年优化基本可以忽略)。它的真正优势是:

维度yield returnList<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() 等……
}
状态转移图: foreach 开始 │ ▼ state=0 ──MoveNext()──▶ Console.WriteLine("start") yield return 10 state=1, return true │ ▼ (调用方拿到 10,处理完后再次调用 MoveNext) state=1 ──MoveNext()──▶ Console.WriteLine("middle") yield return 20 state=2, return true │ ▼ (调用方拿到 20,处理完后再次调用 MoveNext) state=2 ──MoveNext()──▶ Console.WriteLine("end") state=-1, return false → foreach 退出
想亲眼看看?打开 SharpLab.io,贴入上面的 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
}

编译器会把 xi、循环状态等所有在 yield return 之间"存活"的局部变量都提升为状态机类的字段——这样每次 MoveNext() 调用后,变量的值都能在字段中"记住",下次调用时从字段恢复。

和 async/await 状态机的相似性:如果你已经理解了 async Task 方法被编译成状态机,你会发现 yield return 状态机的结构几乎一样——都是把方法体拆成若干个"断点",在每个 await(或 yield return)之后切开,用 switch 来跳转到正确的恢复点。区别在于:

四、yield break —— 提前终止迭代

yield 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 returntry-finally 中时,finally 块的语义有关——编译器需要保证 finally 在迭代器 Dispose 时执行。

五、实战模式

模式一:延迟过滤 —— LINQ 就是这么做出来的

所有 LINQ 方法(WhereSelectTake…)都是用 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;
    }
}
递归 + yield return 的性能陷阱:上面的 Flatten 方法在深层嵌套时会产生大量嵌套的迭代器对象——每一层递归创建一个迭代器,遍历时层层穿透。对于深度超过几百层的树,考虑用 Stack<T> 手动展开(非递归遍历)。

六、什么时候用 yield return(什么时候不用)

场景用?理由
大数据量、逐条处理 ✅ 用 省内存,首条延迟低
链式数据转换(管道) ✅ 用 LINQ 风格——多个操作串联,但只遍历一次
无限序列 / 数学序列 ✅ 用 只能按需生成,不可能全部实例化
数据源支持提前终止 ✅ 用 调用方 Take(5) 时就只生成 5 条,不多干活
小数据量(几十条) ⚠️ 随意 用 List 也没差——但 yield return 同样清晰
需要对全量数据排序 / 分组 ❌ 不用 排序必须先拿到所有数据——yield return 省不了内存
需要随机访问(按索引取) ❌ 不用 IEnumerable 只能向前遍历,适合用 List/Array
方法体内没有循环/迭代逻辑 ❌ 不用 只有一个 yield return 不如直接返回
经验法则:如果你发现自己写了一个 List<T>,用 Add 一点点往里塞,然后返回——考虑改用 yield return。如果数据量小或需要随机访问/排序,返回 List<T>Array 没问题。

七、为什么说 yield return 是理解异步流的前提

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;
}
关键洞见:同步 yield return 迭代器里的每次 MoveNext() 都是同步的——如果你在迭代器内部做 I/O,调用线程就阻塞在那里。异步流(下一课)解决了这个问题:每次 MoveNextAsync() 都是可等待的,I/O 操作不阻塞线程。但两者状态机的结构、延迟执行的语义、链式组合的模式——完全一样。所以理解 yield return 是理解异步流的基础。

📝 小测验

Q1. 以下代码中,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);
选择正确答案:

Q2. 关于 yield return 的内存使用,以下哪句正确?

选择正确答案:

Q3. 编译器为 yield return 方法生成了什么?

选择正确答案:

Q4. 下面的代码有什么潜在问题

public static IEnumerable<int> GetData()
{
    var data = new List<int> { 1, 2, 3 };
    return data;  // 直接返回了 List
}
选择正确答案:

Q5. yield return 和 async/await 有什么共同点

选择正确答案:

Q6. yield break 的作用是什么?

选择正确答案:

Lesson 03 · yield return 同步迭代器 · 下一课预告:Lesson 04 — C# 8 Async Streams

💬 有任何不理解的地方?随时问 agent 老师——追问是学习的正确姿势。