閉じる

【C++】shared_ptrの使い方|基本から実装・注意点まで徹底解説

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

手動でのnewdeleteの管理は、メモリリークや二重解放といった深刻なバグを引き起こす原因となりがちです。

しかし、現代のC++(モダンC++)では、スマートポインタの登場により、これらのリスクを劇的に低減できるようになりました。

その中でも特に強力で汎用性の高いツールがstd::shared_ptrです。

本記事では、このshared_ptrの基本から、パフォーマンスを最大化する実装手法、そして陥りやすい落とし穴である循環参照の回避方法まで、現在のベストプラクティスに基づき徹底的に解説します。

現代のC++におけるメモリ管理とshared_ptrの役割

C++プログラミングにおいて、動的に確保したメモリ(ヒープ領域)の寿命を適切に管理することは、アプリケーションの安定性を左右する極めて重要な要素です。

かつてのC++では、プログラマが責任を持ってdeleteを呼び出す必要がありましたが、複雑な分岐や例外処理が加わると、どこで解放すべきかを正確に把握することは困難を極めました。

そこで導入されたのが、RAII(Resource Acquisition Is Initialization)という設計指針です。

これは、リソースの取得をオブジェクトの構築時に行い、リソースの解放をオブジェクトの破棄時に自動的に行う仕組みです。

std::shared_ptrはこのRAIIを実現するための代表的なスマートポインタであり、「所有権の共有」という概念を提供します。

これにより、複数のオブジェクトが同じリソースを指し示している場合でも、最後の一つが消滅したタイミングで自動的にメモリが解放されるようになります。

RAIIとスマートポインタの重要性

現代のC++開発では、生ポインタ(T*)を使用してメモリを直接管理することは原則として避けるべきとされています。

生ポインタは「それがどこを指しているか」を示すだけであり、「誰がそのメモリを所有し、いつ解放すべきか」という情報を持っていないからです。

一方、shared_ptrをはじめとするスマートポインタは、メモリの所有権に関する明示的な意思表示をコードに持たせることができます。

これにより、開発者はメモリ管理という低レイヤーの作業から解放され、より本質的なロジックの実装に集中できるようになるのです。

shared_ptrの基本概念と仕組み

std::shared_ptrがどのようにして複数の場所から共有され、適切にメモリを解放するのか、その内部構造を理解することは非常に重要です。

std::shared_ptrは、実際には「対象オブジェクトへのポインタ」「コントロールブロックへのポインタ」という2つのポインタを保持する構造体のような形をしています。

このコントロールブロックこそが、shared_ptrの知能の源です。

リファレンスカウンタ(参照カウンタ)の動作

コントロールブロック内には、そのオブジェクトを現在何個のshared_ptrが指しているかを記録するリファレンスカウンタが存在します。

  1. 新しいshared_ptrが作成され、オブジェクトを所有するとカウントが1になります。
  2. 別のshared_ptrにコピーされると、カウントがインクリメント(増加)されます。
  3. shared_ptrのデストラクタが呼ばれる(スコープを抜けるなど)と、カウントがデクリメント(減少)されます。
  4. カウントが0になった瞬間、管理していたオブジェクトが破棄され、メモリが解放されます。

この仕組みにより、開発者は「今このオブジェクトを使っている人が他にいるか?」を気にする必要がなくなります。

コントロールブロックに含まれる情報

コントロールブロックには、リファレンスカウンタ以外にも重要な情報が含まれています。

項目内容
リファレンスカウンタオブジェクトを共有しているshared_ptrの数。0になるとオブジェクトが削除される。
ウィークカウンタオブジェクトを監視しているweak_ptrの数。0になるとコントロールブロック自体が削除される。
カスタムデリータオブジェクト破棄時に実行する特別な処理(関数オブジェクトなど)。
アロケータコントロールブロック自体のメモリ確保に使用されたアロケータ。

このように、shared_ptrは単なるポインタ以上の情報を管理しているため、生ポインタに比べてわずかなメモリと計算のオーバーヘッドが存在します。

しかし、それによって得られる安全性と利便性は、多くの場合でそのコストを大きく上回ります。

shared_ptrの基本的な使い方

まずは、shared_ptrの宣言、初期化、そして基本的な操作方法について見ていきましょう。

