3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【C++】初心者のためのクラス設計基礎⑤ ~メモリ管理~

Posted at

はじめに

 前回の続きです。超初心者向け記事です。今回はメモリ管理の話をします。短くまとめたいとは思っているのですが、まあいつもの如く長い記事になってしまいました。

前回までのあらすじ

 前回までずっと配列クラスを作ってきました。最初の方でいくつかのメンバ関数を導入しました。

class Array {
  int length;
  int* array_ptr;
public:
  void allocate(int n) {     // メモリ確保
    length = n;
    array_ptr = new int[length];
  }
  void clear() {             // メモリ解放
    delete[] array_ptr;
    length = 0;
  }
  int size() const { return length; }  // 要素数
};

 さらに演算子オーバーロードも導入しました。

class Array {
  /* 他のメンバ関数、メンバ変数は省略 */
public:
  int& operator[](int n) {        // アクセス演算子
    assert(n < length);
    return array_ptr[n];
  }
  int operator[](int n) const {   // アクセス演算子 const版
    assert(n < length);
    return array_ptr[n];
  }
  Array& operator=(const Array& arr) {  // コピー代入
    allocate(arr.length);
    for (int i = 0; i < length; i++) {
      array_ptr[i] = arr.array_ptr[i];
    }
    return *this;
  }
  Array& operator=(Array&& arr) {       // ムーブ代入
    length = arr.length;
    array_ptr = arr.array_ptr;  
    return *this;
  }
};

 前回はそこにコンストラクタとデストラクタを追加したんでしたね。

class Array {
  /* 他のメンバ関数、メンバ変数は省略 */
public:
  // コピーコンストラクタ
  Array(const Array& arr) : length(arr.length), array_ptr(new int[arr.length]) {
    for (int i = 0; i < length; i++) {
      array_ptr[i] = arr.array_ptr[i];
    }
  }

  // ムーブコンストラクタ
  Array(Array&& arr) : length(arr.length), array_ptr(arr.array_ptr) {
  }

  // デフォルトコンストラクタ
  Array() : length{}, array_ptr{nullptr} {
  }

  // その他のコンストラクタ
  Array(int n) : length(n), array_ptr(new int[n]) {
  }

  // デストラクタ
  ~Array() noexcept { delete[] array_ptr; }
};

 デストラクタでdelete[]を行うことで、変数の破棄と同時に自動的に確保した配列の解放ができるようになりました。しかし、二重解放の危険が生じたのでしたね。また他にも上のコードはメモリリークの危険もあります。今回はこれを解決していきましょう。

クラス設計とメモリ管理

 クラス設計において1位2位を争うレベルで難しいのがメモリ管理です。初心者のうちは結構苦しむ場所でもあります。というのも、どこでメモリリークしていて、どこで二重解放されているかわかりづらいからです。特にデストラクタがどこで呼ばれているか正確に把握するのは、初心者のうちではかなり難しいでしょう。難しいポイントの多いメモリ管理ですが、しっかり整理して考えれば意外と単純だったりします。この章ではメモリ管理の一般的な手法を解説していきます。今回も今までに引き続いて配列クラスArrayを設計しながら解説します。

動機

 前回までに配列クラスのメモリ管理関連のバグをいくつか紹介しているかと思いますが、改めて紹介します。まずはメモリリークの例から紹介します。

int main() {
  Array arr(42);
  arr.allocate(10);
  return 0;
}

 こんなに短いコードですが、残念ながらメモリリークしています。このままではどこでメモリリークしているかわかりづらいので、呼ばれる関数の中の処理をインライン展開してみると以下のようになります。

// 疑似コードです
int main() {
  /* Array arr(42); の中で起きること */
  int arr.length = 42;
  int* arr.array_ptr = new int[42];
  /* ---------- */

  /* arr.allocate(10); の中で起きること */
  arr.length = 10;
  arr.array_ptr = new int[arr.length];
  /* ---------- */

  return 0;

  /* デストラクタ ~Array() の中で起きること */
  delete[] arr.array_ptr;
  /* ---------- */  
}

 もうお判りでしょうか。new[]が2回行われているのにdelete[]が1回だけという時点で極めて怪しいですね。最初に確保した42個分のintを解放せずに再確保してしまっているので、そこでメモリリークを起こしています。
 続いて、二重解放の例です。

