質問を受けた
マイクラ実況していた時に、C言語を勉強している人から質問があった
for文の条件式に ++を使うと結果が変わるのは何故か?
for (int i = 0; i < 10; i++ )
と
for (int i = 0; i < 10; ++i )
と
for (int i = 0; ++i < 10; )
と
for (int i = 0; i++ < 10; )
の違い
みなさんわかりますか?
コードを読む時の事を考え、正規の書き方に統一しろ
まず、質問者に回答したのは上記です
ここでいう正規の方法とは
for (int i = 0; i < 10; i++ )
または
for (int i = 0; i < 10; ++i )
です。
そして上記のどちらが良いかというと後者の方と答えます。
なぜ上記の記法に統一するのかというと
他のプログラマや自分が後で見た時に、正規の記法だと読みやすい&不具合が出ないからです
実際に例のコードを実行する
for (int i = 0; i < 10; i++ )、for (int i = 0; i < 10; ++i )
正規の表記です
for (int i = 0; i < 10; ++i) {
cout << i << " ";
}
あるいは
for (int i = 0; i < 10; i++) {
cout << i << " ";
}
0 1 2 3 4 5 6 7 8 9
意図したとおりに10回のループになっています。
++iもi++も、単独の式では実行結果には違いがありません
(ただし、パフォーマンスに差が出る可能性があるので後に示します)
for (int i = 0; i++ < 10; )
今度は条件式の中で i++とカウントアップしてみます
for (int i = 0; i++ < 10; ) {
cout << i << " ";
}
1 2 3 4 5 6 7 8 9 10
同じく10回ループになっていますが、正規のコードに比べると
カウンターの値が1つ多くなっています
forループでは配列のアクセスに使うことも多いので、添え字が0始まりの言語では例えば下記のコード
int array[10];
for (int i = 0; i++ < 10; ) {
array[i] = i;
}
などのコードを書くと、アクセス違反で鼻から悪魔が出るので
やはりこの書き方をしてはいけない
for (int i = 0; ++i < 10; )
先ほどと同じく条件式の中でカウントアップ
今度は ++iに
for (int i = 0; ++i < 10; ) {
cout << i << " ";
}
1 2 3 4 5 6 7 8 9
1からはじまり、ループ回数が9回になっちゃったね。
これは大問題だ
無理やり正しく動作するよう修正する
for (int i = -1; i++ < 9; ) {
}
for (int i = -1; ++i < 10; ) {
}
上記のように書けば、正規と同じ動作をする。
が、はたして何の効果があるのか?
不具合が増えるだけであり、自己満足でしかないので
チーム開発では絶対に使ってほしくない
同じ理由で
for (int i = 0; i <= 9; ++i) {
}
これも使うべきではないだろう
(これは許容範囲なので、使う時はコメント入れてな!)
for文の解説
for文はこのような構文になっています
for( init-expression(初期化式); cond-expression(条件式); loop-expression(ループ式))
for文はアセンブラレベルでは一つの文ではなく複数に分かれます
まずループ前に初期化式が実行され
ループの開始時に条件式が評価されfalseになるとループを抜けます
そして本文が実行され
最後にループ式(大抵はカウンターのアップなど)が実行され、条件式の評価に戻ります
具体的には下記のイメージです
//for( int i=0; i < 10; ++i ){ ...}
int i=0; // 初期化式
loop:
if(!(i < 10) ) goto end; // 条件式
...
++i; // ループ式
goto loop;
end:
上記のように展開されます
例のコードだと、このように展開されます(イメージ)
//for( int i=0; ++i < 10;){ ...}
int i=0; // 初期化式
loop:
if(!(++i < 10) ) goto end; // 条件式
...
// ループ式無し
goto loop;
end:
問題は
if(!(++i < 10) )
この条件式です
演算子の優先順位
演算子には優先順位があります
数学でいえば、+や-より、×や÷の方が優先度が高い。()の方が更に優先度が高いので
1+2×3 = 1 + (2x3) となり7になりますよね。
C言語、C++では下記の優先順位がつけられています
https://ja.cppreference.com/w/c/language/operator_precedence
インクリメントと関係演算子の優先度
表のように i++(後置インクリメント)は順序1、++i(前置インクリメント)は順序2で
いずれも <(関係演算子) 順序6よりも優先度が高いため、インクリメントが先に評価されます
そのため
前置インクリメント for( int i=0; ++i < 10;)
は、初回ループ時は ++iでiの値が1。2回目は2・・・9回目は9、10回目は10になり 条件式がfalseとなり
出力結果は1から9までの9個になります
1 2 3 4 5 6 7 8 9
ところが
後置インクリメント for( int i=0; i++ < 10;)
は出力結果が
1 2 3 4 5 6 7 8 9 10
と1から10の10個と結果が違います
演算子の優先順位だけでは説明できませんね
前置インクリメントと後置インクリメントの違い
実は前置インクリメント(++i)と、後置インクリメント(i++)は大きな違いがある
どちらも変数の値を+1する事は変わらないのだが、戻り値が異なる
前置インクリメントは+1された値、後置インクリメントは元の値が返却される
つまり
後置インクリメント for( int i=0; i++ < 10;)
のi++ < 10は、元のiの値で条件評価されたあとに+1される事になる
その為初回ループ時は i=0で評価され、評価終了時にi=1になる。10回目のループは i=9で 評価終了時にi=10になり
11回目のループで i=10になり条件がfalseになる
その結果
1 2 3 4 5 6 7 8 9 10
となります
具体的には i=0時に
cout << i++;
だと 0が表示され
cout << ++i;
だと 1が表示されます
(どちらも表示後のiの値は1になっています)
インクリメント、加算などの実装例
とりあえず上記の話で納得した人も、納得できない人もいると思うが
なぜ正規の記述を勧めるかわかったよね?
あと、実際はコンパイラの最適化等で同じになる事も多いが
n=n+1やn+=1よりは ++nを使うべき(CPUによっては1加算をする高速な専用命令が存在し、後述するオーバーヘッドも無く速くなる)
可能であればn++より++nを使うべき(後述)
n=n+5より n+=5を使うべき(後述)
それらの説明のために実際の演算子の実装例を(例です)
#include<iostream>
using namespace std;
template<class T>
class Hoge {
T value;
public:
Hoge() : value(0) {}
Hoge(T val) : value(val){}
operator T() const noexcept{
return value;
}
// ++n
T& operator++() {
value++;
return(value);
}
// n++
T operator++(int) {
T oldValue = value;
value++;
return(oldValue);
}
// n + x
T& operator+=(const T& arg){
value += arg;
return(value);
}
};
template<class T>
Hoge<T> operator+(const Hoge<T>& lhs, const Hoge<T>& rhs) {
T val = (T)lhs + (T)rhs;
Hoge<T> ret = Hoge<T>(val);
return(ret);
}
int main() {
Hoge<int> h0;
Hoge<int> h1(1);
cout << h0++ << " h0=" << h0 << endl;
cout << ++h0 << " h0=" << h0 << endl;
cout << (h0+=2) << " h0=" << h0 << endl;
cout << (h0 + h1) << " h0=" << h0 << endl;
return 1;
}
前置インクリメント vs 後置インクリメント
C++の場合、前置インクリメントは引数無しの下記、operator++()
後置インクリメントは引数intで、operator++(int)
ただしこの引数は前置と後置を区分するためのダミーで実際には引数は取りません
// ++n
T& operator++() {
value++;
return(value);
}
// n++
T operator++(int) {
T oldValue = value;
value++;
return(oldValue);
}
両者の違いをみていこう
前置インクリメントは自身の値をインクリメントして、その値を返せばよいため
戻り値を自身の参照にする事が出来る
が、後置インクリメントでは戻り値にインクリメント前の値を返す必要があるので
一時変数を作りそれを返却する必要がある
つまり、後置インクリメントはAllocateが走るために処理が重くなる
ので、どちらでも良い場合は前置インクリメント/デクリメントを使うべきだ
複合代入(n+=5) vs 加算&代入(n=n+5)
template<class T>
class Hoge {
...
T value;
// n + x
T& operator+=(const T& arg){
value += arg;
return(value);
}
};
template<class T>
Hoge<T> operator+(const Hoge<T>& lhs, const Hoge<T>& rhs) {
T val = (T)lhs + (T)rhs;
Hoge<T> ret = Hoge<T>(val);
return(ret);
}
複合代入の場合は、メンバ変数に直接加算してそのままメンバの値を返す事が可能なので
参照を返している
ただし、引数をとるため、その分のオーバーヘッドがインクリメント演算子と比較すると存在する
代入と加算は、メンバ変数ではなくフリー関数で実装する事が多い
一時変数を作り返却する必要があるので、少なくとも1回はAllocateが走る
なので、可能であれば複合代入を使うことをおすすめする
備考
パイプラインストール
数年前に話題になった、CPUのパイプラインストールの影響で後置インクリメントの方が速くなる
という主張がありました
大雑把にまとめると
前置インクリメントでは参照を返すため、パイプラインストールが起こる可能性があるが
後置インクリメントはコピーを返すのでパイプラインストールが発生しない
AllocateのコストはNRVOが適用されると非常に低くなる
このため、Allocate < パイプラインストール の状況では後置インクリメントの方が高速である
ただし実際に測定した例では、ほとんどの場合に置いて前置インクリメントの方が速いと思う事と
基本的に前置インクリメントの方がコードのリーディングミスをする人が少ないであろう事から
私は、どちらでもよい場合は前置インクリメントを勧めている