ポインタと連鎖リストを理解しよう講座

文責:吉廣卓哉(和歌山大)

イントロダクション

皆さん、C言語は難しいと思っていますか?

はじめはそのように感じるかもしれません。

でも、いくつかのポイントがわかれば、とてもすっきり理解できるのです。

「C言語のプログラムはなんとなくわかるんだけど、理解がふわっとしていてわかった気になれない」

そう思っている人は多いのでは?

でも、そう思っているうちは、C言語は自分のモノになりません。

このWebページでは、C言語のなかでも、特に苦戦する人が多い「ポインタ」に焦点をあてます。

そして、これを自分のモノにするためのポイントを、根っこからわかりやすく追っていきます。

まず、C言語はハードウェアに近いプログラミング言語だ、ということを知っていますか?

C言語を理解するためには、コンピュータのハードウェア、特に「メモリ」のことを知ることが大事です。

復習になりますが、まずはメモリについて、話をしましょう。

C言語とメモリの関係

「メモリ」ってなんでしたっけ?覚えていますか?

メモリとは、コンピュータを構成するために必須の電子部品であり、

データを一時的に記憶しておくことができるものです。

「ハードディスク」もデータを一時的に記憶しておく電子部品ですね。

そういう意味で良く似ていますが、役割はだいぶん違います。

メモリは書込み/読み出しが高速ですが、電源を切ると内容が消えてしまいます。

一方、ハードディスクは書込み/読み出しは遅いですが、電源を切ってもデータは残ります。

なので、ハードディスクはずっと残しておく大切なファイルを記憶しておくために使われ、

メモリは、コンピュータが働いている間に使う一時的な記憶領域として使われます。

皆さんがC言語でよく使う「変数」は、まさにコンピュータが働いてる間に

一時的に値が記憶される場所ではないですか?そうです。変数は、メモリの中に保存されるのです。

 

では、メモリと変数の関係を見てみます。

まず、コンピュータのメモリとは、次の図に示すようなものです。


要するに"0"と"1"の羅列です。コンピュータは"0"と"1"を扱うんでしたね。

この"0"と"1"のそれぞれを、「ビット」と呼びます。

でも、これではわけがわからないので、少しわかりやすく書いてみましょう。


"0"と"1"が8つずつまとめられました。この8ビットのまとまりを「バイト」と呼びます。

図にはこのまとまりが8つありますから、「8バイト」のデータということになります。

コンピュータはデータをバイト単位で扱います。

そのためには、コンピュータは、各「バイト」を区別する必要がありますね。

だから、メモリの各「バイト」には、それぞれを区別するための「番地」が振ってあります。

「番地」は、もう少しちゃんと言うと、「アドレス」と呼ばれます。

図にもあるように、アドレスは0から順に連番で振られます。

ここで、知ってますか?

32ビットコンピュータでは、アドレスは32ビットで表現されるんですが、

32ビットを使えば、4,294,967,296個の数を表せます。およそ40億の数です。

つまり、1バイトにひとつずつアドレスを対応付けると、4GB(ギガバイト)のメモリを扱えるんです。

32ビットコンピュータに積めるメモリの上限は4GBでしょう?

それはこういう理由なのです。

 

さて、メモリって何?っていうのをイメージできるようになりましたか?

では、次はC言語の変数とメモリの関係を考えましょう。

次のプログラムを見てください。

 
  int i;

これはお馴染みのプログラムコードですね。

整数(int)型の変数iを宣言しているだけです。

では、この一行によって何が起こるかわかりますか?

何も起こらない?いやいや、この一行には大切な役割があります。

それは、この変数に対応するメモリ領域を確保する、という役割です。

つまり、こうです。


メモリ上に、変数iに対応する「箱」がありますね?

この箱は、変数iの値を入れておく箱です。

変数iのためにこの箱をメモリ上に用意するのが、この一行の役割です。

つまり、変数iといえば4番地、という対応関係を作るのですね。

この箱がなければ、変数iには値を入れられませんから、とても大事な命令です。

ところで、この箱は4番地から7番地までの4バイトにまたがっていますね。

これは、32ビットコンピュータでは通常、int型の変数は4バイトで表されるからです。

変数iのアドレスは4番地ですが、実際には、7番地までの4バイトを使うんですね。

これがchar型であれば、4番地の1バイトのみです。

アドレスが同じでも、型が違えば、箱の用意の仕方が違うわけです。

 

それでは、次に、これはどうでしょうか?

 
1:  struct prefecture {
2:    char name[16];
3:    int population;
4:    int area;
5:    double density;
6:  };
7:
8:  structure prefecture pref; 
9:
10: pref.population = 10000;

