閉じる

【C++】make_sharedとnewの違いを解説!メリット・デメリットと使い分け

C++におけるメモリ管理の歴史は、生ポインタの時代からスマートポインタの普及によって劇的な進化を遂げました。

その中でも、リソースの所有権を共有するstd::shared_ptrは最も頻繁に利用されるクラスの一つです。

しかし、このstd::shared_ptrを生成する際には、古くからあるnew演算子を直接使う方法と、C++11で導入されたstd::make_sharedを使う方法の2種類が存在します。

「どちらを使っても結果は同じではないのか」と思われがちですが、実はパフォーマンス、例外安全性、メモリレイアウトにおいて決定的な違いがあります。

現在のモダンなC++開発においては、原則としてstd::make_sharedが推奨されますが、特定の状況下ではnewを選択すべきケースも存在します。

本記事では、これら2つの生成手法を徹底的に比較し、エンジニアが現場で迷うことのないよう詳細に解説します。

std::shared_ptr生成の基本手法

まずは、それぞれの記述方法を確認しましょう。

std::shared_ptrは、動的に確保されたオブジェクトの寿命を「参照カウンタ」によって管理するスマートポインタです。

new演算子を使用した生成

伝統的な方法は、new演算子で確保したメモリのポインタをstd::shared_ptrのコンストラクタに渡す手法です。

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

class MyClass {
public:
    MyClass(int val) : value(val) {
        std::cout << "Constructor called: " << value << std::endl;
    }
    ~MyClass() {
        std::cout << "Destructor called: " << value << std::endl;
    }
private:
    int value;
};

int main() {
    // new演算子でインスタンス化し、スマートポインタに渡す
    std::shared_ptr<MyClass> ptr(new MyClass(10));

    return 0;
}

このコードでは、まずnew MyClass(10)が実行され、ヒープ領域にオブジェクトが作成されます。

その後、そのアドレスがstd::shared_ptrに渡されます。

std::make_sharedを使用した生成

次に、推奨されるstd::make_sharedを利用した記述です。

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

int main() {
    // std::make_sharedを使用してインスタンス化
    // テンプレート引数に型、関数引数にコンストラクタの引数を渡す
    auto ptr = std::make_shared<MyClass>(20);

    return 0;
}

std::make_sharedを使うと、newというキーワードをコードから排除でき、より簡潔に記述できます。

また、autoキーワードとの相性も良く、型名を二度書く手間(冗長性)を省くことができます。

決定的な違い:メモリレイアウトとアロケーション回数

これら2つの手法の最大の違いは、メモリの確保のされ方にあります。

コントロールブロックの存在

std::shared_ptrは、内部で「コントロールブロック」と呼ばれる管理領域を持っています。

ここには、以下の情報が含まれています。

  • 現在の所有者数(参照カウンタ)
  • 弱参照(weak_ptr)の数(弱参照カウンタ)
  • カスタムデリータ(指定されている場合)
  • アロケータ情報

newを使用する場合、まずnewによって「オブジェクトのメモリ」が確保され、その後にstd::shared_ptrの内部で「コントロールブロックのメモリ」が別途確保されます。

つまり、合計で2回のヒープアロケーションが発生します。

一方で、std::make_sharedは、オブジェクト本体とコントロールブロックを1つの大きなメモリブロックとして同時に確保します。

アロケーション回数は1回で済みます。

パフォーマンスへの影響

アロケーション回数の減少は、単に速度が向上するだけでなく、キャッシュ効率にも寄与します。

アロケーションコストの削減

ヒープからのメモリ確保はコストの高い操作です。

回数が半分になることは、ループ内などで大量のオブジェクトを生成する際に大きな差となります。

参照局所性の向上

オブジェクトと管理データがメモリ上で隣接しているため、CPUキャッシュに乗りやすくなります

メモリ断片化の抑制

小さなブロックをバラバラに確保するよりも、まとまったブロックを確保する方がメモリの管理効率が向上します。

例外安全性におけるメリット

std::make_sharedが推奨されるもう一つの大きな理由は、例外安全性(Exception Safety)です。

複雑な引数評価時のリスク

C++17より前の仕様、および特定の条件下では、関数の引数の評価順序は厳密に規定されていませんでした。

例えば、以下のような関数呼び出しを考えてみましょう。

C++
void process(std::shared_ptr<MyClass> p1, int other_value);

