たのしい工学

プログラミングを学んで、モノをつくりたいひと、効率的に仕事をしたい人のための硬派なブログになりました

【独学するコンピュータサイエンス】副作用完了点とシーケンスポイント

   

式と副作用

まだ説明していない演算子や説明済みでも不十分な演算子が存在します。これらについて説明するには副作用とシーケンス・ポイントの概念を避けて通れません。また式そのものについても理解を深める必要があります。やや複雑なので少しずつ理解していくぐらいの気持ちで読み進めてください。

二項演算子 + を例にします。第一項の式を A 、第二項の式を B とするとその記述は A + B です。コンパイラー側からすれば A の式の値を先に読み込むか B の式の値を先に読み込むかは問題になりません。ですから A の式の値を先に読み込む、即ち評価するか、或いは B の式を先に評価するかは不定です。評価の順序は分かりませんが + 演算する時点で A 、 B 共に評価済みでなければなりませんし、実際そうであるからこそ二項演算として成り立ちます。これは二項演算 + に限らず殆どの演算子に当てはまる事です。
今度は A + B * C を考えます。演算子の優先順位からすれば B * C が先に演算された後に A にその結果の値との + 演算が行われます。即ち A + ( B * C ) です。この場合の式の評価順序も不定です。ただ * 演算が行われる時点では必ず B と C が評価済みである事、 + 演算が行われる時点では A 及び ( B * C ) の評価が行われる事は決まっています。コンパイラーは先に A B C 全てを評価した後に * 演算、後 + 演算を行っても良いですし、 C を評価、 B を評価、後 B * C を演算、それから A を評価した結果とを加算する、でも良いのです。
従って次の様な演算を行う場合は注意が必要です。


int f( int a );

int main( void )
{
int x = f( 2 ) / f( 4 );
return ( x );
}

int f( int a )
{
static int c = 10; /* 静的記憶域期間 */

 c -= a;
 return ( c );
}

これを実行した x の結果は式の評価順序で変わってしまいます。
f( 2 ) 、f( 4 ) の順に評価されれば x = 8 / 4 となり x は 2 です。
f( 4 ) 、f( 2 ) の順に評価されると x = 4 / 6 となり x は 0 になります。

この様に評価順序によって得られる値が変わってしまう記述をしてはいけません。今の演算を f( 2 ) 、 f( 4 ) の順に行いたいのであれば文を分けて明確にします。


int main( void )
{
int x;
int y;

x = f( 2 );
y = f( 4 );
return ( x / y );
}

これなら評価順序は必ず記述した文の順になります。
ではどうして記述した文の順番に演算が行われるのでしょう。当たり前のことですが「文は記述した順に実行される」為です。しかし不十分な説明です。なぜなら副作用とシーケンス・ポイントの説明が無いからです。

副作用とシーケンス・ポイント

副作用 (side effect) とは実行環境に何等かの変化をもたらす事を指します。 volatile 指定オブジェクトへの読み書き、オブジェクトの変更、ファイルの変更、以上何れかの三点、或いはその三点の何れかを含む関数呼び出しの事です。
シーケンス・ポイント (sequence point) を端的に表現すると副作用が完了している事を保障する場所です。このため、またの名を副作用完了点といいます。

詳しく説明します。二つのシーケンス・ポイントにはさまれた演算 C = A + B を考えます。最初のシーケンス・ポイントを 、二つ目のシーケンス・ポイントを とします。式 C = A + B とシーケンス・ポイントを目に見える形で表現して

