【C++】デストラクタが呼ばれる/呼ばれない

すごく久しぶりの記事更新になってしまいました。
デストラクタ備忘録です。

  • 派生されるクラスのデストラクタは仮想関数にしなければならない
  • 純粋仮想デストラクタには定義が必要
  • placement new により構築されたオブジェクトは明示的にデストラクタを呼び出す必要がある
  • std::shared_ptr<T> は、リソースを解体する時に、初期化時の実引数の型のデストラクタが呼ばれる
  • std::unique_ptr<T> の場合は、shared_ptr とは異なり T 型のデストラクタが呼ばれる

デストラクタを仮想にする

まずは超基本的なことから。
多態するクラス階層では、デストラクタは仮想関数である必要があります。

class Base {
public:
  // ・・・
  virtual ~Base();  // 仮想デストラクタ
};

class Derived : public Base {
public:
  // ・・・
  ~Derived();  // 派生側では virtual はつけなくても良い
};

多態を前提としたクラスを利用する場合、たいていは基底クラスのポインタ(や参照やスマートポインタ)に派生クラスのオブジェクトnew で確保して突っ込み、この基底型ポインタを介して処理を行います。
派生クラスのオブジェクトを直接触りに行くことはあまりありません。

で、いざ解体する段になった、つまりポインタを delete する時、ポインタが指している先のオブジェクトがたとえ派生型オブジェクトだろうと、基底型のポインタが呼ぼうとするデストラクタは基底クラスのデストラクです。
つまり派生型のデストラクタが呼ばれないままになってしまいます。これでは派生型オブジェクト特有部分の解体がうまく行われません。

しかし、デストラクタを仮想関数にしておけば、オーバライドの仕組みによってもっとも派生したクラスのデストラクタがちゃんと呼ばれるようになります。
派生クラスのデストラクタは基底クラスのデストラクタを呼ぶので、もっとも派生したクラスのデストラクタを呼び出せば、クラス階層の全てのデストラクタが再帰的に適切な順番で呼び出され、オブジェクト全体がうまく解体されるようになります。
というわけで、派生される前提のクラスのデストラク仮想関数にしなければなりません

純粋仮想デストラク

インタフェースの実装のために使われる純粋仮想関数ですが、デストラクタも純粋仮想関数にすることができます
これは、抽象クラス(つまり、オブジェクトが生成されるのは概念的に避けたいクラス)を作りたいが、そのクラスに純粋仮想にできるメンバ関数がない場合に有効な方法です。
基本的にどのクラスにもデストラクタは存在するので、上のような期待に応えられるわけです。

で、普通の純粋仮想関数はオーバライドが強制されるため、基底クラス側での定義は必ずしも必要ないのですが、純粋仮想のデストラクタは基底クラスの方でも定義が必要です。

#include <iostream>

class Abstract {
public:
  virtual ~Abstract() = 0;  // 純粋仮想デストラクタ
};

class Concrete : public Abstract {
public:
  ~Concrete() = default;
  void f() {
    std::cout << "call f()\n";
  }
};

int main()
{
  Concrete c;
  c.f();
}

上の例は、これだけだとリンクエラーになります。
これは、派生クラスのデストラクタは仮想かどうかにかかわらず処理の最後に基底クラスのデストラクタを呼ぶためです。
このため、たとえ純粋仮想関数であったとしても定義がないとリンクエラーになってしまうのです。
純粋仮想であることを指定する妙ちくりんな記法である= 0は定義と同時には書けないので、必然的に定義は宣言とは別の場所に書くことになります。

placement new とデストラク

placement new で構築されたオブジェクトは、デストラクタが自動的に呼ばれないため、明示的にデストラクタを呼んでやる必要があります

char* buffer = new char[sizeof(Hoge)];
Hoge* p = new(buffer) Hoge{};  // placement new

p->~Hoge();  // デストラクタの明示的呼び出し
delete[] buffer;

placement new (配置new)というのは、すでに確保された領域に対してオブジェクトの構築を行う機能です。
普通の new は、必要な領域を確保したうえで、その領域に対してコンストラクタの呼び出しを行いますが、placement new は領域の確保は行わず、代わりに受け取ったポインタの先にある領域に対してコンストラクタを呼び出す、という処理を行います。
例えば、std::vectorなどのコンテナでは、先に必要なだけの領域を確保しておき、あとから各要素を初期化していくような処理をすることができます。
こういった処理を実現するため、コンテナでは要素の初期化には placement new が使われています。

スマートポインタとデストラク

C++ では生のポインタの使用は可能なら避けるべきです。
例えば、new/delete を自分で行うような処理は、メモリリークや二重に解放してしまうなどの問題を引き起こす可能性が高くなります。
そこで、new/delete の代わりにstd::unique_ptrstd::shared_ptrなどといったスマートポインタ*1を使います。

Base* p = new Derived;

上のように自分で new する代わりに、

std::shared_ptr<Base> sp{ new Derived };
std::unique_ptr<Base> up{ new Derived };

こんな感じに置き換えるだけで、とりあえず delete のし忘れなどの心配はなくなります。

さて、このふたつのうちshared_ptr の方は、興味深いことに、デストラクタを仮想にしなくても派生側のデストラクタを呼んでくれます
初期化時に渡された型を shared_ptr 自身が覚えてくれているのです。
しかし、unique_ptr の方は、仮想でなければ派生クラスのデストラクタが呼ばれません

class Base {
public:
  ~Base() = default;  // 仮想関数になっていない!
};
class Derived : public Base {
public:
  ~Derived() = default;
};

void f()
{
  // 解体時に、
  std::shared_ptr<Base> sp{ new Derived };  // ~Derived() が呼ばれる
  std::unique_ptr<Base> up{ new Derived };  // ~Base() が呼ばれる(!)
}

継承が予想されるならデストラクタを仮想にすべき、という原則を守っていれば、この問題は発生しません。

*1:広義のスマートポインタは『ポインタのような振る舞いをするオブジェクト』であり、必ずしも資源管理を目的としたものではないのですが(例えばイテレータもスマートポインタの一種と言える)、たいていは資源管理目的のものを指すので、ここでもそちらの意味で使っています