閉じる

【C++】shared_ptr 参照カウントの仕組みと使い方を徹底解説

C++におけるメモリ管理は、かつてはエンジニアを悩ませる最大の課題の一つでした。

しかし、スマートポインタの登場によってその状況は劇的に改善されました。

中でも「shared_ptr」は、複数の所有者でオブジェクトを共有できる強力な仕組みを提供します。

この記事では、shared_ptrの核となる「参照カウンタ」の仕組みから、パフォーマンスを最大化する使い方、そして陥りやすい罠まで、現代的なC++開発で必須となる知識を徹底的に解説します。

shared_ptrの基本概念と参照カウンタの役割

C++のstd::shared_ptrは、動的に確保されたメモリ(オブジェクト)の寿命を自動的に管理する「スマートポインタ」の一種です。

最大の特徴は、一つのオブジェクトを「複数のポインタが共同で所有できる」点にあります。

参照カウンタとは何か

shared_ptrは、内部で「参照カウンタ」と呼ばれる数値を保持しています。

これは、対象のオブジェクトを現在いくつのshared_ptrが指し示しているかをカウントするものです。

参照カウンタの仕組みは非常にシンプルです。

  1. 新しいshared_ptrがオブジェクトを所有すると、カウントが1増える。
  2. shared_ptrがコピーされると、カウントがさらに増える。
  3. shared_ptrの有効期限が切れる(スコープを抜ける)か、別のオブジェクトを指すと、カウントが1減る。
  4. カウントが0になった瞬間、オブジェクトは自動的にメモリから解放される。

この仕組みにより、開発者が手動でdeleteを呼び出す必要がなくなり、メモリリークや二重解放(Double Free)といった致命的なバグを未然に防ぐことができます。

shared_ptrの内部構造:コントロールブロック

shared_ptrを正しく理解するためには、その内部で何が起きているかを知る必要があります。

shared_ptrは単なるポインタではなく、実際には2つのポインタを保持する構造体のようなものです。

ポインタの二重構造

shared_ptrのインスタンスは、以下の2つのアドレスを保持しています。

  • オブジェクトへのポインタ:実際のデータが存在するメモリを指します。
  • コントロールブロックへのポインタ:参照カウンタやカスタムデリータ、weak_ptr用のカウンタなどが格納された領域を指します。

なぜコントロールブロックが必要なのか

複数のshared_ptrが同じオブジェクトを共有する場合、どのポインタから見ても「現在の参照数」が共通でなければなりません。

そのため、参照カウンタはオブジェクト本体とは別に、ヒープ領域に独立して確保される必要があります。

これがコントロールブロックです。

実践的なshared_ptrの使い方とコード例

それでは、具体的なコードを用いてshared_ptrの挙動を確認してみましょう。

基本的な生成とカウントの推移

shared_ptrを生成し、コピーや破棄によって参照カウントがどのように変化するかを示します。

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

class MyClass {
public:
    MyClass() { std::cout << "MyClass 生成\n"; }
    ~MyClass() { std::cout << "MyClass 破棄\n"; }
};

int main() {
    std::cout << "--- スコープ開始 ---\n";
    
    // shared_ptrの生成
    std::shared_ptr<MyClass> ptr1 = std::make_shared<MyClass>();
    
    // 現在の参照カウントを確認
    std::cout << "ptr1所有時 カウント: " << ptr1.use_count() << "\n";

    {
        // 別のポインタにコピー(参照カウントが増える)
        std::shared_ptr<MyClass> ptr2 = ptr1;
        std::cout << "ptr2にコピー後 カウント: " << ptr1.use_count() << "\n";
    } // ptr2のスコープ終了(参照カウントが減る)

    std::cout << "ptr2破棄後 カウント: " << ptr1.use_count() << "\n";

    std::cout << "--- スコープ終了 ---\n";
    return 0;
} // ptr1のスコープ終了。カウントが0になりオブジェクトが破棄される
実行結果
--- スコープ開始 ---
MyClass 生成
ptr1所有時 カウント: 1
ptr2にコピー後 カウント: 2
ptr2破棄後 カウント: 1
--- スコープ終了 ---
MyClass 破棄

このように、use_count()メソッドを使用することで、現在の参照数を取得できます。

ただし、use_count()は主にデバッグ目的で使用し、プログラムのロジックに組み込むことは推奨されません(マルチスレッド環境では値が即座に変わる可能性があるためです)。

make_sharedを使うべき理由

shared_ptrを生成する方法には、newキーワードを使う方法と、std::make_shared関数を使う方法の2種類があります。

モダンなC++では、原則としてmake_sharedを使用します

効率的なメモリ割り当て

newを使用してshared_ptrを作ると、オブジェクトの生成とコントロールブロックの生成で合計2回のメモリ割り当て(メモリ確保)が発生します。

