[前へ]   [目次へ]   [次へ]

宣言、定義の正体(2)(C/C++)


今回は、変数を作成する時、裏で行われている処理についてです。

今回流れを追うのは、以下のソースです。
<  1>
<  2>
<  3>
<  4>
<  5>
<  6>
<  7>
<  8>
#include <stdio.h>

int main(void){
   
int n;//(1)
   
n=10;//(2)
   
printf("%d",n);
   
return 0;
まず、(1)4行目からです。
これまでは「 int 型の変数 n を作成します」で終わっていたわけですが、
今回はこの裏に突っ込みます。

まず、この部分をコンパイラがどう処理するかです。
int n;
という変数定義文から、
「名前 n 」を記述された位置が所属する「ネームテーブル」に追加します。

「ネームテーブル」というのは変数のスコープを管理するのに使用する名前リストで、
プログラム全域で共有する「グローバル」、
コンパイル単位で共有する「ファイル」、
ブロック単位で使用する「ブロック」などがあります。

このうち、「グローバル」「ファイル」のネームテーブルは同時に一つしか存在しません。
「ブロック」は { } が多重されている数だけ同時に存在します。

(1)4行目が記述されている位置は main 関数の定義ブロック(3行目〜8行目)に所属します。
なので、この main 関数ブロックのネームテーブルに n という名前が登録されます。
この時、既に n が同ネームテーブル内に存在した場合はコンパイルエラーになります。

つまり、多重定義エラーというのは同じネームテーブル内に同じ名前を
二度登録しようとすると発生するわけです。

ちなみに、「グローバル」ネームテーブルに登録するには
関数定義の外で変数定義(グローバル変数定義、この場合は2行目など)します。
同様に、「ファイル」ネームテーブルに登録するには
関数定義の外で static 装飾を付けて変数定義します。


ネームテーブルへの登録に成功したら、次はその名前に対応付ける情報が設定されます。
名前に対応して設定されるのは以下の情報です。
「型名」:設定されている「実体」の型名。事前に「型定義」(後述)されていなければなりません。
「実体ID(相対)」:対応する「実体」を識別するためのID。

この場合は「型名」には int が設定されます。
「実体ID(相対)」はコンパイラが勝手に決めるのでどのような値になるかは分かりませんが、
コンパイラが矛盾のないように設定してくれます。

ところで、(相対)とは何かというと、
このIDは「実行時に確保した領域のID+この相対ID」として実際の実体を識別する、ということです。
実行するまで確保した領域のIDは分からないので、このようになっています。
この「実体ID(相対)」の値はコンパイル後に逆アセンブルすると見ることができますが、
見たところであまり意味はありません(笑)。

で、「相対」があればもちろん「絶対」もあります。
「実体ID(絶対)」は、その値だけで実際の実体を識別します。
つまり、さっきの「実行時に確保した領域のID+この相対ID」を計算した後は「実体ID(絶対)」になります。
そして、「実体ID(絶対)」とはC/C++言語からは「ポインタ値」とか「アドレス」と呼びます。
これについては近々詳しくやります。


続いて、先ほど出てきた「型定義」についてです。
「型定義」も専用のネームテーブルに登録されています。
「型定義」に定義された名前に対応付けられる情報は以下の通りです。
「大きさ」:その型の実体を一個作成するのに必要なバイト数。
「メンバテーブル」:その型が構造体やクラスなど、複数の実体を持つ場合に使用するネームテーブル。
「ビット配置」:その型がC/C++言語標準定義の型の場合、またはビットフィールドを含む場合、各ビットの扱い。
「同義型名」:その型が typedef によって定義された場合、複製元の型名。

C/C++の基本型である bool,char,short,int,long,float,double,long double などの型は、
最初から定義済みの状態になっています。

型定義は「 typedef 」による型定義の複製の他、「 struct 」「 class 」「 union 」「 enum 」などの
型定義キーワードを用いて定義されることで行われます。

なお、配列型(int[10]など)やポインタ型(int*など)は元になる型が定義されると自動的に使えるようになります。





ここまでがコンパイル段階で処理されます。
この先は実行時に処理されるので、コンパイラは命令として実行ファイルに書き出します。

実行時にはまず、変数の実体を作成するメモリ領域を確保します。
この時確保される大きさは、そのネームテーブル内(この場合は main 関数ブロック)にある
全ての実体を作成するのに必要な大きさをコンパイラが計算して決定されます。
また、コンパイラはそのブロック以下に属するブロックのネームテーブル分も一斉に確保することもあります。

この時の大きさの計算はネームテーブル内の各要素の「型名」を使って「型定義」の要素にある大きさを取得し、
それを全て合計することで求めます。
この時、「型名」が配列だった場合は単体の型として「型定義」の大きさを取得し、それを要素数で掛けます。
「型名」がポインタ型だった場合は「ポインタ型」の「型定義」の大きさを取得して使用します。

今回の場合はネームテーブルには n しかないので、 n の分だけが求められます。
そして、この確保した領域に各要素の実体を作成していきます。
C言語だと「実体を作成する」っていうのは実はメモリ領域のIDだけあれば終わってしまうので、
単純にメモリ領域だけ持ってきてその上にあたかも実体があるように扱ってしまってもかまいません。

しかし、C++言語の場合は「実体を作成する」時に「コンストラクタ」という関数が実行されたり、
「実体を破棄する」時に「デストラクタ」という関数が実行されたりすることがあるので、
単純にメモリ領域だけ持ってきて使う、というわけにはいかない場合があります。
(コンストラクタ、デストラクタについては「クラス」の項にて)

さて、C++の場合は必要に応じてこのタイミングで「コンストラクタ」が実行されます。

と、これでようやく
int n;
で行われていたことが完了です。



さて、それではここで仮想のコンパイラで上記を実際にここまでやってみます。
int n;
という定義から、 main 関数のネームテーブルに n を登録します。
main (3-8行)関数ブロックのネームテーブルの状態
名前型名相対ID
nint0
続いて、 n の型である int 型の型定義を参照し、 int 型の大きさを取得します。
この時の型定義テーブル(ただし全部は巨大なので一部だけ)
型名大きさメンバテーブルビット配置同義型(複製元)
char1なし7,6,...,1,0なし
int4なし7,6,...,25,24(Little)なし
float4なしIEEE32bit floatなし
double8なしIEEE64bit floatなし
void*4なし7,6,...,25,24(Little)なし
以下略

以上の二表により、このネームテーブルに必要な大きさは4バイトであることがわかりました。
なので、コンパイラは4バイト確保するよう、ここに命令を置きます。

続いて、実体の作成を行うよう命令を置くわけですが、
結局のところ int 型には特に実体作成の処理はないので、特に命令を配置することはありません。
なお、定義時に初期化文が指定されている場合( int n=10; など)は、
この実体作成のタイミングで初期化処理が実行されることになります。

実は、ここで「実体作成の処理がない」というのがC/C++の便利なところであり、面倒なところです。
便利なところは、「領域だけ取ってくればあたかも実体があるように使っても問題ない」という点。
面倒なところは、「いちいち初期化しないといけない」という点です。

というのもここで何も処理をしないということは、
確保したメモリのその領域は前回使った状態のまま放置されているということです。
これが、「初期化しない変数の値は不定」という理由です。
実際には、「不定」というより「前回使用時の残骸」が入ってるわけですね。

ちなみに、前回使ったのがいつかは分かりませんし、分かる必要もありません。
しかしメモリはPC全体で共有してるのですから、他のプログラムが使ってた残骸だったりするかもしれません。
なので結局のところ、「何が入ってるか全く予想できない」ので、「不定」なのですね(笑)



さて、実行時には、4バイト確保する命令が実行され、
4バイトのメモリ領域を確保するところまでが、この一行の担当となります。
この時、確保されたメモリ領域のIDが0x0013FF7Cだったとすると、
n の実体ID(絶対)は メモリ領域のID 0x0013FF7C + n の相対ID 0 で 0x0013FF7C となります。

この時存在する変数実体(このソースで定義していないものは省略)
実体ID(絶対)実体の型保持値
0x0013FF7Cint不定(未初期化)





長かったですが、次は(2)5行目、 n=10 の部分です。
まずコンパイラは、ネームテーブルから n の情報を探します。
続いて演算子=、定数10と続き、それぞれの型が取得されます。

この場合、両辺の型は同じなので、単純にコピーされるだけです。
コンパイラは n の実体を操作するために「確保領域ID+相対ID」を用いて実体の絶対IDを計算し、
その結果得られた絶対IDが示す実体に10を代入するよう、命令を生成します。

先ほどに引き続き、実行時に確保されたメモリ領域のIDが0x0013FF7Cだったとして、
n の実体ID(絶対)は先ほどの通り、 0x0013FF7C となります。
なので、 0x0013FF7C の int 型実体に10を代入します。



今のところ、この辺の裏方は全てコンパイラが正しく処理してくれています。
ところが、今後これらの裏方を直接操作する要素が出てきます。
例えば、「ポインタ」というのはここまでの実体IDとかをプログラム上に引きずり出して、
直接実体IDを使って処理を仕掛けるためのものなのです。

ここで重要になるのは、どこまでがコンパイル段階で処理されて、どこからが実行時に処理されているかです。
例えば、記述ミスをした時、コンパイル段階で問題になるならコンパイルエラーが出ますが、
実行段階で問題になるならコンパイルエラーは出ません。

次回は配列に対するアクセスについて書いていきますが、
配列に対する範囲外アクセス がコンパイルエラーを出さない理由もこの辺にあります。
何が言いたいかというと、配列に要素番号を指定してアクセスする、というのは実行時に処理されているということです。
実行時に処理されているので、コンパイル段階では何も検出されないわけです。

他にも、処理タイミングの違いは処理の柔軟性にもモノを言います。
コンパイル時に処理する場合、その時は変数の実体とかそういうものはないので、
ソースに記述された情報が全てであり、変数を指定して処理内容を変えることができません。

一方、実行時に処理する場合、変数の実体などは全て存在している状況で処理することになるため、
変数のその時の値などを使って処理内容をその場で決定することができます。

ということは、もし配列への要素番号指定がコンパイル時に処理されているのなら、
変数を使って要素番号を指定できないはずなんですね。
変数を使えるということは、その時点でそれは実行時に処理されているということなんです。

この辺の話は構造上絶対的な話なので、「〜は定数しか指定できない」とかいう指定があるものは
コンパイル時に処理されてると思っていいです。
(配列の要素数指定とかいい例ですね、今回書いた通り、
   配列はコンパイル時点で確保する大きさを定めているので、変数は使えないのです)


さて、先ほども書いたとおり、次回は「配列変数に対するアクセスについて」です。

[前へ]   [目次へ]   [次へ]

プログラミング講座 総合目次

最終更新 2008/10/17