多次元配列とは、要素そのものが配列である配列のことです。int grid[3][4]; は特別なグリッド型ではありません。4 個の int からなる配列が 3 つ、隙間なく並んでいるだけです。これが腑に落ちれば、残りの話題は自然についてきます。配置、インデックスの計算、そして一見不可解な「関数に渡すときのルール」です。
実用的な用途のほとんどは 2 次元で足ります。グリッド、テーブル、行列、ゲーム盤、画像などです。そこで本ページも 2 次元を軸に進めます。
宣言と初期化
int grid[3][4]; // 3 行 4 列 - int が 12 個
最初の数値が行数、2 つ目が列数です。初期化子はフラットにも内側の波かっこ付きでも書けますが、形が見えるので波かっこを使う価値があります。
int e[][3] の形は重要です。行数は空欄にして初期化子に決めさせられますが、列数は決して省略できません。その理由は次の節で説明します。
行優先(row-major)配置
C は 2 次元配列を行優先順で格納します。行 0 の全体、次に行 1 の全体……と、途切れのない 1 つのメモリブロックに並べます。裏で行ポインタの配列が作られたりはしません。
int grid[3][4] = {{ 1, 2, 3, 4},
{ 5, 6, 7, 8},
{ 9,10,11,12}};
頭の中のイメージ 実際のメモリ上の並び
+----+----+----+----+
| 1 | 2 | 3 | 4 | 行 0 +--+--+--+--+--+--+--+--+--+--+--+--+
+----+----+----+----+ | 1| 2| 3| 4| 5| 6| 7| 8| 9|10|11|12|
| 5 | 6 | 7 | 8 | 行 1 +--+--+--+--+--+--+--+--+--+--+--+--+
+----+----+----+----+ \__ 行 0 ___/\__ 行 1 ___/\_ 行 2 __/
| 9 | 10 | 11 | 12 | 行 2
+----+----+----+----+ grid[i][j] は要素番号 i*4 + j の位置
この i * 列数 + j という式が仕組みのすべてであり、コンパイラが何かを添字付けするために列数を知っていなければならない理由でもあります。行数はこの計算に一切登場しません。
アドレスを表示すれば配置を直接確かめられます。
アドレスは sizeof(int) ずつ、ある行の終わりと次の行の始まりのところも含めて隙間なく増えていきます。平坦化したループがそれを証明しています。flat[k] は 12 個すべての要素を 1 つの連なりとして歩きます。
この配置には知っておく価値のある性能上の帰結もあります。行を外側、列を内側にしてループするとメモリを順番に触るので、CPU キャッシュに好かれます。ループの入れ子を逆にして内側で列方向に下っていくと、毎回 1 行分ジャンプすることになり、大きな配列では数倍遅くなることがあります。
ネストしたループ
2 次元には 2 つの for ループが要ります。外側が行を選び、内側がその行の列を掃きます。
カウンタには意味の分かる名前を付け(行には i/row、列には j/col)、順序はどこでも grid[row][col] に揃えましょう。2 次元配列のバグの半分は、添字を入れ替えてしまったものです。
#define したサイズも飾りではありません。これでループの範囲と宣言が、形を変えたときにずれてしまうことがなくなります。
2 次元配列を関数に渡す
ここが誰もがつまずくルールです。関数の仮引数は列数を宣言しなければなりません。
理由は減衰(decay)です。grid を渡すと、その先頭要素へのポインタに変換されます。そして要素は行なので、型は int (*)[4]、すなわち 4 個の int の配列へのポインタになります。grid[i][j] が意味を持つには、コンパイラが 1 行分の距離を知っていなければならず、それがこの 4 です。行数は本当に型に含まれていないので、別の引数として運ばれます。
int (*grid)[COLS] と int grid[][COLS] は同じ仮引数の 2 通りの書き方であることに注意してください。かっこは必須です。int *grid[COLS] だとポインタの配列になってしまいます。その違いは ポインタと配列 で扱います。
列数が実行時にしか分からない場合、C99 の可変修飾仮引数を使えば先に渡せます。
void print_any(int rows, int cols, int grid[rows][cols]);
rows と cols は、それらを使う配列仮引数より前に宣言しなければなりません。これが使えない場合の一般的な代替は、平坦な 1 次元配列と手計算の添字演算です。
data[i * cols + j] は、サイズ固定の場合にコンパイラが代わりに書いてくれるものそのものです。手で書いても 1 行で済み、実行時に決まるどんな形にも対応できます。
行列の例
行列の掛け算はこのページの内容をすべて結びつけます。行優先の記憶領域に対する 3 重のネストループです。
まねする価値のある点が 2 つあります。内側の k ループは a[i][k] と b[k][j] を組み合わせます。片方の添字が行を歩き、もう片方が列を歩きます。そして転置は内側のループを j = i + 1 から始めます。0 から始めるとすべてのペアを 2 回入れ替えてしまい、行列は元のままになります。
3 次元、そしてその先
このパターンは拡張できますし、関数仮引数のルールも同様です。最初の次元を除くすべての次元を宣言しなければなりません。
実務では、3 次元あたりからサイズ固定の配列は扱いづらく感じられ始め、多くのコードは添字を計算する平坦なブロックか、各軸の意味に名前を付けた 構造体 の配列へ切り替えます。
よくある間違い
grid[i, j]と書く。 カンマ演算子はiを評価して捨て、jで添字付けします。コンパイルは通ります。しかし間違いです。grid[i][j]を使いましょう。- 添字を入れ替える。
grid[col][row]は間違った場所から本物の要素を読むので、捕まえてくれるエラーがありません。どこでも[row][col]の順序を保ちましょう。 - 仮引数で列サイズを省く。
void f(int grid[][])はコンパイルできません。そしてそれはコンパイラがあなたを救っているのです。 - 範囲外に出る。 ほかの 配列 と同じく、境界チェックはありません。
[3][4]のグリッドでgrid[0][5]は黙ってgrid[1][1]を読みます。配置が連続していて、計算はそんなことを気にしないからです。
よくある質問
C で 2 次元配列はどう宣言しますか?
角かっこにサイズを 2 つ書きます。int grid[3][4]; は 4 列の行を 3 つ、合計 12 個の int を宣言します。「3 個の要素からなる配列で、その各要素が 4 個の int の配列」と読んでください。C は文字どおりそのように格納します。
C の 2 次元配列はメモリ上にどう格納されますか?
行優先(row-major)順です。行 0 の全要素、次に行 1 の全要素……という具合に、1 つの連続したブロックに並びます。grid[i][j] は先頭から i * 列数 + j 要素目にあり、だからこそコンパイラが必要とする数値は列数なのです。
C で 2 次元配列を関数に渡すにはどうしますか?
仮引数で列数を宣言しなければなりません。void print(int grid[][4], int rows)、あるいは同じ意味の void print(int (*grid)[4], int rows) です。配列は行へのポインタに減衰するので行数は省略できますが、列サイズがなければコンパイラは行の開始位置を計算できません。
2 次元配列を全部ゼロで初期化できますか?
できます。int grid[3][4] = {0}; は全要素をゼロにします。列挙しなかった要素はゼロ初期化されるからです。int grid[3][4] = {{1, 2}}; は行 0 の最初の 2 つを設定し、残り 10 個はゼロのままになります。