最終更新日時(UTC):
が更新

履歴 編集

可変長データを扱うクラスの効率的なdelete(C++20)

概要

従来のクラススコープでのoperator deleteオーバーロードにおいて、オーバーロードされたoperator deleteが呼び出されるのはそのクラスのオブジェクトが破棄された後であり、そのオーバーロード内からはそのクラスのオブジェクト(及びメンバ)にアクセスすることはできなかった。

#include <iostream>

struct S {
  std::string str;

  // #1 Sのための operator delete オーバーロード
  void operator delete(void* p) {
    // Sのオブジェクトは破棄済みのため、未定義動作となる
    const S* self = reinterpret_cast<const S*>(p);  // UB
    std::cout << self->str;   // UB

    // メモリ領域の解放
    ::operator delete(p);
  }
};

int main() {
  S* p = new S{};

  // Sのデストラクタ呼び出しの後#1が呼び出される
  delete p;
}

delete式は指定されたポインタの指しているオブジェクトを破棄した後にそのオブジェクトが占めていたメモリ領域を解放するが、operator deleteのオーバーロードはオブジェクト破棄後のメモリ領域解放のみを担うため、operator deleteのオーバーロードが呼ばれた時点でその対象の領域上のオブジェクトは生存期間を終えている。

C++20から、第ニ引数にstd::destroying_delete_tを取るようにoperator deleteをオーバーロードすることで、delete式の行う全ての振る舞いをハンドリングできるようになり、operator deleteのオーバーロード内からdelete対象のオブジェクトに安全にアクセスすることができる。

#include <iostream>
#include <new>
#include <memory>

struct S {
  std::string str;

  // #1 Sのための operator delete オーバーロード
  void operator delete(S* p, std::destroying_delete_t) {
    // pの指すオブジェクトはデストラクタ呼び出し前のため、安全に参照できる
    const S& self = *p;     // ok
    std::cout << self.str;  // ok

    // デストラクタ呼び出しをしなければならない
    std::destroy_at(p);
    // メモリ解放
    ::operator delete(p);
  }
};

int main() {
  S* p = new S{"test"};

  // Sのデストラクタは呼ばれずに#1が呼び出される
  delete p;
}

このように、std::destroying_delete_tを取るoperator deleteのオーバーロードのことをdestroying operator deleteと呼ぶ。destroying operator deleteにおいては、delete式が行う全てのことをユーザーが行う必要がある。

仕様

クラススコープで定義されたoperator delete()で、第二引数がstd::destroying_delete_tであるものをdestroying operator deleteと呼ぶ。クラスCに対するdestroying operator deleteの第一引数はC*でなければならず、それらの点以外は通常のoperator deleteオーバーロードの制約に従う。

struct S {
  // これらの宣言はいずれも、destroying operator deleteではない(コンパイルエラーとなる)
  void operator delete(void*, std::destroying_delete_t);      // 第一引数はS*
  void operator delete(S*, auto);                             // 第二引数は std::destroying_delete_t
  int* operator delete(S*, std::destroying_delete_t);         // 戻り値はvoid
  virtual void operator delete(S*, std::destroying_delete_t); // 仮想関数にできない
};

単一オブジェクトに対するdelete式の実行に伴うoperator deleteの探索の結果、その候補の中にdestroying operator deleteが含まれている場合、destroying operator deleteではないoperator deleteは候補から外れる。

struct S {
  // destroying operator delete
  void operator delete(S*, std::destroying_delete_t);

  // destroying operator deleteが定義されている限り、こちらが呼び出されることはない
  void operator delete(void*);
};

ただし、配列に対するdelete式の場合は、destroying operator deleteは考慮されない。

struct S {
  // #1 destroying operator delete
  void operator delete(S* p, std::destroying_delete_t) {
    std::cout << "S::operator delete()\n";

    std::destroy_at(p);
    ::operator delete(p);
  }
};

int main() {
  S* p = new S[1]{};

  // #1は呼び出されず、要素ごとに破棄された後で配列の領域が解放される
  delete[] p;
}

また、配列板のoperator delete[]destroying operator deleteとしてオーバーロードすることもできない。

struct S {
  void operator delete[](S* p, std::destroying_delete_t); // ng
};

単一オブジェクトに対するdelete式においてそのオブジェクトの静的型と動的型が一致しない場合(つまり、基底クラスのポインタから派生クラスのオブジェクトをdeleteしようとする場合など)、その静的型はその動的型の基底クラスである必要があり両方の型に仮想デストラクタが必要となるが、destroying operator deleteoperator deleteとして使用される場合にはその必要はない。したがって、destroying operator deleteが使用されない場合にdelete式が指定されたポインタの指す最も派生したオブジェクトを削除せず未定義動作になるような場合(仮想デストラクタの定義忘れなど)でも、destroying operator deleteが使用された場合は未定義動作とならない(ただし、呼び出されたdestroying operator deleteがそのオブジェクトを正しく破棄しない場合は未定義動作となりうる)。

