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

ポインタ(4)(C/C++)


今回は、ポインタに関するミスのパターンについてです。

これまで解説してきた内容を理解していれば、当然そうなるのはわかるのですが、
分かっていてもやってしまう間違いというのはあるものなので、
今回はその辺についてやっていきます。


ポインタに関するミスというと、実は基本的に3種類しかありません。
それは、「 無効ポインタの参照 」「 参照レベルのミス 」「 型の不一致 」の3つです。
ポインタの扱いを間違えると様々な現象が起こるので、難しいと思われがちなポインタですが、
実際はこの三点を抑えているだけで、99%のポインタ系のバグを見つけることができます。

さて、実際に起こる現象やデバッグについては次回以降として、
今回は上記3種類の内容について、解説していきます。


ポインタ系バグで特に要注意なのは、直ちに異常終了しないケースにおいて、
後々になって異常終了したり、変な動作をしたりという「地雷」的な発現をするパターンです。
これは上記の全てのバグパターンにおいて、結果として発生する可能性があります。

「地雷」パターンが発生するとまず、メモリ上に存在する実体に対して
「地雷」のようなものを埋め込みます。(実際は実体の保持するデータを破壊しています)
その後、その実体を使って何か演算をしようとすると「地雷が爆発」してバグります。

厄介なことにこのパターンは「地雷」を踏まなければバグが発現しないため、
「たまたま踏まなかった」という理由でバグがなかなか発現せず、
「コードを変更したら踏むようになった」とか「ユーザが使ったら踏んだ」とか、
そういうタチの悪いバグと化すケースが多いです。

ポインタ系バグの要所を押さえて、素早くバグを検出することは非常に重要なことなのです。



●無効ポインタの参照

無効ポインタというのは、無効な絶対IDや、有効な絶対IDを保持していないポインタ型変数のことです。
ここでいう「有効な絶対ID」というのは、
主に最近の実行時に示す「実体リスト」の「実体ID(絶対)」に入っている絶対IDです。
また、文字列定数やライブラリ内にある実体の絶対ID、関数なども有効な絶対IDです。
これらの絶対IDをプログラム中で使用することになった場合は、その時に「実体リスト」へと示すことにします。
常に全部列挙しようとしたら大変なことになりますからね・・・(笑)

そして、「有効な絶対ID」でないものは全て「無効な絶対ID」です。
無効ポインタを参照するケースでやりがちなのは、
「 NULL を参照する」「未初期化のポインタを参照する」
「範囲外のポインタを参照する」「破棄された実体のポインタを参照する」です。

まず、「 NULL を参照する」についてです。
NULL については以前少し触れましたが、これは「常時無効なポインタ」と定義されています。
これを参照すると「必ずアクセス違反で異常終了する」他、「無効であることを判定できる唯一の絶対ID」となっています。
というのも、絶対IDは実行時に割り当てられる上、少し前にやったように「使用済みの領域はここまで」というような
割と適当な管理がされています。そのため、ある絶対IDを見たときにそれが有効であるか、
無効であるかを判断する適切な条件が存在しません。
(明らかに妙な絶対IDであればアクセス違反になりますが、わずかにずれた程度だと判断できません)

しかし、無効なポインタが必要になるケースは少なからずあります。
たとえば、検索の結果、ヒットした要素のポインタ値(絶対ID)を返すような関数があったとして、
何も見つからなかった時にそれを示す値が必要になったりするわけです。
そのため、「有効でないこと」を示すために NULL は便利に使われます。

また、「参照すると必ず異常終了」という特性は、
無効ポインタを参照したことを確実に提起するため、デバッグ時に役立ちます。
それ以外の無効ポインタでは、場合によっては異常終了しないことがあるため、
バグの発見が遅れたり、原因を突き止めるのが難しくなったりします。



このパターンの典型的な発現コード
<  1>
<  2>
<  3>
<  4>
<  5>
<  6>
<  7>
<  8>
#include <stdio.h>

