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

履歴 編集

可変サイズをもつコンテナのconstexpr化(C++20)

概要

C++20より、定数式における動的メモリ確保と解放が許可される。それに伴い、std::vectorstd::stringの全メンバ関数がconstexpr対応し、定数式で使用できるようになる。

constexpr int test_vector() {
  std::vector<int> v = {5, 3, 2, 9, 1, 0, 4};
  v.push_back(11);

  int s{};
  for(auto n : v) {
    s += n;
  }

  return s;
}

constexpr bool check_cpp_file(const std::string& filename) {
  return filename.ends_with(".cpp") || filename.ends_with(".hpp");
}

static_assert(test_vector() == 35);         // OK
static_assert(check_cpp_file("main.cpp"));  // OK

これは主に以下の変更によって達成されている。

  • デストラクタのconstexpr対応
  • new/delete式のconstexpr対応
  • std::allocator/std::allocator_traitsconstexpr対応

仕様

constexprデストラクタ

デストラクタにconstexprを付加し、デストラクタを定数式で実行する事が可能となる。これはユーザー定義のデストラクタでも同様。

そのようなconstexprデストラクタの本体、およびそのクラスの基底クラスと非静的メンバ変数の全てのデストラクタは定数式で実行可能でなくてはならない。

struct C : base {

  // constexprデフォルトデストラクタ
  constexpr ~C() = default;

  // あるいは定義しても良い
  constexpr ~C() {
    // 何か定数式で可能な処理
    // ...
  }

  // 全ての基底クラスおよび非静的メンバ変数もまた定数式でデストラクト可能でなければならない
  int n;
  std::string str; 
};

default指定した時の振る舞いは、constexprコンストラクタにdefault指定した時の振る舞いに準ずる。例えば、defaultデストラクタ(特に、トリビアルデストラクタ)はその処理が全て定数式で実行可能であるならば、暗黙的にconstexprである。

これに伴って、クラス型のリテラル型はconstexprデストラクタを持つ事が追加で要求されるようになる。そして、クラス型のconstexpr変数は、その型がリテラル型で初期化が定数式で可能であり、かつデストラクタが定数式で実行可能でなくてはならなくなる。

C++17までは、クラス型のリテラル型はトリビアルデストラクタを要求されており、そのconstexprオブジェクトは初期化が定数式で実行可能であることだけが要求されていた。そのため、C++17までのリテラル型はC++20においてもリテラル型であり、定数式での扱いは変わらない。

なお、クラスが仮想基底クラスを持つ時、デストラクタもコンストラクタもconstexpr指定することはできない。

new/delete

定数式では未定義動作を可能な限り検出しコンパイルエラーとしなければならない。operator new/operator deletemalloc/freeはその実行に伴ってポインタの再解釈(void*への/からのキャスト)が必要となるが、ポインタの再解釈は検出しづらい未定義動作に繋がりうるため定数式では禁止されている。

そのため、そのようなポインタの再解釈が発生しない動的メモリ確保機能であるnew/delete式がコンパイル時の動的メモリ確保・解放の方法として許可される。new/delete式はoperator new/operator deleteとは異なり、メモリの確保・解放とその領域のオブジェクト構築・破棄を一挙に行う言語機能である。

constexpr int f() {
  // 確保と構築
  int* p = new int;

  *p = 20;
  int n = *p;

  // 破棄と解放
  delete p;

  return n;
}

当然ながら、new/delete式によって動的メモリ確保しようとする型はリテラル型であり、呼び出されるコンストラクタとデストラクタは共に定数式で実行可能でなければならない。

また、コンパイル時に実行されるnew式はグローバルのオーバーロード可能なoperator newを呼び出すものでなくてはならない。そうではないnew式の定数式における評価はコンパイルエラーとなる。

struct S {
  int n = 10;

  // 仮に定数式で実行可能なように定義されていたとしても
  constexpr void* operator new(std::size_t n);
  constexpr void operator delete(void* p) noexcept;
};

constexpr int f() {
  S* s = new S{}; // NG、ユーザー定義operator newの呼び出し

  s->n = 20;
  int n = s->n;

  delete s;

  return n;
}

そして、コンパイル時にnew式で確保されたメモリ領域は、コンパイル時にdelete式によって解放されなければならない。その対応が取れていないnew/delete式の呼び出しは、どちらもコンパイルエラーとなる。

constexpr int f() {
  int* p = new int;

  *p = 20;
  int n = *p;

  // 忘れる
  //delete p;

  return n;
}

int main () {
  constexpr int n = f();  // NG、コンパイルエラー
}

