ホーム < ゲームつくろー! < C++踏み込み編 < ->、*、&、[]演算子って奥深い


その1 ->、*、&、[]演算子って奥深い


 「->」「*」「&」そして「[ ]」演算子は、ポインタ関連を扱う演算子です。これらの関係を曖昧に捕らえていると、関数の引数や戻り値、演算子のオーバーロードなどで大混乱を起こします。私は・・・起こしました(笑)。そこで、これら演算子の振る舞いをまとめてみました。頭で箱と矢印を描きながらご覧下さい。



@ メンバ選択演算子「->」は右辺値が大事

 「->」これはメンバ選択演算子というのだそうです(MSDN)。アロー演算子と呼ぶ人もいます。これは「後置演算子」、つまり演算子の左側の値(左辺値)を使います。

 メンバ選択演算子の左辺値に来ることが出来るのは通常「クラスのオブジェクトへのポインタ」です(「通常」じゃない場合については後述)。一般的なポインタ変数(int*型、double*型など)は左辺値に出来ません。この演算子を「ポインタの先にあるものを取ってきてくれる」と勘違いして、

int Val = 100;
int *Ptr = &Val;

int Result = Ptr->;

などとしてはいけません(しないな、これは(笑))。
 この演算子はさらに右辺にクラスのメンバ変数およびメンバ関数(identifier)を置く決まりがあります。

 演算子なので、演算を行って何か答えが出ます。メンバ選択演算子の答えは「右辺値の型」と同じと決められています。右辺値がメンバ関数の場合は、その戻り値の型です。例えば、

class Person
{
publc:
   int age;
   int GetAge();
};

 このPersonクラスについて、

Person *me = new Person;

とポインタの先にオブジェクトを置いた状態で、

int Age = me->GetAge();

という演算になります。=の右側でメンバ選択演算子が演算を行い、この場合GetAge関数が実行されて、結果としてint型の年齢が返ります。

「んなことわかってるよ!」と怒られそうですが、右辺はメンバであれば何でも良いので、

me->GetAge;

というのもありなんです。関数に「()」が無くなってしまいまいました。さて問題、この演算の結果を代入することが出来る変数は、どう宣言すればよいのでしょうか?これ、なかなか出来ないんですよね(私もですが)。正解は以下のように宣言します。

int (Person::*Func)();
Func = me->GetAge;

 メンバ選択演算子で括弧が無いメンバ関数名を指定した時には「関数ポインタ」を指すという決まりになっています。関数だってメモリのどこかに置かれているのですから、当然アドレスを持っています。よって、それを格納するポインタもちゃんと定義でき、それが関数ポインタというわけです。変数名を太文字で示していますが、こんなところに置くんですよね。そして、どのクラスで定義されている関数かを識別するために「Person::」という接頭子も付けて、括弧でくくって、関数ですから戻り値と引数定義部分をちゃんと付けます(今回は引数なしですけど)。

 この関数ポインタを自由に使えるようになると、C++のマニアックな領域に一歩近づきます。

 さて、文頭に「通常」と書いたのは、この演算子の左辺値がポインタでなくても良い場合があるからです。それはこの演算子をオーバーロードした時に起こります。

class Dumy
{
public:
   Dumy(){m_DumyValue = 100;}
   int m_DumyValue;
};

class Person
{
private:
   Dumy m_Dumy;

publc:
   int age;
   int GetAge();

public:
   Dumy* operator ->(){ return &m_Dumy;}
};

 DumyというクラスをPersonクラスのメンバ変数として持たせ、メンバ選択演算子をオーバーロードします。この時演算結果としてDumyオブジェクトへのポインタを返します。すると、

Person me;
int Val = me->m_DumyValue;

という使い方になるのです。meはポインタではなく実体です。メンバ選択演算子は後ろの型からその演算子がクラス内で再定義されているかをチェック。この場合はされているので、それを実行して、しかもお行儀の良いことに、ポインタとして返された値の参照先のm_DumyValueをちゃんと返してくれます。これは、使えます!



A 間接演算子「*」はたぐるイメージで

 間接演算子はC言語の初心者の9割はハマルだろう「ポインタ」と深く関係する演算子で、これは「単項演算子」です。ポインタは色々な表現やイメージの仕方があると思いますが、私のイメージは「ダンボール」なんです。

 ダンボールの中に数字が入っています。これがメモリに値が格納されている状態。ダンボールには中身が誰にでも分かるようにシールが張ってあります。これが変数名。一方で管理人しかわからない通し番号も振ってある。これがアドレス(番地)。1つの箱に、「値」「変数名」「通し番号」という3つの属性が付いているわけです。
 ある変数名の付いたダンボールには、通し番号であるアドレスが入っている。これがポインタ変数。アドレスがあるとそこに行きたくなるもの・・・なるんですってば!テクテクと歩いて、アドレスの箱を見つけて中身をのぞくと、何かが入っている。

 この「住所を頼りにたぐっていって箱を見つけて中身を見る」。これが間接演算子の役目、演算内容です。