int main() {
  Array arr1(42);
  Array arr2 = std::move(arr1);
  return 0;
}

 またまた、非常に簡単な例ですが、やはりこれもバグっています。こちらもインライン展開してみましょう。

// 疑似コードです
int main() {
  /* Array arr1(42); の中で起きること */
  int arr1.length = 42;
  int* arr1.array_ptr = new int[42];
  /* ---------- */
  /* Array arr2 = std::move(arr1); の中で起きること */
  int arr2.length = arr1.length;
  int* arr2.array_ptr = arr1.array_ptr;
  /* ---------- */

  return 0;
  
  /* デストラクタ arr2.~Array() の中で起きること */
  delete[] arr2.array_ptr;
  /* ---------- */
  /* デストラクタ arr1.~Array() の中で起きること */
  delete[] arr1.array_ptr;
  /* ---------- */
}

 今回は先ほどとは逆にdelete[]が2個あってnew[]は1個ですね。そして、arr1.array_ptrarr2.array_ptrは全く同じメモリ領域を司るので最初に確保した42個分のintを2度解放してしまっていることになります。これは二重解放で実行時エラーを引き起こします。
 というわけで、これらを解決していきましょう。

メモリ管理

 最も簡単なメモリ管理の方針としては以下が挙げられます。

  • 新しく確保する前に必ず解放する (メモリリーク対策)
  • 他のオブジェクトと同一のメモリ領域を共有しない (二重解放対策)
  • メモリを確保していないときはポインタをnullptrにしておく (二重解放対策)

 まあもちろんこの枠に当てはめることができないケースも無数に存在する (特に2つ目のルールが適用できないケースは多い) わけですが、今回はこれで十分でしょう。この3点を念頭に置きつつ設計するだけで非常に楽になります。ちなみに1つ目と2つ目のルールが何故重要かは、1つ前の動機の章で見た例からわかると思いますが、3つ目のルールはいまいちピンとこない方も多いと思います。3つ目のルールにどんな意味があるかというと、実はdeletedelete[]nullptrを渡したときは何もしません。そのため、ポインタをnullptrにしておけばどこでdelete及びdelete[]が呼ばれても心配ないということです。

int main() {
  int* p = nullptr;
  delete p;       // 何もしないので問題ない
  return 0;
}

 newdeleteは対にしなければならないというイメージが強い方も多いと思いますが、nullptrの時には対になっていなくてもOKです。
 様々なメンバ関数を書く上で考えなければならないパターンは大きく分けて2つです。

  • オブジェクトがメモリを保持している時
  • オブジェクトがメモリを保持していない時

 まあ当たり前ですね。勿論もっと複雑なケースもたくさんあります。後でやりますが、operator=では考えなければならないパターンがもっと多いです。ですが、基本はこの2つを考えておけば十分です。また、先ほどの3つの方針から、オブジェクトがメモリを保持していない時を考える場合は、ポインタがnullptrであると仮定していいでしょう
 さて実際に、Arrayのメンバ関数のメモリ関連のバグを修正していきましょう。constについてはだいぶ前に取り扱いましたが、constメンバ関数ではクラスの中身が変わらないので、メモリ管理等を考える必要はなさそうです。そのため、constでないメンバ関数のみを考えましょう。

Array::allocate(int)

 まずはArray::allocate(int)からです。これはメモリを確保する関数でしたね。上で述べた通り、関数呼び出し時にメモリを保持している場合と保持していない場合を考える必要があります。

  • メモリを保持している時 : 新しくメモリ確保する前に、保持しているメモリをdelete[]しなければならない
  • メモリを保持していない時 : ポインタはnullptrなので、確保前にdelete[]してもしなくてもいい

 このように考えることができます。結論としては確保前に常にdelete[]してしまうのが良さそうですね。

class Array {
  /* 他のメンバ関数、メンバ変数は略 */
public:
  void allocate(int n) {
    delete[] array_ptr;   // あらかじめ delete[] する
    length = n;
    array_ptr = new int[length];
  }
};

 これで、どんな時もメモリリークが起こらなさそうです。

