JavaScript循环性能-为什么将迭代器向0递减比递增更快?

71

在他的书中《Even Faster Web Sites》中,史蒂夫·桑德斯(Steve Sounders)写道,改进循环性能的简单方法是将迭代器向0递减,而不是向总长度递增(实际上这一章是由尼古拉斯C.扎卡斯(Nicholas C. Zakas)撰写的)。这种改变可以使每次迭代的复杂度不同,从而节省高达50%的原始执行时间。例如:

var values = [1,2,3,4,5];
var length = values.length;

for (var i=length; i--;) {
   process(values[i]);
}

对于for循环、do-while循环和while循环,这几乎是相同的。

我想知道,这是什么原因?为什么递减迭代器会更快?(我对技术背景感兴趣,而不是证明这种说法的基准测试。)


编辑:乍一看,这里使用的循环语法看起来是错误的。没有length-1i>=0,所以让我们澄清一下(我也感到困惑)。

以下是通用的for循环语法:

for ([initial-expression]; [condition]; [final-expression])
   statement
  • initial-expression - var i=length

    首先会执行这个变量声明。

  • condition - i--

    这个表达式会在每次循环迭代之前被计算。它会在第一次通过循环之前使变量递减。如果这个表达式计算结果为false,则循环结束。在JavaScript中,0 == false,所以如果最终i等于0,那么它会被解释成false,循环也会结束。

  • final-expression

    这个表达式在每次循环迭代的末尾被计算(在下一次计算condition之前)。这里不需要这个表达式,因此为空。在for循环中,这三个表达式都是可选的。

这里并不是问题的重点,但由于它有点不太常见,我认为澄清一下会很有趣。另外,它更快的原因可能是因为使用了较少的表达式(0 == false“技巧”)。


1
你难道没有漏掉终止条件吗? - Dave O.
9
终止条件是 i--。在递减之前,当 i 为0时,它将变为0(假)。由于该条件具有递减变量本身的副作用,因此在语句中不需要第三个(更改/增加/任何其他)表达式。 - cHao
1
@Soundlink:实际上,当 i 等于 0 时,它的值为 false。在其余时间里,它的值为 true。 - cHao
1
@Gumbo:循环第一次检查条件——即读取:它评估i-- - cHao
那么为什么在Chrome中,如果在while循环外访问.length,递减操作会变得非常慢呢?http://jsperf.com/preincrement-vs-postincrement-vs-predecrement-vs-postde/5 - CodeManX
显示剩余4条评论
12个回答

68

我对Javascript不是很确定,在现代编译器下可能并不重要,但在“古老的日子”里,这段代码:

for (i = 0; i < n; i++){
  .. body..
}

将生成

move register, 0
L1:
compare register, n
jump-if-greater-or-equal L2
-- body ..
increment register
jump L1
L2:

在这个倒数计数的代码中

for (i = n; --i>=0;){
  .. body ..
}

将生成

move register, n
L1:
decrement-and-jump-if-negative register, L2
.. body ..
jump L1
L2:

所以在循环内部,它只进行了两个额外的指令,而不是四个。


5
值得注意的是,“在旧时代”,JavaScript永远不会被转换为机器码,所以这有点无关紧要。 - skeggse
1
@skeggse JavaScript仍然有一个解释器,即浏览器,必须决定如何执行代码。虽然它不完全被“编译”,但它必须以某种方式发送到处理器。应该说的是_解释器_而不是_编译器_,但说它不会转换为机器码可能并不完全准确。尽管这取决于浏览器的自由裁量权。例如Mozilla使用Spider Monkey - haelmic
@haelmic 我很确定我们在说同一件事。JavaScript 的原始实现包括一个解释器,而现代版本(如 Spider Monkey 和 V8)则会有选择地 JIT 编译代码。 - skeggse

29

我认为这是因为你将循环结束点与0进行比较,这比与<length(或另一个JS变量)进行比较更快。

这是因为序数运算符<, <=, >, >=是多态的,所以这些运算符需要在操作符左右两侧进行类型检查,以确定应该使用什么比较行为。

这里有一些非常好的基准测试:

JavaScript循环编码最快的方法是什么


非常有趣的链接,可惜他没有使用前缀++i运算符添加测试循环,根据http://www.prototypejs.org/api/array的说法,使用前缀i++递增运算符应该比使用后缀i++更快。 - Marco Demaio
pre- (++i) 和 post- (i++) 之间的任何差异都取决于浏览器对 JavaScript 的实现(或者可以忽略不计)。逻辑上的差异更为重要(Douglas Crockford 指出,使用这种结构很容易出错)。pre- (++i) 版本在语句的其余部分之前增加/减少,而 post- (i++) 版本在增加/减少之后增加/减少。i-- 的工作顺序是 (1] 如果为0,则退出,2] 减量,3] 执行循环); --i 将失败,因为顺序 (1] 减量,2] 如果为0,则退出,3] 执行循环) 永远不会处理第0个元素。 - Chuck Kollars

15

说一个迭代可以有更少的指令很容易。让我们比较这两个:

