C++でのメモリ管理において、スマートポインタの導入は革命的な進化でした。
かつての「newしたら必ずdeleteする」という手動管理の時代から解放され、std::shared_ptrなどの登場により、リソースの寿命管理は劇的に安全になりました。
しかし、この便利なスマートポインタにも「最大の落とし穴」が存在します。
それが循環参照です。
循環参照が発生すると、たとえプログラム内でそのオブジェクトを一切使用していなくても、メモリが解放されずに残り続けるメモリリークを引き起こします。
現代のC++開発において、この問題を理解し、回避策であるstd::weak_ptrを正しく使いこなすことは、プロフェッショナルなプログラマとしての必須スキルと言えるでしょう。
本記事では、循環参照のメカニズムから、その具体的な解決策、設計上のベストプラクティスまでを徹底的に解説します。
循環参照とは何か?
循環参照とは、2つ以上のオブジェクトが互いにstd::shared_ptrで参照し合っている状態を指します。
この状態に陥ると、オブジェクトの「参照カウンタ」が永久にゼロにならず、プログラムが終了するまでメモリが解放されないという深刻な問題が発生します。

std::shared_ptrの仕組みと参照カウンタ
循環参照を理解するためには、まずstd::shared_ptrの仕組みを正しく把握する必要があります。
std::shared_ptrは、「参照カウンタ」と呼ばれる数値を管理しています。
これは、現在いくつのstd::shared_ptrがそのオブジェクトを所有しているかを示す数値です。
- 新しい
std::shared_ptrがオブジェクトを指すと、カウンタが1増えます。 std::shared_ptrがスコープを抜けて破棄されるか、別のオブジェクトを指すと、カウンタが1減ります。- カウンタが0になった瞬間、オブジェクトは自動的に破棄(delete)されます。
この「カウンタが0にならない限り消えない」というルールが、循環参照において仇となります。
なぜ循環参照でメモリが漏れるのか
例えば、クラスAがクラスBを持ち、クラスBもまたクラスAを持っているケースを考えましょう。
- AがBを保持する(Bの参照カウンタは1)。
- BがAを保持する(Aの参照カウンタは1)。
- 外部(メイン関数など)からAとBへのポインタを破棄する。
通常ならここでオブジェクトが消えてほしいところですが、AはBに持たれているのでカウンタは1のままであり、BもAに持たれているのでカウンタは1のままです。
どちらも「相手が消えるまで自分は消えない」と主張し合うため、結果として永遠にメモリ上に残り続けてしまいます。
これが循環参照によるメモリリークの正体です。
循環参照が発生するコード例
理論だけではイメージしづらいため、実際に循環参照が発生し、デストラクタが呼ばれない(=メモリが解放されない)コードを確認してみましょう。

メモリリークするプログラムの実装
以下のコードは、双方向のリレーションを持つシンプルな「Node」構造体です。
#include <iostream>
#include <memory>
#include <string>
// 循環参照をデモンストレーションするためのクラス
struct Node {
std::string name;
std::shared_ptr<Node> next; // 次のノードへのポインタ
Node(std::string n) : name(n) {
std::cout << name << " が生成されました。" << std::endl;
}
~Node() {
std::cout << name << " が破棄されました(デストラクタ実行)。" << std::endl;
}
};
void createCircularReference() {
// 2つの共有ポインタを作成
auto nodeA = std::make_shared<Node>("NodeA");
auto nodeB = std::make_shared<Node>("NodeB");
// お互いに参照し合う(循環参照の形成)
nodeA->next = nodeB;
nodeB->next = nodeA;
std::cout << "関数スコープの終了直前です。" << std::endl;
}
int main() {
std::cout << "--- 処理開始 ---" << std::endl;
createCircularReference();
std::cout << "--- 処理終了 ---" << std::endl;
return 0;
}
実行結果の確認
上記のプログラムを実行すると、以下のような出力になります。
--- 処理開始 ---
NodeA が生成されました。
NodeB が生成されました。
関数スコープの終了直前です。
--- 処理終了 ---
注目すべきは、デストラクタのメッセージが表示されていない点です。
本来、createCircularReference関数のスコープを抜けた時点でnodeAとnodeBは破棄されるはずですが、互いに参照し合っているために参照カウンタが0にならず、メモリ上に居座り続けています。
解決策:std::weak_ptrの導入
この膠着状態を打破するために導入されたのが、std::weak_ptrです。
std::weak_ptrは、一言で言えば「所有権を持たないスマートポインタ」です。
オブジェクトを指し示すことはできますが、参照カウンタ(shared count)を増加させないという特殊な性質を持っています。