int main(void){
   
int *p;
   p=NULL;
   printf(
"%d\n",*p);//ここで発現します
   
return 0;


次に、「未初期化のポインタを参照する」についてです。
これまで解説したように、ポインタ型変数でも実体が作成された直後は値が不明になっています。
不明な値が「有効な絶対ID」である可能性はほとんどありませんが、
稀に有効な絶対IDが入っていることもあり、このようなポインタを参照すると
「実行する度に動作が変わる」ような厄介な現象を引き起こすことがあります。
このパターンは非常にタチが悪いバグになるので、
作成したポインタ型変数にはとりあえず NULL を入れておく等するとデバッグで便利です。( NULL なら少なくとも異常終了はしてくれる)

また、ある程度ソースレベルでも判断がつくため、
コンパイラによっては警告を出してくれる場合がありますが、
完全な検出はできないと思った方がいいので、過信は禁物です。

このパターンの典型的な発現コード
<  1>
<  2>
<  3>
<  4>
<  5>
<  6>
<  7>
#include <stdio.h>

int main(void){
   
int *p;
   printf(
"%d\n",*p);//ここで発現します。コンパイラによってはここで「未初期化な変数を参照」という警告が出ます。
   
return 0;


次に、「範囲外のポインタを参照する」についてです。
これは主に配列変数や、配列変数内の実体の絶対IDを使っているときに起きがちなミスです。
一般に「バッファオーバーフロー」と呼ばれることも多いミスで、
ようするに「確保した要素数を超えた位置を参照してしまった」場合です。
もっとも発生しやすく、また大きく絶対IDがずれないため異常終了もされにくいという、
種類としては最も厄介なものと言えるでしょう。

その一方で、昔から数多のプログラマを悩ませてきたパターンであるだけに、
デバッガによっては発生を調査してくれるものもあるようです。

このパターンの典型的な発現コード
<  1>
<  2>
<  3>
<  4>
<  5>
<  6>
<  7>
<  8>
<  9>
< 10>
< 11>
< 12>
#include <stdio.h>

int main(void){
   
int i,n[10]={0,1,2,3,4,5,6,7,8,9};
   
int *p;
   p=n;
   
for(i=0;i<=10;i++){
      printf(
"%d\n",p[i]);//ここで i==10 の時発現します。
                          //しかし多くの場合、謎な値が表示されるのみです。
   }
   
return 0;


最後に、「破棄された実体のポインタを参照する」についてです。
これは主に動的確保(実行時に大きさを決めて実体を作成する)した実体にやってしまいがちなミスです。
動的確保した実体は不要になった時点で明示的に破棄しなければいけないことになっていますが、
実はまだ必要なのに破棄を行ってしまい、後から使おうとしてこのパターンが完成するケースが多いです。

ローカル変数であっても、実体が破棄された後で参照しようとすれば同様にこのパターンになりますが、
あからさまなケース(ローカル変数の絶対IDを return しようとする等)はコンパイラが警告してくれることが多いです。
加えて、普通に作るとローカル変数の実体IDを破棄後も参照するようなプログラムにはあまりならないため、
ローカル変数に起因してこのパターン、というのはあまり聞きません。

このパターンの典型的な発現コード(C++)
<  1>
<  2>
<  3>
<  4>
<  5>
<  6>
<  7>
<  8>
<  9>
< 10>
#include <cstdio>
using namespace std;

int main(void){
   
int *p;
   p=
new int;//新しいint型実体を作成します。
   
delete p;//作成したint型実体を破棄します。
   
printf("%d\n",*p);//ここで発現します。
   
return 0;


このように、何らかの理由で発生した「無効な実体ID」を参照することによって、このグループが発生します。
体感ですが、ポインタ系のバグの80%ぐらいはこのグループであるような気がします。
対策としては、「無効な実体ID」ができる条件になったらしつこく NULL を代入しておくことです。
そうしておけば発現したら NULL 参照で落ちてくれるので、
デバッガを用いて無効参照した位置を高確率で拾うことができます。
参照した位置が問題とは限らないところが厄介ですが、それでもデバッグはかなり楽になります。


●参照レベルのミス

参照レベルというのは、 & アドレス取得演算子や、 * ポインタ参照演算子を使った時に増減されるもので、
何回ポインタ参照すれば元の実体にたどりつくかという値です。

以前解説したとおり、上記の演算子を使うと型が変化します。
参照レベルは実質、型についている * の数と思って構いません。

大抵の場合、型チェックの関係で、これを間違えるということはないように思われます。
しかしながら、途中に配列を含んだ複雑なパターンで実体を作成していると、
何段目が配列要素で、どこまで要素を保有しているかなどがわかりにくくなることがあります。
また、 void* 型(今後解説します)へ入れた場合なども、型に付いている * の数が失われるので、
元通り復元するのはプログラマの仕事となり、ここでも同じような状況になります。

状況的には特殊なので、そんなに遭遇機会はありません。
さらに、このパターンは比較的、派手な無効ポインタ参照を招きやすいので、
結果として無効ポインタ参照になっていても、こちらのパターンが原因の可能性も考慮しておく必要があります。

また、このパターンは動作が複雑になりやすいため、
原因の究明は困難(というか混乱)になりがちなので、対処するには覚悟が必要です。

下記の発現コードの時点でも既にかなりややこしいですね。

このパターンの典型的な発現コード(C++)
<  1>
<  2>
<  3>
<  4>
<  5>
<  6>
<  7>
<  8>
<  9>
< 10>
< 11>
< 12>
< 13>
< 14>
< 15>
< 16>
< 17>
< 18>
< 19>
< 20>
< 21>
< 22>
< 23>
< 24>
< 25>
< 26>
< 27>
< 28>
< 29>
< 30>
< 31>
< 32>
< 33>
< 34>
< 35>
< 36>
< 37>
< 38>
< 39>
#include <cstdio>
#include <cstdlib>
using namespace std;

int sortproc(const void *val1,const void *val2){
   
return *(const int*)val1-*(const int*)val2;//ここで発現します。
            //↑ここの型は本来、const int**であるべきですが、
            //間違えてconst int*にしてしまっています。
            //このケースではまず異常終了はしないでしょうが、
            //ポインタ(絶対ID)の大小を比較しているので、意図した結果にはなりません。
}

int main(void){
   
int **data;
   data=
new int*[5];//配列オブジェクトを作成します。
   
data[0]=new int[6];
   data[1]=
new int[7];
   data[2]=
new int[8];
   data[3]=
new int[9];
   data[4]=
new int[10];
   
//各オブジェクトに値を入れておきます。
   
data[0][0]=5;
   data[1][0]=3;
   data[2][0]=1;
   data[3][0]=2;
   data[4][0]=4;
   
//↓今入れた値でソートします。
   
qsort(data,5,sizeof(int*),sortproc);
            
//qsort関数はsortproc関数を繰り返し呼び出してソートを実行します。
   //↓で1,2,3,4,5と表示されることを意図しています。
   
printf("%d,%d,%d,%d,%d\n",data[0][0],data[1][0],data[2][0],data[3][0],data[4][0]);
   
delete[] data[4];//配列オブジェクトを破棄します。
   
delete[] data[3];
   
delete[] data[2];
   
delete[] data[1];
   
delete[] data[0];
   
delete[] data;
   
return 0;
今回の例ではやっていることの意味がよくわからないかもしれません。
何故かと言うと現実に起こるようなコードをかなり簡略化したものだからです。

現実には、二次元配列を動的確保するようなケースは結構大規模なプログラムでしか行いません。
そのようなケースではソート(整列)一つ取っても様々な条件が設定できるので、
コードが複雑化しやすく、上記のようなミスも起こりやすくなります。



●型の不一致

「型の不一致」というのは、ポインタ型の型と実体の型が適合および互換していない時のことです。
このパターンの発生には必ず型キャストが絡むため、型キャストを行わなければまず起きません。

「型キャスト」というのは、「目標の型を変換する操作」のことです。
今までにも「暗黙の型キャスト」とか「明示的型キャスト」とかの言葉を使ったことがあります。
「暗黙の型キャスト」は演算時や代入時に自動的に行われる型変換のことでした。
「明示的型キャスト」は () 型キャスト演算子を通常型に適用した型変換のことでした。
「明示的型キャスト」は暗黙の型キャストをソース上で明示しただけです。

「暗黙の型キャスト」「明示的型キャスト」ともに、実行することに危険はありません。
なぜなら実行できない場合はコンパイルエラーになるので、危険な操作にはなり得ません。

しかし、型キャストにはもう一個「強制型キャスト」という種類があります。
名前からして危険な雰囲気漂うこの操作は、実際にバグを誘発するような危険性を持っています。
よくC/C++の解説等で「可能な限りキャストを使ってはいけない」と言われるのは、
確実にこの「強制型キャスト」についてです。

そして、その「強制型キャスト」が誘発させるのがこの「型の不一致」というバグです。

さて、「強制型キャスト」は「ポインタ型を他の型へキャスト」した場合や、
「整数型などからポインタ型へキャスト」した場合に起こります。
以下の例をご覧ください。
<  1>
<  2>
<  3>
<  4>
<  5>
<  6>
<  7>
<  8>
<  9>
< 10>
< 11>
< 12>
< 13>
< 14>
< 15>
< 16>
< 17>
< 18>
< 19>
< 20>
< 21>
< 22>
< 23>
< 24>
< 25>
< 26>
#include <stdio.h>

int main(void){
   
int a;//int型実体定義
   
short b;//short型実体定義
   
int *c;//int型へのポインタ型実体定義
   
short *d;//short型へのポインタ型実体定義
   
float *e;//float型へのポインタ型実体定義
   
   
a=10;
   b=a;
//暗黙の型キャスト(代入) int から short です。
   
b=(short)a;//明示的型キャスト int から short です。
   
a=b+10;//暗黙の型キャスト(演算) short から int です。(10はint型なのでこうなります)
   
a=(int)b+10;//明示的型キャスト short から int です。
   
   
c=&a;//cにaのポインタを代入です。
   
d=&b;//dにbのポインタを代入です。
   //安全なのは↑までです。
   //d=c;//C++ではこれはエラーです。(int*からshort*への変換は認められない)
   //e=c;//C++ではこれはエラーです。(int*からfloat*への変換は認められない)
   
d=(short*)c;//強制型キャスト int* から short* です。この操作はコンパイラを強制的に黙らせます。
   
e=(float*)c;//強制型キャスト int* から float* です。この操作はコンパイラを強制的に黙らせます。
   
   
printf("a=%d,b=%d,c=%d,d=%d,e=%f,f=%f\n",a,b,*c,*d,*e,(float)*c);
   
return 0;
実行例:
a=20,b=10,c=20,d=20,e=0.000000,f=20.000000

d については普通に扱えているように見えますが、 e は何故か0になってしまいました。
これこそがこのバグの厄介なところで、「バグが表面化しないことがある」という特性があります。
また、この結果はコンパイラ依存なので必ず上記の結果になる保証はありません。

また、明示的型キャストを使った f はちゃんと20.000000と表示できています。
このように、明示的型キャストによる型変更は常に安全です。

この例では強制キャストしたポインタを介して代入を行っていないため、
異常終了したりするようなことには発展していませんが、
このポインタを介して代入したりするとより一層カオスっていきます。

では、コンパイラの型チェックによる安全性を放棄するような「強制型キャスト」が存在する理由はなんでしょうか。
一見すれば、型チェックが有効(不可能な型変換はエラー)である「明示的型キャスト」だけが存在すればいいような気がします。
その辺はC言語の生い立ちに関わる話になっていくのですが、
もともとC言語というのは「プログラマ全知全能」という思想で作られているところがあるので、
「プログラマがこうと言ったらこう」みたいな問答無用さがあるんです。
ようはコンパイラよりもプログラマの方がエライんですね(笑)

最近作られた言語はプログラマよりもコンパイラの方がエライところがあって、
「コンパイラがダメというものはダメ」みたいな言語が多いわけですが、
そこはC言語系での「強制型キャスト」バグだとか、そういうのが一向に減らなかったというのも一因らしいです。


さてさて、そんな「プログラマ全知全能」思想から認められている強制型キャスト。
もちろんタダの危険物ってわけじゃないんですが、その辺の話はまだ時期尚早なので今はまだしません(笑)

既に上にも発現コード書いてありますが、
このパターンの典型的発現コード
<  1>
<  2>
<  3>
<  4>
<  5>
<  6>
<  7>
<  8>
<  9>
< 10>
< 11>
#include <stdio.h>

int main(void){
   
short a[2]={5,10};
   
int *b;
   
   b=(
int*)a;//強制型キャストします
   *b=20;//さらに代入します
   
printf("a[0]=%d,a[1]=%d\n",a[0],a[1]);
   
return 0;
実行例:
a[0]=20,a[1]=0

a[0] が20になるのはいいのですが、 a[1] も0になってしまいました。
これもコンパイラ依存なので結果は保証されません。
複雑なコードで強制型キャストを使うとどこで原因を作ったかが分かりにくくなるため、
一度バグを埋め込んでしまうと厄介です。
しかしながら、強制型キャストはソース上でも明らかであるため、まだ「参照レベルのミス」よりはマシです。

このように強制型キャストはバグを生みやすいため、
使用する場合は慎重に、いざバグった時は真っ先に疑っていくぐらいの考えでいきましょう。



さて、ポインタ系のバグパターンを書いてきました。
ポインタ系バグの共通点は「地雷型発現をすることがある」「いろんな事が起こる」という、
タチの悪さMAXなものなのです。
そのため、ポインタ系バグは理詰めで解ける保証がなく、直感頼りになりがちです。
しかしながら、これらのパターンを理解し、コンパイラの動作を理解していれば、
これらのバグにも検出の手掛かりがあることが分かるはずです。
少々デバッガ頼りになってしまう面もありますが、
そこはデバッガの本業ですから、デバッガには思う存分働いてもらいましょう(笑)

次回からはこれらのパターンが引き起こす現象、デバッグの糸口などについての予定です。

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

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

最終更新 2008/10/17