2017年4月25日
私はC言語を知らない
(2015-03-03)by Dmitri Gribenko
本記事は、原著者の許諾のもとに翻訳・掲載しております。
(注:2017/04/27、いただいたフィードバックを元に翻訳を修正いたしました。)
この記事では、皆さん(特にC言語のプログラマ)に「自分はCを分かっていなかった」と気付いてもらうことを目標にしています。
Cの落とし穴は、思っているよりもずっと身近なところにあります。ちょっとしたコードにも 未定義の動作 が潜んでいることを以下で示しましょう。
この記事はQ&A形式になっており、それぞれの例題は独立したソースコードとして扱ってください。
1.
int i;
int i = 10;
Q: これは正しいコードでしょうか? (変数の二重定義エラーが発生するでしょうか。上述の通り、これは独立したソースファイルであり、関数本体や複合ステートメントの一部ではありません)
解答
A: 正しいコードです。1行目は仮定義であり、2行目でコンパイラが処理した後に “定義” になります。
2.
extern void bar(void);
void foo(int *x)
{
int y = *x; /* (1) */
if(!x) /* (2) */
{
return; /* (3) */
}
bar();
return;
}
Q: x
がNULLポインタであっても bar()
が呼び出されました(プログラムはクラッシュしません)。これはオプティマイザのエラーでしょうか。それとも何も問題はないのでしょうか?
解答
A: 何も問題はありません。もし x
がNULLポインタなら、1行目の未定義の動作が発生するので、プログラマには何も保証されません。つまり、この1行目でプログラムがクラッシュするとは限りませんし、2行目でプログラムが制御を返すとも限りません。コンパイラが従う一連の処理ルールについても説明しましょう。まず、コンパイラでは1行目を解析します。そして x
がNULLポインタになり得ないことを確認すると、2行目と3行目の実行されないコードを削除します。変数 y
は未使用なので削除されます。また、 *x
型はvolatile型修飾子とは見なされないため、メモリから読み取られた値も削除されます。
このようにして、未使用の変数によってNULLポインタのチェックが取り除かれます。
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;
}
}
Q: 元の関数と最適化した関数を呼び出して、 zp
で異なる結果を得ることはできるでしょうか?
解答
A: yp == zp
とすると可能です。
4.
double f(double x)
{
assert(x != 0.);
return 1. / x;
}
Q: この関数で inf
が返されることはあるでしょうか? 浮動小数点数は(ほとんどのマシンで)IEEE 754に従って実装されており、 assert
が有効(NDEBUGは未定義)であると仮定します。
解答
A: はい、あります。1e-309などの非正規化した値を x
に渡せば十分です。
5.
int my_strlen(const char *x)
{
int res = 0;
while(*x)
{
res++;
x++;
}
return res;
}
Q: 上記の関数は、ヌル(文字)で終わる行の長さを返す必要があります。バグはどこでしょうか?
解答
A: オブジェクトのサイズを格納するために int
型を使用することは間違っています。なぜならintがオブジェクトのサイズを格納できるという保証はないからです。 size_t
を使うべきでしょう。
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;
}
Q: 上記のコードは無限ループになります。なぜでしょうか?
解答
A: size_t
は符号なしのデータ型です。 i
も符号なしなので、 i >= 0
が常にTRUE(真)となるため、上記は無限ループになります。
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つの異なるコンパイラにかけて、リトルエンディアンのマシンで実行しました。すると、次の通り実行結果が一致しませんでした。
1.v=10 2. v=11 3. v=11
1.v=10 2. v=10 3. v=11
Q: 2番目の結果が得られる理由を説明してください。
解答
A: 上記のプログラムは未定義の動作となります。例えば、エイリアス(別名)の厳格な規則に違反しているためです。コードの2行目で int
の値を変更していますね。すると、 long
の値は変わらないと仮定することができます(互換性のないデータ型を持つ別のポインタのエイリアスは、逆参照できません)。従ってコンパイラは、1行目を実行して読み取った値をそのまま、3行目の long
へ渡します。
8.
#include <stdio.h>
int main(){
int array[] = { 0, 1, 2 };
printf("%d %d %d\n", 10, (5, array[1, 2]), 10);
}
Q: このコードは正しいですか? 未定義の動作ではないとすると、print文で何が出力されるでしょうか?
解答
A: はい、正しいです。ここではコンマ演算子が使われていますね。まず、コンマの左側の引数が計算されて廃棄されます。次に、右側の引数が計算されて、演算子全体の処理結果として使われます。出力結果は「 10 2 10
」となります。
ここで、関数呼び出しにもコンマ記号が使われることに注意してください(例えば f(a(), b())
)。これはコンマ演算子ではありません。従って関数呼び出しの場合は、計算の順序は保証されません。 a()
と b()
は任意の順序で呼び出されます。
9.
unsigned int add(unsigned int a, unsigned int b){
return a + b;
}
Q: add(UINT_MAX, 1)
の実行結果はどうなりますか?
解答
A: 符号なしの数値のオーバーフローが定義されていますね。これは 2^(CHAR_BIT * sizeof(unsigned int))
で計算されます。結果は0となります。
10.
int add(int a, int b)
{
return a + b;
}
Q: では、 add(INT_MAX, 1)
の実行結果はどうなるでしょうか?
解答
A: 符号付きの数値がオーバーフローするので、未定義の動作となります。
11.
int neg(int a)
{
return -a;
}
Q: このコードは、未定義の動作となるでしょうか?仮にそうだとすれば、どの引数でそれが起こるでしょうか?
解答
A: neg(INT_MIN)
です。ECMが追加コード(2の補数)で負の値を表す場合、 INT_MIN
の絶対値は INT_MAX
の絶対値よりも大きく、その差は1となります。この場合、 -INT_MIN
が符号付きのオーバーフローとなるため、未定義の動作になります。
12.
int div(int a, int b){
assert(b != 0);
return a / b;
}
Q: このコードは、未定義の動作となりますか?仮にそうだとすれば、どの引数でそれが起こるでしょうか?
解答
A: ECMが追加コードで負の値を表す場合、 div(INT_MIN, -1)
で未定義の動作となります。理由については、直前の質問の解答を参照してください。
— Dmitri Gribenko gribozavr@gmail.com
この作品は クリエイティブ・コモンズ 表示-継承 3.0 非移植ライセンス の下に提供されています。
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa