关于c ++:for循环中pIter!= cont.end()的性能

Performance of pIter != cont.end() in for loop

我最近通过Habor萨特经历了"例外C++",我对他在第6项-临时对象中给出的一个特别的建议有严重的怀疑。

他提出在以下代码中查找不必要的临时对象:

1
2
3
4
5
6
7
8
9
10
11
string FindAddr(list<Employee> emps, string name)
{
  for (list<Employee>::iterator i = emps.begin(); i != emps.end(); i++)
  {
    if( *i == name )
    {
      return i->addr;
    }
  }
  return"";
}

作为示例之一,他建议在循环之前预先计算EDOCX1的值(0),因为每次迭代都会创建一个临时对象:

For most containers (including list), calling end() returns a
temporary object that must be constructed and destroyed. Because the
value will not change, recomputing (and reconstructing and
redestroying) it on every loop iteration is both needlessly
inefficient and unaesthetic. The value should be computed only once,
stored in a local object, and reused.

他建议替换为以下内容:

1
2
list<Employee>::const_iterator end(emps.end());
for (list<Employee>::const_iterator i = emps.begin(); i != end; ++i)

对我来说,这是不必要的复杂。即使用紧凑的auto替换丑陋的类型声明,他仍然得到两行代码而不是一行。更重要的是,他在外部范围内有这个end变量。

我确信现代编译器无论如何都会优化这段代码,因为我在这里实际使用的是const_iterator,并且很容易检查循环内容是否以某种方式访问容器。编译器在过去的13年里变得更聪明了,对吧?

不管怎样,在大多数情况下,我更喜欢使用i != emps.end()的第一个版本,因为我不太担心性能。但我想确定一下,这是否是一种我可以依靠编译器来优化的结构?

更新

感谢您对如何改进这个无用代码的建议。请注意,我的问题是关于编译器,而不是编程技术。目前唯一相关的答案是NPE和Ellioh。


除非我弄错了,否则你所说的那本书已经在1999年出版了。那是14年前的事了,在现代编程中,14年是很多时间。1999年提出的许多好的、可靠的建议现在可能已经完全过时了。虽然我的答案是关于一个编译器和一个平台,但还有一个更一般的想法。

