閉じる

【C言語】配列要素への安全なアクセス方法|範囲外・バグを防ぐ実践例

C言語での配列アクセスはとても高速で便利ですが、その反面少しの油断で簡単にバグやクラッシュにつながります。

本記事では「配列要素への安全なアクセス」に焦点を当て、範囲外アクセスを防ぐ実践的なコードパターンを詳しく解説します。

メモリの仕組みから、安全なループ、動的配列、テストや静的解析まで、一通り押さえられる内容になっています。

C言語の配列アクセスの基本

配列インデックスとメモリの関係

C言語の配列は、メモリ上に同じ型の値が連続して並んでいる領域として確保されます。

例えば次のような配列を考えます。

C言語
#include <stdio.h>

int main(void) {
    int arr[5] = {10, 20, 30, 40, 50};

    // 配列の各要素とアドレスを表示
    for (int i = 0; i < 5; i++) {
        printf("arr[%d] = %d, &arr[%d] = %p\n",
               i, arr[i], i, (void*)&arr[i]);
    }

    return 0;
}
実行結果
arr[0] = 10, &arr[0] = 0x7ffede6015a0
arr[1] = 20, &arr[1] = 0x7ffede6015a4
arr[2] = 30, &arr[2] = 0x7ffede6015a8
arr[3] = 40, &arr[3] = 0x7ffede6015ac
arr[4] = 50, &arr[4] = 0x7ffede6015b0

このように各要素のアドレスは一定の間隔で増えていきます。

int型なら通常4バイトなので、アドレスが4ずつ増加しています。

Cコンパイラはインデックスが範囲内かどうかを自動でチェックしません

単に「先頭アドレス + 型サイズ × インデックス」でアクセスするだけです。

つまり、arr[100]のような明らかにおかしなインデックスでも、コンパイルは通ってしまいます。

範囲外アクセスが危険な理由

配列の範囲外アクセスが危険な理由は、「そのメモリ領域に、他の変数やシステムの重要な情報が存在する可能性がある」からです。

例えば次のコードを見てください。

C言語
#include <stdio.h>

int main(void) {
    int a[3] = {1, 2, 3};
    int secret = 9999;

    // 範囲外書き込み(バグ)
    a[5] = 42;    // 本来あってはならない

    printf("secret = %d\n", secret);

    return 0;
}
実行結果
9999
(多くの場合は見た目上変化なしだが、環境によってはsecretが壊れる可能性あり)

このコードはコンパイルも実行もできます。

しかし動作は未定義(undefined behavior)です。

環境によってはsecretの値が書き換わるかもしれませんし、たまたま何も起きないかもしれません。

「たまたま動いている」状態ほど危険なものはありません

また、配列の範囲外アクセスは次のような深刻な問題につながることがあります。

  • プログラムのクラッシュ(segmentation fault)
  • 他の変数や制御情報の破壊による予測不能な挙動
  • セキュリティホール(バッファオーバーフロー攻撃の入口)

C言語で配列境界チェックが必要な背景

C言語は「低レベル制御」「高速性」を重視した言語です。

その設計思想から、実行時の境界チェックのようなオーバーヘッドのある機能を標準では持ちません。

JavaやC#、Pythonなど多くの高級言語では、arr[10]のように範囲外アクセスをすると例外が発生して即座に検出されます。

しかしC言語では、インデックスが範囲外でもコンパイラも実行環境も基本的には何も言いません。

そのため、配列の境界チェックはプログラマ自身が行う必要があるのです。

本記事のテーマである「安全な配列アクセス」とは、本来Cコンパイラが自動でやってくれないことを、設計とコードパターンで補うということでもあります。

配列要素への安全なアクセス方法

if文によるインデックス範囲チェック

最も基本的な方法は、アクセスの直前にif文でインデックスの範囲を確認するやり方です。

C言語
#include <stdio.h>

#define ARRAY_SIZE 5