Array::clear()

 続いて、Array::clear()です。メモリ解放の関数でしたね。これは関数呼び出し時だけでなく関数呼び出し後にも注意する必要があります。まずは関数呼び出し時の場合分けから。

  • メモリを保持している時 : そのまま解放する
  • メモリを保持していない時 : ポインタはnullptrなので、解放しても何も起きないのでOK

 まあ単純でしたね。そして、関数呼び出し後ですが、この時オブジェクトは必ずメモリを保持していないので、3つのルールに従うならばポインタはnullptrにしなければなりません。よってコードは以下のようにするのが良さそうです。

class Array {
  /* 他のメンバ関数、メンバ変数は略 */
public:
  void clear() {
    delete[] array_ptr;
    length = 0;
    array_ptr = nullptr;  // nullptrにする 
  }
};

 これによって、Array::clear()を呼び出した後も安心してArrayを使い続けられます。

Array::Array()

 次はデフォルトコンストラクタです。初期化時に動く関数なので、必ずメモリを保持していない状態から始まりますね。事後条件としては、メモリ確保はないので、ポインタをnullptrにすべきでしょう。

class Array {
  /* 他のメンバ関数、メンバ変数は略 */
public:
  Array() : length{}, array_ptr{nullptr} {
  }
};

 これは変化なしですね。まあ実はわざわざarray_ptr{nullptr}と書かなくてもarray_ptr{}だけでnullptrに初期化されるのですが。

Array::Array(int)

 Array::Array(int)です。このコンストラクタはメモリ確保がありますね。特にポインタをnullptrにする必要などもありません。なので、全くそのままで大丈夫でしょう。

class Array {
  /* 他のメンバ関数、メンバ変数は略 */
public:
  Array(int n) : length(n), array_ptr(new int[n]) {
  }
};

 強いて変更するとすれば、確保した領域を初期化するか否かでしょう。確保領域は未初期化なので、これを読み取るのはバグの原因になります。なので、あらかじめゼロ初期化しておくと嬉しいケースも多いですが、今回はこのままにしておきます。

Array::Array(const Array&)

 コピーコンストラクタですね。これはコピー元とコピー先 (*this) の両方を考える必要がありますが、コピー元はconstなので特に変化しません。コピー先では、コピー元のオブジェクトがメモリを保持しているかどうかに応じて、メモリを確保する場合としない場合がありますね。メモリを確保しない場合nullptrを入れるべきです。

class Array {
  /* 他のメンバ関数、メンバ変数は略 */
public:
  Array(const Array& arr) : length(arr.length),
    array_ptr(arr.array_ptr == nullptr ? nullptr : new int[arr.length]) {  // メモリ確保無しならnullptr
    for (int i = 0; i < length; i++) {
      array_ptr[i] = arr.array_ptr[i];
    }
  }
};

 こうすれば、メモリ確保しない場合はコピー元はarray_ptr == nullptrになります。

Array::Array(Array&&)

 ムーブコンストラクタです。ムーブ元はconstではないので、ムーブ元の状態も関数内で変化させることができます。ムーブコンストラクタでは、ムーブ元の保持するメモリを奪ってしまうことができるんでしたね。3つのルールに従うならば、同じメモリ領域を共有しない方が良いので、ムーブ元のポインタは何か別の値に変更すべきです。ここで何の値を代わりに入れるかですが、やはり3つのルールに従うならば、ムーブ元はメモリ領域を保持しなくなるので、ポインタはnullptrにすべきですね。

class Array {
  /* 他のメンバ関数、メンバ変数は略 */
public:
  Array(Array&& arr) : length(arr.length), array_ptr(arr.array_ptr) {
    arr.length = 0;
    arr.array_ptr = nullptr;   // nullptrにする
  }
};

 これで二重解放を防ぐことができます。ムーブ元がメモリを保持していない場合でもムーブ先とムーブ元の両方のポインタがnullptrになるので正しく動きます。
 なお、arr.length = 0は念のためです。できるだけ、lengthと実際に確保されているメモリの要素数は一致していた方がバグにならないので、ここでも一致させています。

