【C++】クラスをコピーしたら、終了時にクラッシュする原因とは?|浅いコピーによる二重解放を再現して、Rule of Five・=delete・Rule of Zeroで直す

【C++】クラスをコピーしたら、終了時にクラッシュする原因とは?|浅いコピーによる二重解放を再現して、Rule of Five・=delete・Rule of Zeroで直す C++

デストラクタに、「解放しました」という表示を入れておきました。実行すると、1回目の表示は、出ます。ところが、2回目の表示が出る前に、プログラムが落ちました。同じアドレスを、2回、解放しようとしたからです。

配列をnewで持つクラスを、何も考えずに、コピーすると、こうなります。コピーされたのは、配列の中身ではなく、配列の場所を指すポインタだけだからです。この記事では、その二重解放を再現して、直し方を3通り比べました。検証は、Visual Studio 2022付属のMSVC(19.44、x64、/W4)で行っています。

浅いコピーとは?

浅いコピー(シャローコピー)とは、

・オブジェクトのメンバを、そのまま1つずつコピーするだけの、コピーの仕方。メンバがポインタなら、「指す先」ではなく、「アドレスの値」だけがコピーされる

です。C++では、コピーコンストラクタを書かなければ、このコピーが、自動で作られます。値だけを持つクラスなら、これで問題ありません。ポインタで、newした領域を持つクラスでは、2つのオブジェクトが、同じ領域を、共有してしまいます。

実験:何も書かずに、コピーする

▼c1_shallow.cpp
#include <cstdio>

// 配列を持つだけの、簡単なクラス(コピーの書き方を、何も決めていない)
class Buffer
{
public:
    explicit Buffer(int n) : size_(n), data_(new int[n]) { for (int i = 0; i < n; ++i) data_[i] = i; }
    ~Buffer() { delete[] data_; std::printf("  ~Buffer: %p を解放\n", (void*)data_); }

    int* data() const { return data_; }

private:
    int  size_;
    int* data_;
};

int main()
{
    std::setvbuf(stdout, nullptr, _IONBF, 0);           // 落ちる直前までの出力が、消えないように、バッファしない
    std::printf("a を作る\n");
    Buffer a(4);
    {
        std::printf("b を、a のコピーとして作る\n");
        Buffer b = a;                                   // 自動で作られるコピー(中のポインタを、そのままコピーする)
        std::printf("  a.data() = %p, b.data() = %p\n", (void*)a.data(), (void*)b.data());
        std::printf("bのスコープを出る\n");
    }                                                   // b のデストラクタ → 配列を解放
    std::printf("main を出る\n");
    return 0;                                           // a のデストラクタ → 同じ配列を、もう一度解放
}
終了コード: 0xC0000374
--- 標準出力 ---
a を作る
b を、a のコピーとして作る
  a.data() = 000001C967133A70, b.data() = 000001C967133A70
bのスコープを出る
  ~Buffer: 000001C967133A70 を解放
main を出る
終了コード: 0x00000001
--- 標準出力 ---
a を作る
b を、a のコピーとして作る
  a.data() = 00000281416948E0, b.data() = 00000281416948E0
bのスコープを出る
  ~Buffer: 00000281416948E0 を解放
main を出る

a.data()とb.data()が、まったく同じアドレスでした(000001C967133A70など)。bのスコープを出たところで、1回目の解放が行われ、mainの終わりで、aが、すでに解放された、同じ場所を、もう一度delete[]しようとして、落ちました。

Releaseの終了コードは、0xC0000374(ヒープが壊れた)でした。Debugでは、終了コードは1で、ダイアログは出ませんでした。どちらも、「main を出る」の表示のあとで、プログラムが止まっています。クラッシュする場所は、コピーした行ではなく、ずっとあとの、デストラクタです。そのため、原因を探すのに、時間がかかります。

直し方①:コピーを、自分で書く(Rule of Three / Five)

1つ目は、コピーの仕方を、自分で決めることです。デストラクタを書いたら、コピーコンストラクタとコピー代入も、書く。これを、「Rule of Three」と呼びます。C++11以降は、ムーブも加えて、「Rule of Five」とも呼ばれます。

▼c2_deep.cpp
#include <algorithm>
#include <cstdio>
#include <utility>

