気まま研究所ブログ

ITとバイク、思ったことをてきとーに書きます。

func(i, i++)の落とし穴 評価順序の違い

Twitterで流れてきてふと気になったので備忘録。
もっぱら高級言語ばっかり触ってきたので一瞬わからなかったのですが、C言語ではこのようなコードはコンパイラ依存で結果が変わります。

ANSI-Cの規格書に目を通したわけではないので間違いがあったらご指摘いただけると幸いです。

検証環境

項目 詳細
OS macOS 10.15.1
CPU Core i5-8210Y
gcc gcc 9.2.0
clang clang-1001.0.46.4

検証コード

#include <stdio.h>

void func(int a, int b) {
    printf("%d %d", a, b);
}

int main() {
    int i = 0;
    func(i, i++);
}

不定と未定義

不定と未定義の違いです。
未規定と表記されることもありますが、どちらも3文字でややこしいのでここでは参考にした初級C言語Q&A(7)に習って不定と表記します。

JIS(JIS X 3010:2003 プログラム言語C - 日本工業規格の簡易閲覧)では不定を以下のように定義しています。

3.4.4   未規定の動作(unspecified behavior)  この規格が,二つ以上の可能性を提供し,個々の場合にどの可能性を選択するかに関して何ら要求を課さない動作。

つまり、言語仕様としては正いものの、実際の処理はコンパイラ依存のことを言います。
例えば、「int value = add(1, 2) + add(3, 4);」みたいなコードは言語仕様として正しいため、必ずコード生成が行われ、動作することが保証されますが、どちらのaddが先に呼び出されるかはコンパイラ依存となります。
なお、この例の場合は式の評価順もそうですが、引数の評価順も不定です。

JISでは未定義を以下のように定義しています。

3.4.3   未定義の動作(undefined behavior)  可搬性がない若しくは正しくないプログラム構成要素を使用したときの動作,又は正しくないデータを使用したときの動作であり,この規格が何ら要求を課さないもの。

    参考  未定義の動作に対して,その状況を無視して予測不可能な結果を返してもよい。翻訳時又はプログラム実行時に,文書化された,環境に特有な方法で処理してもよい(診断メッセージの発行を伴っても伴わなくてもよい。)。さらに(診断メッセージを出力し)翻訳又は実行を中断してもよい。

つまり、言語仕様として定義されていない正しくない動作のことを意味します。
例えば、「int i;」みたいな未初期化変数(C言語 未初期化変数の罠)とか。
これは言語仕様として定義されておらず、コンパイラによっては0初期化してもいいし、初期化せずにメモリの値をそのまま使ってもいいし、エラーとして止まるようにしてもいい。

不定と未定義の決定的な違いは言語仕様的に正しいかどうかです。
不定な処理コードは必ず言語仕様として正しい何らかの動作をすることが保証されていますが、未定義な処理コードは動作する保証はおろか、コンパイルすら通らない可能性があります。 また、未定義な処理の場合は常にそうなるのか、それとも実行する環境に左右されるのかすら定義されていないので非常に危険です。
未初期化変数はその最たる例でしょう。
なお、「int value = add(1, 2) + add(3, 4);」のようなものは不定なため、左右どちらか一方から評価されますが、評価順序に影響されないように組む必要はあります。

未定義な処理はコンパイラで警告を表示するようにすると大抵表示されるので、予めオプションを追加しておくことをお勧めします。(gccなら-Wall)
gccならこんな感じで表示されます。

main.c:22:14: warning: operation on 'i' may be undefined [-Wsequence-point]
   22 |     func(i, i++);

関数の引数の評価順序は不定である

結構盲点になりがちですが、C言語では関数の引数の評価順序は不定です。
今回の例だと、func(i, i++)はどちらから評価されるかがコンパイラによって変わります

ANSI-Cの規格書が手に入らないので読んでいませんが、Programming in ANSI-C, 1.14 Ambiguous Statements, p187にて似たようなコードと説明がありました。

Has an order of evaluation of function parameters been assumed?

Consider:
        myfunc(i++, arr[i]);

Which value of i is used when accessing the array arr? It cannot be assumed
that the parameters are evaluated left to right. Indeed it is very common for
compilers to work the other way round. How the above statement is evaluated
will vary from one compiler to another.

引数の評価順に関する内容ですが、左右どちらから評価するかは決まっておらず、コンパイラ依存だと述べられて居ます。