int main() {
  Array arr1(42);
  Array arr2 = std::move(arr1);  // ムーブ
  for (int i = 0; i < arr1.size(); i++) {  // arr1.size() == 0 となり、
    arr1[i] = 0;                           // ムーブ後に使用してもバグにならない。
  }
  return 0;
}

 ですが、一般的にはムーブされた変数は再初期化するまで使用しないのが"常識"なので、arr.length == 0にしなくてもいいのでは、とも思います。勿論この"常識"は人によって違うと思いますが、僕の場合はムーブされた後の変数は再初期化しない限り使いません。ただ、0を代入するだけなので大したコストでもないということもあり、arr.length == 0にするパターンの方が多いように感じます。

operator=(const Array&)

 コピー代入ですね。コピー元がメモリを持っているときはdelete[]する必要がありますね。そうでないときはdelete[]してもしなくてもいいですが、この場合はdelete[]してしまいましょう。
 注意しなければならないのが自己代入です。つまり、コピー先とコピー元が同じオブジェクトであるときです。もし自己代入した場合、最初にdelete[]をするとコピー元もコピー先も情報が消えてしまいます。これは困りますね。そのため、この時は何もせずにreturnしましょう。

class Array {
  /* 他のメンバ関数、メンバ変数は略 */
public:
  Array& operator=(const Array& arr) {
    if (this == &arr) { return *this; }  // 自己代入でバグらないようにする
    delete[] array_ptr;                  // delete[] しておく
    length = arr.length;
    array_ptr = arr.array_ptr == nullptr ? nullptr : new int[length];  // メモリ確保無しならnullptr
    for (int i = 0; i < length; i++) {
      array_ptr[i] = arr.array_ptr[i];
    }
    return *this;
  }
};

 自己代入なんてそんなこと普通しないでしょと思うかもしれませんが、結構やりがちです。以下の例を見てください。

#include <random>
#include <ctime>
#include <utility>

int main() {
  const int n = 42;
  Array arr[n] = {};
  std::srand(std::clock());   // 乱数初期化
  int x = std::rand() % n;    // 0 ~ n-1 の中からランダムに選ぶ
  int y = std::rand() % n;    // 0 ~ n-1 の中からランダムに選ぶ
  std::swap(arr[x], arr[y]);  // 入れ替え
  return 0;
}

 これはランダムに取り出した2つのArrayを入れ替えるだけのプログラムですが、もしもx == yだった場合はstd::swap関数内で自己代入 (この場合はコピー代入ではなくムーブ代入ですが) が起きます。これは一見自己代入しているようには見えませんね。このように自己代入の可能性を秘めたコードは結構たくさんあります。無論、自己代入の可能性なんぞ全て見破れるしそれを避けるのがプログラマとしての"常識"だ、というのであれば、自己代入は考慮しなくてもいいでしょうし、考慮しない方がほんの僅かに高速です。ですが、大した高速化にもならないので通常は自己代入の対策をします。

operator=(Array&& arr)

 さて、ムーブ代入です。これまでのことを総合して考えれば、自己代入をチェックして、ムーブ先をdelete[]して、ムーブ元のメモリをムーブ先に渡して、ムーブ元のポインタをnullptrにすれば良さそうですね。

class Array {
  /* 他のメンバ関数、メンバ変数は略 */
public:
  Array& operator=(Array&& arr) {
    if (this == &arr) { return *this; }  // 自己代入でバグらないようにする
    delete[] array_ptr;         // delete[] しておく
    length = arr.length;
    array_ptr = arr.array_ptr;
    arr.length = 0;
    arr.array_ptr = nullptr;    // nullptrにする
    return *this;
  }
};

 ムーブ元がもともとメモリを保持していなくても問題なく動作します。例の如くarr.length = 0をするかどうかは皆さんの主義信条によります。

Array::~Array()

 最後はデストラクタです。オブジェクトがメモリを保持しているときもしていない時もdelete[]してしまっていいでしょう。また、関数終了後はオブジェクトは破棄されるので、ポインタをnullptrにする必要はありませんね。

class Array {
  /* 他のメンバ関数、メンバ変数は略 */
public:
  ~Array() noexcept { delete[] array_ptr; }
};

 すなわちこれはそのままで大丈夫ですね。

