C++デストラクタの使い方入門:オブジェクト破棄時の自動後片付けとRAIIの基礎・実例

C++では、オブジェクトがスコープを抜ける、あるいは明示的に破棄されるときに自動で後片付けを行う仕組みとして「デストラクタ」が用意されています。

本稿ではデストラクタの宣言方法と呼ばれるタイミング、RAIIによる例外安全なリソース管理、継承時の仮想デストラクタ、設計上のベストプラクティス、そして典型的なトラブルの回避方法まで、実用コードとともに丁寧に解説します。

目次
  1. C++デストラクタとは?オブジェクト破棄時の自動後片付けの基礎
  2. RAIIの基本概念:スコープで確実にリソース解放
  3. 使い方の基本とコード例:デストラクタでリソース管理
  4. 継承とポリモーフィズム:仮想デストラクタの設計
  5. 設計ベストプラクティスと落とし穴
  6. トラブルシューティング:デストラクタが呼ばれない/効かないとき
  7. まとめ

C++デストラクタとは?オブジェクト破棄時の自動後片付けの基礎

デストラクタの役割と宣言方法(~ClassName() の書き方)

デストラクタの役割

デストラクタは、オブジェクトが破棄される際に自動的に呼び出される特別なメンバ関数です。

ファイルディスクリプタやソケット、メモリ、ミューテックスなどのリソース解放、ログ出力や統計カウントの減算など、スコープ終了時に必ず行いたい後処理を記述します。

宣言と定義の基本形

デストラクタはクラス名の先頭にチルダ(~)を付けて宣言します。

引数や戻り値は持ちません。

C++
class Widget {
public:
    Widget();              // コンストラクタ
    ~Widget() noexcept;    // デストラクタ(通常は noexcept 推奨)
};

実装では、例外を投げないことが重要です。

C++11以降、デストラクタは原則として例外を投げないことが期待され、例外が外へ漏れると std::terminate が呼ばれます。

そのため、noexcept を明示するか、少なくとも例外を外へ出さないように実装します。

いつデストラクタが呼ばれるか(スコープ終了・delete・例外伝播時)

呼ばれる主なタイミング

オブジェクトのストレージ期間に応じて、デストラクタが呼ばれるタイミングが決まっています。

  • 自動記憶域期間(スタックに置かれたオブジェクト): スコープ終了時(ブロックを抜けるときや関数から戻るとき)。例外でスタックが巻き戻される場合も確実に呼ばれます。
  • 動的記憶域期間(new で確保): delete(あるいは delete[])が呼ばれたとき。
  • 一時オブジェクト: フルエクスプレッションの末尾(文の評価が終わるとき)。
  • 静的/スレッド記憶域期間(static/thread_local): プログラム終了時/スレッド終了時に破棄されます。

デモ:スコープ終了と例外伝播での破棄順序

C++
#include <iostream>
#include <stdexcept>
#include <string>

struct Tracer {
    std::string name;
    Tracer(std::string n) : name(std::move(n)) {
        std::cout << "construct " << name << '\n';
    }
    ~Tracer() noexcept {
        std::cout << "destroy  " << name << '\n';
    }
};

void g() {
    Tracer inner{"inner"};
    // ここで例外を投げると、inner は必ず破棄されます
    throw std::runtime_error("boom");
}

void f() {
    Tracer outer{"outer"};
    {
        Tracer block{"block"};
    } // block はここで破棄
    g(); // ここで例外発生、outer は巻き戻しで破棄
}

int main() {
    try {
        f();
    } catch (const std::exception& e) {
        std::cout << "caught: " << e.what() << '\n';
    }
}
実行結果
construct outer
construct block
destroy  block
construct inner
destroy  inner
destroy  outer
caught: boom

注意点:デストラクタで例外を投げない・noexcept指定の推奨

なぜ例外禁止か

スタック巻き戻し中(すでに例外が飛んでいる最中)に別の例外がデストラクタから送出されると、二重例外となり std::terminate が呼ばれます。

これを避けるため、デストラクタは原則として例外を外へ出してはいけません。