int main(void) {
    int arr[ARRAY_SIZE] = {10, 20, 30, 40, 50};
    int index;

    printf("0〜%d の範囲でインデックスを入力してください: ", ARRAY_SIZE - 1);
    if (scanf("%d", &index) != 1) {
        printf("入力エラーです。\n");
        return 1;
    }

    // 安全な範囲チェック
    if (index >= 0 && index < ARRAY_SIZE) {
        printf("arr[%d] = %d\n", index, arr[index]);
    } else {
        printf("インデックスが範囲外です。\n");
    }

    return 0;
}
実行結果
0〜4 の範囲でインデックスを入力してください: 2
arr[2] = 30

「0以上 配列サイズ未満」という条件は、配列アクセスで必ず意識したい基本形です。

なお、Cではインデックスは負の値も指定できてしまうため、index >= 0も必ず含めることが重要です。

関数化で配列アクセスを安全にする方法

毎回if文を書くのは煩雑ですし、チェック漏れの原因にもなります。

そこで「配列アクセス専用の関数」を用意し、そこにチェックを集約する方法が有効です。

C言語
#include <stdio.h>
#include <stdbool.h>

#define ARRAY_SIZE 5

// 配列から安全に値を取得する関数
bool get_array_value(const int arr[], size_t size, size_t index, int *out_value) {
    if (index >= size) {
        // 範囲外
        return false;
    }
    *out_value = arr[index];
    return true;
}

int main(void) {
    int arr[ARRAY_SIZE] = {10, 20, 30, 40, 50};
    int value;

    if (get_array_value(arr, ARRAY_SIZE, 3, &value)) {
        printf("arr[3] = %d\n", value);
    } else {
        printf("インデックスが範囲外です。\n");
    }

    if (get_array_value(arr, ARRAY_SIZE, 10, &value)) {
        printf("arr[10] = %d\n", value);
    } else {
        printf("インデックスが範囲外です。(10)\n");
    }

    return 0;
}
実行結果
arr[3] = 40
インデックスが範囲外です。(10)

このようなラッパ関数を作っておけば、呼び出す側ではifによるチェックを意識する必要が減り、バグを防ぎやすくなります。

size_tと定数マクロを使った安全な添字管理

インデックスや配列サイズの扱いでは、型と定数の管理も重要です。

size_tを使う理由

配列サイズやインデックスにはsize_tを使うのが慣習的なベストプラクティスです。

  • size_t配列サイズやメモリサイズを表すための符号なし整数型
  • 標準ライブラリの関数(sizeofstrlenなど)もsize_tを返す
C言語
#include <stdio.h>

int main(void) {
    int arr[10];

    size_t size = sizeof(arr) / sizeof(arr[0]);  // 要素数
    for (size_t i = 0; i < size; i++) {
        arr[i] = (int)i;
    }

    for (size_t i = 0; i < size; i++) {
        printf("arr[%zu] = %d\n", i, arr[i]);
    }

    return 0;
}
実行結果
arr[0] = 0
arr[1] = 1
...
arr[9] = 9

ここでは%zuという書式指定子を使ってsize_tを表示しています。

定数マクロによるサイズの一元管理

配列のサイズをマジックナンバーではなくマクロや定数として定義することも重要です。

C言語
#define MAX_USERS 100

typedef struct {
    int id;
    char name[32];
} User;

User g_users[MAX_USERS];

このようにしておけば、MAX_USERSを1箇所変更するだけで、関連する全てのコードのサイズが変わります。

「あちこちに生の数字を書かない」ことが、安全な配列アクセスへの第一歩です。

配列サイズと要素数を扱うベストプラクティス

配列を扱うときは、「確保したサイズ」と「実際に使っている要素数」を分けて管理すると安全です。

C言語
#include <stdio.h>

#define CAPACITY 10

typedef struct {
    int data[CAPACITY];  // 確保している配列領域
    size_t size;         // 実際に使用している要素数
} IntArray;

// 要素を追加する安全な関数
int push_back(IntArray *arr, int value) {
    if (arr->size >= CAPACITY) {
        // これ以上追加できない
        return -1;
    }
    arr->data[arr->size] = value;
    arr->size++;
    return 0;
}

