非平凡的条件语句是否应移至循环的初始化部分?


21

我从stackoverflow.com上的这个问题中得到了这个主意

以下是常见的模式:

final x = 10;//whatever constant value
for(int i = 0; i < Math.floor(Math.sqrt(x)) + 1; i++) {
  //...do something
}

我要说明的一点是条件语句有些复杂,不会改变。

这样,是否最好在循环的初始化部分中声明它?

final x = 10;//whatever constant value
for(int i = 0, j = Math.floor(Math.sqrt(x)) + 1; i < j; i++) {
  //...do something
}

这更清楚吗?

如果条件表达式很简单怎么办

final x = 10;//whatever constant value
for(int i = 0, j = n*n; i > j; j++) {
  //...do something
}

47
为什么不将其移动到循环之前的行,然后也可以给它起一个明智的名称。
jonrsharpe

2
@Mehrdad:它们不是等效的。如果x幅度较大,Math.floor(Math.sqrt(x))+1则等于Math.floor(Math.sqrt(x))。:-)
R.,

5
@jonrsharpe因为那样会扩大变量的范围。我并不一定要说这是不这样做的一个很好的理由,但这就是某些人不这样做的原因。
凯文·克鲁姆维德

3
@KevinKrumwiede如果范围是一个值得关注的问题,可以通过将代码放在自己的块中来限制它,例如,{ x=whatever; for (...) {...} }或者更好的是,考虑是否有足够的事情需要将它作为一个单独的函数。
Blrfl 2016年

2
@jonrsharpe您也可以在init部分中声明它时,使用一个合理的名称。并不是说我会把它放在那里。如果是分开的,它仍然更容易阅读。
JollyJoker

Answers:


62

我要做的是这样的:

void doSomeThings() {
    final x = 10;//whatever constant value
    final limit = Math.floor(Math.sqrt(x)) + 1;
    for(int i = 0; i < limit; i++) {
         //...do something
    }
}

坦白地说,将初始化j(现在limit)塞入循环头的唯一好理由是保持其正确作用域。要使一个无问题的所有事情都成为一个很好的封闭范围。

我可以体会到对速度的渴望,但是在没有充分理由的情况下不要牺牲可读性。

当然,编译器可以优化,初始化多个变量可能是合法的,但是循环很难按原样进行调试。请对人类友善。如果确实确实减慢了我们的速度,那么很高兴了解它以进行修复。


好点子。如果表达式很简单,我会假设不打扰另一个变量,例如,for(int i = 0; i < n*n; i++){...}您不会分配n*n给变量吗?
Celeritas

1
我会和我有。但不是为了速度。为了提高可读性。可读代码本身往往很快。
candied_orange

1
如果您将标记为常量(final),那么即使进行范围界定,问题也消失了。谁在乎在以后的功能中是否可以访问具有编译器强制阻止其更改的常量?
jpmc26 2013年

我认为一件大事是您期望发生的事情。我知道该示例使用sqrt,但是如果它是另一个函数怎么办?函数是纯函数吗?您是否一直期望相同的值?有副作用吗?您打算在每次迭代中都发生副作用吗?
Pieter B

38

一个好的编译器会以任何一种方式生成相同的代码,因此,如果要提高性能,请仅在关键循环中进行更改并且您已对它进行了概要分析并发现有区别,才进行更改。正如人们在关于函数调用的情况的注释中指出的那样,即使编译器无法优化它,在绝大多数情况下,性能差异也将太小而无法让程序员考虑。

然而...

我们一定不要忘记,代码主要是人与人之间交流的媒介,而且您的两种选择都无法很好地与其他人交流。第一个给人的印象是表达式需要在每次迭代时进行计算,第二个则位于初始化部分,这意味着它将在循环内的某个位置进行更新,在整个循环中它实际上是恒定的。

实际上,我更希望将其从循环中拉出来,final并使之对于阅读代码的任何人都立即清楚地理解。那也不理想,因为它增加了变量的范围,但是封闭函数无论如何都不应包含更多的循环。


5
一旦开始包含函数调用,编译器就难以优化。编译器必须具有Math.sqrt没有副作用的特殊知识。
彼得·格林

@PeterGreen如果这是Java,则JVM可以解决它,但是可能需要一些时间。
chrylis -strike-

这个问题被标记为C ++和Java。我不知道Java的JVM有多高级,什么时候可以并且不能弄清楚,但是我确实知道C ++编译器通常不能弄清楚它的作用,但函数是纯函数,除非它有一个非标准的注释告诉编译器。因此,或者该函数的主体可见,并且可以通过相同的标准将所有间接调用的函数检测为纯函数。注意:无副作用不足以将其移出循环条件。如果循环主体可以修改状态,则依赖于全局状态的函数也不能移出循环。
hvd

一旦将Math.sqrt(x)替换为Mymodule.SomeNonPureMethodWithSideEffects(x),它就会变得很有趣。
Pieter B

9

正如@Karl Bielefeldt的回答中所说,这通常是非问题。

但是,这曾经是C和C ++中的一个常见问题,并且在不降低代码可读性的情况下出现了一个技巧,可以绕过该问题- 向后迭代,直到0

