2023年03月26日くいなちゃん


6さいからのプログラミング」第7話では、メインメモリのアドレスをやりとりできる「ポインタ」や「参照」について解説します!

1変数のアドレス

第6話では、同じ処理を何度も行うときに、選択処理や反復処理を用いることでシンプルに書けることを示しました。 今回は、たくさんの変数をシンプルに扱う方法について解説します。
C言語で30個の変数を扱うとき、例えば図1-1のように書くことができます。
  1. int main(void)
  2. {
  3.   int a;
  4.   int b;
  5.   int c;
  6.   int d;
  7.   int e;
  8.   int f;
  9.   int g;
  10.   int h;
  11.   int i;
  12.   int j;
  13.   int k;
  14.   int l;
  15.   int m;
  16.   int n;
  17.   int o;
  18.   int p;
  19.   int q;
  20.   int r;
  21.   int s;
  22.   int t;
  23.   int u;
  24.   int v;
  25.   int w;
  26.   int x;
  27.   int y;
  28.   int z;
  29.   int aa;
  30.   int ab;
  31.   int ac;
  32.   int ad;
  33.  
  34.   return 0;
  35. }
図1-1: variables.c
この場合、各変数に5を代入したいときには「a=5;b=5;c=5;...」と何度も書く必要があります。 しかし、仮にこれらの変数がメインメモリ上に連続して存在していたなら、図1-2のように、メインメモリのアドレスを変数のサイズ分だけ進めながら書き換えることで、単純に代入できそうです。
変数のアドレス
図1-2: 変数のアドレス
例えばこの例では、変数aが存在しているアドレスがA0番地なので、A0番地から順に、int型のサイズである4バイトずつ移動しながら値を書き換えると、すべての変数が書き換えられることになります。 これを実現するものとして、C言語やC++には「ポインタ」という機能が用意されています。

2ポインタ

「ポインタ」とは、変数のアドレスを入れる変数です。 そして何の型の変数のアドレスを入れるかによってポインタには種類があり、その変数の型のうしろに「*」を付けて表現します。 例えばint型の変数のアドレスを入れるポインタの型は「int*」型です。
ポインタは変数の一種に過ぎないので、プログラム上でも変数と同じように扱います。 例えば、「int*」型のポインタを「ptr」という名前で作成するには、「int* ptr;」と書きます(図2-1)。
  1. #include <stdio.h>
  2.  
  3. int main(void)
  4. {
  5.   int a;
  6.   int* ptr;
  7.  
  8.   a = 5;
  9.   ptr = &a;
  10.   *ptr = 3;
  11.   printf("%d\n", a);
  12.  
  13.   return 0;
  14. }
図2-1: pointer.c
9行目には変数aの前に「&」が付いていますが、これはその変数のアドレスを返すという演算子です。 つまり「ptr=&a;」で、ポインタptrには変数aのアドレスが入ります。 ここでは仮に、変数aのアドレスをA0番地としておきましょう。 ptrにはA0が入ります。
10行目にはポインタの前に「*」が付いていますが、これは6行目の「int* ptr;」の「*」とは別物で、ポインタに入っているアドレスの場所に存在している変数を返す演算子です。 ptrには現在A0が入っていますので、「*ptr」とはA0番地に存在している変数、すなわち変数aとなります。 つまり「*ptr=3;」とは「a=3;」のことで、変数aの値が3に書き換わります(図2-2)。
ポインタ
図2-2: ポインタ
図では「5」が変数「int a」に入っており、そのアドレス「A0」がポインタ「int* ptr」に入っています。
ややこしいですが、つまりポインタの前に付ける「*」演算子は、そのポインタに入っているアドレスの先を変数に見立てて、値を読み書きできるというものです。 例えばメインメモリの03番地の値を書き換えたい場合は、「ptr=3;」として、「*ptr=1234;」とすれば書き換わります。 ただしこの場合、メインメモリの03番地が読み書きできる領域とは限らないため、おそらくエラーが発生します。 必ず読み書きできるメインメモリの領域を操作しなければなりません。

3ポインタのポインタ

さて、ポインタとは変数のアドレスを入れる変数でした。 ここで、ポインタ自体も変数の一種であることを踏まえると、「ポインタのアドレスを入れるポインタ」というものを考えることができます(図3-1)。
  1. #include <stdio.h>
  2.  
  3. int main(void)
  4. {
  5.   int a;
  6.   int* ptr;
  7.   int** ptrptr;
  8.  
  9.   a = 5;
  10.   ptr = &a;
  11.   ptrptr = &ptr;
  12.   **ptrptr = 3;
  13.   printf("%d\n", a);
  14.  
  15.   return 0;
  16. }
