C# foreach 为什么不能增删、迭代器底层原理、版本号机制、以及所有能遍历中增删的方案

一、foreach 本质编译成了什么

  1. 源码写法
cs 复制代码
List<int> list = new List<int> { 1,2,3,4 };
foreach (var item in list)
{
    Console.WriteLine(item);
}
  1. 编译器语法糖解糖后真实代码
cs 复制代码
List<int> list = new List<int> { 1,2,3,4 };
// 获取迭代器
List<int>.Enumerator enumerator = list.GetEnumerator();
try
{
    // 循环向后移动指针
    while (enumerator.MoveNext())
    {
        int item = enumerator.Current;
        Console.WriteLine(item);
    }
}
finally
{
    // 释放迭代器资源
    enumerator.Dispose();
}

核心三个角色:

  1. IEnumerable:提供 GetEnumerator()
  2. IEnumerator:提供 MoveNext()CurrentReset()
  3. Enumerator 是 List 内部结构体迭代器

二、List 内部版本号机制

1. List<T> 里有个隐藏字段
cs 复制代码
// List<T> 源码内部
private int _version;
  • 只要对集合做结构性修改_version++
  • 哪些操作会让版本号自增:
    • AddAddRange
    • RemoveRemoveAtRemoveRange
    • InsertClearReverseSort(改变结构 / 顺序)
  • 只读操作:索引访问遍历读 不改变版本号
2. 迭代器初始化时会快照版本号

List 的迭代器结构体内部也有一个字段:

cs 复制代码
internal struct Enumerator : IEnumerator<T>
{
    private List<T> _list;
    private int _index;
    // 记录遍历开始那一刻的集合版本
    private int _version;
    private T _current;
}

遍历一开始 :迭代器把当前 list._version 存到自己 _version 里做快照

3. MoveNext () 第一步就校验版本

每次调用 MoveNext(),第一件事:

cs 复制代码
if (_list._version != _version)
{
    throw new InvalidOperationException("集合已修改;可能无法执行枚举操作。");
}

只要遍历中途你增删了元素,list._version 变了,和迭代器保存的快照版本对不上,直接抛异常

三、为什么 C# 要设计成「遍历中禁止增删」?

不是故意限制,是为了规避三类致命问题:

  1. 漏遍历:正向遍历删除元素,后面元素前移,下一次索引跳过一个元素
  2. 重复遍历:中间插入元素,指针回头重复遍历
  3. 索引越界、内存错乱:动态扩容、缩容导致内部数组地址变化,迭代器指针失效

foreach 依赖迭代器顺序往后走,没有索引概念 ,一旦底层数组变了,迭代器无法自洽,所以微软直接用版本号校验一刀切保护。

cs 复制代码
不是 foreach 不让改,是 List、ArrayList、Dictionary 这类普通集合的迭代器自带版本校验。

四、哪些修改会报错、哪些不会

1. 会报错(改变集合结构)

foreach 里执行:

  • Add / Remove / RemoveAt / Insert / Clear→ 版本号变化 → 抛异常
2. 不会报错(只改元素内容,不改结构)
cs 复制代码
foreach (var item in list)
{
    // 修改元素本身的值,不增删、不改变集合个数
    item = 999;
}

只改元素内容,不改动集合结构,版本号不变,不会报错。

五、到底哪些方式可以遍历中增删?

方式一:for 倒序遍历(最常用、零额外开销)

原理

从最后一个索引往前遍历,删除元素时,前面元素前移不影响还没遍历到的前面索引,不会跳项、不会漏项。

cs 复制代码
List<int> list = new List<int> { 1,2,3,4,5 };
// 倒序
for (int i = list.Count - 1; i >= 0; i--)
{
    if (list[i] % 2 == 0)
    {
        list.RemoveAt(i);
    }
}

特点

  • 可以安全删除
  • 也可以插入、Add(但插入建议别在循环里搞,容易逻辑乱)
  • 无版本校验报错
  • 性能最高,无额外内存分配

正向 for 为什么不行?

cs 复制代码
// 错误写法
for (int i = 0; i < list.Count; i++)
{
    if (条件) list.RemoveAt(i);
    // 删除后后面元素前移,i++ 会跳过下一个元素
}
方式二:先遍历标记,再统一删除(业务最稳、可读性强)
  • 先 foreach 遍历,把要删除的元素 / 索引暂存到临时集合

  • 遍历结束后,再循环删除

    cs 复制代码
    List<int> list = new List<int> { 1,2,3,4,5 };
    // 1. 收集待删除项
    var needDel = list.Where(x => x % 2 == 0).ToList();
    // 2. 事后删除
    foreach (var item in needDel)
    {
        list.Remove(item);
    }

    特点

  • 完全避开 foreach 中增删

  • 逻辑清晰,适合复杂业务判断

  • 会多一个临时 List,少量内存开销

