【C++】仮想デストラクタを付け忘れると、派生クラスの後始末が呼ばれない?|メモリが増え続ける様子を実測して、unique_ptrとshared_ptrの違いも確かめる

【C++】仮想デストラクタを付け忘れると、派生クラスの後始末が呼ばれない?|メモリが増え続ける様子を実測して、unique_ptrとshared_ptrの違いも確かめる C++

敵のクラスを、基底クラスEnemyと、派生クラスSlimeに分けました。Slimeは、自分で1024バイトのメモリを確保して、デストラクタで解放します。Enemy*で持って、deleteすると、Slimeのデストラクタの表示が、出ません。コンパイルも、実行も、エラーなしで通ります。

原因は、基底クラスのデストラクタに、virtualが付いていないことです。この記事では、付いていないときと、付いているときを、同じコードで比べて、実際に何が呼ばれて、何が漏れるのかを確かめました。スマートポインタなら大丈夫なのかも、あわせて試しています。検証は、Visual Studio 2022付属のMSVC(19.44、x64)で行いました。

仮想デストラクタとは?

仮想デストラクタとは、

・基底クラスのポインタ経由でdeleteしたときに、実際のオブジェクトの型(派生クラス)のデストラクタを、呼んでもらうためのvirtual付きのデストラクタ

です。virtualが付いていないと、deleteは、ポインタの型(基底クラス)のデストラクタしか呼びません。規格の上では、この状態のdeleteは、未定義動作です。実験では、どんな動きになるかを、確かめます。

実験①:virtualが無いときと、有るときを比べる

▼v1_basic.cpp(VIRTUAL_DTORを0と1にして、2通りでビルド)
#include <cstdio>

#ifndef VIRTUAL_DTOR
#define VIRTUAL_DTOR 0
#endif

static int g_baseDtor = 0;
static int g_derivedDtor = 0;

class Enemy                                    // 基底クラス
{
public:
    Enemy() { std::printf("  Enemy() 生成\n"); }
#if VIRTUAL_DTOR
    virtual ~Enemy() { ++g_baseDtor; std::printf("  ~Enemy() 破棄\n"); }
#else
    ~Enemy() { ++g_baseDtor; std::printf("  ~Enemy() 破棄\n"); }
#endif
    virtual void Update() {}
};

class Slime : public Enemy                     // 派生クラス: 自分でメモリを確保する
{
public:
    Slime() : memory_(new char[1024]) { std::printf("  Slime() 生成(1024バイト確保)\n"); }
    ~Slime() { delete[] memory_; ++g_derivedDtor; std::printf("  ~Slime() 破棄(1024バイト解放)\n"); }
    void Update() override {}

private:
    char* memory_;
};

int main()
{
    std::printf("[virtual デストラクタ: %s]\n", VIRTUAL_DTOR ? "あり" : "なし");
    Enemy* e = new Slime();                    // 基底クラスのポインタで持つ
    std::printf("delete e を実行\n");
    delete e;                                  // 基底クラスのポインタ経由で、delete する
    std::printf("結果: ~Enemy が呼ばれた回数 = %d, ~Slime が呼ばれた回数 = %d\n", g_baseDtor, g_derivedDtor);
    return 0;
}
[virtual デストラクタ: なし]
  Enemy() 生成
  Slime() 生成(1024バイト確保)
delete e を実行
  ~Enemy() 破棄
結果: ~Enemy が呼ばれた回数 = 1, ~Slime が呼ばれた回数 = 0
[virtual デストラクタ: あり]
  Enemy() 生成
  Slime() 生成(1024バイト確保)
delete e を実行
  ~Slime() 破棄(1024バイト解放)
  ~Enemy() 破棄
結果: ~Enemy が呼ばれた回数 = 1, ~Slime が呼ばれた回数 = 1

virtualなしでは、delete eで呼ばれたのは~Enemy()だけで、~Slime()は一度も呼ばれません(最後の行は、~Slime が呼ばれた回数 = 0)。Slimeが確保した1024バイトは、解放されないままです。virtualありでは、~Slime()、~Enemy()の順に、両方が呼ばれました。

なお、virtualなしのビルドでは、警告が出ました。これは、/W4に/w14265を足して、有効にしたものです。

v1_basic.cpp
v1_basic.cpp(17): warning C4265: 'Enemy': クラスは仮想関数を含んでいますが、その非単純デストラクターが仮想ではありません。このクラスのインスタンスは正しく消滅されない可能性があります
v1_basic.cpp(26): warning C4265: 'Slime': クラスは仮想関数を含んでいますが、その非単純デストラクターが仮想ではありません。このクラスのインスタンスは正しく消滅されない可能性があります