for (var i=0; i<length; i++) {
}

for (var i=length; i--;) {
}

当您将每个变量访问和每个运算符视为一条指令时,前者的for循环使用了5条指令(读取i、读取length、计算i<length、测试(i<length) == true、增加i),而后者仅使用了3条指令(读取i、测试i == true、减少i)。这是一个5:3的比率。


1
你是否希望将 i = length,因为for循环在第一次迭代时也进行条件测试? - palswim
1
你在后面的循环中漏掉了“读取i”,这导致比率为5:3。 - Sachin Jain

6
使用反向 while 循环呢:
var values = [1,2,3,4,5]; 
var i = values.length; 

/* i is 1st evaluated and then decremented, when i is 1 the code inside the loop 
   is then processed for the last time with i = 0. */
while(i--)
{
   //1st time in here i is (length - 1) so it's ok!
   process(values[i]);
}

在我看来,这段代码比 for(i=length; i--;) 更易读。

注:IMO是“In My Opinion”的缩写,意为“在我看来”。

1
问题不在于找到更易读的语法,而在于找到一个解释为什么递减迭代器更快的方法。 - Soundlink
你的答案是最好的,根据 https://jsperf.com/while-vs-for ,while(i--) 是最快的。 - Scott Stensland

4

for 循环中的自增和自减操作在 2017 年的区别

在现代 JS 引擎中,根据个人 Benchmark.js 测试结果,在 for 循环中执行自增操作通常比执行自减操作更快,而且更常规:

for (let i = 0; i < array.length; i++) { ... }

这取决于平台和数组长度,如果length = array.length有任何显著的正面效果,但通常情况下并没有:

for (let i = 0, length = array.length; i < length; i++) { ... }

最近的V8版本(Chrome,Node)对array.length进行了优化,因此在任何情况下都可以有效地省略length = array.length


1
递增在2019年仍然比递减快。https://jsperf.com/ppi-vs-ipp-forloop/9 - Qwerty

3
这还有一个更"高效"的版本。由于for循环中的每个参数都是可选的,因此您甚至可以跳过第一个参数。
var array = [...];
var i = array.length;

for(;i--;) {
    do_teh_magic();
}

通过这种方式,您甚至可以跳过对 [initial-expression] 的检查。因此,您最终只剩下一个操作。


2
嘿,安德烈亚斯,FYI这与@Gumbo解释的for循环完全等效。您只是将初始化部分移到了循环外,而无论如何都需要相同的时间。此外,现在最好将for转换为while。 - Sachin Jain
如果某个操作修改了计数器并且你“跳过”了零,那么你就会得到一个无限循环。尽管这种情况不太可能发生,但像 for(;i-->=0;) 这样做仍然是一个好主意。 - ierdna
只需使用while循环,它甚至更快。 - Frondor

3
在现代JS引擎中,前向循环和反向循环的区别几乎不存在。但性能差异归结为两个因素:
a)每个周期都要进行长度属性的额外查找。

//example:
    for(var i = 0; src.length > i; i++)
//vs
    for(var i = 0, len = src.length; len > i; i++)

这是反向循环最大的性能提升,显然也适用于正向循环。

b) 额外变量赋值。

反向循环较小的提升是只需要一个变量赋值而不是两个。

//example:
    var i = src.length; while(i--)


3
我一直在研究循环速度,发现递减比递增更快的小贴士非常有趣。但是,我还没有找到能证明这一点的测试。在jsperf上有很多循环基准测试。这里是一个测试递减的示例: http://jsperf.com/array-length-vs-cached/6 然而,缓存数组的长度(也推荐Steve Souders的书)似乎是一种获胜的优化方法。

实际上,在Safari中似乎更快 - 真让人沮丧,每个浏览器都不同! - nabrown

1

我不确定它是否更快,但我看到的一个原因是,当您使用增量迭代大量元素的数组时,您往往会写成:

for(var i = 0; i < array.length; i++) {
 ...
}

您实际上是访问数组N的长度属性(元素数量)多次。而当您递减时,只访问一次。这可能是一个原因。

但您也可以按以下方式编写增量循环:

for(var i = 0, len = array.length; i < len; i++) {
 ...
}

确实,多次访问长度属性是不好的,但我认为这不是原因,因为在他的书中,他已经考虑了增量循环的情况。 - Soundlink

1

我对C#和C++(语法相似)进行了基准测试。实际上,在for循环中,与do whilewhile相比,性能存在本质差异。在C++中,增量时性能更高。这也可能取决于编译器。

在Javascript中,我认为一切都取决于浏览器(Javascript引擎),但这种行为是可以预料的。Javascript被优化用于处理DOM。因此,想象一下,您循环遍历一组DOM元素,并在每次迭代时获取它们,然后在删除它们时递增计数器。您删除0元素,然后删除1元素,但是然后跳过了占据0位置的元素。在向后循环时,该问题消失了。我知道所给出的示例并不完全正确,但我确实遇到过必须从不断变化的对象集合中删除项目的情况。

由于向后循环比向前循环更常见,我猜测JS引擎仅针对此进行了优化。


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