🤖

インクリメント演算子とコンパイラの気持ち

2021/12/04に公開

はじめに

突然ですがクイズです。以下のコードの実行結果はどうなるでしょうか?

#include <stdio.h>

int main(){
  int a = 1;
  int b = ++a + ++a + ++a;
  printf("%d\n",b);
}

結果は処理系に依存し、gccなら10に、clangなら9になります。これが未定義動作なのか処理系定義なのか、それとも他の何かは仕様警察に任せるとして、gccやclangはこれをどう解釈したのか、その気持ちを探ってみようと思います。

なお、以下は中間コードその他から僕が勝手にコンパイラの気持ちを推し量ったものであり、正確性については保証しません。

インクリメント演算子

++というインクリメント演算子は、変数に1を加算するものです。++aa++のように、前に付ける場合と後ろにつける場合で、代入に対する振る舞いが変わります。

前につける場合、インクリメントは代入の前に行われます。例えば、

int a = 1;
int b = ++a;

と書いた時には、

int a = 1;
a = a + 1
int b = a;

と等価です。したがってb=2になります。

後ろにつけた場合は、インクリメントは代入の後に行われます。したがって、

int a = 1;
int b = a++;

int a = 1;
int b = a;
a = a + 1

と等価であり、b=1になります。

さて、このインクリメント演算子は、しばしば問題を引き起こします。例えば加算の両側に現れた場合、その解釈に曖昧さが現れるからです。

以下の例を考えましょう。

int a = 1;
int b = ++a + ++a;

このとき、bの値はいくつになるべきでしょうか?この時既に、gccとclangで意見が食い違います。gccは6、clangは5と解釈します。これがどういうことなのかを見てみましょう、というのがこの記事の目的です。

clangの気持ち

clangの気持ちを見るには、LLVM中間コードを見るのが手っ取り早いです。

例えばこんなコードを書いてみます。

int func(int a){
  return ++a;
}

このLLVM中間コードを見てやりましょう。

clang -emit-llvm -S test.c

すると、test.llができるので中身を見てやります。

; Function Attrs: noinline nounwind optnone uwtable
define dso_local i32 @func(i32 %0) #0 {
  %2 = alloca i32, align 4
  store i32 %0, i32* %2, align 4
  %3 = load i32, i32* %2, align 4
  %4 = add nsw i32 %3, 1
  store i32 %4, i32* %2, align 4
  ret i32 %4
}

ごちゃごちゃしていますが、ゆっくり眺めると、インクリメント演算子を一時変数に受けて、代入はその一時変数の値を使うことがわかります。つまり、

int b = ++a;

は、

int tmp = a + 1;
a = tmp;
int b = tmp;

という解釈をします。これがわかるのが、LLVMの最後のstoreです。先程のLLVMをCっぽく書くと、

int func(int a){
  int tmp = a + 1;
  a = tmp;
  return tmp;
}

となります。aは使われないのに、a=tmpという代入文があります。それに対応しているのがstore i32 %4, i32* %2, align 4です。

さて、++aint tmp=a+1;a=tmpに変換されるとわかると、clangの「気持ち」が理解できます。

以下のコードを考えましょう。

int a = 1;
int b = ++a + ++a;

++aは、一時変数tmp = a + 1;に変換され、++aの値としてtmpを使う、というルールから、まず左側の++aはこのように変換されます。

int a = 1;
int tmp1 = a + 1;
a = tmp1;
int b = tmp1 + ++a;

右側の++aも同様に変換されます。

int a = 1;
int tmp1 = a + 1;
a = tmp1;
int tmp2 = a + 1;
a = tmp2;
int b = tmp1 + tmp2;

この動作を追うと、tmp1=2tmp2=3になるため、結果は2 + 3 = 5になります。

全く同様にして、

int a = 1;
int b = ++a + ++a + ++a;

は、

int a = 1;
int tmp1 = a + 1; // tmp1 = 2
a = tmp1;
int tmp2 = a + 1; // tmp2 = 3
a = tmp2;
int tmp3 = a + 1; // tmp3 = 4
a = tmp3;
int b = tmp1 + tmp2 + tmp3;

に変換され、答えは2 + 3 + 4 = 9になります。これが、冒頭のコードの実行結果が、clangでは9になる理由です。

GCCの気持ち

次に、GCCの気持ちを探ってみましょう。こんなコードを書きます。

int func(int a){
  return ++a + ++a;
}

gccに-fdump-tree-allオプションをつけてコンパイルすると、コンパイルがこのコードをどのように解釈したかがわかります。