図3-1: pointer2.c
このプログラムでは「ptrptr」が「ポインタのポインタ」になっています。 「int*」型のポインタのアドレスを入れるポインタを作りたいため、int*に「*」を付けた「int**」が型となります。
この「ptrptr」に対し「*ptrptr」と書くと、ptrptrに入っているアドレス先の変数を指しますので、「ptr」のことを意味します。 更にこれに*演算子を付けて「**ptrptr」とすると、「*ptr」のこと、つまりptrに入っているアドレス先の変数を指しますので、「a」のことを意味します。 結果的に「**ptrptr=3;」とは「a=3;」と同等で、aに3が入ります(図3-2)。
ポインタのポインタ
図3-2: ポインタのポインタ
ポインタを使うと30個もの変数をシンプルに扱えたのと同様に、ポインタのポインタを使うと30個ものポインタを扱うときにシンプルに書けます。
慣れるまでややこしいですが、概念は単純です。 同様に、「int***...」とすることで、ポインタのポインタのポインタの…と何重にもなったポインタが作れます。

4ポインタ演算

ポインタに対する演算としては、「+」と「-」の演算子があります。 これは「ポインタ+整数」もしくは「整数+ポインタ」のように書き、ポインタに入っているアドレスから指定した整数個分の変数のサイズだけ加減できます。
例えば、int*型のポインタpにアドレスA0が入っているとき、「p+1」はA0からint型1つ分だけ進めたアドレスになります。 つまりint型が4バイトの環境では「p+1」はA4です。 「p+2」はA8になります(図4-1)。
ポインタの加減算
図4-1: ポインタの加減算
図のように「*p」「*(p+1)」「*(p+2)」と1ずつ増やしていくと、pに入っているアドレスA0から出発して、メインメモリ上の連続したint型の変数を順に読み書きすることができます。

5void*

ポインタの一種として「void*」型があります。 これは、void型の変数のアドレスを入れるポインタ、という意味ではなく、単にアドレスを入れるだけのポインタだと捉えてください。 void*は単にアドレスを入れるだけなので、例えばvoid*型のポインタpがあったとき、「p+1」「p-1」といったアドレスの演算はできません。 また、「*p」としてアドレス先にアクセスすることもできません。 void*型は主に、どんな型の変数のアドレスが入るか判らないときに使われます。

6NULL

アドレスの0番地は特別で、通常どの変数のアドレスでもないことを意味します。 stdio.hなどの中ではこの0番地を「NULLヌル」と名付けており、ポインタがどの変数のアドレスも入れていないことを示したい場合にNULLを入れます(図6-1)。
  1. #include <stdio.h>
  2.  
  3. int main(void)
  4. {
  5.   void* p;
  6.   p = NULL;
  7.   return 0;
  8. }
図6-1: null.c

7配列

さてここまでで、ポインタを使うとメインメモリ上に連続した変数を読み書きできると説明しましたが、メインメモリ上に連続させて変数を作るには、C言語では「 変数名[要素数];」のように書きます。 例えば、「int a[30];」と書くと、メインメモリ上にint型変数30個分の連続した領域が確保されます。
このとき「a」の名前は、ポインタのように扱えます。 つまり「*a」「*(a+1)」「*(a+2)」…「*(a+29)」と書け、30個分の変数の値を読み書きできます。 ただしポインタとは異なり、「a=0;」のような、aが保持するアドレスそのものを書き換える操作はできません。 このように、連続した領域を確保しその先頭アドレスを保持するaのことを、「配列はいれつ」といいます。
「int a[30];」として作成した配列aに対し「*a」「*(a+2)」のようにアクセスできますが、C言語にはさらにこれを見やすくする仕組みがあり、「*a」のことは「a[0]」、「*(a+2)」のことは「a[2]」と書けます(図7-1)。
配列の糖衣構文
図7-1: 配列の糖衣構文
これは、配列を「int a[30];」と作成すると、あたかもa[0]~a[29]の30個のint型変数が作られたかのように見せるもので、人間にとって直観的な記法となっています。 またこの記法はポインタにも同様に使え、ポインタpに対し「p[5]」などと書けます。 この「a[b]」の記法はコンパイル時に「*(a+b)」の形に自動的に置き換えられてコンパイルされます。
それでは、30個のint型変数を作成してそれぞれに5を代入するプログラムを書いてみましょう(図7-2)。
  1. int main(void)
  2. {
  3.   int a[30];
  4.   int i;
  5.   for (i = 0; i < 30; i++)
  6.   {
  7.     a[i] = 5;
  8.   }
  9.   return 0;
  10. }
図7-2: array.c
for文と組み合わせることで、とてもシンプルに書けました。

8参照

C++やJavaやC#など多くの言語では、メインメモリのアドレスを直接操作するのは危険なので、「他の変数の場所を指す変数」という概念が用意されました。 これはポインタではなく「参照さんしょう」と呼ばれます。
「参照」は、内部的にはポインタと同様、他の変数のアドレスを格納していますが、その格納しているアドレスを取り出したりはできません。 アドレスを意識せずに扱えるようになっています。

9動的な配列