// 呼び出し側
process(std::shared_ptr<MyClass>(new MyClass(10)), get_data());

このとき、コンパイラは以下の順序で処理を行う可能性があります。

  1. new MyClass(10)を実行し、メモリを確保。
  2. get_data()を実行。
  3. 確保したポインタをstd::shared_ptrのコンストラクタに渡す。

もし、get_data()の中で例外が投げられた場合、1で確保されたメモリを解放するポインタがまだどこにも保持されていないため、メモリリークが発生します。

make_sharedによる解決

std::make_sharedを使用した場合、オブジェクトの構築とスマートポインタへの格納が単一の関数呼び出し内で行われるため、途中で他の処理が割り込む余地がなく、例外が発生しても正しくメモリが管理されます。

C++
// 安全な記述
process(std::make_shared<MyClass>(10), get_data());

現在(C++17以降)では引数の評価順序に関する規定が厳格化され、この特定の問題は発生しにくくなっていますが、「常に安全なパターンを選択する」という観点から、依然としてstd::make_sharedの使用がベストプラクティスとされています。

std::make_sharedを使えない・使うべきでないケース

ここまではstd::make_sharedのメリットを述べてきましたが、万能ではありません。

特定の状況ではnewを選択しなければならない、あるいはnewの方が有利な場合があります。

1. カスタムデリータが必要な場合

std::shared_ptrには、オブジェクトが破棄される際の挙動をカスタマイズする「カスタムデリータ」を指定できます。

しかし、std::make_sharedのインターフェースはこれを受け付けていません。

C++
// ファイルポインタをshared_ptrで管理し、fcloseで閉じたい場合
// make_sharedではデリータを指定できないため、new(または直接ポインタを渡すコンストラクタ)を使う
std::shared_ptr<FILE> file_ptr(fopen("test.txt", "r"), [](FILE* f) {
    if (f) {
        std::fclose(f);
        std::cout << "File closed." << std::endl;
    }
});

特定のリソース管理(ソケットのクローズ、Win32 APIのハンドル解放など)を行う場合は、コンストラクタ経由での生成が必須となります。

2. コンストラクタが非公開(private)な場合

std::make_sharedは内部でnewを実行するため、対象クラスのコンストラクタにアクセスできる必要があります。

そのため、コンストラクタがprivateやprotectedに設定されている「Factoryパターン」や「Singletonパターン」の実装内では、そのままでは利用できません。

C++
class PrivateClass {
private:
    PrivateClass() {} // プライベートコンストラクタ
public:
    static std::shared_ptr<PrivateClass> create() {
        // return std::make_shared<PrivateClass>(); // コンパイルエラー!
        return std::shared_ptr<PrivateClass>(new PrivateClass()); // OK
    }
};

3. メモリの「完全な解放」を早めたい場合(weak_ptrの存在)

これは非常に高度で重要なポイントです。

std::make_sharedを使うと、オブジェクトとコントロールブロックが同じメモリ領域に配置されます。

このため、参照カウンタが0になりオブジェクトのデストラクタが呼ばれたとしても、「weak_ptr」が一つでも残っている限り、メモリブロック全体が解放されません。

オブジェクトが非常に巨大(例:数メガバイトの配列を持つクラス)で、かつstd::weak_ptrがその寿命より長く存在する可能性がある場合、std::make_sharedを使うと「デストラクタは呼ばれているのに、巨大なメモリ領域が占有され続ける」という現象が発生します。

このようなメモリ効率が極めて重要なケースでは、newを使用してオブジェクトとコントロールブロックを個別に管理させる方が賢明です。

C++標準の進化と配列のサポート

C++17以前では、配列をstd::shared_ptrで扱う際にもnewが必要でしたが、C++20以降で状況が変わりました。

C++20:配列対応のmake_shared

C++20からは、配列に対してもstd::make_sharedが使えるようになり、安全かつ効率的に配列を管理できるようになりました。

C++
// C++20での配列生成
auto arr = std::make_shared<int[]>(10); // 要素数10のint配列を確保
arr[0] = 100;

これにより、配列管理においてnew[]を直接書く必要性はほぼなくなりました。

現在のプロジェクトであれば、配列もmake_sharedに寄せるのが一般的です。

比較表:make_shared vs new

これまでの内容を表にまとめました。

