LoginSignup
244
211

More than 1 year has passed since last update.

C++20スマートポインタ入門

Last updated at Posted at 2022-06-22

概要

本記事は、C++11スマートポインタ入門の内容を全体的に見直し、C++20時点での規格に合わせて情報を加筆したものです。C++20までの変更点を知りたい方は、色付き部分のみ追いかけていただけると幸いです。

皆さんは、メモリの動的確保に関連したトラブルに遭遇したことはないでしょうか? nullptrで初期化するのを忘れていた、気がつけばメモリが確保されていない、うっかりdeleteし忘れてメモリリークを起こした・・・etc... これらはいずれも、new/deleteを使って動的メモリを手動管理していることに起因するトラブルです。

実は、最近のC++では、メモリの動的確保に際して、new/deleteを書く必要はほとんどありません。 多くの場合、スマートポインタがそれらの作業を代替してくれるからです。

この記事では、初めてスマートポインタについて学ぶ人を対象に、C++に用意されている3種のスマートポインタの機能と使い方、およびそれらの使い分けについて解説します。

スマートポインタって?

動的メモリは危険がいっぱい

C++の教科書では、決まってメモリの動的確保には new deleteを使うとあります。が、これらをミスなく使いこなすのは少々厄介です。

例えば、引数lenの長さのint型配列を確保して使用する関数を考えてみます。以下のコード、問題山積みなんですが、何がやばいかわかりますか?

void func(int len){
   int* ptr;

   //lenが0より大きいとき、その長さのメモリを確保
   if(len > 0){
      ptr = new int[len];
   }

   //ptrを使って計算
   for(int i = 0; i<len; ++i){
      ptr[i] = i*i;
   }
}

そうです、deleteし忘れているので、メモリの解放忘れ=「メモリリーク」が起きています。

では、deleteを追記すればOKでしょうか?

void func(int len){
   int* ptr;

   //lenが0より大きいとき、その長さのメモリを確保
   if(len > 0){
      ptr = new int[len];
   }

   //ptrを使って計算
   for(int i = 0; i<len; ++i){
      ptr[i] = i*i;
   }
+
+   //メモリを解放
+   delete[] ptr; // <--- new!!
}

C++では、newしていないポインタやすでに解放したメモリに対するdeleteは、未定義動作を引き起こします。lenが0以下の時、ptrはそもそもnewしたメモリを保持していないので、deleteを実行すると一発アウトです。

ではでは、ptrの中身に応じてdeleteするか条件分岐をつければいいでしょうか?

void func(int len){
   int* ptr;

   //lenが0より大きいとき、その長さのメモリを確保
   if(len > 0){
      ptr = new int[len];
   }

   //ptrを使って計算
   for(int i = 0; i<len; ++i){
      ptr[i] = i*i;
   }

   //メモリを解放
+   if(ptr){
       delete[] ptr;
+   }
}

このコード、そもそもptrが初期化されておらず、何が入っているかわからない状況です。つまり、lenが0以下でも、たまたまif(ptr)がtrueとなることで、newされていないptrに対してdelete[]が実行されてしまう可能性が残っています。こうした問題を避けるため、本来はint* ptr = nullptr;と明示的に初期化すべきです。

上記の例はシンプルな関数なので誤りを見つけやすいですが、メモリの確保から解放までの間に多くの処理が入るとどうでしょう? forifの複雑な組み合わせやオブジェクト間でのメモリのやり取りの中で動的メモリを管理しなければいけないとしたら?

構造が複雑化するほど、メモリの動的確保を完璧に管理するのは困難になります。タチの悪いことに、これらメモリが関わるバグはコンパイル時に検出するのが難しいことが多く、「実行中たまにプログラムが落ちる」というような極めて原因究明が面倒な事態にもなりかねません。

スマートポインタとは?

上で書いたトラブルは、確保した動的メモリを管理する『責任者』がいないことが原因と言えます。「今メモリを保持しているか」を常に管理し、「必要無くなればメモリを解放」してくれるような動的メモリの『責任者』がいれば、こんなトラブルは起きないはずです。

実は、この動的メモリを管理する『責任者』の役割を果たすクラスこそがスマートポインタです。

スマートポインタでは、メモリの所有権という概念でメモリ管理を行なっています。簡単に言ってしまうと、所有権を持つ =「そのメモリにアクセスする権利と、解放する義務」です。所有権は他のスマートポインタに渡したり、共同所有したりすることはできますが、所有権が消えてなくなることはありません。そして、自身が破棄される時、すなわちディストラクタが呼ばれる際に、もし自分が保有するメモリの唯一の所有者なら、そのメモリを解放します。つまり、確保したメモリ毎に所有権を設定して責任者を決めることで、勝手にメモリが解放されるのを防ぎ、同時にメモリの解放忘れや不適切なメモリの解放を防いでいるわけです。

もっとも、スマートポインタの利用時にはこうした概念を意識する必要はありません。重要なことは、スマートポインタにメモリを委ねてしまえば、 初期化から解放まで動的メモリ管理を一切気にする必要がなくなるということです。

実際に使ってみる

先ほどの例を、スマートポインタを使って書き直してみましょう。なお使用する際には<memory>ヘッダをincludeする必要があります。

