C言語で、ある関数に2次元配列を渡したいことがあった。しかし、2次元配列をどのように渡せばいいか、関数の中で配列要素にどのようにアクセスしたらよいか がイマイチ曖昧で悩む事があった。そもそも配列を引数で渡すという概念がなくて、先頭要素のポインタを渡せばいい事くらいは知っていたのだけど、2次元配列になるとよく分からなかった。なので、配列がいかにメモリ空間に格納されているのかを勉強し直してみた。
ちなみに、本記事を書くにあたってこの本で勉強した↓
C言語のポインタだけにフォーカスした本で、
「ポインタ演算なんてやめてしまえば?」
とか
「*++argv[0]なんてワケのわからない表現を嬉しそうに使われても困ります」
とか、ちょっとクスっとしてしまうけど、ポインタへの理解が一段階も二段階も深まる名著。
ポインタに何かモヤモヤを感じている人は是非読んでみてください。全力でオススメできます。普通に読み物としても面白いです。
そもそも配列とは
配列とは、同一の型のデータを一まとめにして扱うことのできる変数。宣言したときに、定義したデータ長分のメモリが確保され、各値が書き込まれる。
1 |
uint8_t array[4] = { 0x0C,0x0A,0x08,0x04 }; |
例えば上記のコードだと、4byte分のメモリが確保され、1byteずつ計4つのデータが格納される。
配列の要素にアクセスするときはarray[2]
のような形でアクセスできる。この場合は、3つめの要素0x08
を取り出すことができる。宣言のときは[]内が1始まりで、プログラム中では0始まりなのがややこしくてムカつく。
実際にメモリにどのように格納されているか見てみる
では、VisualStudioのデバッガを使って配列がどのようにメモリに格納されているのか見てみることにする。VisualStudioにはメモリウィンドウという、メモリの中身を観測できるモニタがあるので、これを使う。
下記のようなテストプログラムを用意した。
1 2 3 4 5 6 7 8 9 |
#include <stdio.h> #include <stdint.h> int main() { uint8_t array1[4] = { 0x23,0xA0,0x48,0xFB }; printf("The address of array1 is 0x%p.\n", &array1[0]); return 0; } |
ただ配列を宣言して、先頭要素のアドレスをprintfするだけのプログラム。
デバッグモードで実行し、main()関数の最後のreturnにブレークポイントをおいた。
メモリウィンドウの「アドレス:」という部分に変数名を入れると、その変数が格納されているメモリが表示される。
array1のメモリを見てみると、アドレス0x010FFE74 – 0x010FFE77まで、連続して配列の要素が格納されていることが分かる。
printfの結果もメモリモニタで確認しているように下記のようになる。
1 |
The address of array1 is 0x010FFE74. |
データ型を変えてみるとどうなるか
それでは、データ型をuint8_tからuint32_tにしてみるとどうなるのか。
先ほどのコードの配列のデータ型を変えて試してみる。
世の中のPCはバイトアクセスなので、1アドレスに対して1byteのデータを格納するようにできている。uint32_tは32bit=4byteなので1要素に対して4アドレス分の領域が確保される。
printfの結果は下記のようになる。
1 2 3 4 |
The address of array1[0] is 0x00AFFDA0. The address of array1[1] is 0x00AFFDA4. The address of array1[2] is 0x00AFFDA8. The address of array1[3] is 0x00AFFDAC. |
&array1[N]
のように要素のアドレスを取り出してみると、その要素が格納されているアドレス領域の先頭アドレスを示す。
ポインタに1を足したらどうなるか?
次に、&array1[0]
のポインタをインクリメントしたらどうなるか試してみる。
C言語を始めたころ曖昧だったのは、ポインタに1を足したときポインタが0x1アドレスだけ移動するのか、それとも次の要素のアドレスに移動するのかということだった。実際に試してみる。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#include <stdio.h> #include <stdint.h> int main() { uint32_t array1[4] = { 0x1045AB23,0xA0991122,0x48986754,0xFBCD9A80 }; uint32_t* ptr = &array1[0]; printf("ptr is 0x%p.\n", ptr); ptr++; printf("ptr+1 is 0x%p.\n", ptr); ptr++; printf("ptr+2 is 0x%p.\n", ptr); ptr++; printf("ptr+3 is 0x%p.\n", ptr); ptr++; return 0; } |
array[0]のアドレスをポインタ型変数ptrに代入し、インクリメントしてprintfを繰り返している。
printfの結果は以下のようになる。
1 2 3 4 |
ptr is 0x006FFA54. ptr+1 is 0x006FFA58. ptr+2 is 0x006FFA5C. ptr+3 is 0x006FFA60. |
見ての通り、4アドレスずつ移動していることが分かる。
プログラミングを始めたころは、ポインタ型にデータ型が指定されていることが理解できなかった。なぜなら、どんなデータ型であってもアドレスの桁数は同じはずだからだ。でもこうやって実際に動かしてみると、ポインタのデータ型は変数1つ(配列の場合は要素一つ)が格納されたアドレスの範囲を示すのに必要なことが分かる。例えば、今回の例ではptr
をuint32_t*
として宣言しているから、ポインタに1ずつ足していくと4アドレスずつポインタが移動していく。
実はuint32_t array1[4]
の要素アドレスを示すポインタptr
は必ずしもuint32_t*
で宣言しないといけない訳ではなく、uint8_t*
で宣言しても問題ない。ただし、この場合はポインタを1ずつインクリメントした場合1アドレス(1byte)ずつ移動してしまうことになるので、要素を移動するには4ずつインクリメントしなければいけない。
まぁ、煩雑になるだけなので普通はこんなことはしないでしょう。
配列の名前は配列の先頭要素のポインタを示す
配列を複製するときに使うmemcpy()などの関数は、引数に配列の名前を入れるように扱う。例えば、array2をarray1にコピーするときなんかは以下のように書く。
1 |
memcpy(array1, array2, sizeof(array2)); |
一見、「配列」を引数として渡しているようにも見えるが、先述の通りC言語にはそのような仕様がない。memcpyの仕様を読んでみると、第一, 第二引数はポインタ型の引数となっている。つまり、上の例で”array1″, “array2″と記述されている部分はポインタであるという事。
では、実際にどのようなポインタになっているのか、以下のようなコードで確認してみる。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
#include <stdio.h> #include <stdint.h> int main() { uint32_t array1[4] = { 0x1045AB23,0xA0991122,0x48986754,0xFBCD9A80 }; printf("array1 is 0x%p.\n", array1); printf("&array1[0] is 0x%p.\n", &array1[0]); return 0; } |
出力結果は下記のようになる。
1 2 |
array1 is 0x00F6FC40. &array1[0] is 0x00F6FC40 |
このように、“array1″は配列array1[]の先頭要素のポインタであることが分かる。
これを応用すると、以下のように考えることができる。
*array1
は array[0]
*(array1+1)
は array[1]
*(array1+2)
は array[2]
つまり、配列は何も特別な変数ってわけでもなくて、「連続的なメモリ空間に連続して変数を格納した変数」という話で、”[ ]”っていうのは各変数のポインタへのアクセスを分かりやすくしている と考えることができる。
memcpyの話は下記の記事に詳しく書いています。
2次元配列の構造
2次元配列がプログラムやメモリ空間上でどのように扱われているか確認してみる。下記のようなプログラムを走らせてみた。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
#include <stdio.h> #include <stdint.h> int main() { uint8_t array1[3][4] = {{0x00, 0x01, 0x02, 0x03}, {0x10, 0x11, 0x12, 0x13}, {0x20, 0x21, 0x22, 0x23} }; printf("array1 is 0x%p.\n", array1); printf("&array1[0] is 0x%p.\n", &array1[0]); printf("array1[0] is 0x%08x.\n", array1[0]); printf("&array1[0][0] is 0x%p.\n", &array1[0][0]); printf("array1[1] is 0x%08x.\n", array1[1]); printf("&array1[1][0] is 0x%p.\n", &array1[1][0]); printf("array1[2] is 0x%08x.\n", array1[2]); printf("&array1[2][0] is 0x%p.\n", &array1[2][0]); return 0; } |
メモリモニタは下記のようになった。
二次元配列は、実体としてはメモリ空間に連続的にデータが格納されていることが分かる。要素数12の一次元配列として宣言したときと実体は全く変わらない。
2次元配列の真の意味を考える
宣言の記述内容をよく見ると、{}
で囲まれた配列が3つあって、それが束ねられてさらに配列になっているように書かれている。
これを踏まえると「array1[3][4]は要素3の配列であり、1つの要素は要素数4の配列である」と説明することもできそう。言い方を変えると、「array1はあくまで一次元配列であり、持っている要素が一次元配列なだけである」という事。
あーややこしい。
今回の宣言の内容を読むと、
array1の中には、”array1[0]”という名前の配列と、”array1[1]”という名前の配列と “array1[2]”という名前の配列 が格納されていると考えられる。
先述の通り、配列の名前は配列の先頭要素のポインタを示す。つまり、array1[3][4]において、例えば“array1[1]”と記述すると”&array1[1][0]”と全く同じだという事になる。
先のコードのprintfの出力を確認すると下記のようになる。
1 2 3 4 5 6 7 8 |
array1 is 0x0059F7C0. &array1[0] is 0x0059F7C0. array1[0] is 0x0059f7c0. &array1[0][0] is 0x0059F7C0. array1[1] is 0x0059f7c4. &array1[1][0] is 0x0059F7C4. array1[2] is 0x0059f7c8. &array1[2][0] is 0x0059F7C8. |
想定通り、array[1]と&array[1][0]は同じ値を示している。
このような特性から、「array1は要素数3のポインタ配列である」とまとめることができる。
結局のところ、2次元配列はメモリアクセスを分かりやすく表現するための親切機能に過ぎなくて、実体としてはメモリに連続的に値が格納されているだけだった。
ここまでくると、一番最初に疑問に感じていた「2次元配列をどのように関数に渡せばよいか」が分かってくる。配列要素のアドレスに適切にアクセスできれば良いのだから、どんな形でもその配列のポインタを引数として渡してやればいい。array1
で渡して対象の要素までポインタをインクリメントしてもいいし、1行目のデータを扱いたいのだったらarray1[1]
で渡してやればいい。もちろん、&array1[2][0]
みたいな形でもいいはず。
まとめ
一度ちゃんと考えてみると、いままでこんなに曖昧な理解でよくやっていたなぁと思う。メモリ空間みたいな低層の領域まで理解すると、より自由にプログラムを扱える気がする。
ただ一方で、「もうちょい分かりやすくなんねーすか?」とも思ってしまう(笑)
次は構造体や関数で同じような事をやってみようかなと思う。