C++のデストラクタは、オブジェクトの寿命が終わるときに自動的に呼び出される特別なメンバ関数です。
主な役割は、オブジェクトが使用していたリソースを安全に解放することです。
たとえば、動的メモリ、ファイル、ソケット、ロック、データベース接続、OSハンドルなどをクラスが管理している場合、デストラクタで後始末を行います。
デストラクタは、クラス名の前に ~ を付けて定義します。
class Sample {
public:
~Sample() {
// オブジェクト破棄時に呼ばれる処理
}
};
デストラクタには、次のような特徴があります。
| 項目 | 内容 |
|---|---|
| 名前 | ~クラス名 |
| 戻り値 | なし。void も書かない |
| 引数 | 取れない |
| オーバーロード | できない |
| 呼び出し | 通常は自動的に呼ばれる |
| 主な目的 | リソースの解放、後始末 |
たとえば、次のようなコードを見てみます。
#include <iostream>
using namespace std;
class Test {
public:
Test() {
cout << "コンストラクタが呼ばれました\n";
}
~Test() {
cout << "デストラクタが呼ばれました\n";
}
};
int main() {
Test t;
cout << "mainの中です\n";
}
このコードを実行すると、次のような出力になります。
コンストラクタが呼ばれました
mainの中です
デストラクタが呼ばれました
Test t; によってオブジェクトが作られ、main 関数の終了時に t の寿命が終わるため、デストラクタが自動的に呼ばれます。
デストラクタが呼ばれるタイミング
デストラクタが呼ばれるタイミングは、オブジェクトの作られ方によって異なります。
ローカル変数の場合
関数やブロックの中で作られたローカル変数は、そのスコープを抜けるときに破棄されます。
void func() {
Test t;
} // ここで t のデストラクタが呼ばれる
内側のスコープで作られたオブジェクトは、そのスコープを抜けた時点で破棄されます。
void func() {
Test a;
{
Test b;
} // ここで b のデストラクタが呼ばれる
} // ここで a のデストラクタが呼ばれる
また、例外によってスコープを抜ける場合でも、通常はスタック巻き戻しの過程でデストラクタが呼ばれます。
void func() {
Test t;
throw std::runtime_error("error");
} // 例外で抜ける途中でも t のデストラクタが呼ばれる
この仕組みは、C++のRAIIを支える重要な特徴です。
ただし、std::abort() や std::terminate()、std::_Exit() などでプログラムが強制終了する場合には、通常のデストラクタが呼ばれないことがあります。
new で作ったオブジェクトの場合
new で動的に作ったオブジェクトは、delete したときにデストラクタが呼ばれます。
Test* p = new Test;
delete p; // ここでデストラクタが呼ばれる
逆に、delete を忘れると、そのオブジェクトのデストラクタは通常呼ばれません。
Test* p = new Test;
// delete p; を忘れると、デストラクタが呼ばれない
プログラム終了時にOSがメモリを回収することはありますが、それはC++のデストラクタが呼ばれるという意味ではありません。
デストラクタの中でファイルを閉じる、ログを書き出す、通信を終了する、ロックを解除するなどの処理をしている場合、それらの後始末は実行されない可能性があります。
配列を new[] で作った場合
配列を new[] で作った場合は、必ず delete[] で解放します。
Test* arr = new Test[3];
delete[] arr;
この場合、3つの要素それぞれに対してデストラクタが呼ばれます。
次のように、new[] で確保した配列に対して delete を使うのは誤りです。
Test* arr = new Test[3];
delete arr; // NG。未定義動作
delete[] arr; // OK
これは単に「1個分しかデストラクタが呼ばれない」という話ではありません。
C++では未定義動作になります。
グローバル変数や static 変数の場合
グローバル変数や static 変数のデストラクタは、プログラム終了時に呼ばれます。
Test globalObj;
int main() {
}
関数内の static オブジェクトも、通常はプログラム終了時に破棄されます。
void func() {
static Test t;
}
一時オブジェクトの場合
一時オブジェクトは、通常、その式の評価が終わったタイミングで破棄されます。
Test();
関数呼び出しに一時オブジェクトを渡す場合は、関数呼び出しが終わったあとに破棄されます。
void func(Test t) {
}
func(Test());
この場合、Test() で作られた一時オブジェクトは、関数呼び出しの完了後に破棄されます。
デストラクタの呼び出し順序
C++では、オブジェクトは基本的に構築された順番の逆順で破棄されます。
同じスコープ内のオブジェクトの破棄順
次のコードを見てみます。
#include <iostream>
using namespace std;
class Test {
string name;
public:
Test(string n) : name(n) {
cout << name << " constructed\n";
}
~Test() {
cout << name << " destructed\n";
}
};
int main() {
Test a("A");
Test b("B");
Test c("C");
}
出力は次のようになります。
A constructed
B constructed
C constructed
C destructed
B destructed
A destructed
A → B → C の順に作られ、C → B → A の順に破棄されます。
このように、同じスコープ内の自動変数は、構築完了の逆順で破棄されます。
メンバ変数の破棄順
クラスが別のクラス型のメンバ変数を持っている場合、そのメンバ変数も自動的に破棄されます。
#include <iostream>
using namespace std;
class Member {
string name;
public:
Member(string n) : name(n) {
cout << "Member " << name << " constructed\n";
}
~Member() {
cout << "Member " << name << " destructed\n";
}
};
class Owner {
Member m1;
Member m2;
public:
Owner() : m1("m1"), m2("m2") {
cout << "Owner constructed\n";
}
~Owner() {
cout << "Owner destructed\n";
}
};
int main() {
Owner o;
}
出力のイメージは次のとおりです。
Member m1 constructed
Member m2 constructed
Owner constructed
Owner destructed
Member m2 destructed
Member m1 destructed
流れは次のようになります。
| 順番 | 処理 |
|---|---|
| 1 | メンバ変数が宣言順に構築される |
| 2 | クラス本体のコンストラクタが実行される |
| 3 | クラス本体のデストラクタが実行される |
| 4 | メンバ変数が宣言順の逆順に破棄される |
重要なのは、メンバ変数の構築順は、コンストラクタ初期化子リストの順番ではなく、クラス内での宣言順で決まるという点です。
class Owner {
Member m1;
Member m2;
public:
Owner() : m2("m2"), m1("m1") {
}
};
このように初期化子リストで m2 を先に書いても、実際には m1 が先に構築され、その後に m2 が構築されます。
破棄順はその逆です。
また、デストラクタ本体が実行されている間は、メンバ変数はまだ生きています。
class Owner {
Member m;
public:
~Owner() {
// ここでは m はまだ破棄されていない
}
};
継承がある場合の破棄順
継承がある場合、構築は基底クラスから派生クラスの順に行われます。
一方、破棄はその逆で、派生クラスのデストラクタが先に呼ばれ、その後で基底クラスのデストラクタが呼ばれます。
#include <iostream>
using namespace std;
class Base {
public:
Base() {
cout << "Base constructed\n";
}
~Base() {
cout << "Base destructed\n";
}
};
class Derived : public Base {
public:
Derived() {
cout << "Derived constructed\n";
}
~Derived() {
cout << "Derived destructed\n";
}
};
int main() {
Derived d;
}
出力は次のようになります。
Base constructed
Derived constructed
Derived destructed
Base destructed
つまり、構築は Base → Derived、破棄は Derived → Base の順です。
仮想デストラクタ
C++で継承を使う場合、特に注意が必要なのが仮想デストラクタです。
基底ポインタ経由で削除する場合は virtual が必要
基底クラスのポインタを通じて派生クラスのオブジェクトを削除する可能性があるなら、基底クラスのデストラクタは virtual にするべきです。
次のコードは危険です。
#include <iostream>
using namespace std;
class Base {
public:
~Base() {
cout << "Base destructor\n";
}
};
class Derived : public Base {
public:
~Derived() {
cout << "Derived destructor\n";
}
};
int main() {
Base* p = new Derived;
delete p; // 未定義動作
}
このコードでは、Base のデストラクタが virtual ではありません。
そのため、Base* 経由で Derived オブジェクトを delete すると未定義動作になります。
「Derived のデストラクタが呼ばれない可能性がある」というより、この delete p; 自体がC++の標準上、正しく定義されていない動作です。
正しくは、基底クラスのデストラクタを virtual にします。
#include <iostream>
using namespace std;
class Base {
public:
virtual ~Base() {
cout << "Base destructor\n";
}
};
class Derived : public Base {
public:
~Derived() override {
cout << "Derived destructor\n";
}
};
int main() {
Base* p = new Derived;
delete p;
}
出力は次のようになります。
Derived destructor
Base destructor
virtual ~Base() にすることで、実際の型である Derived のデストラクタが先に呼ばれ、その後で Base のデストラクタが呼ばれます。
すべての基底クラスで必ず virtual が必要とは限らない
ただし、「継承を使うなら常にデストラクタを virtual にするべき」とまでは言い切れません。
重要なのは、基底クラスのポインタ経由で削除する設計かどうかです。
基底ポインタ経由で削除する設計なら、次のようにします。
class Base {
public:
virtual ~Base() = default;
};
一方、基底ポインタ経由で削除させたくない設計なら、protected な非仮想デストラクタにする選択肢もあります。
class Base {
protected:
~Base() = default;
};
この場合、外部から次のような削除はできません。
Base* p;
// delete p; // コンパイルエラー
つまり、実務的には次のように考えるとよいです。
| 設計 | デストラクタ |
|---|---|
| 基底ポインタ経由で削除してよい | public virtual ~Base() = default; |
| 基底ポインタ経由で削除させない | protected ~Base() = default; |
派生クラスのデストラクタに override は付けられる
基底クラスのデストラクタが virtual であれば、派生クラスのデストラクタも仮想関数になります。
そのため、派生クラス側のデストラクタには override を付けられます。
class Base {
public:
virtual ~Base() = default;
};
class Derived : public Base {
public:
~Derived() override = default;
};
override は必須ではありませんが、意図を明確にでき、ミスの検出にも役立ちます。
delete とデストラクタの関係
delete は、単にメモリを解放するだけの処理ではありません。
delete p;
この処理では、通常、次の2つが行われます。
| 順番 | 処理 |
|---|---|
| 1 | オブジェクトのデストラクタを呼ぶ |
| 2 | メモリ解放関数を呼ぶ |
つまり、概念的には次のような処理に近いです。
p->~T();
operator delete(p);
ただし、実際には配列版の delete[]、クラス固有の operator delete、サイズ付きdelete、アラインメント対応deleteなども関係するため、完全にこの2行と同じというわけではありません。
学習段階では、delete は「デストラクタを呼んだあとにメモリを解放する」と理解しておくとよいです。
delete nullptr は安全
C++では、nullptr に対して delete しても問題ありません。
Test* p = nullptr;
delete p; // OK。何もしない
この場合、デストラクタは呼ばれず、何も起きません。
そのため、次のようなチェックは基本的に不要です。
if (p) {
delete p;
}
単に次のように書けます。
delete p;
ただし、現代C++では、そもそも delete を直接書くより、std::unique_ptr などのスマートポインタを使う方が安全です。
デストラクタの明示的な呼び出し
C++では、デストラクタを明示的に呼ぶこともできます。
obj.~Test();
しかし、通常のコードで明示的にデストラクタを呼ぶことはほとんどありません。
たとえば、次のようなコードは危険です。
int main() {
Test t;
t.~Test(); // 通常はやらない
} // ここで再び t のデストラクタが呼ばれる
自動変数のデストラクタを明示的に呼ぶと、スコープ終了時にも再びデストラクタが呼ばれます。
その結果、二重破棄になり、未定義動作につながります。
明示的なデストラクタ呼び出しが使われる場面
明示的なデストラクタ呼び出しは、主に placement new と組み合わせるような低レベル処理で使われます。
#include <new>
#include <cstddef>
alignas(Test) std::byte buffer[sizeof(Test)];
Test* p = new (buffer) Test; // placement new
p->~Test(); // 明示的に破棄
このようなコードでは、メモリ領域を自分で用意し、その上にオブジェクトを構築しています。
この場合、通常の delete は使わず、デストラクタを明示的に呼んでオブジェクトの寿命を終了させます。
一般的なアプリケーションコードでは、明示的なデストラクタ呼び出しは避けるべきです。
デストラクタと例外
デストラクタから例外を外に投げる設計は、基本的に避けるべきです。
class Test {
public:
~Test() {
throw std::runtime_error("error"); // 危険
}
};
特に危険なのは、すでに別の例外が発生してスタック巻き戻しが行われている最中に、デストラクタからさらに例外が投げられるケースです。
void func() {
Test t;
throw std::runtime_error("main error");
} // t のデストラクタでも例外が出ると危険
このような状況でデストラクタから別の例外が外に出ると、std::terminate が呼ばれ、プログラムが強制終了する可能性があります。
デストラクタは基本的に noexcept にする
C++11以降、デストラクタは通常、暗黙的に noexcept(true) になります。
ただし、メンバ変数や基底クラスのデストラクタが例外を投げうる場合などは、暗黙の例外仕様が変わることがあります。
実務では、デストラクタから例外を外に出さない設計にするのが基本です。
class Logger {
public:
~Logger() noexcept {
try {
// 終了処理
} catch (...) {
// ログに残すなど
// ただし例外は外に出さない
}
}
};
デストラクタの中で失敗する可能性がある処理を行う場合は、デストラクタ内で例外を捕捉し、外に漏らさないようにします。
RAIIとデストラクタ
C++のデストラクタを理解するうえで、最も重要な考え方がRAIIです。
RAIIは、Resource Acquisition Is Initialization の略です。
日本語では、次のような意味で理解できます。
リソースの獲得をオブジェクトの初期化に結びつけ、リソースの解放をオブジェクトの破棄に結びつける設計
たとえば、ファイルを扱うクラスを考えてみます。
#include <cstdio>
#include <stdexcept>
class File {
FILE* fp = nullptr;
public:
explicit File(const char* filename) {
fp = std::fopen(filename, "r");
if (!fp) {
throw std::runtime_error("ファイルを開けません");
}
}
~File() noexcept {
if (fp) {
std::fclose(fp);
}
}
};
このクラスを使うと、スコープを抜けるときに自動的に fclose が呼ばれます。
void func() {
File file("data.txt");
// 処理
} // ここで file のデストラクタが呼ばれ、fclose される
途中で return しても、例外が発生しても、通常はデストラクタが呼ばれるため、リソースを解放できます。
これがRAIIの大きなメリットです。
RAIIクラスではコピー制御も考える
ただし、手動でリソースを管理するRAIIクラスでは、コピー制御も重要です。
先ほどの File クラスは、そのままだとコピーされたときに問題が起きます。
File f1("data.txt");
File f2 = f1; // FILE* がコピーされ、二重 fclose の危険がある
このような場合、コピーを禁止する必要があります。
class File {
FILE* fp = nullptr;
public:
explicit File(const char* filename) {
fp = std::fopen(filename, "r");
if (!fp) {
throw std::runtime_error("ファイルを開けません");
}
}
~File() noexcept {
if (fp) {
std::fclose(fp);
}
}
File(const File&) = delete;
File& operator=(const File&) = delete;
};
さらに、所有権を移動できるようにしたいなら、ムーブコンストラクタとムーブ代入演算子を定義します。
class File {
FILE* fp = nullptr;
public:
explicit File(const char* filename) {
fp = std::fopen(filename, "r");
if (!fp) {
throw std::runtime_error("ファイルを開けません");
}
}
~File() noexcept {
if (fp) {
std::fclose(fp);
}
}
File(const File&) = delete;
File& operator=(const File&) = delete;
File(File&& other) noexcept : fp(other.fp) {
other.fp = nullptr;
}
File& operator=(File&& other) noexcept {
if (this != &other) {
if (fp) {
std::fclose(fp);
}
fp = other.fp;
other.fp = nullptr;
}
return *this;
}
};
ただし、実務では可能な限り、std::ifstream や std::ofstream などの標準ライブラリを使う方が自然です。
標準ライブラリに見るRAIIの例
C++標準ライブラリには、RAIIを利用したクラスが多くあります。
std::string
std::string は文字列用のメモリを自動的に管理します。
std::string s = "hello";
スコープを抜けると、std::string のデストラクタが呼ばれ、内部で管理していたリソースが適切に解放されます。
std::vector
std::vector は動的配列を管理します。
std::vector<int> v;
v.push_back(1);
v.push_back(2);
内部のメモリ確保や解放は std::vector が行うため、自分で new[] や delete[] を書く必要はありません。
std::unique_ptr
std::unique_ptr は、単独所有の動的オブジェクトを管理します。
#include <memory>
auto p = std::make_unique<int>(10);
p が破棄されると、管理しているオブジェクトも自動的に delete されます。
{
auto p = std::make_unique<int>(10);
} // ここで自動的に delete される
配列を管理する場合は、std::unique_ptr<T[]> を使うこともできます。
std::unique_ptr<int[]> data = std::make_unique<int[]>(100);
この場合、破棄時には delete[] が使われます。
ただし、std::unique_ptr はコピーできません。
auto p1 = std::make_unique<int>(10);
// auto p2 = p1; // エラー。コピー不可
これは、所有権が二重になることを防ぐための安全な仕様です。
std::shared_ptr
std::shared_ptr は、複数のポインタで同じオブジェクトを共有所有するためのスマートポインタです。
std::shared_ptr<int> p1 = std::make_shared<int>(10);
std::shared_ptr<int> p2 = p1;
最後の shared_ptr が破棄されたときに、管理対象のオブジェクトが破棄されます。
std::lock_guard
std::lock_guard は、ミューテックスのロックとアンロックを自動化するRAIIクラスです。
#include <mutex>
std::mutex m;
void func() {
std::lock_guard<std::mutex> lock(m);
// このスコープ内ではロックされている
} // ここで自動的に unlock される
途中で return しても、例外が発生しても、スコープを抜けると lock のデストラクタが呼ばれ、ミューテックスが解放されます。
Rule of Three
C++では、デストラクタを自分で定義する場合、コピーコンストラクタとコピー代入演算子も考える必要があります。
これをRule of Threeと呼びます。
対象になるのは、次の3つです。
| 特殊メンバ関数 | 役割 |
|---|---|
| デストラクタ | リソースを解放する |
| コピーコンストラクタ | コピー生成時の処理を決める |
| コピー代入演算子 | 代入時の処理を決める |
たとえば、次のクラスは危険です。
class Buffer {
int* data;
public:
Buffer(int size) {
data = new int[size];
}
~Buffer() {
delete[] data;
}
};
このクラスをコピーすると、ポインタの値だけがコピーされます。
Buffer a(10);
Buffer b = a; // 浅いコピー
この場合、a.data と b.data が同じメモリを指します。
その結果、a と b のデストラクタがそれぞれ delete[] data を実行し、二重解放が起きる可能性があります。
正しく管理するには、コピーコンストラクタとコピー代入演算子も定義する必要があります。
#include <algorithm>
class Buffer {
int* data;
int size;
public:
Buffer(int s) : data(new int[s]), size(s) {
}
~Buffer() {
delete[] data;
}
Buffer(const Buffer& other)
: data(new int[other.size]), size(other.size) {
std::copy(other.data, other.data + 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;
}
};
ただし、実務ではこのような手動管理よりも、std::vector や std::unique_ptr を使う方が安全です。
Rule of Five
C++11以降では、ムーブセマンティクスが導入されたため、さらにムーブコンストラクタとムーブ代入演算子も考える必要があります。
これをRule of Fiveと呼びます。
対象になるのは、次の5つです。
| 特殊メンバ関数 | 役割 |
|---|---|
| デストラクタ | リソースを解放する |
| コピーコンストラクタ | コピー生成時の処理 |
| コピー代入演算子 | コピー代入時の処理 |
| ムーブコンストラクタ | ムーブ生成時の処理 |
| ムーブ代入演算子 | ムーブ代入時の処理 |
手動でリソースを所有するクラスでは、次のように5つを考える必要があります。
#include <algorithm>
class Buffer {
int* data;
int size;
public:
Buffer(int s) : data(new int[s]), size(s) {
}
~Buffer() {
delete[] data;
}
Buffer(const Buffer& other)
: data(new int[other.size]), size(other.size) {
std::copy(other.data, other.data + 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
: data(other.data), size(other.size) {
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;
}
};
ただし、現代C++では、このようなクラスを自分で書くより、標準ライブラリのRAII型に任せる設計が推奨されます。
Rule of Zero
現代C++で特に重要なのがRule of Zeroです。
Rule of Zeroとは、デストラクタ、コピーコンストラクタ、コピー代入演算子、ムーブコンストラクタ、ムーブ代入演算子を自分で書かない設計を目指す考え方です。
たとえば、手動でメモリ管理をする代わりに、std::vector を使います。
#include <vector>
class Buffer {
std::vector<int> data;
public:
Buffer(int size) : data(size) {
}
};
この場合、メモリ管理は std::vector が担当します。
そのため、Buffer クラス自身はデストラクタを書く必要がありません。
class Buffer {
std::vector<int> data;
};
可能であれば、デストラクタを自分で書くよりも、標準ライブラリの型にリソース管理を任せる方が安全です。
デストラクタの = default と = delete
デストラクタは、明示的に = default や = delete を指定できます。
= default
= default は、コンパイラが生成する通常のデストラクタを使うという意味です。
class Sample {
public:
~Sample() = default;
};
特別な後始末が不要な場合は、デストラクタ自体を書かなくても問題ありません。
class Sample {
};
ポリモーフィックな基底クラスでは、次のように書くことがよくあります。
class Base {
public:
virtual ~Base() = default;
};
= delete
= delete を使うと、デストラクタを削除できます。
class NoDestroy {
public:
~NoDestroy() = delete;
};
ただし、デストラクタが削除されているクラスは、通常の自動変数として作れません。
NoDestroy obj; // 破棄できないためエラー
この機能はかなり特殊で、一般的なアプリケーションコードではあまり使いません。
privateデストラクタとprotectedデストラクタ
デストラクタには、public、protected、private のアクセス指定を付けられます。
privateデストラクタ
デストラクタを private にすると、外部から自由にオブジェクトを破棄できなくなります。
class OnlyHeap {
private:
~OnlyHeap() {}
public:
static void destroy(OnlyHeap* p) {
delete p;
}
};
この場合、外部から直接 delete することはできません。
OnlyHeap* p = /* ... */;
// delete p; // エラー
OnlyHeap::destroy(p); // OK
また、デストラクタが private だと、自動変数として作ることもできません。
OnlyHeap obj; // スコープ終了時にデストラクタを呼べないためエラー
スマートポインタと組み合わせる場合は、カスタムデリータなどが必要になることがあります。
protectedデストラクタ
基底クラスとして使うが、外部から基底ポインタ経由で削除させたくない場合は、protected なデストラクタを使うことがあります。
class Base {
protected:
~Base() = default;
};
この場合、外部から次のようにはできません。
Base* p;
// delete p; // エラー
一方で、派生クラスのオブジェクトは普通に破棄できます。
class Derived : public Base {
public:
~Derived() = default;
};
int main() {
Derived d; // OK
}
Derived のデストラクタから Base の protected デストラクタにはアクセスできるためです。
純粋仮想デストラクタ
抽象クラスを作るために、デストラクタを純粋仮想関数にすることがあります。
class Base {
public:
virtual ~Base() = 0;
};
ただし、純粋仮想デストラクタにも定義が必要です。
Base::~Base() {
}
完全な例は次のようになります。
class Base {
public:
virtual ~Base() = 0;
};
Base::~Base() {
}
なぜ定義が必要なのかというと、派生クラスを破棄するとき、最後に基底クラスのデストラクタも必ず呼ばれるからです。
class Derived : public Base {
public:
~Derived() override {
}
};
Derived オブジェクトが破棄されると、次の順でデストラクタが呼ばれます。
Derived::~Derived()
Base::~Base()
そのため、Base::~Base() の実体が必要になります。
デストラクタ内で仮想関数を呼ぶときの注意
デストラクタ内で仮想関数を呼ぶ設計には注意が必要です。
#include <iostream>
using namespace std;
class Base {
public:
virtual ~Base() {
cleanup();
}
virtual void cleanup() {
cout << "Base cleanup\n";
}
};
class Derived : public Base {
public:
void cleanup() override {
cout << "Derived cleanup\n";
}
};
この場合、Base のデストラクタ内で cleanup() を呼んでも、通常は Derived::cleanup() ではなく Base::cleanup() が呼ばれます。
基底クラスのデストラクタが実行されている時点では、派生クラス部分はすでに破棄済みとして扱われます。
そのため、コンストラクタやデストラクタ内で仮想関数の動的ディスパッチに依存する設計は避けるべきです。
デストラクタと const
デストラクタに const は付けられません。
class Test {
public:
~Test() const { } // エラー
};
デストラクタはオブジェクトを破棄する処理なので、const メンバ関数にはできません。
ただし、const オブジェクトにもデストラクタは呼ばれます。
const Test t;
この t も、スコープを抜けるとデストラクタが呼ばれます。
Pimplイディオムとデストラクタ
中級以上で重要になるのが、不完全型とデストラクタの関係です。
たとえば、Pimplイディオムでは、クラスの実装詳細をヘッダから隠すために、次のような設計を使うことがあります。
// Widget.h
#include <memory>
class Widget {
class Impl;
std::unique_ptr<Impl> pImpl;
public:
Widget();
~Widget();
};
このとき、Impl の定義は .cpp 側に置きます。
// Widget.cpp
class Widget::Impl {
public:
int data;
};
Widget::~Widget() = default;
std::unique_ptr<Impl> は、破棄時に Impl の完全な型を必要とします。
そのため、Widget のデストラクタをヘッダ内で暗黙生成させるのではなく、.cpp 側で定義することがあります。
これは、デストラクタが「どこで生成・定義されるか」が重要になる例です。
デストラクタのよくあるミス
デストラクタまわりでは、いくつか典型的なミスがあります。
基底クラスのデストラクタを virtual にしない
基底ポインタ経由で派生クラスを削除する可能性があるなら、基底クラスのデストラクタは virtual にする必要があります。
悪い例です。
class Base {
public:
~Base() {}
};
class Derived : public Base {
public:
~Derived() {}
};
Base* p = new Derived;
delete p; // 未定義動作
正しくは次のようにします。
class Base {
public:
virtual ~Base() = default;
};
new[] に対して delete を使う
配列を new[] で確保した場合は、必ず delete[] を使います。
int* p = new int[10];
delete p; // NG。未定義動作
delete[] p; // OK
二重deleteする
同じポインタに対して複数回 delete するのは未定義動作です。
int* p = new int(10);
delete p;
delete p; // NG。未定義動作
delete した後のポインタは、もう有効なオブジェクトを指していません。
手動で delete を管理すると、このようなミスが起きやすいため、現代C++ではスマートポインタを使う方が安全です。
浅いコピーで二重解放が起きる
デストラクタで手動解放を行うクラスは、コピー時にも注意が必要です。
class Sample {
int* p;
public:
Sample() {
p = new int(10);
}
~Sample() {
delete p;
}
};
このクラスをコピーすると、ポインタの値だけがコピーされます。
Sample a;
Sample b = a; // p が共有される
その結果、a と b の両方が同じポインタを delete しようとして、二重解放になる可能性があります。
このような設計は、std::unique_ptr や std::vector を使って避けるのが基本です。
デストラクタから例外を投げる
デストラクタから例外を外に投げるのは危険です。
class Sample {
public:
~Sample() {
throw std::runtime_error("error"); // NG
}
};
デストラクタ内で例外が発生する可能性がある場合は、デストラクタ内で捕捉し、外に出さないようにします。
class Sample {
public:
~Sample() noexcept {
try {
// 後始末
} catch (...) {
// 例外を外に出さない
}
}
};
実務でのデストラクタの考え方
実務では、デストラクタを自分で書く機会は昔より少なくなっています。
多くの場合、標準ライブラリのRAII型を使えば、明示的なデストラクタを書かずに安全な設計ができます。
| 管理したいもの | 使うもの |
|---|---|
| 文字列 | std::string |
| 動的配列 | std::vector |
| 単独所有の動的オブジェクト | std::unique_ptr |
| 共有所有の動的オブジェクト | std::shared_ptr |
| ファイル | std::ifstream, std::ofstream, std::fstream |
| ロック | std::lock_guard, std::unique_lock |
| スコープ終了時の後始末 | RAIIクラス |
たとえば、次のような手動管理のクラスは避けたい設計です。
class UserList {
User* users;
int size;
public:
UserList(int n) {
users = new User[n];
size = n;
}
~UserList() {
delete[] users;
}
};
現代C++では、次のように std::vector を使う方が安全です。
#include <vector>
class UserList {
std::vector<User> users;
public:
UserList(int n) : users(n) {
}
};
この場合、デストラクタを書く必要はありません。
std::vector が内部のメモリ管理を担当してくれるからです。
C++デストラクタのまとめ
C++のデストラクタは、オブジェクトの寿命が終わるときに呼ばれる特別なメンバ関数です。
主な目的は、オブジェクトが所有しているリソースを解放することです。
重要なポイントを整理すると、次のようになります。
| ポイント | 内容 |
|---|---|
| デストラクタの名前 | ~クラス名() |
| 戻り値 | なし |
| 引数 | 取れない |
| オーバーロード | できない |
| ローカル変数 | スコープを抜けると破棄される |
new で作ったオブジェクト | delete で破棄される |
new[] で作った配列 | delete[] で破棄する |
| 破棄順 | 基本的に構築の逆順 |
| 継承時 | 派生クラス → 基底クラスの順に破棄 |
| 仮想デストラクタ | 基底ポインタ経由で削除するなら必要 |
| 例外 | デストラクタから外に投げない |
| 現代C++ | RAIIとRule of Zeroを重視する |
実務で特に重要なのは、次の3つです。
| 重要ポイント | 説明 |
|---|---|
| RAIIを使う | リソースの獲得と解放をオブジェクトの寿命に結びつける |
| 基底クラスの削除設計を明確にする | 基底ポインタ経由で削除するなら virtual デストラクタにする |
手動の new / delete を避ける | std::vector、std::unique_ptr、std::shared_ptr などを使う |
初心者のうちは、デストラクタを「オブジェクトが消えるときに自動で呼ばれる後片付け関数」と理解すれば十分です。
中級以上では、RAII、仮想デストラクタ、Rule of Three、Rule of Five、Rule of Zeroまで理解すると、C++らしい安全で保守しやすい設計ができるようになります。
以上、C++のデストラクタについてでした。
最後までお読みいただき、ありがとうございました。
