C#开发基础之深入理解“集合遍历时不可修改”的异常背后的设计

前言

欢迎关注【dotnet研习社】,今天我们聊聊一个基础问题"集合已修改:可能无法执行枚举操作"背后的设计。

在日常 C# 开发中,我们常常会操作集合(如 List<T>Dictionary<K,V> 等)。一个新手开发者极有可能遇到下面这个经典异常:

System.InvalidOperationException: Collection was modified; enumeration operation may not execute.

这通常意味着你在 遍历集合的过程中尝试修改集合本身(添加或删除元素) ,这是被禁止的。本文将深入剖析这个问题产生的原因,并分享常见的几种 安全解决方案 ,帮助我们从容应对这一异常。

一、问题复现

来看一个简单的例子:

csharp 复制代码
List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };

foreach (int num in numbers)
{
    if (num % 2 == 0)
    {
        numbers.Remove(num); // 报错!
    }
}

运行后会抛出异常:

复制代码
System.InvalidOperationException: Collection was modified; enumeration operation may not execute.

这是因为 foreach 在枚举集合时,会维护一个内部状态来防止在枚举过程中破坏结构,一旦结构变动,就会抛出异常。

二、常见的正确做法

方法 1:倒序 for 循环移除元素

适用于 List<T> 这种支持索引的集合:

csharp 复制代码
for (int i = numbers.Count - 1; i >= 0; i--)
{
    if (numbers[i] % 2 == 0)
    {
        numbers.RemoveAt(i);
    }
}

✅ 倒序循环可以避免因为索引变动导致的跳过元素或崩溃。

方法 2:使用 LINQ 的 Where + ToList() 创建副本遍历

csharp 复制代码
foreach (var num in numbers.Where(n => n % 2 == 0).ToList())
{
    numbers.Remove(num);
}

ToList() 会创建一个集合副本,这样你就可以安全地对原集合进行修改了。

方法 3:临时列表收集要移除的项,二次遍历移除

csharp 复制代码
var toRemove = new List<int>();
foreach (var num in numbers)
{
    if (num % 2 == 0)
    {
        toRemove.Add(num);
    }
}
foreach (var num in toRemove)
{
    numbers.Remove(num);
}

✅ 这种方法安全可靠,尤其适合处理复杂条件删除场景。

方法 4:直接使用 List<T>.RemoveAll()

这是最简洁的一种方式:

csharp 复制代码
numbers.RemoveAll(n => n % 2 == 0);

✅ 适用于只需要从集合中删除符合某个条件的元素场景。

三、适用于不同集合类型的说明

集合类型 遍历时可修改? 推荐处理方式
List<T> 倒序/临时列表/RemoveAll
Dictionary<K,V> ToList()拷贝键值对后操作
HashSet<T> 先收集,后统一移除
ConcurrentBag<T> 支持并发读写,无需额外处理

如果正在开发多线程程序,强烈推荐使用线程安全集合,如 ConcurrentDictionary<K,V>ConcurrentQueue<T> 等。

四、深入理解为何不能修改

  • foreach 的底层是使用了 IEnumerator
  • 当修改集合时(比如 Remove()),集合的 version 字段会更新;
  • IEnumerator 检测到版本变动后,会抛出 InvalidOperationException,以防止出现难以调试的数据错误。

我们可以通过查看 .NET 源码中关于 List<T>IEnumerator 以及 version 字段的真实实现,验证上面的描述并深入理解:

在该文件中可以看到 List<T> 的实现细节:

示例:List<T>.Enumerator.MoveNext() 中的 version 检查

csharp 复制代码
public bool MoveNext()
{
    List<T> localList = list;

    if (version == localList._version && (index < localList._size))
    {
        current = localList._items[index++];
        return true;
    }
    return MoveNextRare();
}

而在 MoveNextRare() 中可以看到抛出异常的逻辑:

csharp 复制代码
private bool MoveNextRare()
{
    if (version != list._version)
    {
        ThrowHelper.ThrowInvalidOperationException_InvalidOperation_EnumFailedVersion();
    }

    index = list._size + 1;
    current = default!;
    return false;
}

说明只要外部在枚举过程中修改了集合(导致 _version 改变),枚举器就会感知并抛出异常。

这种机制的目的是保护开发者避免数据一致性错误,虽然它带来了限制,但也增强了代码的健壮性。

五、总结一句话

遍历集合时不要修改集合本身。

如果需要修改,请先复制副本延后批量处理 ,不要在 foreach 中直接 AddRemove

六、附加:通用工具方法(删除满足条件的元素)

我们可以封装一个更通用的方法,供多处复用:

csharp 复制代码
public static void SafeRemove<T>(List<T> list, Func<T, bool> predicate)
{
    list.RemoveAll(predicate);
}

使用方式:

csharp 复制代码
SafeRemove(numbers, n => n % 2 == 0);

七、延伸阅读推荐

相关推荐
码智社19 小时前
AES加密原理详解及Java实现加解密实战
java·开发语言
AI云海19 小时前
python 列表、元组、集合和字典
开发语言·python
萧瑟余晖20 小时前
JDK 26 新特性详解
java·开发语言
马优晨21 小时前
Freemarker 完整讲解(后端 Java 模板引擎)
java·开发语言·freemarker·freemarker 完整讲解·freemarker模板引擎
人邮异步社区1 天前
怎么把C语言学到精通?
c语言·开发语言
心平气和量大福大1 天前
C#-WPF-控件-TextBox 数据绑定
开发语言·c#·wpf
ttwuai1 天前
Cursor 生成 CRUD 后,Go 后台接口别只测 200:JWT、RBAC 和 tenant_id 怎么验
开发语言·后端·golang
এ慕ོ冬℘゜1 天前
前端基础:什么是时间戳?JS获取时间戳三种方法与实战用途
开发语言·前端·javascript
执明wa1 天前
LayoutInflater详解: XML是如何变成View的?
android·xml·开发语言·android studio
一次旅行1 天前
Python+大模型端到端自动化日报系统
开发语言·python·自动化