めもめも

このブログに記載の内容は個人の見解であり、必ずしも所属組織の立場、戦略、意見を代表するものではありません。

ポインタと配列の微妙な関係

話の背景

C言語のポインタと配列の関係については、『「配列へのポインタ」と「ポインタの配列」の見分け方』で紹介した「エキスパートCプログラミング」という書籍の「徹底的な解説」をこよなく愛していたのですが、最近、紹介されて「C言語ポインタ完全制覇」という書籍を読んだところ、類似の内容が、もう一段噛み砕いた形で説明されており好感を持ちました。

その中で、『「配列名に[ ]を付けずに配列名だけ書くと先頭要素へのポインタになる」という説明は間違っており、[ ]を付けようが付けまいが、いつでも配列名は先頭要素へのポインタになるのだ。』という記述があります。

これはなかなか説明が難しい部分で、実は私も、上記書籍の説明は数回読み返して理解できました。以下に私なりの解説を掲載するので、興味ある方は、解読(?)に挑戦してください。(もちろん、上記の2冊の書籍もおすすめですよ!)

解説

まず、ポインタ変数はメモリ上のアドレスを格納すると言われますが、単なるアドレスの入れ物ではありません。コンパイラは、それが指し示すアドレスの中にある値の「型」を理解して処理をします。

たとえば、次の2つを比較すると、前者は普通にコンパイルされるのに対して、後者はコンパイル時に警告が出ます。

int *p;
int i = 256;
p = &i; // コンパイラは *p(pが指し示す内容)が int だと理解して普通にコンパイルする。
int *p;
char c = 'A';
p = &c; // コンパイラは *p(pが指し示す内容)が int にならないので警告を出す。

また、ポインタが配列要素を指す場合、ポインタに加減演算をすると、配列要素の「型」に合わせて、「指定数だけ前後にある配列要素を指すように」アドレスを増減させます。

int *p;
int i[5] = {1, 3, 5, 7, 9};
p = &(i[0]); // p = i と書いても同じ
printf( "%d\n", *(p + 4) ); // 「9」を表示
printf( "%d\n", p[4] ); // 上記と同じ結果。p[4] は *(p + 4) のシンタックスシュガー。

コンパイラは、最初のprintfの中にある (p + 4) をコンパイルするにあたって、*p が int だと知っているので、「(p + 4)が指すアドレスは、pが指すアドレスを sizeof(int) * 4 だけ増やしたもの」になるようなアセンブラコードを吐き出します。アドレスの値を単純に4増やすわけではありません。

つまり、ポインタを扱う際は、「どのような型の値を指すポインタなのか」を意識することが大切です。(あえて、指し示すものの型をコンパイラに意識させたくない場合は、「void *p」のようにvoid形へのポインタを使うことになります。)

そして、最後のprintfにある「p[4]」はその上の「*(p + 4)」とまったく同じ意味です。これは、

・ルール(1):ポインタに[n]をつけると、コンパイラは暗黙に「p[n] → *(p + n)」と変換する

という謎な暗黙変換ルールがあるためです。

また、同じコードの3行目では、ポインタpが「配列iの先頭要素i[0]のアドレス」を指すように、「&(i[0])」という値を代入していますが、これは単純に「p = i」と書いても同じ意味になります。これもまた、コンパイラの暗黙変換ルールによるものです。

・ルール(2):コンパイラは、式の中に現れる配列変数名「i」を先頭要素のアドレス「&(i[0])」に暗黙に変換する

これらのルールは非常に首尾一貫しており、i[3]というような一見普通の表現についてさえも上記の変換を行います。

「なんのこと? i[3]は配列iの(先頭を0番目として)3番目の要素という決まった書き方じゃないの????」

じゃないのですよ。。。。i[3]に上記の2つのルールを適用して、暗黙の変換を行なってみます。

まず、ルール(2)を適用すると、次のように置き換わります。

i[3] → &(i[0])[3]

ここで、&(i[0])は、ポインタなので、ルール(1)が適用されます

i[3] → &(i[0])[3] → *( &(i[0]) + 3 )

最後に残った表現をアセンブラコードに吐き出す際に、冒頭で説明したルールが適用されます。つまり、&(i[0])は、intを指すポインタなので、3を足すという処理は、実際には&(i[0])の指すアドレスを「sizeof(int) * 3」だけ増やすという処理に置き換えられます。

というわけで、結果的に、i[3] は「配列iの(先頭を0番目として)3番目の要素」に一致することになります。

世間でよく「配列とポインタは同じもの?!」という誤解があるそうですが、決して同じものではありません。ルール(1)(2)のおかげで、「配列とポインタの違いが曖昧なプログラムコードを書いてもコンパイラがよきに計らってくれる」というだけのことです。

もちろん、すべてのケースをよきに計らってくれるわけではないので、違いを理解した上でプログラムコードを書くことは大切です。ルール(1)(2)の適用が行われないように、あえて明示的に「*(p + n)」とか「&(i[0])」のような表記を利用して書いてみると、配列とポインタの違いがよく分かるのではないでしょうか。

えー。厳密に解釈すると「&(i[0])」のiにもルール(2)を適用すると、無限に展開が行われることになるのですが、『「i」を「&(i[0])」に置換する』というわけではなくて、『「i」を「&(i[0])」相当のものとして解釈する』ぐらいに考えておいてください。

おまけ

上記の解説から分かるとおり、「a[n]」という表記があった場合、aの正体がポインタなのか、配列なのかによって、コンパイラは異なる処理を行います。つまり、ポインタと配列は、決して同じものではないのです。コンパイラの暗黙ルールのおかげで、見かけ上、同じ表記法が使えるだけです。

で、よくある間違いの例で出てくるのが、

char c[16] = "Hello World!";

で定義された配列を別のソース内で、

extern char *c;

と、ポインタとして宣言してしまうやつです。cは配列なので、c[n]という表記は、(ルール(1)(2)により)コンパイラによって「*(c[0]のアドレス + n)」と解釈されるべきなのですが、ポインタとして宣言してしまうと、コンパイラは(ルール(1)のみを適用して)「*(cが指すアドレス + n)」と解釈してしまいます。

ポインタpにおける「pが指すアドレス」というのは、「変数pの値を格納するメモリ領域に書き込まれたアドレス値」のことです。したがって、配列c[]に対して、「cが指すアドレス」という処理をすると「配列c[0]〜c[16]の値を格納するメモリ領域に書き込まれた値(今の場合 "Hello World!")を無理やりアドレス値として解釈したもの」という訳のわからないアドレス値がとり出されてしまい、メモリ破壊なり、segmentation faultなり、ひどい目に合うことになります。