foreach 循环在 C# 中能够遍历数组、列表、字典以及各种自定义类型,背后依赖的并不是什么神秘语法,而是两个接口:IEnumerable 和 IEnumerator。当编译器遇到 foreach 时,它会尝试在目标对象上调用 GetEnumerator 方法,拿到一个迭代器,然后反复调用 MoveNext 读取 Current。换句话说,只要一个类型能够提供符合约定的迭代器,foreach 就能正常工作。迭代器模式要解决的核心问题,就是把遍历逻辑从集合本身中剥离出来,让同一个集合可以支持多种遍历策略,而调用方依然只需要写一段 foreach 代码。

一、IEnumerable 与 IEnumerator:遍历的底层契约
在 C# 中,一个类型能否被 foreach 遍历,取决于它是否实现了 IEnumerable 或 IEnumerable<T>。泛型版本和非泛型版本的核心思路一致,泛型版本多了强类型支持,避免装箱拆箱。实现 IEnumerable<T> 时,编译器要求提供两个 GetEnumerator 方法:一个公开的泛型版本,一个显式接口实现的非泛型版本。非泛型版本通常直接调用泛型版本,保持行为一致。
IEnumerator<T> 则是迭代器本身,它负责记录当前遍历的位置。它有三个核心成员:Current 属性返回当前位置的元素,MoveNext 方法把游标向前移动并返回是否还有元素,Reset 方法把游标重置到初始状态。实际开发中 Reset 很少被调用,很多集合甚至会直接抛出 NotSupportedException。手动实现一套完整的迭代器需要额外管理游标状态,对于包含多个字段、嵌套结构的集合来说,代码量会迅速膨胀。
下面是一个手动实现迭代器的简单例子。虽然功能正常,但可以看到状态维护全部压在开发者身上,稍微复杂一点的遍历逻辑就容易出错。
public class SimpleCollection : IEnumerable<int>
{
private int[] _items;
public SimpleCollection(int[] items)
{
_items = items;
}
public IEnumerator<int> GetEnumerator()
{
return new SimpleEnumerator(_items);
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
private class SimpleEnumerator : IEnumerator<int>
{
private int[] _items;
private int _position = -1;
public SimpleEnumerator(int[] items)
{
_items = items;
}
public int Current
{
get
{
if (_position < 0 || _position >= _items.Length)
throw new InvalidOperationException();
return _items[_position];
}
}
object IEnumerator.Current => Current;
public bool MoveNext()
{
_position++;
return _position < _items.Length;
}
public void Reset()
{
_position = -1;
}
public void Dispose() { }
}
}这段代码中,SimpleEnumerator 是一个嵌套类,专门负责保存 _position 游标,并在 MoveNext 中判断是否越界。它的逻辑已经很直白,但如果要支持反向遍历、跳过某些元素、或者根据条件过滤,就必须在 MoveNext 和 Current 里加入更多判断,同时还要考虑多线程下状态隔离。这种手写方式在简单场景可以接受,在复杂场景下就变成负担。
二、yield return 如何把状态机交给编译器
从 C# 2.0 开始引入的 yield return 关键字,让迭代器的实现发生了根本变化。你只需要在一个返回 IEnumerable<T> 或 IEnumerator<T> 的方法里逐个 yield return 元素,编译器就会自动生成一个完整的状态机类。这个类实现了 IEnumerator<T> 接口,并用一个整数状态字段记录当前执行到哪个 yield return 位置。每次 MoveNext 被调用时,状态机从上次暂停的位置继续执行,局部变量的值也会保留在生成类的字段中。
这一点是理解迭代器模式的关键。表面上你写的是一个普通方法,实际上它会被改写成类似前面手动实现的那套结构,只是状态管理由编译器完成。正因如此,yield return 不能出现在带有 ref 或 out 参数的方法中,也不能出现在匿名方法或不安全代码块里,因为生成的状态机无法安全地保存这些上下文。
用 yield return 改写前面的集合,代码会非常简洁:
public class SimpleCollection : IEnumerable<int>
{
private int[] _items;
public SimpleCollection(int[] items)
{
_items = items;
}
public IEnumerator<int> GetEnumerator()
{
for (int i = 0; i < _items.Length; i++)
{
yield return _items[i];
}
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}这里看不到任何游标字段、边界检查和 Dispose 实现,因为编译器已经替我们生成了这些代码。更重要的是,当方法包含多个 yield return 时,状态机可以保证每次 MoveNext 只执行到一个 yield 就暂停,后续调用继续往后执行。这种能力让编写复杂的遍历逻辑变得非常自然,比如先输出头部元素,再输出主体,最后输出尾部。
还需要注意 yield break 的作用。它可以在遍历过程中提前终止迭代,相当于生成的状态机直接进入结束状态。例如在找到满足条件的元素后停止后续判断,就可以使用 yield break,避免不必要的循环。
三、自定义集合遍历行为的典型场景
自定义集合的遍历行为,最常见的需求是让集合提供多个不同维度的遍历方法,而调用方仍然可以继续使用 foreach。例如一个订单集合,默认遍历返回全部订单,但业务上可能还需要只遍历有效订单、按页返回订单、或者按金额区间筛选。如果把所有过滤逻辑都塞进 GetEnumerator,显然不够灵活,而且无法同时满足多种需求。
合理的做法是保留默认遍历为返回全部元素,另外提供返回 IEnumerable<T> 的公开方法,内部使用 yield return 构建独立的遍历序列。下面的 OrderCollection 演示了这种模式:
public class Order
{
public int Id { get; set; }
public string Customer { get; set; }
public bool IsActive { get; set; }
public decimal Amount { get; set; }
}
public class OrderCollection : IEnumerable<Order>
{
private List<Order> _orders = new List<Order>();
public void Add(Order order)
{
_orders.Add(order);
}
public IEnumerator<Order> GetEnumerator()
{
foreach (var order in _orders)
{
yield return order;
}
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
public IEnumerable<Order> GetActiveOrders()
{
foreach (var order in _orders)
{
if (order.IsActive)
{
yield return order;
}
}
}
public IEnumerable<Order> GetPagedOrders(int pageIndex, int pageSize)
{
int start = (pageIndex - 1) * pageSize;
int end = Math.Min(start + pageSize, _orders.Count);
for (int i = start; i < end; i++)
{
yield return _orders[i];
}
}
}GetActiveOrders 在遍历时逐项判断 IsActive,只返回满足条件的订单。GetPagedOrders 则根据页码和每页数量计算起始索引与结束索引,再按区间返回。这两个方法的返回值都是 IEnumerable<Order>,调用方可以直接写 foreach (var order in orders.GetActiveOrders()),并不需要关心内部如何筛选或分页。
这种设计还有一个好处:它与 LINQ 的延迟执行理念一致。遍历方法只是构建了一个查询序列,并不会立刻执行循环体。只有真正开始 foreach 或调用 ToList、Count 等操作时,才会触发迭代。因此,如果集合在遍历前被修改,最终结果会反映修改后的数据;如果在遍历过程中修改集合,则可能触发版本检测异常。
如果需要支持不同排序方式,也可以添加类似 GetOrdersSortedByAmount 的方法,但注意不要在 yield return 之前对底层列表直接排序,除非你能接受修改原始集合。更稳妥的做法是复制一份列表再排序,或者使用 LINQ 的 OrderBy 方法生成新的序列。
四、延迟执行与资源释放的注意事项
理解延迟执行对使用迭代器至关重要。一个包含 yield return 的方法在调用时并不会立即执行方法体,而是返回一个可迭代对象。只有当你开始枚举这个对象时,方法体才会从第一个 yield return 开始运行。下面的例子可以清楚地看到执行时机:
public IEnumerable<int> GenerateNumbers()
{
Console.WriteLine("开始生成第一个数字");
yield return 1;
Console.WriteLine("生成第二个数字");
yield return 2;
Console.WriteLine("生成第三个数字");
yield return 3;
}如果只是执行 var numbers = GenerateNumbers();,控制台不会有任何输出。只有在 foreach (var n in numbers) 时,才会依次打印三段文字并返回数字。如果只遍历前两个元素就 break,那么第三个 Console.WriteLine 不会执行,因为状态机没有继续调用 MoveNext。
这种延迟特性在读取文件、访问数据库或调用远程接口时尤其需要留意。很多人习惯把读取逻辑写进返回 IEnumerable<T> 的方法里,认为资源已经在方法返回时处理完毕,实际上资源要等到枚举结束才会释放。正因如此,在迭代器方法内使用 try-finally 或 using 是安全释放资源的关键。下面是一个读取文件行的例子:
public IEnumerable<string> ReadLines(string path)
{
using (var reader = new StreamReader(path))
{
string line;
while ((line = reader.ReadLine()) != null)
{
yield return line;
}
}
}如果调用方只枚举了前几行就中断,using 块还是会执行 Dispose,因为编译器生成的状态机实现了 IDisposable,foreach 在结束或异常时会调用它。这也是为什么迭代器方法内部推荐使用 using 而不是手动打开资源后忘记释放。但要注意,如果迭代器对象没有被正确枚举,比如只拿到 IEnumerable<T> 而没有遍历,那么 Dispose 不会触发,资源也就不会释放。
另外,在 foreach 遍历期间修改底层集合会引发 InvalidOperationException。这是因为集合内部通常维护一个版本号,每次添加或删除元素都会递增。迭代器在 MoveNext 时会校验版本号是否与创建时一致,不一致就报错。这个机制能避免遍历到脏数据,但也限制了某些场景下的操作。如果确实需要在遍历时修改集合,可以先 ToList 复制一份再遍历副本,或者使用 for 循环倒序删除。
回到迭代器模式本身,C# 通过 IEnumerable、IEnumerator 和 yield return 提供了一套非常成熟的实现路径。自定义集合的遍历行为,本质上就是为不同的业务需求编写独立的迭代方法,让每个方法返回一个经过筛选、排序或分页处理的序列。掌握这套机制后,你可以在不改变集合存储结构的前提下,灵活扩展遍历方式,同时借助延迟执行减少不必要的计算和资源占用。
C#迭代器模式自定义集合遍历yield return修改时间:2026-09-18 03:46:58