+#include <memory>
void func(int len){
-   int* ptr;
+   std::unique_ptr<int[]> ptr;

   //lenが0より大きいとき、その長さのメモリを確保
   if(len > 0){
-     ptr = new int[len];
+     ptr = std::make_unique<int[]>(len);
   }

   //ptrを使って計算
   for(int i = 0; i<len; ++i){
      ptr[i] = i*i;
   }

-   //メモリを解放
-   if(ptr){
-       delete[] ptr;
-   }
}

書き換えが必要なのはこれだけです。ポインタの宣言と代入をスマートポインタ用のものに書き換えるだけです。newdeleteもないので、本当に動的メモリ確保が行われているのか不安になりますが、内部で行われていることは非常に単純です。

まず、コンストラクタ内でポインタの初期化が自動的に行われます。

std::unique_ptr<int[]> ptr;
//こう書くのと同じ
//int* ptr=nullptr;

メモリの確保はmake_unique関数が内部で処理してくれています。

ptr = std::make_unique<int[]>(len);
//make_unique関数内部でnewしている
//ptr = new int[len];

アクセス方法は普通のポインタと基本的に同じです。さすがにインクリメントやディクリメントによる参照先の移動はできませんが、*->、配列型なら[]によるアクセスが可能です。

//配列型ならoperator[]で要素にアクセスできる
for(int i = 0; i<len; ++i){
   ptr[i] = i*i;
}

deleteに相当する処理は明確に記述しなくても、ディストラクタが呼ばれた際に所有権を持つメモリは自動的に解放してくれています

void func(int len){
   std::unique_ptr<int[]> ptr;
   ...
} // ここでptrのディストラクタが呼ばれる
  // ptrがメモリを保持していれば、自動的にdeleteで解放される

このように、スマートポインタに置き換えるだけで上記で指摘したような、ポインタの初期化忘れ、メモリの解放忘れや、不適切な対象への deleteの実行といったトラブルを防ぐことができるのです。

スマートポインタの種類

C++11以降のC++には、所有権の持つ性質の違いから、3種類のスマートポインタが用意されています。

unique_ptrは、その名の通りあるメモリの所有権はただ一つのインスタンスが保持するよう設計されたスマートポインタです。軽量・シンプルで生のポインタに比べても基本的にパフォーマンス上のオーバーヘッドなしに使えます。ただし、所有権は常に単一のインスタンスが保持する以上、コピーはできずムーブ(所有権の移動)のみが可能です(ムーブについてはムーブセマンティクス右辺値参照などの記事をご参照ください)。

shared_ptrは、その名の通りあるメモリの所有権を複数のインスタンスで共有できるように設計されたスマートポインタです。最大の特徴はコピー可能なことです。コピーされた場合、所有権は複数のインスタンスで共有され、メモリの解放は最後に所有権を持つインスタンスによって行われます。ただし、この所有権を共有する仕組みのために、生のポインタに比べて(たとえコピーしなくても!)パフォーマンス面で若干のオーバーヘッドが存在します。

weak_ptrは、自身ではメモリへの所有権は持たず、代わりに shared_ptrの持つメモリへの参照を保持します。所有権を持たないため、知らない間に参照先のメモリが解放されてしまう場合もありますが、「まだ参照先のメモリが解放されいないか」を確かめることができる関数と、shared_ptrへと昇格することで参照先の所有権を確保する機能を持っています。

まとめるとこんな感じです。

型名 コピー ムーブ アクセス 所有権 特徴
unique_ptr × 単一 軽量・シンプル
shared_ptr 共有 コピー可能だがオーバーヘッドが存在
weak_ptr - 所有権を持たずshared_ptrへの参照を保持

以下では、具体的にこれら3つのスマートポインタの使い方について紹介していきます。

スマートポインタの基本的な使い方

まずは基本となるスマートポインタである、unique_ptrshared_ptrの使い方についてみてみましょう。

メモリの管理を委ねる

スマートポインタにメモリの所有権を委ねるには、コンストラクタで指定するか、 reset(pointer)を使います。

//コンストラクタの引数として、動的確保したメモリのアドレスを指定することもできる
std::unique_ptr<int> uptr1(new int(10));
std::shared_ptr<int> sptr1(new int(10));

//あるいはreset関数を使って、後から代入することもできる
std::unique_ptr<int> uptr2;
uptr2.reset(new int(10));
std::shared_ptr<int> sptr2;
sptr2.reset(new int(10));

ただし、同等の処理はmake_unique<T>make_shared<T>関数を使った方が簡単で間違いがありません。例えば、上記の処理は以下のように書き直せます。

//make_xxxが、newしてスマートポインタに詰めるところまでやってくれる
auto uptr1 = std::make_unique<int>(10); // C++14以降
auto sptr1 = std::make_shared<int>(10); // C++11以降

//後から代入する場合も以下のように書ける
std::unique_ptr<int> uptr2;
uptr2 = std::make_unique<int>(10); // C++14以降
std::shared_ptr<int> sptr2;
sptr2 = std::make_shared<int>(10); // C++11以降

make_uniqueはC++14以降で使用可能です。

