C++プログラマへの道 #25 - スマートポインタ

Last Edited: 3/18/2025

このブログ記事では、C++におけるスマートポインタを紹介します。

CPP Smart Pointers

これまで、私たちは動的メモリ管理に生ポインタを使用してきました。その場合、mallocfreeまたはnewdeleteを使って手動でメモリを割り当てて解放する必要があります。 しかし、生ポインタの使用は、特にコードベースが大きく複雑になるにつれて問題を引き起こします。この記事では、それらの問題とスマートポインタがどのようにそれらを解決するかについて説明します。

生ポインタの問題点

最も明白な問題は、単にメモリの解放を忘れることによるメモリリークのリスクです。コードが複雑になると、予想以上に頻繁に発生します。 もう一つの問題は、解放または削除されたオブジェクトへの参照を保持するダングリングポインタです。 これはオブジェクトを解放した後にポインタをNULLに設定することで防ぐことができますが、それを行うことを忘れがちです。

両方の問題は重大であり、予測不可能な動作、クラッシュ、潜在的なセキュリティ脆弱性につながる可能性がありますが、 特にコードベースが膨大な場合、私たち人間がこれらの問題を排除することを忘れないことを保証するのは難しいです。 したがって、C++が文字列を安全に扱うためのSTLライブラリを提供するのと同様に、C++はスマートポインタ(C++11から)を提供しています。 これらはポインタをラップするオブジェクトであり、メモリの解放を自動的に処理します。

ユニークポインタ

ポインタがオブジェクトを破棄するタイミングとメモリを解放するタイミング(通常はスコープ外になったとき)を制御する場合、 そのポインタはスマートであり、オブジェクトの所有権を持っています。生ポインタはそのような制御を持たず、 ユーザーが手動でオブジェクトを破棄する必要があるため、生ポインタには所有権がないと言われています。 ユニークポインタは所有権を持つそのようなスマートポインタの一つであり、2つのユニークポインタインスタンスが同じオブジェクトを管理することを許可しません。

#include <memory> // スマートポインタを内包
 
class Example {
public:
    Example() {
        std::cout << "Created!" << std::endl;
    };
    ~Example() {
        std::cout << "Destroyed!" << std::endl;
    };
    print() {
        std::cout << "Printed!" << std::endl;
    };
};
 
int main() {
    // std::unique_ptr<T>ユニークポインタを作成
    std::unique_ptr<Example> example = std::make_unique<Example>(); // => Created!
    // パラメター有りのコンストラクタを用いる場合 std::make_unique<Example>(Example(params));
    // std::unique_ptr<Example, optional deleter> example(constructor);を使用することも可能
 
    // std::unique_ptr<Example> copy = example => Err!
    std::unique_ptr<Example> copy = std::move(example); // =>所有権を移行
 
    return 0;
    // mainスコープ外になるためcopyがExampleオブジェクトを破棄 => Destroyed!
};

上記のコードは、ユニークポインタの初期化方法とその動作を示しています。上記のように、ユニークポインタはスコープ外になるとオブジェクトを破棄してメモリを解放できるため、 手動でdeleteを実行する必要はありません。また、ユニークポインタは同じオブジェクトを指す複数のポインタを許可しないため、=演算子を使用してユニークポインタをコピーすることはできないことも確認できます。 ユニークポインタの新しいインスタンスを作成する必要がある場合(関数にユニークポインタを渡す場合など)、std::moveを使用してオブジェクトの所有権を新しいユニークポインタに移動させることができます。

void func_err(std::unique_ptr<Example> moved) {
    moved -> print(); // => Printed!
    // movedがスコープ外になり、オブジェクトを破棄
};
 
std::unique_ptr<Example> func(std::unique_ptr<Example> moved) {
    moved -> print(); // => Printed!
    return moved;
};
 