問題解決の確認

 さてこれで大体の問題は解決されたはずです。動機の章で取り上げた例で確認してみましょう。

int main() {
  Array arr(42);
  arr.allocate(10);
  return 0;
}

 この例では修正前はメモリリークを起こすのでしたね。修正後の挙動は以下のようになります。

// 疑似コードです
int main() {
  /* Array arr(42); の中で起きること */
  int arr.length = 42;
  int* arr.array_ptr = new int[42];
  /* ---------- */

  /* arr.allocate(10); の中で起きること */
  delete[] arr.array_ptr;
  arr.length = 10;
  arr.array_ptr = new int[arr.length];
  /* ---------- */

  return 0;

  /* デストラクタ ~Array() の中で起きること */
  delete[] arr.array_ptr;
  /* ---------- */  
}

 new[]delete[]がそれぞれ2回ずつ出てきて、ちゃんと対になっていますね。これでメモリリークは回避できます。
 続いて、二重解放の方の例はどうでしょう。

int main() {
  Array arr1(42);
  Array arr2 = std::move(arr1);
  return 0;
}

 このコードは修正前は二重解放を起こしていましたが、修正後は以下のように動きます。

// 疑似コードです
int main() {
  /* Array arr1(42); の中で起きること */
  int arr1.length = 42;
  int* arr1.array_ptr = new int[42];
  /* ---------- */
  /* Array arr2 = std::move(arr1); の中で起きること */
  int arr2.length = arr1.length;
  int* arr2.array_ptr = arr1.array_ptr;
  arr1.length = 0;
  arr1.array_ptr = nullptr;
  /* ---------- */

  return 0;
  
  /* デストラクタ arr2.~Array() の中で起きること */
  delete[] arr2.array_ptr;
  /* ---------- */
  /* デストラクタ arr1.~Array() の中で起きること */
  delete[] arr1.array_ptr;  // nullptr なので OK
  /* ---------- */
}

 一見new[]1つに対しdelete[]が2つあるのでバグっているように見えますが、これは大丈夫です。なぜなら、delete[] arr1.array_ptrにおいてarr1.array_ptr == nullptrだからです。そのため、これは二重解放を回避できています。めでたしめでたし。

残った問題 (解決は次回以降)

 実はまだメモリ管理は完全とは言えません。というのも、new式が例外を出す場合を考えられていないからです。new式が例外を出す状況というのは、例えばメモリ不足等の理由でoperator newでメモリ確保に失敗した場合や、new式内で呼ばれるコンストラクタが例外を出した時等です。

struct S {
  S() { throw 1; }   // 例外を出すコンストラクタ
};
int main() {
  S* sp = new S();   // new式内のコンストラクタで例外が出る
  return 0;
}

 例外を出した時でもクラスが壊れず正しく動くことを例外安全と呼びます。この例外安全に注意しながらクラス設計をするのが重要なのですが、これはまた次回以降にしましょう。
 あとそろそろテンプレートの話もしたいですね。こちらも次回以降にしましょう。

メモリ管理再考

 メモリ管理はいろいろなコードフローを考えなければいけないので結構面倒ですね。そこでメモリ管理をサポートするスマートポインタというものが標準ライブラリに提供されています。具体的にはstd::unique_ptrstd::shared_ptrstd::weak_ptrの3つです。本当はstd::auto_ptrというものもありましたが、これはstd::unique_ptrの劣化版で、かなり使いづらいのでC++11以降非推奨、C++17で削除されました。では、3つのスマートポインタの使い方を紹介していきましょう。

std::unique_ptr

 このスマートポインタは、まさにメモリ管理の章で述べた3つのルールを体現したようなポインタです。新しくメモリ領域を確保する際には、今まで持っていたメモリ領域を自動で解放してくれますし、コピーコンストラクト及びコピー代入不可なのでうっかり他のオブジェクトと同じメモリ領域を共有することは (通常は) ありません。また、メモリ領域を持っていないときは常にnullptrになっています。素晴らしいですね。具体的にコード例を見てみましょう。

#include <memory>

struct S {  // 適当な構造体
  int x, y;
  int func() const { return x + y; }
  S() = default;
  S(int x_, int y_) : x(x_), y(y_) {}
};

