ポインタと多次元配列

配列とポインタの関係性について、今度は多次元配列について見てみます。

このページにおいて「二次元配列」「多次元配列」と呼ぶ場合、特に説明がない限り以下の構文による配列を指します。
(要素数などは説明の都合で変わることがあります)


int arr[2][3] = {
    { 1, 2, 3 },
    { 4, 5, 6 }
};

通常の二次元配列

メモリ配置の連続性

一次元配列の場合と同様に、多次元配列も先頭の要素から末尾までがメモリ上に隙間なく並べられます。
これを利用すると、配列の先頭アドレスと全体の要素数さえ分かっているなら多次元配列を一次元配列であるかのように扱うことができます。


int arr[][4] = {
    { 1, 2, 3, 4 },
    { 5, 6, 7, 8 }
};

//配列の先頭要素のアドレス取得
int* p = arr;
for (int n = 0; n < 8; ++n) {
    printf("%d ", p[n]);

    //以下の記述でも同じ
    //printf("%d ", *(p + n));
}
1 2 3 4 5 6 7 8

行のデータ型

二次元配列は以下のような表と考えることができます。


int arr[2][3] = {
    { 1, 2, 3 },
    { 4, 5, 6 }
};
arr[0][0] arr[0][1] arr[0][2]
arr[1][0] arr[1][1] arr[1][2]

二次元配列の「一次元目」は「行」(横方向)を表します。
例えばarr[0]は、「arr[0][0]arr[0][1]arr[0][2]」を要素とする配列です。
「二次元目」は、その行の中の列(縦方向)、つまりその配列の中のひとつの要素です。

二次元配列の一次元目の要素のデータ型は配列へのポインタ型(配列のポインタ型)で、配列要素がint型ならばint*[]型です。
[]にはその配列の要素数が入ります。
上のサンプルコードではint*[3]型ということになります。
これを変数で受け取る場合は少し特殊な記法になるので、以下のコードを参照してください。


int arr[][4] = {
    { 1, 2, 3, 4 },
    { 5, 6, 7, 8 }
};

int (*p1)[4] = arr[0]; //0行目の要素(配列)へのポインタ
int (*p2)[4] = arr[1]; //1行目の要素(配列)へのポインタ

//各要素へのアクセス方法およびそのサイズ
printf("sizeof(arr)\t\t=%zu\n", sizeof(arr));
printf("sizeof(arr[0])\t\t=%zu\n", sizeof(arr[0]));
printf("sizeof(arr[0][0])\t=%zu\n", sizeof(arr[0][0]));
printf("sizeof(p1)\t\t=%zu\n", sizeof(p1));
printf("sizeof(*p1)\t\t=%zu\n", sizeof(*p1));
printf("sizeof(*p1[0])\t\t=%zu\n", sizeof(*p1[0]));
sizeof(arr)             =32
sizeof(arr[0])          =16
sizeof(arr[0][0])       =4
sizeof(p1)              =8
sizeof(*p1)             =16
sizeof(*p1[0])          =4

この実行結果は64ビット環境の場合なので、ポインタのサイズは8バイトになっています。

配列名からポインタ型への変換は、先頭要素へのポインタ型に変換されます。
また、添え字演算子はアドレス演算なので、これもポインタ型が返ります。

上記コードのポインタ変数への代入は、厳密に言えば「ただのポインタ型」から「配列へのポインタ型」への暗黙的な変換が行われています。
C++では異なるポインタ型同士の変換は暗黙的にはできないので、明示的な変換(キャスト)が必要です。


int arr1[][4] = {
    { 1, 2, 3, 4 },
    { 5, 6, 7, 8 }
};

//明示的な型変換
int (*p1)[4] = (int(*)[4])arr1[0];
int (*p2)[4] = reinterpret_cast<int(*)[4]>(arr1[1]);

配列へのポインタであるp1は、それ自体はポインタ変数なのでサイズは通常のポインタ変数と同等です。
(32ビット環境なら4バイト、64ビット環境なら8バイト)
ポインタが指し示す値(*p1)は配列で、データ型はint*[4]型なので、配列のサイズが取得できます。
(今回はint型が4つなので16バイト)
配列の各要素(*p1[0])はint型なので、4バイトが取得されます。