int main(void) {
    IntArray arr = {{0}, 0};

    for (int i = 0; i < 12; i++) {
        if (push_back(&arr, i) != 0) {
            printf("%d を追加できませんでした(容量オーバー)\n", i);
        }
    }

    printf("格納された要素数: %zu\n", arr.size);
    for (size_t i = 0; i < arr.size; i++) {
        printf("arr[%zu] = %d\n", i, arr.data[i]);
    }

    return 0;
}
実行結果
10 を追加できませんでした(容量オーバー)
11 を追加できませんでした(容量オーバー)
格納された要素数: 10
arr[0] = 0
...
arr[9] = 9

「容量(capacity)」と「現在の要素数(size)」を分ける発想は、動的配列や標準ライブラリのデータ構造(vectorなど)でも広く使われている定石です。

バグを防ぐための実践テクニック

off-by-oneエラーを防ぐコードパターン

off-by-oneエラーとは、配列アクセスでインデックスを1つ多く(または少なく)してしまう典型的なバグです。

特にループの終了条件で起こりやすいです。

誤りやすい例:

C言語
int arr[10];

// 間違い: i <= 10 だと 10 回 + 1 回 = 11 回アクセスしてしまう
for (int i = 0; i <= 10; i++) {
    arr[i] = i;    // arr[10] は範囲外
}

正しいパターン:

C言語
int arr[10];

// 正しい: i < 10 なら 0〜9 の10要素にのみアクセス
for (int i = 0; i < 10; i++) {
    arr[i] = i;
}

「0から始めるときは< 配列サイズを使う」と覚えておくとミスが減ります。

forループでの安全な配列走査パターン

配列を先頭から順に処理するときの「安全な基本パターン」をいくつか示します。

典型的な走査パターン(要素を書き込み)

C言語
#define ARRAY_SIZE 10

int arr[ARRAY_SIZE];

for (size_t i = 0; i < ARRAY_SIZE; i++) {
    arr[i] = (int)i * 2;
}

実際の要素数(size)を使うパターン

C言語
int arr[100];
size_t size = 0;   // 実際の要素数

// 何らかの処理でsizeまで要素を埋める
// ...

// 実際に入っている要素だけを走査
for (size_t i = 0; i < size; i++) {
    printf("%d\n", arr[i]);
}

このように「配列のサイズ」か「現在の要素数」のどちらか1つをループ条件に使うと、off-by-oneエラーを防ぎやすくなります。

ポインタと配列アクセスを安全に扱うコツ

Cではarr[i]*(arr + i)は同じ意味です。

つまり配列アクセスはポインタ演算でもあるということです。

そのためポインタでも同じように範囲外アクセスの危険があります。

安全なポインタ走査パターン

C言語
#include <stdio.h>

int main(void) {
    int arr[] = {10, 20, 30, 40, 50};
    size_t size = sizeof(arr) / sizeof(arr[0]);

    int *p = arr;             // 先頭要素へのポインタ
    int *end = arr + size;    // 終端(1つ先)を指すポインタ

    // p が end に達するまで進める(範囲外には出ない)
    for (; p < end; p++) {
        printf("%d\n", *p);
    }

    return 0;
}
実行結果
10
20
30
40
50

この「先頭ポインタ」と「終端ポインタ」を使うパターンは、標準ライブラリでもよく使われる書き方です。

常にp < endという条件を守ることで、安全に走査できます。

動的配列(malloc)の範囲外アクセス対策

mallocなどで動的に配列を確保する場合も、「要素数」と「実際にアクセスしてよい範囲」をきちんと管理する必要があります。

C言語
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    size_t n = 10;
    int *arr = malloc(sizeof(int) * n);
    if (arr == NULL) {
        perror("malloc");
        return 1;
    }

    // 安全なアクセス: 0〜n-1 だけを使う
    for (size_t i = 0; i < n; i++) {
        arr[i] = (int)(i * 10);
    }

    // 危険な例 (コメントアウト)
    // arr[n] = 999;  // 範囲外アクセス(やってはいけない)

    for (size_t i = 0; i < n; i++) {
        printf("%d\n", arr[i]);
    }

    free(arr);
    return 0;
}
実行結果
0
10
20
...
90