C = A + B
にて説明します。
の時点ではそれより後に記述されているあらゆる副作用は発生しません。
C A B の評価の順序は分かりません。ですが演算子の優先順位により最初に行われる演算は + 、次に行われるのが = です。
ここでは仮に C A B の順に評価されるとします。 A + B の演算が行われ C への代入演算が行われます。この時点で C の内容が書き換わるかどうかは分かりません。しかし に達した時点で C の内容は必ず書き換わっている、即ち副作用が完了している事が保障されます。
つまり の時点で C の副作用は発生しない事が保障され、 の時点で C の副作用が完了している事が保障されます。
シーケンス・ポイントとは、直前のシーケンス・ポイントから現在のシーケンス・ポイントの間に存在する副作用が完了している事を保障する場所の事であり、現在のシーケンス・ポイントより後に記述されている副作用は発生しない事も保証されています。
ではシーケンス・ポイントはどこにあるのでしょうか。
一つはやや不正確ですが文末を示すセミコロンです。複数の文の演算は順に行われますが、直前の文で格納された値を現在の文で使用しても、それは先の文の演算の結果である事が保証されてまいす。
他にもあるのでそれは後述します。

シーケンス・ポイントに関しては次の二つの制約がありこれに反した場合の動作は未定義です。
・二つの前後するシーケンス・ポイント間の一つのオブジェクトに対する変更は一回のみ。
・二つの前後するシーケンス・ポイント間の変更前の値は、格納する値を決定する為にだけ使用する。

後者について補足します。副作用の発生するオブジェクトの変更前の値は、オブジェクトに値を単純に格納する為にだけ使用します。このオブジェクトを A とします。 A の変更前の値をそのまま自身を含むオブジェクトに格納するのは良いですし、 A の値を用いて演算を行っても構いません。ただしその演算は代入するオブジェクトへの格納の為のものであって他に影響を及ぼす様な使用法をしてはならない、という意味です。
それぞれの制約について具体例を挙げます。

前者の例として、一つのオブジェクトの変更を二回行ってみます。


int a;
int b = 0;
a = b++ + b++; /* オブジェクトの変更を二回行っているので未定義 */

以前も少し説明した例です。
式 a = b++ + b++ の前後にあるシーケンス・ポイントを考えます。式の前のシーケンス・ポイントは int b = 0; のセミコロンの直前です。式の後のシーケンス・ポイントは式の後のセミコロンの直前です。
この二つのシーケンス・ポイント間で b に 1 加算する処理、即ち b の副作用が二回発生します。よって制約違反でありその結果は未定義です。つまり a の値も b の値もどうなるかは分からないのです。

今度は後者の例として、変更前の値を、格納する値の決定ではない事に使用します。

int e = 1;
int f[ 5 ];
f[ e++ ] = e + 2; /* 変更前の値を配列の添字に使用。よって未定義 /
f[ e ] = e++ + 3; /
同上 /
f[ 2 ] = e++ + 4; /
変更前の値は格納する値の為にだけ使用、故に可 */
式 f[ e++ ] = e + 2 の前後のシーケンス・ポイントは、それぞれ前の行のセミコロンと式の後のセミコロンです。
この二つのシーケンス・ポイント間に於いて e の値が 1 加算されます。しかし変更前の値を f[ e++ ] と配列の添字として使用しています。制約違反となり未定義動作です。
式 f[ e ] = e++ + 3 も意味は今と同じです。 e++ によって e の値が変更されるにも拘らず e の変更前の値を添字として使用しています。
一方、式 f[ 2 ] = e++ + 4 は変更前の e の値はオブジェクト f[ 2 ] に格納する値の為の演算にだけ使用しているので正しい記述となります。

これを見て「 *p++ = 2; はどうなのか」と疑問に思うかも知れません。左辺の *p++ は演算子の優先順位により *( p++ ) となります。 ++ 演算子はポインター変数そのものの値を変更しています。その後 * 演算により ++ 演算前の p の指し示すアドレスの内容が対象になります。代入演算はポインターの指し示す内容に対して行っています。 ++ 演算は p に対して、 = 演算は *p に対してです。従って副作用は p が一回、 *p が一回です。一つ一つ見ていけば規格に沿った記述なのが分かるでしょう。

尚副作用が複数のオブジェクトに起こる場合、その順番は式の評価同様決まっていません。

 - コンピュータサイエンス