shared_ptrは所有するメモリだけでなく自身の参照カウンタ(後述)も動的にメモリを確保する必要があるため、make_shared<T>を使うのは処理速度上もメリットがあります。

make_unique<T>make_shared<T>関数は、コンストラクタが複数の引数をとるようなクラスの場合でも使えます。

//複数の引数を持つコンストラクタを呼び出す場合にも使える
auto uptr3 = std::make_unique<std::pair<int,std::string>>(10, "test"); // C++14以降
auto sptr3 = std::make_shared<std::pair<int,std::string>>(10, "test"); // C++11以降

newを手動で呼ぶこと自体がトラブルの原因となりうるので、特に理由がない限りmake_unique<T>make_shared<T>関数を使いましょう!

なお、make_unique<T>make_shared<T>は引数無しで呼び出したとしてもT()による明示的初期化が成されます。つまり、int型のような組み込み型ではゼロ初期化が行われます。しかし、値を必ず代入して使う場合、このような明示的初期化はパフォーマンス上コストとなります。こうしたケースのために、C++20以降ではデフォルト初期化のための関数make_unique_for_overwrite<T>make_shared_for_overwrite<T>が用意されました。ただし、これらの関数で生成した場合、初期値として入っている値は不定なので、必ず使用前に上書きして使う必要があります。

auto uptr1 = std::make_unique<int>();
// int()の呼び出しによる明示的初期化
// *uptr1はゼロ初期化されている

auto uptr2 = std::make_unique_for_overwrite<int>();
// デフォルト初期化されるため、*uptr2の中身は不定
int i = *uptr2; //ERROR 不定の中身を評価する動作は未定義
*uptr2 = 10;    //明示的に上書き初期化すればOK

メモリにアクセスする

メモリの所有権を実際に保持しているかは、 operator bool()、つまりbool型の文脈で使用することで判定できます。所有権を持つ場合にはtrue、持たない場合にはfalseを返します。

std::unique_ptr<int> uptr;
std::make_shared<int> sptr;

//メモリの所有権を保持しているかどうかは、boolの文脈で使用することで判定できる
bool canAccessU = uptr;

//キャストでもOK
auto canAccessS = static_cast<bool>(sptr);

//if文やfor文の条件式に放り込んでもOK
if(uptr){
   //--- 所有しているときの処理 ---
}
for(int i =0; i<10 && sptr; ++i){
   //--- iが10未満で、かつsptrがメモリを所有している際の処理 ---
}

ポインタの保持するメモリにアクセスするには、通常のポインタ同様に operator*()operator->()を使用します。

auto uptr = std::make_unique<std::string>("unique");
auto sptr = std::make_shared<std::string>("shared");

//operator*()でstring型を参照
// "unique" "shared"と表示される
std::cout << *uptr << std::endl;        
std::cout << *sptr << std::endl;        

//operator->()で、string型のsize関数を呼び出せる
unsigned int usize = uptr->size();
unsigned int ssize = sptr->size();

get関数を使うことで、所有権を保持するメモリのアドレス(生ポインタ)を取得することができます。ただし、この関数を実行してもメモリの所有権はスマートポインタが保持したままです。

auto uptr = std::make_unique<std::string>("unique");
auto sptr = std::make_shared<std::string>("shared");

//管理しているメモリのアドレスを取得する
std::string* rawuptr = uptr.get();
std::string* rawsptr = sptr.get();

//取得したアドレスを勝手にdeleteするのはダメ!!
delete rawuptr; //ERROR 勝手に解放されてしまうと動作は未定義

所有権を移動する

ムーブを使って所有権の移動を行うことができます。

//unique_ptr
auto uptr1 = std::make_unique<int>(10);
auto uptr2 = std::move(uptr1); //ムーブコンストラクタ uptr1の所有権がuptr2に移動する uptr1は空になる
std::unique_ptr<int> uptr3;
uptr3 = std::move(uptr2);        //ムーブ代入演算子 uptr2の所有権がuptr3に移動する uptr2は空になる

//shared_ptr
auto sptr1 = std::make_shared<int>(10);
auto sptr2 = std::move(sptr1); //ムーブコンストラクタ 所有権がsptr2に移動する sptr1は空になる
std::unique_ptr<int> sptr3;
sptr3 = std::move(sptr2);        //ムーブ代入演算子 所有権がsptr3に移動する sptr2は空になる

移動先がすでに別のメモリを管理していた場合でも、古いメモリは自動的に解放されるので安心です。

auto uptr1 = std::make_unique<int>(10);
auto uptr2 = std::make_unique<int>(20);

//ムーブで代入されると、uptr1が持っていたメモリは解放される
uptr1 = std::move(uptr2);

//shared_ptrも同様

もちろん、swapだって使えます。

auto uptr1 = std::make_unique<int>(10);
auto uptr2 = std::make_unique<int>(20);

//uptr1とuptr2の管理するメモリが入れ替わる
std::swap(uptr1,uptr2);

//shared_ptrも同様

関数の戻り値として使うこともできます。

std::unique_ptr<std::string> hello(int num){
   std::string str;
   for(int i = 0; i<num; ++i){
      str += "hello!";
   }
   return std::make_unique<std::string>(str);
}

auto response = hello(4);
//"hello!hello!hello!hello!"が入ったunique_ptrが帰ってくる