インスタンスの生成と初期化

shared_ptrを利用するには、ヘッダファイル<memory>をインクルードする必要があります。

インスタンスを生成する最も推奨される方法は、std::make_shared関数を使用することです。

C++
#include <iostream>
#include <memory> // shared_ptrを使用するために必要
#include <string>

class User {
public:
    User(std::string name) : name_(name) {
        std::cout << name_ << " が生成されました。" << std::endl;
    }
    ~User() {
        std::cout << name_ << " が破棄されました。" << std::endl;
    }
    void greet() const {
        std::cout << "こんにちは、" << name_ << " です。" << std::endl;
    }

private:
    std::string name_;
};

int main() {
    // std::make_sharedを使用してインスタンスを生成
    // User型のオブジェクトを管理するshared_ptrを作成
    std::shared_ptr<User> user1 = std::make_shared<User>("Alice");

    // メンバへのアクセス (生ポインタと同様の構文)
    user1->greet();

    // 参照カウントの確認
    std::cout << "現在の参照カウント: " << user1.use_count() << std::endl;

    {
        // 別のshared_ptrにコピー
        std::shared_ptr<User> user2 = user1;
        std::cout << "コピー後の参照カウント: " << user1.use_count() << std::endl;
        user2->greet();
    } // user2のスコープが終了。参照カウントが1減る。

    std::cout << "user2スコープ終了後の参照カウント: " << user1.use_count() << std::endl;

    return 0;
} // main終了時にuser1も破棄され、User("Alice")が自動的にデリートされる
実行結果
Alice が生成されました。
こんにちは、Alice です。
現在の参照カウント: 1
コピー後の参照カウント: 2
こんにちは、Alice です。
user2スコープ終了後の参照カウント: 1
Alice が破棄されました。

メンバアクセスと値の取得

shared_ptrは、演算子のオーバーロードにより生ポインタとほぼ同じ感覚で使用できます。

  • -> 演算子: 管理しているオブジェクトのメンバにアクセスします。
  • * 演算子: 管理しているオブジェクトの実体(参照)を取得します。
  • get() メソッド: 管理しているオブジェクトの生ポインタを返します。これは、スマートポインタに対応していない外部ライブラリの関数にポインタを渡す際などに使用します。

注意点として、get()で取得した生ポインタに対して手動でdeleteを行ってはいけません。

管理権限はあくまでshared_ptr側にあるため、二重解放の原因となります。

インスタンス生成におけるmake_sharedの重要性

先ほどの例でstd::make_sharedを使用しましたが、これには明確な理由があります。

実は、new演算子を使ってshared_ptrを初期化することも可能ですが、それは避けるべきパターンとされています。

C++
// 非推奨な方法
std::shared_ptr<User> user(new User("Bob"));

// 推奨される方法
auto user = std::make_shared<User>("Bob");

なぜmake_sharedが推奨されるのか、その理由は主に2つあります。

1. パフォーマンスの最適化

shared_ptrは、「オブジェクト本体」と「コントロールブロック」の2つを管理する必要があります。

newを使用した場合、オブジェクトの生成で1回、コントロールブロックの生成で1回、合計2回のメモリ確保(アロケーション)が発生します。

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

これにより、メモリ確保の回数が半分になり、キャッシュ効率も向上するため、実行速度が改善されます。

2. 例外安全性

複雑な関数の引数などでスマートポインタを生成する場合、newを使用すると、例外が発生した際にメモリリークを起こすリスクがあります(C++17以降では一部改善されていますが、依然としてmake_sharedの方が安全です)。

make_sharedは、オブジェクトの構築とshared_ptrへの代入がアトミック(不可分)に行われるため、安全性が高まります。

shared_ptrの所有権の共有と寿命

shared_ptrの真骨頂は、複数のオブジェクト間でリソースを共有できる点にあります。

関数への渡し方

shared_ptrを関数の引数として渡す場合、その目的によって最適な渡し方が異なります。

値渡しによる所有権の共有

関数内でそのオブジェクトを保持し続ける必要がある場合は、値渡しを行います。

これにより、関数内で参照カウントが増加し、関数の実行中にオブジェクトが消滅することを防げます。

