よくある落とし穴と安全なコーディング

C言語の落とし穴は道路の穴のようなものです——無害に見えますが、踏み込むとプログラムがクラッシュします。これらの穴を認識して避けて通れるようになりましょう。

演算子優先順位の落とし穴

C言語には15レベルの演算子優先順位があり、一部の組み合わせは間違いを起こしやすいです:

最もよくある優先順位の間違い

C
int *p = malloc(10 * sizeof(int));
if (p == NULL)

これは間違いではありませんが、次は間違いです:

C
int a = 1, b = 2, c = 3;
int result = a & b == 0;

==&より優先順位が高いため、これは実際にはa & (b == 0)、つまり1 & 0 = 0となり、(a & b) == 0ではありません。

優先順位クイックリファレンス(間違いやすいケース)

落とし穴の式 実際の意味 意図した意味 修正
a & b == 0 a & (b == 0) (a & b) == 0 括弧を追加
a << 2 + 1 a << (2+1) (a<<2)+1 括弧を追加
*p++ *(p++) (*p)++ 括弧を追加
a | b + c a | (b+c) (a|b)+c 括弧を追加
💡 ヒント: 黄金ルール:迷ったら括弧を追加!括弧は無料ですが、バグは高くつきます。

配列の境界外アクセス

C言語は配列のインデックスをチェックしません——境界外アクセスは未定義動作です。ゴミ値を読む、クラッシュする、または「たまたま動く」(これが最も危険)可能性があります。

C
int arr[5] = {1, 2, 3, 4, 5};
arr[5] = 100;
arr[-1] = 99;

どちらの行も境界外書き込みです。コンパイラは文句を言いませんが、他の変数を破壊したりクラッシュしたりする可能性があります。

古典的な境界外:ループのオフバイワン

C
int arr[5];
for (int i = 0; i <= 5; i++) {
    arr[i] = 0;
}

i <= 5i < 5であるべきです。このオフバイワンエラーによりarr[5]が書き込まれます。

文字列の境界外

C
char buf[5];
strcpy(buf, "Hello, World!");

bufは5バイトの領域しかありませんが、文字列には14バイト('\0'を含む)が必要です——典型的なバッファオーバーフローです。

ダングリングポインタ

既に解放されたメモリをまだ参照しているポインタです:

C
int *create_value(void) {
    int x = 42;
    return &x;
}

xはローカル変数です。関数が返るとスタックフレームが解放され、&xはダングリングポインタになります。これにアクセスするとゴミ値が読まれる可能性があります。

C
int *p = malloc(sizeof(int));
*p = 42;
free(p);
printf("%d\n", *p);

free(p)の後、pが指すメモリは返却されています。pを通じてアクセスするのは未定義動作です。

⚠️ 注意: freeの直後にポインタをNULLに設定してください:free(p); p = NULL;。これにより、後で誤って使用しても、追跡困難なランダムエラーよりも少なくともセグフォルトで気づけます。

メモリリーク

確保されたメモリが解放されないことです:

C
void leak_example(void) {
    int *p = malloc(100 * sizeof(int));
    if (p == NULL) return;
    if (some_error) return;
    free(p);
}

some_errorが真のとき、関数は早期リターンし、free(p)がスキップされ、100個のint分のメモリがリークします。

正しいバージョン

C
void no_leak(void) {
    int *p = malloc(100 * sizeof(int));
    if (p == NULL) return;
    if (some_error) {
        free(p);
        return;
    }
    free(p);
}

未定義動作

未定義動作(UB)はC言語で最も危険な概念です——規格は「動作は未定義」と言い、何が起こってもよいことを意味します。

よくある未定義動作

動作 説明
配列の境界外アクセス インデックスが配列の範囲を超える
ヌルポインタの間接参照 *NULL
ダングリングポインタの使用 解放済みメモリへのアクセス
符号付き整数のオーバーフロー INT_MAX + 1
ビット幅を超えるシフト 1 << 100
1つの式で同じ変数を2回変更 i = i++ + 1
ゼロ除算 int x = 1 / 0
初期化されていない変数の読み取り int x; printf("%d", x);

