生ポインタ設計レビュー:柔軟さの裏にある責務分離を読み解く

C++レビューの中でも、ある意味「一番空気感を読む必要がある」テーマが生ポインタです。
T* という極めて単純なシンタックスの裏側に、設計者の意図が詰まっています。

この記事ではまず、「良い生ポインタ利用」をじっくり読み解き、その後に「やりがちな失敗」をレビュー観点付きで整理していきます。


技術視点での良い実装例

今回の題材は APIアクセスのログを記録するLoggerクラス にしてみます。
ログの内容は毎回API呼び出しごとに構造体としてまとめられますが、Logger自体はその情報を「読んで記録するだけ」であり、寿命管理や所有は持ちません。
まさに「読み取り専用の生ポインタ」を使うケースですね。

良い設計例
// ログ記録用のデータ構造
struct ApiRequestLog {
    int requestId;
    std::string endpoint;
    std::string clientIp;
    int responseCode;
    time_t requestedAt;
};

// ログ記録クラス
class ApiLogger {
public:
    // 読み取り専用参照として生ポインタを利用
    void logRequest(const ApiRequestLog* logEntry) {
        if (!logEntry) {
            std::cout << "[WARN] Null log entry received" << std::endl;
            return;
        }

        std::cout << "[LOG] RequestId: " << logEntry->requestId << std::endl;
        std::cout << "[LOG] Endpoint: " << logEntry->endpoint << std::endl;
        std::cout << "[LOG] ClientIp: " << logEntry->clientIp << std::endl;
        std::cout << "[LOG] ResponseCode: " << logEntry->responseCode << std::endl;
    }
};

ポイントを整理するとこうなります。

  • 生ポインタは非所有参照用途
  • 破棄責任は呼び出し元に集約
  • ヌル許容は明確に考慮
  • スマートポインタとも競合しない構造

さらに呼び出し側の利用例も見ておきます。

呼び出し例
void processRequest() {
    ApiRequestLog logEntry {
        .requestId = 1001,
        .endpoint = "/api/items",
        .clientIp = "192.168.0.10",
        .responseCode = 200,
        .requestedAt = std::time(nullptr)
    };

    ApiLogger logger;
    logger.logRequest(&logEntry); // 参照を渡すだけ
}

寿命はローカル変数のスコープが管理しており、Loggerは参照して読むだけです。
これが最もシンプルで安全な生ポインタ設計の典型パターンです。

生ポインタをレビューするときは 「なぜこれがスマートポインタではなく生ポインタなのか?」 という設計意図の読み解きがスタート地点になります。


良くない実装①:破棄責任が曖昧な生ポインタ設計

さて、ここからがレビューアーの本領発揮フェーズです。
よくある問題パターンから見ていきます。

問題コード例①

問題例①
class ApiLogger {
public:
    void logRequest(ApiRequestLog* logEntry) {
        if (logEntry == nullptr) {
            std::cout << "[WARN] Null log entry" << std::endl;
            return;
        }

        std::cout << "[LOG] RequestId: " << logEntry->requestId << std::endl;

        delete logEntry;
@Reviewer
呼び出し元が所有権を持つべき箇所でAPI内部がdeleteすると責務が衝突します。破棄責任をAPIから呼び出し側へ移し、API側では破棄処理を削除してください。
} };

指摘ポイント

  • 生ポインタを受け取っておきながらdeleteしてしまっている
  • 呼び出し側とAPI側で 破棄責任の二重管理 になる
  • スマートポインタ活用の道も塞がれてしまう

改善例

修正例①
class ApiLogger {
public:
    void logRequest(const ApiRequestLog* logEntry) {
        if (!logEntry) {
            std::cout << "[WARN] Null log entry" << std::endl;
            return;
        }

        std::cout << "[LOG] RequestId: " << logEntry->requestId << std::endl;
    }
};
  • 読み取り専用に限定し、破棄責任は呼び出し側に戻しています
  • const指定で読み取り専用を明確化

生ポインタ+const+破棄責任は原則「セットで管理」と意識しましょう。


良くない実装②:コピー所有と参照責務の混在

次にやりがちなのが「コピーもするのに参照も残す」という設計崩壊パターンです。

問題コード例②

問題例②
class ApiLogger {
public:
    void logRequest(ApiRequestLog* logEntry) {
        if (!logEntry) return;

        internalLog = *logEntry;   // コピー保持
        savedPointer = logEntry;   // 参照も保存
@Reviewer
コピーと参照を両立させると寿命管理が二重化して危険です。savedPointerは削除し、コピー所有に責任を統一してください。もしくは参照のみ保持する設計に変更してください。
} private: ApiRequestLog internalLog; ApiRequestLog* savedPointer; };

指摘ポイント

  • コピーした時点でオブジェクト所有はLogger側に移っているべき
  • savedPointer保持に意味がなくなる
  • 破棄責任の所在が読み取れなくなる

改善例

修正例②
class ApiLogger {
public:
    void logRequest(const ApiRequestLog& logEntry) {
        internalLog = logEntry;
    }

private:
    ApiRequestLog internalLog;
};
  • 参照受け取り+コピー保持に整理
  • 寿命管理は完全にLoggerクラス内部のみで完結

「参照受け取り → コピー所有に切り替える」パターンは保守性・安全性が高くレビューでも高評価を受けやすい設計です。


レビュー観点チェックリスト(生ポインタ設計専用)

レビュー時は以下の観点で読み解いていきます。

観点 内容
所有権の明示 誰がdelete責任を持つか一貫しているか
スマートポインタ検討 生ポインタ利用が本当に妥当か再確認
const利用 読み取り専用用途ならconst指定が適切か
ヌル許容 nullptr許容が明示的に設計されているか
API契約整合性 呼び出し側との契約が説明可能か
寿命管理の単純化 ライフサイクルが複雑化していないか
テスト容易性 モックやスタブ可能な設計になっているか

あとがき

生ポインタは「柔軟」「軽量」「歴史的資産互換」と、良い面もたくさんあります。
しかしレビューアーが注目すべきは常に「責務の分離と契約の読みやすさ」です。

スマートポインタ全盛の今だからこそ、
「あえて生ポインタにしている理由」 を読み取る目がレビュー品質を決定づけます。

UML Diagram