実装の指針

  • 例外が起こり得る処理はデストラクタ内で捕捉してログに残すなどし、外に投げないようにします。
  • noexcept 明示は読み手への意図伝達になります。
  • そもそもデストラクタで重い処理や失敗可能な操作を行わない設計(前段での確定的なコミット/ロールバック)が有効です。
C++
struct SafeCloser {
    ~SafeCloser() noexcept {
        try {
            // 失敗する可能性のある処理
            // 例: ネットワークへのフラッシュ、例外を投げうるAPI呼び出し
        } catch (...) {
            // ログのみ、または無視
        }
    }
};

RAIIの基本概念:スコープで確実にリソース解放

RAIIと例外安全:自動後片付けで堅牢なC++コードにする

RAIIの要点

RAII(Resource Acquisition Is Initialization)は、「リソースの獲得は初期化時に、解放は破棄時に」というイディオムです。

コンストラクタでリソースを獲得し、デストラクタで必ず解放するため、例外が発生してもリークしません。

例外安全性の向上

RAIIにより、以下が自然に達成されます。

  • 例外発生時にも破棄が自動で行われ、リークやロック取りっぱなしを防ぐ。
  • エラーパスごとの後片付けコードを分散させずに済み、保守性が上がる。

ルール・オブ・ゼロ/スリー/ファイブとデストラクタの関係

ルール・オブ・ゼロ

所有リソースを標準ライブラリの型(例: std::unique_ptrstd::vector)に任せれば、自作クラスはデストラクタやコピー/ムーブを定義する必要がなくなります。

これが最も安全・簡潔です。

ルール・オブ・スリー

リソースを自前管理する場合、デストラクタ・コピーコンストラクタ・コピー代入演算子の3つを一貫して定義しなければなりません。

ルール・オブ・ファイブ

C++11以降はムーブが加わり、コピー/ムーブコンストラクタとコピー/ムーブ代入、デストラクタの5つを整合的に定義する必要があります。

ムーブを備えることで高効率な転送が可能になります。

使い方の基本とコード例:デストラクタでリソース管理

カスタムRAIIクラスの実例(ファイル/ソケット/ハンドルのクローズ)

FILE* の薄いRAIIラッパー

C++
#include <cstdio>
#include <stdexcept>
#include <string>
#include <iostream>

class File {
    std::FILE* fp_ = nullptr;

public:
    explicit File(const char* path, const char* mode) {
        fp_ = std::fopen(path, mode);
        if (!fp_) {
            throw std::runtime_error("failed to open file");
        }
    }

    ~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;
    }

    void write_line(const std::string& s) {
        std::fputs(s.c_str(), fp_);
        std::fputc('\n', fp_);
        std::fflush(fp_); // 例外を投げないC API
    }
};

int main() {
    try {
        File f{"example.txt", "w"};
        f.write_line("hello");
        std::cout << "wrote a line\n";
    } catch (const std::exception& e) {
        std::cerr << "error: " << e.what() << '\n';
    }
}
実行結果
wrote a line

上のクラスは、例外が起きてもデストラクタで fclose が必ず呼ばれます。

ソケットやWindowsハンドル(HANDLE)も同様のパターンで包めます。

スマートポインタのデストラクタ:std::unique_ptr と std::shared_ptr

unique_ptr:単独所有とカスタムデリータ

std::unique_ptr<T> は単独所有を表し、スコープ終了時に自動で delete されます。

配列には std::unique_ptr<T[]> を使います。

CAPIのハンドルにはカスタムデリータが便利です。

C++
#include <memory>
#include <iostream>

struct Tracked {
    ~Tracked() noexcept { std::cout << "~Tracked\n"; }
};

struct HandleCloser {
    void operator()(int* p) const noexcept {
        std::cout << "closing handle " << *p << '\n';
        delete p;
    }
};

int main() {
    {
        std::unique_ptr<Tracked> p = std::make_unique<Tracked>();
        // スコープ終了で ~Tracked が呼ばれる
    }
    {
        std::unique_ptr<int, HandleCloser> h{new int{42}};
        // スコープ終了で HandleCloser が呼ばれる
    }
}
実行結果
~Tracked
closing handle 42

shared_ptr:共有所有と循環参照の注意

std::shared_ptr<T> は参照カウントで共有所有を表します。