まず、誤解してはいけないのは、1行目から6行目の構造体の定義部分だけでは、

メモリ上に箱は用意されないということです。

この6行は、型の定義、つまり、箱の形(設計図だと思えばよい)を決めているだけです。

int型では4バイトを使って数値を表す箱、char型は1バイトで1文字を表す箱でした。

同じように、prefectureという構造体は、char型の変数を16個、int型を2個、double型を1個、

これだけをひとまとめにしたような箱だ、という形を決めているわけです。

そしてその次の8行目で、メモリ上に箱を作っています。

その様子はこんな感じです。


prefecture構造体のメンバ変数が、宣言された順番にメモリ上に配置されます。

メンバ変数のそれぞれに対して、ちゃんと場所が用意されているんですね。

このように、変数でも、構造体でも、メモリを意識することが理解への第一歩です。

ちなみに、10行目ではメンバ変数populationに10000を代入しています。

構造体のメンバ変数を扱うときにはこのように、

「.」(ドット演算子)を使うことを思い出しておいてください。

 

準備ができたところで、ポインタの話に移りましょう。

 

ポインタとは何か

 

変数や構造体は比較的イメージしやすいけれど、

ポインタになると全然わからなくなる。こんな人は多いんじゃないでしょうか?

それは、ポインタが何か、ということが理解できていないからです。

ポインタとは、変数の一種。つまり、値を入れる「箱」なんです。

そのことがわかると、ポインタの理解は一気に進みます。

まず、次のプログラムコードを見てみましょう。

 
  int *p;

 

さあ、でてきましたね。ポインタです。

以前に説明した「int i;」とは何が違うのでしょうか。

この変数pの箱を図示すると、次のようになります。


箱が一つあるだけで、「int i;」の場合と同じですね・・・。

では、次のプログラムコードを見て違いを確認しましょう。

 
1:  int *p, *q;
2:  int i;
3:
4:  i = 5;
5:  p = &i;
6:  q = p;
7:  *q = 3;

 

何が起こるかわかりますか?

まず、1行目と2行目では変数p,q,iのメモリ領域を確保しています。

その様子を表したのが次の図です。

(ややこしくなるので、メモリ領域全体の絵は省きました。)


3つの「箱」がありますね。

そして、箱のアドレスは、それぞれ10、20、30番地に割り当てられました。

ちなみに、割り当てられる番地はプログラムの実行時に決まるので、

実行するたびに異なります。

 

4行目以下のプログラムの動作は、全てこの箱を使って説明できます。

では、その動作を見てみましょう。

まず、4行目。これはわかりますね?

箱iに値5が入ります。ここまでは問題ないでしょう。

では5行目は何をしているんでしょうか?

まず、知っておかないといけないのがコレ「&」です。(「アンパサンド」と読む。)

ある変数に演算子「&」を付けることで、その変数のアドレスを表します。

ということは、5行目が実行されると、変数iのアドレスである30(番地)が、

pの箱に入ります。

そう、ポインタ変数の「箱」には、アドレスが入るんですね。

int型変数iの場合には整数値が入り、ポインタ変数pの場合にはアドレスが入る。

それだけの違いなんです。

だからもちろん、6行目では同じ型の変数であるqにpの値(アドレス)を代入できます。

通常の変数と同じですね。

 

でも、ポインタ変数には、ひとつ、特殊な使い方があります。

それが7行目です。qに「*」(「アスタリスク」と読む)という演算子がついていて、

「*」がついたqに3という整数値を代入しています。

この*qは「qの箱の中にあるアドレスが指している場所」を表します。

その場所には変数iの箱がありますね。

だから、変数iの箱の中に3という値が入るのです。

ここで注意して欲しいことは、「qの箱の中にあるアドレスが指している場所」

にある箱がどんな形(型)の箱なのかがわからないと、代入ができないということです。

例えば、その場所にある箱がint型であれば普通は4バイトの領域ですし、

char型なら1バイト、double型なら8バイトですね。

また、同じ4バイトでも、整数値が入るint型とアドレスが入るポインタ型では、

同じ数値でも異なる0と1の列で表現されて、メモリ上に保持されます。

(詳しくは計算機システムIを復習してください)。

 

では、箱の形はどうしてわかるのでしょうか。

それが、ポインタ変数の型です。

もういちど1行目の変数qの宣言を見てください。

変数qはポインタだけど、「int」と書いていませんか?

そう、qはただの「ポインタ型」ではなくて、「intのポインタ型」なのです。

そして、この「int」は、ポインタが指す先の場所に

どのような形(型)の箱があるかを表しているんですね。

このようにポインタが指す先にある箱の形がわかることで、

ポインタ変数を使った値の代入が正しく代入できることがわかると思います。