C4265は、「クラスは仮想関数を含んでいますが、デストラクタが仮想ではありません。このクラスのインスタンスは、正しく消滅されない可能性があります」という内容です(今回は、/W4だけでの出方は、試していません)。virtualありのビルドでは、この警告は、出ませんでした。

実験②:漏れたメモリは、どれくらい増えるのか

「1体だけなら、たいした量ではない」と、思うかもしれません。そこで、敵を作って、基底クラスのポインタでdeleteする処理を、40万回繰り返して、プロセスのメモリを測りました。

▼v3_mem.cpp
#include <windows.h>
#include <psapi.h>
#include <cstdio>

#ifndef VIRTUAL_DTOR
#define VIRTUAL_DTOR 0
#endif

class Enemy
{
public:
#if VIRTUAL_DTOR
    virtual ~Enemy() {}
#else
    ~Enemy() {}
#endif
    virtual void Update() {}
};

class Slime : public Enemy
{
public:
    Slime() : memory_(new char[1024]) { memory_[0] = 1; }
    ~Slime() { delete[] memory_; }

private:
    char* memory_;
};

static double privateMB()
{
    PROCESS_MEMORY_COUNTERS_EX pmc{};
    GetProcessMemoryInfo(GetCurrentProcess(), (PROCESS_MEMORY_COUNTERS*)&pmc, sizeof(pmc));
    return pmc.PrivateUsage / (1024.0 * 1024.0);
}

int main()
{
    std::printf("virtual デストラクタ: %s\n", VIRTUAL_DTOR ? "あり" : "なし");
    std::printf("%-14s %s\n", "作って消した数", "プロセスのメモリ(MB)");
    std::printf("%-14d %.1f\n", 0, privateMB());
    for (int round = 1; round <= 4; ++round)
    {
        for (int i = 0; i < 100000; ++i)
        {
            Enemy* e = new Slime();
            delete e;                                  // 基底クラスのポインタ経由で解放する
        }
        std::printf("%-14d %.1f\n", round * 100000, privateMB());
    }
    return 0;
}
virtual デストラクタ: なし
作って消した数 プロセスのメモリ(MB)
0              0.7
100000         103.8
200000         206.6
300000         309.4
400000         412.2
virtual デストラクタ: あり
作って消した数 プロセスのメモリ(MB)
0              0.7
100000         0.7
200000         0.7
300000         0.7
400000         0.7
作って消した敵の数と、プロセスのメモリ使用量。virtualデストラクタが無いと増え続け、有ると増えない
`virtual`なしでは、10万体ごとに約103MB増える。有りでは、ずっと0.7MBのまま

virtualなしでは、10万体ごとに、約103MBずつ増えて、40万体で412MBに達しました。ありでは、40万体作って消しても、0.7MBのままです。ゲームで、弾や敵を、何度も出しては消すとき、この差は、「長く遊ぶほど、重くなる」という症状になります。

実験③:スマートポインタなら、大丈夫?

「deleteを書かなければ、この問題は起きない」と、考えがちですが、unique_ptrでは、そうとは限りません。4通りの持ち方で、virtualなしの基底クラスを、試しました。

▼v2_smart.cpp
#include <cstdio>
#include <memory>

// virtual デストラクタが、無い基底クラス
class Enemy
{
public:
    ~Enemy() { std::printf("    ~Enemy()\n"); }
    virtual void Update() {}
};

class Slime : public Enemy
{
public:
    Slime() : memory_(new char[1024]) {}
    ~Slime() { delete[] memory_; std::printf("    ~Slime()  <- 派生クラスの後始末\n"); }

private:
    char* memory_;
};

int main()
{
    std::printf("[生ポインタ: Enemy* e = new Slime; delete e;]\n");
    {
        Enemy* e = new Slime();
        delete e;
    }
    std::printf("[unique_ptr<Enemy> に、new Slime を入れる]\n");
    {
        std::unique_ptr<Enemy> u(new Slime());
    }
    std::printf("[shared_ptr<Enemy> に、new Slime を入れる]\n");
    {
        std::shared_ptr<Enemy> s(new Slime());
    }
    std::printf("[shared_ptr<Enemy> に、make_shared<Slime> を入れる]\n");
    {
        std::shared_ptr<Enemy> s = std::make_shared<Slime>();
    }
    return 0;
}
[生ポインタ: Enemy* e = new Slime; delete e;]
    ~Enemy()