int func(S* ptr) {  // 適当な関数
  return ptr->func();
}

int main() {
  std::unique_ptr<S> ptr1{new S(0, 1)};                 // メモリ確保
  std::unique_ptr<S> ptr2{std::make_unique<S>(0, 1)};   // これも上とほぼ同じ意味
                                                        // 一般的にはnewよりstd::make_uniqueの方が良い
  // 通常のポインタと同様に使える
  ptr2->x;        
  (*ptr2).func(); 

  // 引数を渡す時等、生ポインタが必要なら get() を使う
  func(ptr2.get());
  
  // ptr1 = ptr2;            // error: コピー不可
  ptr1 = std::move(ptr2);    // OK: ムーブはできる。ptr1のメモリは解放され、ptr2のメモリを譲り受ける
                             // ptr2は何も持っていない状態 (nullptr) になる
  return 0;
  // デストラクタで保持しているメモリ領域がdeleteされる
}

 こんな感じで、コピー不可な点以外はほとんど通常のポインタと同じように扱えます。メモリを確保する際は、new式も使えますが、std::make_uniqueを使う方が多いです。一番大きな理由は単にnewと書くとdeleteを書かなければならないという発作を抑えるためであって、要は気持ちの問題です。あと、=で初期化できるか否かというのもあります。

// std::unique_ptr<S> = new S;               // error
std::unique_ptr<S> = std::make_unique<S>();  // OK

 これはまた別の機会にちゃんと解説しますが、explicit指定子がコンストラクタについているため、=での初期化が禁止されています。
 他にもstd::make_uniqueを使う利点として、例外安全であるという話もありますが、それもまた別の機会に話すことにします。
 ちなみにコピー不可ではありますが、新しいメモリ領域を確保してポインタの中身をコピーするというのは簡単にできます。

ptr1 = std::unique_ptr<S>{new S(*ptr2)};     // Sのコピーコンストラクタが動く
ptr1 = std::make_unique<S>(*ptr2);           // これもほぼ同じ意味

 これら2つはstd::unique_ptr自体をコピーしているわけではなく、中身のSをコピーしているだけなのでOKです。
 あと、もう一つ配列版のstd::unique_ptrも紹介した方が良いでしょう。基本的な使い方はほぼ同じですが、newdeleteの代わりにnew[]delete[]を使うバージョンです。

int main() {
  std::unique_ptr<int[]> ptr1{new int[42]};                 // OK
  std::unique_ptr<int[]> ptr2{std::make_unique<int[]>(42)}; // 上と同じ意味

  ptr2[3];  // 配列アクセス可能

  return 0;
  // デストラクタで保持しているメモリ領域がdelete[]される。
}

 通常版と違って配列版のstd::unique_ptroperator[]のオーバーロードがあります。逆にoperator->operator*はなくなります。
 気になるのはstd::unique_ptrの実行速度ですが、最適化すれば生ポインタとほぼ同等の速度になると言われています。なので、ガンガン使っていきましょう。

std::shared_ptr

 続いてstd::shared_ptrです。メモリ管理の章で述べた通り、3つのルールのうちの2つ目、他のオブジェクトと同一のメモリ領域を共有しない、というのは理想的ではありますが、実際には複数のオブジェクト間でメモリを共有したくなる状況は結構多いです。そこで使えるのがstd::shared_ptrです。これは参照カウンタというものを持っていて、いくつのオブジェクトがメモリを共有しているのか数えていてくれます。そして、参照カウンタが0になったタイミングで、保持していたメモリを解放します。そのため、std::shared_ptrstd::unique_ptrと違ってコピー代入及びコピーコンストラクト可能です。

int main() {
  std::shared_ptr<S> ptr1{new S(0, 1)};               // メモリ確保
  std::shared_ptr<S> ptr2{std::make_shared<S>(0, 1)}; // 上とほぼ同じ意味

  // 通常のポインタと同様に使える
  ptr2->x;        
  (*ptr2).func(); 

  // コピー可能
  ptr1 = ptr2;           // ptr1がもともと持っていたメモリ領域は解放され、
                         // ptr2の持っているメモリ領域を共有
  return 0;
  // ptr2とptr1のデストラクタが呼ばれ、メモリ領域が正しくdeleteされる
}

 C++17以降配列版のstd::shared_ptrも提供されるようになりました。また、配列版std::make_sharedはC++20で実装されました。