C++
void processUser(std::shared_ptr<User> u) {
    // 関数内でコピーを受け取るため、参照カウントが増える
    u->greet();
}

参照渡しによる一時的な利用

関数内でオブジェクトを利用するだけで、所有権を共有する必要がない場合は、const参照渡しが適しています。

参照カウントの増減が発生しないため、オーバーヘッドを抑えることができます。

C++
void viewUser(const std::shared_ptr<User>& u) {
    // 参照カウントは増えない
    u->greet();
}

reset()による所有権の放棄

shared_ptrが指している対象を途中で変更したり、明示的に手放したりしたい場合はreset()を使用します。

C++
auto p = std::make_shared<int>(10);
p.reset(); // 参照カウントが減り、0になればメモリ解放。pはnullptrになる。

p.reset(new int(20)); // 新しいポインタで再設定(※make_sharedの方が良いが、リセット時はこの形も可能)

カスタムデリータによる柔軟なリソース管理

shared_ptrは、通常のメモリ解放(delete)以外の後処理が必要なリソースの管理にも利用できます。

例えば、ファイルのクローズや、特定のAPIの終了処理などです。

これを実現するのがカスタムデリータです。

C++
#include <iostream>
#include <memory>
#include <cstdio> // FILE用

int main() {
    // ファイルポインタを管理するshared_ptr
    // 第2引数に、破棄時に呼ばれる関数(ラムダ式)を指定
    std::shared_ptr<FILE> filePtr(
        std::fopen("test.txt", "w"), 
        [](FILE* f) {
            if (f) {
                std::cout << "ファイルを閉じます。" << std::endl;
                std::fclose(f);
            }
        }
    );

    if (filePtr) {
        std::fputs("Hello, shared_ptr!", filePtr.get());
        std::cout << "ファイルに書き込みました。" << std::endl;
    }

    return 0;
} // スコープを抜ける際、カスタムデリータが呼ばれ、自動的にfcloseされる
実行結果
ファイルに書き込みました。
ファイルを閉じます。

このように、メモリ以外のリソース管理にもshared_ptrを応用することで、リソースの解放漏れを完全に防ぐことが可能になります。

なお、std::make_sharedではカスタムデリータを指定できないため、この場合はコンストラクタを使用する必要があります。

循環参照とその解決策 (weak_ptr)

shared_ptrを利用する上で、最も注意しなければならないのが循環参照(Circular Reference)という現象です。

これは、2つのオブジェクトがお互いをshared_ptrで指し合ってしまうことで、参照カウントが永遠に0にならず、メモリリークを引き起こす問題です。

循環参照の具体例

以下のコードは、典型的な循環参照の例です。

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

struct Node {
    std::shared_ptr<Node> neighbor; // 他のNodeを指す
    ~Node() { std::cout << "Node破棄" << std::endl; }
};

int main() {
    auto nodeA = std::make_shared<Node>();
    auto nodeB = std::make_shared<Node>();

    nodeA->neighbor = nodeB; // AがBを所有
    nodeB->neighbor = nodeA; // BがAを所有(循環参照!)

    return 0;
} // nodeA, nodeBがスコープを抜けても、お互いの参照によりカウントが1残り、破棄されない

このプログラムを実行しても、「Node破棄」というメッセージは表示されません。

つまり、メモリリークが発生しています。

weak_ptrによる解決

この問題を解決するために用意されているのがstd::weak_ptrです。

weak_ptrは「オブジェクトを監視するが、所有権は持たない」ポインタです。

参照カウントを増やさないため、循環を断ち切ることができます。

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

struct Node {
    std::weak_ptr<Node> neighbor; // shared_ptrではなくweak_ptrを使用
    ~Node() { std::cout << "Node破棄" << std::endl; }
};

int main() {
    auto nodeA = std::make_shared<Node>();
    auto nodeB = std::make_shared<Node>();

    nodeA->neighbor = nodeB;
    nodeB->neighbor = nodeA; // weak_ptrなので、参照カウントは増えない

    // weak_ptrを使用する際は、lock()を呼んでshared_ptrに変換してアクセスする
    if (auto sharedNeighbor = nodeA->neighbor.lock()) {
        std::cout << "NodeAの隣人にアクセス成功" << std::endl;
    }

    return 0;
} // 正常に破棄される
実行結果
NodeAの隣人にアクセス成功
Node破棄
Node破棄