さて、C/C++以外の言語の配列は、C/C++とは仕組みが異なることがあります。 例えばJavaC#の配列は、配列の作成時に要素数が固定されません。 実体はヒープ領域に作られ、配列は要素数実体への参照を持っています(図9-1)。
動的な配列
図9-1: 動的な配列
図では、左のC/C++でも、右のJavaやC#でも、「01 23 ...」という値が配列に入っていますが、右のJavaやC#ではこれらはヒープ領域に置かれています。 ヒープ領域はプログラムの実行中に自由に確保や解放ができるので、要素数を柔軟に変えることができるという仕組みです。
例えば先ほどと同様、30個の変数を作成してそれぞれに5を代入するプログラムをC#で書くと、図9-2のようになります。
  1. class Array
  2. {
  3.   static void Main(string[] args)
  4.   {
  5.     int[] a = new int[30];
  6.     for (int i = 0; i < 30; i++)
  7.     {
  8.       a[i] = 5;
  9.     }
  10.   }
  11. }
図9-2: array.cs
5行目の「new int[30]」でヒープ領域に連続した領域が確保され、aにそこへの参照を代入しています。

10連想配列

PHPなどの配列は、これまでとは異なる「連想配列れんそうはいれつ」と呼ばれるものです。 連想配列は、メインメモリ上に連続した領域があるのではなく、要素番号と値との対応が保持される仕組みです。 例えば配列aに対し、a[2]に5を入れたり、a[100]に8を入れた段階で、メインメモリ上にこれらの対応関係が作られます(図10-1)。
連想配列
図10-1: 連想配列
このため、a[100]を使ってもメインメモリ上にたくさんの領域が確保される心配がない上に、「a["apple"]」のように要素番号に整数以外の値を用いることもできます。
この図のように、a[2]に5、a[100]に8、a["apple"]に4を代入するプログラムをPHPで書くと、図10-2のようになります。
  1. <?php
  2.   $a = array();
  3.   $a[2] = 5;
  4.   $a[100] = 8;
  5.   $a["apple"] = 4;
  6. ?>
図10-2: array.php
PHPでは変数名の前に「$」を付けます。 2行目で空の連想配列を作ってaに代入し、3~5行目で値を代入しています。

11文字列

最後に、文字列について解説します。 文字列とは、基本的には文字型の配列です。 JavaやC#の配列は先ほど紹介したように柔軟なため、JavaやC#における文字列も自由に長さを変えられる領域を持った扱いやすいものになっています。 一方、C言語の文字列は通常、領域の大きさが固定されています(図11-1)。
文字列
図11-1: 文字列
C言語の配列は要素数を保持しないので、メインメモリ上のどこまでが文字列なのかが判りません。 そこで文字列の最後に'\0'という特殊な文字を付けることで文字列の終端を表すことにしています。 '\0'とは、文字コードが0の文字です。
C言語での文字列の扱いを具体的なプログラムで示すと、図11-2のようになります。
  1. #include <stdio.h>
  2. #include <string.h>
  3.  
  4. int main(void)
  5. {
  6.   char s[6];
  7.   strcpy(s, "Hello");
  8.   printf("%s\n", s);
  9.   s[1] = 'i';
  10.   s[2] = '\0';
  11.   printf("%s\n", s);
  12.   return 0;
  13. }
図11-2: str.c
プログラム中に「"Hello"」のように文字列リテラルを書くと、静的領域にあらかじめ埋め込まれた文字列の先頭アドレスを意味します(図11-3)。
文字列リテラル
図11-3: 文字列リテラル
図の例では、静的領域の30番地から「Hello」の値が格納されているため、ソースコード中に"Hello"と書くと30番地を表します。
7行目の「strcpy関数」は、第2引数の文字列を第1引数にコピーする関数で、「string.h」の中に存在します。 よって「strcpy(s,"Hello");」を実行すると、s[0]~s[5]にはそれぞれ「'H'」「'e'」「'l'」「'l'」「'o'」「'\0'」の6文字が入ります。 終端文字'\0'があるので、配列sの領域は1文字分多めに確保しておく必要があります。
printf関数の「%s」は、渡されたアドレスから終端文字までの文字列を画面に表示する機能です。 よって8行目の「printf("%s\n",s);」で画面には「Hello」が表示されます。
9行目の「s[1]='i';」により、「Hello\0」は「Hillo\0」に書き換わります。 同様に、10行目の「s[2]='\0';」で「Hi\0lo\0」になります。
2回目のprintfでは、sの中身である「Hi\0lo\0」を表示しようとしますが、この文字列の途中には終端文字があるため、画面には「Hi」とだけ表示され、残りの「lo」は無視されます。
今回は、メインメモリを自在に操作するための「ポインタ」「参照」や、たくさんの変数をシンプルに扱える「配列」、そして「文字列」の仕組みについて説明しました。 次回は、目的を達成するためにはプログラムでどう書けば良いかについて解説します!
1679822559jaf