メモリの二重解放回避テク

メモリの二重解放とは, new などで確保したメモリ領域(ヒープ領域)を2回 delete などで解放することを言う. new と delete をきちんと対応させて書いていないときに起こる問題だ.特にクラスのメンバに動的確保したメモリへのポインタを含む場合にやってしまいがちである.ここでは二重解放によって発生する問題を解説する.
結論から言うと,メモリを解放した後 (delete p1;),ほかの用途でメモリを確保し (p2=new T;),たまたまそれが解放したメモリと同じアドレスに割り当てられてしまった場合 (p1==p2),最初のメモリを二重解放すると (delete p1;),新しく確保したメモリ (p2) が解放されてしまう.この結果,新しく確保したメモリにアクセスすると値が書き換えられていたり,セグメンテーションフォルトが発生する場合がある.プログラマはまず,新しく確保したメモリ (p2) に問題があることを疑うが,実は最初のメモリ (p1) の解放に問題があるという,大変デバッグしにくい問題に直面するのだ.

例 (g++-4.3 で確認).

#include <iostream>
using namespace std;
int main(int argc, char**argv)
{
  int *p1= new int; // p1 を確保
  cout<<"p1 is allocated to "<<p1<<endl;
  *p1= 100;
  delete p1;  // p1 を解放
  cout<<"p1("<<p1<<") is freed"<<endl;
  int *p2= new int;  // p2 を確保
  cout<<"p2 is allocated to "<<p2<<endl;
  delete p1;  // p1 の2度目の解放
  cout<<"p1("<<p1<<") is freed"<<endl;
  *p2= 20;
  cout<<"p2("<<p2<<")= "<<*p2<<endl;
  //delete p2;
  return 0;
}

これを実行すると,

p1 is allocated to 0x8701008
p1(0x8701008) is freed
p2 is allocated to 0x8701008
p1(0x8701008) is freed
p2(0x8701008)= 20

のような結果になる. p1 (0x8701008) の二重解放によって,新しく確保した p2 (同じメモリ領域 0x8701008) が解放されてしまっているが,その領域にアクセスできており,大変危険なコードになっていることがわかる.ちなみに glibc (GNU C Library) の場合,解放されているメモリ領域を解放しようとすると,二重解放 (double free) を検出してくれる.例えば上の例で,最後の delete p2 のコメントアウトを外すと,

*** glibc detected *** ./a.out: double free or corruption (fasttop): 0x0891b008 ***
======= Backtrace: =========
/lib/i686/cmov/libc.so.6[0xb7d384f4]
/lib/i686/cmov/libc.so.6(cfree+0x96)[0xb7d3a6f6]
/usr/lib/libstdc++.so.6(_ZdlPv+0x21)[0xb7f36281]
./a.out(__gxx_personality_v0+0x2b8)[0x804895c]
/lib/i686/cmov/libc.so.6(__libc_start_main+0xe5)[0xb7ce0455]
./a.out(__gxx_personality_v0+0x3d)[0x80486e1]
======= Memory map: ========
...

のように,二重解放したことをレポートしてくれる.ただし,いつでも二重解放を検出してくれるわけではない.現に,上のコードで p1 を2回解放したときは,既に p2 が同じ領域に確保されていたから,二重解放とは判定せず,単に p2 を解放しただけだった*1

このように,メモリを二重解放すると発見しにくいバグに悩まされる可能性があり,メモリを動的に確保するプログラムを書く場合は new/delete (new[]/delete[]) を必ず対で使うように注意しなければならない.一度,ベクタやリストと言ったデータ構造を自分で実装してみると,コツがつかめる(データ構造のコピーもできるようにしてみよう).

以下,堀下げた問題と,回避テクニックについて.

ポインタ変数を NULL で初期化する

int *p1; ではなく,

  int *p1(NULL);

と書く.

delete した後 NULL を代入する

delete p1; を,

  if(p1!=NULL) {delete p1; p1= NULL;}

のように書く.解放された p1 には NULL が代入されているので,二重解放する心配が軽減される. NULL による初期化と併用すれば, p1 が未確保のときに解放することがなくなる.

コピーコンストラクタを必ず作る

コピーコンストラクタとは,同じクラスのオブジェクトで初期化する場合のコンストラクタだ.重要なのは,自分で作ったクラスに対して,明示的にコピーコンストラクタを作らない場合,コンパイラがコピーコンストラクタを自動生成することだ.このとき,ポインタ型のメンバ変数は,アドレスがコピーされる.このポインタが動的に確保された領域を指す場合,コピーされた側のオブジェクトのポインタ変数も同じ領域を指すことになり,二重解放する危険性が生じる.

例:

