Edited at

C言語分かってなかった (I Do Not Know C)

More than 3 years have passed since last update.

Dmitri Gribenko氏によるBlog記事 "I Do Not Know C" より訳出。原文および訳文のライセンスは CC BY-SA 3.0 に従う。


この記事の目的は、皆に(とくにCプログラマに)「C言語分かってなかった」と言わせることです。

C言語の死角は思っているよりも身近にあり、よくある単純なコードですら 未定義動作(undefined behavior) を含む可能性があると示したいと思います。

記事は質問に対する回答の形をとります。全ての例示コードは別々のファイルに分かれていると考えてください。

(訳注:Qiita/Markdown表現の制約から、読中ネタバレ防止のため文章順序を変更しています。前半には質問のみを、後半には質問と回答の対を訳出しました。)


質問編


1.

int i;

int i = 10;

問:このコードは正しいですか?(変数が2回定義されたというエラーにならないでしょうか?これは独立したソースファイルであり、関数本体や複合文(compound statement)の一部でないことを思い出してください。)


2.

extern void bar(void);

void foo(int *x)
{
int y = *x; /* (1) */
if(!x) /* (2) */
{
return; /* (3) */
}
bar();
return;
}

問:x がヌルポインタのとき、bar() が呼び出されるという結果になりました(プログラムはクラッシュしていません)。これは誤った最適化なのでしょうか?それとも何もかも正しい?


3.

次の関数を考えます:

#define ZP_COUNT 10

void func_original(int *xp, int *yp, int *zp)
{
int i;
for(i = 0; i < ZP_COUNT; i++)
{
*zp++ = *xp + *yp;
}
}

下記の通りに最適化しようと思います:

void func_optimized(int *xp, int *yp, int *zp)

{
int tmp = *xp + *yp;
int i;
for(i = 0; i < ZP_COUNT; i++)
{
*zp++ = tmp;
}
}

問:オリジナルの関数と最適化バージョンで、zp に異なる結果が格納されるような呼び出し方はできますか?


4.

double f(double x)

{
assert(x != 0.);
return 1. / x;
}

問:この関数が inf を返すことはありますか?浮動小数点数は(大抵のマシンと同じく)IEEE 754に従って実装されており、assert が有効(NDEBUGは未定義)と仮定します。


5.

int my_strlen(const char *x)

{
int res = 0;
while(*x)
{
res++;
x++;
}
return res;
}

問:上記の関数は、ヌル終端された行の長さを返します。バグを見つけてください。


6.

#include <stdio.h>

#include <string.h>
int main()
{
const char *str = "hello";
size_t length = strlen(str);
size_t i;
for(i = length - 1; i >= 0; i--)
{
putchar(str[i]);
}
putchar('\n');
return 0;
}

問:無限ループになりました。何が起きたのでしょう?


7.

#include <stdio.h>

void f(int *i, long *l)
{
printf("1. v=%ld\n", *l); /* (1) */
*i = 11; /* (2) */
printf("2. v=%ld\n", *l); /* (3) */
}
int main()
{
long a = 10;
f((int *) &a, &a);
printf("3. v=%ld\n", a);
return 0;
}

プログラムを2種類のコンパイラでコンパイルし、リトルエンディアンのマシンで動かします。次の2つの結果が得られました:

1. v=10    2. v=11    3. v=11

1. v=10 2. v=10 3. v=11

問:2つ目の結果について説明できますか?


8.

#include <stdio.h>

int main()
{
int array[] = { 0, 1, 2 };
printf("%d %d %d\n", 10, (5, array[1, 2]), 10);
}

問:このコードは正しいでしょうか?もし未定義動作を含まないとしたら、何が表示されるでしょう?


9.

unsigned int add(unsigned int a, unsigned int b)

{
return a + b;
}

問:add(UINT_MAX, 1) の結果はどうなりますか?


10.

int add(int a, int b)

{
return a + b;
}

問:add(INT_MAX, 1) の結果はどうなりますか


11.

int neg(int a)

{
return -a;
}

問:未定義動作が起こりえるでしょうか?もしあるなら、どんな値のとき?


12.

int div(int a, int b)

{
assert(b != 0);
return a / b;
}

問:未定義動作が起こりえるでしょうか?もしあるなら、どんな値のとき?