したがって、C++20のコンパイル時動的メモリ確保の仕様では、コンパイル時に確保したメモリ領域を実行時へ持ち越すことはできない。

実際には、これらの定数式中のnew式において呼び出される::operator new()の評価は常に省略されている。この省略はC++14より許可されている最適化の一環として行われ、スタック領域などのストレージを別途あてがうことで動的メモリ確保を避けるものである。対応するdelete式における::operator delete()の呼び出しも同様に省略され、定数式におけるnew/delete式はメモリの確保と解放が一貫していることのマーカーとしての側面が強くなっている。

constexpr void f() {
  // このコードは定数式中で
  int* d = new int{2};
  delete d;

  // たとえば次のようなコードと等価になる
  int d{2};
}

実際にはどこのストレージが提供されるかは実装定義である。

std::allocator/std::allocator_traits

標準ライブラリのコンテナ等はnew/delete式を直接利用するわけではなく、std::allocator_traitsを介してstd::allocatorを使用してメモリ確保・解放とオブジェクト構築・破棄を行う。std::allocator/std::allocator_traitsも見かけ上はポインタの再解釈を必要とせずにそれを行うため、std::allocator/std::allocator_traitsによるメモリ確保周りの機能もまた、コンパイル時の動的メモリ確保・解放の方法として許可される。

std::allocator/std::allocator_traitsではnew/delete式とは異なり、メモリの確保・解放(allocate/deallocate)とその領域へのオブジェクト構築・破棄(construct/destroy)の操作が複合していない。オブジェクト構築・破棄においてはplacement newpseudo-destructor callが必要となるが、placement newはポインタの再解釈が必要となることから許可されず、そのために不必要であるのでpseudo-destructor callも許可されない。

代わりに、placement newを行うライブラリ機能であるconstruct_atを追加し、pseudo-destructor callを行うdestroy_atと共にconstexprを付加し定数式で使用可能とする。これらの関数はvoid*ではなくT*を取るため、これによってポインタ再解釈を回避しつつplacement newpseudo-destructor callが定数式で使用可能となる。

そして、std::allocator_traitsconstructdestroyconstruct_at/destroy_atを呼び出して処理を行うように変更される。なお、これによって実行時の振る舞いが変化することはない。

constexpr int f() {
  std::allocator<int> alloc{};

  // 確保と構築
  int* p = alloc.allocate(1);
  p = std::construct_at(p);

  *p = 20;
  int n = *p;

  // 破棄と解放
  std::destroy_at(p);
  alloc.deallocate();

  return n;
}

当然ながら、std::allocatorによって動的メモリ確保しようとする型はリテラル型であり、construct_at/destroy_atによって呼び出されるコンストラクタとデストラクタは共に定数式で実行可能でなければならない。

また、std::allocator<T>::allocateが呼び出される場合は必ずその領域はstd::allocator<T>::deallocateによって解放されていなければならず、deallocateconstruct_atdestroy_atの引数のT*のポインタはstd::allocator<T>::allocateによって確保された領域を指していなければならない。

constexpr int f() {
  std::allocator<int> alloc{};

  // 確保と構築
  int* p = alloc.allocate(1);
  p = std::construct_at(p);

  *p = 20;
  int n = *p;

  // 忘れる
  //std::destroy_at(p);
  //alloc.deallocate();

  return n;
}

int main () {
  constexpr int n = f();  // NG、コンパイルエラー
}

すなわち、new/delete式と同様にコンパイル時に確保したメモリ領域を実行時へ持ち越すことはできない。

この規則はまた、std::allocator/std::allocator_traitsによって確保されconstruct_atによってオブジェクトが構築された領域をdelete式で解放する事、またはその逆も許可されない事を示している。

constexpr int f() {
  std::allocator<int> alloc{};

  // 確保と構築
  int* p = alloc.allocate(1);
  p = std::construct_at(p);

  *p = 20;
  int n = *p;

  // 破棄と解放
  delete p; // NG、コンパイルエラー

  return n;
}

constexpr int g() {
  std::allocator<int> alloc{};

  // 確保と構築
  int* p = new int;

  *p = 20;
  int n = *p;

  // NG、コンパイルエラー
  std::destroy_at(p);
  alloc.deallocate();

  return n;
}

destroy_atには類似のファミリとしてdestroy_nと、それらのrange版があり(あるいは追加され)、construct_atrange版が同時に追加されるが、それらについてもconstruct_at/destroy_atと同様の扱いが可能となる。

std::allocator::allocate()はグローバルの::operator new()を呼び出すが、この呼び出しはnew式の時と同様に省略されており、std::allocator::deallocate()における::operator delete()の呼び出しも省略されている。この2つもまたnew/delete式と同様に、メモリの確保と解放が一貫していることのマーカーとしての側面が強くなっている。