#include <iostream>
using namespace std;
class TTest
{
private:
  int *p;
public:
  TTest (void)
      : p(NULL)  // p を NULL で初期化
    {
      p= new int;
      cout<<"p is allocated to "<<p<<endl;
      *p= 0;
    };
  ~TTest (void)  // デストラクタ
    {
      if(p!=NULL)
      {
        delete p;
        cout<<"address "<<p<<" is freed."<<endl;
        p= NULL;
      }
    };
  void set (int i) {*p= i;};
  const int& get (void) const {return *p;};
};

int main(int argc, char**argv)
{
  TTest x;
  x.set(10);
  TTest y(x);  // y を x をコピーすることで生成
  cout<<"x.get()= "<<x.get()<<endl;
  cout<<"y.get()= "<<y.get()<<endl;
  cout<<"&(x.get())= "<<&(x.get())<<endl;
  cout<<"&(y.get())= "<<&(y.get())<<endl;
  return 0;  // x, y のそれぞれに対してデストラクタが呼ばれる
}

結果:

p is allocated to 0x92b2008
x.get()= 10
y.get()= 10
&(x.get())= 0x92b2008
&(y.get())= 0x92b2008
address 0x92b2008 is freed.
*** glibc detected *** ./a.out: double free or corruption (fasttop): 0x092b2008 ***

glibc が二重解放を検出した.既に解放されたアドレス (0x092b2008) を解放しようとしたからだ.このように,動的に確保されたオブジェクトをメンバに含むクラスの場合,コピーコンストラクタを必ず自分で実装しなければならない.上の例の場合,

  TTest (const TTest &src) // コピーコンストラクタ
      : p(NULL)  // p を NULL で初期化
    {
      p= new int; // src.p とは別の領域に p を確保
      cout<<"p is allocated to "<<p<<endl;
      *p= *(src.p);
    };

というコードを書き加えれば,この問題を解決できる.面倒でも,コピーコンストラクタの実装を怠ってはならない.

もしくは private 隠蔽する

どうしてもコピーコンストラクタを作りたくない(面倒とかで)場合は,コピーコンストラクタの宣言を private か protected にして,アクセスできないように隠蔽するとよい.例えば上の例の場合,

private:
  TTest (const TTest &src);

としておくと(中身は定義しなくていい),オブジェクトのコピー TTest y(x) に対して,

error: 'TTest::TTest(const TTest&)' is private

というコンパイルエラーを発生させることができる.これにより,危険なポインタのコピーを回避できるのだ.

いずれにしても,動的に確保されたオブジェクトをメンバに含むクラスの場合,コピーコンストラクタは重要だ.

operator= を必ずオーバーロードするか private 隠蔽する

コピーコンストラクタと同様,自分で作ったクラスに対して,明示的に operator= を再定義しない場合,コンパイラが operator= を自動生成する.やはりポインタがコピーされてしまう.例えば,上の例で(コピーコンストラクタを private に隠蔽),

  TTest y;
  y= x;

のようにした場合,結果が

p is allocated to 0x974c008
p is allocated to 0x974c018
x.get()= 10
y.get()= 10
&(x.get())= 0x974c008
&(y.get())= 0x974c008
address 0x974c008 is freed.
*** glibc detected *** ./a.out: double free or corruption (fasttop): 0x0974c008 ***

となって,アドレス 0x974c008 が2回解放されたことがわかる.もちろん, x.p のアドレス値が y.p にコピーされただけだからだ.自分で operator= を

  const TTest& operator= (const TTest &rhs)
    {
      if (p==NULL)  p= new int; // 今回は不要だが..
      *p= *(rhs.p);
      return *this;
    };

のようにオーバーロードすることで,この問題を解決できる.なお,上記例の場合 operator= の中で p を確保する必要はないが,確保が必要になる場合もあるので,あえて書いておいた.

もちろん,

private:
  ...
  const TTest& operator= (const TTest &rhs);

として private 隠蔽してもいい.この場合,オブジェクトのコピー (y=x) に対してコンパイルエラーが生成される.


先程,ベクタやリストを自分で実装してみるとよいと書いた理由は,これらのデータ構造のコピーやリサイズ,要素の追加などを実装することで,上述の operator= やコピーコンストラクタの重要性を実感でき,メモリ操作についての理解が深まるからだ.また,これらのデータ構造のアクセス効率,追加・削除のコストに関しても理解でき, STL (Standard Template Library) のコンテナ (std::vector, std::list, etc.) を効率的に使う助けになるだろう. STL のソースを読んでみるのも勉強になる.

*1:これは当り前だ.関数にポインタを渡すと適当な処理の後,解放する,と言ったコードはよく見られるし(例えば OpenCV の cvReleaseImage 関数),極めて有益だ.