C++中,i++和++i之间是否存在性能差异?

419

1
我重新标记了,因为这两个标签是找到这类问题最简单的方法。我还检查了其他没有连贯标签的问题,并给它们添加了连贯的标签。 - George Stocker
139
在使用C++和++C之间是否存在性能差异? - new123456
3
文章:在使用迭代器时,是否合理使用前缀自增运算符++it而不是后缀运算符it++?- http://www.viva64.com/en/b/0093/ - user965097
1
这取决于处理器。PDP-11具有后增和前减寻址模式,因此i++--i++ii--更有效率。 - user207421
20个回答

499

[简要概述:如果没有特殊原因,请使用++i而不是i++]

C++方面的答案稍微有些复杂。

如果i是一个简单类型(不是C ++类的实例),则与C语言相同,答案是一样的(没有性能差异),因为编译器会生成代码。

但是,如果i是C ++类的一个实例,则i ++++ i都会调用其中一个operator ++函数。下面是这些函数的标准对:

Foo& Foo::operator++()   // called for ++i
{
    this->data += 1;
    return *this;
}

Foo Foo::operator++(int ignored_dummy_value)   // called for i++
{
    Foo tmp(*this);   // variable "tmp" cannot be optimized away by the compiler
    ++(*this);
    return tmp;
}

由于编译器不生成代码,而只是调用operator++函数,因此无法优化tmp变量及其关联的复制构造函数。 如果复制构造函数很昂贵,则这可能会对性能产生重大影响。


4
编译器可以通过另一条评论中提到的NRVO技术,在调用函数中分配临时变量tmp,避免进行第二次拷贝并返回。 - Blaisorblade
11
如果operator++被内联,编译器不是可以完全避免这个问题吗? - Eduard - Gabriel Munteanu
23
如果操作符++被内联并且tmp从未被使用,那么它可以被移除,除非tmp对象的构造函数或析构函数具有副作用。 - Zan Lynx
8
C和C++的区别在于,在C中,您有一个保证运算符将被内联,此时一个合适的优化器将能够消除差异;而在C++中,您不能假设内联 - 并非总是如此。 - Blaisorblade
4
如果答案提到了关于持有指针(包括auto、智能或基础类型)指向动态分配(堆)内存的类,其中复制构造函数必须执行深拷贝,我会点赞。在这种情况下,没有争议,++i可能比i++更有效率约一个数量级。关键是培养使用前置增量的习惯,只要你的算法实际上不需要后置增量语义,你就会养成编写本质上更加高效的代码的习惯,无论你的编译器能否进行优化。 - phonetagger
显示剩余9条评论

73

是的,有这样一种情况。

++运算符可能被定义为函数,也可能不是。对于原始类型(int、double等),这些运算符是内置的,因此编译器可能能够优化您的代码。但是对于定义了++运算符的对象,情况就不同了。

operator++(int)函数必须创建一个副本。这是因为后缀++预期返回与其持有的值不同的值:它必须将其值保存在临时变量中,将其值增加并返回该临时变量。对于operator++(),前缀++,则没有必要创建一个副本:对象可以自己递增,然后简单地返回自身。

以下是说明:

struct C
{
    C& operator++();      // prefix
    C  operator++(int);   // postfix

private:

    int i_;
};

C& C::operator++()
{
    ++i_;
    return *this;   // self, no copy created
}

C C::operator++(int ignored_dummy_value)
{
    C t(*this);
    ++(*this);
    return t;   // return a copy
}

每次调用operator++(int)都必须创建一个副本,并且编译器无法干涉。在选择时,请使用operator++(); 这样您就不需要保存一个副本。这在多个增量(大循环?)和/或大型对象的情况下可能是重要的。