生のポインタ同様、暗黙の型変換が可能なポインタ同士であれば、異なる型間でも所有権を移動することが出来ます。

struct foo{};            //基底クラス
struct bar:public foo{}; //派生クラス

auto ptrBar = std::make_unique<bar>();
//派生クラスのスマートポインタを基底クラスのスマートポインタに変換可能
std::unique_ptr<foo> ptrFoo = std::move(ptrBar);
//逆はアウト
std::unique_ptr<bar> ptrBar2 = std::move(ptrFoo); //Compile ERROR
//shared_ptrも同様

なお、unique_ptrからshared_ptrへと所有権を移動することも可能です。残念ながら、shared_ptrからunique_ptrへと所有権を移動する方法はありません。

std::unique_ptr<int> uptr;
std::shared_ptr<int> sptr;

uptr = std::make_unique<int>(10);

//unique_ptr -> shared_ptr の所有権移動
sptr = std::move(uptr); //OK

//shared_ptr -> unique_ptr の所有権移動
uptr = std::move(sptr); //Compile ERROR この向きの移動はできない

なお、C++17以降はテンプレート型の推論補助(deduction guide)を使って、以下のようにテンプレート引数を省略した所有権の移動も書けます

auto uptr std::make_unique<int>(10);
//テンプレート引数無しでも、右辺から正しいshared_ptr型が推論される
std::shared_ptr sptr = uptr; 

メモリを解放する

メモリの解放は、放っておいてもディストラクタで行われますので、使用上意識する必要はありません。

{
   auto uptr = std::make_unique<int>(10);
   auto sptr = std::make_shared<int>(10);
}//ここでptrのディストラクタが呼ばれ、自動的に解放される

もちろん、他の代入されたりreset()関数で新たなメモリが割り当てられた場合でも、古いメモリはきちんと解放されます。

auto uptr = std::make_unique<int>(10);
auto sptr = std::make_shared<int>(10);

//代入されると、それまで管理していたメモリは解放される
uptr = std::make_unique<int>(20);
sptr = std::make_shared<int>(10);

//reset関数を呼び出しても、それまで管理していたメモリは解放される
uptr.reset(new int(30));
sptr.reset(new int(30));

明示的に解放したい場合は引数なしで reset()関数を呼ぶことで実現できます。

auto uptr = std::make_unique<int>(10);
auto sptr = std::make_shared<int>(10);

//reset()関数を呼ぶことで、明示的に解放できる
uptr.reset();
sptr.reset();

配列型を扱う

unique_ptr<T[]>shared_ptr<T[]>のように指定すれば、配列を扱うこともできます。

//型名[]をテンプレート引数に指定してコンストラクタを呼ぶ
std::unique_ptr<int[]> ptrArrayU(new int[10]);   //C++11以降
std::shared_ptr<int[]> ptrArrayS(new int[10]);   //C++17以降

shared_ptrが配列に対応したのはC++17以降です。C++11,14環境においてshared_ptrで配列を扱うためには、以下のようにdeleter(補足1参考)を明示的に配列のものとして指定して使用する必要があります。また、後述するoperator[]によるアクセスはできず、get関数の戻り値から辿る必要があります。

{
   //[]型名をテンプレート引数に指定することで、配列も扱える
   //第2引数で、配列用にdeleterを指定
   //deleterを明示的に指定する際には、make_sharedは使えない
   std::shared_ptr<int> ptrArrayS(new int[10], std::default_delete<int[]>());

   //operator[]は使えない
   //代わりに、get関数からアクセスはできる
   for(int i=0;i<10;++i){
      //ptrArray[i]=i;      //C++14まではCompile ERROR
      ptrArray.get()[i]=i;  //ok
   }

}//default_delete<int[]>を指定しておけば、自動的にdelete[]が呼ばれて解放される。

なお、配列の場合でもmake_unique<T[]>関数を使って作成できます! この場合、引数は確保する配列長になります。

//これでもOK
auto ptrArrayU = std::make_unique<int[]>(10);   //C++14以降
auto ptrArrayS = std::make_shared<int[]>(10);   //C++20以降

make_uniqueが使えるのはC++14以降です。また、make_sharedが配列型の確保に対応したのは、C++20以降です。

配列型の場合、 operator[](size_t)によって配列のようにアクセスすることができます。

auto ptrArrayU = std::make_unique<int[]>(10);
auto ptrArrayS = std::make_shared<int[]>(10);

//配列型の場合operator[](size_t)を使うことができる
for(int i=0;i<10;++i){
   ptrArrayU[i] = i;
   ptrArrayS[i] = -i;
}

unique_ptrとshared_ptrの違い

ここまでみてきたように、unique_ptrshared_ptrの間では、ほとんど同じ使い方ができますが、名前の通り所有権をめぐる考え方の違いから、いくつかの機能が異なっています。

コピーの可否

所有権を単一のインスタンスが保持する設計のunique_ptrでは、コピーは禁止されています。これは、コピーできてしまうと、コピー先とコピー元のどちらが所有権を持っているのか分からなくなってしまうためです。

auto ptr = std::make_unique<int>(10);