i = i++ + 1の落とし穴:

C
#include <stdio.h>

int main(void) {
    int i = 3;
    i = i++ + 1;
    printf("%d\n", i);
    return 0;
}
▶ 試してみよう

コンパイラによって4、5、またはその他の値が出力される可能性があります。i++iを変更し、代入もiを変更するため、1つの文で同じ変数を2回変更することは未定義動作です。

💡 ヒント: 未定義動作の恐ろしいところは、デバッグモードでは正しく動作するが、最適化ビルドでは壊れる可能性があることです。コンパイラはUBが決して起こらないと仮定し、予期しないコード変換を行う場合があります。

バッファオーバーフローの防止

バッファオーバーフローはC言語で最も有名なセキュリティ脆弱性です。1988年のモリスワームはバッファオーバーフロー攻撃を利用しました。

危険な関数と安全な代替

危険な関数 問題 安全な代替
gets(buf) 長さ制限なし fgets(buf, size, stdin)
strcpy(dst, src) 宛先サイズの確認なし strncpy(dst, src, size-1)
sprintf(buf, fmt, ...) 宛先サイズの確認なし snprintf(buf, size, fmt, ...)
strcat(dst, src) 残り容量の確認なし strncat(dst, src, size-strlen(dst)-1)
scanf("%s", buf) 入力長の制限なし scanf("%99s", buf)またはscanf_s

安全な関数の詳細

strncpy

C
char *strncpy(char *dest, const char *src, size_t n);

最大n文字をコピーします。srcn文字未満の場合、destの残りは'\0'で埋められます。しかし、srcがちょうどn文字の場合、destは自動的にヌル終端されません!

C
#include <stdio.h>
#include <string.h>

int main(void) {
    char buf[6];
    strncpy(buf, "Hello World", sizeof(buf) - 1);
    buf[sizeof(buf) - 1] = '\0';
    printf("%s\n", buf);
    return 0;
}
TEXT
Hello
⚠️ 注意: strncpyは自動的に'\0'を追加しません!コピー後に手動で追加する必要があります。そうしないと、後続の文字列操作が境界外になる可能性があります。

snprintf

C
int snprintf(char *str, size_t size, const char *format, ...);

最大size-1文字を書き込み、自動的に'\0'を追加します。

C
#include <stdio.h>

int main(void) {
    char buf[10];
    int n = snprintf(buf, sizeof(buf), "Value is %d", 12345);
    printf("出力: \"%s\"\n", buf);
    printf("必要な長さ: %d, 実際の容量: %d\n", n, (int)sizeof(buf));
    return 0;
}
TEXT
出力: "Value is 1"
必要な長さ: 14, 実際の容量: 10

安全な入力処理パターン:

C
#include <stdio.h>
#include <string.h>

int main(void) {
    char name[32];

    printf("名前を入力してください: ");
    if (fgets(name, sizeof(name), stdin) == NULL) {
        fprintf(stderr, "入力に失敗しました\n");
        return 1;
    }

    name[strcspn(name, "\n")] = '\0';

    if (strlen(name) == 0) {
        fprintf(stderr, "名前は空にできません\n");
        return 1;
    }

    printf("こんにちは、%sさん!\n", name);
    return 0;
}
▶ 試してみよう
💡 ヒント: fgets + strcspnで改行を削除するのが、ユーザー入力を処理する標準的な安全パターンです。getsは決して使わないでください。

安全な文字列連結:

C
#include <stdio.h>
#include <string.h>