特徴std::make_sharedshared_ptr(new T)
アロケーション回数1回(効率的)2回
実行速度高速(キャッシュ効率が良い)普通
例外安全性非常に高い(安全)記述によってはリスクあり
コードの簡潔さ簡潔(autoと相性が良い)やや冗長
カスタムデリータ不可可能
privateコンストラクタ不可可能
メモリ解放の細かさweak_ptrがあると解放が遅れるオブジェクトは即座に解放される

実践的な使い分けガイドライン

現代のモダンC++開発における推奨フローを以下に示します。

基本戦略:make_sharedをデフォルトにする

コードの可読性、保守性、そしてパフォーマンスの観点から、基本的には常に std::make_shared を使用してください。

「newを書いたら負け」と言われることもあるほど、現代のC++では生のアロケーションを隠蔽することが推奨されています。

new(コンストラクタ)を選択する判断基準

以下の条件に当てはまる場合のみ、newを使用したコンストラクタ呼び出しを検討してください。

独自のメモリ解放処理が必要な時

特定のAPIで確保したリソースをラップする場合など、カスタムデリータが必要なケース。

クラス設計上の制約がある時

コンストラクタが隠蔽されており、クラス内部の特定のメンバ関数からしか生成できないケース。

メモリフットプリントを極限まで絞る時

非常に巨大なオブジェクトを管理し、かつstd::weak_ptrを多用する設計において、オブジェクトのメモリだけでも早急にOSに返したいケース。

具体的なサンプルコード:性能と安全性の両立

最後に、これまでの知識を統合した実践的なコード例を紹介します。

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

class Resource {
   public:
    Resource() : name_("Default") {
        std::cout << "Default acquired." << std::endl;
    }

    Resource(const std::string& name) : name_(name) {
        std::cout << name_ << " acquired." << std::endl;
    }
    ~Resource() {
        std::cout << name_ << " released." << std::endl;
    }
    void do_something() {
        std::cout << name_ << " is working." << std::endl;
    }

   private:
    std::string name_;
};

int main() {
    // 1. 基本:make_sharedを使用(推奨)
    auto res1 = std::make_shared<Resource>("StandardResource");
    res1->do_something();

    // 2. 配列の管理(C++20以降)
    auto res_array = std::make_shared<Resource[]>(2, Resource("ArrayElement"));
    // ※注意:実際のC++20 make_shared<T[]>
    // は要素ごとに同じ引数を渡すことは難しいため、
    // 配列の場合はデフォルトコンストラクタが呼ばれる形式が一般的です。

    // 3. 例外安全を考慮したコンテナへの格納
    std::vector<std::shared_ptr<Resource>> resources;
    try {
        // emplace_backとmake_sharedの組み合わせは非常に強力
        resources.push_back(std::make_shared<Resource>("VectorResource"));
    } catch (...) {
        // 例外が発生してもリークしない
    }

    // 4. カスタムデリータが必要な場合のみnew形式
    std::shared_ptr<Resource> custom_res(
        new Resource("CustomDeleterResource"), [](Resource* r) {
            std::cout << "Special cleanup for: ";
            delete r;
        });

    return 0;
}
実行結果
StandardResource acquired.
StandardResource is working.
VectorResource acquired.
CustomDeleterResource acquired.
Special cleanup for: CustomDeleterResource released.
VectorResource released.
StandardResource released.

このコードでは、大半のケースでstd::make_sharedを活用しつつ、特殊な破棄処理が必要な場合のみnewを使い分けています。

これが、現代的なC++プログラマが取るべきスタイルです。

まとめ

std::make_sharednew(コンストラクタ)の違いは、単なる書き方の好みの問題ではありません。

その裏側には、「メモリ確保回数の最適化」と「例外安全性の確保」という、堅牢なシステム構築に不可欠な要素が隠されています。

原則としてstd::make_sharedを第一選択とし、実行効率を最大限に高めましょう。

ただし、「カスタムデリータ」「非公開コンストラクタ」「weak_ptrによるメモリ保持問題」という3つの例外的な状況においては、newを用いた生成が必要になることを覚えておいてください。

現在の開発環境においては、言語仕様の進歩によりmake_sharedの制約も緩和されつつあります。

常に最新の規格を意識しながら、これらのツールを適切に使い分けることで、バグが少なくパフォーマンスの高いC++コードを記述することができるでしょう。

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

URLをコピーしました!