てけもぐ Tech 忘備録

C言語の配列の話

対象読者

C言語初学者

解決すること

多次元配列へのアクセスとか、ポインタとの違いとか

内容

C言語のひっかかりやすいところって、実は配列まわりだと思うんです。やれポインタ演算で置き換えられるとか、やれ関数の引数になるとポインタとして扱われるだとかありますけど、よーく見ると、えー!ってなる。最初から配列は配列で他の型と違うんだけれども。

まず、

-配列は確保された領域の先頭アドレスそのものです。ポインタはアドレスを入れられる変数。

ポインタは変数だからアドレスを代入出来るんだけども、配列の変数はアドレスそのものなので変更出来ません。

int main(){
  int a[1] = {10};
  int b[1];
  a = b; // error
}

この場合は、関数の中の static じゃない変数なので、a と b は、main関数のとこのスタックに確保された変数領域。a も b もそこのアドレス自身を示しているので、a の値は変更出来ません。中身、つまり確保領域の中はもちろん変えられるけど。同じと言うなら(誰も言ってないけど)、ポインタのアドレス(&ptr)と一緒ということ。

配列の代入は出来ないので、私がよくやりたくなる以下もダメなパターン。

nt main(){
  int a[2][2];
  int a0[2] = {10, 20};
  int a1[2] = {10, 20};
  a[0] = a0;   // error!
  a[1] = a1;   // error!
  return a[1][1];
}

当たり前じゃん!て声も聞こえてきそうですけども、でもですよ?

-配列の代入はサポートしてないのに、構造体の代入はコピーされる形でサポートされてる。

struct ST_A{
  int num;
  int array[2];
};
int main(){
  struct ST_A a = {10, {20, 30}};
  struct ST_A b;

  b = a; // copy ok!

  return b.num + b.array[1]; // return 10+30. ok!
}

同じ様にスタック上にまとまった領域を確保してるだけなのに、配列に対するこの仕打ち(笑)。配列入でも構造体なら代入コピー。

しかも代入だけじゃなくてアクセスする方でも、

-添字でのアクセスは以下のデリファレンスを使った演算と同じになると言われる。

int main(){
  int a[2] = {10, 20};
  return a[1] == *(a+1);  // true!
}

これだけなら、なるほど流石C言語は低レベルなアドレス演算も出来るんだ!とか思いそうですけどもー、

-多次元配列でのポインタ演算でのアクセスも同じと言われている。

int main(){
  int a[2][2] = {{10, 20}, {30, 40}};
  return a[1][1] == *(*(a+1)+1);  // true!
}

この結果当たり前なんでしょうか?だって、配列は2x2xsizeof(int) の領域をスタック上に確保しているだけです。最後にデリファレンスするのはいいんですけど、内側のカッコはデリファレンスして中身を取り出したら数値そのものになってしまいません?定義が *a[] とかの形から入るならポインタですからデリファレンスするのは分かりますけども。

コンパイラの出力も、型の定義の仕方で *(*(a+1)+1) の出力は変わってきます。a[][] の形だと最後までデリファレンスせずに、アドレス計算して最後でけ中身の取り出しに見える...。つまり型によって'*'の演算がなくなる。もちろん型自体の処理はしるんでしょうけど。これ混乱しません?この場合の(a+1)*(a+1)の違いは?デリファレンスしてないじゃん!て。

以下の2つのコードを gcc の -S オプションで出力すると、*(*(a+1)+1) の扱いの違いが分かると思います。(foo() を使ってるのは、gcc に演算を端折らせないため。)

int foo(){
  return 1;
}
int main() {
  int a[2][2];
  a[0][0] = 10;
  a[0][1] = 20;
  a[1][0] = 30;
  a[1][1] = 40;
  return *(*(a+foo())+1);  // return 40!
}

ポインタを使ってやると、

int foo(){
  return 1;
}
int main() {
  int a0[2] = {10, 20};
  int a1[2] = {30, 40};
  int *a[2] = {a0, a1};
  return *(*(a+foo())+1); // return 40!
}

んで最後に、

-関数の引数に配列が渡されるとポインタになります。

え?ですよ。コンパイラの仕組みが分かるとそりゃそうだって思うのでしょうけど。ちゃんと別にポインタ変数が用意されて暗黙のうちにその変数にアドレスが代入されるので、厳重注意!ですわ。書いてあったのかもだけどさ...。だって、呼び出し元の配列変数のアドレスがそのまま入ってるってデザインも、もしかしたらあるかもじゃないですか。これの説明せいで配列とポインタの区別の混乱が助長される様な。

歴史的な理由もあったりで言語デザインがーとかではなくて、躓きやすいところはデカデカと書いておいて欲しいというのは、過ぎた望みなんでしょうかね..。

C言語って、型の宣言とポインター、それに配列がちょっとひっかかりやすいと思うんですが、型の宣言は見た目がもう面倒くささを主張してるし、ポインターは難しいと脅されながら実はそんなに難しくない。でも、配列はしれっと書いてあるくせに実は曲者。こんなによく使う基本的な型なのに。

他所であんまり情報が見つけられなかったので書いておきます。

Tags