int*[3]型とint*[4]型など、要素数が異なると別の型になります。
C言語のポインタ型同士は暗黙的に変換可能なため、例え要素数の異なる配列のポインタ型同士であっても代入はできてしまいますが、本来と異なる型で扱うと正しく要素アクセスできなくなったりバッファオーバーランの原因となります。

配列へのポインタ型に対する要素アクセスは以下のどちらでも同じ意味になります。



int arr[][4] = {
    { 1, 2, 3, 4 },
    { 5, 6, 7, 8 }
};

int (*p1)[4] = arr[0];
int (*p2)[4] = arr[1];

//要素アクセスは以下のどちらでも同じ意味
*p1[0];
p1[0][0];

二次元配列の行は読み取り専用です。
取得できるアドレスは配列の先頭要素から計算されているだけで、そのアドレスがどこかに保存されているわけではないためです。



int arr[][4] = {
    { 1, 2, 3, 4 },
    { 5, 6, 7, 8 }
};

//書き換えは不可
//(コンパイルエラー)
arr[0] = p2;

「配列へのポインタ型」と「配列の先頭要素へのポインタ型」を混同しないように注意してください。


int arr[][4] = {
    { 1, 2, 3, 4 },
    { 5, 6, 7, 8 }
};

//0行目の要素である配列「へのポインタ型」
int (*p1)[4] = arr[0];

//0行目の要素である配列「の先頭要素へのポインタ型」
int *p2 = arr[0];

//配列へのポインタの先頭要素へのアクセス
int a = *p1[0];
//配列の先頭要素へのポインタの先頭要素へのアクセス
int b = p2[0];

printf("%zu\n", sizeof(p1));    //ポインタ変数のサイズ
printf("%zu\n", sizeof(*p1));   //配列のサイズ
printf("%zu\n", sizeof(*p1[0]));//先頭要素のサイズ
printf("%zu\n", sizeof(p2));    //ポインタ変数のサイズ
printf("%zu\n", sizeof(*p2));   //先頭要素のサイズ
8
16
4
8
4

配列へのポインタ型は、指し示す先が配列であるという情報を持ちます。
具体的にはアドレスと配列の票素数、および要素のデータ型の情報です。
配列の先頭要素へのポインタ型はただのint*型のポインタで、アドレスと指し示す先のデータ型の情報を持っています。
配列に関係なく使用するデータ型なので、要素数の情報は持っておらず、配列を扱う場合は別の何らかの方法で要素数を知る必要があります。

関数に引数として渡す場合

二次元配列を関数の引数に渡す場合のデータ型は、二次元配列の宣言時に使用したデータ型と同じです。
ただし一次元目の要素数を関数側から知る手段はないため、別の引数で渡す必要があります。


void f(int arr[][4], size_t count)
{
    //引数arrは「int*[4]型」となっている

    printf("sizeof(arr)\t\t=%zu\n", sizeof(arr)); //ポインタ変数のサイズ
    printf("sizeof(arr[0])\t\t=%zu\n", sizeof(arr[0]));
    printf("sizeof(arr[0][0])\t=%zu\n", sizeof(arr[0][0]));
    printf("count\t\t\t=%zu\n", count);
}

int main()
{
    int arr[][4] = {
       { 1, 2, 3, 4 },
       { 5, 6, 7, 8 }
    };
    f(arr, sizeof(arr) / sizeof(arr[0]));
}
sizeof(arr)             =8
sizeof(arr[0])          =16
sizeof(arr[0][0])       =4
count                   =2