3
前置自增运算符会在代码中引入数据依赖性:CPU 必须等待自增操作完成后,才能在表达式中使用它的值。在深度流水线的 CPU 上,这会导致停滞。而后置自增运算符则没有数据依赖性。因此,如果后置自增的副本计算量不大,仍然可能击败前置自增。 - Matthias
在后缀代码中,这是如何工作的? C t(*this); ++(*this); return t; 在第二行,您正在递增此指针,因此如果您正在递增此指针,那么t如何更新。难道不是将此的值已经复制到t中了吗? - rasen58
The operator++(int) function must create a copy. no, it is not. No more copies than operator++() - Severin Pappadeux

63
这是一个关于不同翻译单元中使用增量运算符的基准测试。使用g++ 4.5编译器进行测试。
暂时忽略样式问题。
// a.cc
#include <ctime>
#include <array>
class Something {
public:
    Something& operator++();
    Something operator++(int);
private:
    std::array<int,PACKET_SIZE> data;
};

int main () {
    Something s;

    for (int i=0; i<1024*1024*30; ++i) ++s; // warm up
    std::clock_t a = clock();
    for (int i=0; i<1024*1024*30; ++i) ++s;
    a = clock() - a;

    for (int i=0; i<1024*1024*30; ++i) s++; // warm up
    std::clock_t b = clock();
    for (int i=0; i<1024*1024*30; ++i) s++;
    b = clock() - b;

    std::cout << "a=" << (a/double(CLOCKS_PER_SEC))
              << ", b=" << (b/double(CLOCKS_PER_SEC)) << '\n';
    return 0;
}

O(n)自增

测试

// b.cc
#include <array>
class Something {
public:
    Something& operator++();
    Something operator++(int);
private:
    std::array<int,PACKET_SIZE> data;
};


Something& Something::operator++()
{
    for (auto it=data.begin(), end=data.end(); it!=end; ++it)
        ++*it;
    return *this;
}

Something Something::operator++(int)
{
    Something ret = *this;
    ++*this;
    return ret;
}

结果

使用g++ 4.5在虚拟机上的结果(计时以秒为单位):

Flags (--std=c++0x)       ++i   i++
-DPACKET_SIZE=50 -O1      1.70  2.39
-DPACKET_SIZE=50 -O3      0.59  1.00
-DPACKET_SIZE=500 -O1    10.51 13.28
-DPACKET_SIZE=500 -O3     4.28  6.82

O(1)增量

测试

现在让我们看下面的文件:

// c.cc
#include <array>
class Something {
public:
    Something& operator++();
    Something operator++(int);
private:
    std::array<int,PACKET_SIZE> data;
};


Something& Something::operator++()
{
    return *this;
}

Something Something::operator++(int)
{
    Something ret = *this;
    ++*this;
    return ret;
}

在递增时它不做任何事情。这模拟了递增具有恒定复杂度的情况。

结果

现在结果变化极大:

Flags (--std=c++0x)       ++i   i++
-DPACKET_SIZE=50 -O1      0.05   0.74
-DPACKET_SIZE=50 -O3      0.08   0.97
-DPACKET_SIZE=500 -O1     0.05   2.79
-DPACKET_SIZE=500 -O3     0.08   2.18
-DPACKET_SIZE=5000 -O3    0.07  21.90

结论

性能方面

如果您不需要先前的值,请养成使用前缀递增的习惯。即使是内置类型,也要保持一致,这样您就会习惯它,并且在将来用自定义类型替换内置类型时不会遭受不必要的性能损失。

语义方面

  • i++ 表示 增加 i,我对先前的值感兴趣
  • ++i 表示 增加 i,我对当前值感兴趣 或者 增加 i,我不关心先前的值。无论如何,您都会习惯它,即使现在还没有。

Knuth.

过早优化是万恶之源。同样,过早悲观也是如此。


