C言語の副作用、副作用完了点について調べてみたので、自分なりの理解を整理してみました。
本記事の内容はC90の規格票(「プログラム言語C JISX3010-1993 (ISO/IEC 9899:1990)」)を根拠としています。
副作用完了点とは何か
家事ロボットに以下の作業を命令したとします。
- 庭に水を撒く。
- 料理を作る。
- 洗濯をする。
ロボットから作業完了の報告を受けた時点で結果を確認してみると、庭の芝生には水が撒かれ、料理も完成しており、洗濯も終わっています。
つぎに、以下の作業を命令したとします。
- 銀行に行ってお金を下ろす。
- スーパーで食材を買う。
- 料理を作る。
ロボットが外出してしばらく経った後、ロボットからエラーの報告を受けました。「銀行でお金をおろすことだけできた」そうです。いったい何があったのか聞いてみると、
「最初に料理を作ろうとしたが、食材が無いので料理を作ることができなかった。つぎにスーパーに行ったが、お金が無いので食材を買うことができなかった。最後に、銀行に行ってお金を下ろすことはできた。」
という答えが返ってきました。
普通の人間なら作業の依存関係を考慮し、お金を下ろす→食材を買う→料理を作る…という順番で命令を遂行しますが、ロボットは融通が利きません。ロボットが守るのは「作業報告の時点で各作業を実行した結果が確定していること」です。この「作業報告の時点」にあたるものが「副作用完了点」になります。
規格では、式の評価にともなって起きる副作用の結果が、直前の副作用完了点から次の副作用完了点までの間に確定していることを保証していますが、具体的にいつ確定するかは保証しません。先ほどの例で言うと、「ロボットが作業完了を報告した時点で、各作業の実行結果が確定している」ことは保証するが、各作業の結果がどのような順序で、いつ確定するかは保証されない、ということです。順序性を考慮するなら、前の作業の終了報告を受けてから、次の命令をロボットに与えなければなりません。
未定義の動作を起こすコード例(1)
i = 1;
i = i++;
上のコード例が未定義動作を起こすとされる規格上の理由は「式i=i++
において、i
に格納された値を変更する回数が1回を超えているため」です。以下では規格から離れて、そのような制約が存在する理由について考えてみます。
まず、後置増分演算子++
には以下の性質があります。1
- 結果は、そのオペランドの値とする。
- 結果を取り出した後、オペランドの値を増分する。
つぎに、コード例の式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
の値の更新)が、どのような順序で完了するのかが保証されないため、未定義の動作になります。直近前後の副作用完了点を両端とする開区間における、任意の時点でのオブジェクト格納値のスナップショットは一意に決定できないと考えるべきです。
未定義の動作を起こすコード例(2)
int n;
n = n = 42;
代入演算子の結合規則から演繹すると、まず式n = 42
が評価され、評価結果として右辺値42
を返します。このとき、n
の値が42に更新される副作用が発生します。次に、一番左の代入式n = 42
が評価され、同様に値更新の副作用が発生します。
n
の値が42
に更新される副作用が2回発生するわけですが、とはいっても、これらの副作用の完了がいかなる順序であっても、i
の値が42
に更新されることには変わりないのだから、これはこれで健全なコード(未定義の動作を起こさない)ではないか、と思われるかもしれません。
しかし、直近前後の副作用完了点の間で、同一オブジェクトの値の変更が2回以上発生している以上、未定義の動作を起こすと判断せざるをえないのです。2
値が変わっていないのだから、「変更」と言えないのでは?という指摘もあるかもしれません。C90では陽に書かれていませんが、C99では値が変化しない場合も含めて「変更」のようです(以下「プログラム言語C JISX3010-2003 (ISO/IEC 9899:1999)」より引用)。
3.1 アクセス(access) <実行時の動作>オブジェクトの値を読み取る,又は変更すること。
参考1. これらの二つの動作のうちのいずれか一方だけを意味する場合は,“読み取る”又は“変更する”という用語を使う。
2. “変更する”は,格納する新しい値が,格納前の値と同じである場合も含む。
3. 評価されない式は,オブジェクトをアクセスしない。
未定義の動作を起こすコード例(3)
i = 1;
a[i++] = i;
上のコード例が未定義の動作を起こす規格上の理由は「変更前の値は、格納される値を決定するためにだけアクセスしなければならない(規格6.3.4)」という制約に反するためです。
ちなみにi = a[i++];
としても、これはこれで「直前の副作用完了点から次の副作用完了点までの間に、式の評価によってオブジェクトに格納された値を変更する回数は、高々1回でなければならない(規格6.3.4)」という制約に反するため、未定義の動作を起こします。
未定義の動作を起こすコード例(4)
int n = 0;
int *np = &n;
n = (*np)++;
式n
と式*np
は、シンタックス上の表現は異なりますが、意味規則上、その評価結果は同一のオブジェクトを返すため、直近前後の副作用完了点の間で同一オブジェクトの値を2回変更することになります。したがって、未定義の動作を起こします。
疑問点
では、以下はどうでしょうか。
i = 1;
a[++i] = i;
代入式a[++i] = i
における右オペランドのi
の評価結果が、変更前の値なのか変更後の値なのか断定できません。でもこれ、規格の記述からは「未定義の動作を起こす」という結論に持っていけないんですよね。
式a[++i] = i
において、i
の値の変更は1回のみなので「変更する回数は高々1回」の制約には反しません。また、++i
の評価結果はi
の変更後の値なので、「変更前の値は、格納される値を決定するためにだけアクセスしなければならない(規格6.3.4)」の制約にも反しません。前置増分演算子のオペランドとして変更前のi
へのアクセスが発生しますが、それはi
に格納する値を決定するためのアクセスなので、それも制約に反しません。というように、規格の記述からは「未定義の動作を起こす」という結論に持っていけないんですよね。
別の根拠として、代入演算子のオペランドの評価順序が未規定であることを持ってくることはできますが、そうすると「未定義の動作」ではなく「代入される値が未規定である」ということになってしまいます。未定義の動作であれば、a[2]
に代入される値は不明か、あるいはそもそも代入自体が行われない(あるいはこれら以外か)。未規定の動作であれば、a[2]
には1
か2
のいずれかが代入されるが、それ以外の可能性は無い。
結論として、上記コード例は「a[2]
に1
か2
のいずれが代入されるかは未規定である」ということでよいのでしょうか?
副作用完了点としての関数呼出し
副作用完了点のひとつに「実引数の評価完了後の関数の呼出し」があります。以下のコード例では、関数の実引数で式i++
を指定し、その関数内でi
の値をポインタ経由で出力しています。関数呼出しが副作用完了点として働くため、関数に入った時点ではiの値の更新(1
→2
)がすでに完了していることが分かります。
void f(int n, int *np)
{
printf("i++の評価結果 = %d\n", n);
printf("関数に入った時点でのiの値 = %d\n", *np);
}
int main(void)
{
int i = 1;
f(i++, &i);
return 0;
}
$ ./sample
i++の評価結果 = 1
関数に入った時点でのiの値 = 2
JIS X 3010-1993(ISO/IEC 9899/1990) より抜粋
以下、おまけ。JIS規格票から、副作用完了点に関する記述の抜粋です。
5.1.2.3 プログラムの実行
ボラタイルオブジェクトへのアクセス、オブジェクトの変更、ファイルの変更、又はこれらのいずれかの操作を行う関数の呼出しは、すべて副作用(side effect)と呼び、実行環境の状態に変化を生じる。式の評価は、副作用を起こしてもよい。
副作用完了点(sequence point)と呼ばれる実行順序における特定の点において、それ以前の評価に伴う副作用は、すべて完了していなければならず、それ以降の評価に伴う副作用が既に発生していてはならない。
6.3 式
式(expression)は、値の計算を指定するか、オブジェクト若しくは関数を指し示すか、副作用を生成するか、又はそれらの組合わせを行う演算子及びオペランドの列とする。直前の副作用完了点から次の副作用完了点までの間に、式の評価によってオブジェクトに格納された値を変更する回数は、高々1回でなければならない。更に、変更前の値は、格納される値を決定するためにだけアクセスしなければならない(34)。
構文で示されているか(35)、又は(関数呼出し演算子
()
、&&
、||
、?:
及びコンマ演算子に対して)後で規定する場合を除いて、部分式の評価順序及び副作用が生じる順序は未規定とする。
注(34) この段落は、
i = i + 1 ;
は許されるが
i = ++i + 1 ;
は、未定義の式文であることを規定している。
附属書C(参考) 副作用完了点
5.1.2.3で規定した副作用完了点を、次に示す。
● 実引数の評価完了後の関数の呼出し(6.3.2.2)
● 次の演算子の第1オペランドの終わり
論理AND
&&
(6.3.13)
論理OR|
(6.3.14)
条件演算子?
(6.3.15)
コンマ演算子,
(6.3.17)● 次の完全式の終わり
初期化子(6.5.7)
式文の中の式(6.6.3)
選択文(if
文又はswitch
文)の制御式(6.6.4)
while
文又はdo
文の制御式(6.6.5)
for
文の三つの各式(6.5.5.3)
return
文の中の式(6.6.6.4)
-
時々「
i++
は行の実行後にi
の値を更新し、++i
は行の実行前にi
の値を更新する」という説明を見かけますが、i++
及び++i
の違いは、値の更新の完了が保証されるタイミングではなく、式の評価結果(オペランドの値を返すか、オペランドの値を1増分した値を返すか)だけです。また、行は副作用完了点とは関係ありません。 ↩ -
値が使用されず、副作用が必要とされないことが演繹できる式は評価しなくてもよいとする規定(最適化のことを言っているのだと思います)もあるため、右側の部分式
n = 42
以外の評価は不要だと判断した場合、値の変更は1回しか起こさないのだから、そのような最適化のもとでは未定義動作とはならないのでは…と考えていた時期が僕にもありました。しかしn
の値を2回以上変更する以上、n=n=42
の結果を「演繹できる」と考えること自体が常識に依存した思い込みであり、やはり未定義動作と考えるべきです。 ↩