🐙

C++: スマートポインタのメモリ管理の仕組み

2023/11/12に公開

概要

C++11 から導入されているスマートポインタ(unique_ptr, shared_ptrなど) のメモリ管理の仕組みについて調べたことをまとめています。スマートポインタでは、new/deleteの関係ように自分で意図的に delete をしなくても、その変数の生存範囲(スコープ)が終わったタイミングで自動で破棄されるため、動的に確保されたメモリのメモリリークを防ぐことが出来ます。

ここではスマートポインタ自体の前提知識について説明しませんが、C++11スマートポインタ入門が非常に良くまとまっているため、必要があればこれを読むことをお勧めします。

また、ソフトウェアデザイン 2021年9月号でも、C/C++のメモリ管理について概要が少しだけ説明されています。

参照先が誰も居なくなった時点でメモリ解放

スマートポインタの場合、その変数を参照する人が誰も居なくなった時点で自動的にメモリが開放されます。例えば以下のコードの場合、data 変数はそれが位置するスコープを抜けた時点で自動的に data が delete されます。

 例1 生存範囲のスコープを抜けた時点で data は自動的に開放される
#include <iostream>
#include <memory>

int main() {
  {
    auto data = std::make_unique<int>(0);
    *data = 1;
    std::cout << *data << std::endl;
  }
  return 0;
}

ある関数もしくはあるクラスのメンバ変数として存在する場合も同じで、参照している人がゼロになった時点(以下の例だと Mem クラスインスタンスが破棄されたタイミング) でスマートポインタの変数も自動的に破棄されます。

例2 Mem クラスのインスタンスが破棄されるタイミングで data は自動的に開放される
class Mem {
 public:
  Mem(std::unique_ptr<int> data) : data_(std::move(data)) {}

 private:
   std::unique_ptr<int> data_;
};

int main(){
  auto data = std::make_unique<int>(0);
  // data の所有権は mem に移動
  auto mem = Mem(std::move(data));
  return 0;
}

スマートポインタ内部の仕組み

ここからが本題ですが、ではスマートポインタは内部でどのようにして参照先を管理しているのでしょうか? unique_ptrshared_ptr の 2 つについて簡易実装モデルを用いて説明していきます。

unique_ptr の簡易実装

unique_ptr は仕様上参照先は一人だけで、std::move を利用して所有権を移管していきます。最終的にその変数が存在するスコープ(生存範囲/スタック)から抜けた時に、unique_ptr のデストラクタで実体変数のポインタを delete します。

ポイントは以下の3点です。

  1. 参照先が一人だけ
  2. std::move を使った所有権の移動(それ以外のコピー等は禁止)
  3. 参照先(unique_ptr の存在しているスコープ) が破棄されるタイミングでメモリを開放 (delete)

コピーコンストラクタ/コピー代入演算子を用いて、コピーや代入操作を禁止することで実現が出来ます。また、ムーブコンストラクタとムーブ代入演算子を定義してあげ、ムーブ時には移動元のポインタをクリアし、移動先だけで参照するようにするなどして対応可能です。

以下、unique_ptrの簡易実装のサンプルを示します。

unique_ptr の簡易実装サンプル
#include <iostream>
#include <memory>

template <typename T>
class unique_ptr {
public:
  // コンストラクタ
  unique_ptr() {}
  unique_ptr(T* pointer) : pointer_(pointer) {}

  // デストラクタ
  ~unique_ptr() {
    // デストラクタで管理対象の変数を delete
    if (pointer_) {
      delete pointer_;
    }
  }

  // コピーコンストラクタ (コピー禁止)
  unique_ptr(const unique_ptr&) = delete;

  // コピー代入演算子 (コピー禁止)
  unique_ptr& operator=(const unique_ptr&) = delete;

  // ムーブコンストラクタ
  unique_ptr(unique_ptr&& v)
    : pointer_(v.pointer_) { // 移譲元の参照を引き継ぐ
    v.pointer_ = nullptr; // 移譲元の参照ポインタをクリア
  }

  // ムーブ代入演算子
  unique_ptr& operator=(unique_ptr&& v) {
    if (&v == this) { // a = a; のような自己代入の対応 (何もしない)
      return *this;
    }

    auto* temp = pointer_; // 確実にコピー終了後に古いデータを削除するために一旦tempにコピー
    pointer_ = v.pointer_; // 移譲元の参照を引き継ぐ
    v.pointer_ = nullptr; // 移譲元の参照ポインタをクリア
    delete temp; // 代入前の古いデータは削除する

    return *this;
  }

  // 実体変数の値参照
  T& operator*() const { return *pointer_; }

  // 実体変数のポインタ参照
  T* operator->() const { return pointer_; }

  // getメソッド: 実体変数のポインタ取得
  T* get() const { return pointer_; }

  // releaseメソッド: 実体変数のポインタ取得 & 管理対象から外す
  T* release() {
    auto* temp = pointer_;
    pointer_ = nullptr;
    return temp;
  }

 private:
  T* pointer_ = nullptr; // 実体変数へのポインタ
};