回答編


1.


int i;

int i = 10;

問:このコードは正しいですか?(変数が2回定義されたというエラーにならないでしょうか?これは独立したソースファイルであり、関数本体や複合文(compound statement)の一部でないことを思い出してください。)


答:はい。正しいコードです。1行目は、コンパイラが(2行目の)定義を処理した後に"定義"に変化する、仮定義(tentative definition)と呼ばれるものです。

(訳注:ISO/IEC 9899:1999では"tentative definition"、対応するJIS X 3010:2003では"仮定義"という用語を用います。異なるコンパイル単位にint i;int i = 10;をそれぞれ配置すると、仮定義ではなくなり未定義動作を引き起こします。仮定義はC言語固有の仕様であり、C++言語には引き継がれていません。)


2.


extern void bar(void);

void foo(int *x)
{
int y = *x; /* (1) */
if(!x) /* (2) */
{
return; /* (3) */
}
bar();
return;
}

問:x がヌルポインタのとき、bar() が呼び出されるという結果になりました(プログラムはクラッシュしていません)。これは誤った最適化なのでしょうか?それとも何もかも正しい?


答:全てが正しいです。x がヌルポインタの場合、行(1)で未定義動作が生じ、プログラマには何も保証されません:プログラムが行(1)でクラッシュする必要はありませんし、行(2)で実行制御される行(3)で関数から戻ることもあります。仮にコンパイラが従うルールに言及するならば、次の事が全て起こります。コンパイラは行(1)の解析後に、x がヌルポインタには決してならないと考え、行(2)と行(3)をデッドコード(dead code)として取り除きます。変数 y は未使用のため削除されます。*x 型はvolatile修飾されておらず、メモリ読み出しも同様に取り除かれます。

このように、未使用の変数がヌルポインタチェックを削除してしまうのです。

(訳注:納得できない方も居るかと思いますが、C言語の未定義動作とはこういうモノです。Old New Thing: 未定義動作はタイムトラベルを引き起こす(他にもいろいろあるけど、タイムトラベルが一番ぶっ飛んでる) も参考になります。)


3.


次の関数を考えます:

#define ZP_COUNT 10

void func_original(int *xp, int *yp, int *zp)
{
int i;
for(i = 0; i < ZP_COUNT; i++)
{
*zp++ = *xp + *yp;
}
}

下記の通りに最適化しようと思います:

void func_optimized(int *xp, int *yp, int *zp)

{
int tmp = *xp + *yp;
int i;
for(i = 0; i < ZP_COUNT; i++)
{
*zp++ = tmp;
}
}

問:オリジナルの関数と最適化バージョンで、zp に異なる結果が格納されるような呼び出し方はできますか?


答:可能です。yp == zp とします。

(訳注:コンパイラはこのような最適化を勝手に行えません/行ってはくれません。ちなみに、const修飾(const int *xp, const int *yp)は最適化処理に影響しません。C99以降ではrestrict修飾により最適化を許可できますが、誤使用は解析困難なバグの温床になるため、取扱いには注意が必要です。)


4.


double f(double x)

{
assert(x != 0.);
return 1. / x;
}

問:この関数が inf を返すことはありますか?浮動小数点数は(大抵のマシンと同じく)IEEE 754に従って実装されており、assert が有効(NDEBUGは未定義)と仮定します。


答:はい、ありえます。x に 1e-309 のような非正規化(denormalized)数を渡せば十分です。

(訳注:非正規化数は、通常の浮動小数点数(正規化数)で表現可能な値よりも、さらにゼロ近傍の"小さな"値を表現したものです。IEEE 754 doubleの場合、最も小さい正規化数はおおそ 2.2e-308 であり、1e-309 はさらに小さい値となります。非正規化数については 浮動小数点 Tips も参考になります。)


5.


int my_strlen(const char *x)

{
int res = 0;
while(*x)
{
res++;
x++;
}
return res;
}

問:上記の関数は、ヌル終端された行の長さを返します。バグを見つけてください。


答:オブジェクトサイズの格納に int 型を用いるのは誤りです。int ではあらゆるオブジェクト型のサイズを格納できる保証がありません。ここは size_t を使うべきです。

