C++の基礎 - スマートポインタ(shared ptr)

提供:MochiuWiki - SUSE, Electronic Circuit, PCB
ナビゲーションに移動 検索に移動

概要

C++11では、unique_ptr<T>、shared_ptr<T>、weak_ptr<T>の3種のスマートポインタが追加された。
これらのスマートポインタは、メモリの動的確保の利用の際に生じる多くの危険性を低減する目的で使用されるが、
それぞれ独自の考え方と機能を持っている。

3種のスマートポインタを適切に使い分けることで、安全性と開発速度の向上が見込めるだけでなく、
プログラマの意図に合わせてポインタを記述し分けることができる、非常に強力なツールとなる。

ここでは、スマートポインタについて初めて学ぶ人を対象に、
C++11で追加された3種のスマートポインタの機能と使い方、および3種をどのように考えて使うかについて、初歩的な解説を行う。


shared_ptrとは

メモリの所有権を持つunique_ptr<T>がただ一つに限られていたのに対し、
同一のメモリの所有権を複数で共有できるようにしたスマートポインタが、shared_ptr<T>である。

具体的には、次のような仕組みである。
shared_ptr<T>は、所有権を持つポインタの数を記録するカウンタを持っている。
所有権を持つshared_ptr<T>がコピーされると、内部でカウンタがインクリメントされ、ディストラクタや明示的解放時にデクリメントされる。
全ての所有者がいなくなると、カウンタがゼロとなり、メモリが開放される。
カウンタで所有者数を管理することで、複数のshared_ptr<T>が所有権を保持していても、適切なタイミングで一度だけメモリ解放が実行されるのである。

shared_ptr<T>は、以下のような特徴を持つ。
・メモリの所有権を、複数のshared_ptr<T>で共有することができる。メモリの開放は、全ての所有権を持つ shared_ptr<T>が破棄された際に実行される。
・コピーもムーブも可能。
・内部でカウンタを利用している関係上、やや通常のポインタよりメモリ確保やコピー等の処理が遅い。
・配列を扱うことができる。ただし、明示的にdeleterを指定する必要がある。

下記のサンプルコードのように、 shared_ptr<T>を利用することで、複数のポインタから同一のメモリを安全に利用し、破棄できるようになる。

 #include<memory>
 #include<iostream>
 
 int main()
 {
    // 空のshared_ptrを作成
    std::shared_ptr<int> ptr1;
 
    {
       // int型変数の所有権を持つptr2を作成
       std::shared_ptr<int> ptr2(new int(0));
 
       // ptr2の所有権をptrにコピー、共有する
       ptr1 = ptr2;
 
       *ptr1 += 10;
       *ptr2 += 10;
 
    } // ここでptr2のディストラクタが呼ばれる
      // ptrも同一のメモリに対する所有権を持っているため、まだ解放されない
 
   // ptrはまだ使用可能
   std::cout << "ptr = " << *ptr1 << std::endl;  // ptr = 20と表示
 
 } // ここでptrのデストラクタが呼ばれる
   // メモリの所有権を持つポインタがいなくなったので解放される



shared_ptrの使い方

詳しい使い方を見てみる。
unique_ptr<T>と同様、使用の際には#include <memory>を指定する必要がある。

