デストラクタに、「解放しました」という表示を入れておきました。実行すると、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などに任せる) | 最小 | 最も低い | ほとんどの場面(まず、これを選ぶ) |
浅いコピーの、よくある失敗例と対処法
①デストラクタだけを書いて、コピーを忘れる
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)を、選んでください。