(訳注:C言語仕様では正のint最大値は15bit分、つまり (2^15 - 1) 以上であることしか保証されせん。sizeof(int) < sizeof(size_t)となる処理系を考慮すべきです。身近なケースでは、LLP64/LP64モデルの64bitコンパイルが該当します。)


6.


#include <stdio.h>

#include <string.h>
int main()
{
const char *str = "hello";
size_t length = strlen(str);
size_t i;
for(i = length - 1; i >= 0; i--)
{
putchar(str[i]);
}
putchar('\n');
return 0;
}

問:無限ループになりました。何が起きたのでしょう?


答:size_t は符号なし整数型です。i が符号なしの場合、i >= 0 は常に真となります。


7.


#include <stdio.h>

void f(int *i, long *l)
{
printf("1. v=%ld\n", *l); /* (1) */
*i = 11; /* (2) */
printf("2. v=%ld\n", *l); /* (3) */
}
int main()
{
long a = 10;
f((int *) &a, &a);
printf("3. v=%ld\n", a);
return 0;
}

プログラムを2種類のコンパイラでコンパイルし、リトルエンディアンのマシンで動かします。次の2つの結果が得られました:

1. v=10    2. v=11    3. v=11

1. v=10 2. v=10 3. v=11

問:2つ目の結果について説明できますか?


答:このプログラムは未定義動作を含んでいます。つまり、厳密な別名付けルール(strict aliasing rules)に違反しています。行(2)では int を変更しています。そのため、他の long は変更されないと仮定して良いのです。(適合しない型(incompatible type)の別名となっているポインタの参照はがしは行えません。)これが行(1)実行中に読み取った long 値を、コンパイラが行(3)にも同じ値として渡せる理由です。

(訳注:ISO/IEC 9899:1999では"incompatible"、対応するJIS X 3010:2003では"適合しない"という用語を用います。"適合(compatible)"や"strict aliasing rules"については JPCERT - EXP39-C. 適合しない型のポインタを使って変数にアクセスしない も参考になります。)


8.


#include <stdio.h>

int main()
{
int array[] = { 0, 1, 2 };
printf("%d %d %d\n", 10, (5, array[1, 2]), 10);
}

問:このコードは正しいでしょうか?もし未定義動作を含まないとしたら、何が表示されるでしょう?


答:正しいです。ここではコンマ演算子(comma operator)が使われています。まず、コンマの左辺が計算されて値が破棄されます。つづいて右辺が計算され、演算子全体の値として用いられます。出力は 10 2 10 となります。

関数呼び出しでのコンマ文字(例えば f(a(), b()))はコンマ演算子ではなく、そのため関数引数の計算順序は保証されません:a()b()は任意の順序で呼び出されます。


9.


unsigned int add(unsigned int a, unsigned int b)

{
return a + b;
}

問:add(UINT_MAX, 1) の結果はどうなりますか?


答:符号なし整数でのオーバーフローは定義されており、2^(CHAR_BIT * sizeof(unsigned int)) から算出されます。結果は 0 となります。

(訳注:ここでの^記号はC言語のビット単位XOR演算ではなく、数学のべき乗演算を表します。)


10.


int add(int a, int b)

{
return a + b;
}

問:add(INT_MAX, 1) の結果はどうなりますか


答:符号付き整数のオーバーフローは ― 未定義動作です。


11.


int neg(int a)

{
return -a;
}

問:未定義動作が起こりえるでしょうか?もしあるなら、どんな値のとき?


答:neg(INT_MIN)。電子計算機が負数を2の補数として表現するなら、INT_MIN の絶対値は INT_MAX の絶対値よりも1だけ大きくなります。このケースでは、-INT_MIN は符号付きオーバーフローを引き起こし、未定義動作となります。

(訳注:C言語仕様では、符号付き整数型の表現方式を規定しません。原文では"additional code (two's complement)"となっていますが、ここでの日本語訳は"2の補数"を優先しました。"additional code"は、2の補数表現での符号反転処理が ビット反転後に+1加算 で行えることから来るようです。たぶん。)


12.


int div(int a, int b)

{
assert(b != 0);
return a / b;
}

問:未定義動作が起こりえるでしょうか?もしあるなら、どんな値のとき?


答:電子計算機が負数を2の補数として表現するなら、div(INT_MIN, -1) ― あとは前質問を参照してください。