次の図に、7行目までを実行した結果、それぞれの箱にどんな値が入っているかを表します。

プログラムの動作を追いながら、値を確認してみてください。


さて、練習として、もう少しややこしい例を見てみましょう。

1:  int *p, *q;
2:  int **r; 
3:  int i,j,k;
4:
5:  i = 5;
6:  p = &i;
7:  q = p; 
8:  j= *q; 
9:  r = &q; 
10:  k = **r; 

 

このプログラムの動作がわかるでしょうか。

7行目までは、これまでに説明したとおりです。

8行目はどんな動作をするでしょうか。もうわかりますね?

7行目では、qの箱にpの中身、つまりiのアドレスが代入されます。

そうすると、8行目は、そのアドレスが指している箱の中身をjに代入しますから、

変数iの中身である5が、jにも代入されます。

もう大丈夫ですね。

 

では、9行目はどうでしょうか。

このrは、見慣れない形をしていますね。

2行目の宣言では、*が2つも付いています。

これは、「ポインタのポインタ」と呼ばれます。

ちょっと戸惑うかもしれませんが、同じように考えれば大丈夫です。

つまり、qのようなポインタ変数の箱のアドレスを入れる箱なんです。

だから、9行目では、qに"&"をつけて、アドレスを代入しています。

10行目では、"**r"という表現がありますね。

これも、前の考え方をそのまま使えば、意味がわかります。

rの箱にはアドレスが入りますね。

この箱が指している先にある箱は、*rで表されますが、これが、qの箱です。

qの箱にもアドレスが入っていますが、これが指す先にある箱は**で表されます。

つまり、**rが指しているのは、qの箱のアドレスが示す先、つまりiの箱ですから、

10行目では、kの箱に、iの箱の中身である5が代入されます。

10行目までが実行された結果を次の図に示しておきます。


ところで、この図には、「矢印」が書き込まれていることに注意しましょう。

この矢印は、ポインタ変数が参照している箱を示しています。

でも、あくまでも直感的に理解するための補助として書いているだけですから、誤解のないように。

つまり、例えば、pの箱の中身が40であるから、

図をわかりやすくするために、pから40番地への矢印を書いただけなのです。

当然、箱の中の値が変われば、この矢印が指す先も変わることになります。

矢印は表記上の便宜に過ぎませんので、そのつもりで図を見てください。

リスト構造を作ろう

ポインタを理解したところで、いよいよ、リスト構造を作ってみましょう。

これは、構造体をポインタでつなぐことで実現します。

まず、構造体の定義からはじめます。

 
1:  struct list {
2:    int data;
3:    struct list *next;
4:  };   

リスト構造を作るときに使う構造体には、一つの特徴があります。

それは、構造体のメンバ変数の型として、その構造体自身が使われていることです。

これを「自己参照構造体」と呼びます。

ポインタを理解した皆さんならおわかりだと思いますが、こうすることで、

その構造体の「箱」に次の構造体の「箱」のアドレスを入れておいて、辿ることができるようになります。

では、早速、つないでみましょう。

 
1:  struct list *root;
2:  struct list *p;
3:  
4:  p = malloc(sizeof(struct list));
5:  if (p != NULL) {
6:    root = p;
7:    p->data = 0;
8:    p->next = NULL;
9:  }

1行目と2行目で、2つのポインタを宣言しています。

確認しておきますが、この時にできる箱はアドレスが一つだけ格納される大きさです。

4行目は見慣れない書き方かもしれませんが、ここで構造体の箱を作っています。

構造体の宣言と違うのは、この書き方だと動的にメモリ領域を確保できるということです。

つまり、4行目のような文をfor文で複数回繰り返すことで、複数個の箱を作ることができます。

この方法により、箱をいくつ作ったら良いかわからない場合でも、メモリの確保が可能です。

「構造体の宣言」のような静的な方法では、このようなことはできません。

4行目のmalloc関数は、メモリ領域を確保する関数です。

引数には、確保したいメモリ領域の大きさ(つまり、何バイトの領域を確保したいのか)、を渡します。

sizeof演算子は、カッコの中に「変数の型」をとり、その大きさが何バイトであるかを返します。

ここで、カッコの中に書くのは「値」ではなく「型」であることに注意してください。

関数が引数にとるのは「値」ですから、sizeofは関数ではなく、演算子なのです。

 

5行目のif文は、メモリの確保が成功したかどうかをチェックしています。

malloc関数の仕様を調べるとわかりますが、malloc関数は、

メモリ領域の確保に成功したときには、戻り値として、その先頭アドレスを返し、

失敗したときには、NULLを返します。

(このような関数の仕様を自分で調べられることは大切です。できるようになりましょう。)