また、初級C言語Q&A(7)でも同様のことが述べられて居ました。

なお、関数の引数を区切るのに使うカンマは、カンマ演算子ではないため、評 価順序は不定です。

こういったように、func(i, i++)はコンパイラによって渡される引数の値は変わることになります。
例えば、clangだと左から評価されるので実際に実行される関数としては「func(0, 0)」になりますが、gccだと右から評価されるため、「func(1, 0)」となります。
アセンブラを見ても綺麗に逆になっていました。

clang

_func:
    ...
    movl    %edi, -4(%rbp)
    movl    %esi, -8(%rbp)
    movl    -4(%rbp), %esi
    movl    -8(%rbp), %edx
    leaq    L_.str(%rip), %rdi
    movb    $0, %al
    callq   _printf
    ...

_main:
    movl    $0, -4(%rbp)        # int i = 0;
    movl    -4(%rbp), %edi      # %ediにi(0)をコピー
    movl    -4(%rbp), %eax
    movl    %eax, %ecx
    addl    $1, %ecx
    movl    %ecx, -4(%rbp)
    movl    %eax, %esi          # %esiに退避した%eax(0)をコピー
    callq   _func

clangはediレジスタにiの値をコピーします。
次にiの値を退避した後に加算処理をし、退避した値をesiレジスタにコピーします。
funcでは引数で渡されたものを単純にprintfに飛ばすだけですが、movl命令の順番を覚えておいてください。

gcc

_func:
LFB1:
    ...
    movl    %edi, -4(%rbp)
    movl    %esi, -8(%rbp)
    movl    -8(%rbp), %edx
    movl    -4(%rbp), %eax
    movl    %eax, %esi
    leaq    lC0(%rip), %rdi
    movl    $0, %eax
    call    _printf
    ...

_main:
    ...
    movl    $0, -4(%rbp)        # int i = 0;
    movl    -4(%rbp), %eax
    leal    1(%rax), %edx
    movl    %edx, -4(%rbp)
    movl    -4(%rbp), %edx
    movl    %eax, %esi
    movl    %edx, %edi
    call    _func
    ...

gccでは加算前の%eaxを%esiにコピーし、加算します。
なんで「leal 1(%rax), %edx」が加算処理になるの?って感じに理解しきれていませんが、加算らしい。
で、加算後の値を%ediにコピーしています。
あとはそれをfuncでprintfに渡します。
func内でもmovl命令がclangと比べて逆転してるのも評価順が左右逆なことを示しています。

引数が増えるとどうなるかは知らない。

副作用のある変数を同一式内で参照している未定義な処理

これに関しては直接的な原因ではありませんが、副作用のある変数操作を行う場合、その変数を同一式内で参照することは未定義な処理となります。

今回の例では大した問題ではありませんが、「func(array[i], i++)」みたいなことをしていると評価順によっては致命的なバグが発生する可能性があります。
例えば、引数を右から評価するコンパイラならarrayの長さを超えた場所を指定してしまう可能性があります。

また、「array[i] = i++」みたいな処理も未定義処理です。

int array[5];
int i = 0;
while (i < 5)
    array[i] = i++;

通常はarray[0] = 0, ..., array[4] = 4となることを想定しますが、未定義なのでarray[1] = 0, ..., array[5] = 4となってもおかしくないわけです。
なお、gcc, clang共に想定通り動きましたが、未定義なことに変わりはないので避けるべきです。

こういった理由から「func(i, i++)」は未定義な処理となります。

こんなことあったな程度の認識でしたが、改めて調べると理屈がわかってすっきりしました。
お恥ずかしながら未定義と不定の違いはしっかりと認識できていなかったので勉強するとてもいい機会でした。

現代の高級言語を触っているとこういったこととはほぼ無縁となりますが、知っているのと知らないのとでは雲泥の差があるでしょうし、定期的にC言語は勉強したいものです。
とくにWin32APIを触るのなら尚更ですね。

ちなみにC#では評価順がしっかりと定義されています。(関数の引数の評価順序は不定みたい)
優先順位と評価順序

int i = 0;
i = i++;

みたいなヤバイコードでも先の定義と照らし合わせると、=演算子は右から左と定義されているのでi++が先に評価され、i++は後置インクリメントなので加算前の0がiに格納されます。
なので結果は0です。

ただ、定義されているとはいえこういったコードはバグの原因に成りかねないですしぱっと見よくわからないので使わないに越したことはないでしょう。