【C#】for 和 foreach 循环的选择
本文最后更新于:几秒前
前言
先简单看看一下 for 与 foreach 在遍历集合的区别,最根本的:for 更胜一筹
- for -> 基于索引器 -> 调用本机代码 -> 拿出数据。
- foreach -> 基于 Enumerator -> GetEnumerator() -> 进入 while 循环 -> 索引器拿出数据 -> MoveNext() (类似于 for 循环: 检查索引是否合理,合理就自增 (true),否则就退出 (false))。
for
假如现有一个非数组 (数组的遍历编译器会有优化,不存在下列问题) 的集合需要遍历,你可能会这么写:
1 |
|
这里有一个细节,for 循环的条件写成了 i < col.Count
,这意味这每循环一次,都会访问 Count 这个属性,而这个属性在遍历期间肯定不会变的,因此这在无形中又增加了调用方法 (属性也是一种方法) 的开销,当然实际上这也是微乎其微的,来看看 IL:
可见,尽管在编译后,编译器也没有进行优化,依旧在循环内部访问 Count 属性。
这时有的朋友就要说了,你这是 IL,不代表在运行时也是这么操作的,那我们来看看 JIT,这里就直接借用 JacksonDunstan 的图了
可见,遍历数组时,由于编译器优化,无论是否缓存 .Length 属性,性能都几乎一样。但遍历非数组时,就惨了,蓝色的是没缓存 .Count 的,橙色是缓存了的。
因此,在遍历非数组时,特别是数量很大的,就非常有必要缓存了。怎么缓存?也就是把 .Count 赋值给 for 循环外部的临时变量中,如下
1 |
|
再来看看 IL:
可见 get_Count() 被移到了循环外部,此时就能达到与遍历数组同等的性能了。
foreach
在文章开头我们就知道 foreach 循环依赖于对象实现的 Enumerator,先来看看 IL
测试代码:平平无奇的 foreach 而已
1 |
|
然而编译后:
可见,首先调用了 List<T>.GetEnumerator 拿到 Enumerator,进入 while 循环,再调用其中的 get_Current (背后实际上是就是索引器) 拿到元素,接着调用 MoveNext 进行索引自增,然后进入下一轮循环。
你知道吗?foreach 在遍历数组时也会像 for 那样被优化,看看 IL:
1 |
|
怎么样,是不是感觉非常舒服。
特殊场景
如果你需要在遍历集合时增删该集合的元素时,就要注意了。因为大多数情况下直接在遍历过程中删除、添加元素都会引发问题。
这里就以我遇到的为例。
在 WinForms 中,我们要删除 ListView.SelectedItems 即选中的所有项时 (假设项数 > 1),如果此时我们还用 for 循环且不经过特殊处理的话:
1 |
|
你就会发现,居然报错了:
1 |
|
显示索引超出范围了,可按理来说不可能出现这种错误才对吧,这是怎么回事?
通过调试,我们发现当从集合移除元素时,因为元素自己被删除,导致其余元素的索引也会随之发生更改。什么意思呢?看看模拟:
1 |
|
所以,针对这种特殊情况,有 3 种解决方法:
1. 继续使用 for 循环
常规的 for 循环肯定是不行的。仔细想想,上述场景中,就是因为 for 循环中 i 自增引发的异常,那我们阻止 i 自增不就行了吗?也就是始终让它删除索引为 0 的项,永远不可能报错。
1 |
|
我们直接删除 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. 使用 while 循环
其实 while 循环也可以,它在这里相当于上述第一种 for 循环,即始终传 0 给索引器,且集合中有元素再进行删除
1 |
|
3. 使用 foreach 循环:
没想到吧,foreach 也可以。
1 |
|
但是我们之前分析过,foreach 是一种原理类似 for 循环 (给定值,保条件,自增减) 但是性能不如 for 循环的循环。
也就是说要是去遍历 List<T> 等集合的同时增删集合元素的话,foreach 绝对是不可行的。
但为什么这里可以,因为这里是 百年难得一遇的 特殊情况。
通过查阅源代码,我们发现 WinForms 的 ListView.SelectedItems 的类型 ListView.SelectedListViewItemCollection 在 GetEnumerator 时,返回的是 ListViewItem[] (数组) 的 GetEnumerator,也就是它巧妙的运用数组元素数量不可变的特性,遍历时用该数组,然后在循环体中增删自己的 Item,虽然同样会发生 Item 索引更改的情况,但这也不会导致正在遍历的那个数组的元素索引的改变。
简而言之,这是一种 “先遍历快照,再借用快照拿到 Item 删除原集合元素” 的一种很微妙的实现,故称为 “特殊情况”。其他类型的集合大家可以试试是否也采用了类似的原理。
总结
- for: 【首选】性能敏感场景、遍历大型集合 (包括数组)、需要索引的时候。
注意:遍历非数组大型集合时,需要提前缓存元素数量。 - foreach: 不需要索引、遍历数组、小集合的时候。
- 特殊场景:当需要在遍历集合时增删该集合的元素,注意根据实际情况选择倒序 for 循环、while 循环,以及部分集合类型允许的 foreach 循环
链接
- JacksonDunstan.com | Should You Cache Array.Length and List.Count?
https://www.jacksondunstan.com/articles/3577