はじめに
C言語を習得する上で最難関とされる「ポインタ型」の取り扱いについて、参考書を読んで理解しようとするより、実際のコードを見て、いじって身体で学んだ方が圧倒的に理解が早く、またイメージがつきやすいと感じたため、Pointer Stew という有名な「ポインタと配列を使用するパズル」(既に難しそう笑)を用いてポインタ型に関する理解を深めました。
ポインタ型とは
「あるデータを指すデータ」のことです。データのアドレス。データがどこに記憶されているか。「ポインタもまたデータである」と言うことが一番大事だと思います。
Pointer Stew
それでは、実際のコードを見ながらポインタ型の取り扱い方について見ていきます。
#include <stdio.h>
1 int main()
2 {
3 char *x[4] = {"enter", "new", "point", "first"};
4 char **xp[4];
5 char ***xpp;
6 int i;
7 for (i = 0; i < 4; i++)
8 xp[i] = x + (3 - i);
9 xpp = xp;
10
11 printf("%s", **++xpp);
12 printf("%s ", *--*++xpp+3);
13 printf("%s", *xpp[-2]+3);
14 printf("%s\n", *--xpp[-1]+1);
15
16 return 0;
17}
サイトによって書き方はまちまちですが、見た感じ内容に大差はなかったので今回はこのサイト1のコードを参照します。出力結果は何になるでしょうか…??
解説
1. char型のポインタ配列
3 char *x[4] = {"enter", "new", "point", "first"};
ここでは「 x 」が an array of pointers to characters つまり、「char型のポインタ配列」(要素がchar型のポインタ、である配列)であることを示しています。ここで、char型は1文字を格納するデータ型であるので、char型のポインタは(複数)文字列の先頭文字のアドレスを格納しているデータです。この意味で、char型のポインタは以下のようにchar型の配列と似た使い方が可能になります。( s1,s2 は共に printf の中で先頭文字のアドレスを意味している。)
char *s1 = "pointer"; printf("%s\n", s1); >>> pointer
char s2[] = "stew"; printf("%s\n", s2); >>> stew
したがって、今回の場合は「ポインタ型の配列」のため、配列の各要素がポインタ型(上で言う s1, s2 )になります。ゆえに、以下のように出力することが可能です。
char *x[4] = {"enter", "new", "point", "first"};
printf("%s\n", x[0]);
>>> enter
printf("%p\n", x);
>>> 0x7ffc2369d7d0 (ASLRが有効になってるため、プログラムを実行する度に変化する)
なお、上の出力の2つ目は、ポインタ配列 x
のアドレス、つまり、ポインタ配列 x
の先頭のアドレスを表しています。("enter"
のアドレスではありません!! "enter"
のアドレスは、printf("%p", x[0])
で参照可能です。)
2. char型のポインタ型のポインタ配列
4 char **xp[4];
6 int i;
7 for (i = 0; i < 4; i++)
8 xp[i] = x + (3 - i);
ここでは、「 xp
」がan array of pointers to pointers to characters つまり、「char型のポインタ型のポインタ配列」であることを示しています。そのため、先ほどの例を踏まえると、以下のように出力できます。
char **xp[4];
int i;
for (i = 0; i < 4; i++){
xp[i] = x + (3 - i);
printf("i=%d: %p\n",i,xp[i]);
}
>>> i=0: 0x7ffc2369d7e8
>>> i=1: 0x7ffc2369d7e0
>>> i=2: 0x7ffc2369d7d8
>>> i=3: 0x7ffc2369d7d0
ちなみに、この時 i
の値が 1 変わると アドレス(番地・住所)が 8 変化することが観察できますが、これの理由も、
int size = sizeof(x[0]);
printf("%d\n", size);
>>> 8
であることで理解できます。char型のポインタ配列の各要素に割り当てられているサイズが 8 byte だから、ということです。
3. char型のポインタのポインタのポインタ
5 char ***xpp;
9 xpp = xp;
ここまでくれば流れで予想できると思いますが、ここでは、「 xpp
」が a pointer to pointers to pointers to characters つまり、「char型のポインタのポインタのポインタ」であることを示していますので、以下のように出力ができます。
printf("%p\n", xpp); >>> 0x7ffc2369d7b0
printf("%p\n", *xpp); >>> 0x7ffc2369d7e8
printf("%p\n", **xpp); >>> 0x400714
printf("%s\n", **xpp); >>> first
本題
それでは実際に Pointer Stew を考えてみますが、その前に「型定義時と運用時には *
の意味が異なる(ように見える)」ことについて少しだけ触れます。
型定義時は今まで述べてきた通り、
-
char*
=「char型のポインタ」 -
char**
=「char型のポインタのポインタ」
と言うように、「『*』=『のポインタ』」と考えることができていました。
しかし、上の printf
関数内での *
の使われ方はそれとは明らかに違うことがわかります。例えば**xpp
が "first"
を表しているように、ポインタに *
をつけることで、「ポインタが指している対象」を意味します。(つまり、ここでは **xpp
はchar型のポインタの役割をしている。)
それでは、ここから各出力結果を考えていきます。
11 printf("%s", **++xpp);
これは、9 行目の式 xpp = xp
から分かる通り、 xpp
が「char型のポインタ型のポインタ配列 xp
」の先頭アドレスを意味しています。「++
」は+1
と同義(足すタイミングが異なるだけ。後ろに付く場合もある)だと考えれば、先頭から2つ目のアドレスを指しており、(ここまでで ++xpp
)そこに *
が2つ付くことによって、 xp[1] = x + (3 - 1) = x + 2
、char *x[4] = {"enter", "new", "point", "first"}
からここでの出力は "point"
であることが分かります。
12 printf("%s ", *--*++xpp+3);
これは、「++
」の理解が多少必要になります。i++
≒ i=i+1
であるから、先の計算で xpp
は 1 インクリメントされています。したがって、ここで更に1インクリメントすることによって、「char型のポインタ型のポインタ配列 xp
」の「3つ目」のアドレスを指していることになります。(ここまでで ++xpp
)
そこに*
が1つ付くことによって、 xp[2] = x + (3 - 2) = x + 1
、これを--
で1デクリメントすることによって、「char型のポインタ配列 x
」の先頭のアドレスを示します。(ここまでで --*++xpp
)
更に *
が付くことによって先頭の要素 "enter"
の先頭のアドレスを意味し、最後に3足すことで4文字目 "e"
のアドレスを意味します。そこから printf
するので、ここでの出力は "er"
であることが分かります。
13 printf("%s", *xpp[-2]+3);
これは、xpp[-2]
の意味をきちんと理解する必要があります。「 xpp
の後ろから2つ目の要素だな!」と思うかもしれませんが(僕もそう思いました)、残念ながらそれは間違いです。正しくは、xpp
の指すアドレスに []
の中の数 (今回は -2
) を足したアドレスを指すと言うことを意味しています。(他にも *(ポインタ変数 + 要素番号)
なんてものもあるみたいです。)
先ほどまでですでに xpp
は2インクリメントされているので、-2
してちょうど元々 xpp
が指していたアドレスを指します。つまり、xp[0] = x + (3 - 0) = x + 3
そこに *
が付くことによって "first"
の先頭アドレスを指し、先ほどと同様にして出力は"st"
となります。
14 printf("%s\n", *--xpp[-1]+1);
最後は"ew"
です。
と言うのはあまりにも雑すぎるので一応解説も載せておくと、xpp[-1]
が先程と同様にして xp[1] = x + (3 - 1) = x + 2
を指し、そこから1デクリメントして *
が付くことによって "new"
の先頭アドレスを指し、最後に +1
をするので2文字目から出力が始まり、結果"ew"
となります。
以上より、プログラムを通した出力は "pointer stew"
となります。
最後に
ポインタ型を難しくしている要因は、記号の使い方だと感じています。例えば
3 char *x[4];
4 char **xp[4];
5 char ***xpp;
というように定義しますが、これだと結局 *x
, xp
, xpp*
が何を表すものとして定義されたのかがわかりにくい気がします。多少の誤差かもしれませんが、
3 char* x[4];
4 char** xp[4];
5 char*** xpp;
と型を表す方にポインタ型である記号をつけた方が格段にわかりやすくなると思います。また、先にも記述しましたが、型定義時に使う *
と関数内で使われる *
が同じ記号なのも苦戦するポイントだと感じました。「同じような役割を持つ記号は同じ記号にする」と言う考え方が働いているようですが、かえってわかりにくくなっている気がします…。(他に理由があったら教えてください。)
おわりに
僕自身間違った認識をしている部分もあるかと思いますので、何かミス等あればご指摘よろしくお願いします🙇♂️