std::weak_ptrの特徴と役割
std::weak_ptrには以下の主要な特徴があります。
| 特徴 | 詳細 |
|---|---|
| 参照カウンタへの影響 | オブジェクトの生存期間を延ばさない(カウンタを増やさない)。 |
| 安全性 | 指しているオブジェクトが既に破棄されているかをチェックできる。 |
| アクセス方法 | 直接メンバにアクセスできず、lock()を使用してshared_ptrに変換する必要がある。 |
| 用途 | 循環参照の解消、キャッシュの実装、一時的なアクセスの監視など。 |
std::weak_ptrは「オブジェクトが生きているなら使いたいけれど、私が原因でオブジェクトを延命させたくはない」という、いわば「傍観者」のような立場を取ります。
weak_ptrを使った循環参照の回避
それでは、先ほどのコードをstd::weak_ptrを使って修正し、正しくメモリが解放されるようにしてみましょう。

修正後のプログラム実装
#include <iostream>
#include <memory>
#include <string>
struct Node {
std::string name;
// std::shared_ptr<Node> next; // これが原因だった
std::weak_ptr<Node> next; // weak_ptrに変更することで解決!
Node(std::string n) : name(n) {
std::cout << name << " が生成されました。" << std::endl;
}
~Node() {
std::cout << name << " が破棄されました(デストラクタ実行)。" << std::endl;
}
};
void fixCircularReference() {
auto nodeA = std::make_shared<Node>("NodeA");
auto nodeB = std::make_shared<Node>("NodeB");
// 片方、あるいは両方をweak_ptrにすることで循環を断ち切る
nodeA->next = nodeB;
nodeB->next = nodeA;
std::cout << "関数スコープの終了直前です。" << std::endl;
}
int main() {
std::cout << "--- 処理開始 ---" << std::endl;
fixCircularReference();
std::cout << "--- 処理終了 ---" << std::endl;
return 0;
}
実行結果の確認
修正後のプログラムを実行すると、以下のようになります。
--- 処理開始 ---
NodeA が生成されました。
NodeB が生成されました。
関数スコープの終了直前です。
NodeA が破棄されました(デストラクタ実行)。
NodeB が破棄されました(デストラクタ実行)。
--- 処理終了 ---
今度は、関数を抜けた瞬間に両方のオブジェクトのデストラクタが正しく実行されました。
なぜ解決したのでしょうか?
それは、nodeB->nextがstd::weak_ptrになったことで、nodeBからnodeAへの参照がカウントされなくなったからです。
nodeAを指すのは外部の変数(nodeA共有ポインタ)のみとなり、スコープを抜けてその変数が消えた瞬間にnodeAのカウンタが0になり、正常に破棄が始まります。
weak_ptrを使う際の必須テクニック:lock()
std::weak_ptrは所有権を持たないため、オブジェクトが既に破棄されている可能性があります。
そのため、ptr->nameのように直接メンバにアクセスすることはできません。
安全にアクセスするには、lock()というメソッドを使用します。

