yohhoyの日記

技術的メモをしていきたい日記

ストリーム入出力と評価順規定の厳格化

プログラミング言語C++における評価順規定の変遷についてメモ。本記事ではストリーム挿入(出力)演算子<<のみを扱うが、ストリーム抽出(入力)演算子>>にも同様に適用される。

クイズ:下記プログラムを実行すると何が出力されるか?

#include <iostream>

int main()
{
  int i = 0;
  std::cout << ++i << ++i;  // ★
}

こたえ:

  • C++14以前:未定義動作(undefined behavior)を引き起こすため、コンパイラや最適化有無によって出力結果は変化する。大抵は12もしくは22となる可能性が高いが、これ以外の出力が得られるかもしれない。
  • C++17以降:出力12が保証される。∩(・ω・)∩ばんじゃーい

共通

ストリーム挿入/抽出演算子<<, >>は実際にはオーバーロードされたシフト演算子であり、前掲コードはbasic_ostream<char>::operator<<(int)メンバ関数呼び出しと等価となる。説明のため、各部分式にラベルe1, e2, e3, m1, m2を付与する。

(((std::cout . operator<<) ( ++i )) . operator<<) ( ++i );
// ^^^^^^^^^   ^^^^^^^^^^    ^^^      ^^^^^^^^^^    ^^^
// e1          e2            m1       e3            m2

クラスメンバ関数へのアクセスはドット(.)演算子を介するため、例えば 部分式e1 と 部分式e2 は同演算子オペランドと解釈される。ここでは関数呼び出し構文 (e1.e2)(m1) も含めて、演算優先順位を丸括弧で強調している。

C++98/03仕様

前掲コードの副作用完了点(sequence point)は式文の末尾、つまり末尾セミコロン(;)にのみ存在する。関数呼び出し構文 (((e1.e2)(m1)).e3)(m2) では 部分式(((e1.e2)(m1)).e3)と 部分式m2の評価順序は未規定(unspecified)となるため、部分式m1, m2の評価順もまた未規定となる*1。部分式m1, m2は副作用完了点到達までに同一オブジェクトを複数回変更するため未定義動作を引き起こす。C++03 1.9/p16, 5/p4より一部引用(下線部は強調)。

16 There is a sequence point at the completion of evaluation of each full-expression.

4 Except where noted, the order of evaluation of operands of individual operators and subexpressions of individual expressions, and the order in which side effects take place, is unspecified. Between the previous and next sequence point a scalar object shall have its stored value modified at most once by the evaluation of an expression. Furthermore, the prior value shall be accessed only to determine the value to be stored. The requirements of this paragraph shall be met for each allowable ordering of the subexpressions of a full expression; otherwise the behavior is undefined. (snip)

C++11/14仕様

関数呼び出し構文 (((e1.e2)(m1)).e3)(m2) では 部分式(((e1.e2)(m1)).e3) と 部分式m2 は順序付けられない(unsequenced)ため、部分式m1, m2同士もまた順序付けられない(unsequenced)。同一変数に対する順序付けられない(unsequenced)更新操作(side effect)は未定義動作を引き起こす。C++11 1.9/p15, 5.2.2/p8より一部引用(下線部は強調)。

15 Except where noted, evaluations of operands of individual operators and of subexpressions of individual expressions are unsequenced. [Note: In an expression that is evaluated more than once during the execution of a program, unsequenced and indeterminately sequenced evaluations of its subexpressions need not be performed consistently in different evaluations. -- end note] The value computations of the operands of an operator are sequenced before the value computation of the result of the operator. If a side effect on a scalar object is unsequenced relative to either another side effect on the same scalar object or a value computation using the value of the same scalar object, the behavior is undefined. (snip)

8 [Note: The evaluations of the postfix expression and of the argument expressions are all unsequenced relative to one another. All side effects of argument expression evaluations are sequenced before the function is entered (see 1.9). -- end note]

C++17仕様

C++17では演算子オーバーロードがされていても組み込み演算子オペランド評価順が適用される*2ため、評価順の解釈においては下記コードが対象となる。

 (std::cout << ++i) << ++i;
//^^^^^^^^^    ^^^     ^^^
//e1           m1      m2

さらにC++17のシフト演算子<<, >>オペランドは 左辺→右辺 の順に評価される、つまり “シフト演算子左辺に現れる全ての値の計算(value computation)と更新操作(side effect)” は “シフト演算子右に現れる全ての値の計算(value computation)と更新操作(side effect)” よりも前に順序付け(sequenced before)られる。よって 部分式m1による変数更新 は 部分式m2による変数更新 よりも前に順序付け(sequenced before)られるため、プログラマが期待する通りの実行結果が得られる。C++14 4.6/p15, 8/p2, 8.8/p2-4, 16.3.1.2/p2より一部引用(下線部は強調)。

15 Sequenced before is an asymmetric, transitive, pair-wise relation between evaluations executed by a single thread (4.7), which induces a partial order among those evaluations. Given any two evaluations A and B, if A is sequenced before B (or, equivalently, B is sequenced after A), then the execution of A shall precede the execution of B. (snip) An expression X is said to be sequenced before an expression Y if every value computation and every side effect associated with the expression X is sequenced before every value computation and every side effect associated with the expression Y.

2 [Note: Operators can be overloaded, that is, given meaning when applied to expressions of class type (Clause 12) or enumeration type (10.2). Uses of overloaded operators are transformed into function calls as described in 16.5. Overloaded operators obey the rules for syntax and evaluation order specified in Clause 8, but the requirements of operand type and value category are replaced by the rules for function call. Relations between operators, such as ++a meaning a+=1, are not guaranteed for overloaded operators (16.5). -- end note]

2 The value of E1 << E2 is E1 left-shifted E2 bit positions; vacated bits are zero-filled. (snip)
3 The value of E1 >> E2 is E1 right-shifted E2 bit positions. (snip)
4 The expression E1 is sequenced before the expression E2.

2 If either operand has a type that is a class or an enumeration, a user-defined operator function might be declared that implements this operator or a user-defined conversion can be necessary to convert the operand to a type that is appropriate for a built-in operator. In this case, overload resolution is used to determine which operator function or built-in operator is to be invoked to implement the operator. Therefore, the operator notation is first transformed to the equivalent function-call notation as summarized in Table 12 (where @ denotes one of the operators covered in the specified subclause). However, the operands are sequenced in the order prescribed for the built-in operator (Clause 8).
(snip)

関連URL

*1:副作用完了点の定義によって式文全体が未定義動作となるため、“部分式m1, m2のどちらが先に評価されるか” という議論にはもはや意味がない。

*2:厳密には「組み込み演算子の構文により演算子オーバーロードが呼び出される」という条件が必要。関数呼び出し構文 std::cout.operator<<(++i).operator<<(++i); を用いた場合は、通常の関数呼び出しにおける順序付け規則が適用される。本文中のコードでは演算子オーバーロードが basic_ostream<char>::operator<<(int) メンバ関数として定義されるため、C++17 8.2.2/p5によりwell-definedとなる。演算子オーバーロードが通常の関数(非メンバ関数)として定義される場合、順序付けの保証度合いが弱くなり未規定の動作(unspecified behavior)となる可能性がある。C++17 8.2.2/p5 Example参照。普通でない演算子オーバーロードの呼び出しは自己責任で...