C++レビュー|unique_ptrとshared_ptrの責務切り分け徹底ガイド
この記事のポイント
- unique_ptrとshared_ptrの役割と設計責務を整理できる
- 責務の線引きが不明瞭なコードをレビューで正しく読み解ける
- スマートポインタ設計の失敗パターンを防止する観点が身につく
そもそも責務切り分けとは何を言っているのか
C++のスマートポインタは「メモリリークを防ぐ道具」として語られることが多いですが、本質は 「責務の明示」 です。
- 誰がそのオブジェクトを所有するのか?
- いつ解放されるべきか?
- 何箇所から所有されうるのか?
この問いに対する答えがコードに埋め込まれているかどうか──
それをレビューアーは確認します。
unique_ptrの責務
- 所有権を一元化する
- 明確に所有権を移譲(move)できる
- 所有者がスコープ終了と共に破棄する
shared_ptrの責務
- 複数の責務が所有権を共有する必要がある時だけ使用
- 参照カウントで寿命を延長する
- 循環参照リスクが発生しうる
責務切り分けレビューとは、「unique_ptrで済むところにshared_ptrを使っていないか?」 を読み解く仕事とも言えます。
なぜこれをレビューするのか
責務境界が曖昧な設計は、以下の設計事故を引き起こします。
- 破棄責任の衝突(ダブルdelete・リーク)
- ユースアフターフリーの発生
- テスト容易性の低下
- 循環参照による不具合検知困難化
スマートポインタは「自動破棄の道具」ではなく、責任を埋め込む言語機能です。
レビューアーはその責任分布を正確に読み解く必要があります。
レビューアー視点
- 常にunique_ptrから設計意図を読む
- shared_ptr使用理由は必ず説明可能かを確認
- API契約上の所有権移譲が明示されているか確認
- 循環参照の封じ込め策(weak_ptr活用)が組み込まれているか確認
開発者視点
- 「とりあえずshared_ptr」はNG
- コンテナ格納・処理フローはunique_ptr主体で設計
- shared_ptrを導入するなら共有責務の理由を文書化
- weak_ptrで生存期間循環を防ぐ設計を怠らない
良い実装例
良い設計例
// ログデータ定義
struct ApiRequestLog {
int requestId;
std::string endpoint;
std::string clientIp;
int responseCode;
time_t requestedAt;
};
// Loggerは読み取りのみの責務
class ApiLogger {
public:
void logRequest(const ApiRequestLog& logEntry) {
std::cout << "[LOG] RequestId: " << logEntry.requestId << std::endl;
}
};
// Controllerは所有責務を一元管理
class RequestController {
public:
void process() {
auto logEntry = std::make_unique<ApiRequestLog>();
logEntry->requestId = 123;
logger.logRequest(*logEntry);
storeLog(std::move(logEntry));
}
private:
void storeLog(std::unique_ptr<ApiRequestLog> entry) {
logs.push_back(std::move(entry));
}
ApiLogger logger;
std::vector<std::unique_ptr<ApiRequestLog>> logs;
};
良いポイント
- 所有権はControllerが一元保持(unique_ptr)
- Loggerは読み取り専用(const参照)
- API契約が読みやすく、破棄責任が明確化
レビュー観点
- unique_ptrを設計の基本単位にしているか
- shared_ptrは正当な理由があって採用されているか
- 所有権移譲APIは明示的か
- 非所有APIはconst参照で統一しているか
- 共有責務発生時は必ずweak_ptrで循環制御しているか
- テスト可能性(mock挿入・ライフサイクル制御)が保たれているか
良くない実装例: ケース1(shared_ptrの乱用)
問題例①
class ApiLogger {
public:
void logRequest(std::shared_ptr<ApiRequestLog> logEntry) {
if (!logEntry) {
std::cout << "[WARN] Null log entry" << std::endl;
return;
}
std::cout << "[LOG] RequestId: " << logEntry->requestId << std::endl;
}
};
class RequestController {
public:
void process() {
auto logEntry = std::make_shared<ApiRequestLog>();
logEntry->requestId = 123;
logger.logRequest(logEntry);
logs.push_back(logEntry);
}
private:
ApiLogger logger;
std::vector<std::shared_ptr<ApiRequestLog>> logs;
};
@Reviewer所有権の共有が不要な場面でshared_ptrを使用しています。責務分離が曖昧になります。Controller内でunique_ptrに統一し、Loggerにはconst参照で渡す構造に修正してください。
問題点
- 共有所有の必要がないのにshared_ptrを使用
- 所有責務の可読性が崩れる
- 将来的に循環参照を誘発しやすくなる
改善例
改善例①
class ApiLogger {
public:
void logRequest(const ApiRequestLog& logEntry) {
std::cout << "[LOG] RequestId: " << logEntry.requestId << std::endl;
}
};
class RequestController {
public:
void process() {
auto logEntry = std::make_unique<ApiRequestLog>();
logEntry->requestId = 123;
logger.logRequest(*logEntry);
storeLog(std::move(logEntry));
}
private:
void storeLog(std::unique_ptr<ApiRequestLog> entry) {
logs.push_back(std::move(entry));
}
ApiLogger logger;
std::vector<std::unique_ptr<ApiRequestLog>> logs;
};
良くない実装例: ケース2(API契約の切り分け不在)
問題例②
class ApiLogger {
public:
void registerLog(std::unique_ptr<ApiRequestLog> logEntry) {
logs.push_back(std::move(logEntry));
}
void backupLogs(std::vector<std::shared_ptr<ApiRequestLog>> backups) {
for (auto& entry : backups) {
logs.push_back(std::move(entry));
}
}
private:
std::vector<std::unique_ptr<ApiRequestLog>> logs;
};
@Reviewer所有権移譲API(unique_ptr)と共有API(shared_ptr)が混在し契約が破綻しています。統一方針に整理してください。全て所有移譲に統一するならbackupLogsもunique_ptr受取に修正してください。
問題点
- 複数APIで所有契約がブレている
- 呼び出し側がどの契約で呼べばよいか混乱
- スマートポインタ設計の破綻
改善例
改善例②
class ApiLogger {
public:
void registerLog(std::unique_ptr<ApiRequestLog> logEntry) {
logs.push_back(std::move(logEntry));
}
private:
std::vector<std::unique_ptr<ApiRequestLog>> logs;
};
観点チェックリスト
まとめ
unique_ptrとshared_ptrの責務切り分けを読み解くのは、
設計レビューの「読解力」そのもの です。
- unique_ptrは責務集中の道具
- shared_ptrは責務分散の道具
レビューアーは
「なぜunique_ptrで済ませなかったのか?」
を常に問う姿勢が重要になります。