一方で、std::make_sharedを使用すると、オブジェクトとコントロールブロックを「一つの連続したメモリ領域」にまとめて確保します。

例外安全性

make_sharedを使うもう一つの大きな理由は、例外安全性です。

複数の引数を持つ関数の呼び出し中にポインタを生成する場合、newを使うとメモリリークの危険性がわずかに生じますが、make_sharedはそのリスクを完全に排除します。

循環参照という最大の罠とweak_ptrによる解決

shared_ptrを利用する上で必ず知っておかなければならないのが「循環参照(Circular Reference)」の問題です。

循環参照とは

2つのオブジェクトが互いにshared_ptrで指し合っている状態を指します。

この状態になると、どちらのポインタも「相手が自分を指している」ために参照カウントが0にならず、永遠にメモリが解放されないメモリリークが発生します。

weak_ptrによる解決

この問題を解決するのがstd::weak_ptrです。

weak_ptrは「所有権を持たない」スマートポインタであり、オブジェクトを指していても参照カウントを増やしません

循環参照が懸念される親子関係などの構造では、親から子へはshared_ptr、子から親へはweak_ptrを使用するのが定石です。

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

struct Node {
    std::shared_ptr<Node> neighbor;
    // std::weak_ptr<Node> neighbor; // 循環参照を避けるにはこちらを使う
    ~Node() { std::cout << "Node 破棄\n"; }
};

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

    a->neighbor = b;
    b->neighbor = a; // ここで循環参照が発生

    std::cout << "スコープを抜けます\n";
    return 0;
}

上記のコードをそのまま実行すると、~Node()は一度も呼ばれません。

b->neighborweak_ptrに変更することで、正しく破棄されるようになります。

shared_ptrの注意点とベストプラクティス

shared_ptrは便利ですが、万能ではありません。

正しく使うためのポイントをまとめました。

1. unique_ptrをデフォルトにする

全ての動的オブジェクトをshared_ptrにする必要はありません。

所有者が一人であることが明確な場合は、std::unique_ptrを使用してください。

unique_ptrはオーバーヘッドがほぼゼロであり、設計もシンプルになります。

所有権を「共有」する必要が出てきたときに初めてshared_ptrへの移行を検討しましょう。

2. 生ポインタからの複数回生成を避ける

同じ生ポインタから、別々にshared_ptrを作成してはいけません。

C++
int* rawPtr = new int(10);
std::shared_ptr<int> p1(rawPtr);
std::shared_ptr<int> p2(rawPtr); // NG! それぞれが独立したコントロールブロックを作ってしまう

これを実行すると、p1とp2がそれぞれ「自分が唯一の所有者だ」と勘違いし、両者が破棄されるときに二重解放が発生します。

必ずコピーコンストラクタか、make_sharedを使用するようにしましょう。

3. スレッド安全性について

shared_ptrの「参照カウントの操作」はスレッドセーフです。

複数のスレッドから同時に同じオブジェクトを指すshared_ptrをコピーしたり破棄したりしても、カウントが壊れることはありません。

ただし、「オブジェクト自体の操作」や「shared_ptrインスタンスの書き換え」はスレッドセーフではありません

複数のスレッドから一つのshared_ptrインスタンスを同時に変更(別のオブジェクトを代入するなど)する場合は、ミューテックスによる保護や、C++20で導入されたstd::atomic<std::shared_ptr<T>>の利用を検討してください。

最新仕様:配列サポートの強化

近年のC++(C++20以降)では、shared_ptrの利便性がさらに向上しています。

配列のサポート

以前のshared_ptrでは、配列を扱う際に独自のデリータを指定する必要がありましたが、現在はstd::shared_ptr<T[]>という形式が標準でサポートされています。

C++
// C++20以降の書き方
auto arr = std::make_shared<int[]>(10); // 10要素のint配列を確保
arr[5] = 100; // operator[] も利用可能

これにより、動的配列の共有管理も非常に簡潔に記述できるようになりました。

まとめ

shared_ptrは、参照カウントという仕組みを通じて複雑なオブジェクトの寿命管理を自動化してくれる、非常に強力なツールです。

コントロールブロックの存在を意識し、std::make_sharedを積極的に活用することで、安全かつ高速なプログラムを記述できます。

項目内容
基本の仕組み参照カウントが0になると自動解放される
生成方法原則として std::make_shared を使用する
最大の注意点循環参照を避けるために std::weak_ptr を併用する
使い分けまずは unique_ptr を検討し、共有が必要な場合のみ shared_ptr を使う

メモリ管理の自動化は、アプリケーションの堅牢性を高めるための第一歩です。

shared_ptrの特性を正しく理解し、メモリリークのない洗練されたC++コードを目指しましょう。

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

URLをコピーしました!