1
有趣的测试。现在,将近两年半之后,gcc 4.9和Clang 3.4显示出类似的趋势。Clang在两者中稍微快一些,但前缀和后缀之间的差距比gcc更糟糕。 - chew socks
1
我真正想看到的是一个现实世界的例子,展示++i / i++有何不同。例如,在std迭代器中是否有区别? - Jakob Schou Jensen
1
@JakobSchouJensen:这些都是真实世界的例子。考虑一个大型应用程序,具有复杂的树形结构(例如kd树、四叉树)或在表达式模板中使用的大容器(为了最大化SIMD硬件上的数据吞吐量)。如果在特定情况下不需要后增语义,我不确定为什么会回退到后增。 - Sebastian Mach
@phresnel:我认为operator++不是你每天都会用到的表达式模板——你有实际的例子吗?operator++的典型用法是在整数和迭代器上。这就是我认为如果有任何区别,那么在这些地方知道它是否有任何区别会很有趣(当然,在整数上没有区别,但在迭代器上可能有)。 - Jakob Schou Jensen
@JakobSchouJensen:没有实际的业务例子,但有一些数字计算应用程序,其中你需要计数。关于迭代器,考虑一个以惯用C++风格编写的光线跟踪器,你需要一个深度优先遍历的迭代器,例如 for (it=nearest(ray.origin); it!=end(); ++it) { if (auto i = intersect(ray, *it)) return i; },不要在意实际的树结构(BSP、kd、Quadtree、Octree Grid等)。这样的迭代器需要维护一些状态,例如“父节点”、“子节点”、“索引”等等。总的来说,我的立场是,即使只有很少的例子存在,... - Sebastian Mach
后增量,如果你是这个意思,就使用前增量(但我相信你不会质疑这个 :)) - Sebastian Mach

25

Google C++ Style Guide中提到:

前置自增和前置自减

在处理迭代器和其他模板对象时,请使用前缀形式(++i)的增量和减量运算符。

定义:当一个变量被自增(++i或i++)或自减(--i或i--)且表达式的值未被使用时,需要决定是使用前置自增(减)还是后置自增(减)。

优点:当返回值被忽略时,“前置”形式(++i)永远不会比“后置”形式(i++)效率低,并且通常更高效。这是因为后置自增(或减)需要复制i的值作为表达式的值。如果i是迭代器或其他非标量类型,则复制i可能很昂贵。由于这两种类型的自增操作在值被忽略时的行为相同,为什么不总是使用前置自增呢?

缺点:在C语言中,使用后置自增在表达式的值未被使用时已经成为了一种传统,特别是在for循环中。有些人认为后置自增更容易阅读,因为“主语”(i)在“动词”(++)之前,正如英语中的一样。

决策:对于简单标量(非对象)值,没有理由偏好其中一种形式,我们允许使用任何一种。对于迭代器和其他模板类型,请使用前置自增。


1
决策:对于简单的标量(非对象)值,没有理由偏好一种形式,我们允许任何一种。对于迭代器和其他模板类型,请使用前置递增。 - Nosredna
2
嗯,...那是什么东西? - Sebastian Mach
答案中提到的链接目前已经失效。 - karol

22

不能完全地说编译器无法在后缀情况下优化掉临时变量的复制。使用 VC 做一个快速测试可以看到,在某些情况下它至少可以这样做。

比如下面的例子,产生的代码对于前缀和后缀来说是相同的:

#include <stdio.h>

class Foo
{
public:

    Foo() { myData=0; }
    Foo(const Foo &rhs) { myData=rhs.myData; }

    const Foo& operator++()
    {
        this->myData++;
        return *this;
    }

    const Foo operator++(int)
    {
        Foo tmp(*this);
        this->myData++;
        return tmp;
    }

    int GetData() { return myData; }

private:

    int myData;
};

int main(int argc, char* argv[])
{
    Foo testFoo;

    int count;
    printf("Enter loop count: ");
    scanf("%d", &count);

    for(int i=0; i<count; i++)
    {
        testFoo++;
    }

    printf("Value: %d\n", testFoo.GetData());
}

无论你使用 ++testFoo 还是 testFoo++,最终的代码还是一样的。实际上,在不从用户读取计数的情况下,优化器将整个过程缩减为了一个常量。因此,下面的代码:

for(int i=0; i<10; i++)
{
    testFoo++;
}

printf("Value: %d\n", testFoo.GetData());

导致以下结果:

