C言語 配列 操作 完全ガイド:基礎からポインタ、動的配列まで徹底解説
C言語を学ぶ上で、避けては通れない、そして極めて重要な概念が「配列」です。データ構造の基本中の基本でありながら、その挙動やポインタとの関係性は奥深く、多くのプログラマーがここでつまづきます。しかし、ご安心ください。この記事では、C言語における配列の操作について、初心者の方でも理解できるよう、基礎の基礎から応用、さらにはよくある落とし穴とその対処法まで、徹底的に解説します。
この記事を読み終える頃には、あなたはC言語の配列操作に関する確かな知識と自信を身につけ、より複雑なプログラムも柔軟に設計できるようになっているでしょう。
目次
- C言語配列の基礎の基礎
- 配列とは何か?
- 配列の宣言と初期化
- 要素へのアクセス
- 配列のサイズ取得
- 一次元配列の基本的な操作
- 要素の代入・更新
- 配列の走査(ループ処理)
- 配列のコピー
- 配列の検索
- 配列の並べ替え(ソート)
- 多次元配列を理解する
- 二次元配列とは
- 宣言と初期化
- 要素へのアクセスと走査
- 配列とポインタの密接な関係
- 配列名はポインタ定数である
- ポインタ演算による要素アクセス
- 関数への配列渡しと注意点
- ポインタ配列と配列ポインタの違い
- 動的配列(メモリ割り当て)による柔軟な操作
- なぜ動的配列が必要か
malloc,calloc,realloc,freeの使い方- 動的二次元配列の作成
- C言語配列操作のよくある落とし穴と対策
- 配列の範囲外アクセス
- ヌル終端文字列と文字配列
sizeof演算子の誤解- メモリリーク
- 実践!C言語配列操作の活用例
- スタック/キューの実装
- 行列計算
- ゲーム開発における活用
- まとめと次のステップ
1. C言語配列の基礎の基礎
C言語における配列は、同じデータ型の変数をメモリ上で連続して配置したものです。これにより、複数のデータをまとめて効率的に扱うことができます。
配列とは何か?
想像してみてください。あなたは100人分のテストの点数を管理したいとします。もし配列を使わなければ、score1, score2, ..., score100 のように100個の変数を宣言しなければなりません。これでは非常に非効率的で、管理も大変です。
ここで配列の登場です。配列を使えば、int scores[100]; のようにたった1行で100人分の点数を格納する領域を確保できます。
- 特徴1:同じデータ型
配列のすべての要素は同じデータ型でなければなりません。
int型の配列にはint型のデータのみ、char型の配列にはchar型のデータのみ格納できます。 - 特徴2:連続したメモリ領域 配列の要素はメモリ上で連続して配置されます。これにより、インデックス(添字)を使って高速に目的の要素へアクセスできます。
配列の宣言と初期化
配列を使うには、まず宣言する必要があります。
// int型の要素を5つ持つ配列を宣言
int numbers[5];
このコードは、numbers という名前の int 型の配列を宣言しています。[5] は、この配列が5つの要素(つまり、numbers[0] から numbers[4] まで)を格納できることを意味します。要素数は正の整数リテラルか、コンパイル時に決定される定数式である必要があります。
宣言と同時に初期化することも可能です。
// 宣言と同時に初期化
int scores[5] = {10, 20, 30, 40, 50};
// 要素数を省略することも可能(初期化子の数で自動的に決定される)
int ages[] = {25, 30, 35}; // agesは3つの要素を持つ配列になる
もし初期化子リストの要素数が配列の宣言時の要素数よりも少ない場合、残りの要素はゼロで初期化されます。
int data[5] = {1, 2}; // dataは {1, 2, 0, 0, 0} となる
要素へのアクセス
配列の各要素には、インデックス(添字) を使ってアクセスします。C言語の配列インデックスは 0(ゼロ)から始まります。
int arr[3] = {100, 200, 300};
// 最初の要素にアクセス (インデックス0)
printf("最初の要素: %d\n", arr[0]); // 出力: 最初の要素: 100
// 2番目の要素にアクセス (インデックス1)
printf("2番目の要素: %d\n", arr[1]); // 出力: 2番目の要素: 200
// 最後の要素にアクセス (インデックス2)
printf("最後の要素: %d\n", arr[2]); // 出力: 最後の要素: 300
インデックスが 0 から始まるため、N 個の要素を持つ配列の最後の要素のインデックスは N-1 になります。この点に慣れるまで戸惑うかもしれませんが、非常に重要です。
配列のサイズ取得
配列の要素数を取得する方法はいくつかありますが、最も一般的なのは sizeof 演算子を使用する方法です。
#include <stdio.h>
int main() {
int arr[] = {10, 20, 30, 40, 50};
// 配列全体のサイズ(バイト単位)
size_t total_size = sizeof(arr);
// 要素1つ分のサイズ(バイト単位)
size_t element_size = sizeof(arr[0]);
// 要素数 = 配列全体のサイズ / 要素1つ分のサイズ
size_t num_elements = total_size / element_size;
printf("配列全体のサイズ: %zu バイト\n", total_size);
printf("要素1つ分のサイズ: %zu バイト\n", element_size);
printf("配列の要素数: %zu\n", num_elements);
return 0;
}
出力例:
配列全体のサイズ: 20 バイト
要素1つ分のサイズ: 4 バイト
配列の要素数: 5
(int 型が4バイトの場合)
重要: この sizeof を使った要素数取得方法は、配列が関数に渡されたポインタではない場合にのみ有効です。関数に配列を渡すと、それはポインタとして扱われ、sizeof はポインタ自体のサイズを返してしまいます。(後述「関数への配列渡し」で詳しく解説します。)
2. 一次元配列の基本的な操作
C言語の配列操作の核心は、いかに効率的にデータを読み書きし、管理するかです。ここでは、一次元配列の基本的な操作を具体的に見ていきましょう。
要素の代入・更新
配列の特定の要素の値を変更するには、その要素に直接代入します。
#include <stdio.h>
int main() {
int data[4] = {100, 200, 300, 400};
// 最初の要素を更新
data[0] = 50;
printf("更新後の data[0]: %d\n", data[0]); // 出力: 50
// 3番目の要素 (インデックス2) を更新
data[2] = 999;
printf("更新後の data[2]: %d\n", data[2]); // 出力: 999
return 0;
}
配列の走査(ループ処理)
配列のすべての要素に順番にアクセスすることを「走査(トラバース)」と呼びます。これは通常 for ループを使って行われます。
#include <stdio.h>
int main() {
int numbers[] = {1, 2, 3, 4, 5};
size_t num_elements = sizeof(numbers) / sizeof(numbers[0]);
printf("配列の要素:\n");
for (size_t i = 0; i < num_elements; i++) {
printf("numbers[%zu] = %d\n", i, numbers[i]);
}
// 合計値の計算などにも使える
int sum = 0;
for (size_t i = 0; i < num_elements; i++) {
sum += numbers[i];
}
printf("合計値: %d\n", sum); // 出力: 15
return 0;
}
配列のコピー
C言語では、配列を別の配列に直接 = 演算子で代入することはできません。例えば、int arr1[] = {1, 2, 3}; int arr2 = arr1; のようなコードはコンパイルエラーになります。これは、配列名がその先頭アドレスを示すポインタ定数であるためです。
配列をコピーするには、要素を一つずつコピーするか、memcpy 関数を使用します。
手動で要素をコピー
最も基本的な方法です。
#include <stdio.h>
int main() {
int source[] = {10, 20, 30, 40, 50};
size_t size = sizeof(source) / sizeof(source[0]);
int destination[5]; // コピー先の配列は十分な大きさが必要
for (size_t i = 0; i < size; i++) {
destination[i] = source[i];
}
printf("コピー後の destination 配列:\n");
for (size_t i = 0; i < size; i++) {
printf("%d ", destination[i]); // 出力: 10 20 30 40 50
}
printf("\n");
return 0;
}
memcpy 関数を使用
memcpy は string.h ヘッダーファイルに含まれる関数で、任意のメモリブロックを高速にコピーするために使用されます。
#include <stdio.h>
#include <string.h> // memcpy を使うために必要
int main() {
int source[] = {10, 20, 30, 40, 50};
size_t size = sizeof(source) / sizeof(source[0]);
int destination[5];
// memcpy(コピー先アドレス, コピー元アドレス, コピーするバイト数)
memcpy(destination, source, sizeof(source)); // sizeof(source) は配列全体のバイト数
printf("memcpy でコピー後の destination 配列:\n");
for (size_t i = 0; i < size; i++) {
printf("%d ", destination[i]); // 出力: 10 20 30 40 50
}
printf("\n");
return 0;
}
memcpy は非常に高速ですが、コピー元とコピー先のメモリ領域が重なっていないことが前提です。重なっている場合は memmove を使用すべきですが、配列の単純なコピーでは通常 memcpy で十分です。
配列の検索
配列の中から特定の値を探す操作です。最も単純なのは「線形探索」です。
#include <stdio.h>
#include <stdbool.h> // bool型を使うために必要
int main() {
int arr[] = {15, 23, 7, 42, 10, 30};
size_t size = sizeof(arr) / sizeof(arr[0]);
int target = 42; // 探したい値
bool found = false;
int index = -1;
for (size_t i = 0; i < size; i++) {
if (arr[i] == target) {
found = true;
index = i;
break; // 見つかったらループを終了
}
}
if (found) {
printf("値 %d はインデックス %d で見つかりました。\n", target, index);
} else {
printf("値 %d は配列内に見つかりませんでした。\n", target);
}
// 存在しない値を探す例
target = 99;
found = false;
index = -1;
for (size_t i = 0; i < size; i++) {
if (arr[i] == target) {
found = true;
index = i;
break;
}
}
if (found) {
printf("値 %d はインデックス %d で見つかりました。\n", target, index);
} else {
printf("値 %d は配列内に見つかりませんでした。\n", target); // 出力: 値 99 は配列内に見つかりませんでした。
}
return 0;
}
ソート済みの配列であれば、二分探索などのより高速なアルゴリズムも利用できます。
配列の並べ替え(ソート)
配列の要素を特定の順序(昇順または降順)に並べ替える操作です。多くのソートアルゴリズムがありますが、C標準ライブラリには qsort 関数が用意されており、非常に強力です。
qsort 関数を使用
qsort は stdlib.h に定義されており、クイックソートアルゴリズムに基づいています。任意のデータ型をソートできる汎用性がありますが、比較関数を自作する必要があります。
#include <stdio.h>
#include <stdlib.h> // qsort を使うために必要
// int型を昇順に比較する関数
int compare_ints(const void *a, const void *b) {
// voidポインタをintポインタにキャストし、デリファレンスして値を比較
return (*(int*)a - *(int*)b);
}
// int型を降順に比較する関数
int compare_ints_desc(const void *a, const void *b) {
return (*(int*)b - *(int*)a);
}
int main() {
int arr[] = {5, 2, 8, 1, 9, 4};
size_t num_elements = sizeof(arr) / sizeof(arr[0]);
printf("ソート前: ");
for (size_t i = 0; i < num_elements; i++) {
printf("%d ", arr[i]);
}
printf("\n");
// qsort(配列の先頭アドレス, 要素数, 各要素のサイズ, 比較関数へのポインタ)
qsort(arr, num_elements, sizeof(int), compare_ints);
printf("昇順ソート後: ");
for (size_t i = 0; i < num_elements; i++) {
printf("%d ", arr[i]); // 出力: 1 2 4 5 8 9
}
printf("\n");
// 降順ソートの例
int arr_desc[] = {5, 2, 8, 1, 9, 4};
qsort(arr_desc, num_elements, sizeof(int), compare_ints_desc);
printf("降順ソート後: ");
for (size_t i = 0; i < num_elements; i++) {
printf("%d ", arr_desc[i]); // 出力: 9 8 5 4 2 1
}
printf("\n");
return 0;
}
qsort は、C言語で本格的なソートを行う際の強力なツールです。比較関数の書き方をマスターすれば、構造体のソートなど、より複雑なデータも柔軟に並べ替えられます。
3. 多次元配列を理解する
これまでは一次元配列について見てきましたが、C言語では「多次元配列」もサポートしています。これは、データを格子状や表形式で表現するのに非常に便利です。最も一般的なのは二次元配列です。
二次元配列とは
二次元配列は、行列(マトリックス)や表を表現するのに適しています。例えば、3行4列の表を考えた場合、これを二次元配列で表現できます。
イメージとしては「配列の配列」です。各要素がさらに配列になっているような構造です。
宣言と初期化
二次元配列は、行と列の数を指定して宣言します。
// int型の要素を持つ3行4列の二次元配列を宣言
int matrix[3][4];
初期化も同様に、ネストした {} を使って行います。
int matrix[3][4] = {
{1, 2, 3, 4}, // 0行目
{5, 6, 7, 8}, // 1行目
{9, 10, 11, 12} // 2行目
};
// 行の要素数を省略することはできないが、列の要素数は省略可能 (ただし、初期化子の数で決まる)
// int matrix[][4] = {{...},{...}}; // OK
// int matrix[3][] = {{...},{...}}; // NG
要素へのアクセスと走査
二次元配列の要素には、[行インデックス][列インデックス] の形式でアクセスします。もちろん、インデックスは0から始まります。
走査には、ネストした for ループを使用します。外側のループが行を、内側のループが列を処理するのが一般的です。
#include <stdio.h>
int main() {
int matrix[3][4] = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
// 特定の要素にアクセス
printf("matrix[0][0]: %d\n", matrix[0][0]); // 出力: 1
printf("matrix[1][2]: %d\n", matrix[1][2]); // 出力: 7
printf("matrix[2][3]: %d\n", matrix[2][3]); // 出力: 12
printf("\n--- 行列の全要素を表示 ---\n");
for (int i = 0; i < 3; i++) { // 行のループ
for (int j = 0; j < 4; j++) { // 列のループ
printf("%2d ", matrix[i][j]); // 2桁で表示し、スペースを空ける
}
printf("\n"); // 1行終わったら改行
}
return 0;
}
出力例:
matrix[0][0]: 1
matrix[1][2]: 7
matrix[2][3]: 12
--- 行列の全要素を表示 ---
1 2 3 4
5 6 7 8
9 10 11 12
三次元配列なども同様に、matrix[x][y][z] のように次元を増やして表現できます。ただし、メモリ上でどのように配置されるかを理解しておくことが重要です。C言語では、多次元配列も最終的には一次元の連続したメモリ領域として扱われます(行優先順)。
4. 配列とポインタの密接な関係
C言語の配列操作を真に理解するには、ポインタとの関係を深く掘り下げる必要があります。C言語における配列とポインタは、非常に密接な関係にあり、時に同じように扱われることもあります。
配列名はポインタ定数である
C言語において、配列名(例: int arr[5]; の arr)は、その配列の最初の要素のメモリのアドレスを示す「ポインタ定数」として機能します。
つまり、arr と &arr[0] は同じメモリのアドレスを指します。
#include <stdio.h>
int main() {
int arr[3] = {10, 20, 30};
printf("配列名 arr のアドレス: %p\n", (void*)arr);
printf("最初の要素 &arr[0] のアドレス: %p\n", (void*)&arr[0]);
printf("2番目の要素 &arr[1] のアドレス: %p\n", (void*)&arr[1]);
// arr はポインタとして扱える
int *ptr = arr; // ptr は arr の最初の要素を指す
printf("ptr が指す値: %d\n", *ptr); // 出力: 10
return 0;
}
注意点: arr はポインタ定数なので、arr = another_array; のように別の配列を指すように変更することはできません。ptr = another_array; のように通常のポインタ変数に代入することは可能です。
ポインタ演算による要素アクセス
配列名がポインタであるという性質を利用して、ポインタ演算によって配列の要素にアクセスできます。
arr[i] は *(arr + i) と等価です。
arrは配列の先頭アドレス。arr + iは、先頭アドレスからi個分の要素のサイズだけ進んだアドレス。*(arr + i)は、そのアドレスに格納されている値(つまりarr[i]の値)。
#include <stdio.h>
int main() {
int arr[] = {100, 200, 300, 400};
size_t size = sizeof(arr) / sizeof(arr[0]);
printf("--- 配列インデックスでアクセス ---\n");
for (size_t i = 0; i < size; i++) {
printf("arr[%zu] = %d\n", i, arr[i]);
}
printf("\n--- ポインタ演算でアクセス ---\n");
for (size_t i = 0; i < size; i++) {
printf("*(arr + %zu) = %d\n", i, *(arr + i));
}
// ポインタ変数を使ったアクセス
int *ptr = arr;
printf("\n--- ポインタ変数でアクセス ---\n");
for (size_t i = 0; i < size; i++) {
printf("*(ptr + %zu) = %d\n", i, *(ptr + i));
}
// さらに、ptr++ でポインタを進めることもできる
printf("\n--- ポインタをインクリメントしてアクセス ---\n");
ptr = arr; // ポインタを最初に戻す
for (size_t i = 0; i < size; i++) {
printf("*ptr = %d\n", *ptr);
ptr++; // 次の要素を指すようにポインタを進める
}
return 0;
}
このポインタ演算の理解は、C言語のメモリ管理や関数への配列渡しを理解する上で非常に重要です。
関数への配列渡しと注意点
関数に配列を引数として渡す際、実はその配列の「コピー」が渡されるのではなく、「配列の先頭要素へのポインタ」が渡されます。これを 「配列の減衰(array decay)」 と呼びます。
#include <stdio.h>
// 配列を引数にとる関数 (実際はポインタとして受け取る)
void print_array(int arr[], size_t size) { // int arr[] は int *arr と同じ意味
printf("関数内での配列のサイズ: %zu バイト (これはポインタのサイズです!)\n", sizeof(arr)); // ポインタのサイズが出力される
for (size_t i = 0; i < size; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
int main() {
int my_array[] = {10, 20, 30, 40, 50};
size_t actual_size = sizeof(my_array) / sizeof(my_array[0]);
printf("main関数内での配列のサイズ: %zu バイト\n", sizeof(my_array)); // 配列全体のサイズが出力される
print_array(my_array, actual_size); // 配列と要素数を渡す
return 0;
}
出力例:
main関数内での配列のサイズ: 20 バイト
関数内での配列のサイズ: 8 バイト (これはポインタのサイズです!)
10 20 30 40 50
(int が4バイト、ポインタが8バイトの環境の場合)
この例から分かるように、関数内で sizeof(arr) を計算しても、それは関数に渡されたポインタのサイズ(通常4バイトまたは8バイト)であり、元の配列全体のサイズではありません。そのため、配列を関数に渡す際には、配列の要素数も一緒に引数として渡すのがC言語の慣習です。
ポインタ配列と配列ポインタの違い
名前が似ていますが、全く異なる概念です。
ポインタ配列 (Array of Pointers): ポインタを要素として持つ配列です。例えば、複数の文字列(
char*)を格納する場合によく使われます。char *names[] = {"Alice", "Bob", "Charlie"}; // char* を3つ持つ配列 printf("%s\n", names[0]); // "Alice"namesはchar型ポインタの配列です。各要素が文字列の先頭を指します。配列ポインタ (Pointer to an Array): 配列全体を指すポインタです。あまり頻繁には使われませんが、多次元配列を扱う際などに役立つことがあります。
int matrix[2][3] = {{1,2,3}, {4,5,6}}; int (*ptr_to_array)[3]; // 3つのint要素を持つ配列を指すポインタ ptr_to_array = matrix; // matrixの0行目を指す printf("ptr_to_array[0][1]: %d\n", ptr_to_array[0][1]); // 出力: 2 printf("ptr_to_array[1][0]: %d\n", ptr_to_array[1][0]); // 出力: 4ptr_to_arrayはint [3]型の配列を指すポインタです。つまり、ptr_to_arrayはmatrix[0]を指しており、ptr_to_array + 1はmatrix[1]を指します。
この違いを理解することは、複雑なデータ構造や動的なメモリ割り当てを扱う上で不可欠です。
5. 動的配列(メモリ割り当て)による柔軟な操作
これまでに見てきた配列は、そのサイズをコンパイル時に決定する必要がありました(静的配列)。しかし、プログラムの実行中に必要な配列のサイズが分かったり、配列のサイズを途中で変更したい場合があります。このようなときに「動的配列」が非常に役立ちます。
C言語では、malloc、calloc、realloc、free といった関数を使って、ヒープ領域に動的にメモリを割り当て、配列のように利用することができます。
なぜ動的配列が必要か
- 実行時にサイズを決定したい: ユーザー入力やファイルの内容に基づいて配列のサイズを決めたい場合。
- メモリ効率: 必要な分だけメモリを確保したい。静的配列のように最大の可能性を考慮して大きな配列を確保すると、メモリの無駄が生じる場合があります。
- サイズ変更: プログラムの実行中に配列のサイズを増やしたり減らしたりしたい場合。
malloc, calloc, realloc, free の使い方
これらの関数は stdlib.h ヘッダーファイルに含まれています。
malloc(Memory ALLOCation): 指定されたバイト数のメモリブロックを確保し、その先頭アドレスをvoidポインタとして返します。初期化は行われません(メモリの内容は不定)。#include <stdio.h> #include <stdlib.h> // malloc, free を使うために必要 int main() { int *dynamic_array; int num_elements = 5; // int型を5つ格納するメモリ領域を確保 // (intのサイズ * 5) バイト dynamic_array = (int *)malloc(num_elements * sizeof(int)); // メモリ確保の成否を確認 (重要!) if (dynamic_array == NULL) { fprintf(stderr, "メモリの確保に失敗しました。\n"); return 1; // エラー終了 } // 動的に確保したメモリを配列のように利用 for (int i = 0; i < num_elements; i++) { dynamic_array[i] = (i + 1) * 10; } printf("動的配列の要素: "); for (int i = 0; i < num_elements; i++) { printf("%d ", dynamic_array[i]); // 出力: 10 20 30 40 50 } printf("\n"); // 確保したメモリを解放 (重要!) free(dynamic_array); dynamic_array = NULL; // 解放後にポインタをNULLにすると、ダングリングポインタを防げる return 0; }calloc(Contiguous ALLOCation):mallocと同様にメモリを確保しますが、引数が異なり、確保したメモリブロックを全てゼロで初期化する点が異なります。#include <stdio.h> #include <stdlib.h> int main() { int *dynamic_array; int num_elements = 3; // int型を3つ格納するメモリ領域を確保し、全て0で初期化 dynamic_array = (int *)calloc(num_elements, sizeof(int)); if (dynamic_array == NULL) { fprintf(stderr, "メモリの確保に失敗しました。\n"); return 1; } printf("callocで確保した動的配列の初期値: "); for (int i = 0; i < num_elements; i++) { printf("%d ", dynamic_array[i]); // 出力: 0 0 0 (全てゼロ初期化されている) } printf("\n"); free(dynamic_array); dynamic_array = NULL; return 0; }realloc(RE-ALLOCation): 既にmallocやcallocで確保したメモリブロックのサイズを変更します。新しいサイズが元のサイズより大きい場合、追加のメモリが割り当てられ、元の内容がコピーされます。小さい場合は切り詰められます。新しいアドレスを返す可能性があるので、必ず戻り値を受け取ります。#include <stdio.h> #include <stdlib.h> int main() { int *dynamic_array; int initial_elements = 3; dynamic_array = (int *)malloc(initial_elements * sizeof(int)); if (dynamic_array == NULL) return 1; for (int i = 0; i < initial_elements; i++) { dynamic_array[i] = (i + 1); } printf("初期状態 (3要素): "); // 出力: 1 2 3 for (int i = 0; i < initial_elements; i++) printf("%d ", dynamic_array[i]); printf("\n"); // サイズを5要素に拡張 int new_elements = 5; int *temp_array = (int *)realloc(dynamic_array, new_elements * sizeof(int)); if (temp_array == NULL) { fprintf(stderr, "メモリの再確保に失敗しました。\n"); free(dynamic_array); // 元のメモリはまだ有効なので解放する return 1; } dynamic_array = temp_array; // 新しいアドレスをポインタに代入 // 新しい要素を初期化 for (int i = initial_elements; i < new_elements; i++) { dynamic_array[i] = (i + 1); } printf("拡張後 (5要素): "); // 出力: 1 2 3 4 5 for (int i = 0; i < new_elements; i++) printf("%d ", dynamic_array[i]); printf("\n"); free(dynamic_array); dynamic_array = NULL; return 0; }reallocは失敗するとNULLを返します。このとき、元のメモリブロックは解放されずにそのまま残ります。そのため、reallocの戻り値を直接元のポインタに代入せず、一時的なポインタで受け取ってから代入することが推奨されます。free:malloc,calloc,reallocで確保したメモリブロックを解放し、OSに返却します。freeを忘れるとメモリリークの原因になります。 一度解放したメモリを再度freeしたり、解放済みポインタにアクセスしたりする(ダングリングポインタ)と、未定義動作を引き起こすため注意が必要です。
動的二次元配列の作成
動的な二次元配列は、ポインタの配列として作成することが一般的です。つまり、各行が動的に確保された一次元配列であり、それらの行を指すポインタの配列も動的に確保するという二段階のプロセスになります。
#include <stdio.h>
#include <stdlib.h>
int main() {
int rows = 3;
int cols = 4;
// 1. 行を指すポインタの配列 (int* 型のポインタを格納する配列) を確保
int dynamic_matrix = (int )malloc(rows * sizeof(int *));
if (dynamic_matrix == NULL) return 1;
// 2. 各行の配列を確保
for (int i = 0; i < rows; i++) {
dynamic_matrix[i] = (int *)malloc(cols * sizeof(int));
if (dynamic_matrix[i] == NULL) {
// メモリ確保失敗時のエラー処理と、それまでに確保したメモリの解放
for (int j = 0; j < i; j++) {
free(dynamic_matrix[j]);
}
free(dynamic_matrix);
return 1;
}
}
// 値を代入して表示
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
dynamic_matrix[i][j] = (i + 1) * 10 + (j + 1);
printf("%d ", dynamic_matrix[i][j]);
}
printf("\n");
}
// 3. メモリの解放 (確保した逆の順序で行う)
for (int i = 0; i < rows; i++) {
free(dynamic_matrix[i]); // 各行を解放
}
free(dynamic_matrix); // ポインタの配列を解放
dynamic_matrix = NULL;
return 0;
}
動的二次元配列のメモリ管理は複雑になるため、解放の順序やエラーハンドリングに特に注意が必要です。
6. C言語配列操作のよくある落とし穴と対策
C言語の配列は強力ですが、その柔軟さゆえに、初心者だけでなく経験者でも陥りやすい落とし穴がいくつか存在します。
配列の範囲外アクセス (Out-of-bounds access)
C言語は、配列のインデックスが有効な範囲内にあるかを自動的にはチェックしません。そのため、存在しないインデックスにアクセスしようとすると、メモリ上の不定な領域を読み書きしてしまい、深刻なバグやクラッシュ(セグメンテーション違反)、さらにはセキュリティ上の脆弱性につながる可能性があります。
#include <stdio.h>
int main() {
int arr[5]; // インデックスは 0 から 4 まで
arr[0] = 10;
arr[4] = 50;
// 範囲外アクセス!
arr[5] = 60; // これは未定義動作
printf("arr[5]の値 (未定義動作): %d\n", arr[5]);
// 負のインデックスも範囲外
// arr[-1] = 0; // 未定義動作
return 0;
}
対策:
- 常に配列のサイズとインデックスの範囲を意識する。
- ループ処理では、終了条件が正しいか厳密に確認する。
- デバッグ時にValgrindなどのメモリデバッガツールを利用する。
ヌル終端文字列と文字配列
C言語の文字列は、文字の配列であり、末尾に必ず \0 (ヌル文字) を持つ「ヌル終端文字列」として扱われます。この \0 の存在を忘れると、文字列操作関数が意図しない動作をしたり、バッファオーバーフローの原因になります。
#include <stdio.h>
#include <string.h> // strlen を使うために必要
int main() {
char str1[] = "Hello"; // 内部的には {'H','e','l','l','o','\0'} で6バイト
char str2[5] = "World"; // {'W','o','r','l','d'} となり、\0 が入るスペースがない!
// str2 はヌル終端されていないため、未定義動作を引き起こす可能性がある
printf("str1: %s (長さ: %zu)\n", str1, strlen(str1));
// str2 はヌル終端されていないので、strlenはメモリを探索し続ける可能性があり危険
// printf("str2: %s (長さ: %zu)\n", str2, strlen(str2)); // 危険!
// 正しい使い方
char safe_str[6] = "World"; // \0 のために1バイト余分に確保
printf("safe_str: %s (長さ: %zu)\n", safe_str, strlen(safe_str));
return 0;
}
対策:
- 文字列を格納する文字配列を宣言する際は、ヌル文字1バイト分を考慮して、必要なサイズより1大きいサイズを確保する。
strcpyやstrcatの代わりに、サイズを考慮したstrncpy,strncatや、より安全なsnprintfなどの関数を利用する。
sizeof 演算子の誤解
「配列のサイズ取得」のセクションでも触れましたが、sizeof 演算子は配列とポインタで異なる挙動をします。
- 配列に対して
sizeof: 配列全体のバイト数を返します。 - ポインタに対して
sizeof: ポインタ変数のバイト数(通常4バイトまたは8バイト)を返します。
この違いを理解していないと、特に配列を関数に渡した際に意図しない結果を招きます。
#include <stdio.h>
void func(int arr[]) { // arrはポインタ int* として扱われる
printf("func内でsizeof(arr): %zu バイト\n", sizeof(arr)); // ポインタのサイズが出力される
}
int main() {
int my_array[10]; // 10個のint要素を持つ配列
printf("main内でsizeof(my_array): %zu バイト\n", sizeof(my_array)); // 10 * sizeof(int) バイトが出力される
func(my_array);
return 0;
}
対策:
- 関数に配列を渡す際は、必ず要素数も引数として渡す。
sizeofを使う際には、それが配列に適用されているのか、それともポインタに適用されているのかを意識する。
メモリリーク
動的にメモリを確保する malloc、calloc、realloc を使用した場合、そのメモリは不要になったら必ず free で解放しなければなりません。解放し忘れると、そのメモリはOSに返却されず、プログラムが終了するまで占有され続けます。これが「メモリリーク」です。
長時間稼働するアプリケーションや、メモリ確保を頻繁に行うプログラムでは、メモリリークは深刻な問題となります。
#include <stdio.h>
#include <stdlib.h>
void allocate_and_forget() {
int *data = (int *)malloc(100 * sizeof(int));
if (data == NULL) return;
// ... data を使用 ...
// free(data); // これを忘れるとメモリリーク!
}
int main() {
for (int i = 0; i < 1000; i++) {
allocate_and_forget(); // 1000回メモリリークが発生する
}
printf("プログラム終了。多くのメモリが解放されずに残る可能性があります。\n");
return 0;
}
対策:
mallocやcallocなどでメモリを確保したら、対応するfreeを必ず記述する。- 関数内で確保したメモリを返す場合、呼び出し側が解放する責任があることを明確にする。
- エラー発生時など、例外的なコードパスでもメモリが解放されるよう注意深く設計する。
- Valgrindなどのツールでメモリリークを検出する。
7. 実践!C言語配列操作の活用例
ここまでC言語の配列操作について基礎から応用まで見てきました。最後に、実際のプログラミングで配列がどのように活用されるか、具体的な例をいくつか紹介します。
スタック/キューの実装
データ構造の基本であるスタック(LIFO: 後入れ先出し)やキュー(FIFO: 先入れ先出し)は、配列を使って簡単に実装できます。
スタックの例:
#include <stdio.h>
#define MAX_SIZE 5
int stack[MAX_SIZE];
int top = -1; // スタックの頂点を指すインデックス
void push(int item) {
if (top >= MAX_SIZE - 1) {
printf("スタックオーバーフロー!\n");
} else {
stack[++top] = item;
printf("%d をプッシュしました。\n", item);
}
}
int pop() {
if (top < 0) {
printf("スタックアンダーフロー!\n");
return -1; // エラー値
} else {
int item = stack[top--];
printf("%d をポップしました。\n", item);
return item;
}
}
int main() {
push(10);
push(20);
push(30);
pop();
push(40);
push(50);
push(60); // オーバーフロー
pop();
pop();
pop();
pop(); // アンダーフロー
return 0;
}
行列計算
二次元配列は、数値計算における行列(マトリックス)の表現に最適です。行列の加算や乗算など、さまざまな数学的処理を実装できます。
行列の加算の例:
#include <stdio.h>
#define ROWS 2
#define COLS 3
int main() {
int A[ROWS][COLS] = {{1, 2, 3}, {4, 5, 6}};
int B[ROWS][COLS] = {{7, 8, 9}, {10, 11, 12}};
int C[ROWS][COLS]; // 結果を格納する行列
printf("行列A:\n");
for (int i = 0; i < ROWS; i++) {
for (int j = 0; j < COLS; j++) {
printf("%d ", A[i][j]);
}
printf("\n");
}
printf("行列B:\n");
for (int i = 0; i < ROWS; i++) {
for (int j = 0; j < COLS; j++) {
printf("%d ", B[i][j]);
}
printf("\n");
}
// 行列の加算
for (int i = 0; i < ROWS; i++) {
for (int j = 0; j < COLS; j++) {
C[i][j] = A[i][j] + B[i][j];
}
}
printf("行列A + 行列B (行列C):\n");
for (int i = 0; i < ROWS; i++) {
for (int j = 0; j < COLS; j++) {
printf("%d ", C[i][j]); // 出力: 8 10 12 / 14 16 18
}
printf("\n");
}
return 0;
}
ゲーム開発における活用
ゲーム開発では、配列は非常に頻繁に登場します。
- マップの表現: 二次元配列でゲームのフィールド(床、壁、アイテムなど)を表現します。
char map[HEIGHT][WIDTH]; - インベントリ(持ち物): プレイヤーが持っているアイテムのリストを配列で管理します。
Item player_inventory[MAX_ITEMS]; - スプライトアニメーション: アニメーションの各フレームを指すポインタの配列など。
Image *animation_frames[NUM_FRAMES]; - スコアボード: プレイヤーのハイスコアを記録する配列。
int high_scores[10];
これらの例からも分かるように、配列は単なるデータの格納庫ではなく、様々なアルゴリズムやデータ構造、アプリケーションの中核をなす重要な要素であることが理解できるでしょう。
8. まとめと次のステップ
C言語における配列操作は、プログラミングの基礎であり、同時に奥深いテーマです。この記事では、C言語配列の基本的な宣言と初期化から始まり、一次元・多次元配列の操作、ポインタとの密接な関係、そして動的メモリ割り当てによる柔軟な配列の扱い方まで、幅広く解説しました。さらに、陥りやすい落とし穴とその対策、具体的な活用例もご紹介しました。
配列をマスターすることは、C言語のメモリ管理を理解し、より効率的で堅牢なプログラムを書くための第一歩です。ポインタとの連携や動的メモリ管理は、最初は難しく感じるかもしれませんが、繰り返し練習し、コードを書いていく中で必ず理解が深まります。
次のステップとして、以下のような学習を続けることをお勧めします。
- 練習問題を解く: 実際に配列を使った様々なアルゴリズム(ソート、探索、行列演算など)を自分で実装してみましょう。
- デバッガを活用する: メモリの内容やポインタのアドレスを追いながらコードを実行することで、配列とポインタの挙動がより明確に理解できます。
- 他のデータ構造を学ぶ: 連結リスト、スタック、キュー、ツリー、ハッシュテーブルなど、配列をベースにした、または配列の限界を補う様々なデータ構造について学び、C言語で実装してみましょう。これらは、より複雑な問題解決に役立ちます。
- C標準ライブラリの関数を調べる:
string.hやstdlib.hには、配列や文字列操作に関する多くの便利な関数が用意されています。これらを使いこなすことで、より効率的なコードを書けるようになります。
C言語の配列操作は、他の言語(特に低レベルな操作が可能な言語)での配列やメモリ管理の理解にも繋がる普遍的なスキルです。この記事が、あなたのC言語学習の強力な一助となれば幸いです。プログラミングの旅は続くので、楽しみながら学び続けていきましょう!
I love codes. I also love prompts (spells). But I get a lot of complaints (errors). I want to be loved by both of you as soon as possible.