導入:なぜデストラクタは一つでは足りないのか?
C++でポリモーフィズムを利用する際、仮想デストラクタ(virtual destructor)はメモリリークを防ぐための必須知識です。しかし、ソースコード上で `virtual ~MyClass() = default;` と一行書くだけで、コンパイラは裏側で複雑な処理を行っています。実は、バイナリレベルでは一つのデストラクタではなく、用途の異なる「3つのデストラクタ」が生成されているのです。この仕組みを理解することで、メモリ管理の裏側にある「なぜポリモーフィックな削除が安全に行えるのか」という謎が解けます。
基礎知識:D0, D1, D2 とは何か
C++のABI(Application Binary Interface)において、コンパイラはクラスの継承関係やメモリ配置に応じて、以下の3種類のデストラクタを内部的に生成します。
D0 (Deleting Destructor):自身のメンバ破棄に加え、`operator delete` を呼び出してヒープメモリ自体を解放します。`delete ptr;` と書いた時に呼ばれるのはこれです。
D1 (Complete Destructor):自身のメンバと、派生クラスを含むオブジェクト全体を破棄します。スタック上に確保されたオブジェクトの寿命が尽きた時に呼び出されます。
D2 (Base Object Destructor):自身のメンバのみを破棄します。仮想基底クラスや派生クラスには触れません。主に派生クラスのデストラクタから、基底クラス部分を安全に片付けるために使われます。
これらが使い分けられることで、スタック上の破棄とヒープ上の破棄が曖昧にならず、安全なオブジェクトの破棄が可能になります。
実装と解決策
普段のコーディングでこれらを意識する必要はほとんどありません。しかし、`delete` 演算子をオーバーロードする際や、複雑な多重継承を行う際には、この「隠し関数」の存在が重要になります。Vtableには主に D0 と D1 のエントリが登録され、実行時の型情報に基づいて適切な処理が選択されます。
サンプルプログラム
以下のコードをコンパイルし、デバッガやシンボル解析ツール(nmコマンド等)で覗くと、実際に複数のデストラクタが生成されている様子を確認できます。
include
class Base {
public:
// 仮想デストラクタを定義すると、コンパイラは D0, D1, D2 を生成する
virtual ~Base() {
std::cout << "Baseのデストラクタ" << std::endl;
}
};
class Derived : public Base {
public:
~Derived() override {
std::cout << "Derivedのデストラクタ" << std::endl;
}
};
int main() {
// 仮想デストラクタのおかげで、Baseポインタ経由でもDerivedのデストラクタが正しく呼ばれる
Base obj = new Derived();
// ここで呼び出されるのは D0 (Deleting Destructor)
// 1. Derivedのデストラクタ -> 2. Baseのデストラクタ -> 3. メモリ解放
delete obj;
return 0;
}
応用・注意点:現場で陥りやすい罠
この仕組みを知っておくべき最大の理由は「仮想デストラクタの付け忘れ」による未定義動作を避けるためです。もし基底クラスに仮想デストラクタがない場合、コンパイラは D0/D1/D2 を使い分けるための Vtable エントリを作成しません。その結果、派生クラスのデストラクタが呼ばれず、メモリリークだけでなく、リソースの解放漏れによる深刻なバグを引き起こします。
また、最適化レベルを上げると、コンパイラはこれらの隠し関数をインライン展開し、バイナリサイズを削減します。デバッグ時には見えにくい挙動ですが、「仮想デストラクタは、安全に削除するためのスイッチである」と認識しておくことが、堅牢なC++プログラミングの第一歩です。

コメント