i++比++i效率低,如何证明这一点?

16

我试图通过示例说明前缀递增比后缀递增更有效率。

从理论上讲,这是有道理的:i++需要能够返回未递增的原始值并因此存储它,而++i可以返回递增后的值而无需存储先前的值。

但是有没有一个好的实例来展示这一点呢?

我尝试了以下代码:

int array[100];

int main()
{
  for(int i = 0; i < sizeof(array)/sizeof(*array); i++)
    array[i] = 1;
}

我使用gcc 4.4.0编译它的方式如下:

gcc -Wa,-adhls -O0 myfile.cpp

我再次尝试了一下,将后缀递增改为前缀递增:

for(int i = 0; i < sizeof(array)/sizeof(*array); ++i)

在这两种情况下,结果的汇编代码是完全相同的。

这有点出乎意料。似乎通过关闭优化(使用-O0)应该会有不同的结果来展示这个概念。我错过了什么?是否有更好的例子来说明这一点?


12
编译器足够聪明,可以推断出在您的循环示例中,++i和i++会产生相同的结果。试着将其赋值给一个变量,并计算一些东西,比如一个数组索引之类的东西,来实际使用它的结果。但我敢说你不会看到什么实质性的差异。 - Lasse V. Karlsen
1
顺便说一下:sizeof(array)/sizeof(array[0])... sizeof(*array)=sizeof(int) - Artyom
2
你忽略了编译器比你聪明这一点。在你的示例代码中,无论是i++还是++i都将产生相同的效果。这甚至不是编译器优化,只是普通的代码生成。如果你想关闭优化并获得不同的汇编代码,请编写包含i++和++i的较大表达式的代码。(当你再次打开优化时发现它没有关系)。在C++中,你应该测试后自增与前自增,因为这可能会产生微小的差异(例如在迭代器上)。 - nos
2
使用整数时,您不太可能看到编译器可以进行一些智能优化的差异。您可能能够检测到具有正确定义的前缀和后缀增量(即它们的行为类似于内置类型)的用户定义类型的差异。 - Martin York
@Artyom:好的,但是如果你决定更改array存储在其元素中的类型,如果你使用sizeof(int)而不是sizeof(*array),那么你很可能会遇到错误,因为你很可能会忘记在那个地方也进行更改。代码是不断变化的,因此始终要尽可能地使其易于更改,这通常意味着减少需要更改代码的位置的数量,特别是如果那些位置即使你不更改它们也会编译通过(就像在这种情况下一样)。 - HelloGoodbye
9个回答

24

通常情况下,后置自增运算将会导致一个副本的产生,而前置自增则不会。当然,在许多情况下,这将被优化掉,在不能优化时,复制操作也将是可以忽略的(例如对于内置类型)。

这里有一个小例子,展示了后置自增的潜在低效性。

#include <stdio.h>

class foo 
{

public:
    int x;

    foo() : x(0) { 
        printf( "construct foo()\n"); 
    };

    foo( foo const& other) { 
        printf( "copy foo()\n"); 
        x = other.x; 
    };

    foo& operator=( foo const& rhs) { 
        printf( "assign foo()\n"); 
        x = rhs.x;
        return *this; 
    };

    foo& operator++() { 
        printf( "preincrement foo\n"); 
        ++x; 
        return *this; 
    };

    foo operator++( int) { 
        printf( "postincrement foo\n"); 
        foo temp( *this);
        ++x;
        return temp; 
    };

};


int main()
{
    foo bar;

    printf( "\n" "preinc example: \n");
    ++bar;

    printf( "\n" "postinc example: \n");
    bar++;
}

优化后的构建结果(实际上由于返回值优化,在后置增量情况下删除了第二个复制操作):

construct foo()

preinc example: 
preincrement foo

postinc example: 
postincrement foo
copy foo()

一般来说,如果您不需要后置增量的语义,为什么要冒险发生不必要的复制呢?

当然,需要记住自定义的operator++() - 无论是前缀还是后缀变体 - 可以自由地返回任何想要的内容(甚至可以随意操作),我想可能有很多人并不遵循通常的规则。偶尔会遇到返回"void"的实现,这使得通常的语义差异消失了。


3
这个例子清楚地说明了 i++ 的问题 - 如果 i 是一个复杂类型,其中复制操作很昂贵(比如一个精巧的引用计数迭代器),那么必须避免进行复制。 - Tom Leys

8

对于整数,您不会看到任何区别。您需要使用迭代器或其他一些东西,在其中后缀和前缀真正有所不同。而且您需要打开所有优化,而不是关闭!