destroying operator deleteoperator deleteとして使用されるdelete式の実行において、delete式はdelete対象オブジェクトのデストラクタを呼び出さないでoperator deleteを呼び出す。また、その際のdestroying operator deleteの第二引数(std::destroying_delete_tに対応する引数)に渡される値は未規定

destroying operator deleteに限らず全てのoperator deleteオーバーロードにおいて、delete式に指定されているポインタの指すオブジェクトがクラス型であり、そのデストラクタが仮想デストラクタである場合、そのdelete式の実行に伴うoperator deleteの探索はそのオブジェクトの動的型(実行時の実際のクラス型)のスコープで行われる。この探索は、クラスの仮想関数を基底クラスから呼び出す時と同じものである。

// 基底クラス
struct B {
  virtual ~B();

  // 普通のdelete演算子オーバーロード
  void operator delete(void*, std::size_t);
};

// 派生クラス1
struct D : B {
  // 普通のdelete演算子オーバーロード
  void operator delete(void*);
};

// 派生クラス2
struct E : B {
  void log_deletion();

  // destroying operator delete
  void operator delete(E *p, std::destroying_delete_t) {
    p->log_deletion();
    p->~E();
    ::operator delete(p);
  }
};

void f() {
  B* bp = new D;
  delete bp; // #1 D::operator delete(void*)が呼び出される

  bp = new E;
  delete bp; // #2 E::operator delete(E*, std::destroying_delete_t)が呼び出される
}

#1において、Dのオブジェクトはdelete式によって破棄され、そのメモリ領域はD::operator deleteによって解放される。
#2において、Eのオブジェクトの破棄とそのメモリ領域の解放はE::operator deletedestroying operator delete)によって行われる。

可変サイズクラスのdelete

#include <iostream>
#include <string_view>
#include <string>
#include <new>
#include <memory>
#include <cstring>

class inlined_fixed_string {
  size_t length;

  inlined_fixed_string(size_t size, const char *data)
    : length(size)
  {
    // 自身の後ろにある領域に文字列をコピーする
    // P0593R6の効果によって、charオブジェクトの生存期間が自動開始される(C++20以降)
    std::memcpy(this + 1, data, size);
  }

public:
  inlined_fixed_string() = delete;

  auto size() const -> std::size_t {
    return length;
  }

  auto view() const -> std::string_view {
    // thisの後ろにはlength個のcharオブジェクトが生存期間内にあるため、このreinterpret_castは問題ない
    return { reinterpret_cast<const char*>(this + 1), this->size() };
  }

  // std::stringから構築するファクトリ関数
  static auto Make(const std::string& str) -> inlined_fixed_string* {
    // 自身の末尾に固定長(実行時可変長)の文字列用領域を確保する
    const std::size_t full_size = sizeof(inlined_fixed_string) + str.length() + 1;

    // パディングしたサイズでメモリの確保
    // new式ではなくグローバルnew演算子の呼出
    auto* p = ::operator new(full_size);

    // ログ出力
    std::cout << "inlined_fixed_string::Make() : " << full_size <<" byte allocate.\n";

    // 確保したメモリ領域にplacement newして構築
    // コンストラクタ内で文字列をコピー
    return ::new(p) inlined_fixed_string(str.length() + 1, str.c_str());
  }

  // 確保した領域を含めてdelete
  void operator delete(inlined_fixed_string* p, std::destroying_delete_t) {
    // 末尾領域の長さを取得
    const std::size_t full_size = sizeof(inlined_fixed_string) + p->length;

    // デストラクタ呼び出し
    std::destroy_at(p);

    // char(トリビアルに破棄可能な)型はデストラクタ呼び出しを省略可能
    std::ranges::destroy(p + 1, p + 1 + p->length + 1);

    // メモリ解放
    ::operator delete(p, full_size);

    // ログ出力
    std::cout << "inlined_fixed_string::operator delete() : " << full_size <<" byte deallocate.\n";
  }
};

int main() {
  std::string str = "C++20 destroying operator delete test.";
  auto* p = inlined_fixed_string::Make(str);

  std::cout << p->view() << "\n";

  delete p;
}

出力

inlined_fixed_string::Make() : 47 byte allocate.
C++20 destroying operator delete test.
inlined_fixed_string::operator delete() : 47 byte deallocate.

正しいデストラクタへのディスパッチ

#include <iostream>
#include <new>

// 仮想デストラクタを定義しない基底クラス
struct base {
  int kind = 0;

  // destroying operator delete宣言 #1
  void operator delete(base* p, std::destroying_delete_t);
};

struct derived1 : base {
  derived1() : base{1} {}
};

struct derived2 : base {
  derived2() : base{2} {}
};

