Cry’s Diary

2006-01-08

[][]動的削除子 (dynamic deleter) - 意外と知られていない? boost::shared_ptr の側面

boost::shared_ptr は動的削除子 (dynamic deleter) と呼ばれる技法に基づいて実装されています.この動的削除子という技法で重要なのは, boost::shared_ptr が最終的に呼び出す解放処理が

決定する,ということです.

以下のようなコードが,動的削除子の効果が一番分かりやすい例になるでしょう.

class X{
public:
  ~X()
  { std::cout << "X::~X" << std::endl; }
};

class B{
public:
  ~B() // virtual でないことに注意!!
  { std::cout << "B::~B" << std::endl; }
};

class D : public B{
public:
  ~D()
  { std::cout << "D::~D" << std::endl; }
};

int main(){
  {
    boost::shared_ptr< void > px( new X() ); // void * のように見えるけれど……
  } // ここでちゃんと X::~X が走る!

  {
    boost::shared_ptr< B > pb( new D() ); // デストラクタが仮想でない基底クラスのスマポに
                                          // 派生クラスのポインタを代入.大丈夫か?
  } // ちゃんと D::~D も呼ばれます!
}

この boost::shared_ptr の魔法のような挙動を説明するために,簡単に解放処理の部分だけを抜き出した dynamic_scoped_ptr を用意してみました.

class deleter_base
{
public:
  virtual ~deleter_base(){};
  virtual void operator()( void *p ) const = 0;
};

template< class T >
class deleter_impl
  : public deleter_base
{
public:
  virtual ~deleter_impl()
  {}

  virtual void operator()( void *p ) const
  {
    delete static_cast< T * >( p );
  }
};

template< class T >
class dynamic_scoped_ptr
  : private boost::noncopyable // http://tinyurl.com/dtbp5
{
public:
  template< class U >
  dynamic_scoped_ptr( U *p )
    : p_( p )
    , p_deleter_( new deleter_impl< U >() )
  {}

  ~dynamic_scoped_ptr()
  {
    (*p_deleter_)( p_ );
    delete p_deleter_;
  }

private:
  T *p_;
  deleter_base *p_deleter_;
};

このスマートポインタのもっとも重要な部分はコンストラクタテンプレートで,ここで解放処理が決定されます.例えば, dynamic_scoped_ptr< void > が X へのポインタを保持する場合を考えます..

dynamic_scoped_ptr< void >( new X() );

このコンストラクタの呼び出しで, X のオブジェクトへのポインタvoid * の形で保持するとともに,同時に deleter_impl< X > を生成してそれを deleter_base のポインタとして持ちます.デストラクタでは,deleter_base の operator() が呼ばれますが,これは仮想関数で実際には deleter_impl< X > の operator() を呼び出します. deleter_impl< X >::operator() へは void * の形で delete するべきポインタが渡されますが, deleter_impl< X > は削除するべき対象が実際には X のポインタであることを知っているため,渡された void * を安全に X * へ static_cast して delete を実行します.

class X{
public:
  ~X()
  { std::cout << "X::~X" << std::endl; }
};

class B{
public:
  ~B() // virtual でないことに注意!!
  { std::cout << "B::~B" << std::endl; }
};

class D : public B{
public:
  ~D()
  { std::cout << "D::~D" << std::endl; }
};

int main(){
  {
    dynamic_scoped_ptr< void > px( new X() ); // void * のように見えるけれど……
  } // ここでちゃんと X::~X が走る!

  {
    dynamic_scoped_ptr< B > pb( new D() ); // デストラクタが仮想でない基底クラスに
                                           // 派生クラスのポインタを代入.大丈夫か?
  } // ちゃんと D::~D も呼ばれます!
}

この,「テンプレート引数の型によらず,生成時に実際に渡されたポインタの型で解放処理が決まる」ことと「コンストラクタ呼び出しの段階で解放処理が決定する(コンパイルされる)」こと,この2点が以下に述べるような利点を boost::shared_ptr にもたらすことになります.

