このアーティクルでは前置と後置、2種類のインクリメントについて考察と現状をまとめるものです。
長年の常識
従来(~2015)は前置インクリメント(++value)と後置インクリメント(value++)の挙動の違いを正しく完全に理解した上で前置インクリメントを使うべきだ。というのがC++プログラマの基本姿勢でした。
各インクリメントの標準的な実装を以下に示します。
1 2 3 4 5 6 7 8 9 10 11 12 |
// 前置インクリメント T& T::operator++() { // ※ここでなんらかのインクリメント処理 return *this; // 自身の参照を返す } // 後置インクリメント T T::operator++(int) { T old(*this); // インクリメント前にコピーした後 ++*this; // 前置インクリメントを呼ぶ return old; // コピーしたものを返す } |
後置インクリメントにはひと目で遅くなりそうな処理が見て取れますね。
前置インクリメントがインクリメント処理後、単純に自身の参照を返すのに対し、後置インクリメントではインクリメント前に一時オブジェクトの生成、そしてインクリメント後にはその前に生成した一時オブジェクトを値で返しています。
前置と後置では、単純にオブジェクトをコピーして返す分、普通に考えたら後置の方が遅いよね。というのが従来の認識でした。
「C++ Coding Standards -101のルール、ガイドライン、ベストプラクティス」の中でも、特に後置インクリメントの必然性が無い時は迷わず前置インクリメントを使うことが推奨されてきました。
元の値を必要としないときは前置形式の演算子を使おう __C++ Coding Standards (p50)
新たな主張
「ゲームエンジン・アーキテクチャ第二版」の中の一節を紹介します。
しかし、値が使われる場合、CPUのパイプラインでストールを生じさせないので、ポストインクリメントの方が優秀である。したがって、プレインクリメントの動作が絶対に必要である場合を除いて、必ずポストインクリメントを使う習慣を身につけたほうがよい。 __ゲームエンジンアーキテクチャ第二版 プレインクリメント vs ポストインクリメント
従来の主張と真逆の主張が展開されたのです。後置インクリメントの方が優秀なので出来る限りそちらを使うべきとされました。
ゲームエンジン・アーキテクチャの著者は、UnchartedやThe Last of Usの開発元であるノーティドッグ社のエンジニアです。その彼が後置インクリメントを使うべきと主張したことで大きな話題となりました。
後置インクリメントが優秀とされる理由は以下のとおりです。
- 前置インクリメントは値を書き換えて戻すのでインクリメント処理が終了するまで戻す値が決まらない
- それはデータ依存性を生み、深いパイプラインのCPUではストールを発生させる
- 後置インクリメントはインクリメント処理と戻す値は別インスタンスなので並列に処理が可能
- よってストールの発生が無い分、後置インクリメントが優秀
なるほど、といった感じです。
考察を深めよう
今後は後置インクリメントに統一だ!今まで書いた前置インクリメントを全部置換しなくちゃ!と、急ぐ前に、本当にゲームエンジン・アーキテクチャの主張が正しいかは一考の余地があります。
深いパイプラインにおけるストールの可能性はデータ依存性があるかぎり発生しうる問題です。
この部分の主張は正しいです。
しかし、ゲームエンジン・アーキテクチャでは後置インクリメントに発生していたコピーコストについて言及がありません。
文脈を読み解く限り、「コピーコスト<ストールコスト」という前提があるようです。このインクリメントの項以外でもゲームエンジン・アーキテクチャではストールに対する嫌悪を書いてある箇所があり、著者はかなりストールコストに対してナイーブになっているように感じます。
一時オブジェクトの生成に伴うコストについて、好意的に解釈してみましょう。
例で示した実装の場合、後置インクリメントで変更前の値を返す際にRVOの最適化が期待されるので、前置インクリメントも後置インクリメントも(このインクリメントの結果として使う時の)オブジェクトの生成は1度きりになる可能性があります。厳密には直前のテンポラリオブジェクトが(oldという)名前付きオブジェクトなので、この最適化を期待するためにはコンパイラがNRVO(Named Return Value Optimization)に対応している必要があります。それでもほとんどのコンパイラはNRVOが効くのでコピーコストは低くなる可能性があります。
なお手元のmsvc(Visual Studio 2013 Professional)ではNRVOに対応していませんでした……。
ちゃんとNRVOが効くコンパイラ(今回はgcc)を使って実際のアセンブラコードを見てみましょう。
ユーザー定義の前置インクリメントと後置インクリメントを用意します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class Hoge { public: Hoge(int c) : a_(c) , b_(c) {} Hoge& operator ++() { a_ += 1; b_ += 1; return *this; } Hoge operator++(int) { Hoge old(*this); ++*this; return old; } int a_; int b_; }; |
前置インクリメント、後置インクリメントそれぞれの処理を呼び出す処理を書きます。
pre.cpp
1 2 3 4 5 6 7 8 9 |
#include <cstdio> // 前置インクリメントを呼び出すバージョン int main(int argc, char *argv[]) { Hoge h(argc); ++h; // ここで呼ぶ std::printf("%d", h.a_); return 0; } |
post.cpp
1 2 3 4 5 6 7 8 9 |
#include <cstdio> // 後置インクリメントを呼び出すバージョン int main(int argc, char *argv[]) { Hoge h(argc); h++; // ここで呼ぶ std::printf("%d", h.a_); return 0; } |
これら2つの処理のアセンブラコードを出力します。
1 |
g++-4 *.cpp -S -O0 |
アセンブラコードを読むのが嫌いな人は行数だけ見ればいいです。
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 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 |
.file "pre.cpp" .section .text$_ZN4HogeC1Ei,"x" .linkonce discard .align 2 .globl __ZN4HogeC1Ei .def __ZN4HogeC1Ei; .scl 2; .type 32; .endef __ZN4HogeC1Ei: LFB9: pushl %ebp LCFI0: movl %esp, %ebp LCFI1: movl 8(%ebp), %eax movl 12(%ebp), %edx movl %edx, (%eax) movl 8(%ebp), %eax movl 12(%ebp), %edx movl %edx, 4(%eax) popl %ebp LCFI2: ret LFE9: .section .text$_ZN4HogeppEv,"x" .linkonce discard .align 2 .globl __ZN4HogeppEv .def __ZN4HogeppEv; .scl 2; .type 32; .endef __ZN4HogeppEv: LFB10: pushl %ebp LCFI3: movl %esp, %ebp LCFI4: movl 8(%ebp), %eax movl (%eax), %eax leal 1(%eax), %edx movl 8(%ebp), %eax movl %edx, (%eax) movl 8(%ebp), %eax movl 4(%eax), %eax leal 1(%eax), %edx movl 8(%ebp), %eax movl %edx, 4(%eax) movl 8(%ebp), %eax popl %ebp LCFI5: ret LFE10: .def ___main; .scl 2; .type 32; .endef .section .rdata,"dr" LC0: .ascii "%d\0" .text .globl _main .def _main; .scl 2; .type 32; .endef _main: LFB12: pushl %ebp LCFI6: movl %esp, %ebp LCFI7: andl $-16, %esp LCFI8: subl $32, %esp LCFI9: call ___main movl 8(%ebp), %eax movl %eax, 4(%esp) leal 24(%esp), %eax movl %eax, (%esp) call __ZN4HogeC1Ei leal 24(%esp), %eax movl %eax, (%esp) call __ZN4HogeppEv movl 24(%esp), %eax movl %eax, 4(%esp) movl $LC0, (%esp) call _printf movl $0, %eax leave LCFI10: ret LFE12: .section .eh_frame,"w" Lframe1: .long LECIE1-LSCIE1 LSCIE1: .long 0x0 .byte 0x1 .ascii "\0" .uleb128 0x1 .sleb128 -4 .byte 0x8 .byte 0xc .uleb128 0x4 .uleb128 0x4 .byte 0x88 .uleb128 0x1 .align 4 LECIE1: LSFDE1: .long LEFDE1-LASFDE1 LASFDE1: .long LASFDE1-Lframe1 .long LFB12 .long LFE12-LFB12 .byte 0x4 .long LCFI6-LFB12 .byte 0xe .uleb128 0x8 .byte 0x85 .uleb128 0x2 .byte 0x4 .long LCFI7-LCFI6 .byte 0xd .uleb128 0x5 .byte 0x4 .long LCFI10-LCFI7 .byte 0xc5 .byte 0xc .uleb128 0x4 .uleb128 0x4 .align 4 LEFDE1: .def _printf; .scl 2; .type 32; .endef |
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 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 |
.file "post.cpp" .section .text$_ZN4HogeC1Ei,"x" .linkonce discard .align 2 .globl __ZN4HogeC1Ei .def __ZN4HogeC1Ei; .scl 2; .type 32; .endef __ZN4HogeC1Ei: LFB9: pushl %ebp LCFI0: movl %esp, %ebp LCFI1: movl 8(%ebp), %eax movl 12(%ebp), %edx movl %edx, (%eax) movl 8(%ebp), %eax movl 12(%ebp), %edx movl %edx, 4(%eax) popl %ebp LCFI2: ret LFE9: .section .text$_ZN4HogeppEv,"x" .linkonce discard .align 2 .globl __ZN4HogeppEv .def __ZN4HogeppEv; .scl 2; .type 32; .endef __ZN4HogeppEv: LFB10: pushl %ebp LCFI3: movl %esp, %ebp LCFI4: movl 8(%ebp), %eax movl (%eax), %eax leal 1(%eax), %edx movl 8(%ebp), %eax movl %edx, (%eax) movl 8(%ebp), %eax movl 4(%eax), %eax leal 1(%eax), %edx movl 8(%ebp), %eax movl %edx, 4(%eax) movl 8(%ebp), %eax popl %ebp LCFI5: ret LFE10: .section .text$_ZN4HogeppEi,"x" .linkonce discard .align 2 .globl __ZN4HogeppEi .def __ZN4HogeppEi; .scl 2; .type 32; .endef __ZN4HogeppEi: LFB11: pushl %ebp LCFI6: movl %esp, %ebp LCFI7: subl $20, %esp LCFI8: movl 8(%ebp), %eax movl 4(%eax), %edx movl (%eax), %eax movl %eax, -8(%ebp) movl %edx, -4(%ebp) movl 8(%ebp), %eax movl %eax, (%esp) call __ZN4HogeppEv movl -8(%ebp), %eax movl -4(%ebp), %edx leave LCFI9: ret LFE11: .def ___main; .scl 2; .type 32; .endef .section .rdata,"dr" LC0: .ascii "%d\0" .text .globl _main .def _main; .scl 2; .type 32; .endef _main: LFB12: pushl %ebp LCFI10: movl %esp, %ebp LCFI11: andl $-16, %esp LCFI12: subl $32, %esp LCFI13: call ___main movl 8(%ebp), %eax movl %eax, 4(%esp) leal 24(%esp), %eax movl %eax, (%esp) call __ZN4HogeC1Ei movl $0, 4(%esp) leal 24(%esp), %eax movl %eax, (%esp) call __ZN4HogeppEi movl 24(%esp), %eax movl %eax, 4(%esp) movl $LC0, (%esp) call _printf movl $0, %eax leave LCFI14: ret LFE12: .section .eh_frame,"w" Lframe1: .long LECIE1-LSCIE1 LSCIE1: .long 0x0 .byte 0x1 .ascii "\0" .uleb128 0x1 .sleb128 -4 .byte 0x8 .byte 0xc .uleb128 0x4 .uleb128 0x4 .byte 0x88 .uleb128 0x1 .align 4 LECIE1: LSFDE1: .long LEFDE1-LASFDE1 LASFDE1: .long LASFDE1-Lframe1 .long LFB12 .long LFE12-LFB12 .byte 0x4 .long LCFI10-LFB12 .byte 0xe .uleb128 0x8 .byte 0x85 .uleb128 0x2 .byte 0x4 .long LCFI11-LCFI10 .byte 0xd .uleb128 0x5 .byte 0x4 .long LCFI14-LCFI11 .byte 0xc5 .byte 0xc .uleb128 0x4 .uleb128 0x4 .align 4 LEFDE1: .def _printf; .scl 2; .type 32; .endef |
やっぱり後置インクリメントの方が遅いですね。
では次はコンパイラの最適化オプションをバッチリ有効にしたバージョンで見てみましょう。
1 |
g++ *.cpp -S -O3 |
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 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
.file "pre.cpp" .def ___main; .scl 2; .type 32; .endef .section .rdata,"dr" LC0: .ascii "%d\0" .text .p2align 4,,15 .globl _main .def _main; .scl 2; .type 32; .endef _main: LFB12: pushl %ebp LCFI0: movl %esp, %ebp LCFI1: andl $-16, %esp LCFI2: subl $16, %esp LCFI3: call ___main movl 8(%ebp), %eax movl $LC0, (%esp) addl $1, %eax movl %eax, 4(%esp) call _printf xorl %eax, %eax leave LCFI4: ret LFE12: .section .eh_frame,"w" Lframe1: .long LECIE1-LSCIE1 LSCIE1: .long 0x0 .byte 0x1 .ascii "\0" .uleb128 0x1 .sleb128 -4 .byte 0x8 .byte 0xc .uleb128 0x4 .uleb128 0x4 .byte 0x88 .uleb128 0x1 .align 4 LECIE1: LSFDE1: .long LEFDE1-LASFDE1 LASFDE1: .long LASFDE1-Lframe1 .long LFB12 .long LFE12-LFB12 .byte 0x4 .long LCFI0-LFB12 .byte 0xe .uleb128 0x8 .byte 0x85 .uleb128 0x2 .byte 0x4 .long LCFI1-LCFI0 .byte 0xd .uleb128 0x5 .byte 0x4 .long LCFI4-LCFI1 .byte 0xc5 .byte 0xc .uleb128 0x4 .uleb128 0x4 .align 4 LEFDE1: .def _printf; .scl 2; .type 32; .endef |
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 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
.file "post.cpp" .def ___main; .scl 2; .type 32; .endef .section .rdata,"dr" LC0: .ascii "%d\0" .text .p2align 4,,15 .globl _main .def _main; .scl 2; .type 32; .endef _main: LFB12: pushl %ebp LCFI0: movl %esp, %ebp LCFI1: andl $-16, %esp LCFI2: subl $16, %esp LCFI3: call ___main movl 8(%ebp), %eax movl $LC0, (%esp) addl $1, %eax movl %eax, 4(%esp) call _printf xorl %eax, %eax leave LCFI4: ret LFE12: .section .eh_frame,"w" Lframe1: .long LECIE1-LSCIE1 LSCIE1: .long 0x0 .byte 0x1 .ascii "\0" .uleb128 0x1 .sleb128 -4 .byte 0x8 .byte 0xc .uleb128 0x4 .uleb128 0x4 .byte 0x88 .uleb128 0x1 .align 4 LECIE1: LSFDE1: .long LEFDE1-LASFDE1 LASFDE1: .long LASFDE1-Lframe1 .long LFB12 .long LFE12-LFB12 .byte 0x4 .long LCFI0-LFB12 .byte 0xe .uleb128 0x8 .byte 0x85 .uleb128 0x2 .byte 0x4 .long LCFI1-LCFI0 .byte 0xd .uleb128 0x5 .byte 0x4 .long LCFI4-LCFI1 .byte 0xc5 .byte 0xc .uleb128 0x4 .uleb128 0x4 .align 4 LEFDE1: .def _printf; .scl 2; .type 32; .endef |
なんということでしょう。
前置も後置もまったく同じ結果になりました。流石、昨今のコンパイラは優秀です。
この結果から考えると、確かに一時オブジェクトの生成に対するコストよりCPUがストールすることの方がコスト高だという結論を導くことはできそうです。
現実的な観点
我々は現実の世界で実際にコードを書くプログラマです。そこには机上の論理以外の様々な要素を考慮する必要があります。
まず、第一に、インクリメントがストールをもたらす為には、その結果を使う必要があります。結果を使おうとして初めてストールの可能性が生まれます。
ただ単に一行
1 |
++value; |
と書いてもストールすることはありません。
平行して結果を使わないからです。
今回のゲームエンジン・アーキテクチャの主張は、後置インクリメントはインクリメントの結果を待たなくても、戻り値のオブジェクトは決定しているのでそのオブジェクトに対する処理はインクリメントの動作と並行して行えるよね。ならストールしないよね。というものです。
つまり後置インクリメントで戻される、インクリメント前のオブジェクトを使う必要があります。式の最中で。
あまり馴染みが無いシチュエーションだと感じる人も多いのではないでしょうか。もちろん、無理やり気味に、そう書くことは可能です。
しかし、今のコーディングプラクティスにおいて、一行のステートメント中で複数の処理を書くことは可読性を落とすことになります。しかも式中でインクリメントとその結果を利用するようなものなら、なおさら。
結果として、後置インクリメントであることがアドバンテージになるシチュエーションが訪れないのなら、単に一時オブジェクトの生成に対する懸念がある分、後置インクリメントの方が不利なのかなという気もします。
第二に、ストールのコストは肌で感じづらいという問題があります。
処理に直接関係するコストは、例えばアセンブラコードの出力を見れば把握できます。
余分なオブジェクトが生成されているな、とか、最適化されているな、とか。
しかし、あるコードを書いた時にその部分の処理によってCPUにストールが発生しているかというのは極めて把握が難しい問題です。なるべくCPUの効率を上げるコーディングの仕方というものはもちろん存在していて、でもそういったものは往々にして直感的ではなく、可読性や保守性を下げるものです。
CPUのアーキテクチャへの理解と、現実にコードを書く我々、この2つを勘案した上で、実際に前置インクリメントを使うべきか、後置インクリメントを使うべきか、また今の時代そういうことは気にしないべきか、考える必要があります。
私は、後置インクリメントを使っている事を指摘した際に「だってゲームエンジン・アーキテクチャに書いてあったからなんとなく」という答えをするプログラマが増えない事だけを願っています。
まとめ
- 前置インクリメントを使うべきという主張の理由を理解しよう
- 後置インクリメントを使うべきという主張の意図を汲もう
- 時代の変化とともに柔軟に再考しよう