首页 / C# 入门教程 / 迭代器与 yield return

C# 入门教程

迭代器与 yield return

本教程共 100 篇 · 第 61 篇 · 更新于 2026-07-31 · 约 11 分钟阅读

C#C# 入门教程编程语言迭代器yieldLINQ

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,会自动帮你生成状态机,省去手写 CurrentMoveNext 的麻烦。

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。这是编译器生成状态机的安全限制。

第二,迭代器方法不能带 refoutin 参数。因为它要把状态保存到别处,引用参数没法「记住」。

第三,迭代器是惰性的,这意味着副作用会延迟发生。下面这段代码不会立刻打印,而是等到 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 的标准查询操作(如 WhereSelectTake)接收和返回的也是 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 25Squares() 永远不会自己停,但因为惰性,配合 WhereTake(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# 里很底层也很好用的机制。