C における代入式の左辺と右辺の評価順序 #2

C と Java における代入式の左辺と右辺の評価順序 は副作用完了点の完了までの間に、被副作用オブジェクトを複数回参照しているために発生する問題で、こうしたコードは動作未定義という扱いになってしまう。

a[i] = ++i;

これは単に以下のように書き直せばよい。

a[i] = i;
++i;

あるいは

a[i] = i + 1;
++i;

または

a[i + 1] = i;
++i;

もしくは

a[i + 1] = i + 1;
++i;


もう少し複雑なケースではどうだろうか(本当はこっちが本命)。

#include <stdio.h>
#include <stdlib.h>

typedef struct {
    int i;
} foo;

int bar(foo **fpp) {
    *fpp = (foo *)calloc(1, sizeof(foo));
    return 1;
};

int main(int argc, char *argv[]) {
    foo *f = (foo *)calloc(1, sizeof(foo));
    f->i = bar(&f);
    
    printf("f->i=%d\n", f->i);
    return 0;
}

関数にポインタのポインタを渡す、その関数内でポインタの値が変わる、関数の戻り値で元のポインタのメンバに値が設定される。

期待する動作としては、ポインタは更新されつつ、更新されたポインタのメンバに 1 が設定されること。

これを gcc と cl でコンパイルして実行すると、以下のようになる。

gcc

f->i=0

cl

f->i=1

cl では期待通りの動作になっているが、gcc はそのようにはなっていない。

これは副作用完了点とは関係がないので、単に右辺と左辺の評価順序が不定なだけなような気がしている。


該当部分をアセンブラコードで見てみると

cl
    lea     eax, DWORD PTR _f$[ebp] // eax に &f を格納
    push    eax                     // &f をスタックに積む
    call    _bar                    // 関数呼び出し
    add     esp, 4                  // スタック破棄
    mov     ecx, DWORD PTR _f$[ebp] // ecx に &f を格納(メモリから再ロード)
    mov     DWORD PTR [ecx], eax    // f->i に bar の戻り値を代入

gcc
    movl    %eax, -12(%ebp)         // スタックに f を積む
    movl    -12(%ebp), %ebx         // ebx に f を格納
    leal    -12(%ebp), %eax         // eax に &f を格納
    movl    %eax, (%esp)            // &f をスタックに積む
    call    _bar                    // 関数呼び出し
    movl    %eax, (%ebx)            // 戻り値を f->i に代入(メモリから再ロードなし)

ローカル変数 f のアドレスを関数呼出し後にレジスタに読み直しているかどうかで動作が異なる。

cl は読み直しているので、左辺は後で評価される、gcc は読み直していないので先に評価された値がそのまま使われる、ということなのだと思う。

この問題を防ぐには、

    int i = bar(&f);
    f->i = i;

という具合に、副作用のある式で複数回被副作用オブジェクトを参照しないようにすればよいので、やはりこれも副作用完了点に関する問題なのかもしれない。