領域の確保が成功したかどうかをチェックしているのですね。

なお、「NULL」とは特別に定義されたポインタ型の値で、「アドレスがない」ことを表します。

メモリ領域の確保に成功した場合には、6行目で、確保したメモリ領域のアドレスをrootに格納します。

これで、リスト構造のはじめの1ノードがやっとできました。

図にすると、こうなります。


50番地には、malloc関数で確保した、構造体のメモリ領域があります。

malloc関数の戻り値がこの番地であり、これはpに代入されましたから、pの箱には50が入っています。

また、6行目でrootにpの値を代入していますから、rootにも50番地が代入されます。

つまり、rootから新たに作った構造体を参照できるようになっています。

 

ところで、50番地の領域には変数名がついていないことに注意してください。

「変数の宣言」をしたならば、変数とアドレスが結びついてますが、

「malloc関数」で確保した場合には、そのような結びつきはありません。

だから、ポインタ変数を使ってアドレスを保持することで、そのメモリ領域を利用することになります。

もし、どのポインタ変数にもアドレスが保持されていない領域があったらどうなるでしょうか?

その領域はどこからも参照することはできませんから、利用することができません。

このような、動的に確保したがどこからも参照されない領域が増加することを、「メモリリーク」と呼びます。

「leak」は「漏れる」という意味ですね。

コンピュータからメモリがどんどん漏れて、無駄になっている状態のことです。

「メモリリーク」は、無駄にコンピュータのメモリ領域を消費してコンピュータに悪影響を与えます。

メモリリークするようなプログラムを作ってはいけません。

 

そういえば、7、8行目の説明が抜けていますね。

ここでは何をしているのでしょうか。

それは、構造体のメンバ変数の初期化です。

構造体の宣言でも、malloc関数でも、メモリ領域を新たに確保した場合には、

その領域にもともと入っていた値がそのままの状態で入っています。

これでは、構造体のメンバ変数の値が定まりませんから、何らかの値で初期化しておくのです。

7行目は、この構造体のデータを格納するdataに0を上書きして初期化しています。

8行目は、ポインタ変数であるnextをNULLで初期化します。

ポインタ変数の初期化には、NULLを使うと便利です。

さて、ここで、「->」という演算子が出てきました。

これは、「アロー演算子」と呼ばれ、その左側のポインタ変数が指す先のメンバ変数を表します。

つまり、左側のポインタ変数のアドレスが示す場所を探すと、

その場所に構造体があるので、その構造体の中にある、右側のメンバ変数を表します。

ポインタ変数pを使って、pが示す先の領域に書込みをしているのがわかりますね。

 

理解できたでしょうか。

ここまで理解したところで、もう少しリスト構造のノードを増やしてみましょう。

次のソースコードを見てください。
1:  p = malloc(sizeof(struct list));
2:  if (p != NULL) {
3:    p->data = 1;
4:    p->next = NULL;
5:    root->next = p;
6:  }

もう一つ構造体の領域を確保して、リスト構造の末尾に追加しています。

ここで再びpを使っていますが、こうやって上書きして良い理由はわかるでしょうか。

その理由は、このpが一時的に使用される変数だからです。

pの中身を書き換えても、リスト構造はrootから辿っていけば操作できますから、問題ないのです。

このプログラムでは、1行目で新しいノードへのポインタがpに格納され、3ー4行目でpを初期化した後、

rootが指す構造体領域のメンバ変数nextにpのポインタを代入しています。

この結果、以下のような状況になります。

リスト構造に末尾に、ノードが一つ追加されたのがわかりますね。


最後に、この2つのノードの間にノードを挿入してみましょう。

もうわかると思いますので、プログラムリストと結果の図だけを示します。

皆さん、各自で確認をしてみてください。
1:  p = malloc(sizeof(struct list));
2:  if (p != NULL) {
3:    p->data = 2;
4:    p->next = NULL;
5:    p->next = root->next;
6:    root->next = p;
7:  }


ちなみに、この講座では、リスト構造のノードが保持するデータは1つの整数値ですが、

より複雑なデータを扱うために、

複数の変数を用いたり、配列や構造体を用いたり、することができます。

それ以外にも、「構造体へのポインタ」を用いると、より柔軟なデータを扱えますね。

実際にリスト構造を使うときには、そのようにして、より複雑なデータを扱うことがほとんどです。

 

以上でリスト構造の基本が理解できたと思います。

リスト構造は、はじめは難しく思えます。

でも、ポインタ変数が箱だということを知れば、じっくり考えればわかるようになるはずです。

皆さん、しっかり理解して、リスト構造をマスターしましょう。

 

<お・し・ま・い>