16
在进行任何基准测试时,您需要让编译器生成尽可能优秀的代码。如果使用较低级别的优化,各种因素(如寄存器使用不当)可能会影响结果并导致结果失真。我之前在这里发布基准测试时就曾受到过这种问题的困扰。 - anon
1
我认为他只是想看到,没有优化的情况下,++x应该比x++生成更快的代码。 - GManNickG
1
我的观点是,在没有优化的情况下,其他因素可能会妨碍证明即使在理想世界中也是真实的。 - anon
以下是有关编程的内容,翻译成中文:特别提到优化陷阱。请只返回翻译后的文本,尽量简洁完整。 - Mehrdad Afshari
@Mihail 我已在下面的第二个答案中发布了自己的基准测试。 - anon
显示剩余5条评论

5

我喜欢遵循“说到做到”的原则。

++i 简单地递增。 i++ 递增并且具有特殊的、非直观的评估结果。只有在明确需要这种行为时,我才使用 i++,而在其他所有情况下都使用 ++i。如果你也遵循这个实践,当你在代码中看到 i++ 时,就会明显地意识到后递增行为确实是有意的。


8
我敢打赌,如果这种编程语言被命名为++C,人们会更经常默认执行这个操作。 - Reunanen
5
也许它被称为C++是因为在特定情况下使用时,它仍然像C语言一样运作。 - Tom Leys
2
当你在代码中看到 i++ 时,很明显是有意使用后增行为。你一定是那些记得这种差别的特殊人之一;-) - quant_dev

4

几点说明:

  • 首先,你不太可能在任何方面看到主要的性能差异
  • 其次,如果你禁用了优化,那么你的基准测试是无用的。我们想知道的是这个改变是否给我们更加高效的代码,这意味着我们必须使用编译器能够产生的最有效代码来使用它。我们不关心它在未优化的构建中是否更快,我们需要知道它在优化的构建中是否更快。
  • 对于内置数据类型,如整数,编译器通常能够优化掉差异。问题主要出现在具有重载增量迭代器的更复杂类型中,编译器无法轻易地看到两个操作在上下文中是等效的。
  • 你应该使用最清晰地表达你意图的代码。你想“将一个值加一”,还是“将一个值加一,但仍然在原始值上继续工作一段时间”?通常情况下,前者是正确的,这时候前缀递增更好地表达了你的意图。

如果你想展示差异,最简单的选择就是实现这两个运算符,并指出其中一个需要额外的副本,另一个则不需要。


0

尝试使用while或对返回值进行操作,例如:

#define SOME_BIG_CONSTANT 1000000000

int _tmain(int argc, _TCHAR* argv[])
{
    int i = 1;
    int d = 0;

    DWORD d1 = GetTickCount();
    while(i < SOME_BIG_CONSTANT + 1)
    {
        d += i++;
    }
    DWORD t1 = GetTickCount() - d1;

    printf("%d", d);
    printf("\ni++ > %d <\n", t1);

    i = 0;
    d = 0;

    d1 = GetTickCount();
    while(i < SOME_BIG_CONSTANT)
    {
        d += ++i;

    }
    t1 = GetTickCount() - d1;

    printf("%d", d);
    printf("\n++i > %d <\n", t1);

    return 0;
}

使用 /O2 或 /Ox 在 VS 2005 中编译,我在台式机和笔记本电脑上都尝试过。

在笔记本电脑上稳定得到了大致相似的数据,在台式机上数字有些不同(但速率大致相同):

i++ > 8xx < 
++i > 6xx <

xx 表示数字不同,例如 813 vs 640 - 仍然可以加速约 20%。

还有一个要点 - 如果用 "d = " 替换 "d +=",你会看到一个很棒的优化技巧:

i++ > 935 <
++i > 0 <

然而,它相当具体。但毕竟,我没有看到任何理由改变我的想法并认为没有区别 :)


只要 id 是整数,你在这里不应该看到任何区别。 - Nathan Fellman
然而,在使用/O2选项的VS2005中,我确实看到了差异——++i更快。但是,我只是好奇为什么在调试版本中,i++给了我更好的性能表现? - Mikhail Churbanov
1
你计算的是不同的东西:第一个和第二个循环中的结果d在SOME_BIG_CONSTANT值上是不同的,因为在第一个循环中你先加后递增,在第二个循环中则是先递增后加。这实际上导致了不同的代码。你应该尝试使用:"d+=i; i++;"或者"d+=i; ++i;"来看看区别(并且你将不会发现)。 - Artyom
另外,我建议将printf放在GetTickCount测量之外。 - Artyom
谢谢,我已经修复了代码 - 现在计算相同的值。而且将printf放在外面确实更好,以使测试更清晰。 - Mikhail Churbanov
显示剩余4条评论

0

这段代码及其注释应该展示两者之间的差异。

class a {
    int index;
    some_ridiculously_big_type big;

    //etc...

};

// prefix ++a
void operator++ (a& _a) {
    ++_a.index
}

// postfix a++
void operator++ (a& _a, int b) {
    _a.index++;
}

// now the program
int main (void) {
    a my_a;

    // prefix:
    // 1. updates my_a.index
    // 2. copies my_a.index to b
    int b = (++my_a).index; 

    // postfix
    // 1. creates a copy of my_a, including the *big* member.
    // 2. updates my_a.index
    // 3. copies index out of the **copy** of my_a that was created in step 1
    int c = (my_a++).index; 
}