int main() {
    std::unique_ptr<Example> example = std::make_unique<Example>(); // => Created!
    
    std::unique_ptr<Example> moved = func(std::move(example));
    moved -> print(); // Printed!
 
    func_err(std::move(moved));
    moved -> print(); // Err! オブジェクトはすでに破棄済み
    return 0;
};

上記はユニークポインタの独自の動作を示しています。func_err関数内でstd::moveを使用してオブジェクトの所有権を移動させることに成功しましたが、 funcとは異なり、ユニークポインタを返さなかったため、オブジェクトが破棄され、func_errの後にメンバー関数にアクセスするとエラーが発生しました。 ここでは、ユニークポインタを受け渡す複雑さが見られます。getおよびreleaseメソッドを使用して、オブジェクトの所有権を保持したままにするかしないかを選びながら、 ユニークポインタから生ポインタを取得することができます。

しかし、それらを使用するには、getからの生ポインタが変更されないこと(constを使用して強制できます)や、releaseからの生ポインタが手動で解放されることを確認するための追加の考慮が必要です。 いずれにせよ、これはメモリ管理の詳細を抽象化する目的を損なうため、複数のスコープでポインタを使用することが分かっている場合には、 複数のポインタ間でオブジェクトの所有権を共有できる別の種類のスマートポインタを使用することが促されます。

共有ポインタ

共有ポインタは、複数のポインタが同じオブジェクトの所有権を共有することを可能にするスマートポインタの一種で、スマートポインタが複数のスコープに渡される場合にコードを簡素化できます。 このポインタは、同じオブジェクトを指す共有ポインタの数を追跡し、それらがすべてスコープ外になった場合にのみオブジェクトを破棄してメモリを解放します。 以下は共有ポインタの動作を示しています。

void func(std::shared_ptr<Example> input) {
    // 参照カウントが3に上昇
    input -> print(); // => Printed!
    // inputがスコープ外になるため参照カウントが2に減少(オブジェクトは破棄されない
};
 
int main() {
    std::shared_ptr<Example> example = std::make_shared<Example>(); // => Created!
    std::shared_ptr<Example> copied = example; // Allowed! 参照カウントが2に上昇
    func(copied);
    copied -> print(); // オブジェクトがまだ破棄されていないため、アクセス可能
    return 0;
    // exampleとcopiedがどちらもスコープ外になり、参照カウントが0になるため、オブジェクトを破棄
};

ユニークポインタとは異なり、共有ポインタはコピー操作と移動操作の両方を許可するため、関数に共有ポインタをコピーとして渡すことができます。 シンプルさという点では優れていますが、参照カウントの導入によりユニークポインタとは異なり、生ポインタと比較するとオーバーヘッドがあります。 また、複数の共有ポインタがまだオブジェクトを所有している可能性があるため、オブジェクトの所有権を放棄するreleaseメソッドは提供されていません。

(共有ポインタは配列を指すこともできますが、std::shared_ptr<int> example(new int[5], std::default_delete<int[]>)のように明示的に定義されたデリータが必要です。 ここではmake_sharedを使用できず、ユニークポインタとは異なりoperator[]にアクセスできません。)

弱いポインタ

共有ポインタは便利ですが、循環依存がある場合にメモリリークを引き起こす可能性があります。循環依存は、例えば、2つのオブジェクトを指す2つの共有ポインタを作成し、 それらが互いに指し合うように共有ポインタ属性にコピーされる場合に発生します。このような場合、共有ポインタ属性がまだ互いを指し合っていて参照カウントが1のままであるため、 共有ポインタがスコープ外になってもオブジェクトを破棄できません。この問題を回避するために、弱いポインタを使用できます。 弱いポインタは共有ポインタのように機能しますが、オブジェクトの所有権はありません。

class Example {
public:
  // std::shared_ptr<Example> ptrAttribute; これを以下に置き換え
  std::weak_ptr<Example> ptrAttribute;
  ~A(){ std::cout << "Destroyed!" << std::endl; };
};
 