軽くポインタ変数のおさらいをしておきます。

Person me;

と宣言した時点で、変数名が書かれたシールをべたっと張ったダンボールが用意されます。もちろん通し番号も付いてます。入れることが出来るのはPersonオブジェクトだけです。これは基本中の基本。

Person *me;

と宣言すると、変数名が書かれたシールをべたっと張ったダンボールが用意されます。もちろん通し番号も付いてます。ここまではさっきとまったく同じです。違うのは入れることが出来るもので、Personオブジェクトが入っているダンボールのアドレス(通し番号)を入れることが出来ます。中を開ければ、住所が書かれた紙が入っているイメージです。では、

Person ***me;

には何を入れることが出来るのか?正解は、Personオブジェクトが入っているダンボールのアドレス、を入れてあるダンボール(*me)のアドレス、を入れてあるダンボール(**me)のアドレスです。何だかねぇ・・・。でも、時に使うことはあるんです。

 さて、上の「*」は間接演算子ではありません。これは単なるポインタ宣言です・・・と区別して考えた方が正直すっきりします。間接演算子は、

Person *me;    // ポインタ宣言
int Age = (*me).GetAge();

と使います。ポインタに間接演算子を付けると、そのポインタが指している変数の型が演算結果として出されます。meはPerson型のポインタなので、指す先にはPersonオブジェクトの本体がいます。よって、(*me)で返される型はPerson型で、それに「.」を付けてさらにメンバ関数にアクセスしているわけです。ちなみに、括弧を外すとエラーになります。

Person ****me;    // ポインタ宣言

のようなポインタで(*me)と演算した時の答えの型は(***Person)です。間接演算子が1つ外れるんですね。でも、これはまだアドレスですから、Person型までするには(****me)と宣言のときについた「*」の数だけたぐって行けばいいんです。

Person ****me;    // ポインタ宣言
int Age = (****me).GetAge();

関数でポインタを引数に取ることがありますが、同様に間接演算子でアドレスを頼りにダンボールをたぐるって中を見て、時に中に違うものを入れることもできます。これを使ったのが有名な「参照渡し」というものです。



B アドレス演算子「&」ってかなり謎

 このアドレス演算子&は、やっぱり何か演算結果を出してくれる単項演算子です。右辺値しか取りません。この演算子が出すものは何か?それは「変数のアドレスを返す」ということです。先ほどの段ボールの例で言えば、変数名を見てどこか別の場所に一緒に書いてある通し番号を返す演算子です。演算結果は中に入っている変数のアドレスとなります。

 この演算子、「->」とか「*」よりも分かりにくいと私などは思ってしまいます。例えば、

Person me, *ptr_me;
ptr_me = &me;

は代入可能です。これはわかります。meは変数名、&meはその変数名を見てアドレスを返す演算をするわけで、meはPerson型だから、&meはそのアドレスを返す。ポインタ変数ptr_meはアドレスを入れる専門の箱だから、めでたく代入ができると。ところが、

Person me, *ptr_me;
&me = ptr_me;

これは出来ません。イコールを逆にしただけなのに・・・。一方、

Person me;
Person &address_me = me;

これは出来るんです!アドレスに実体を入れる?しかも、address_meはどうなると思いますか?meとまったく同じPerson型として扱えます。「じゃぁ、&はいらねぇじゃねえか!!」。その通りなんですよね。もう1つ、

Person &address_me;

これはコンパイルエラーになってしまいます。「参照が初期化されずに宣言されています。」と言われてしまうんです。

この「&」の解釈。まず、変数宣言で使った時には、「真っ白な紙」が用意された状態です。通し番号を書くべき紙に何も書かれていない。だから、この紙が何を指しているかわからないので、コンパイルエラーとなる。「Person &address_me = me;」のように宣言と同時に代入するのはOK。この代入は「変数名meのアドレスの書き写し」のような事をしているわけです。ここまでは変数宣言でのお話。
 宣言以外で&を使ったときには、変数のアドレスを返してくれるんですが、これは「リードオンリー」なんです。読み込めるだけ。だから、ptr_me = &meは可だったんです(これも書き写し)。しかし逆「&me = ptr_me」は書き込みなんでダメだったというカラクリです。

 変数宣言で何かアドレスを書き込まれると、直ちにダンボールに貼られます。そして、この変数は通常の実体と同じ振る舞いになります。ここもミソですよね。

 変数宣言の時だけ、ただ1度だけ書き込みが可能である変数。通常はリードオンリー。それが&meの正体です。何だか市販のCDみたいな奴ですね。

 さて、ではちょっと問題。

int val = 100;
int &address_val = val;   // これは可能

とした後に、「address_val = 500」と入れると、valはどうなるか?

