一、foreach 本质编译成了什么
- 源码写法
cs
List<int> list = new List<int> { 1,2,3,4 };
foreach (var item in list)
{
Console.WriteLine(item);
}
- 编译器语法糖解糖后真实代码
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();
}
核心三个角色:
IEnumerable:提供GetEnumerator()IEnumerator:提供MoveNext()、Current、Reset()Enumerator是 List 内部结构体迭代器
二、List 内部版本号机制
1. List<T> 里有个隐藏字段
cs
// List<T> 源码内部
private int _version;
- 只要对集合做结构性修改 ,
_version++ - 哪些操作会让版本号自增:
Add、AddRangeRemove、RemoveAt、RemoveRangeInsert、Clear、Reverse、Sort(改变结构 / 顺序)
- 只读操作:
索引访问、遍历读不改变版本号
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# 要设计成「遍历中禁止增删」?
不是故意限制,是为了规避三类致命问题:
- 漏遍历:正向遍历删除元素,后面元素前移,下一次索引跳过一个元素
- 重复遍历:中间插入元素,指针回头重复遍历
- 索引越界、内存错乱:动态扩容、缩容导致内部数组地址变化,迭代器指针失效
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 遍历,把要删除的元素 / 索引暂存到临时集合
-
遍历结束后,再循环删除
csList<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 迭代器,不触发版本号异常。
内部大致逻辑:
- 遍历内部底层数组
_items - 用一个写入索引,把不满足删除条件的元素往前覆盖
- 最后截断列表长度,一次性清理
- 只在最后修改一次版本号
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>csConcurrentDictionary<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 种方案
- for 倒序遍历:删元素首选,性能高
- 先收集再批量删:业务逻辑最清晰
- List.RemoveAll:一行批量删,底层优化性能最好
- 复制集合快照遍历:简单粗暴
- Concurrent 并发集合:多线程随便边遍历边增删
- LinkedList 链表:适合频繁增删节点场景
-
只改元素内容、不改集合个数,foreach 里是允许的,不会报错。