boost::shared_ptr< void >

いかなる型でも保持できて,かつメモリの解放を安全かつ自動で行ってくれるスマートポインタとして機能します.いわゆる Object クラスのような最上位基底クラスポインタであるかのように扱うことができる,と考えることもできます.

struct A{};
struct B{};
struct C{};

std::vector< boost::shared_ptr< void > > object_vec;
object_vec.push_back( boost::shared_ptr< A >( new A() ) );
object_vec.push_back( boost::shared_ptr< B >( new B() ) );
object_vec.push_back( boost::shared_ptr< C >( new C() ) );

object_vec.clear() // 各オブジェクトを適切に解放

もちろん,各オブジェクトを実際の型で扱う際にはダウンキャストしないといけません.

不完全型 (incomplete type) によるコンパイラファイアウォールスマートポインタの両立(その1) - Pimpl

動的削除子による決定的に重要恩恵として,不完全型との協調動作が挙げられます.以下では, "Exceptional C++" などで取り上げられている, Pimpl や不完全型へのポインタによるコンパイラファイアウォールの知識を前提とします.

"Exceptional C++" ではヘッダ間の依存関係を断ち切る強力な手法として Handle-Body (Pimpl) イディオムが紹介されていますが,重要な注意点が指摘されていません.標準から不完全型に delete を適用する場合の記述を以下に抜粋します.

5.3.5 Delete

5 If the object being deleted has incomplete class type at the point of deletion and the complete class has a non-trivial destructor or a deallocation function, the behavior is undefined.

5.3.5 Delete

5 delete されるオブジェクトが delete される時点で不完全型であり,かつ,その型の完全な定義に trivial でない(コンパイラが暗黙に生成したものではない,明示的な)デストラクタあるいはメモリ解放関数(operator delete)があるならば,その振る舞いは未定義となる.

delete は引数ポインタが指しているオブジェクトのデストラクタを呼び出さないといけない(あるいはそのクラスが独自の operator delete を実装しているならそれを呼ばないといけない)わけですが,渡されたポインタが不完全型へのポインタの場合,型の定義が見えていないため呼び出すべきデストラクタ(あるいは operator delete)が分からず未定義な動作となるわけです.

この不完全型に対する delete の制約は,スマートポインタコンパイラファイアウォールの両立という観点で非常に邪魔になります. boost::shared_ptr が採用している動的削除子の技法はこの問題を当然のごとく,しかし見事に解決しています.

boost::shared_ptr のこの強力な利点を確認するために,動的削除子を採用していないスマートポインタで不完全型を取り扱うとどうなるか見てみます.まず,動的削除子に基づいていない実装のスマートポインタである boost::scoped_ptr で Handle-Body (Pimpl) を実装しようとしてみます.

// my_class.hpp

class MyClass
{
private:
  class Impl; // 実装クラスの先行宣言
  boost::scoped_ptr< Impl > pimpl_;

public:
  MyClass();

  ..... // MyClass のインタフェース宣言
};
// my_class.cpp

class MyClass::Impl
{
public:
  Impl(){}

  ..... // 実装詳細
};

MyClass::MyClass()
  : pimpl_( new Impl() )
{}

..... // MyClass のその他の実装(ほとんどが Impl へ丸投げされる)

MyClass が解体されるときに自動的に pimpl_ が指す実装詳細のオブジェクトも解放してもらおうとする意図のコードです.非常に自然なコードに見えます.