int main() {
  std::shared_ptr<int[]> ptr1{new int[42]};
  std::shared_ptr<int[]> ptr2{std::make_shared<int[]>(42)};
  ptr2[3];    // 配列アクセス可能
  return 0;
  // デストラクタで正しくdelete[]される
}

 std::unique_ptrと同様に配列版にはoperator[]のオーバーロードがある代わりに、operator->operator*のオーバーロードがなくなります。
 基本的にはstd::shared_ptrよりstd::unique_ptrを使った方が高速ですが、必要に応じてstd::shared_ptrも使いましょう。ただし、std::shared_ptrには気を付けなければいけない点があります。それは循環参照です。つまり、ポインタを辺とみなす有向グラフが巡回グラフになるケースです。

struct S {
  std::shared_ptr<S> ptr;
};

int main() {
  std::shared_ptr<S> sptr = std::make_shared<S>();   // ここで新しく確保したSを(A)とする
  sptr->ptr = std::make_shared<S>();                 // ここで新しく確保したSを(B)とする
  sptr->ptr->ptr = sptr;                             // (B)のポインタに(A)のアドレスをコピー
  // sptr -> (A) <-> (B) のような形になっている。
  // つまり、(A)と(B)で循環している
  return 0;
  // sptrが破棄されても(A)も(B)も互いに参照しあっているので、両方とも参照カウンタは 1
  // つまり解放されない。メモリリーク
}

 循環参照するとメモリリークを起こします。これを解決するために、std::weak_ptrがあります。

std::weak_ptr

 std::weak_ptrは主にstd::shared_ptrの循環参照を解決する目的で使われます。それ以外の目的で使っているのはあまり見たことがありません。std::weak_ptrはメモリ管理を一切行わず、std::shared_ptrにメモリ管理の全権を委ねているので、参照切れを起こす場合があり、注意が必要です。
 基本的にstd::weak_ptrを使う際はlock()してから使います。実際にはstd::weak_ptrから直接メモリにアクセスするのではなく、lock()の戻り値のstd::shared_ptrを使ってメモリにアクセスします。

#include <memory>
int main() {
  std::shared_ptr<int> sptr = std::make_shared<int>();
  std::weak_ptr<int> wptr = sptr;
  {
    std::shared_ptr<int> ptr = wptr.lock();   // lock() は std::shared_ptr を返す
    if (ptr) {             // ptr != nullptr か確認 (参照切れしていないかどうか)
      int x = *ptr;        // ptrが生存している間は安全に使える
    }
  }                        // ptrの生存期間が終わると、安全に使える期間は終了する
  return 0;
}

 さて、これで本当に循環参照を解決できているか確認しましょう。

#include <memory>
struct W;
struct S {
  std::shared_ptr<W> ptr;
};
struct W {
  std::weak_ptr<S> ptr;
};

int main() {
  std::shared_ptr<S> sptr = std::make_shared<S>();   // ここで新しく確保したSを(A)とする
  sptr->ptr = std::make_shared<W>();                 // ここで新しく確保したWを(B)とする
  sptr->ptr->ptr = sptr;                             // (B)のポインタに(A)のアドレスをコピー
  // sptr -> (A) <-> (B) という形になっている
  // std::shared_ptr だけに注目すると sptr -> (A) -> (B) という形
  // std::weak_ptr だけに注目すると sptr    (A) <- (B) という形
  return 0;
  // sptrが破棄されると(A)は参照カウンタが0になり (std::weak_ptrはカウントされない) 解放される
  // (A)が破棄されると(B)は参照カウンタが0になり解放される
}

 解決できましたね。めでたしめでたし。

終わりに

 今回はメモリ管理の話でした。スマートポインタは便利ですが、一度は生ポインタでのメモリ管理をやっておいて損はないと思います。次回はexplicitの話かテンプレートの話のどちらかです。お楽しみに。

おしまい

3
4
0

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
3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?