前回記事の続きです。このシリーズでは、ポインタを知ってはいるけどよく分からないという人向けに、分かりやすく解説しようってことをやってます。
世の中にポインタを解説した文献(ウェブサイト含め)が数多くあれど、ポインタを「ある障害を乗り越えるための技術」という視点で解説している文献ってないよなぁと思ったのと、その視点から解説した方が分かりやすそうと思ってこういうシリーズをやってます。この記事はそのシリーズの第2回目の記事になります。
前回は、変数操作ってのはメモリ操作と同じなんだよーって話をしました。そして、swap関数を愚直に作ってみると、なんだか上手くいかないなぁってところで終わりました。
このswap関数が意図したとおりに動作しないというのが先ほど言っていた”障害”に当たるわけですが、ではなぜ意図したとおりに動作しなかったのかをこの記事では見ていきます。ちなみに、ここで達成したい動作というのは2つの変数の値を入れ替えることです。
メモリのイメージ
では、swap関数で上手くいかなかった原因を見ていくわけですが、そのときにメモリ上で何が起こっていたのかを考える必要があります。
ということで、メモリとはどのようなものか、メモリ操作(変数操作)とはどのようなものかを説明していきます(はじめは抽象的で分かりにくいかもしれませんが、後ろでは具体例と画像を使って説明してるので、諦めないで読み進めていただければと思います)。
まず、メモリのイメージとして、とても長くて細いテープをイメージしてください。で、そのテープに区切り線があって、区切られた箱一つ一つに10とか200みたいな数値が書き込まれているという状態を想像してみてください。要するに、この記事のサムネイルみたいなテープをイメージしてくださいってことですな。
ここで、その箱の内の一つを別の数値に書き換えるとします。それが、メモリ上にある数値を記録するという行為になります。
また、先ほどの箱と同じものでも違うものでもいいですが、箱の内の一つから数値を読み取ったとします。それが、メモリ上にある数値を読み取るという行為になります。
抽象的で分かりづらかったと思うので、次のような具体例で考えてみます。
#include <stdio.h>
int times_two( int i )
{
return i * 2;
}
int main()
{
int a;
a = 10;
printf("a = %d\n", a);
a = times_two( a );
printf("a = %d\n", a);
return 0;
}
このプログラムは、2倍する前の数値と2倍した後の数値を表示するプログラムです。
関数がmain関数を含め、全部で2つ定義されています。times_twoという関数は、引数として与えられたint型整数を2倍して返すという関数になってます。
main関数は、int型変数のaに10を代入しておいて、その後、times_two関数を呼び出すことで2倍する(つまり、最終的なaの値は20)という操作をやっています。
なので、全体としては、int型整数aに最初に設定されている数値(つまり、10)をprintf関数で画面に表示して、その後aをtimes_two関数で2倍して、その結果(つまり、20)を表示するというプログラムになっていると分かります(なので、出力画面には10と20が表示されます)。
この記事を読んでいる方であれば、このプログラムは理解できるかと思います。
では、ここで考えていただきたいことがあります。このプログラムが実行されている間、メモリ上ではどのようなことが起こっていたのでしょうか?
それが次の節での話になります。
メモリはどのように操作するか
先ほど、メモリのイメージとして区切り線の引かれたとても長細いテープをイメージしていただきました。そのテープは次の図のようになっているはずです。
しかしこれだけでは、どの箱を書き換えていいのか、あるいはどの箱から数値を読み取ったらいいのか分かりません。そこで、CPUは先頭からの通し番号で各箱を区別しています(ただし、先頭は0、つまり、一つ目の箱は通し番号0番として数えます)。図にすると次のような感じ。
テープと一緒に通し番号も書き加えました。例えば、通し番号10番の変数を書き換えると言えば、先頭から11番目の箱に入っている数値を別の数値に書き換える(同じ数値に書き換えても構いませんが)ということを意味します。
人間も本来であれば、数値を書き込んだり読み取ったりするときには、その対象としたい箱の通し番号を指定する必要があります。ただ、それだと分かりづらいということで、人間は通し番号の代わりに”変数名”というものを使うわけです。これも図的に表すと次の図のような感じ。
つまり、本来なら通し番号10番の箱を書き換えたいと思ったら、「通し番号10番の数値を書き換える」というプログラムを書く必要があるのですが、それでは変数が増えると大変だということで、人間は代わりに「変数名としてaが付けられた通し番号の数値を書き換える」というプログラムを書くことにしているのです。
もう少し言い換えれば、先ほどのプログラムでは「CPU(コンピュータ)は通し番号を介して、人間は変数名を介して、メモリを操作している」ということになります。
今までは、メモリ上にすでに変数名が対応付けられていることを前提に話を進めていました。ですが実際のプログラムを考えていただくと分かる通り、変数というものはプログラム内で人間が定義して初めて使えるようになるものです。
これを今回の話に沿って解釈すると、「テープ(メモリ)上のどこかの箱に○○という変数名を対応付けてね」と人間が指示しているわけです。では、その対応付けはいつ行われるのでしょうか?
変数はいつまで変数でいられるか?
メモリはテープみたいなもので、そのテープ上の箱に読み書きするときは通し番号が必要だけど、人間は代わりに変数名でその箱を扱っているという話をしました。
そのような観点から、先ほどのtimes_two関数のプログラムがやってたことを解釈し直してみます。
まず、プログラム実行前でのメモリの状態は次の図のようになっているはずです。
どこにも変数aやiと対応付けられた通し番号を持つ箱が無くて、どの箱にも何かわからない数値が入っているという状況です。
ここで、プログラムを実行してint a;
を実行した段階で初めて通し番号と「a」という変数名が対応付けられます。つまり、状況としては次の図のようになります(色を付けている部分が変化しています)。
このように変数名と通し番号を対応付けることを「メモリを確保する」とか「メモリを割り当てる」と呼んでいるわけです。
この対応付けは必ずしも通し番号10番にされるわけではありませんが、説明の便宜上、とりあえず通し番号10番に対応付けられたとしておきます。この後の説明でも変数名との対応付けを図示することがありますが、同様にその通し番号に必ずしも対応付けられるわけではありません。
この段階では、まだtimes_two関数は実行されていないので、変数iは存在しません(iという変数名と対応付けられた通し番号を持つ箱は存在しません)。
次に、times_two関数が実行されると、新しく「i」という変数が作られ(どれかの箱の通し番号とiという変数名が対応付けられ)、そこには10が書き込まれます。というのも、times_two関数の呼び出し元(要するにmain関数)では、引数としてaが指定されていて、そのaに対応する箱には10が書き込まれているからです。
つまり、変数aの数値をコピーした変数iが新しく作られるということですな。その状況が次の図。
ここで、times_two関数は「i」という変数名に対応付けられた通し番号を持つ箱(要するに通し番号11番の箱)から数値を読み取って、その数値に2倍した数値を引数として返します。その時点でtimes_two関数は終了して、処理がまたmain関数に戻ります。
ここまでの話は、上の節までの話を理解できていればすんなりと理解できることかと思います。元は、swap関数が狙い通りの動作をしなかった原因を考えていたわけですが、それにはここからの話が関わってきます。
このtimes_two関数が終了した時点で、メモリ上で何が起こるのかを考えてみます。まず、数値を2倍し終わった時点では上のような状態が保持されたままです。
次に、処理がtimes_two関数からmain関数に移るときに、「i」という変数名に対応付けられた通し番号を持つ箱(通し番号11番の箱)が破棄されます。
つまり、対応付けが解除されます。それと同時に、先ほどまで格納されていた10という数値はtimes_two関数実行前と同じように何かわからない数値に置き換わります。これを図的に表すと次のような感じ。
そして、変数名「a」と対応付けられた箱には、times_two関数のreturn文により返されてきた20という数値が書き込まれます(次の図)。
そして、プログラムが終了すると同時に、メモリはプログラム開始前の状態に戻ります。
以上が、プログラム実行中にメモリ上で起きていたことになります。
この説明の中では、図を作る都合上、aとiを通し番号10番と11番というように、隣り合った箱が使われるものとして表現しましたが、実際には隣り合っているかどうかは分かりません。配列の場合は隣り合うことが保証されていますが、このように別の変数として確保すると、隣り合うという保証はされません。
スコープの説明までたどり着けなかったorz
前回記事の最後の方で、”スコープ”なる概念を説明しますと言っていましたが、説明が予想以上に長くなってしまったので、その説明はまた次回に回します。
この記事でお伝えしたかったことは次の2点で、
- 人間はプログラム上でメモリ上の通し番号を変数名として扱っている
(だから、人間はメモリを変数名を介して操作していると言える) - 関数が終了した時点で、その関数内で定義された変数が破棄される
の2つです。その説明を分かりやすくしたかったがために、メモリのイメージとして長細いテープ(サムネイルみたいな)を考えて、そのテープに区切り線が付けられているというような状況を考えてもらいました。
この記事で紹介した考え方は、それ単体でも、プログラムが上手く動作しないときとか、プログラムを実行すると何がどうなるのかを考えるときなんかに使えるかと思いますので、理解しておいて損はないかと。
というわけで今回はこの辺りで。ではでは~
P.S.
う~ん、記事を書き進めてるうちに分かりやすい説明を思いついてどんどん書き直すから、次回予告が次回予告の役割を果たさないなぁ。
次回予告については「この記事を投稿した時点ではそう考えていたんだな」くらいにお考えください。まぁ、次回予告を当てにしてる方がいらっしゃったらの話ですけど笑