C#编译器是否会优化Count属性?

13
List<int> list = ...

for(int i = 0; i < list.Count; ++i)
{
           ...
}

那么编译器是否知道在每次迭代中无需调用list.Count呢?


它正在调用一个变量,就像你做 int count = list.Count 并使用它一样。我对你的问题感到困惑? - Nathan Loding
@Nathan:实际上它调用的是一个属性,这实际上是一个方法调用而不是变量提取。 JIT将负责优化此操作(通常不是C#编译器),但实际上并没有发生。 - Reed Copsey
使用像那样的for循环,可能确实需要这样做,因为您可以修改集合。 - Ed S.
@Reed:我会期望JIT 确实 优化掉getter/setter的方法调用;我知道Java的确实这样做了。 - BlueRaja - Danny Pflughoeft
@BlueRaja:在某些情况下确实会这样,但并不总是优化它。Java也是一样的... - Reed Copsey
7个回答

22

你确定吗?

List<int> list = new List<int> { 0 };

for (int i = 0; i < list.Count; ++i)
{
    if (i < 100)
    {
        list.Add(i + 1);
    }
}
如果编译器缓存了上面的Count属性,那么list的内容将为0和1。如果没有缓存,内容将是从0到100的整数。
现在,这对你来说可能看起来是一个人为的例子;但下面这个例子呢?
List<int> list = new List<int>();

int i = 0;
while (list.Count <= 100)
{
    list.Add(i++);
}

这两个代码片段看起来似乎完全不同,但这只是因为我们倾向于以不同的方式思考 for 循环和 while 循环。在任何一种情况下,变量的值都将在每次迭代中进行检查。而且,在任何一种情况下,该值都可能会发生改变。

通常情况下,不能安全地假设编译器在行为上优化某些内容,因为 "优化" 和 "非优化" 版本的代码之间的行为实际上是不同的。


如果我没记错的话,第一个例子无法编译,因为你不能在迭代列表时修改它。 - Neil N
1
@Neil,那将是枚举而不是通过索引访问? - Phil Gan
2
@Neil:这就是 OP 犯的错误,认为 for 循环与迭代相同。在实践中它可能总是被这种方式,但这不是它根据定义所的。它本质上是一个 while 循环。你可能在想 foreach,它根据定义确实是一个枚举。如果列表在调用之间被修改,则会引发异常,因为 List<T>.Enumerator.MoveNext 抛出 InvalidOperationException。无论如何,上面的 for 循环确实可以编译;你自己试试吧。 - Dan Tao
我可能在想foreach。 - Neil N
@Neil: 是的,完全可以理解。顺便说一下,即使在这种情况下(如果从foreach中修改List<T>则会引发异常),它也是特定于List<T>.Enumerator类型的; 它不是所有集合的固有特征。(可能只是BCL中的所有集合,但并非所有集合。)必须专门编写一个IEnumerator<T>实现来在MoveNext上引发异常;如果没有,那么在枚举集合时修改它并没有本质的违规之处。无论哪种方式,代码肯定都会编译 - Dan Tao
@Karmastan:你怎么能确定Vitaliy的Count是常数?他在循环内没有提供任何代码!这正是我的观点:List<T>.Count属性可能会改变;也可能不会。CRMay引用的文章中提供的示例都涉及数组,这些数组保证长度恒定。是的,我想可能通过相当多的巫术,编译器可以检测到for循环内是否修改了List<T>,但我没有看到有关此事的文档记录。我想告诉OP的主要观点是,这种“优化”并不总是适当的。 - Dan Tao

9

C#编译器不进行任何此类优化。然而,JIT编译器会对数组进行优化,我认为(数组是不可调整大小的),但不会对列表进行优化。

在循环结构中,列表的计数属性可能会发生变化,因此这将是一种错误的优化方式。


4
值得注意的是,正如其他人没有提到的那样,从这样的循环中无法知道“Count”属性实际上会执行什么操作,或者它可能具有什么副作用。
考虑以下情况:
第三方实现名为“Count”的属性可以执行任何所需的代码。例如,我们都知道返回一个随机数。对于List,我们可以更加自信地了解其操作方式,但JIT如何区分这些实现呢?
循环内的任何方法调用都可能改变Count的返回值(不仅是直接在集合上进行“Add”,而且在循环中调用的用户方法也可能对集合进行处理)。
同时执行的任何其他线程也可以更改Count值。
JIT只是不能“知道”Count是常量。
然而,如果实现是微不足道的话,JIT编译器可以通过内联Count属性的实现(只要它是微不足道的),使代码运行更加高效。在您的示例中,它可能会被内联到一个简单的变量值测试中,避免了每次迭代的函数调用开销,从而使最终代码快速而美观。(注:我不知道JIT是否会这样做,只是它可以这样做。我并不真的关心 - 看看我的答案的最后一句话就知道为什么)
但是即使进行了内联,值在循环迭代之间仍可能更改,因此仍需要从RAM中读取每个比较的值。如果将Count复制到本地变量中,并且JIT可以通过查看循环中的代码确定该局部变量将保持常量,则它可能能够进一步优化它(例如,通过将常量值保存在寄存器中而不是必须在每次迭代时从RAM中读取它)。因此,如果您(作为程序员)“知道” Count 在循环的生命周期内将保持不变,则可以通过将Count缓存在本地变量中来帮助JIT。这为JIT提供了最佳优化循环的机会。(但是不能保证JIT实际上会应用此优化,因此手动“优化”可能对执行时间没有影响。如果您的假设(即Count是常量)不正确,则会有风险出现问题。或者您的代码可能会因为另一个程序员编辑循环内容以使Count不再是常量而导致代码崩溃,他没有注意到您的聪明才智)
因此,这个故事的寓意是:JIT可以通过内联来优化这种情况。即使它现在没有这样做,它也可能会在下一个C#版本中这样做。通过手动“优化”代码,你可能不会获得任何好处,而且你会冒着改变其行为从而破坏它的风险,或者至少使未来维护你的代码更加危险,或者可能错失未来JIT增强的机会。因此,最好的方法就是按照你已经写好的方式编写代码,在分析器告诉你循环是性能瓶颈时再进行优化。
因此,在我看来,考虑/理解这种情况是有趣的,但最终你实际上并不需要知道。一点点知识可能是一件危险的事情。只要让JIT去执行它的任务,然后对结果进行分析以查看是否需要改进即可。

3
如果您查看生成的IL代码,您会在循环条件处看到类似于Dan Tao示例中的以下行:

如果您查看生成的IL代码,您会在循环条件处看到类似于Dan Tao示例中的以下行:

callvirt instance int32 [mscorlib]System.Collections.Generic.List`1<int32>::get_Count()

这是不可否认的证据,即在每次循环迭代中都会调用计数(即 get_Count())。

我的x86汇编技能非常生疏,但是对JIT结果的调查显示出一个“call FFFFFFFFF6BA91F0”,这可能表示调用了get_Count()函数。 - AlfredBr
4
不,JIT编译器会将其转换为直接的CPU寄存器加载,这被称为"内联"。在Release版本中,小型属性获取程序始终是内联的。要查看它,您需要查看优化后的机器代码。 - Hans Passant
2
没错,IL并不是实际发生情况的最终决定。 - Ben Voigt

1

对于所有其它评论者所说的'Count'属性可能在循环体中改变:JIT优化能够让你利用实际运行的代码,而不是最坏情况下可能发生的情况。一般来说,计数可能会改变。但并非所有代码都是如此。

因此,在发帖者的示例中(可能没有任何改变计数的内容),JIT是否检测到在循环中,不会更改List用于保存其长度的任何内部变量?如果它检测到list.Count是常量,它不会将该变量访问提取出循环体吗?

我不知道JIT是否这样做。但是我并不认为这个问题可以轻易地被解决为“从不”。


这可能会非常困难,因为您可能在某个其他线程上引用了该列表,而在当前线程的循环中进行修改。当然,这将导致一系列其他问题,但核心问题是列表是可变的,因此您无法保证从一个迭代到另一个迭代的计数状态。即使忽略线程处理(Jitter是否递归检查每个方法调用以检查副作用?),这也是正确的。 - Dan Bryant
是的,确定起来可能很困难。我发帖的重点是有时候很容易确定。而当它变得容易时,可能会被优化。至于线程,如果在遍历列表时未受锁保护,则即使是未经优化的版本也存在错误。 - Karmastan
没有人说“永远不会”。原帖问道:“编译器是否知道在每次迭代中不必调用list.Count?”我回答:“你确定吗?” 我觉得原帖的作者认为Count属性不会改变,我的意图是挑战这种假设。 - Dan Tao

0

0

这取决于Count的具体实现;我从未注意到在List上使用Count属性会有任何性能问题,因此我认为这是可以的。

在这种情况下,您可以通过foreach节省一些打字。

List<int> list = new List<int>(){0};
foreach (int item in list)
{ 
    // ...
} 

除非您在循环体中使用索引“i”。像对每个元素加一这样简单的操作不能使用“foreach”完成。 - Ben Voigt
OP没有提到要将每个元素添加什么? - Phil Gan

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接