final x = 10;//whatever constant value
for(int i = Math.floor(Math.sqrt(x)); i >= 0; i--) {
  //...do something
}

现在,每个迭代中的条件就是>= 0每个编译器将编译为1或2条汇编指令。在过去的几十年中制造的每个CPU都应进行如下基本检查:在我的x64机器上进行快速检查,我可以预见的是cmpl $0x0, -0x14(%rbp)(long-int-compare值0与寄存器rbp偏移-14)和jl 0x100000f59(如果先前的比较对“ 2nd-arg <1st-arg”)

请注意,我删除了+ 1Math.floor(Math.sqrt(x)) + 1; 为了使数学运算正确,起始值应为int i = «iterationCount» - 1。同样值得注意的是,您的迭代器必须经过签名;unsigned int将无法工作,并且可能会警告编译器。

在使用基于C的语言进行编程约20年之后,我现在只编写反向索引迭代循环,除非有特殊原因要进行正向索引迭代。除了对条件进行更简单的检查之外,反向迭代还常常避开了迭代过程中原本会带来麻烦的数组突变。


1
您撰写的所有内容在技术上都是正确的。但是,关于指令的评论可能会引起误解,因为所有现代CPU的设计都可以与正向迭代循环配合使用,无论它们是否有特殊的指令。无论如何,大多数时间通常都花在循环内部,而不执行迭代。
约根·

4
应当指出,现代编译器优化在设计时就考虑了“普通”代码。在大多数情况下,无论是否使用优化“技巧”,编译器都会生成速度相同的代码。但是某些技巧实际上可能会阻碍优化,具体取决于它们的复杂程度。如果使用某些技巧可以使您的代码更具可读性或帮助您捕获常见的错误,那很好,但是请不要欺骗自己,以为“如果我以此方式编写代码,它将更快。” 按照通常的方式编写代码,然后进行概要分析以查找需要优化的地方。
2016年

另请注意,unsigned如果您修改支票,计数器将在此处工作(最简单的方法是在双方上添加相同的值);例如,对于任何减量Dec,只要语言对变量具有明确定义的环绕规则(特别是对于和都必须为true ),则检查(i + Dec) >= Dec应始终具有与signedcheck 相同的结果i >= 0,并带有signedunsigned计数器。但是请注意,如果编译器未对其进行优化,则此方法可能不如签名检查有效。unsigned-n + n == 0signedunsigned>=0
贾斯汀时间2恢复莫妮卡

1
@JustinTime是的,签署的要求是最困难的部分;Dec在起始值和结束值上都添加一个常量是可行的,但是却使其直观性降低了,并且如果i用作数组索引,则还需要unsigned int arrayI = i - Dec;在循环主体中执行一个。当卡在一个无符号的迭代器中时,我只是使用正向迭代;通常i <= count - 1有条件地使逻辑与反向迭代循环保持平行。
Slipp D. Thompson

1
@ SlippD.Thompson我并不是要Dec专门添加起始值和结束值,而是要Dec在两侧向上移动条件检查。 for (unsigned i = N - 1; i + 1 >= 1; i--) /*...*/ 这使您可以i在循环中正常使用,同时保证条件左侧的最低可能值是0(防止回绕干扰)。但是,在使用无符号计数器时,使用正向迭代肯定要简单得多。
贾斯汀时间2恢复莫妮卡

3

一旦将Math.sqrt(x)替换为Mymodule.SomeNonPureMethodWithSideEffects(x),它就会变得很有趣。

基本上,我的作案手法是:如果期望某事物始终提供相同的值,则只需对其进行一次评估。例如List.Count,如果列表在循环操作期间不会发生变化,则将循环外的计数放入另一个变量中。

其中一些“计数”可能会非常昂贵,特别是在处理数据库时。即使您正在处理的数据集在列表的迭代过程中也不会发生变化。


当计数昂贵时,您根本不应该使用它。相反,您应该做的等效操作for( auto it = begin(dataset); !at_end(it); ++it )
Ben Voigt,2016年

@BenVoigt使用迭代器绝对是进行这些操作的最佳方法。我只是提到了它,以说明我对使用具有副作用的非纯方法的观点。
Pieter B

0

我认为这是特定于语言的。例如,如果使用C ++ 11,我会怀疑如果条件检查是一个constexpr函数,则编译器很可能会优化多个执行,因为它知道每次都会产生相同的值。

但是,如果函数调用是不是库函数 constexpr编译器几乎可以肯定会在每次迭代中执行它,因为它不能推论出这一点(除非它是内联的,因此可以推导为纯函数)。

我对Java的了解较少,但是鉴于它是JIT编译的,因此我猜编译器在运行时具有足够的信息以可能内联和优化条件。但这将取决于良好的编译器设计,并且编译器认为此循环是我们只能猜测的优化优先级。

我个人认为,如果可以的话,将条件放在for循环中会稍微好一些,但是如果条件复杂,我会将其写入a constexprinline函数,或者等价于您的语言来暗示该函数是纯净的且可优化的。这使意图很明显,并保持惯用循环样式,而不会造成巨大的不可读行。它还为条件检查提供了一个名称,即条件检查是否具有其自身的功能,因此读者可以在逻辑上立即查看检查的目的,而无需阅读复杂的检查内容。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.