00401000  push        0Ah  
00401002  push        offset string "Value: %d\n" (402104h) 
00401007  call        dword ptr [__imp__printf (4020A0h)] 

因此,尽管后缀版本可能会更慢,但如果您不使用它,优化器很可能足够好以消除临时副本。


8
你忘记记录重要的一点,这里所有内容都是内联的。如果操作符的定义不可用,则无法避免在非内联代码中进行复制;通过内联,优化显而易见,所以任何编译器都会执行它。 - Blaisorblade

7
  1. ++i - 更快,不使用返回值
  2. i++ - 更快,使用返回值

如果不使用返回值,则编译器保证在++i的情况下不使用临时变量。不能保证更快,但保证不会更慢。

如果使用返回值,则i++允许处理器将增量和左侧推入流水线,因为它们彼此不依赖。++i 可能会导致流水线停顿,因为处理器在预增操作完全完成之前无法启动左侧。同样,流水线停顿并不是保证发生,因为处理器可能会找到其他有用的东西可供使用。


6
我想指出Andrew Koenig最近在Code Talk上发布的一篇优秀文章。

http://dobbscodetalk.com/index.php?option=com_myblog&show=Efficiency-versus-intent.html&Itemid=29

在我们公司,为了保持一致性和性能,我们也使用++iter的惯例,但是Andrew提出了关于意图与性能之间被忽视的细节。有时候我们想要使用iter++而不是++iter。

因此,首先确定您的意图,如果前置或后置不重要,则选择前置,因为它将通过避免创建额外的对象并抛出它来获得一些性能优势。


更新的链接:http://www.drdobbs.com/architecture-and-design/efficiency-versus-intent/228700184 - Ofek Shilon

5

@Ketan

...提出了关于意图与性能的被忽视的细节。有时候我们想要使用iter++而不是++iter。

显然,后缀和前缀递增具有不同的语义,我相信每个人都同意当结果被使用时应该使用适当的运算符。我认为问题在于当结果被丢弃时应该怎么做(例如在for循环中)。对于这个问题的答案(在我看来)是,由于性能考虑最多可以忽略不计,因此您应该做更自然的事情。对于我来说,++i更自然,但我的经验告诉我,我是少数派,使用i++将导致大多数人阅读您的代码时产生较少的心理负担。

毕竟这就是语言没有被称为"++C"的原因。[*]

[*] 插入关于++C是更合乎逻辑的名称的义务讨论。


4
(开玩笑)如果你记得Bjarne Stroustrup最初将C++编码为生成C程序的预编译器,那么C++这个名称就很合理了。因此,C++返回一个旧的C值。或者这可能是为了增强C++从一开始就有一些概念上的缺陷。 - kriss

4
当你把运算符看作是返回值函数及其实现时,++ii++ 之间的性能差异将更加明显。为了更好地理解发生了什么,以下代码示例将使用 int 来表示struct++i 会先增加变量,然后返回结果。在许多情况下,这可以在原地完成,并且需要极少的 CPU 时间,只需要一行代码即可。
int& int::operator++() { 
     return *this += 1;
}

但对于i++来说就不同了。

后增量运算符i++通常被视为在增加之前返回原始值。然而,函数只有在完成时才能返回结果。因此,需要创建一个包含原始值的变量副本,增加该变量,然后返回保存原始值的副本:

int int::operator++(int& _Val) {
    int _Original = _Val;
    _Val += 1;
    return _Original;
}

当使用前缀自增和后缀自增没有功能差异时,编译器可以进行优化,使得两者之间没有性能差异。但是,如果涉及到复合数据类型,例如structclass,则在后缀自增时会调用复制构造函数,如果需要进行深度复制,则无法执行此优化。因此,前缀自增通常比后缀自增更快,并且需要更少的内存。


3

Mark: 只是想指出operator++是很好的内联候选函数,如果编译器选择这样做,在大多数情况下冗余复制将被消除。(例如POD类型,通常是迭代器。)

话虽如此,在大多数情况下使用++iter仍然是更好的风格。 :-)


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