ところが,上記の MyClass を使おうとすると boost::checked_deleteコンパイルエラーを発生させるはずです. Impl が不完全型であるような時点で Impl へのポインタを delete しようとするためです.少し分かりにくいですが,これがコンパイルエラーを発生させる理由は以下のとおりです.

  1. MyClass のデストラクタを書いていないので,コンパイラが暗黙のデストラクタを用意しようとする.
  2. コンパイラが用意する暗黙のデストラクタは inline が宣言された空の関数定義として宣言される
  3. この暗黙のデストラクタは pimpl_ のデストラクタを inline で呼び出そうとする
  4. pimpl_ のデストラクタは保持している Pimpl 型へのポインタに delete を適用しようとする
  5. pimpl_ が保持しているポインタの型 Impl は inline (MyClass の定義時点)では不完全型
  6. 不完全型へのポインタを delete しようとしているため boost::checked_delete がそれをコンパイルエラーとして弾く

このコンパイルエラーを回避するには, pimpl_ に delete が適用される時点で Impl の完全な定義が見えているようにすれば良いわけです.が…….

このコンパイルエラーを回避する1つ目の方法として MyClass::Impl の定義をヘッダに持ってきて, MyClass の暗黙のデストラクタから Impl の定義が見えるようにしてみます.

// my_class.hpp

class MyClass
{
  class Impl
  {
  public:
    Impl(){}

    .....
  };

public:
  MyClass()
    : pimpl_( new Impl() )
  {}

  .....
};

inline で Impl の完全な定義が見えているのでコンパイラが用意する暗黙のデストラクタ MyClass::~MyClass は無事にコンパイルされます.……されますが,実装への依存関係を断ち切るために Pimpl を使っているのに,実装を全てヘッダで書いてしまうというのは全く本末転倒で一体何がしたいのが良く分からなくなってしまっています.だめぽ.

もう1つの方法として MyClass のデストラクタを明示的に inline でないように書く,というのがあります.

// my_class.hpp

class MyClass
{
private:
  class Impl;
  boost::scoped_ptr< Impl > pimpl_;

public:
  MyClass();

  .....

  ~MyClass(); // 明示的なデストラクタ.関数の本体をここに書かないように!!
              // 本体をここに書くと暗黙のデストラクタの場合と同じ結果になる.
};
// my_class.cpp

class MyClass::Impl
{
public:
  Impl(){}

  .....
};

MyClass::MyClass()
  : pimpl_( new Impl() )
{}

.....

MyClass::~MyClass()
{} // ここからは Impl の完全な定義が見えている

デストラクタをあえて inline でない形で書くことによって, pimpl_ のデストラクタから Impl の完全な定義が見えていることになります.一方で my_class.hpp では実装が暴露されていないので,実装への依存関係も綺麗に断ち切れています.めでたしめでたし.

……しかしちょっと振り返ってみます.「一体何のために pimpl_ をスマートポインタにしたのか?」当然, MyClass の明示的なデストラクタを書く必要をなくし pimpl_ の解放を自動化するために pimpl_ をスマートポインタにしたはずです.ところがスマートポインタを採用したがために明示的にデストラクタを書かないといけなくなっています.これもまた明らかに本末転倒で,このままではスマートポインタを採用することによる恩恵は極めて小さく, pimpl_ が生のポインタである場合とほとんど変わらない(唯一デストラクタで delete を書き忘れる危険性が減らせるだけ)と言えます.これもやっぱりだめぽ.

不完全型によるコンパイラファイアウォールスマートポインタの両立(その2)

上にあるような Handle-Body (Pimpl) イディオムに限らず,不完全型へのポインタスマートポインタを組み合わせたいパターン豊富にあります.同様に動的削除子を採用していない std::auto_ptr の例で不完全型を扱おうとするケースを以下に挙げてみます.

class X; // 先行宣言
std::auto_ptr< X > createX();
void process( X * );

{
  std::auto_ptr< X > px = createX(); // ファクトリ
  process( px.get() );
} // ここで px が保持しているオブジェクトが自動的に解放されてウマーなはず??

X への先行宣言(と,createX および process の関数宣言)だけで完結し, X の定義への依存関係を断ち切れているようにみえます.しかし,実際には上のコードは未定義の振る舞いを起こします.上のコードで px のデストラクタが呼ばれる際に,不完全な型である X へのポインタに delete を適用しようとするためです(多くのコンパイラでは警告が出ます).これを回避しようとすると以下のように Pimpl の場合と全く同じジレンマに陥ってしまいます.