weak_ptrを使用する際は、対象のオブジェクトが既に破棄されている可能性があるため、必ずlock()メソッドを使用して一時的にshared_ptrに変換し、有効性を確認してからアクセスするという手順を踏みます。

スレッドセーフティとshared_ptr

マルチスレッド環境におけるshared_ptrの挙動についても触れておく必要があります。

shared_ptrコントロールブロック(参照カウント操作)はスレッドセーフです。

複数のスレッドから同時に同じオブジェクトを指すshared_ptrがコピーされたり破棄されたりしても、参照カウントは正しく管理されます。

しかし、管理しているオブジェクトそのものへのアクセスはスレッドセーフではありません。

複数のスレッドから同一のオブジェクトのメンバを読み書きする場合は、別途ミューテックス(std::mutex)などによる同期が必要です。

また、C++20からは、shared_ptrそのものを複数のスレッドで共有して書き換えるためのstd::atomic<std::shared_ptr<T>>が導入されました。

これにより、より高度な並行プログラミングが可能になっています。

unique_ptrとの使い分け基準

C++にはもう一つの主要なスマートポインタであるstd::unique_ptrが存在します。

どちらを使うべきか迷った際は、以下の基準を参考にしてください。

特徴std::unique_ptrstd::shared_ptr
所有権独占的(唯一の所有者)共有(複数の所有者)
コピー不可(ムーブのみ可能)可能
オーバーヘッドほぼゼロ(生ポインタと同等)カウント管理のコストあり
デフォルトの選択まずはこちらを検討共有が必要な場合のみ

プログラミングの原則として、「所有権は可能な限りシンプルに保つ」べきです。

そのため、一箇所でしか管理しないのであればunique_ptrを使い、どうしても複数の場所に寿命を委ねる必要がある場合に限ってshared_ptrを選択するのが、現代的なC++の設計指針です。

実践的な活用シーンと注意点

最後に、shared_ptrを実務で使用する際のアドバイスをいくつか紹介します。

enable_shared_from_thisの活用

クラスの内部(メンバ関数)で、自分自身を指すshared_ptrを他者に渡したい場合があります。

このとき、単純にthisからshared_ptrを作ると、二重管理になってしまい、クラッシュの原因となります。

これを安全に行うにはstd::enable_shared_from_thisを継承します。

C++
class MyClass : public std::enable_shared_from_this<MyClass> {
public:
    void registerToManager() {
        // 安全に自分自身のshared_ptrを取得
        auto self = shared_from_this();
        // manager->add(self); などの処理
    }
};

配列の管理 (C++17/20)

かつてのshared_ptrは配列の管理が苦手でしたが、C++17以降、shared_ptr<T[]>という記法がサポートされ、配列に対しても適切にdelete[]が呼ばれるようになりました。

さらにC++20からはstd::make_shared<T[]>(size)も利用可能になり、配列管理もスマートポインタで完結できるようになっています。

パフォーマンスへの配慮

shared_ptrは非常に便利ですが、参照カウントの操作にはアトミック演算が使われるため、極端にパフォーマンスが要求されるループ内などで頻繁にコピーを繰り返すと、速度低下を招く恐れがあります。

不必要なコピーを避け、const参照渡しを適切に活用することが、高速なプログラムを書くコツです。

まとめ

std::shared_ptrは、C++におけるメモリ管理の負担を劇的に軽減し、プログラムの堅牢性を高める強力なツールです。

  • make_sharedを使用して、効率的かつ安全にインスタンスを生成する。
  • 参照カウントの仕組みにより、リソースの寿命が自動管理されることを理解する。
  • 循環参照の罠を避け、weak_ptrを適切に組み合わせて使用する。
  • 基本はunique_ptrを使い、真に所有権の共有が必要な場面でshared_ptrを選択する。

これらのポイントを押さえることで、メモリリークのない、メンテナンス性の高いモダンなC++コードを記述できるようになります。

スマートポインタを正しく使いこなし、安全で効率的なソフトウェア開発を目指しましょう。

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

URLをコピーしました!