[unique_ptr<Enemy> に、new Slime を入れる]
    ~Enemy()
[shared_ptr<Enemy> に、new Slime を入れる]
    ~Slime()  <- 派生クラスの後始末
    ~Enemy()
[shared_ptr<Enemy> に、make_shared<Slime> を入れる]
    ~Slime()  <- 派生クラスの後始末
    ~Enemy()
持ち方~Slime()は、呼ばれたか
生ポインタ(delete e)呼ばれない
unique_ptr<Enemy>(new Slime)呼ばれない
shared_ptr<Enemy>(new Slime)呼ばれた
shared_ptr<Enemy>にmake_shared<Slime>()呼ばれた
基底クラスのデストラクタに、virtualが無い場合

結果は、unique_ptrだけが、~Slime()を呼びませんでした。shared_ptrは、作るときに、「Slimeとして解放する」という情報も、一緒に覚えるため、virtualが無くても、~Slime()が呼ばれました。ただし、これはshared_ptrの実装に頼った動きなので、virtualを付けなくてよい、という意味ではありません。unique_ptrのようなスマートポインタに変えても、virtualは、必要です。

直し方:基底クラスのデストラクタに、virtualを付ける

▼直したEnemyクラス
class Enemy
{
public:
    virtual ~Enemy() = default;     // 基底クラスのデストラクタは、virtualにする
    virtual void Update() {}
};

実験①の「virtualあり」のとおり、これだけで、~Slime()、~Enemy()の順に、呼ばれるようになります。中身が空なら、= defaultで、構いません。

  • virtual関数を1つでも持つクラスは、デストラクタもvirtualにする。C4265が、その目安になる
  • 基底クラスとして使うつもりがないクラスは、finalを付けて、継承を禁止する方法もある
  • deleteを書かずに、unique_ptrやshared_ptrを使っても、基底クラスのデストラクタには、virtualが必要

仮想デストラクタの、よくある失敗例と対処法

①派生クラスに、後始末が無いので、問題ないと思う

派生クラスが、今は何も確保していなくても、あとから、メンバが増えたときに、気づかずにリークします。基底クラスのデストラクタは、最初から、virtualにしておくのが、安全です。

②スマートポインタを使っているので、大丈夫だと思う

実験③のとおり、unique_ptrでは、~Slime()が呼ばれませんでした。virtualは、スマートポインタを使う場合でも、必要です。

③警告を有効にしていない

C4265は、今回は/w14265で有効にして、初めて出ました。基底クラスを、よく作るプロジェクトなら、有効にしておくと、付け忘れに、すぐ気づけます。

④リークに、気づかない

リークしても、プログラムは、落ちません。メモリが増えていくことだけが、症状になります。shared_ptrの循環参照の記事で紹介した、終了時のリークレポート(_CrtSetDbgFlag)や、メモリ使用量の計測が、役に立ちます。

注意点

  • 基底クラスのポインタでdeleteしたとき、デストラクタがvirtualでないと、派生クラスのデストラクタは、呼ばれなかった(規格上は、未定義動作)
  • 40万体で、約412MBが解放されなかった。virtualありなら、0.7MBのまま
  • unique_ptr<Enemy>でも、virtualが無いと、~Slime()は呼ばれなかった。shared_ptrは、呼ばれた
  • C4265は、/w14265で有効にして出た(「クラスは仮想関数を含んでいますが、デストラクタが仮想ではありません」)
  • 直し方は、基底クラスのデストラクタにvirtual(= defaultでよい)
  • 今回の検証は、MSVC 19.44(Visual Studio 2022付属、x64、Release)で行った。未定義動作の結果は、環境で変わりうる

この記事のポイント

  • virtualが無いと、deleteで、~Slime()が呼ばれない(実測で呼ばれた回数は0)
  • 漏れたメモリは、数が増えるほど、増える。40万体で約412MB
  • unique_ptrでも防げない。virtualは、スマートポインタを使うときも、必要
  • 直し方は、基底クラスのデストラクタをvirtualにする、ただ1つ

継承を使うクラスでは、「基底クラスのデストラクタは、virtual」を、決まり事にしてしまうのが、いちばん簡単です。付け忘れても、コンパイルは通り、プログラムも落ちません。静かにメモリを漏らしていくバグほど、最初の1行で、防いでおく価値があります。

あわせて読みたい