一次元配列の項では、仮引数に配列型を記述するとポインタに変換されると説明しましたが(→ポインタと配列#関数の仮引数の配列)、これは正確には一次元目がポインタに変換されます。


void f(int arr[][4], size_t count)
{}
//上のコードは
//こう書いたものとみなされる
void f(int *arr[4], size_t count)
{}

仮引数int arr[][4]は、配列へのポインタ型であるint *arr[4]に変換されるため、関数側から配列全体のサイズを知る方法がなくなります。

ジャグ配列

二次元配列を扱う方法として、ジャグ配列と呼ばれる方法もあります。

ジャグ配列は、配列の各要素に配列へのポインタを格納する配列です。
先ほど説明したC言語の二次元配列と同じように見えますが、明確な違いがあります。


int t1[] = { 1, 2, 3 };
int t2[] = { 4, 5 };
int t3[] = { 6, 7, 8, 9 };
//ジャグ配列
int *arr[] = {
    t1,
    t2,
    t3
};

printf("arr[0][0]=%d\n", arr[0][0]);
printf("arr[0][1]=%d\n", arr[0][1]);
printf("arr[1][1]=%d\n", arr[1][1]);

//この三つは同じ意味
int a = arr[2][3];
int b = (*(arr + 2))[3];
int c = *(*(arr + 2) + 3);
arr[0][0]=1
arr[0][1]=2
arr[1][1]=5

ジャグ配列は上記のように、二次元配列の「行」となる一次元配列を先に宣言し、その後にそれらを保存する配列を作ります。
作成されるのは全て一次元配列ですが、配列の要素がポインタの場合は二次元配列と同じような記法で「表」を表すデータ構造を実現できます。
配列を保存する配列なので「配列の配列」とも呼ばれます。

ジャグ配列はC言語の規格にあるわけではなく、構文によるサポートもありません。

なお、通常の二次元配列も一次元目が表すのは配列へのポインタ型なので、これも「配列の配列」と表現することはできます。
しかし「配列の配列」というと通常はジャグ配列を指します。

メモリは連続しない

ジャグ配列はあらかじめ宣言された配列のアドレスを保存する配列です。
これらの配列が保存されるメモリ上の位置はバラバラであるため、ジャグ配列の実データのメモリ上の並びは連続していません。
ジャグ配列の先頭要素のアドレスからポインタ演算で各要素にアクセスすることはできません。

また、各行の配列の宣言とジャグ配列の宣言とは異なる場所で行うことも可能なので、それぞれの配列の寿命が異なる可能性があります。

配列全体のサイズを取得できない

ジャグ配列は二次元配列のように見えて実体は一次元配列なので、全要素の合計サイズは取得できません。


int t1[] = { 1, 2, 3 };
int t2[] = { 4, 5 };
int t3[] = { 6, 7, 8, 9 };
//ジャグ配列
int* arr[] = {
    t1,
    t2,
    t3
};

printf("sizeof(arr)\t\t=%zu\n", sizeof(arr));
printf("sizeof(arr[0])\t\t=%zu\n", sizeof(arr[0]));
printf("sizeof(arr[0][0])\t=%zu\n", sizeof(arr[0][0]));
sizeof(arr)             =24
sizeof(arr[0])          =8
sizeof(arr[0][0])       =4

配列変数に対するsizeof演算子は一次元目の要素全体のサイズとなります。
これを一次元目の要素のサイズ(ポインタのサイズ)で割ることで、ジャグ配列の行の数(内部に保存する配列の数)は計算で取得可能です。
しかし、int型(4バイト)の要素9個=36という値はジャグ配列からは得ることはできません。

行のデータ型

ジャグ配列の一次元目(行)のデータ型は「配列の先頭要素へのポインタ型」、つまりただのポインタ型です。
「配列へのポインタ型」ではないため、そこから配列の要素数を得ることはできません。
逆に言えば要素数の制限がなく、異なる長さの配列を格納できます。

関数に引数として渡す場合

ジャグ配列の要素はポインタ型で、ジャグ配列自体を関数の引数に渡した場合もポインタ型に変換されます。
つまりこれは「ポインタ型を保存するポインタ型」であり「ポインタのポインタ」ということができます。
これをダブルポインタと言います。


//どちらも同じ意味
void f(int **arr) {}

void f(int *arr[]) {}

ダブルポインタは、ポインタが指し示す値がさらにポインタというデータ型です。
実データへのアクセスはジャグ配列と同じ形式(添字演算子)で行えます。

ジャグ配列を関数の引数として渡す場合、関数側からは配列全体の要素数はもちろん、各行の要素数を知る手段もありません。
そのためこれらを別の手段で渡す必要があるのですが、何行あるかわからない配列の各行の要素数を渡すのは少し手間がかかります。


void f(int **arr, size_t count)
{
    printf("sizeof(arr)\t\t=%zu\n", sizeof(arr)); //ポインタ変数のサイズ
    printf("sizeof(arr[0])\t\t=%zu\n", sizeof(arr[0])); //ポインタのポインタのサイズ
    printf("sizeof(arr[0][0])\t=%zu\n", sizeof(arr[0][0]));
    printf("count\t\t\t=%zu\n", count);
}

int main()
{
    int t1[] = { 1, 2, 3 };
    int t2[] = { 4, 5 };
    int t3[] = { 6, 7, 8, 9 };
    //ジャグ配列
    int* arr[] = {
        t1,
        t2,
        t3
    };
    f(arr, sizeof(arr) / sizeof(arr[0]));
}
sizeof(arr)             =8
sizeof(arr[0])          =8
sizeof(arr[0][0])       =4
count                   =3

以下に、関数側からジャグ配列の全要素にアクセスする方法を紹介します。

最後の行に各行の要素数を格納する

ジャグ配列に必要なデータを保存した後に、ジャグ配列の最後の行にそれぞれの行の要素数を保存する方法です。


#include <stdio.h>

void f(int **arr, size_t count)
{
    //最後の行は各行の要素数の配列なので
    //処理から除外するためにcountから-1する
    for (int n = 0; n < count - 1; ++n) {
        //現在の行の要素数
        int r_count = arr[count - 1][n];
        for (int m = 0; m < r_count; ++m) {
            printf("%d ", arr[n][m]);
        }
        printf("\n");
    }
}

int main()
{
    int t1[] = { 1, 2, 3 };
    int t2[] = { 4, 5 };
    int t3[] = { 6, 7, 8, 9 };
    int t4[] = {
        sizeof(t1) / sizeof(int),
        sizeof(t2) / sizeof(int),
        sizeof(t3) / sizeof(int),
    };
    //ジャグ配列
    int* arr[] = {
        t1,
        t2,
        t3,
        t4
    };
    f(arr, sizeof(arr) / sizeof(arr[0]));
}
1 2 3
4 5
6 7 8 9

各行の要素数の配列は、ジャグ配列の先頭に保存したり、ジャグ配列に保存せず別の引数として配列を渡しても同様にできます。

各行の末尾に、絶対に使用されない値を保存しておく

各配列の要素が絶対に使用しない値があらかじめわかっている場合、その値を各配列の末尾に保存しておくことで、配列の終了の目印にする方法です。
以下は正数(プラス値)を有効な値として使用する場合、末尾に負数を保存しておく例です。


#include <stdio.h>

void f(int **arr, size_t count)
{
    for (int n = 0; n < count; ++n) {
        int m = 0;
        //負数が現れるまでループ
        while(arr[n][m] >= 0){
            printf("%d ", arr[n][m]);
            ++m;
        }
        printf("\n");
    }
}

int main()
{
    //各配列の末尾に負数を保存しておく
    int t1[] = { 1, 2, 3, -1 };
    int t2[] = { 4, 5, -1 };
    int t3[] = { 6, 7, 8, 9, -1 };
    //ジャグ配列
    int* arr[] = {
        t1,
        t2,
        t3
    };
    f(arr, sizeof(arr) / sizeof(arr[0]));
}
1 2 3
4 5
6 7 8 9

この手法が使えるかは使用されるデータに依存する上、各行の配列自身に手を加える必要があるのが難点です。

文字列を保存するジャグ配列

C言語のジャグ配列は扱いにくいですが、複数の文字列を保存する用途としては頻繁に使用されます。
C言語の文字列は末尾にNULL文字が必ず含まれ、要素数(文字列の長さ)を容易に取得可能なので、単純な一次元配列と変わらない感覚で使用可能だからです。


#include <stdio.h>

void f(const char** arr, size_t count)
{
    for (int n = 0; n < count; ++n) {
        printf("%s\n", arr[n]);
    }
}

int main()
{
    char str[] = "This is a pen.";
    const char* strings[] = {
        "abc",
        "I am a student boy.",
        str
    };
    f(strings, sizeof(strings) / sizeof(strings[0]));
}
abc
I am a student boy.
This is a pen.

複数の文字列を保存する配列として使用する場合はあまり意識することはないかもしれませんが、異なる長さの配列(とみなすことのできるデータ)をまとめる配列であり、関数の引数はダブルポインタになるので、これもジャグ配列を利用していることになります。