你可以看到后缀有一个额外的步骤(步骤1),涉及创建对象的副本。这对内存消耗和运行时间都有影响。这就是为什么前缀对于非基本类型更有效率。

根据一些非常大的类型以及您对增量结果所做的任何操作,您将能够在优化或未优化的情况下看到差异。


0
作为对 Mihail 的回应,这是他代码的一个更加便携版本:
#include <cstdio>
#include <ctime>
using namespace std;

#define SOME_BIG_CONSTANT 100000000
#define OUTER 40
int main( int argc, char * argv[] ) {

    int d = 0;
    time_t now = time(0);
    if ( argc == 1 ) {
        for ( int n = 0; n < OUTER; n++ ) {
            int i = 0;
            while(i < SOME_BIG_CONSTANT) {
                d += i++;
            }
        }
    }
    else {
        for ( int n = 0; n < OUTER; n++ ) {
            int i = 0;
            while(i < SOME_BIG_CONSTANT) {
                d += ++i;
            }
        }
    }
    int t = time(0) - now;  
    printf( "%d\n", t );
    return d % 2;
}

外层循环是为了让我调整时间以在我的平台上得到合适的结果。

我不再使用VC++,所以我在Windows上编译它:

g++ -O3 t.cpp

然后我通过交替运行它:

a.exe   

并且

a.exe 1

我的时序结果在两种情况下大致相同。有时一个版本会比另一个版本快20%,有时则相反。我猜这可能是由于我的系统上运行了其他进程。


因此,当使用不同的工具进行编译并在不同的平台上运行时,它似乎会表现出非常不同的行为。 - Mikhail Churbanov
在你得出那个结论之前,请使用你的编译器尝试我的代码。 - anon
10-11与13之间的差异。程序越复杂,你将看到的++差异就越小。 - Mikhail Churbanov

0
也许您可以通过编写带有x86汇编指令的两个版本来展示理论上的区别?正如许多人之前所指出的,编译器将始终自行决定最佳的编译/组装程序的方式。
如果该示例面向不熟悉x86指令集的学生,则可能考虑使用MIPS32指令集--由于某种奇怪的原因,许多人似乎认为它比x86汇编更容易理解。

-4

好的,所有这些前缀/后缀“优化”只是...一些大误解。

主要的想法是i++返回它的原始副本,因此需要复制该值。

这可能对一些迭代器的低效实现是正确的。然而,在99%的情况下,即使使用STL迭代器,也没有区别,因为编译器知道如何进行优化,实际上迭代器只是看起来像类的指针。当然,对于整数或指针等基本类型也没有区别。

所以...忘了它吧。

编辑:澄清

正如我所提到的,大多数STL迭代器类只是用类包装的指针,具有所有成员函数内联,允许优化这种不相关的复制。

是的,如果您有自己的迭代器没有内联成员函数,那么它可能会运行得更慢。但是,您应该只了解编译器做什么和不做什么。

作为一个小证明,看看这段代码:

int sum1(vector<int> const &v)
{
    int n;
    for(auto x=v.begin();x!=v.end();x++)
            n+=*x;
    return n;
}

int sum2(vector<int> const &v)
{
    int n;
    for(auto x=v.begin();x!=v.end();++x)
            n+=*x;
    return n;
}

int sum3(set<int> const &v)
{
    int n;
    for(auto x=v.begin();x!=v.end();x++)
            n+=*x;
    return n;
}

int sum4(set<int> const &v)
{
    int n;
    for(auto x=v.begin();x!=v.end();++x)
            n+=*x;
    return n;
}

将其编译为汇编并比较sum1和sum2,sum3和sum4...

我只能告诉你...使用-02选项,gcc会给出完全相同的代码。


4
有很多情况下迭代器并不仅仅是指针,编译器也很难对其进行优化。 - jalf
这似乎非常重要,以至于每一本教科书都在指出它的重要性。我正在寻找一个清晰的例子来向一个刚接触编程的人解释这个概念。所以不要告诉我去忘记它。 - cschol
1
我不太明白:"i++返回其原始副本,因此需要复制该值的主要思想。这对于某些迭代器的低效实现可能是正确的。" 这总是正确的。i++应该返回原始值。因为它遵循标准,所以将其称为低效似乎有点奇怪。 - GManNickG
一般来说,由于++i比i++更快,编译器可以将'op(i++)'转换为'op(i); ++i',正如您所指出的那样。现在事实是,每当迭代器不是基本类型(指针)并且具有用户提供的增量运算符时,编译器无法执行该优化,因为它无法了解可能的副作用(包括但不限于后增操作抛出异常,从而永远不执行'op(i)')。现在从性能上讲,差异可能微不足道,不值得重新审视代码,使用前增量而不是后增量作为样式规则是可以的。 - David Rodríguez - dribeas

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