答えは「500になる」です。address_valはvalのアドレスを書き写して箱に「べしっ」と貼られます。当然ながら、貼られたダンボール箱には、同じ通し番号である&valがすでに貼ってある(そうしないと同じアドレスが2つ存在してしまう)。だから、address_valに500を入れると、valだって500になる。面白いですね。同じ箱に名前が2つ付いているという、奇妙な状態を作れるわけです。



C 配列参照演算子[ ]はポインタ演算の簡便表記

 配列参照演算子「[ ]」は、中に整数nを入れることによってn+1番目の配列の要素にアクセスできる演算子です。とても分かりやすくて便利です。この演算子は「後置演算子」の1つで、左辺にポインタ変数しか取れません。 演算結果の型はポインタが指す実体の型になります。

 この演算子、例えば次のように使います。

int Ary[100];
int a = Ary[50];

100個のint型変数を用意して、その51番目をaに入れる。配列はイメージしやすいですね。ところで[]は演算子なので変数名ではありません。変数名はAryです。では、この変数には何が入っているのでしょうか?答えはint型のポインタです。ですから、

int Ary[100];
int *Ptr = Ary;

という代入は可能です。どこを指すポインタがAryに入っているのかというと、配列の先頭のアドレスです。まぁ、この辺は配列の基本知識ですから、別段難しくはありません。

 Aryがポインタということは、ポインタ演算が可能です。ポインタ演算とは、ポインタの指すアドレスに対する足し算引き算の演算の事で、変数の大きさを1単位とした加減算が行われます。例えば、

int *Ptr;
int *NextPtr = Ptr + 1;

とすると、NextPtrにはPtrが持っているアドレスのお隣のアドレスが格納されます。間接演算子*を使えば、ポインタの先にある実体を取り出せます。一方で、配列は大きなメモリ空間に数字がずらっと連続して並んだものです。Ary[1]とすると、Ary[0]のお隣さんの実体を取得できます。結局、両者は同じ事をしていて、

int Ary[100];
int UsePtr = *(Ary + 1);
int UseAry = Ary[1]

とどちらの方法でもお隣さんの値を取り出せます。配列参照演算子はポインタ演算の簡便法だったわけです。

 この演算子は次のような初期化が可能です。

int Ary[] = {10, 20, 30}

これで要素数3のint型配列が勝手に作られます。この形式で2次元配列は次のように宣言します。

int Ary[][3] = {{10, 20, 30}, {5, 6, 7}}

「なんで『3』?」って思いますよね。これを空白にするとコンパイルエラーになります。多次元配列をこの形式で宣言するとき、一番最小の単位は固定しないといけないのです。1次元配列の時には、最小単位が1であることが明確なので、コンパイラは空白でもエラーを出さないわけです。ちなみにですね、

int Ary[][2] = {{10, 20, 30}, {5, 6, 7}}
int Ary[][5] = {{10, 20, 30}, {5, 6, 7}}

とすると、上はコンパイラエラーですが、下はなんと通ります。右辺の最小単位数よりも多い場合は、余分に配列を取ってくれるので、エラーにならないんです。「じゃぁ、Ary[0][4]には何が入っているの?」と思いますよね。実は「0」が入っています。定義していない部分を勝手にゼロで初期化してくれているんです。では、

int Ary[100] = { };
int Ary[100] = {0}

はどうか?残念ながら上はコンパイルエラー、下はOKです。さすがに完全省略はダメのようです。

Ary[2][5]というのは、*(Ary[2] + 5)と同じ事をしています。Ary[2]には要素数5の配列の3つ目の先頭アドレスが入っているんです。そこからさらに5つ小刻みに進んだポインタが指す値を間接演算子で取得しています。

 配列参照演算子は単なるポインタ演算なので、当然ですがあっさりと触っちゃいけない領域まで踏み込めます。一度確保した配列を大きくしたり小さくしたりする、いわゆる可変長にする事はできません。ただし、mallocなどのメモリ操作関数で動的に確保した配列に関しては可能です。あと、

int Ary[100] = {0};
iint Result = Ary[-1];

は可能です!ポインタ演算の代わりですから、当然減算もできるわけです。ただ、上の結果は「不定」です。

配列で宣言されるのはポインタと言いましたが、1つだけ普通のポインタと異なる挙動があります。

int BigAry[100] = {0};
iint SmallAry[5] = {0};

BigAry = SmallAry;

この代入はできません。BigAryは「int[100]型のポインタ」、SmallAryは「int[5]型のポインタ」と定義されるため、異なる型のポインタ代入になっているためです。でも、両者ともポインタ演算ではint型が1単位。不思議な感じがしますね。



 どうでしょうか?きっちりマニアックに整理でしょうか?私個人的には曖昧だったアドレス演算子「&」の挙動を整理できたのがちょっと収穫です。この章ではポインタ関連の演算子を詳しくまとめてみました。忘れかけたらまたご覧下さい。