【C#】for 和 foreach 循环的选择

本文最后更新于:几秒前

前言

先简单看看一下 for 与 foreach 在遍历集合的区别,最根本的:for 更胜一筹

  • for -> 基于索引器 -> 调用本机代码 -> 拿出数据。
  • foreach -> 基于 Enumerator -> GetEnumerator() -> 进入 while 循环 -> 索引器拿出数据 -> MoveNext() (类似于 for 循环: 检查索引是否合理,合理就自增 (true),否则就退出 (false))。

for

假如现有一个非数组 (数组的遍历编译器会有优化,不存在下列问题) 的集合需要遍历,你可能会这么写:

1
2
3
4
5
6
List<int> col = [1, 2, 3];

for (int i = 0; i < col.Count; i++)
{
Console.WriteLine(arr[i]);
}

这里有一个细节,for 循环的条件写成了 i < col.Count,这意味这每循环一次,都会访问 Count 这个属性,而这个属性在遍历期间肯定不会变的,因此这在无形中又增加了调用方法 (属性也是一种方法) 的开销,当然实际上这也是微乎其微的,来看看 IL:

可见,尽管在编译后,编译器也没有进行优化,依旧在循环内部访问 Count 属性。

这时有的朋友就要说了,你这是 IL,不代表在运行时也是这么操作的,那我们来看看 JIT,这里就直接借用 JacksonDunstan 的图了

可见,遍历数组时,由于编译器优化,无论是否缓存 .Length 属性,性能都几乎一样。但遍历非数组时,就惨了,蓝色的是没缓存 .Count 的,橙色是缓存了的。

因此,在遍历非数组时,特别是数量很大的,就非常有必要缓存了。怎么缓存?也就是把 .Count 赋值给 for 循环外部的临时变量中,如下

1
2
3
4
5
6
7
List<int> col = [1, 2, 3];
var count = col.Count;

for (int i = 0; i < count; i++)
{
Console.WriteLine(arr[i]);
}

再来看看 IL:

可见 get_Count() 被移到了循环外部,此时就能达到与遍历数组同等的性能了。

foreach

在文章开头我们就知道 foreach 循环依赖于对象实现的 Enumerator,先来看看 IL

测试代码:平平无奇的 foreach 而已

1
2
3
4
5
6
List<int> col = [1, 2, 3];

foreach (var item in col)
{
Console.WriteLine(item);
}

然而编译后:

可见,首先调用了 List<T>.GetEnumerator 拿到 Enumerator,进入 while 循环,再调用其中的 get_Current (背后实际上是就是索引器) 拿到元素,接着调用 MoveNext 进行索引自增,然后进入下一轮循环。

你知道吗?foreach 在遍历数组时也会像 for 那样被优化,看看 IL:

1
2
3
4
5
6
int[] col = [1, 2, 3];

foreach (var item in col)
{
Console.WriteLine(item);
}

怎么样,是不是感觉非常舒服。

特殊场景

如果你需要在遍历集合时增删该集合的元素时,就要注意了。因为大多数情况下直接在遍历过程中删除、添加元素都会引发问题。

这里就以我遇到的为例。

在 WinForms 中,我们要删除 ListView.SelectedItems 即选中的所有项时 (假设项数 > 1),如果此时我们还用 for 循环且不经过特殊处理的话:

1
2
3
4
5
6
7
var items = ListViewMain.SelectedItems;
var length = items.Count;

for (int i = 0; i < length; i++)
{
Delete(items[i]);
}

你就会发现,居然报错了:

1
2
3
4
System.ArgumentOutOfRangeException: InvalidArgument=Value of '1' is not valid for 'index'.
Parameter name: index
at System.Windows.Forms.ListView.SelectedListViewItemCollection.get_Item(Int32 index)
at ...

显示索引超出范围了,可按理来说不可能出现这种错误才对吧,这是怎么回事?

通过调试,我们发现当从集合移除元素时,因为元素自己被删除,导致其余元素的索引也会随之发生更改。什么意思呢?看看模拟:

1
2
3
4
5
6
7
8
9
10
(假设现有 A、B 两个元素,索引分别是 0,1)
0 -> A;
1 -> B;

(for 循环开始,此时 i = 0)
(A 被删除,导致集合只有 B,且索引也被重新分配了)
0 -> B;