gcc- c -fdump-tree-all test.c

ファイルがごちゃごちゃできますが、見るのはtest.c.005t.gimpleです。

func (int a)
{
  int D.1914;

  a = a + 1;
  a = a + 1;
  D.1914 = a * 2;
  return D.1914;
}

わかりやすく書き直すとこうでしょうか。

int func(int a){
  a = a + 1;
  a = a + 1;
  int tmp = a + a;
  return tmp;
}

つまり、GCCは、+の両側に現れたインクリメント演算子をまず処理していまい、その後に加算を実行していることがわかります。これにより、

int a = 1;
int b = ++a + ++a;

は、

int a = 1;
a = a + 1;
a = a + 1;
tmp = a + a
int b = tmp;

と書き直されるため、b=6になります。

では、三段ではどうでしょうか。こんなコードを書きましょう。

int func(int a){
  return ++a + ++a + ++a;
}

これを-fdump-tree-allをつけてコンパイルします。

gcc -c -fdump-tree-all test.c

またたくさんファイルができますが、まずはtest.c.004t.originalを見てみましょう。

{
  return ( ++a +  ++a) +  ++a;
}

先に左側の+を処理することにしたようです。次にtest.c.005t.gimpleを見てみましょう。

func (int a)
{
  int D.1914;

  a = a + 1;
  a = a + 1;
  _1 = a * 2;
  a = a + 1;
  D.1914 = a + _1;
  return D.1914;
}

少し整理するとこうなります。

int func(int a){
  a = a + 1;
  a = a + 1;
  int tmp1 = a + a;
  a = a + 1;
  int tmp2 = tmp1 + a;
  return tmp2;
}

どうやってこうなったか、気持ちを推し量ってみましょう。オリジナルのコードはこうです。

int func(int a){
  return ++a + ++a + ++a;
}

まず、コンパイラは先に左側の加算を実行することにしました。

int func(int a){
  return (++a + ++a) + ++a;
}

左側を一時変数tmp1に受けます。

int func(int a){
  int tmp1 = ++a + ++a;
  return tmp1 + ++a;
}

++a + ++aは、両方のインクリメント演算子を加算の前に解決してしまいます。

int func(int a){
  a = a + 1;
  a = a + 1;
  int tmp1 = a + a;
  return tmp1 + ++a;
}

次に、tmp1 + ++aですが、まず一時変数tmp2にうけましょう。

int func(int a){
  a = a + 1;
  a = a + 1;
  int tmp1 = a + a;
  int tmp2 = tmp1 + ++a;
  return tmp2;
}

tmp1 + ++aのインクリメント演算子を解決します。

int func(int a){
  a = a + 1;
  a = a + 1;
  int tmp1 = a + a;
  a = a + 1;
  int tmp2 = tmp1 + a;
  return tmp2;
}

以上から、GCCは

int a = 1;
int b = ++a + ++a + ++a;

int a = 1;
a = a + 1;
a = a + 1;          // a = 3
int tmp = a + a;    // tmp = 3 + 3
a = a + 1;          // a = 4
int tmp2 = tmp + a; // 3 + 3 + 4
int b = tmp2;

と解釈し、b = 3 + 3 + 4 = 10となります。これが、冒頭のコードの実行結果がgccでは10になる理由です。

まとめ

前置インクリメント演算子が加算の両側に現れたとき、その結果(というか解釈)が処理系に依存すること、さらにgccとclangがそれぞれどのように解釈したか、その気持ちを推し量ってみました。

他にインクリメント演算子のある言語においてa=1のときに++a + ++a + ++aの値がどうなるか調べたところ、clang派が多いようですが、PerlはGCCと同じ10を返しました。

インクリメント演算子はトラブルのもとだからか、採用しないことにした言語も結構あります(RubyやPythonなど)。

また、Goのように前置インクリメントはなく、後置インクリメントだけにして、さらに「文」として値を返さないことにした言語もあります。これにより、

a++

はできますが、

b = a++

といった代入はできません。また、インクリメント演算子は関数の引数に入れた場合も問題を起こします。例えばこんなコードを考えましょう。

#include <stdio.h>

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

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

これは、gccでコンパイルしても、x86では「3 2 1」に、ARMでは「1 2 3」になります。

Goのように、「文」にしてしまうと、関数の引数に突っ込むこともできないため、この問題は発生しません。

というわけで、インクリメント演算子はいろいろ面倒なので注意しましょう。個人的には「採用はするけど文にする」というGoの方針が一番しっくりくるかな、という気がしました。

GitHubで編集を提案

Discussion