方式三:List.RemoveAll
1. 用法
cs 复制代码
// 删除所有满足条件的元素
list.RemoveAll(x => x % 2 == 0);
2. 底层原理

RemoveAll 内部自己用 while 循环 + 数组移位 ,不走 foreach 迭代器,不触发版本号异常

内部大致逻辑:

  1. 遍历内部底层数组 _items
  2. 用一个写入索引,把不满足删除条件的元素往前覆盖
  3. 最后截断列表长度,一次性清理
  4. 只在最后修改一次版本号
3. 优势
  • 一行代码搞定批量删除
  • 底层数组原地移位,性能比自己循环删除更高
  • 不用关心索引、不用倒序
  • 不会报集合已修改异常
4. 适用场景

批量按条件删除,是 List 最优方案之一。

方式四:复制原集合再遍历原集合增删
cs 复制代码
// 复制一份快照
var temp = new List<int>(list);
// 遍历快照,操作原集合
foreach (var item in temp)
{
    if (item > 3)
    {
        list.Remove(item);
    }
}

原理

遍历的是新复制的临时集合,修改的是原集合,两者互不干扰,版本号校验互不影响。适合简单场景,缺点是要完整复制集合,大数据量有内存开销。

方式五:线程安全集合 可边遍历边增删

普通集合不行,并发集合没有版本号强校验

  • ConcurrentBag<T>

  • ConcurrentQueue<T>

  • ConcurrentStack<T>

  • ConcurrentDictionary<TKey,TValue>

    cs 复制代码
    ConcurrentDictionary<int, string> dict = new ConcurrentDictionary<int, string>();
    foreach (var kv in dict)
    {
        // 遍历中可以安全增删改,不会抛集合已修改
        dict.TryAdd(99, "test");
    }

    特点

  • 天生支持遍历中增删

  • 内部加锁,线程安全

  • 性能比普通 List 稍低

  • 适合多线程场景、上位机多设备并发队列场景

方式六:用 LinkedList 链表

LinkedList<T> 迭代器设计不同,遍历中可以删除当前节点

cs 复制代码
LinkedList<int> link = new LinkedList<int>();
var node = link.First;
while (node != null)
{
    var next = node.Next; // 先保存下一个节点
    if (node.Value % 2 == 0)
    {
        link.Remove(node);
    }
    node = next;
}

链表删除节点不影响其他节点索引,不会错乱。

六、总结

  • foreach 不能增删根源 foreach 编译成迭代器,迭代器初始化快照集合版本号 ;每次 MoveNext 校验版本,增删会让 _version++,版本不一致直接抛异常。

  • 禁止增删的目的防止遍历漏项、跳项、索引错乱、底层数组扩容导致指针失效。

  • 可以遍历中增删的 6 种方案

    1. for 倒序遍历:删元素首选,性能高
    2. 先收集再批量删:业务逻辑最清晰
    3. List.RemoveAll:一行批量删,底层优化性能最好
    4. 复制集合快照遍历:简单粗暴
    5. Concurrent 并发集合:多线程随便边遍历边增删
    6. LinkedList 链表:适合频繁增删节点场景
  • 只改元素内容、不改集合个数,foreach 里是允许的,不会报错。

相关推荐
顾温9 小时前
default——C#/C++
java·c++·c#
InCerry10 小时前
.NET性能优化:提升Apache Arrow读写性能
c#·.net周刊
黑咩狗夜.cm15 小时前
(aspose.words .net)内容分别固定在一行左右俩端
c#·word·.net
刚子编程15 小时前
C# Join 实战:左连接写法、字符串拼接与 EF Core 性能调优
开发语言·c#·solr·join
小清兔16 小时前
Addressable的设置打包流程
笔记·游戏·unity·c#
rockey62717 小时前
AScript中一个很有意思的语法
c#·.net·script·eval·expression·动态脚本
刚子编程17 小时前
C# Join 深度解析:参数顺序、多表关联与空值处理最佳实践
开发语言·c#·最佳实践·join·多表关联·空值处理
天天代码码天天17 小时前
C# OnnxRuntime 实现车牌检测识别
c#·车牌识别·号牌识别
刚子编程17 小时前
C# Join 进阶:GroupJoin、性能对决与自定义比较器
java·servlet·c#·join