int main(void) {
    char path[256] = "/home/user";
    const char *subdir = "/documents/work/projects/2024";

    size_t current_len = strlen(path);
    size_t remaining = sizeof(path) - current_len - 1;

    if (strlen(subdir) < remaining) {
        strncat(path, subdir, remaining);
    } else {
        fprintf(stderr, "パスが長すぎます、連結できません\n");
        return 1;
    }

    printf("パス: %s\n", path);
    return 0;
}
▶ 試してみよう
TEXT
パスが長すぎます、連結できません

安全なscanfの使い方

scanfは初心者が最もよく使う入力関数であり、同時に最も危険でもあります:

C
char buf[10];
scanf("%s", buf);

ユーザーが9文字以上入力するとオーバーフローします。安全なバージョン:

C
char buf[10];
scanf("%9s", buf);

またはC11をサポートするコンパイラでscanf_sを使います:

C
char buf[10];
scanf_s("%9s", buf, (unsigned)sizeof(buf));
⚠️ 注意: scanf_sはC11のオプション附属書Kの一部です。MSVCはサポートしていますが、gcc/clangはサポートしていない場合があります。クロスプラットフォームのコードではfgets + sscanfの組み合わせを推奨します。

整数のオーバーフロー

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

C
#include <stdio.h>
#include <limits.h>

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

出力は必ずしもINT_MINではありません——コンパイラはこの加算を最適化で削除する可能性があります。

安全な確認方法:

C
#include <limits.h>

int safe_add(int a, int b) {
    if (a > 0 && b > INT_MAX - a) return 0;
    if (a < 0 && b < INT_MIN - a) return 0;
    return a + b;
}

その他のよくある落とし穴

配列パラメータでのsizeof

C
void func(int arr[]) {
    printf("%zu\n", sizeof(arr));
}

配列が関数パラメータとして渡されると、ポインタに崩壅します。sizeof(arr)は配列サイズではなくポインタサイズ(4または8)を返します。長さは別のパラメータとして渡す必要があります。

マクロ定義の落とし穴

C
#define SQUARE(x) x * x
SQUARE(2 + 3)

これは2 + 3 * 2 + 3 = 11に展開され、25になりません。正しいバージョン:

C
#define SQUARE(x) ((x) * (x))

しかしSQUARE(i++)にはまだ問題があります(iが2回インクリメントされる)。インライン関数の方が安全です。

==と=

C
if (x = 5) {
}

これは比較ではなく代入です!代入式の値は5(非ゼロ)なので、条件は常に真になります。一部のコンパイラは警告を出しますが、すべてでこの警告が有効になっているわけではありません。

❓ よくある質問

Q なぜC言語は自動的に配列の境界をチェックしないのですか?
A パフォーマンスのためです。すべてのアクセスでインデックスをチェックするとプログラムが遅くなります。C言語の設計哲学は「プログラマを信頼する」であり、その代償としてプログラマ自身が安全性を確保する必要があります。
Q free(NULL)はクラッシュしますか?
A いいえ。C規格はfree(NULL)が安全であり何もしないことを保証しています。したがって、freeを呼び出す前にポインタがNULLかどうかを確認する必要はありません。
Q メモリリークを検出するにはどうすればよいですか?
A LinuxではValgrindツールを使います:valgrind --leak-check=full ./program。WindowsではVisual StudioのデバッグヒープやAddressSanitizerが使えます。
Q AddressSanitizerとは何ですか?
A コンパイラ統合のメモリエラー検出ツールです。-fsanitize=addressでコンパイルして有効にします。境界外アクセス、解放後使用、メモリリークなどの問題を検出できます。

📖 まとめ

📝 練習問題

  1. オーバーフローせず、常にヌル終端する安全な文字列コピー関数safe_strcpy(char *dst, size_t dst_size, const char *src)を作成してください
  2. 意図的に配列境界外アクセスと整数オーバーフローを起こすプログラムを作成し、AddressSanitizerでコンパイル・実行して出力を観察してください
  3. 次のコードを監査し、すべてのセキュリティ問題を見つけて修正してください:char buf[8]; gets(buf); sprintf(buf, "Result: %d", value);
100%