int main() {
  auto a = unique_ptr<int>(new int(1));
  *a = 2;
  auto b = std::move(a);
  std::cout << *b << std::endl;

  return 0;
}

shared_ptr の簡易実装

続いて、shared_ptr の簡易版を実装してみます。shared_ptr はご存知の通り、参照先が複数存在することが出来ます。そのため、普通にコピーも出来ますし、最後の参照先が破棄された時にメモリを開放するように、現在参照している人を管理(把握)しておく必要があります。この参照先を把握するためによく用いられるのが参照カウンタです。今回はこれを利用します。

以下、shared_ptrの簡易実装のサンプルを示します。

unique_ptr の簡易実装サンプル
#include <iostream>
#include <memory>

template <typename T>
class shared_ptr {
public:
  // コンストラクタ
  shared_ptr() {}
  shared_ptr(T* pointer)
    : pointer_(pointer), count_(new size_t(1)) {}

  // デストラクタ
  ~shared_ptr() {
    release(); // 参照先が破棄されたので参照カウンタを更新
  }

  // コピーコンストラクタ
  shared_ptr(const shared_ptr& v)
    : pointer_(v.pointer_), count_(v.count_) {
    increment_count();
  }

  // コピー代入演算子
  shared_ptr& operator=(const shared_ptr& v) {
    if (&v == this) { // a = a; のような自己代入の対応 (何もしない)
      return *this;
    }

    pointer_ = v.pointer_;
    count_ = v.count_;
    increment_count();
    return *this;
  }

  // ムーブコンストラクタ
  shared_ptr(shared_ptr&& v)
    : pointer_(v.pointer_), count_(v.count_) { // 移譲元の参照を引き継ぐ
    // 移譲元の情報はクリアして、利用できなくする
    v.pointer_ = nullptr;
    v.count_ = nullptr;
  }

  // ムーブ代入演算子
  shared_ptr& operator=(shared_ptr&& v) {
    pointer_ = v.pointer_ ;
    count_ = v.count_;

    v.pointer_ = nullptr ;
    v.count_ = nullptr ;

    return *this;
  }

  // 実体変数の値参照
  T& operator*() const { return *pointer_; }

  // 実体変数のポインタ参照
  T* operator->() const { return pointer_; }

  // getメソッド: 実体変数のポインタ取得
  T* get() const { return pointer_; }

 private:
  // 参照先をリリースする
  void release() {
    if (!pointer_ || !count_) {
      return ;
    }

    decrement_count();
    if (*count_ == 0) { // 参照先が ゼロになったら、実体データを開放する
      delete pointer_;
      pointer_ = nullptr;
      delete count_;
      count_ = nullptr;
    }
  }

  // マルチスレッドの場合、count_の参照更新はクリティカルセクションとして排他制御が必要 (今回は省略)
  void increment_count() {
    if (!count_) {
      return ;
    }
    ++*count_;
    std::cout << "count = " << *count_ << std::endl;
  }

  // マルチスレッドの場合、count_の参照更新はクリティカルセクションとして排他制御が必要 (今回は省略)
  void decrement_count() {
    if (!count_) {
      return ;
    }
    --*count_;
    std::cout << "count = " << *count_ << std::endl;
  }

  T* pointer_ = nullptr; // 実体変数へのポインタ
  size_t* count_ = nullptr; // 参照カウンタ
};

int main() {
  std::cout << "make_shared" << std::endl;
  auto a = shared_ptr<int>(new int(1)); // 参照カウンタ = 1
  *a = 2;
  {
    std::cout << "コピーコンストラクタ" << std::endl;
    auto b = a; // 参照カウンタ = 2 (コピーコンストラクタ)
    *b = 1;

    std::cout << "コピー代入演算子" << std::endl;
    shared_ptr<int> c;
    c = b; // 参照カウンタ = 3 (コピー代入演算子)
    *c = 3;
    std::cout << "スコープを抜ける" << std::endl;
  }  // 参照カウンタ = 1

  std::cout << "ムーブコンストラクタ" << std::endl;
  auto d = std::move(a);
  std::cout << *d << std::endl;

  std::cout << "ムーブ代入演算子" << std::endl;
  shared_ptr<int> e;
  e = std::move(d);
  std::cout << *e << std::endl;

  std::cout << "終わり" << std::endl;

  return 0;
}

おまけ: 参照カウンタではなく、リストを使って shared_ptr を実現する

上記のサンプルコードの通り、shared_ptr の実装には参照カウンタを用いました。しかし、これには実行速度の観点で少し課題があります。カウンタを複数人で参照利用するために、最初に作成時に new をする必要があります。しかし、実行速度的にnew/deleteは高速ではありません。そのため、shared_ptrを大量に生成・廃棄するようなケースにおいて、実行速度が問題になることがある様子です。

この問題を解決するために、new を使わない別の方法として、双方向リストで参照先を管理する方法があるようです。詳しくはリンクリスト方式なスマートポインタを参照してみてください。

時間がある&気が向いたらここも更新したいと思います。

Discussion