关注额外的变量,重用旧C++的琐碎方法的返回值和类似的技巧,是90年代C++的一个新步骤。像end()那样的琐碎方法应该被内衬得很好,内联的结果应该被优化为它所调用的代码的一部分。99%的情况根本不需要手动操作,例如创建end变量。只有在以下情况下才应进行此类工作:

  • 您知道,在一些编译器/平台上,您应该在代码上运行的代码没有得到很好的优化。
  • 它已经成为您程序中的瓶颈("避免过早优化")。
  • 我看过64位G++生成的内容:

    1
    gcc version 4.6.3 20120918 (prerelease) (Ubuntu/Linaro 4.6.3-10ubuntu1)

    最初,我认为对它进行优化应该是可以的,并且两个版本之间应该没有区别。但看起来有些奇怪:你认为非最优的版本实际上更好。我认为,道德是:没有理由尝试比编译器更聪明。让我们看看这两个版本。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    #include <list>

    using namespace std;

    int main() {
      list<char> l;
      l.push_back('a');

      for(list<char>::iterator i=l.begin(); i != l.end(); i++)
          ;

      return 0;
    }

    int main1() {
      list<char> l;
      l.push_back('a');
      list<char>::iterator e=l.end();
      for(list<char>::iterator i=l.begin(); i != e; i++)
          ;

      return 0;
    }

    然后我们应该通过优化(我使用64位g++,您可以试用编译器)来编译它,然后分解mainmain1

    对于main

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    (gdb) disas main
    Dump of assembler code for function main():
       0x0000000000400650 <+0>: push   %rbx
       0x0000000000400651 <+1>: mov    $0x18,%edi
       0x0000000000400656 <+6>: sub    $0x20,%rsp
       0x000000000040065a <+10>:    lea    0x10(%rsp),%rbx
       0x000000000040065f <+15>:    mov    %rbx,0x10(%rsp)
       0x0000000000400664 <+20>:    mov    %rbx,0x18(%rsp)
       0x0000000000400669 <+25>:    callq  0x400630 <_Znwm@plt>
       0x000000000040066e <+30>:    cmp    $0xfffffffffffffff0,%rax
       0x0000000000400672 <+34>:    je     0x400678 <main()+40>
       0x0000000000400674 <+36>:    movb   $0x61,0x10(%rax)
       0x0000000000400678 <+40>:    mov    %rax,%rdi
       0x000000000040067b <+43>:    mov    %rbx,%rsi
       0x000000000040067e <+46>:    callq  0x400610 <_ZNSt8__detail15_List_node_base7_M_hookEPS0_@plt>
       0x0000000000400683 <+51>:    mov    0x10(%rsp),%rax
       0x0000000000400688 <+56>:    cmp    %rbx,%rax
       0x000000000040068b <+59>:    je     0x400698 <main()+72>
       0x000000000040068d <+61>:    nopl   (%rax)
       0x0000000000400690 <+64>:    mov    (%rax),%rax
       0x0000000000400693 <+67>:    cmp    %rbx,%rax
       0x0000000000400696 <+70>:    jne    0x400690 <main()+64>
       0x0000000000400698 <+72>:    mov    %rbx,%rdi
       0x000000000040069b <+75>:    callq  0x400840 <std::list<char, std::allocator<char> >::~list()>
       0x00000000004006a0 <+80>:    add    $0x20,%rsp
       0x00000000004006a4 <+84>:    xor    %eax,%eax
       0x00000000004006a6 <+86>:    pop    %rbx
       0x00000000004006a7 <+87>:    retq

    查看位于0x00000000400683-0x000000000000040068B的命令。这是循环体,似乎得到了完美的优化:

    1
    2
    3
       0x0000000000400690 <+64>:    mov    (%rax),%rax
       0x0000000000400693 <+67>:    cmp    %rbx,%rax
       0x0000000000400696 <+70>:    jne    0x400690 <main()+64>

    对于main1

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    (gdb) disas main1
    Dump of assembler code for function main1():
       0x00000000004007b0 <+0>: push   %rbp
       0x00000000004007b1 <+1>: mov    $0x18,%edi
       0x00000000004007b6 <+6>: push   %rbx
       0x00000000004007b7 <+7>: sub    $0x18,%rsp
       0x00000000004007bb <+11>:    mov    %rsp,%rbx
       0x00000000004007be <+14>:    mov    %rsp,(%rsp)
       0x00000000004007c2 <+18>:    mov    %rsp,0x8(%rsp)
       0x00000000004007c7 <+23>:    callq  0x400630 <_Znwm@plt>
       0x00000000004007cc <+28>:    cmp    $0xfffffffffffffff0,%rax
       0x00000000004007d0 <+32>:    je     0x4007d6 <main1()+38>
       0x00000000004007d2 <+34>:    movb   $0x61,0x10(%rax)
       0x00000000004007d6 <+38>:    mov    %rax,%rdi
       0x00000000004007d9 <+41>:    mov    %rsp,%rsi
       0x00000000004007dc <+44>:    callq  0x400610 <_ZNSt8__detail15_List_node_base7_M_hookEPS0_@plt>
       0x00000000004007e1 <+49>:    mov    (%rsp),%rdi
       0x00000000004007e5 <+53>:    cmp    %rbx,%rdi
       0x00000000004007e8 <+56>:    je     0x400818 <main1()+104>
       0x00000000004007ea <+58>:    mov    %rdi,%rax
       0x00000000004007ed <+61>:    nopl   (%rax)
       0x00000000004007f0 <+64>:    mov    (%rax),%rax
       0x00000000004007f3 <+67>:    cmp    %rbx,%rax
       0x00000000004007f6 <+70>:    jne    0x4007f0 <main1()+64>
       0x00000000004007f8 <+72>:    mov    (%rdi),%rbp
       0x00000000004007fb <+75>:    callq  0x4005f0 <_ZdlPv@plt>
       0x0000000000400800 <+80>:    cmp    %rbx,%rbp
       0x0000000000400803 <+83>:    je     0x400818 <main1()+104>
       0x0000000000400805 <+85>:    nopl   (%rax)
       0x0000000000400808 <+88>:    mov    %rbp,%rdi
       0x000000000040080b <+91>:    mov    (%rdi),%rbp
       0x000000000040080e <+94>:    callq  0x4005f0 <_ZdlPv@plt>
       0x0000000000400813 <+99>:    cmp    %rbx,%rbp
       0x0000000000400816 <+102>:   jne    0x400808 <main1()+88>
       0x0000000000400818 <+104>:   add    $0x18,%rsp
       0x000000000040081c <+108>:   xor    %eax,%eax
       0x000000000040081e <+110>:   pop    %rbx
       0x000000000040081f <+111>:   pop    %rbp
       0x0000000000400820 <+112>:   retq

    循环的代码类似,它是:

    1
    2
    3
       0x00000000004007f0 <+64>:    mov    (%rax),%rax
       0x00000000004007f3 <+67>:    cmp    %rbx,%rax
       0x00000000004007f6 <+70>:    jne    0x4007f0 <main1()+64>

    但是在这个循环中有很多额外的东西。显然,额外的代码使事情变得更糟。


    我已经使用g++ 4.7.2-O3 -std=c++11编译了以下稍微有点粗糙的代码,并为这两个函数获得了相同的程序集:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    #include <list>
    #include <string>

    using namespace std;

    struct Employee: public string { string addr; };

    string FindAddr1(list<Employee> emps, string name)
    {
      for (list<Employee>::const_iterator i = emps.begin(); i != emps.end(); i++)
      {
        if( *i == name )
        {
          return i->addr;
        }
      }
      return"";
    }

    string FindAddr2(list<Employee> emps, string name)
    {
      list<Employee>::const_iterator end(emps.end());
      for (list<Employee>::const_iterator i = emps.begin(); i != end; i++)
      {
        if( *i == name )
        {
          return i->addr;
        }
      }
      return"";
    }

    无论如何,我认为两个版本之间的选择应该主要基于可读性。如果没有分析数据,像这样的微观优化在我看来还为时过早。


    与流行的观点相反,在这方面,我看不出VC++和GCC之间有什么区别。我快速检查了G+4.7.2和MS C++ 17(AKA VC++ 2012)。

    在这两种情况下,我将生成的代码与问题中的代码(添加了头等以便编译)与以下代码进行了比较:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    string FindAddr(list<Employee> emps, string name)
    {
        auto end = emps.end();
        for (list<Employee>::iterator i = emps.begin(); i != end; i++)
        {
            if( *i == name )
            {
                return i->addr;
            }
        }
        return"";
    }

    在这两种情况下,两段代码的结果基本相同。vc++在代码中包含行号注释,这些注释因额外的行而更改,但这是唯一的区别。使用G++时,输出文件是相同的。

    std::vector代替std::list,得到了几乎相同的结果——没有显著差异。出于某种原因,G++确实将一条指令的操作数顺序从cmp esi, DWORD PTR [eax+4]切换到cmp DWORD PTR [eax+4], esi,但(再次)这是完全不相关的。

    底线:不,你不太可能从用现代编译器手工提升代码的循环中得到任何东西(至少在启用了优化的情况下——我用的是带VC++的/O2b2,用的是带G++的/O3;把优化和关闭的优化进行比较对我来说似乎没有什么意义。


    一些事情…首先,一般来说,构建迭代器(在发布模式下,未检查的分配器)的成本是最小的。它们通常是指针周围的包装纸。对于选中的分配器(在vs中是默认的),您可能会有一些开销,但是如果您确实需要性能,在用未选中的分配器测试重建之后。

    代码不必像你发布的那样难看:

    1
    2
    for (list<Employee>::const_iterator it=emps.begin(), end=emps.end();
                                        it != end; ++it )

    关于您是否要使用其中一种方法或其他方法的主要决定应该是在应用于容器的操作方面。如果容器可能正在改变其大小,那么您可能需要在每次迭代中重新计算end迭代器。如果没有,您可以只预计算一次,然后像上面的代码一样重用。


    如果您真的需要性能,您可以让您闪亮的新C++ 11编译器为您编写:

    1
    2
    3
    for (const auto &i : emps) {
        /* ... */
    }

    是的,这是开玩笑。Herb的例子现在已经过时了。但是由于您的编译器还不支持它,让我们开始真正的问题:

    Is this a kind of construction I could rely on a compiler to optimize?

    我的经验法则是编译器作者比我聪明得多。我不能依赖编译器来优化任何一段代码,因为它可能会选择优化其他影响更大的代码。唯一确定的方法是在系统上的编译器上尝试这两种方法,看看会发生什么。检查探查器结果。如果对.end()的调用仍然存在,请将其保存在单独的变量中。否则,别担心。


    类似vector的容器返回变量,该变量在end()调用上存储指向结束的指针,并进行了优化。如果您在end()调用上编写了进行查找等的容器,请考虑编写

    1
    2
    3
    4
    for (list<Employee>::const_iterator i = emps.begin(), end = emps.end(); i != end; ++i)
    {
    ...
    }

    为了速度


    使用std算法

    他当然是对的,调用end可以实例化并销毁一个临时对象,这通常是不好的。

    当然,编译器可以在很多情况下对此进行优化。

    有一个更好、更强大的解决方案:封装循环。

    您给出的示例实际上是std::find,给出或获取返回值。许多其他的循环也有std算法,或者至少有类似的东西,你可以适应-例如,我的实用程序库有一个transform_if实现。

    所以,在函数中隐藏循环,并将一个const&取为end。与您的示例相同,但更干净。