動的配列では「確保したサイズを忘れる」ことが多いので、関数や構造体でsizecapacityを一緒に持たせて管理するのが有効です。

安全な配列アクセスを支える開発手法

アサーション(assert)によるデバッグ時の検出

assertマクロを使うと、デバッグ時に範囲外アクセスの原因となる不正なインデックスを早期に検出できます。

C言語
#include <stdio.h>
#include <assert.h>

#define ARRAY_SIZE 5

int get_value(const int arr[], size_t size, size_t index) {
    // デバッグ時にインデックスの妥当性を検証
    assert(index < size);
    return arr[index];
}

int main(void) {
    int arr[ARRAY_SIZE] = {1, 2, 3, 4, 5};

    printf("%d\n", get_value(arr, ARRAY_SIZE, 2));

    // わざと不正なアクセス
    printf("%d\n", get_value(arr, ARRAY_SIZE, 10));

    return 0;
}
実行結果
(デバッグビルドの例)
a.out: example.c:9: get_value: Assertion `index < size' failed.
Aborted (core dumped)

assertはNDEBUGマクロを定義すると無効化されるため、本番ではオーバーヘッドなし、開発時のみチェックが可能です。

静的解析ツールによる配列範囲チェック

静的解析ツールを使うと、実行せずにソースコードだけから配列の範囲外アクセスを推測して警告してくれます。

代表的なツールの例を表にまとめます。

ツール名特徴
cppcheckオープンソース。軽量で導入しやすい
clang-tidyClangベース。モダンC/C++向け
Coverity, Klocwork商用ツール。大規模開発でよく利用

静的解析ツールは「人間が見落としやすいパターンのバグ」を網羅的に探すのに向いています。

ただし誤検出もゼロではないため、「警告を参考にコードを改善する」というスタンスで活用するのが現実的です。

テストコードで配列アクセスバグを洗い出す方法

最後に、テストコードによる検証も欠かせません。

特に境界値(0, size-1, size, -1など)を明示的にテストすることで、範囲外アクセスのバグを早期に見つけられます。

簡単なテストの例を示します。

C言語
#include <stdio.h>
#include <stdbool.h>

#define ARRAY_SIZE 5

bool safe_get(const int arr[], size_t size, int index, int *out) {
    if (index < 0 || (size_t)index >= size) {
        return false;
    }
    *out = arr[index];
    return true;
}

// 簡易テスト関数
void run_tests(void) {
    int arr[ARRAY_SIZE] = {10, 20, 30, 40, 50};
    int value;

    struct {
        int index;
        bool expect_ok;
    } cases[] = {
        { 0,  true},
        { 4,  true},
        { 5,  false},
        { -1, false},
    };

    size_t num_cases = sizeof(cases) / sizeof(cases[0]);

    for (size_t i = 0; i < num_cases; i++) {
        bool ok = safe_get(arr, ARRAY_SIZE, cases[i].index, &value);
        printf("index = %d: ok = %s (expected %s)\n",
               cases[i].index,
               ok ? "true" : "false",
               cases[i].expect_ok ? "true" : "false");
    }
}

int main(void) {
    run_tests();
    return 0;
}
実行結果
index = 0: ok = true (expected true)
index = 4: ok = true (expected true)
index = 5: ok = false (expected false)
index = -1: ok = false (expected false)

このようなテストを自動テスト(ユニットテスト)として継続的に実行することで、リファクタリング時などに範囲外アクセスのバグが紛れ込むのを防げます。

まとめ

C言語の配列アクセスは、高速で柔軟である一方、境界チェックが自動で行われないという大きな特徴があります。

そのため、if文による範囲チェック、size_tと定数マクロの活用、容量と要素数の分離管理、off-by-oneを避けるループパターンなど、「人がミスしにくい形」にコードを整える工夫が欠かせません。

さらに、assertや静的解析ツール、テストコードといった開発手法を組み合わせることで、範囲外アクセスのバグを早期に検出できます。

日常的にこれらのパターンを意識しておくことで、C言語での配列操作を安全かつ安心して行えるようになります。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!