i++ と ++i、++i の方が高速という都市伝説を解明
昔、ある時、ふと気がついた。
int i; for (i = 0; i < 10; i++) { } for (i = 0; i < 10; ++i) { }
同じ動作をする for ループなんだけど、i++ と ++i の部分で、i++ の方が処理に無駄がある(下の値とインクリメントした値の両方を保持しなきゃならない)から性能面で劣っていないか、と。
結構ずーっと気になっていたんだけど、腰を上げて確認してみたら、gcc ではどちらも同じだった。以下は for ループ内のインクリメント部分の抜粋
movl _i, %eax /* i を eax に読み込み */ addl $1, %eax /* 1 を eax に足し込み */ movl %eax, _i /* eax を i に戻す */
インクリメントの部分は i++ でも ++i でもこのようになっていて、副作用がないので最小のコードになっているわけだ。これは最適化を無効にしても同じコードが出力される。
では Java はどうだろうか。
20: getfield #2; //Field i:I 23: iconst_1 24: iadd 25: putfield #2; //Field i:I
Java でも i++ と ++i は上記のようにコンパイルされる。C と同じく副作用がないから、どっちでも同じなのである。
じゃあ副作用がある場合はどうなの?こんなコードならどうだろうか。
int i; int postinc(void) { return i++; } int preinc(void) { return ++i; }
上記のアセンブラコードが以下。
.globl _postinc .def _postinc; .scl 2; .type 32; .endef _postinc: pushl %ebp movl %esp, %ebp movl _i, %eax /* i を eax にを読み込む */ movl %eax, %edx /* eax を edx に読み込む */ addl $1, %eax /* 1 を eax にを加える */ movl %eax, _i /* eax を i にを戻す */ movl %edx, %eax /* edx を eax に戻す */ popl %ebp ret .globl _preinc .def _preinc; .scl 2; .type 32; .endef _preinc: pushl %ebp movl %esp, %ebp movl _i, %eax /* i を eax にを読み込む */ addl $1, %eax /* 1 を eax にを加える */ movl %eax, _i /* eax を i 戻す */ movl _i, %eax /* i を eax に読み込む(無駄な処理) */ popl %ebp ret
想定したとおり、i++ の方はレジスタをひとつ多く使っている。++i は最後に無駄な処理があるが、これは最適化なしだから丁寧に戻り値の設定をしているのかも知れない。
最適化オプションを -O2 で試してみた。
.globl _postinc .def _postinc; .scl 2; .type 32; .endef _postinc: movl _i, %eax /* i を eax に読み込む */ pushl %ebp movl %esp, %ebp popl %ebp leal 1(%eax), %edx /* eax に 1 を足した値を edx に格納 */ movl %edx, _i /* edx を i に戻す */ ret .globl _preinc .def _preinc; .scl 2; .type 32; .endef _preinc: movl _i, %eax /* i を eax に読み込む */ pushl %ebp movl %esp, %ebp popl %ebp addl $1, %eax /* 1 を eax に足す */ movl %eax, _i /* eax を i に戻す */ ret
どちらも大幅に最適化されている。
++i の方は最小の 3 ステップで行えている。
i++ の方も lea という命令を使用して 3 ステップで行われている。lea は本来 2 ステップかかる読み込みと演算を 1 ステップで行える命令だ。しかもこの場合、eax には副作用がないので、インクリメントする前の値が保持されたままになる。
ここでいったん結論。
i++ も ++i も速度的には同じ(なことが多い、だろう、最適化次第では)。
さて、面倒くさいけど、ついでに Java も見てみますか。
int i; public int postinc() { return i++; } public int preinc() { return ++i; }
こんなコードをコンパイルすると、バイトコードは以下のようになる。
public int postinc(); Code: Stack=4, Locals=1, Args_size=1 0: aload_0 # リファレンスをスタックに置く 1: dup # リファレンスをスタック上でコピー 2: getfield # i の値をスタックに読み込む 5: dup_x1 # i の値をスタックの二つ後ろにコピー 6: iconst_1 # 1 をスタックに置く 7: iadd # i + 1 の値をスタックの先頭に置く 8: putfield # スタックから i に値を戻す 11: ireturn # 後ろに置いたコピーが返される public int preinc(); Code: Stack=3, Locals=1, Args_size=1 0: aload_0 # リファレンスをスタックに置く 1: dup # リファレンスをスタック上でコピー 2: getfield # i の値をスタックに読み込む 5: iconst_1 # 1 をスタックに置く 6: iadd # i + 1 の値をスタックの先頭に置く 7: dup_x1 # i の値をスタックの二つ後ろにコピー 8: putfield # スタックから i に値を戻す 11: ireturn # 後ろに置いたコピーが返される
どちらのコードも i の値はインクリメントするんだけど、return で返す値はインクリメント前の値なのか後の値なのか、というのが焦点になる。
Java バイトコード命令には dup_x1 というスタック操作命令があって、通常のスタックは先頭にのみ追加・削除できるという概念なんだけど、dup_x1 はスタックの奥に挿入することができる。最終的にこの挿入されたコピーは、ireturn で戻り値として使用されるんだけど、いつ dup_x1 を実行するかの違いだけで、i++ も ++i もステップ数はまったく同じになっている。
ここで最終結論。
i++ も ++i も、速度的には変わらない(少なくともバイトコード時点では)。