//コピーコンストラクタや、コピー代入演算子はエラー
std::unique_ptr<int> ptr2(ptr); //Compile ERROR
std::unique_ptr<int> ptr3;
ptr3=ptr;   //Compile ERROR

一方、所有権を複数のインスタンスで共有可能な設計のshared_ptrは、コピーが可能です。所有権はコピー先とコピー元の両者が共有し、そのメモリに対して所有権を持つ最後のインスタンスが消えるまでメモリは解放されません。

{
   auto ptr1 = std::make_shared<int>(10);
   std::shared_ptr<int> ptr2(ptr1); //コピーコンストラクタ ptr1とptr2で所有権を共有
   {
      //intの所有権を持つ、ptr2を作成
      std::shared_ptr<int> ptr3;
      ptr3 = ptr2;                 //コピー代入演算子 ptr1, ptr2 ptr3で所有権を共有
    }//ここで、ptr3のディストラクタが呼ばれる
     //ptr1, ptr2も同一のメモリに対する所有権を持っているため、まだ解放はされない

   ptr1.reset();  //ptr1が明示的解放 
   //ptr2が同一のメモリに対する所有権を保持しているため、まだ解放はされない

}//ここで、ptr2のディストラクタが呼ばれる
 //ptr2が所有権を持つ最後のポインタなので、メモリが解放される

このような動作は、以下のような仕組みで実現されています。 shared_ptrは、管理するメモリとは別に、そのメモリに所有権を持つインスタンスの数を記録する共有の参照カウンタを持っています。shared_ptrがコピーされると、内部で参照カウンタがインクリメントされ、ディストラクタや明示的解放時によってデクリメントされます。つまり、自身が消えると参照カウンタがゼロとなる=全ての所有者がいなくなる場合にのみメモリを解放する仕組みにすることで、複数のshared_ptr間で安全にメモリを共有できるのです。

ただし、この参照カウンタのためにもメモリが動的確保される他、参照カウンタのインクリメント、デクリメントが必要となるため、処理速度は生のポインタやunique_ptrに比べて劣ります。

なお、上記の参照カウンタの値を知るためにshared_ptrにはuse_count()関数が用意されていますが、マルチスレッド環境下ではこの関数の戻り値は信頼できません。あくまで目安としての利用が推奨されています。

//所有者の数の「目安」を確認するには、use_count関数を使う
std::cout<<"use_count="<<ptr.use_count()<<std::endl;

類似の機能として、自身が保有するメモリに所有権を持つインスタンスの数が単一であるかを確認するunique()関数が用意されていましたが、上述の通りそもそも信頼できない値を返す場合があるため、unique()関数はC++17で非推奨、C++20で削除されました。

所有権の放棄

unique_ptrには、保有するメモリの所有権を放棄するrelease()関数が存在します。release()を行うと、そのメモリの解放は手動で行う必要があります。

auto ptr = std::make_unique<int>(10);

//所有権を放棄する場合は、release関数を使う
//戻り値で管理していたメモリのポインタが帰ってくる
int* pint = ptr.release();

//当然、メモリの解放自体は自分で行う必要がある
delete pint;

動作はget関数と類似していますが、get関数では所有権自体は放棄しない点に注意が必要です。

一方、shared_ptrには所有権を放棄するための関数はありません。

weak_ptrの使い方

3つめのweak_ptrは、必ずshared_ptrと共に使ういわば補助的役割のスマートポインタです。weak_ptrの機能を説明する前に、shared_ptrが原因で生じる、循環参照と呼ばれる厄介な状態について説明します。

循環参照

例えば、以下のようなwomanとman、二つのクラスを考えてみます。それぞれは、自身のパートナーをメンバ変数としてshared_ptrで管理していおり、make_partner関数を呼ぶことで互いをパートナーとして登録します。

struct woman;
struct man{
   std::string name;
   std::shared_ptr<woman> partner;
   man(std::string name_):name(name_),partner(nullptr){}
};
struct woman{
   std::string name;
   std::shared_ptr<man> partner; 
   woman(std::string name_):name(name_),partner(nullptr){}
};
void make_partner(std::shared_ptr<woman>& F,std::shared_ptr<man>& M){
   F.partner = M;
   M.partner = F;
}
int main(){
   auto AlicePtr= std::make_shared<woman>("Alice");
   auto BobPtr= std::make_shared<man>("Bob");
   make_partner(AlicePtr,BobPtr);
}

womanがmanを、manがwomanをshared_ptrで持ち合う、やや特殊な設計ですが、少なくともメモリ管理に関しては、一見何の問題もないコードのように見えます。ところが、実はこの例では、shared_ptrで管理されているメモリが正しく解放されず、メモリリークが発生します

なぜスマートポインタで管理しているのに、メモリリークが発生するのでしょう? make_partner後、Aliceのために確保されたメモリはAlicePtrとBob.partnerの二つ、BobのメモリはBobPtrとAlice.partnerの二つのshared_ptrに所有権を持たれた状態となっています。main関数が終了する際、AlicePtrのデストラクタが呼ばれます。ところが、Bob.partnerがまだ所有権を保持し居てるため、Aliceはこの時点で解放されません。次に、BobPtrのデストラクタが呼ばれますが、今度は解放されずに残っているAliceが保持するAlice.patnerがまだ所有権を保持しているため、Bobもこの時点でも解放されません。結果、AliceとBobのメモリは、最初に所有していたAlicePtr、BobPtrそれぞれのデストラクタが呼ばれたにもかかわらず、解放されずに残ってしまうことになります。これが、循環参照と呼ばれる現象です。