最後の1つが破棄されるときにデストラクタが呼ばれます。

循環参照に注意が必要で、std::weak_ptr を併用して解消します(後述)。

ミューテックスの自動解放:std::lock_guard と std::unique_lock

lock_guard:最小限で確実な解放

std::lock_guard<std::mutex> はスコープに入ったらロック、スコープを出たら自動でアンロックします。

C++
#include <mutex>
#include <thread>
#include <vector>
#include <iostream>

int main() {
    std::mutex m;
    int counter = 0;

    auto worker = [&] {
        for (int i = 0; i < 10000; ++i) {
            std::lock_guard<std::mutex> lg(m); // ここでロック
            ++counter;                          // スコープを抜けるとアンロック
        }
    };

    std::thread t1(worker);
    std::thread t2(worker);
    t1.join(); t2.join();
    std::cout << "counter=" << counter << '\n';
}
実行結果
counter=20000

unique_lock:柔軟なロック制御

std::unique_lock は遅延ロック・タイムアウト・手動unlock/relockなど高度な制御が可能です。

デストラクタで未解放なら自動解放されます。

C++
#include <mutex>
#include <chrono>

void example(std::mutex& m) {
    std::unique_lock<std::mutex> lk(m, std::defer_lock); // まだロックしない
    if (lk.try_lock_for(std::chrono::milliseconds(10))) {
        // ロック成功時の処理
    } // スコープ終了でアンロック(ロック済みなら)
}

継承とポリモーフィズム:仮想デストラクタの設計

基底クラスに仮想デストラクタが必要なケース

ポリモーフィック削除の必須条件

基底クラスを指すポインタ(または参照)経由で派生オブジェクトを削除する可能性があるとき、基底クラスのデストラクタは必ず virtual にします。

そうでないと未定義動作になります。

C++
#include <iostream>

struct Base {
    virtual ~Base() noexcept { std::cout << "~Base\n"; }
    // 仮想関数が1つでもあれば、デストラクタも仮想にしておくのが安全です。
};

struct Derived : Base {
    ~Derived() noexcept { std::cout << "~Derived\n"; }
};

int main() {
    Base* p = new Derived;
    delete p; // ~Derived → ~Base の順に呼ばれる
}
実行結果
~Derived
~Base

仮想でないデストラクタによる未定義動作を避ける方法

未定義動作の典型例と対策

基底のデストラクタが非仮想だと、delete basePtr; は基底部分しか破棄せず、派生のリソースがリークやダングリングを起こす可能性があります。

対策は次の通りです。

  • ポリモーフィズムを用いる設計では、基底のデストラクタを virtual にする。
  • そもそも基底経由で delete しない(ファクトリが std::unique_ptr<Base> を返すなど、所有をスマートポインタに限定する)。
  • インターフェース専用の基底は virtual ~Base() = default; とするのが簡潔です。

設計ベストプラクティスと落とし穴

メンバの破棄順序と依存関係(宣言順・delete不要化)

破棄順序は「宣言の逆順」

クラス内メンバは「宣言順に構築」され「逆順に破棄」されます。

依存関係がある場合は、宣言順を意識して設計します。

手動 delete は避け、メンバにスマートポインタやRAIIクラスを用いれば、正しい順序で自動解放されます。

C++
struct A { ~A() noexcept; };
struct B { ~B() noexcept; };

struct C {
    A a; // 先に構築、後に破棄
    B b; // 後に構築、先に破棄
    // 破棄順は b → a
};

所有権の明確化:生ポインタ所有を避け、スマートポインタを使う

所有・非所有の区別

  • 所有するなら std::unique_ptr(一意)または std::shared_ptr(共有)。
  • 非所有参照なら「生ポインタ/参照/std::observer_ptr(C++23)/std::span」などを用い、ライフタイム管理を所有側に集約します。
  • コンテナには値オブジェクトやスマートポインタを格納し、delete を呼ばない設計にします。

静的/グローバルオブジェクトの破棄順序問題と対策

破棄順序は翻訳単位をまたぐと未規定

異なる翻訳単位(.cppファイル)にある静的オブジェクトの破棄順序は未規定で、依存があると不具合の温床になります。

