最終更新日時(UTC):
が更新

履歴 編集

厳密な式の評価順 [P0145R3](C++17)

概要

C++14までは演算子オペランドにあたる部分式の評価順序は未規定(unspecified)であったが、 C++17では下記リストの演算子に関してはa, bの順で評価されることが規定された。 @=は代入演算子=や任意の複合代入演算子(+=, -=など)を表す。

  1. a.b
  2. a->b
  3. a->*b
  4. a(b1, b2, b3)
  5. b @= a
  6. a[b]
  7. a << b
  8. a >> b

関数呼び出し実引数リストの部分式(上記例ではb1, b2, b3)間の評価順序は、不定順で序列化(indeterminately sequenced)される。 つまりb1, b2, b3の順序とは限らずb3, b2, b1b2, b3, b1などの順序で評価される可能性がある。 その一方で、例えばb1, b2, b3の順に評価が開始する場合には、b1評価完了より前にb2b3の評価が開始する(インターリーブ実行される)ことは決して無い。

上記以外の演算子オペランド(例えばx + yの両項x, y)間の評価順序は、C++17でも従来どおり未規定(unspecified)のままである。

仕様

C++17では演算子オペランドにあたる部分式の評価順序が下記の通りに定められた。

  1. 左から右に評価される。
    • メンバへのポインタ演算子 (.*, ->*)
    • 関数呼び出し演算子、およびT(...)による初期化
    • 添え字演算子
    • シフト演算子
  2. 右から左に評価される。
    • 代入演算子
    • 複合代入演算子(代入と演算を同時に行う+=, -=, |=などのこと)
  3. オーバーロードされた演算子の場合、言語組み込み演算子の評価順序に従う。
    • オーバーロードされた演算子の実際の動作はメンバ関数呼び出しとなるが、関数呼び出しの順序規則を適用しない。
    • ただし演算子オーバーロードをメンバ関数呼び出し形式で行った場合(例えばx.operator=(y))は、関数呼び出しの評価順序(x, y)が適用される。
  4. new式:メモリ確保関数の呼び出しは初期化子の評価より前と規定する。

3番目の規則については、 代入演算子operator=をオーバーロードした場合を考えると理解しやすい。 言語組み込み代入演算子=のオペランドは右から左の順、つまりa(), b()の順で評価される。

int  a();
int& b();

int main()
{
  b() = a(); // 部分式a(), b()の順で評価される
}

Hogeクラスの代入演算子オーバーロードによって、 式b() = a()b().operator=(a())という関数呼び出しが行われる。 このとき3番目の規則が存在しなかったとすると、1番目の関数呼び出しに関する規則が適用される。 関数呼び出しの評価順序は左から右の順、つまりb(), a()の順で評価されるため、 言語組み込み代入演算子オペランドの評価順序と逆になってしまう。

struct Hoge {
  // 代入演算子オーバーロード
  Hoge& operator=(const Hoge&);
};

Hoge  a();
Hoge& b();

int main()
{
  // 実際には b().operator=(a()); が呼び出されるため
  // 仮に3番目の規則が存在しなかったら...
  b() = a(); // b(), a()の順で評価される(逆になってしまう!)
}

プログラムの見た目は全く同じ代入式にも関わらず、 代入演算子オーバーロードの有無によって評価順序が逆になってしまう。 このような振る舞いはプログラマを混乱させるだけだろう。 3番目の規則は「演算子オーバーロードにわざと関数呼び出しの規則を当てはめない」ことで、 演算子オーバーロードの有無に関わらず自然な動作を実現するための規則である。

#include <iostream>
#include <map>

int main() {
  std::map<int, int> m;
  m[0] = m.size(); // C++17 では右から左に評価されるため m は {{0, 0}} になる
  std::cout << m[0] << std::endl;
}

出力

0

この機能が必要になった背景・経緯

C++規格の策定以来、演算子オペランドの評価順序は厳密に定められていなかった。

例に出てきたプログラムは非常に単純だが、 C++14やそれ以前のC++の規格では動作が不定であった。 代入式の評価順序が規定されていなかったためである。

#include <map>

int main() {
  std::map<int, int> m;
  m[0] = m.size(); // C++14 では m が {{0, 0}} か {{0, 1}} のどちらになるか不定
}

下記のプログラムの動作を考える。 std::cout, f(), g(), h()が互いに作用する (例えばf(), g(), h()内でstd::coutに何か出力するなど)場合、 このプログラムの動作は不定であった。

シフト演算子は左結合なのでstd::cout, f(), g(), h()の順に評価されそうだが、 演算子の結合と評価順には直接的な関係はなく、C++プログラマが期待する評価順は保証されなかった。

std::cout << f() << g() << h();

std::coutはシフト演算子オーバーロードを行っているため、 実際にはメンバ関数の呼び出しとして解決される。

std::cout.operator<<(f()).operator<<(g()).operator<<(h());

このときoperator<<(f()).operator<<(g())のように、 メンバ関数呼び出しの連鎖が発生するが、f()を含む部分式とg()の評価順は未規定とされていた。 つまり右から左g(), f()の順で評価されるかも知れないし、 左から右f(), g()の順に評価されるかも知れない。

以上のように、シンプルな代入演算やメンバ関数呼び出し連鎖などC++で広く使われている手法でさえ、 動作結果が不定となってしまう問題がある。 この問題はプログラミング作法が悪いのではなく、 C++規格が現代のプログラミング作法に合わなくなっていることが原因である。 C/C++規格の制定から約30年の時を経て、C++17で是正されることとなった。

C++17では全ての式の評価順を厳密に定めることはあえて避けている。 C++の既存のプログラムを壊すこと無く、なおかつ、 広く用いられているプログラミング手法が不定な動作にならないよう、 対象を限定して注意深く変更した結果である。

関連項目

参照