FromNandの日記

自分的備忘録

C言語における副作用完了点とは?

C言語における副作用で特に厄介なのがインクリメント・デクリメント演算子なので、特にそれについてまとめておきます。
ちなみに、ここのサイトがめっちゃわかりやすかったです。(C - for文におけるiのインクリメントの前置と後置の違いがわかりません|teratail)



wikipediaには、副作用について次のように書かれています。

プログラミングにおける副作用(ふくさよう)とは、ある機能がコンピュータの(論理的な)状態を変化させ、それ以降で得られる結果に影響を与えることをいう。代表的な例は変数への値の代入である。


Cの企画書にはこのように書かれているみたいです。

ボラタイルオブジェクトへのアクセス、オブジェクトの変更、ファイルの変更、又はこれらのいずれかの操作を行う関数の呼び出しは、すべて副作用(side effect) と呼び、実行環境の状態に変化を生じる。式の評価は、副作用を引き起こしてもよい。副作用完了点 (sequence point) と呼ばれる実行順序における特定の点において、それ以前の評価に伴う副作用は、すべて完了していなければならず、それ以降の評価に伴う副作用が既に発生していてはならない。


また、副作用完了点は次のように定義されているようです。

副作用完了点(sequence point)とは、以前に行った評価に伴うすべての副作用が完了し、それ以後に行われる評価に伴うすべての副作用がまだ発生しない、実行列中の地点。副作用完了点には以下のようなものがある。
・完結式の直後
・関数の実引数の評価直後
・関数の返却値のコピー直後
論理和演算子(||)または論理積演算子(&&)の左辺の評価直後
コンマ演算子の左辺の評価直後


ここで一つの例を引用します。

ここで、i = i++;というコードについて考えてみます。
コード例の式i = i++は、以下の処理に分解されます。
部分式i++の評価 … (a)
(a)の評価結果(=1)をiへ代入 … (b)
式i++の評価にともなうiの値の増分 … (c)
規格では、後置増分演算子は「結果を取り出した後、オペランドの値を増分する」と定義されているため、少なくとも「(a)→(c)の順序で行われる」は保障されています。ただ、(b)が順序のどこに入り込むかは断定できません。
例えば、これが(a)→(b)→(c)の順序で行われた場合、2行目の式文の直後ではi==2となりますが、(a)→(c)→(b)の順序で行われた場合、副作用完了点におけるiの値は1となります。
なります…というか、正確には「そうなっても規格には反しない」です。あくまで私個人が想像した理屈づけであり、上記以外の結果(鼻から悪魔)が発生することも可能性としてはアリ(規格に反しない)です。
規格に立ち返ってシンプルに考えると、式i++の評価にともなう副作用(iの値の更新)、及び代入式i = ~の評価にともなう副作用(iの値の更新)が、どのような順序で完了するのかが保証されないため、未定義の動作になります。直近前後の副作用完了点を両端とする開区間における、任意の時点でのオブジェクト格納値のスナップショットは一意に決定できないと考えるべきです。

副作用完了点について - Qiita


また、次のような記述も見つけました。

なぜ未定義になるか、という点から考えてみてはどうでしょう?

未定義になるのは、
代入演算子の各辺、二項演算子の各項は、評価される順序が決まっていない。
前置(後置)++(--)演算子は、副作用完了点以前ではいつ値が変化するか決まっていない。
ということから、組み合わせがたくさんできてしまうのが原因です。

(追記)
後者は、例えばiが0のとき、++iを評価すると結果は1、だけど、iに格納されている値が1になるのは、いつか分かりません、ということです。

    1. iの結果を得るのが評価ですよ。


練習問題
(1) a = ++i; ok オブジェクト i の読み取りは 1回、変更も 1回だから。
(2) a = i++; ok オブジェクト i の読み取りは 1回、変更も 1回だから。
(3) a = ++i+1; ok オブジェクト i の変更のための読み取り 1回、変更 1回だから。
(4) a = i++ +1; ok オブジェクト i の読み取りは 1回、変更も 1回だから。
(5) i = ++i; 未定義。オブジェクト i の変更が、代入演算子によるものと、前置++演算子によるものの 2回なので、変更する回数が高々 1回というのに反する。
(6) i = i++; 未定義。オブジェクト i が 2回変更されるから。
(7) a = ++++i; 未定義ではなく、エラー。++++i は ++(++i) で、(++i) の結果が左辺値ではないので、最初の ++ はオペランドの制約に反する。
(8) a = i++++; も同様に未定義ではなく、エラー。
(9)a = i + (i = 3); 未定義。 オブジェクト i の変更のためでない読み取り 1回、変更 1回だから。
副作用、副作用完了点について - プログラマ専用SNS ミクプラ



関数呼び出しの副作用についても少し書いておきます。
共通のグローバル変数を使用して値を書き換える、つぎのような関数f,gを考えた時、a=f()+g()は未定義になります。

int global = 0;

int f(void){
    global = global + 1;
    return global;
}

int g(void){
    global = global + 2;
    return global;
}


理由はfとgが呼び出される順番が規定されていないからです。
もしfが先に呼び出された場合、f()+g()=1+3=4となります。
もしgが先に呼び出された場合、f()+g()=2+3=5となります。