循環参照自体は、クラスが単一でも生じる現象です。例えば、以下のクラスの例では、うっかり自身を次のタスクとして登録すると循環参照が発生し、Taskのメモリが解放されない事態になります。

//タスク実行クラス
struct task{
   std::shared_ptr<task> next; //次に実行すべきタスクを登録できる
   void do_task(){
      ... //自身のタスクを実行

      //nextが登録されていれば、ついでにそのタスクも実行
      if(next){
         next->do_task();
      } 
   }
};
int main(){
   auto Task = std::make_shared<task>();
   //うっかり自分自身を次のタスクとして登録
   Task->next = Task;
}

なお本筋から外れますが、このコードでTask->do_task()を呼び出すと無限再起(do_task内で自分自身であるdo_taskを呼び出し、その中でさらにdo_taskを呼び出し・・・)となるため、いずれにせよ問題が生じます。

所有せず参照する:weak_ptr

weak_ptrは、循環参照を防ぐために導入されたスマートポインタです。先の二つのスマートポインタと違い、 weak_ptrメモリへの所有権を持つことはありません。代わりに、weak_ptrshared_ptrの指すメモリを参照することができます

先ほどの循環参照の例で見てみます。

struct woman;
struct man{
   std::string name;
-   std::shared_ptr<woman> partner; 
+   std::weak_ptr<woman> partner; 
   man(std::string name_):name(name_),partner(nullptr){}
};
struct woman{
   std::string name;
-   std::shared_ptr<man> partner; 
+   std::weak_ptr<man> partner; 
   woman(std::string name_):name(name_),partner(nullptr){}
};
void make_partner(std::shared_ptr<woman>& F,std::shared_ptr<man>& M){
   F.partner = M;
   M.partner = F;
}
int main(){
   auto AlicePtr= std::make_shared<woman>("Alice");
   auto BobPtr= std::make_shared<man>("Bob");
   make_partner(AlicePtr,BobPtr);
}//所有権は、Alice, Bobしかそれぞれ持っていないので、正しく解放される

変わったのは、man, womanクラスがそれぞれpartnerをshared_ptrではなくweak_ptrで保持するようになった点です。これだけの違いですが、Alice,Bobのデストラクタが呼ばれた際にそれぞれ互いの所有権を持たなくなるため、循環参照が生じずメモリは正しく解放されるようになります。

このように、 weak_ptr所有権を持たずに メモリへの参照のみ保持するスマートポインタです。所有権を持たないため、自身の参照先のメモリが解放されてしまうことはありますが、循環参照によるメモリリークを防ぐことが出来ます。

以下で詳しいweak_ptrの使い方を見ていきましょう。

shared_ptrのメモリを参照させる

他のスマートポインタ同様、メモリを管理させるにはコンストラクタ、代入演算子、あるいはreset()関数を使います。ただし、weak_ptrshared_ptrが所有権を持つメモリしか管理できないため、生のポインタを受け取ることはできません。

auto sptr =std::make_shared<int>(10);

//コンストラクタや代入演算子で、shared_ptrを受け取る
std::weak_ptr<int> wptr1(sptr); 
std::weak_ptr<int> wptr2;
wptr2=sptr;

//リセット関数で割り当てる
std::weak_ptr<int> wptr3;
wptr3.reset(sptr);

//ポインタを直接受け取ることはできない!
std::weak_ptr<int> wptr3(new int(10)); //Compile ERROR

なお、C++17以降はテンプレート型の推論補助(deduction guide)を使って、以下のようにテンプレート引数を省略した書き方もできます。

auto sptr =std::make_shared<int>(10);
//テンプレート引数無しでも、右辺から正しいweak_ptr型が推論される
std::weak_ptr wptr1 = sptr; 

一時的に所有権を取得する

所有権を保持していないので、 operator*()operator->()などを使って、weak_ptrから直接参照先のメモリにアクセスすることはできません。メモリにアクセスするには、まず lock()によってそのメモリへの所有権を保持する shared_ptrを一時的に取得し、そこからアクセスする必要があります。このような設計になっているのは、メモリへの使用中に解放されてしまう事態を避けるためです。

auto sptr=std::make_shared<int>(10);
std::weak_ptr<int> wptr(sptr);
{
   //lock関数によって、まず参照先を保持するshared_ptrを取得する
   std::shared_ptr<int> ptr=wptr.lock();
   //ptrからメモリにアクセス
   //ptrが存在する限り、メモリが勝手によそで解放される危険性はない
}

なお、lock()時点でweak_ptrの参照先のメモリが解放されていた場合、空のshared_ptrが帰ってきます。このことを利用して、実用上は以下のようにif文と組み合わせて使うと良いでしょう

auto sptr=std::make_shared<int>(10);
std::weak_ptr<int> wptr(sptr);

if(auto ptr = wptr.lock()){
   //メモリはまだ解放されていない
   //ptrからメモリにアクセス
}else{
   //すでにメモリが解放されていた
}

