謎の演算子

よく知られたネタだと思うが、while文中でカウントダウンするための演算子がある。

int N = 10;
while(N --> 0)
{
    std::cout << N << ' ';
}
9 8 7 6 5 4 3 2 1 0 

逆方向もあるが、カウントダウン演算子にもインクリメント・デクリメントの前置・後置と同様の違いがある。 左向きカウントダウン演算子は、値が等しくなる時にfalseになるので、この場合最後の0は出力されない。

int N = 10;
while(0 <-- N)
{
    std::cout << N << ' ';
}
9 8 7 6 5 4 3 2 1 

というのはウソで、while(N --> 0)は正しく書くとwhile((N--) > 0)だ。

最後の値が出力されるかどうかの違いは、まさしくデクリメントの前置・後置の差によって生じている。 (イン|デ)クリメントは、前置と後置で返す値が違う。前置(イン|デ)クリメントは変更後の値(への参照)を返す。後置だと変更前の値を返す。 自作クラスで演算子オーバーロードを行う場合、大抵前置から先に実装し、後置は前置を使って実装する。

integer& integer::operator++() noexcept
{
    // 前置
    *this += 1;
    return *this;
}
integer integer::operator++(int) noexcept
{
    // 後置
    const auto tmp = *this;
    ++(*this);
    return tmp;
}

前置は素直だ。だが後置は一度コピーを取り、変更した後にそれを返している。 もしこのクラスが信じられないくらい大量の情報を持っていた場合、後置(イン|デ)クリメントは効率を下げるだろう。 なので、イテレータのような自作クラスの場合、基本的には前置(イン|デ)クリメントを使用するのがよい。 あと、イテレータを作る場合、コピーコストが小さくなるようにしたい。

もし前置後置の議論がこれでおしまいなら、世界は平和で、C++は必要とされなかったかもしれない。 実際の所、他のあらゆる問題と同様、どちらを使うとよいかについて常に正しい解答はない。

というのも、前置(イン|デ)クリメントは値を変更し終えるまで返す値がわからないので、コンパイラは(イン|デ)クリメント周辺の命令の並べかえに慎重にならねばならないのだ。 C++コンパイラはas-ifルールに従っている限り、命令を並べ替えて最適化することが許されている。 後置(イン|デ)クリメントを使った場合、書き換えが終わる前に返す値がわかるので、並列で返した値の評価ができる。 これは並列化のみならず、CPUのパイプラインストールにも影響する問題だ。 パイプラインストールについてはこのサイトで知った記憶がある。ASCII.jp:CPU高速化の常套手段 パイプライン処理の基本 【その2】 (1/4)|ロードマップでわかる!当世プロセッサー事情

(イン|デ)クリメントが返す値をその後評価する場合(例えばwhile文の終了条件等)、そこで並べ替えが可能かどうかは実行コストに(ほんの少しではあるが)効いてくる。 本当に、最後の血の一滴まで絞り尽くすような最適化を行う場合、これは無視しづらくなってくる。 これはアーキテクチャに強く依存する話なので、もちろん常に後置が良いということにもならない。 基本的には前置を使い、ホットスポット周辺で(イン|デ)クリメントの返す値を評価するときは、前置・後置を試して逆アセンブルしたり、速度計測を行って調べるべきだ。

と、ここまで書いてアレだが、この辺りのことは後に示すリンク先の記事で既にしっかり言及されている。 私はこの話を何かのゲーム開発者がしていたおぼろげな記憶があったのだが、リンク先では出展付きで明記されており、ノーティドッグの社員だったらしい。 前置インクリメント vs 後置インクリメント | 闇夜のC++

ノーティドッグと言って個人的に思い出すのはクラッシュバンディクーだが、あの作品は1, 2, 3と非常に楽しんだ記憶がある。 小学生の時は1がまるでクリアできなかったが(と言って2,3もダイヤ取得などはしていなかったのだが)、高校生になってやってみると結構クリア出来て成長を実感した。 最終的に1, 2, 3ともに100%クリアを達成してかなり喜んでいた気がする。当時の成績は落ちた。

さて、(イン|デ)クリメントの前置後置には他の違いもある。前置(イン|デ)クリメントは参照を返すので連ねることができる。

while(0 <---- N)
{
    std::cout << N << ' ';
}
8 6 4 2

逆は不可だ。

while(N ----> 0)
{
    std::cout << N << ' ';
}
error: lvalue required as decrement operand

N--では一時オブジェクト(prvalue)が生成される。その一時オブジェクトに(イン|デ)クリメントを施すことは出来ない。 気持ち的には、(イン|デ)クリメントは既にあるものの値を変更するものなので、どこかに腰を落ち着けていないものに施すことはない。 --Nは既にあるNへの参照を返すので、もう一度(イン|デ)クリメントを行うことができる。

ところで、偶数個の-ならデクリメントの連続になるだろうが、奇数個だとどうなるだろうか。我々には単項演算子-があり、値を正負を逆転する。 連結の見栄え的には前置デクリメントが適しているだろう。

while(0 <--- N)
{
    std::cout << N << ' ';
}
error: lvalue required as decrement operand

怒られた。0 <--- N0 < --(-N)と解釈されているらしい。単項演算子は新しい値を作って返すので、これはprvalueになる。 なので後置(イン|デ)クリメントが連結できないのと同様の理由で、これは繋がらない。繋がったとしても負の値になるので一度も実行されないのだが。

こうすると通る。

while(0 <-(-- N))
{
    std::cout << N << ' ';
}

が、面白みはない。タネがすぐにバレるからだ。

(イン|デ)クリメント演算子の優先順位は結構高かった気がするけどな、と思った所、前置(イン|デ)クリメントと正負の単項演算子の優先順位は等しかった。 その場合、先のコードの挙動はどうなるのだろう。ひょっとして未定義だろうか。正しい意味になるように解釈してくれたりはしないのだろうか。

ところでIMEの変換予測のおかげで以下のような人物を知った。 クリメント・ヴォロシーロフ - Wikipedia 何で知識が増えるかわからないものだ。