#include <x.hpp> // X の完全な定義を導入してみる

{
  std::auto_ptr< X > px = createX();
  process( px.get() );
} // ここで X は完全型なので px が保持しているオブジェクトが自動的に解放されてウマー?

この場合,確かに delete の段階で X の完全な定義が見えているから安全なコードだけれど,これだと X の定義に対する依存性を除去できていないのでコンパイラファイアウォールになってない!!本末転倒!!

じゃあということで, X のオブジェクトを解放する関数を用意してみましょう.

class X; // 先行宣言のみ
std::auto_ptr< X > createX();
void process( X * );
void releaseX( std::auto_ptr< X > );

{
  std::auto_ptr< X > px = createX();
  process( px.get() );
  releaseX( px );
}

releaseX の定義にのみ X の完全な定義を暴露するようにすれば,問題なくコンパイルもできて, X の定義に対する依存性も断ち切れている!!素晴らしい!!

……いやしかしちょっとマテ.そういえばオレ何のためにスマートポインタ使ってたんだ?自動的にリソースの解放処理やってもらうためにスマートポインタ使っていたんじゃなかったのか?じゃあこのご丁寧に手書きされた releaseX という関数の呼び出しは何だ?これだと生のポインタ使ったほうがタイプ数が少ない分まだ良かったんじゃないか?と,またしても本末転倒…….

不完全型によるコンパイラファイアウォールスマートポインタの両立 - boost::shared_ptr の場合

不完全型によるコンパイラファイアウォールスマートポインタとを両立する場合,単純な設計のスマートポインタでは上のように決定的なジレンマが生じるわけです.では, boost::shared_ptr で上の2つの例を実装するとどうなるか?動的削除子がなぜ重要なのかがわかります.

// my_class.hpp

class MyClass
{
private:
  class Impl;
  boost::shared_ptr< Impl > pimpl_;

public:
  MyClass();

  ..... // MyClass のインタフェース

  // コンパイラが暗黙に用意するデストラクタでO.K.
};
// my_class.cpp

class MyClass::Impl
{
public:
  Impl(){}

  ..... // 実装詳細
};

MyClass()
  : pimpl_( new Impl() )
{}

.....

できて当たり前のようなコードを書いているためあまりピンとこないかも知れませんが,この「当たり前にできて当然のこと」を実際に当たり前なように書けるのは, boost::shared_ptr の実装が動的削除子に基づく実装だからこそです.重要なのは pimpl_ オブジェクトが生成される時点(boost::shared_ptr のコンストラクタ)で Impl オブジェクトの delete がコンパイルされることです.このため boost::shared_ptr のコンストラクタを呼び出す時点で Impl の定義が見えている必要はあります.が,コンストラクタの呼び出し時点では Impl のコンストラクタ (new) を呼び出しているわけですから,当然 Impl クラスの定義が見えており,従って安全に Impl オブジェクトの delete がコンパイルできるわけです.

もう一方のケースも boost::shared_ptr では以下のようになります.

class X; // 先行宣言のみ
boost::shared_ptr< X > createX();
void process( X * );

{
  boost::shared_ptr< X > px = createX();
  process( px.get() );
} // px が指しているオブジェクトは自動的に解放される

これもまたスマートポインタとしてできて当然のようなコードですが,動的削除子なくしてはできないことです.

最後にもう1つ,動的削除子によって解決される問題を取り上げます.

バイナリ境界をまたぐスマートポインタ

Windows でのプログラミングに限定した形で進めます. POSIX 環境などで同じ問題があるのか良く知らないので誰か教えてください.)今, 実行ファイル a.exe と動的ライブラリ b.dll があるとします. a.exe と b.dll が異なるコンパイルの設定でコンパイルされている場合,例えば b.dll 内において new で生成したオブジェクトを a.exe 内で delete すると,一般にメモリアクセス違反を起こします.異なるコンパイラの設定でビルドしたバイナリ同士がフリーストア(ヒープ)を共有していないためにこのような問題が起こります.