結局、C++20のコンパイル時動的メモリ確保は定数式にヒープ領域を導入するものではなく、デフォルトの::operator newによる動的メモリ確保を別の領域をあてがう形に置換することで行われている。

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

std::vectorをはじめとする可変サイズのコンテナは実行時に非常に有用であるため、同様に定数式においても有用である可能性があり、その必要性がC++コミュニティからも示されいていた(C++Now 2017: Ben Deane & Jason Turner "constexpr ALL the things!"P0810R0 constexpr in Practiceなど)。

また、静的リフレクション機能の導入にあたっては、コンパイル時に使用可能な可変サイズコンテナおよび可変サイズの文字列型が必要となっていた。例えば、ある型のテンプレート引数をクエリするコードは次のようなものになる

// 型Tのテンプレート引数の情報を取り出す
std::vector<std::metainfo> args = std::meta::get_template_args(reflexpr(T));

※ これは最終的なリフレクション仕様とは異なる可能性がある

これらの流れを受けて、std::vectorstd::stringを定数式で使用可能とするために、その最大の障壁となっていたメモリの動的確保と解放周りの機能が定数式で使用可能となった。

検討されたほかの選択肢

当初検討されていた仕様では、コンパイル時に確保したメモリ領域を実行時に持ち越すことが可能だった。そのようなメモリ領域の確保と解放はクラス型の内部で閉じている必要はあったが、その条件を満たせば静的ストレージに昇格され実行時環境から参照できるようになる。

しかし、当初のアプローチには2つの問題があった。

実行時に持ち越されるメモリ領域を管理するクラスであってもそのデストラクタでその領域を解放している事が求められていたが、それはコンパイラによるテスト要件であり実行時に領域を持ち越そうとする時、実際にそのデストラクタがコンパイル時に呼ばれることはない。しかしその場合、静的ストレージに昇格される領域の内容はいつどの時点のものが保持されるのかが不透明となる。

当初の仕様ではそれに対処するために、std::mark_immutable_if_constexpr()という関数を導入し、この関数に領域へのポインタを渡して呼び出すことでコンパイラへのマーカーとし、呼ばれた時点でのメモリ領域を実行時に持ち越すアプローチをとっていた。

template<typename T>
struct sample {
  std::allocator<T> m_alloc;
  T* m_p;
  size_t m_size;

  // 非トリビアルconstexprコンストラクタでメモリ領域を確保
  template<size_t N>
  constexpr sample(T(&p)[N])
    : m_alloc{}
    , m_p{m_alloc.allocate(N)}
    , m_size{N}
  {
    for(size_t i = 0; i < N; ++i) {
      std::construct_at(m_p + i, p[i]);
    }

    // 実行時に持ち越す領域をコンパイラに伝える
    // ここ以降は確保した領域は不変
    std::mark_immutable_if_constexpr(m_p);
  }

  // constexprデストラクタでメモリ領域を解放
  constexpr ~sample() {
    for(size_t i = 0; i < N; ++i) {
      std::destroy_at(m_p + i);
    }
    m_alloc.deallocate(m_p, m_size);
  }
}

constexpr sample<char> str{"Hello."};
// 実行時、strは"Hello"を保持する静的配列を参照するようになる

2つ目の問題は、コンパイル時に確保された領域は実行時にconstであり書き換えられてはならないが、クラス型のconst伝播の問題から書き換えが可能となってしまっていたことである。

// 当初の仕様ではOK(unique_ptrがconstexpr対応した場合)
constexpr std::unique_ptr<std::unique_ptr<int>> uui 
  = std::make_unique<std::unique_ptr<int>>(std::make_unique<int>());

int main() {
  std::unique_ptr<int>& ui = *uui; // これができてしまう
  ui.reset(); // 静的ストレージの領域をdeleteする?
}

std::unique_ptrではそれ自身のconst性が内部のポインタの参照するオブジェクトまで伝播しないため、コンパイル時に確保されたメモリ領域を参照するようなstd::unique_ptrからは、可変な参照を取得できてしまう。上記例のようにstd::unique_ptrがネストしていれば、そのような領域をdeleteすることもできてしまっていた。

これらの問題について、std::mark_immutable_if_constexpr()によるアプローチを標準化委員会が嫌ったことと、2つ目の問題の解決が簡単ではなかった(時間がかかり得た)事から、コンパイル時に確保したメモリを実行時に持ち越すことについてはC++20への導入を見送ることとなった。

関連項目

参照