C++言語解説:1-5-3.ポインタのダークサイド
2002-06-30 |
[C++ Index Top] [Prev] [Next] |
[概要] ポインタは非常に強力で自由な機能を提供してくれますが、扱い方を間違えたと きの被害も大きいものとなります。ここではポインタに関する注意事項を解説し ます。 [構成]・実装と規格の混同 * ポインタが持つものはアドレスとは限らない * データの並び方は環境により異なる ・ありがちなミスと誤解 * アドレス指定忘れ * 0でポインタを初期化する(C編) * 0でポインタを初期化する(C++編) * 自動変数の戻り値をポイントする * ポインタの型はオブジェクト型だけ? * 配列とポインタを混同する ●実装と規格の混同 ◆ポインタが持つものはアドレスとは限らない ・今まで、具体的イメージを持ってもらうために「ポインタ変数にアドレスを入れて」 といった表現をしてきました。しかし、ある程度「ポインタとは何か」のイメージ ができたところで、真実を言わなければなりません。そう 「ポインタが持つものはメモリアドレスとは限りません」 ・ANSI規格によるとポインタとは 「被参照型のオブジェクト(*1)を間接参照可能なオブジェクト」 と定義されています。これが仕様です。上記仕様が実現できれば、ポインタ変数が 持つ中身の形式は何でも良いのです。つまり実装はコンパイラとOSによって変わる 可能性があります。 ・ポインタが物理的なメモリアドレスを示すとは限らない例として、同時に2個の MS-DOSプロンプト上で、List1のプログラムを実行してみます。ちなみに筆者は WindowsXP & Borland C++ Compiler 5.5で本実験を行いました。 ・List1のプログラムは、変数のアドレスを表示しキー入力を待つものです。キー入力 されるたびに、変数値をインクリメントしていきます。 ・実験としては、片方のMS-DOSプロンプトでプログラムを実行し、キー入力を待つ間 に別のMS-DOSプロンプトで同じプログラムを実行してから、交互にキー入力を行っ ていきます。 [List1.ポインタが持つ値について実験] ┌───────────────────────────────────┐ #include <iostream> #include <cstdio> using namespace std; int main() { int i_a; int* ip_a; char c_b[10]; //キー入力を受ける文字列 ip_a = &i_a; cout << ip_a << endl; //アドレスの表示 cout << "Initial :"; cin >> i_a; //初期値の入力 gets(c_b); //入力待ち1回目 cout << ++i_a; gets(c_b); //入力待ち2回目 cout << ++i_a; gets(c_b); //入力待ち3回目 cout << ++i_a; return 0; } └───────────────────────────────────┘ ┌───────────────────────────────────┐ [実行結果] MS-DOSプロンプト(1) | MS-DOSプロンプト(2) | 0012FF88 ←(2)と同じ!! | 0012FF88 ←(1)と同じ!! Initial :20 | Initial :40 | 21 ←1回目 | 41 ←2回目 22 ←3回目 | 42 ←4回目 23 ←5回目 | 43 ←6回目 └───────────────────────────────────┘ ・実行結果では、変数のアドレスは同じものになっています。では、このプログラム は異なるMS-DOSプロンプトで独立実行できないかというと、そんなことはありませ ん。実際の出力結果を見ても変数は独立であることがわかります。実体が同一であ れば値の干渉が起きるはずですが、それは見られません。 ・この結果よりWindowsから各プロセスに渡されるメモリ空間がプロセス毎に用意され ていると予想されます。マルチタスクで動作するOSでは、OS側でリソースのバッテ ィングを避ける仕組みがあるのでしょう。 ・このように、ポインタに渡すデータは環境とコンパイラにより変わる可能性が大き くなっており、盲目的に「物理アドレス」だと思いこむと痛い目に遭います。アセ ンブラコードを解析しても、それは実装であり規格ではないのです。 ◆データの並び方は環境により異なる(★) ・ポインタを使えば、高い自由度でデータを操作する事ができます。しかし、メモリ に対してデータをどう置くかというのは、やはりOSとコンパイラに任されており、 メモリイメージは環境(CPU)により異なります。 ・例えば、筆者のPC環境ではint型は4byteとなります。 int i_a = 0x44332211; //(*2) としたとき、もしFig1のようにデータが置かれていたら、1byte毎にアクセスするこ とで2桁分の値を取得できるはずです。 [Fig1.int型のメモリイメージ(仮定)] ┌───────────────────────────────────┐ 1byte 1byte 1byte 1byte ←──→←──→←──→←──→ ┌───┬───┬───┬───┐ │ 0x44 │ 0x33 │ 0x22 │ 0x11 │ └───┴───┴───┴───┘ └───────────────────────────────────┘ ・そこで、int型の変数に4byteデータを入れ、そのアドレスをchar型ポインタで受け た後、無理矢理1byte毎にアクセスして、値を表示してみました。 [List2.int型データの置かれ方] ┌───────────────────────────────────┐ #include <iostream> #include <iomanip> using namespace std; int main() { int i_a = 0x44332211; //16進2文字(1byte)×4 char* cp_a; cp_a = (char*)&i_a; //char(1byte)でキャスト cout.setf(ios::showbase); //基数表示 cout << "base = " << hex << i_a << endl; cout << "0:" << hex << (int)*cp_a << endl; cout << "1:" << hex << (int)*(cp_a+1) << endl; cout << "2:" << hex << (int)*(cp_a+2) << endl; cout << "3:" << hex << (int)*(cp_a+3) << endl; return 0; } └───────────────────────────────────┘ ┌───────────────────────────────────┐ [実行結果] base = 0x44332211 0:0x11 1:0x22 2:0x33 3:0x44 └───────────────────────────────────┘ ・この結果からわかるように、データは下位桁から納められています。データの置か れ方をバイトオーダー(byte order)と呼びますが、この例のように下位桁から置か れるバイトオーダーをリトルエンディアン(little endian)と呼びます。x86系、 DEC系、VAX系等が該当します。 ・そして最初に想定したように、上位桁から置かれるバイトオーダーはビッグエンデ ィアン(big endian)と呼ばれます。68系、IBM系がこちらです。 ・つまりint型データですら、プラットフォームやOSによりバイトオーダーが異なるの です。エンディアンの存在を無視してポインタによるデータアクセスを行う事は非 常に危険なうえに、移植性0のコードを量産する行為と言えます。 ●ありがちなミスと誤解 ◆アドレス指定忘れ(★) ・ポインタに示すべき参照オブジェクトのアドレス指定を忘れる。もっともありがち なミスの割に、被害は大きいものとなります。例えばList3を見て下さい。 [List3.アドレス入れ忘れ (*実行禁止)] ┌───────────────────────────────────┐ #include <iostream> using namespace std; int main() { int i_a = 10; int* ip_a; *ip_a = 20; //ip_aはどこを指している? return 0; } └───────────────────────────────────┘ ・もともとは、i_aのアドレスを渡して、ip_aを介してデータ操作するつもりが、ip_a にアドレスを入れ忘れてしまったケースです。 ・どこを指しているかわからないip_aを介してデータを入れようとしています。入れ た後、何が起きるかわかりません。 ・従って、ポインタを使う際は「それは何を指している」のかを常に意識する必要が あります。 ・これを防ぐ手として、よく使われているのがnullポインタによる初期化です。ポイ ンタ定義時に、常にnullポインタで初期化するようにしておけば、参照先がnullポ インタの際「参照オブジェクトが確定されていない」ことがわかります。 ◆0でポインタを初期化する(C編) ・C言語において、nullポインタによる初期化を行う場合ですが、本によっては int* ip_a = 0; とすることで、nullポインタとして初期化できると書いています。これは正しいの ですが、筆者はお勧めしません。nullポインタによる初期化は int* ip_a = NULL; のようにNULLマクロを使用すべきだと考えています。 ・その理由ですが、nullポインタのアドレスは0とは限らないからです。処理系によっ ては0でないこともあるからです。 ・しかし、この場合以下の指摘があると思います。 「おかしい。先の int* ip_a = 0; を間違いだとは言ってないじゃないか!」 ・実は、式中で「0」が使用された場合、コンパイラは「0」をnullポインタに読み替 えるのです。従って int* ip_a = 0; は、処理系によらず(*3) int* ip_a = NULL; と解釈されます。 ・すると「ならばNULLを使わなくても良いじゃないか!」と言われるでしょう。確かに 式中では必要有りません。しかし「0」がnullポインタに読み替えられるのは式中だ けなのです。「0」が単独で使用される場合、それはnullポインタと解釈されません。 ・nullポインタと解釈して欲しい「0」が単独で利用される場合。それは「関数の引数 としてnullポインタを指定する」ときでしょう。 ・新しいC又はC++では、プロトタイプ宣言が義務づけられているので、通常の引数指 定で「0」がポインタに対する値かどうかコンパイラから判断できないケースは無く なったのですが、処理系によっては引数の数が可変長になる関数があります。 ・例えばWin/DOS/UNIXに共通するものでは子プロセスを呼び出すexec系関数がそれに 相当します。この関数を使用する場合、引数の最後にNULLを記述することになって います。0と書いた場合、移植性が落ちてしまうと言われているようです。 ・こんな理由で筆者はNULLマクロの使用を慣習付けていますが、実際のところはデー タの「0」とnullポインタを視覚的に区別することを目的としています。 ◆0でポインタを初期化する(C++編) ・しかしC++の場合、Cとは状況が異なります。C++では ┌───────────────────────────────────┐ 0は、ポインタがオブジェクトを参照していないことを示すポインタリテラル として機能する。 〜原著より引用〜 └───────────────────────────────────┘ と定義されているのです。 ・ある処理系で「無効なポインタ」が真に0アドレスの場合、C言語のコンパイラでは NULLマクロを #define NULL (void* 0) のように定義することができます。 ・しかし、C++はCよりも型のチェックが厳しくなっているので 全ての派生型ポインタをvoid*ポインタへ代入する ことはできても void*ポインタを他の派生型ポインタへ代入する ことはできません。すると、上記のようにNULLがvoid*ポインタの0と定義されてい たら int* i_a = NULL; と記述できず int* i_a = (int*)NULL; //C的キャスト 又は int* i_a = static_cast<int*>(NULL); //C++的キャスト(2-5章で説明) のようなおかしな真似をする必要が出るのです。 ・これを避けるためにC++コンパイラではNULLの定義を #define NULL 0 としています(しているように思われます)。 ・しかし、Cの場合で説明した「関数の引数としてNULLポインタを指定する」ときに問 題が生じてしまいます。特にC++では、関数オーバーロード(1-6章で説明)と呼ばれ る機能により void Func(char*){・・・} //char*ポインタ引数型 void Func(int){・・・} //int引数型 のような引数型の違う同名関数を定義することができ、Cの場合に比べて問題を引き 起こす確率が高くなっているのです。 ・例えば、明示的にNULLポインタを与えることを目的として Func(NULL); のように記述したと仮定します。このときC++のコンパイラでは「#define NULL 0」 により Func(0); と解釈されるため、呼び出される関数が Func(char*) なのか Func(int) なのか曖 昧になってしまうのです。原著では、この場合 Func(static_cast<char*>(0)); と記述するように書いています。 ・現実的なところでは、ライブラリを使用するユーザが、このような記述をいちいち 気にするとは思えません。そしてCの場合で説明したExec関数においては、Cコンパ イラで「大丈夫」だったプログラムが、C++コンパイラでは「危ない」状態になるこ とを意味しています。C++では明示的キャストが必要なのです。細かいところでは、 C++のCに対する「完全互換性」は確保されていないのです。 ・そして、ここで説明した内容から「C++でやってはまずいこと」として 「数値型とポインタ型の関数オーバーロード」 が出てきてしまいます。 ・上記のこともあり、NULLポインタの扱いについては「C++はCより退化した」という のが筆者の感覚です。ただでさえ誤解されやすいNULLポインタの扱いがCとC++で異 なるため、その混乱にますます拍車がかかっています。間違いなくC++最大級のダー クサイドです。 ◆自動変数の戻り値をポイントする(★) ・これは自動変数の性質に対する理解不足が引き金になっています。例をList4に示し ます。 [List4.自動変数をポイントする (*実行はお勧めしない)] ┌───────────────────────────────────┐ #include <iostream> #include <cstring> using namespace std; char* Strcat_NG(char* cp_a, char* cp_b); int main() { char* cp_a ="ABCD"; //文字列定数1 char* cp_b ="efgh"; //文字列定数2 char* cp_ab; //戻り値引き受けポインタ cp_ab = Strcat_NG(cp_a, cp_b); //アドレスを受け取る cout << "at_main :" << cp_ab << endl; //mainに戻って表示 return 0; } char* Strcat_NG(char* cp_a, char* cp_b) { char cp_ab[26]; //ローカル変数(自動変数) strcpy(cp_ab, cp_a); strcat(cp_ab, cp_b); cout << "at_sub :" << cp_ab << endl; //サブルーチン内表示 return cp_ab; //ローカル変数のアドレスを戻す!! } └───────────────────────────────────┘ ┌───────────────────────────────────┐ [実行結果] at_sub :ABCDefgh at_main :?BCDefgh ←先頭は表示不能文字へ化けました └───────────────────────────────────┘ ・関数内のローカル変数は自動変数(automatic variable)の性質を持ちます。関数が 呼び出されると生成され、関数の実行が終了すると破棄されます。 ・List4の場合、関数Strcat_NG()内のローカル変数cp_ab(のアドレス)を戻り値にして います。この場合、Strcat_NG()内ではcp_abの内容が正しく表示されますが、 main()に戻ったところでは、データが部分的に壊れています。 ・ただし、自動変数の値はすぐに破棄されるのではなく、しばらく残っているケース もあり、List4のようなプログラムをしていても偶然動いていることがあります。 この場合、プログラムが不安定となり、再現性の無いバグに悩まされることになる でしょう。 ◆ポインタの型はオブジェクト型だけ? ・ここで突然使用したオブジェクト型(object type)という言葉ですが、これは大きさ が確定しているオブジェクトを示す型です。例えば、intやdouble等。 ・int型オブジェクトを指すポインタは int* ip_a; と記述しますが、これはint*という型があるということでしょうか。実は違います。 ポインタの型は派生で決まるからです。参照オブジェクトの型からポインタの型を 構成することをポインタ型派生(pointer type derivation)と呼びます。 ・だから何なのか、つまりポインタ型は参照するオブジェクトによっていくらでも派 生され、それはオブジェクト型だけではないと言いたいのです。 ・例えば2次元の配列を考えてみます。 int i_a[2][3]; この場合、以下のように解釈できます。 [Fig2.int型2次元配列のイメージ] ┌───────────────────────────────────┐ ┌─────────┐ │ ┌─────┐ │ i_a[2][3]→ 2×│3×│int(4byte)│ │ │ └─────┘ │ └─────────┘ └───────────────────────────────────┘ ・これは「int×3の配列×2の配列」という意味です。配列名i_aの要素単位は int×3 なので、i_aが参照するオブジェクトは「int×3」という型となります。私の環境で はint型は4byteなので「int×3」というオブジェクトは12byteの領域を使用してい ます。よって i_a + 1 というオフセット演算は、12byte先のアドレスを指すことになります。一方 *(i_a + 1) は、要素である「int×3」配列の先頭アドレスなので、「int」のオブジェクトを参 照しています。したがって *(i_a + 1) + 1 のオフセット演算は4byte先を指すことになります。 ・言葉だと難しいのですが、以下の図を見れば理解できると思います。 [Fig3.int型2次元配列のポインタ参照] ┌───────────────────────────────────┐ int i_a[2][3] の場合 [1.幾何的イメージ] *(i_a+0)+0 *(i_a+0)+1 *(i_a+0)+2 ↓ ↓ ↓ i_a+0 →┌─────┬─────┬─────┐ │ i_a[0][0]│ i_a[0][1]│ i_a[0][2]│ └─────┴─────┴─────┘ i_a+1 →┌─────┬─────┬─────┐ │ i_a[1][0]│ i_a[1][1]│ i_a[1][2]│ └─────┴─────┴─────┘ ↑ ↑ ↑ *(i_a+1)+0 *(i_a+1)+1 *(i_a+1)+2 [2.メモリイメージ] │ int×3 │ │ int×1 │ │ 参照 │ │ 参照 │ ┌────┐ │ │ │ i_a + 0│→┌───────┐ オブジェクトとポインタの関係 │ │ │*(i_a + 0) + 0│→┌────────┬─────┐ │ │ ├───────┤ │*(*(i_a + 0) +0)│ i_a[0][0]│ │ │ │*(i_a + 0) + 1│→├────────┼─────┤ │ │ ├───────┤ │*(*(i_a + 0) +1)│ i_a[0][1]│ ├────┤ │*(i_a + 0) + 2│→├────────┼─────┤ │ i_a + 1│→├───────┤ │*(*(i_a + 0) +2)│ i_a[0][2]│ │ │ │*(i_a + 1) + 0│→├────────┼─────┤ │ │ ├───────┤ │*(*(i_a + 1) +0)│ i_a[1][0]│ │ │ │*(i_a + 1) + 1│→├────────┼─────┤ │ │ ├───────┤ │*(*(i_a + 1) +1)│ i_a[1][1]│ └────┘ │*(i_a + 1) + 2│→├────────┼─────┤ └───────┘ │*(*(i_a + 1) +2)│ i_a[1][2]│ └────────┴─────┘ └───────────────────────────────────┘ ・前回(ポインタを意識する場面)でも多次元配列を説明しましたが、ポインタ型は参 照するオブジェクトで決まるという認識がない場合、ポインタオフセット演算で int×4のアドレスを指すことに抵抗を感じたかもしれません。 ・その場合には、ここでもう一度多次元配列とポインタの関係について見直すように して下さい。 ◆配列とポインタを混同する(★) ・ポインタはあくまで参照先を指し示すものであり、読み書き可能なメモリ領域確保 を保証するものではありません。 ・例としてList5を見て下さい。 [List5.配列とポインタを混同する (*実行禁止)] ┌───────────────────────────────────┐ #include <iostream> using namespace std; int main() { char* cp_a = "exsample"; //文字定数をポインタで参照 cout << cp_a << endl; *(cp_a+3) = 'A'; //aをAに変える cout << cp_a << endl; return 0; } └───────────────────────────────────┘ ・さて、上記の何が悪いかわかるでしょうか? ポインタcp_aの参照するオブジェクト は定数(例では文字列定数)であることがポイントとなっています。 ・OSや環境によっては、定数がリードオンリー領域に書き込まれるものがあるからで す。しかも上記コードは動いてしまうこともあるのが厄介で、見過ごされているケ ースもあります。 ・この過ちを犯した場合「昨日は動いたけど今日は動かなかった」「こっちでは動い たけどあっちでは動かなかった」といった意味不明なバグに悩まされる最低パター ンに陥ります。しかも非常に見つけにくいというおまけ付きです。 ・つまり、ポインタはあくまで場所を指し示しているだけであり、オブジェクトに対 してできる事は、オブジェクトの性質次第なのです。 (*1)メモリ領域にデータを持つ実体を指します。 (*2)定数に0xを付けると、それは16進表記であることを意味します。 (*3)あくまで、ANSI規格に沿ったC/C++コンパイラを使用していることが前提です。 [Revision Table] |Revision |Date |Comments |----------|-----------|----------------------------------------------------- |1.00 |2002-06-08 |初版 |1.01 |2002-06-16 |図修正 |1.02 |2002-06-23 |リンク修正 |1.03 |2002-06-30 |語句修正 |1.04 |2002-07-28 |内容追加 |1.05 |2003-02-08 |Fig.3 ミス修正 [end] |
Copyright(C) 2002 Altmo
|
[C++ Index Top] [Prev] [Next] |