// コピーを自分で書いた版(Rule of Three / Five)
class Buffer
{
public:
    explicit Buffer(int n) : size_(n), data_(new int[n]) { for (int i = 0; i < n; ++i) data_[i] = i; }
    ~Buffer() { delete[] data_; std::printf("  ~Buffer: %p を解放\n", (void*)data_); }

    // コピーコンストラクタ: 配列の中身まで、新しく作って、コピーする
    Buffer(const Buffer& other) : size_(other.size_), data_(new int[other.size_])
    {
        std::copy(other.data_, other.data_ + other.size_, data_);
    }
    // コピー代入: 自分自身への代入に気をつけて、古い配列を解放してから、コピーする
    Buffer& operator=(const Buffer& other)
    {
        if (this != &other)
        {
            int* newData = new int[other.size_];
            std::copy(other.data_, other.data_ + other.size_, newData);
            delete[] data_;
            data_ = newData;
            size_ = other.size_;
        }
        return *this;
    }
    // ムーブ: 配列の持ち主を、移すだけ(コピーしない)
    Buffer(Buffer&& other) noexcept : size_(other.size_), data_(other.data_) { other.data_ = nullptr; other.size_ = 0; }
    Buffer& operator=(Buffer&& other) noexcept
    {
        if (this != &other)
        {
            delete[] data_;
            data_ = other.data_; size_ = other.size_;
            other.data_ = nullptr; other.size_ = 0;
        }
        return *this;
    }

    int* data() const { return data_; }

private:
    int  size_;
    int* data_;
};

int main()
{
    std::printf("a を作る\n");
    Buffer a(4);
    {
        std::printf("b を、a のコピーとして作る\n");
        Buffer b = a;
        std::printf("  a.data() = %p, b.data() = %p\n", (void*)a.data(), (void*)b.data());
        b.data()[0] = 99;
        std::printf("  b を書き換えた後: a.data()[0] = %d, b.data()[0] = %d\n", a.data()[0], b.data()[0]);
        std::printf("bのスコープを出る\n");
    }
    std::printf("main を出る\n");
    return 0;
}
a を作る
b を、a のコピーとして作る
  a.data() = 00000201B6ECC630, b.data() = 00000201B6ECC810
  b を書き換えた後: a.data()[0] = 0, b.data()[0] = 99
bのスコープを出る
  ~Buffer: 00000201B6ECC810 を解放
main を出る
  ~Buffer: 00000201B6ECC630 を解放
a を作る
b を、a のコピーとして作る
  a.data() = 0000018302EB61E0, b.data() = 0000018302EB6230
  b を書き換えた後: a.data()[0] = 0, b.data()[0] = 99
bのスコープを出る
  ~Buffer: 0000018302EB6230 を解放
main を出る
  ~Buffer: 0000018302EB61E0 を解放

今度は、aとbの、アドレスが違います(...C630と...C810)。bを書き換えても、aの値は、0のままでした。解放も、それぞれ別のアドレスを、1回ずつ行い、DebugとReleaseのどちらでも、落ちませんでした。

コピー代入(operator=)では、自分自身への代入(a = a)に、気をつけます。先に古い配列を解放してしまうと、自分の中身を、失うからです。上の実装では、this != &otherで、判定しています。

直し方②:コピーを、禁止する

コピーする必要が、そもそもないなら、= deleteで、禁止できます。

▼c3_delete.cpp
#include <cstdio>

// コピーを禁止した版
class Buffer
{
public:
    explicit Buffer(int n) : data_(new int[n]) {}
    ~Buffer() { delete[] data_; }

    Buffer(const Buffer&) = delete;               // コピーできなくする
    Buffer& operator=(const Buffer&) = delete;

private:
    int* data_;
};

int main()
{
    Buffer a(4);
    Buffer b = a;                                 // コピーしようとすると、コンパイルエラーになる
    return 0;
}
c3_delete.cpp
c3_delete.cpp(20): error C2280: 'Buffer::Buffer(const Buffer &)': 削除された関数を参照しようとしています
c3_delete.cpp(10): note: 'Buffer::Buffer' の宣言を確認してください
c3_delete.cpp(10): note: 'Buffer::Buffer(const Buffer &)': 関数は明示的に削除されました

コピーしようとした行が、コンパイルエラー(C2280)になりました。「クラッシュして、あとで気づく」のではなく、「ビルドの時点で、気づく」ようになります。リソースを一人だけが持つ、という設計のクラスに向いています。

直し方③:自分で管理せず、vectorに任せる(Rule of Zero)