shared_ptr<T>もunique_ptr<T>と同様にコンストラクタで指定するか、reset関数を使うことでメモリの所有権を委ねる事ができる。
ただ、shared_ptr<T>は所有するメモリだけでなく、自身のカウンタも動的にメモリを確保する必要があるため、
これらのメモリ確保を同時に行えるmake_shared<T>(args...)を使って作成した方が処理効率が良い。可能な限りこちらを使うべきである。

 // コンストラクタやreset関数を使ってのメモリ割り当てが可能
 std::shared_ptr<int> ptr1(new int(10));
 
 std::shared_ptr<int> ptr2;
 ptr2.reset(new int(10));
 
 // make_shared関数を使うと、効率よくメモリを確保できる(C++11以降)
 std::shared_ptr<int> ptr3 = std::make_shared<int>(10);
 
 // 複数の引数を持つコンストラクタもmake_sharedから呼び出せる
 typedef std::pair<int, double> int_double_t;
 std::shared_ptr<int_double_t> ptr4 = std::make_shared<int_double_t>(10, 0.4);


 // shared_ptr<T>は、コピー、ムーブともに使用することができる。
 
 // コピーコンストラクタやコピー代入演算子も可能
 // 所有権は、ptr1、ptr2、ptr3の三者が保持する
 std::shared_ptr<int> ptr1 = std::make_shared<int>(10);
 std::shared_ptr<int> ptr2(ptr1); // ptr1とptr2で所有権を共有
 std::shared_ptr<int> ptr3;
 ptr3 = ptr1;                     // ptr1とptr3で所有権を共有
 
 // ムーブコンストラクタやムーブ代入演算子が可能
 // この時、所有権は移動する
 std::shared_ptr<int> ptr4(std::move(ptr1)); // ptr1の所有権がptr4に移動する
 std::shared_ptr<int> ptr5;
 ptr5 = std::move(ptr2);                     // ptr2の所有権がptr5に移動する


 // unique_ptr<T>からムーブすることも可能。この時、もちろん unique_ptr<T>は所有権を失う。
 
 // コンストラクタでunique_ptrからムーブ
 // 所有権がuptrからptrに移動する
 std::unique_ptr<int> uptr(new int(10));
 std::shared_ptr<int> ptr(std::move(uptr));
 
 // 代入演算子でムーブ
 // 同様に、所有権がuptr2からptr2に移動する
 std::unique_ptr<int> uptr2(new int(10));
 std::shared_ptr<int> ptr2;
 ptr2 = std::move(uptr2);


 // 所有権の放棄はデストラクタやreset関数で行われる
 // ただし、実際にメモリが解放されるのは、そのメモリの所有権を持つポインタが全て破棄された場合である
 
 std::shared_ptr<int> ptr1 = std::make_shared<int>(10);
 {
    // ptrから所有権をコピー
    std::shared_ptr<int> ptr2(ptr);
 } // ここでptr2のデストラクタが呼ばれ、ptr2は所有権を放棄
   // ptr1がまだ所有権を保有しているので、メモリは解放されない
 
 // 引数なしやnullptrを引数としてreset関数を呼んでも、明示的に所有権を放棄できる
 // ptr1が所有権を放棄すると、所有権を持つポインタがなくなるので、ここでメモリ解放
 ptr1.reset();


 // 所有権を実際に保持しているかの判定には、operator bool()を使う
 // 所有権を持つ場合にはtrue、持たない場合にはfalseを返す
 // また、use_count()を使って自身が保持するメモリに所有権を持つポインタの数を、
 // unique()を使って自身が保持するメモリに所有権を持つポインタが唯一(自分だけ)かどうかを調べることができる
 
 std::shared_ptr<int> ptr;
 
 // メモリの所有権を保持しているかどうかは、boolの文脈で使用することで判定できる
 // 所有していれば、trueを返す
 if(ptr)
 {
    // 所有しているときの処理
 }
 
 // bool変数への代入も可能
 bool CanAccess = ptr;
 
 // 所有者の数を確認するには、use_count関数を使う
 std::cout << "use_count = " << ptr.use_count() << std::endl;
 
 // 所有者が唯一であることを確認するには、unique関数を使う
 // use_count() == 1ならtrue, それ以外ならfalseとなる
 if(ptr.unique())
 {
   // 所有者が唯一である場合
   std::cout << "unique" << std::endl; 
 }


 // 生のポインタが欲しいときには、get関数を使う。
 // unique_ptr<T>と違って、複数のポインタが所有権を保持しているので、 所有権のみを放棄するrelease関数は用意されていない。
 
 std::shared_ptr<int> ptr1 = std::make_shared<int>(10);
 
 // 通常のポインタが欲しい時には、get関数を使う
 // ポインタの所有権はshared_ptrが保持し続ける
 int *pint;
 pint = ptr1.get();


 // shared_ptr<T>は、配列を扱うこともできる(shared_ptr<T[]>でない点に注意)
 // ただし、 operator[](size_t)は用意されていない
 // また、deleterを明示的に指定する必要がある。なお、deleterを明示的に指定する際には、make_shared<T>(args...)は使えない
 {
    // []型名をテンプレート引数に指定することで、配列も扱える
    // 第2引数で、配列用にdeleterを指定
    // deleterを明示的に指定する際には、make_sharedは使えない
    std::shared_ptr<int> ptrArray(new int[10], std::default_delete<int[]>());
 
    // operator[]は使えない
    // 代わりに、get関数からアクセスはできる
    for(int i = 0; i < 10; i++)
    {
       // ptrArray[i] = i;    // コンパイルエラー 
       ptrArray.get()[i] = i;
    }
 } // default_delete<int[]>を指定しておけば、自動的にdelete[]が呼ばれて開放される


 // ポインタの保持するメモリにアクセスするには、通常のポインタ同様に operator*()やoperator->()が使用できる
 std::shared_ptr<std::string> pStr = std::make_shared<std::string>("test");
 
 // operator*()でstring型を呼び出せる
 // testと表示される
 std::cout << *pStr << std::endl;        
 
 // operator->()でstring型のsize関数を呼び出せる
 unsigned int StrSize = pStr->size();



shared_ptrの問題点(循環参照)

コピーが禁止されていたunique_ptr<T>と違って、shared_ptr<T>はポインタのコピーができ、しかも安全にメモリも解放される、有用なツールである。
しかし、shared_ptr<T>には、循環参照と呼ばれる厄介な状況が生じうることが知られている。
まず、下記のサンプルコードを見る。

 #include<memory>
 
 class CHoge
 {
    public:
       std::shared_ptr<CHoge> ptr;
 };
 
 int main()
 {
    std::shared_ptr<hoge> pHoge1 = std::make_shared<CHoge>();
    std::shared_ptr<hoge> pHoge2 = std::make_shared<CHoge>();
 
    // pHoge1のメンバ変数で、pHoge2を参照する
    pHoge1->ptr = pHoge2;
 
    // pHoge2のメンバ変数で、pHoge1を参照する
    pHpge2->ptr = pHoge1;
 
    return 0;
 } // shared_ptrのデストラクタが呼ばれるのに、確保した2つのCHogeが解放されない


CHogeを確保した2つのptrは解放されない。
pHoge1、pHoge2が指すメモリに確保されたCHogeを、それぞれHoge1、Hoge2と呼ぶ。
デストラクタが呼ばれる直前、Hoge1はpHoge1とHoge2.ptrが、Hoge2はpHoge2とHoge1.ptrがそれぞれ所有権を持っている。

まず、pHoge1のデストラクタが呼ばれると、Hoge1への所有権を放棄する。
しかし、この時、Hoge2.ptrはHoge1への所有権を保持しているので、Hoge1のデストラクタは呼ばれない。

次に、pHoge2のデストラクタが呼ばれると、Hoge2への所有権を放棄する。
しかし、先ほど同様、Hoge2はHoge1.ptrも所有権を保持しているので、Hoge2のディストラクタは呼ばれない。

結果として、shared_ptrが最初に所有権を委ねられたHoge1、Hoge2は、最後まで解放されないままになってしまう。
このような、相互に参照した状態のことを循環参照と呼ぶ。
循環参照が発生すると、shared_ptr<T>によって安全に管理されているはずのメモリに、メモリリークが発生するのである。