なお、参照先のメモリがすでに解放されてしまっているかは、expired()を使って確認することができます。戻り値がtrueであれば、すでに解放されてしまっています。operator bool()は用意されていません。

auto sptr = std::make_shared<int>(10);
std::weak_ptr<int> wptr(sptr );

//参照先のメモリが解放されていないかどうかはexpired関数を使う
if(wptr.expired()){
   std::cout<<"expired"<<std::endl;
}

ただし、lock()できるかを確認する目的で、expired()を使うのはお勧めしません。マルチスレッド環境では、expired()の戻り値がたとえfalseでも、lock()時点ではすでに解放されてしまっているケースが考えられるためです。

auto sptr = std::make_shared<int>(10);
std::weak_ptr<int> wptr(sptr );

if(!wptr.expired()){
   // <- このタイミングで別スレッドがsptrを開放してしまうかも!
   ptr = wptr.lock();
   // ptrはすでに空かもしれない
   *ptr = 20; //空だった場合、この処理は未定義動作
}

そもそも、前述の通りlock()できない場合は空のshared_ptrが帰ってくるので、事前確認は不要です。

expired()の使い所としては、たとえば複数のweak_ptrを管理している際に、参照先が無効となったものから取り除くような、ガベージコレクション的使い方でしょうか。

std::vector<std::weak_ptr<int>> Watchers; //監視対象を覚えておく配列

while(true){
   ... //Watchersへの登録など、必要なループ処理
   
   //参照先がなくなったweak_ptrをクリーンアップする
   auto End = std::remove_if(
      Watchers.begin(),Watchers.end(),
      [](const auto& wptr){return wptr.expired();}
   );
   //実際に配列から削除
   Watchers.erase(End,Watchers.end());
}

なお、shared_ptr同様weak_ptrにもメモリの所有者の数の目安を返すuse_count()関数が用意されていますが、上述の通りマルチスレッド環境下ではこの関数の戻り値は信頼できないため、あくまで参考として使うことが求められています。

//所有者の数の「目安」を確認するには、use_count関数を使う
std::cout<<"use_count="<<wptr.use_count()<<std::endl;

weak_ptrのコピーとムーブ

weak_ptrは、コピー、ムーブともに使用することができます。

auto sptr=std::make_shared<int>(10);
std::weak_ptr<int> wptr(sptr);

//コピーコンストラクタや、コピー代入演算子もOK
std::weak_ptr<int> wptr2(wptr);
std::weak_ptr<int> wptr3;
wptr3=wptr;

//ムーブコンストラクタや、ムーブ代入演算子もOK
std::weak_ptr<int> wptr4(std::move(wptr2));
std::weak_ptr<int> wptr5;
wptr5=std::move(wptr3);

参照をやめる

明示的に参照を終えたい場合は、reset()を呼び出すことで実現できます。

std::shared_ptr<int> sptr=std::make_shared<int>(10);
std::weak_ptr<int> wptr(sptr);

//reset関数で明示的に参照を終える
wptr.reset();

一見、weak_ptrはメモリの解放に関与しないように見えますが、make_sharedと組み合わせて使用した場合、全てのweak_ptrからの参照が切れるまでメモリが解放されない点に注意が必要です。

これは、make_sharedが、保持するオブジェクト用の領域と参照カウンタ領域、双方を一体的に動的メモリ確保する設計なのが原因です。make_sharedのパフォーマンスが良いのはこの設計のおかげなのですが、欠点として参照カウンタ領域が不要となるまでメモリ全体が解放できなくなります。参照カウンタ領域は、weak_ptrが参照する限り残り続けるため、結果としてweak_ptrからの参照が切れるまで、オブジェクト用の領域まで解放されないのです(なお、メモリ領域の問題だけで、オブジェクト自体のディストラクタは全てのshared_ptrが所有権を放棄した時点で呼び出されます)。

通常のnewを使って生成した場合、保持するオブジェクトのための領域と参照カウンタ領域は別々に動的確保されるため、こうした問題は起きません(引き換えにパフォーマンスは悪化するわけですが)。メモリがweak_ptrによっていつまでも参照され続ける可能性があり、かつ確保するメモリが無視できないほど巨大なケースに限り、newを使ってshared_ptrを構築した方が良いといえます。

スマートポインタをうまく活用しよう

さて、ここまで3種類のスマートポインタの使い方についてみてきました。それでは、どうこの3種を使い分ければよいのでしょう? もっといえば、既存の生のポインタで書かれたコードを、どのようにスマートポインタに置き換えればよいのでしょう?

これを考えるためには、まず「所有」のためのポインタと「参照」のためのポインタを区別する必要があります。「所有」とは、自身がメモリの寿命をコントロールする、ということです。「所有」している限りはメモリは解放されないことが保証される代わりに、自身が所有権を手放さない限りそのメモリは解放されることがないわけです。一方、単にアクセスするためだけにアドレスが一時的に必要なだけであれば、それは「参照」です。メモリの寿命に関与することはありませんが、同時にそれは知らない間に解放される可能性を意味します。このため、「参照」は原則として所有者より内側のスコープなど、参照を使っている間は解放されないことが保証されているケースでのみ使うべきです。