(下一轮循环,此时 i = 1)
(索引 1 不对应任何元素,抛异常)

所以,针对这种特殊情况,有 3 种解决方法:

1. 继续使用 for 循环
常规的 for 循环肯定是不行的。仔细想想,上述场景中,就是因为 for 循环中 i 自增引发的异常,那我们阻止 i 自增不就行了吗?也就是始终让它删除索引为 0 的项,永远不可能报错。

1
2
3
4
5
6
7
var items = ListViewMain.SelectedItems;
var length = items.Count;

for (int i = 0; i < length;)
{
Delete(items[i]);
}

我们直接删除 i++ 就行。但,这样的 for 循环就没有灵魂了,因为 i 是定值。那我们非得要用 for 循环且还要注入灵魂,并保证不抛异常,应该怎么办?

没错,倒序!

仔细想想我们那个场景,i 从 0 自增带来的效果是什么?是不是相当于从集合的开头到结尾依次删除元素?那你有没有想过,如果从集合末尾开始删除元素的话,这样一来,元素也能删除,索引也不会改变。

于是,i-- 来了。怎么自减?从 0 开始?还是 length 开始?还是 length + 1 或 length - 1 开始?i 的初始值对于第一次用倒序 for 循环的朋友们来说也是一个值得深思的问题。

那我们来思考一下这个问题:

假设现有某集合内有 1 个元素,我们要遍历,是不是当 i = 0 让它自己跑一次就完成了?那 0 是不是 = 1 - 1,即 length - 1?没反应过来?那继续

假设现有某集合内有 2 个元素,我们要遍历,是不是让 i = 1 和 i = 0 分别去跑一次,就完成了?那 1 是不是 = 2 - 1,即 length - 1?

所以 i 的初始值就是 length - 1。同时保证 i 自减最多只能到 0,所以条件是 i >= 0,这个就很好理解了。

1
2
3
4
5
6
var items = ListViewMain.SelectedItems;

for (int i = items.Count - 1; i >= 0; i--)
{
Delete(items[i]);
}

2. 使用 while 循环
其实 while 循环也可以,它在这里相当于上述第一种 for 循环,即始终传 0 给索引器,且集合中有元素再进行删除

1
2
3
4
5
6
var items = ListViewMain.SelectedItems;

while (items.Count > 0)
{
Delete(items[0]);
}

3. 使用 foreach 循环:

没想到吧,foreach 也可以。

1
2
3
4
foreach (ListViewItem item in ListViewMain.SelectedItems)
{
Delete(item);
}

但是我们之前分析过,foreach 是一种原理类似 for 循环 (给定值,保条件,自增减) 但是性能不如 for 循环的循环。

也就是说要是去遍历 List<T> 等集合的同时增删集合元素的话,foreach 绝对是不可行的。

但为什么这里可以,因为这里是 百年难得一遇的 特殊情况。

通过查阅源代码,我们发现 WinForms 的 ListView.SelectedItems 的类型 ListView.SelectedListViewItemCollection 在 GetEnumerator 时,返回的是 ListViewItem[] (数组) 的 GetEnumerator,也就是它巧妙的运用数组元素数量不可变的特性,遍历时用该数组,然后在循环体中增删自己的 Item,虽然同样会发生 Item 索引更改的情况,但这也不会导致正在遍历的那个数组的元素索引的改变。

简而言之,这是一种 “先遍历快照,再借用快照拿到 Item 删除原集合元素” 的一种很微妙的实现,故称为 “特殊情况”。其他类型的集合大家可以试试是否也采用了类似的原理。

总结

  • for: 【首选】性能敏感场景、遍历大型集合 (包括数组)、需要索引的时候。
    注意:遍历非数组大型集合时,需要提前缓存元素数量。
  • foreach: 不需要索引、遍历数组、小集合的时候。
  • 特殊场景:当需要在遍历集合时增删该集合的元素,注意根据实际情况选择倒序 for 循环while 循环,以及部分集合类型允许的 foreach 循环

链接


【C#】for 和 foreach 循环的选择
https://wanghaonie.github.io/posts/d1afa7a5de7f/
作者
WangHaonie
发布于
2025-07-22 10:22:01
更新于
2025-08-01 09:45:55
许可协议