対策としては以下が有効です。

  • 関数スコープ静的変数(Meyers’ Singletonなど)で「最初の使用時に初期化」し、破棄順問題を軽減する。
  • 可能なら動的初期化を避け、遅延初期化や依存注入へ設計を改める。
  • 依存関係を単一翻訳単位へ集約するか、明示的なinit()/shutdown()の順序管理を行う。
C++
#include <string>

const std::string& global_resource() {
    static const std::string r = "initialized on first use";
    return r; // 破棄はプログラム終了時に自動、順序問題の影響を受けにくい
}

トラブルシューティング:デストラクタが呼ばれない/効かないとき

循環参照(shared_ptr)によるリークと weak_ptr の活用

循環参照の例

shared_ptr 同士が互いを所有すると参照カウントが0にならず、デストラクタが呼ばれません。

weak_ptr を使って片側の所有を弱参照にします。

C++
#include <memory>
#include <iostream>

struct Node {
    std::string name;
    std::shared_ptr<Node> strong_next; // 注意: 強参照
    std::weak_ptr<Node>   weak_prev;   // 弱参照で循環を断つ
    Node(std::string n) : name(std::move(n)) {}
    ~Node() noexcept { std::cout << "~Node " << name << '\n'; }
};

int main() {
    auto a = std::make_shared<Node>("A");
    auto b = std::make_shared<Node>("B");

    a->strong_next = b; // A → B
    b->weak_prev   = a; // B ⇐(weak) A

    // スコープを抜けると A, B ともに破棄可能
}
実行結果
~Node B
~Node A

std::exit/quick_exit/abort でデストラクタが実行されないケース

スタックは巻き戻されない

std::exitstd::quick_exitstd::abort は関数から通常復帰しないため、現在の関数スコープ内の自動オブジェクトのデストラクタは呼ばれません。

std::quick_exitstd::abort はより強力に即時終了し、ほとんどのクリーンアップを行いません。

C++
#include <iostream>
#include <cstdlib>

struct Guard {
    ~Guard() noexcept { std::cout << "~Guard\n"; }
};

int main() {
    Guard g;
    std::cout << "before exit\n";
    std::exit(0); // ここで戻らない。g のデストラクタは呼ばれない
}
実行結果
before exit

プログラム終了時の振る舞いは関数登録(std::atexitstd::at_quick_exit)や静的オブジェクトの処理に依存する面があり得ますが、自動オブジェクトのデストラクタが呼ばれない点は重要です。

RAIIによる確実な解放を期待するなら、通常のスコープ終了で関数を抜ける設計にすべきです。

new/deleteの不整合・二重解放をRAIIで防ぐ方法

典型的な落とし穴

  • new[] で確保した配列を delete してしまう(正しくは delete[])。
  • 同じポインタを二重に delete
  • 例外発生時に delete がスキップされメモリリーク。

これらは std::unique_ptr<T>(配列なら std::unique_ptr<T[]>)を使えば原理的に防げます。

C++
#include <memory>
#include <iostream>

int main() {
    // 配列の安全な所有
    std::unique_ptr<int[]> arr{new int[10]};
    arr[0] = 123;
    std::cout << arr[0] << '\n'; // スコープ終了で自動 delete[]
}
実行結果
123

まとめ

デストラクタは「オブジェクト破棄時の自動後片付け」を担うC++の中核機能です。

RAIIと組み合わせることで例外下でもリソースを確実に解放でき、delete や手動クローズの記述を大幅に減らせます。

設計では次の点を意識すると堅牢になります。

  • デストラクタでは例外を外へ出さず、noexcept を基本とする。
  • 所有権はスマートポインタや標準コンテナに委ね、ルール・オブ・ゼロを目指す。
  • ポリモーフィックに扱う基底クラスには仮想デストラクタを付ける。
  • メンバ破棄順(宣言の逆順)と静的破棄順の問題に配慮する。
  • std::exit 等による異常終了ではデストラクタが呼ばれないため、通常のスコープ終了で確実に後片付けが行われるよう制御フローを設計する。

これらを守れば、C++でのリソース管理は簡潔かつ安全になり、例外安全性の高いコードを実現できます。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!