このようなケースで boost::shared_ptr を使うとどうなるか? b.dll 内で new で生成したオブジェクトポインタboost::shared_ptr に突っ込んで a.exe に渡す場合を考えます.上に書いたように, boost::shared_ptr が呼び出す解放処理は boost::shared_ptr の生成時点で決定します.つまり b.dll 内で new したオブジェクトboost::shared_ptr に突っ込む場合,その時点で削除子が設定されるため,最終的に呼び出される解放処理はそれがたとえどこで呼び出されようとも b.dllコンパイル設定でコンパイルされたものが呼び出されます(削除子を任意に設定できるため,あえて別のバイナリ関数などを解放処理として設定することもできますが).これによって boost::shared_ptr はコンパイル設定の異なるバイナリの境界をまたいでも安全に動作するスマートポインタとして機能することになります.

追記

コンパイラファイアウォールを目的とした Handle-Body (Pimpl) では共有セマンティクスが不要なことが多く,この場合 pimpl_ にもっともふさわしいのは動的削除子とディープコピーセマンティクスを持ったスマートポインタになります.残念ながら現在の Boost にはそのようなスマートポインタはありません.将来的に policy_ptr が導入されれば, Pimpl にふさわしいスマートポインタを簡単にカスタマイズし導入できることになるでしょう.もちろん, Copy-on-write を実装した Pimpl の場合には boost::shared_ptr がもっとも適した強力なスマートポインタになるでしょう.

・・・・・・ 2006/01/09 03:49 おもしろいっ!

mb2syncmb2sync 2006/01/09 06:56 素晴らしい (言ってみるものだ :^)
deleteは動的と静的がまじっているので駄目だということだ!
deleteまでdeprecatedに。

rstrst 2006/01/09 12:41 スマートポインタは生ポインタのうっかり代入などを防ぐ効果もあるので、個人的には空のdestructorを書くのは許容範囲かと思います。

CryoliteCryolite 2006/01/13 02:08 ちょー忙しかったのでレス書くのを失念してました……すいません.

CryoliteCryolite 2006/01/13 02:09 >MBさん
いやぁ,delete がなくなるとスマポが実装できなくなるw

CryoliteCryolite 2006/01/13 02:21 >rstさん
確かにおっしゃるとおりで,自分も書いていて前者(pimp)の例は
ちょっと説得力に欠けるかなと感じてました.
ただ,上は boost::shared_ptr で書きましたけれど,仮に深いコピーの
セマンティクスと動的な削除子,それに後で気が付きましたけれど動的な
コピー(clone)を受け持つ機能を持ったスマポを使えば,
ハンドルクラスのデストラクタだけでなく代入演算子も
デフォルトのままで pimpl が実装できます.ここまで来れば恐らく
pimpl におけるスマポの役割はかなり決定的になるかと思います.
さらに,この深いコピーのスマポの pimpl におけるマイナーな利点として
実体クラス (body) にコピーコンストラクタさえあれば,外側のハンドル
クラスに自動でコピーコンストラクタと,加えて強い例外安全を満たした
代入演算子が自動で実装されるというのもあります.
(この場合,実体クラスの代入演算子は禁止してしまうぐらいでも良い)

CryoliteCryolite 2006/01/13 14:16 何か上の rst さんへのレス,微妙にずれてる気がするなぁ.
要するに自分が言いたいのは,生ポインタの誤代入を防ぐという機能は
それももちろん重要な機能であるとは思いますけれど,スマポの実装を
注意深く行えば, pimpl におけるスマポの利用によって
より重要である(と私が思う)機能をユーザに提供できるのだから,
生ポインタの誤代入防止ということだけに満足したくはない,
ということです.