いちばん簡単なのは、new[]とdelete[]を、書かないことです。std::vectorに、配列の管理を任せると、コピーも解放も、正しく行われます。

▼c4_zero.cpp
#include <cstdio>
#include <vector>

// Rule of Zero: 自分でnew/deleteせず、vectorに任せる
class Buffer
{
public:
    explicit Buffer(int n) : data_(n) { for (int i = 0; i < n; ++i) data_[i] = i; }

    int* data() { return data_.data(); }

private:
    std::vector<int> data_;                       // コピーも、解放も、vectorが正しく行う
};

int main()
{
    Buffer a(4);
    {
        Buffer b = a;                             // 中身まで、コピーされる
        std::printf("a.data() = %p, b.data() = %p\n", (void*)a.data(), (void*)b.data());
        b.data()[0] = 99;
        std::printf("b を書き換えた後: a.data()[0] = %d, b.data()[0] = %d\n", a.data()[0], b.data()[0]);
    }
    std::printf("main を出る(デストラクタを、1つも書いていない)\n");
    return 0;
}
a.data() = 0000015D7048C890, b.data() = 0000015D7048C530
b を書き換えた後: a.data()[0] = 0, b.data()[0] = 99
main を出る(デストラクタを、1つも書いていない)
a.data() = 000002029308F070, b.data() = 000002029308F7F0
b を書き換えた後: a.data()[0] = 0, b.data()[0] = 99
main を出る(デストラクタを、1つも書いていない)

デストラクタも、コピーコンストラクタも、1つも書いていませんが、aとbは別のアドレスで、bの書き換えも、aに影響しませんでした。「特別なメンバ関数を、1つも書かなくて済む」設計を、Rule of Zeroと呼びます。

どの直し方を選ぶ?

直し方コード量ミスの起きやすさ向いている場面
Rule of Five(自分で書く)多い(5つのメンバ関数)高い(自己代入・例外・ムーブ元の後始末)リソースの管理を、自分で実装する必要があるとき
= delete(コピー禁止)少ない低い(コンパイルで検出)コピーされると困る、リソースを一人だけが持つクラス
Rule of Zero(vectorなどに任せる)最小最も低いほとんどの場面(まず、これを選ぶ)
迷ったら、Rule of Zero。標準ライブラリで足りないときだけ、自分で書く

浅いコピーの、よくある失敗例と対処法

①デストラクタだけを書いて、コピーを忘れる

deleteを書いた時点で、「このクラスは、リソースを持っている」と、言っているのと同じです。コピーコンストラクタと、コピー代入も、必ず一緒に決めます(書くか、= deleteにする)。

②コピーしているつもりが、ないのに、落ちる

コピーは、Buffer b = a;と書いたときだけでなく、関数に値渡しで渡したときや、vectorにpush_backしたときにも、起こります。「コピーした覚えがないのに、クラッシュする」ときは、値渡しが隠れていないかを、確かめてください。

③クラッシュの場所を、探し続ける

実験のとおり、落ちるのは、コピーした行ではなく、デストラクタです。デストラクタに、解放するアドレスを表示すると、同じアドレスが2回出ることで、原因に気づけます。

注意点

  • コピーコンストラクタを書かないと、メンバを、そのままコピーする浅いコピーが、自動で作られる
  • ポインタでnewした領域を持つクラスを、浅いコピーすると、同じアドレスを、2回解放する(今回は0xC0000374で落ちた)
  • 落ちるのは、コピーした行ではなく、デストラクタの時点
  • 直し方は、Rule of Five、= delete、Rule of Zero。迷ったら、Rule of Zero
  • コピー代入では、自己代入(a = a)に、気をつける
  • 今回の検証は、MSVC 19.44(Visual Studio 2022付属、x64)で行った。二重解放の結果は、環境や設定で変わりうる(未定義動作)

この記事のポイント

  • 浅いコピーでは、aとbが、同じアドレスを持ち、デストラクタで二重解放になる
  • 直し方は3通り。自分で書く(Rule of Five)、禁止する(= delete)、任せる(Rule of Zero)
  • deleteを書いたら、コピーも、一緒に決める

二重解放は、コピーを書いた場所ではなく、片づけをする場所で、突然現れます。「後始末を書いたら、コピーのことも考える」。この習慣が、いちばん確実な予防です。そして、できるだけ、後始末を書かずに済む設計(Rule of Zero)を、選んでください。

あわせて読みたい