int main() {
    std::shared_ptr<Example> ptrExample1 = std::make_shared<Example>();
    std::shared_ptr<Example> ptrExample2 = std::make_shared<Example>();
 
    ptrExample1 -> ptrAttribute = ptrExample2;
    ptrExample2 -> ptrAttribute = ptrExample1;
 
    return 0;
    // shared_ptrではptrAttributesが所有権を保持したままであるため、メモリリークが発生
    // weeak_ptrでは所有権を持たないため、問題なくオブジェクトが破棄される
}

上記のコードは、循環依存におけるメモリリークを弱いポインタがどのように回避できるかを示しています。 弱いポインタはoperator=または独自のコンストラクタweak_ptr<T> wkptr(sharedPtr)を使用して共有ポインタをコピーできます。 この問題を解決するために生ポインタを使用することを提案する人もいるかもしれませんが、 弱いポインタは生ポインタとは異なり、use_countlockなどのメソッドにアクセスでき、同じオブジェクトを指す共有ポインタの数を判断したり、 同じオブジェクトを指す共有ポインタを取得したりできます。生ポインタはオブジェクトが既に破棄されているかどうかを知る方法がありません(特にマルチスレッドプログラムでは難しい)。

class A {
public:
  ~A(){ std::cout << "A Destroyed!" << std::endl; };
  void print() { std::cout << "Printed!" << std::endl; };
};
 
class B {
public:
  std::weak_ptr<A> ptrA;
  ~B(){ std::cout << "B Destroyed!" << std::endl; };
  void performPrint() {
    if (std::shared_ptr<A> ptr = ptrA.lock()) {
        ptr -> print();
    }
    else {
        std::cout << "A is already destroyed..." << std::endl;
    }
  }
};
 
int main() {
    B b;
    {
        std::shared_ptr<A> ptr = std::make_shared<A>();
        b.ptrA = ptr;
        b.performPrint(); // Printed!
        // A Destroyed! (shared_ptr ptrがスコープを外れる)
    }
    b.performPrint(); // A is already destroyed...
    return 0;
};

上記の例では、弱いポインタとそのメソッドlockを使用して、Aがまだ生存している場合に安全に印刷操作を実行しています。 生ポインタではこれができず、Aが既に破棄されていてもprintにアクセスしようとします(はnullポインタを使用して回避できますが、手動で管理することは共有ポインタを使用する目的を損ないます)。 弱いポインタにはresetメソッドもあり、オブジェクトへの参照をクリアするために使用します。

どのポインタをいつ使うべきか

カスタムクラスオブジェクトの動的メモリ割り当てにはデフォルトでスマートポインタを使用し、関数にはポインタではなく参照渡しを行うことで、 プログラム内での生ポインタやmallocfreenewdeleteの使用を排除できます。共有ポインタはまだある程度の考慮が必要であり、わずかなオーバーヘッドがありますが、 一般的にはスマートポインタを使用する方が安全であり、開発により集中できます。

したがって、生ポインタの使用ケースは、引数として生ポインタを必要とする外部モジュールを使用する必要がある場合や、 スマートポインタの使用によるパフォーマンスのオーバーヘッド(わずかであっても)が許容できない場合に限られます。 これについての明確なルールは定義されていません(これが部分的にC++が批判される理由でもあります)が、 個人的には、上記の理由で生ポインタを絶対に使用する必要がない限り、C++11以降ではほぼ常にスマートポインタを使用し、手動管理をできるだけ避けるでしょう。

結論

この記事では、C++における3つのスマートポインタ、ユニークポインタ、共有ポインタ、弱いポインタとその使用例を紹介しました。 コードベースが大きくなるにつれて手動メモリ管理は指数関数的に複雑になるため、スマートポインタをマスターすることは大規模プロジェクトで作業する上で重要になる可能性があります。 そのため、これらを使用することに慣れていくことをお勧めします。

リソース