もちろん、スマートポインタによって書き変えるべきポインタは、「所有」のためのポインタです。

「参照」は言語機能としての参照、つまりT&と書き変えることが出来る、と言い換えてもいいかもしれません。ただし、生ポインタによる「参照」は、nullptrによって空の状態を表現できる、他のアドレスを代入できる、インクリメント・デクリメントなどの処理ができます。このため、「参照」のポインタであっても、常にT&で書き変えられるわけではないでしょう。この辺りについては、別の入門記事である「それ、ポインタ使わなくてもできるよ:C言語のポインタとC++の流儀」をご覧ください

スマートポインタを使う上では、基本的にはunique_ptrを第一の選択肢とすべきです。コピーができない、というところに不自由さを感じるかもしれませんが、そもそも「所有権のコピー」が必要となるのはまれです。関数の引数などアクセスするための「参照」が欲しいだけなら、operator*でオブジェクトの実体を渡すか、get()関数でアドレスを取得して引き渡せば済みます。また、関数の戻り値や関数への引き渡し、オブジェクト間のやり取りなど、所有権の「移動」であれば、ムーブで表現可能です。多くの場合、メモリの動的確保はunique_ptrを使っておけば間違いないでしょう。

一方、shared_ptrを使うべきケースは大きく二つに分かれます。一つは、複数のオブジェクトで「所有」する必要があるケースです。(1) 複数のオブジェクトで単一の共有リソースを同時に使用する必要があり、(2) それぞれのオブジェクトの寿命はケースバイケースで誰が一番長生きするかは決められず、(3) 共有リソースは全てのオブジェクトの寿命が尽きれば速やかに開放したい、
といったケースでは、shared_ptrを使って共同所有させるのが自然でしょう。

裏を返せば、上記のいずれかの条件が満たされなければ、unique_ptrでも十分かもしれません。例えば、リソースの使用が同時ではなく順繰りなのであれば、moveによって所有権をオブジェクト間で移していけばよいかもしれません。あるオブジェクトの寿命が常に最も長いと保証できるケースでは、そのオブジェクトがunique_ptrで「所有」し、他のオブジェクトには「参照」で渡せばよいでしょう。共有リソースの解放が多少遅れてもよいなら、より上位のスコープでunique_ptrで確保&管理してもよいわけです。

shared_ptrを使うべきもう一つのケースは、相手が解放されてしまう可能性のある「参照」を作りたい場合です。前述の通り、原則的には「参照」を使っていいのは相手が使用中に解放されないことが保証できるケースで、それが保証できないなら「所有」を使うべきです。しかし、(1)自身がメモリの寿命には影響を与えたくないが、(2)相手が解放されている可能性が保証できないケース、というのも稀にあり得ます。そういったケースでは、所有権を複数のオブジェクトで共有する必要がなくとも、shared_ptrでメモリを管理し、weak_ptrで「参照」を他のオブジェクトに渡す、というのはあり得る設計です。

shared_ptrは一見便利ですが、パフォーマンス上のコストに加えて循環参照のリスクがあります。「所有」のポインタとしてshared_ptrを選択する場合は、可能な限りweak_ptrを使うなどリスクを回避した設計にすることをお勧めします。

補足:deleterの指定

特殊なケースでは、メモリ解放に際してdelete以外の処理を行う必要がある場合もあるかもしれません。例えば、メモリはある関数を介して必ず確保、解放しなければいけない状況を考えてみます。

//int型のリソースをstorageから確保する
int* malloc_int_from_storage();
//int型のリソースをstorageから解放する
void free_int_from_storage(int* ptr);

このような場合、メモリの解放には deleteではなく、 free_int_from_storage関数を使いたくなります。

deleterとは、このようなメモリ解放時の処理を指定する関数オブジェクトです。unique_ptr、では、第二テンプレート引数に指定することで、deleterを指定することができます。

//free_int_from_storageを使ってメモリを解放する関数オブジェクトを定義する。
struct deleter_for_storage{
   void operator()(int* ptr_){
      free_int_from_sotrage(ptr_);
   }
};
int main(){
   //テンプレート第二引数で、deleterを指定する
   std::unique_ptr<int, deleter_for_storage> uptr(malloc_int_from_storage());

   return 0;
}//deleteではなく、free_int_from_storageがメモリ解放の際に呼ばれる。

shared_ptrの場合、deleterをテンプレート引数で指定することはできませんが、コンストラクタに入れることで指定することができます。

//free_int_from_storageを使ってメモリを解放する関数オブジェクトを定義する。
struct deleter_for_storage{
   void operator()(int* ptr_){
      free_int_from_sotrage(ptr_);
   }
};
int main(){
   //コンストラクタの第二引数で、deleterを指定する
   std::shared_ptr<int> sptr(malloc_int_from_storage(), deleter_for_storage());

   //deleteではなく、free_int_from_storageがメモリ解放の際に呼ばれる。
   return 0;
}

deleterを明示的に指定してやれば、 new以外の方法で確保したメモリや、メモリ以外のリソースについてもunique_ptr<T>を用いて管理することができるわけです。

参考文献

cppreference.com
cpprefjp - C++ Reference Site

244
211
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
244
211