// #1に対応する定義
void base::operator delete(base* p, std::destroying_delete_t) {

  switch(p->kind) {
    case 1:
    {
      auto* dp = static_cast<derived1*>(p);
      std::destroy_at(dp);
      ::operator delete(dp);
      std::cout << "destruct derived1\n";
      break;
    }
    case 2:
    {
      auto* dp = static_cast<derived2*>(p);
      std::destroy_at(dp);
      ::operator delete(dp);
      std::cout << "destruct derived2\n";
      break;
    }
    default:
      std::destroy_at(p);
      ::operator delete(p);
      std::cout << "destruct base\n";
  }
}

int main() {
  {
    base* p = new derived1{};
    // #1によってderived1のデストラクタが呼ばれ、メモリが解放される
    delete p;
  }
  {
    base* p = new derived2{};
    // #1によってderived2のデストラクタが呼ばれ、メモリが解放される
    delete p;
  }
}

出力

destruct derived1
destruct derived2

この機能が必要になった背景・経緯

1つ目の例に挙げたinlined_fixed_stringのような可変サイズクラスの定義は、ポインタの間接参照を回避しながら可変長配列を定義することができ、よく書かれるパターンだった(実際にはflexible array memberを使用することが多い)。

しかし、このようなクラスはサイズ付きデアロケーションを活用して効率的に削除することができなかった。

inlined_fixed_stringのオブジェクトを指すポインタをsとすると、delete s;という式の実行において、C++14(CWG Issue 2248解決前)のコンパイラは次のようなコードを呼び出す必要があった

::operator delete(s, full_size);

しかし、この場合に自動でfull_sizeを取得することはできない。

C++17では代わりに次のようなコードを出力する

::operator delete(s, sizeof(inlined_fixed_string));

が、これは正しく確保したメモリ領域を解放していない。

正しくは、クラスでoperator deleteをオーバーロードする必要がある

static void operator delete(void* ptr) {
  ::operator delete(ptr); // アロケータが知っているptrのサイズ情報に頼る(アロケータでそのサイズを求めるためのオーバーヘッドが発生しうる)
}

すなわち、サイズ付きデアロケーションを全く利用しない。これは安全に解放できるようになる一方で、サイズ付きデアロケーションのパフォーマンス上の利点を全て捨てることになる。

理想的には、クラスが保存している実際に確保したメモリのサイズを取得できることが望ましい

static void operator delete(void* ptr) {
  inlined_fixed_string *s = reinterpret_cast<inlined_fixed_string*>(ptr); // UB
  std::size_t full_size = sizeof(inlined_fixed_string) + s->size();       // UB
  ::operator delete(ptr, full_size);
}

しかし、前述のように、このoperator delete実行の前にptrにあるオブジェクトは破棄されているため、これは未定義動作となる。

destroying operator deleteは、この一番最後のoperator delete相当のコードを安全にし、このような可変長クラスのdeleteを効率化するために導入された。

検討されたほかの選択肢

delete p以外の削除メカニズム

destroying operator deleteが必要となる場合に、delete式ではなく別のメカニズムによってオブジェクトの破棄とメモリ解放を行う方法が検討された。しかし、これに次のような欠点がある

  • ユーザー定義型が組み込み型と同様に使用されるという原則に違反している
    • この方法の場合、delete式が使えなくなる
  • 仮想デストラクタを持つ既存のクラス階層は、動的にクラスレイアウトの先頭/末尾に領域を拡張する派生クラスに対して透過的に拡張できない
  • メモリ解放戦略のローカルな選択がコードの利用者にリークする
    • std::unique_ptrなどにおいてはカスタムデリータを指定しなければならない
    • std::make_uniqueなどは使用できない
    • リソース管理にnew/deleteを使用する多くのリソース管理クラスを使用できない

これらの欠点(特に、delete式が使用できないこと)によって、この方法は好まれなかった。

destroying operator deleteの別の構文

destroying operator deleteを宣言する構文として、いくつかの構文が検討された。

struct S {
  // 1. void*の代わりにS*をとる
  void operator delete(S*);

  // 2. 1+~を先頭につける
  void operator ~delete(S*);

  // 3. デストラクタ風宣言
  ~S delete();
};

  1. 通常のoperator deleteオーバーロードと区別がつきづらい
  2. まだ通常のoperator deleteオーバーロードと区別がつきづらい
    • ~deleteは新しい演算子であるため可読性の問題がある
    • destroying operator deletedelete式の実行を完全にオーバーライドするものだが、異なる演算子オーバーロードになっていることによってそれを表現していない
  3. デストラクタ風味の宣言によって、サブオブジェクト(メンバや基底クラス)のデストラクタが自動で呼ばれるという勘違いを招く可能性がある。
    • 加えて、2と同様の問題がある

結局、std::destroying_delete_tを第二引数に取る形が一番シンプルかつ可読性が高いとして採用された。

関連項目

参照