安全なアクセスの実装例
void safeAccess(std::weak_ptr<Node> weakNode) {
// lock()を呼び出してshared_ptrにアップグレードする
// オブジェクトが既に消えていれば、空のshared_ptrが返る
if (std::shared_ptr<Node> sharedNode = weakNode.lock()) {
// オブジェクトが生存している場合のみここに来る
std::cout << "アクセス成功: " << sharedNode->name << std::endl;
} else {
// オブジェクトが既に破棄されている場合
std::cout << "オブジェクトは既に存在しません。" << std::endl;
}
}
このlock()という仕組みにより、「アクセス中に勝手に消される」というマルチスレッド環境でも起こり得るリスクを回避できます。
lock()が成功して得られたstd::shared_ptrが存在する間は、そのオブジェクトの参照カウンタが一時的に増えるため、処理中にオブジェクトが消える心配がありません。
循環参照を回避するための設計ガイドライン
循環参照は、場当たり的にstd::weak_ptrを差し込むだけで解決するものではありません。
根本的には「オブジェクトの所有権」を明確にする設計が重要です。
1. 所有権の階層構造(親子関係)を作る
最も一般的な回避策は、「親は子を所有し、子は親を参照するだけにする」という設計です。
- 親から子へのポインタ:
std::shared_ptr(所有権あり) - 子から親へのポインタ:
std::weak_ptr(所有権なし、単なる参照)
このように「上から下」への一方通行の所有権にすることで、構造的に循環参照が発生しなくなります。
2. 生ポインタとの使い分け
必ずしもすべての参照をstd::weak_ptrにする必要はありません。
- 生ポインタ(T*)や参照(T&)
オブジェクトの寿命が確実に呼び出し元より長いことが保証されている場合(例:一時的な関数の引数など)は、生ポインタ(
T*)や参照(T&)を使う方がパフォーマンス面で有利です。- std::weak_ptr
「オブジェクトが存在しないかもしれない」という不確実性がある場合にのみ、
std::weak_ptrを使用するのが現代的なC++のプラクティスです。
3. オブザーバーパターンの実装
デザインパターンの1つである「Observerパターン」では、被通知側(Observer)が破棄された後も通知側(Subject)がリストを保持し続け、無効なメモリにアクセスしてしまう「ダングリングポインタ」の問題がよく発生します。
ここでもstd::weak_ptrのリストを持つことで、通知時に生存確認を行い、安全にメッセージを送ることができます。
スマートポインタの種類と使い分けまとめ
最後に、循環参照を未然に防ぐためのスマートポインタの使い分けを表にまとめました。
| ポインタの種類 | 特徴・役割 | 循環参照のリスク |
|---|---|---|
| std::unique_ptr | 独占的な所有権。コピー不可。最も推奨される。 | なし(単一所有のため) |
| std::shared_ptr | 共有の所有権。参照カウンタで寿命管理。 | 高い |
| std::weak_ptr | 所有権を持たない観測者。循環参照の解消用。 | なし(カウンタを増やさない) |
「まずは unique_ptr を検討し、共有が必要な場合のみ shared_ptr を使い、循環が発生する場所で weak_ptr を添える」というのが、メモリリークを起こさないための鉄則です。
まとめ
C++における循環参照は、std::shared_ptrの便利さの裏側に潜む罠です。
互いに「相手が消えるまで待つ」というデッドロックのような状態に陥ると、システムのリソースは徐々に食い潰され、最終的にはクラッシュやパフォーマンス低下を招きます。
しかし、std::weak_ptrという「所有権を持たない参照」を適切に導入することで、この鎖を断ち切ることができます。
設計の段階で「誰が誰を所有しているのか」というオーナーシップの明確化を行い、親から子へはshared_ptr、逆方向や横方向の参照にはweak_ptrを選択するようにしましょう。
現代のC++(C++20/23、そして次代のC++26へ)においても、このメモリ管理の原則は変わりません。
スマートポインタの特性を正しく理解し、安全で堅牢なアプリケーション開発を目指してください。
