[C++]explicit(bool)の使いどころ

※この内容はC++20から利用可能になる予定の情報であり、一部の内容が変更される可能性があります。

explicit(bool)指定子

従来コンストラクタと変換関数に付けることのできていたexplicit指定子は 、C++20よりexplicit(expr)という形のものに置き換えられます。
かっこの中のexprに指定出来るのはboolに変換可能な定数式です。

そして、そのexprtrueとなる場合はそのコンストラクタ(変換関数)はexplictコンストラクタになり、falseとなる場合は通常の非explicitコンストラクタとして扱われます。

また、従来の式を指定しないexplicit指定はexplicit(true)として扱われるようになります。

データメンバ型のexplicit性継承

ではこのexplicit(bool)指定子、一体何が嬉しいのでしょうか?

それを知るために、以下のようにテンプレートによって任意の型を保持するようなクラスを考えてみます。

template<typename T>
struct unit {

  //Tに変換可能なUから構築
  template<typename U=T>
  constexpr unit(U&& other)
    : value(std::forward<U>(other))
  {}

  T value;
};

とりあえず最小限のコンストラクタをそろえておきます。
最小限であるとはいえこれで目的を達することができ、特に問題は無いように思えます。

そこで、このunit型にexplicitコンストラクタを持つ型を入れてみましょう。

struct has_explicit_ctor {
  explicit has_explicit_ctor(int) {}
};

int main() {
  has_explicit_ctor s1{1};     //ok、直接初期化(明示的コンストラクタ呼び出し)
  has_explicit_ctor s2 = {2};  //ng、コピーリスト初期化(暗黙変換)
  has_explicit_ctor s3 = 3;    //ng、コピー初期化(暗黙変換)

  unit<has_explicit_ctor> s4 = {10};  //ok
  unit<has_explicit_ctor> s5 = 10;    //ok
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

ご覧のように、包むunit型のコンストラクタを通すことによって、要素型has_explicit_ctorのexplicitコンストラクタのexplicit性が失われてしまっています。
その結果、初期化時に暗黙変換が行われるようになってしまっています・・・

暗黙変換されたくないのでコンストラクタにexplicitを付けているはずで、この様に別の型に包まれたとしても同じようになってくれなければ困ってしまいます。

では、unitのコンストラクタにexplicitを付加してやりましょう。そうすれば解決ですね。

template<typename T>
struct unit {

  //Tに変換可能なUから構築
  template<typename U=T>
  explicit constexpr unit(U&& other)
    : value(std::forward<U>(other))
  {}

  T value;
};

int main() {
  unit<has_explicit_ctor> u1 = {10};  //ng
  unit<has_explicit_ctor> u2 = 10;    //ng
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

しかしこれにも問題があります。

auto f() -> unit<int> {
  return {128};  //compile error!
}

int main() {
  unit<int> u1 = {10};  //ng
  unit<int> u2 = 10;    //ng
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

はい、今度は非explicitコンストラクタを持つ型を入れた時に意図しないコンパイルエラーが多発します。すべてunit型のコンストラクタがexplicitであるためです。
(これがまさに、C++17においてN4387で解決されたstd::tuplestd::pairのコンストラクタの問題です。)

これを解決する一つの策が、型引数Tのコンストラクタのexplicit性を継承するPerfect Initializationと呼ばれるイディオムです。

template<typename Cond>
using enabler = std::enable_if_t<Cond::value, std::nullptr_t>;

template<typename Cond>
using disabler = std::enable_if_t<!Cond::value, std::nullptr_t>;

template<typename T>
struct unit {

  //Tに暗黙変換可能なUから構築
  template<typename U=T, enabler<std::is_convertible<U, T>> = nullptr>
  constexpr unit(U&& other)
    : value(std::forward<U>(other))
  {}
  
  //Tにexplicitに変換可能なUから構築
  template<typename U=T, disabler<std::is_convertible<U, T>> = nullptr>
  explicit constexpr unit(U&& other)
    : value(std::forward<U>(other))
  {}

  T value;
};

auto f() -> unit<int> {
  return {128};  //ok
}

int main() {
  unit<has_explicit_ctor> s1 = {10};  //ng
  unit<has_explicit_ctor> s2 = 10;    //ng
  
  unit<int> u1 = {10};  //ok
  unit<int> u2 = 10;    //ok
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

std::is_convertible<U, T>UからTに暗黙変換可能かどうかを調べるものです。
それを用いて、暗黙変換可能かどうかでSFINAEして、同じコンストラクタを型Tに応じてexplicitかどうかを切り替えます。割と泥臭い・・・
このようにすることで、このunit<T>のような型は内包する型のexplicit性を継承することができます。

しかし、要するにexplicitの有無の違いだけでなぜ二つも同じコンストラクタを書かなければならないのでしょうか。また、さらにunit<U>const U&などから変換するようなコンストラクタを追加すると同じことをしなければなりません。
これは面倒です、どうにかしたい・・・

そこでようやくexplicit(bool)の出番です。これを使うと、unit型のコンストラクタは簡単になります。

template<typename T>
struct unit {

  //Tに変換可能なUから構築
  template<typename U=T>
  explicit(std::is_convertible_v<U, T> == false)
  constexpr unit(U&& other)
    : value(std::forward<U>(other))
  {}
  
  T value;
};

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

少し見辛い所はありますがexplicit(bool)に暗黙変換可能かどうかを判定する式を渡してやることで、一つのコンストラクタの定義だけで先ほどと同じ効果を得られます。
もはや訳の分からないSFINAEによる分岐は必要ありません。

explicit(bool)はおそらくこの問題のためだけに導入されました。ライブラリで複雑な型を書く場合にはありがたみを感じることができるでしょう・・・

ところで、STLにはこのように内部に任意の型のオブジェクトを保持するようなクラスがいくつかあります。std::tuplestd::pairstd::optionalがその代表です。
std::pairstd::optionalのある1つのコンストラクタを見てみます(std::tupleは説明するには複雑になるのでスキップ・・・)。

std::pairの場合

同じ問題が起きるstd::pairのコンストラクタの(5)を見てみます。

このコンストラクタには要件があります。

(5) : is_constructible<first_type, U&&>::value && is_constructible<second_type, V&&>::valueであること

これを考慮したうえでPerfect Initializationすると、おおよそ以下の様な宣言になります。

//与えられた条件全てがtrueの場合に有効化
template<typename... Cond>
using enabler = std::enable_if_t<std::conjunction<Cond...>::value, std::nullptr_t>;

template<typename T1, typename T2>
struct pair {

  template<class U=T1, class V=T2,
    enabler<
        std::is_constructible<T1, U&&>,
        std::is_constructible<T2, V&&>,
        std::is_convertible<U, T1>,
        std::is_convertible<V, T2>
    > = nullptr
  >
  constexpr pair(U&& x, V&& y);
  
  template<class U=T1, class V=T2,
    enabler<
        std::is_constructible<T1, U&&>,
        std::is_constructible<T2, V&&>,
        std::negation<
          std::conjunction<
            std::is_convertible<U, T1>,
            std::is_convertible<V, T2>
          >
        >
    > = nullptr
  >
  explicit constexpr pair(U&& x, V&& y);

};

先ほどのunitとやってることは同じです。型が二つに増えたので複雑になってしまいました。
要はT1,T2どちらかの型が暗黙変換不可であるとき、コンストラクタにexplicitを付けます。

explicit(bool)を使うと・・・

template<typename T1, typename T2>
struct pair {

  template<class U=T1, class V=T2,
    enabler<
        std::is_constructible<T1, U&&>,
        std::is_constructible<T2, V&&>
    > = nullptr
  >
  explicit(!(std::is_convertible_v<U, T1> && std::is_convertible_v<V, T2>))
  constexpr pair(U&& x, V&& y);

};

要件を判定しSFINAEする部分と、Perfect Initializationする部分とが分離していくらか見やすくなりました。そして1つのコンストラクタ定義で済むようになります。

std::optionalの場合

同じ問題が起きるstd::optionalのコンストラクタの(7)を見てみます。

このコンストラクタには要件があります。

型Tの選択されたコンストラクタがconstexprであれば、このコンストラクタもconstexprとなる
型Uから型Tがムーブ構築可能でなければ、このオーバーロードオーバーロード解決の候補から除外される
型Uから型Tに暗黙的に型変換ができる場合、このオーバーロードは非explicitとなる。
型Uから型Tに明示的な型変換ならできる場合、このオーバーロードはexplicitとなる

§23.6.3.1 Constructors [optional.ctor] - N4659も見るとより詳細が分かります。

これを考慮したうえでPerfect Initializationすると、おおよそ以下の様な宣言になります。

//与えられた条件全てがtrueの場合に有効化
template<typename... Cond>
using enabler = std::enable_if_t<std::conjunction<Cond...>::value, std::nullptr_t>;

template<typename T>
class optional {

  template<typename U=T,
    enabler<
      std::is_constructible<T, U&&>,
      std::negation<
        std::is_same<std::decay_t<U>, std::in_place_t>
      >,
      std::negation<
        std::is_same<std::optional<T>, std::decay_t<U>>
      >,
      std::is_convertible<U&&, T>
    > = nullptr
  >
  constexpr optional(U&& rhs);

  template<typename U=T,
    enabler<
      std::is_constructible<T, U&&>,
      std::negation<
        std::is_same<std::decay_t<U>, std::in_place_t>
      >,
      std::negation<
        std::is_same<std::optional<T>, std::decay_t<U>>
      >,
      std::negation<
        std::is_convertible<U&&, T>
      >
    > = nullptr
  >
  explicit constexpr optional(U&& rhs);

};

もう見るのも嫌ですね。2つを書かされる差は説明してきたようにexplicitを付けるためのstd::is_convertible<U&&, T>の結果がtrueなのかfalseなのかの所だけです。

ではexplicit(bool)を使ってみれば・・・

template<typename T>
optional {

  template<typename U=T,
    enabler<
      std::is_constructible<T, U&&>,
      std::negation<
        std::is_same<std::decay_t<U>, std::in_place_t>
      >,
      std::negation<
        std::is_same<std::optional<T>, std::decay_t<U>>
      >,
    > = nullptr
  >
  explicit(!std::is_convertible_v<U&&, T>)
  constexpr optional(U&& rhs);

};

要件を記述するSFINAE部はどうしようもありませんが、記述が一つにまとまり、explicitとなる条件が分離されて見やすくなっています。

これらのように、explicit(bool)があると自分で別の型をラップするような型を作る際にコンストラクタをいくらか簡単に書くことができるようになります。
特に、optionalの例のように複雑な要件が絡む場合に強い恩恵を感じることができるかと思います。

参考文献

この記事のMarkdownソース

[C++]std::from_charsにおける先頭スペースの扱いについて

C++17で追加されたstd::from_charsはその効果の説明においてCライブラリのstrtol、strtod関数を参照し、そこにいくつか制限を加えた形で説明されています。

ところで、strtol、strtod関数は共に入力文字列の先頭にあるスペースを読み飛ばすようになっています。std::from_charsの説明を単純に読むと先頭スペースの扱いについて何も書かれていないので同じようになるように思えます。
しかし実際にはstd::from_charsは先頭スペースの読み飛ばしを行いません。

それについては少し厄介な書き方をされているため、この記事はそこを読み解くためのメモです。

C++17 std::from_chars

C++17(N4659) §23.2.9 [utility.from.chars]のstd::from_chars関数の効果は以下のように記述されています。

整数型のオーバーロードの効果

The pattern is the expected form of the subject sequence in the "C" locale for the given nonzero base, as described for strtol, (以下略

浮動小数点型のオーバーロードの効果

The pattern is the expected form of the subject sequence in the "C" locale, as described for strtod, (以下略

両方をまとめてざっと訳すと

用いるパターンは、strtol(strtod)で説明されているCロケールによるsubject sequenceのexpected formである

そして、See also: ISO C 7.22.1.3, ISO C 7.22.1.4.と終わりに添えられています。

(subject sequenceは説明のため、expected formはピッタリな訳が思いつかないのでそのままにしておきます・・・)

C11 strtol, strtod

C11(N1570) §7.22.1.3, §7.22.1.4 のstrtol, strtod関数の所には以下のように記述されています(長いので一部抜粋)。

strtod

First, they decompose the input string into three parts: an initial, possibly empty, sequence of white-space characters (as specified by the isspace function), a subject sequence resembling a floating-point constant or representing an infinity or NaN; and a final string of one or more unrecognized characters, including the terminating null character of the input string.
~中略~
The subject sequence is defined as the longest initial subsequence of the input string, starting with the first non-white-space character, that is of the expected form. The subject sequence contains no characters if the input string is not of the expected form.

strtol

First, they decompose the input string into three parts: an initial, possibly empty, sequence of white-space characters (as specified by the isspace function), a subject sequence resembling an integer represented in some radix determined by the value of base, and a final string of one or more unrecognized characters, including the terminating null character of the input string.
~中略~
The subject sequence is defined as the longest initial subsequence of the input string, starting with the first non-white-space character, that is of the expected form. The subject sequence contains no characters if the input string is empty or consists entirely of white space, or if the first non-white-space character is other than a sign or a permissible letter or digit.

浮動小数点と整数とで多少の違いはあれど同じようなことが書かれているのでまとめて訳すと

まず入力文字列を3つの部分に分解する。
初めに、isspace関数で識別されるホワイトスペースのシーケンス(空でも可)
次に(浮動小数点数もしくは整数型の文字列を含む)subject sequence
最後に、残った1文字以上の識別されない文字列(終端の\0を含む)

subject sequenceは最初のホワイトスペース以外の文字で始まる入力文字列の部分文字列として定義される。それはexpected formである。
入力文字列がexpected formでない場合、subject sequenceは空になる。

subject sequence

この両方を参照すると、C++std::from_charsの効果の説明におけるsubject sequenceという言葉はCのstrtol, strtodにおけるsubject sequenceを指している事が分かります。
そして、subject sequenceは先頭のホワイトスペースのシーケンスを除いて最初に現われる、想定するパターンにマッチする文字列から構成されます。すなわち、subject sequenceには先頭ホワイトスペースの文字列は含まれていません。

また、std::from_charsが入力文字列中からまず探すのはsubject sequence(のexpected form)であり、先頭ホワイトスペースについては何ら記述がありません。

これらの事からようやく、std::from_charsは入力文字列先頭に1つ以上のホワイトスペースがある場合にそれを読み飛ばすことはしない、という仕様を読み取ることができます。

参考文献

この記事のMarkdownソース

[C++]ラムダ式と単項+演算子

状態を持たない(つまり、キャプチャをしていない)ラムダ式は暗黙的に同じシグネチャの関数ポインタに変換することができます。
しかし、テンプレートパラメータの推論等のタイミングでは暗黙変換以前にラムダ式の生成する関数オブジェクトとしての型が推論されてしまい、関数ポインタとしてラムダ式を受け取ろうとしてもそのオーバーロードにはマッチしません。

template<typename F>
void invoke(F&& f) {
  std::cout << "call functor." << std::endl;
  std::cout << f() << std::endl;
}

template<typename F>
void invoke(F* f) {
  std::cout << "call function pointer." << std::endl;
  std::cout << f() << std::endl;
}

int main()
{
  invoke([]{ return 10; });
}

/* 出力
call functor.
10
*/

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

ごくたまに、こういう時に何とかして関数ポインタの方に行ってほしいことがあります。そんな時、わざわざ関数ポインタの型をusingしてキャストして...としなくても次のように頭に+を付けることで解決できます。

template<typename F>
void invoke(F&& f) {
  std::cout << "call functor." << std::endl;
  std::cout << f() << std::endl;
}

template<typename F>
void invoke(F* f) {
  std::cout << "call function pointer." << std::endl;
  std::cout << f() << std::endl;
}

int main()
{
  invoke( []{ return 10; });
  invoke(+[]{ return 20; });
}

/* 出力
call functor.
10
call function pointer.
20
*/

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

見た目からすると、ラムダの頭に+を付けることで明示的に関数ポインタへ変換しているような気分になれます。

組み込みの単項+演算子の挙動

C++17規格の組み込み単項+演算子を見てみると次のようにあります。

The operand of the unary + operator shall have arithmetic, unscoped enumeration, or pointer type and the result is the value of the argument. Integral promotion is performed on integral or enumeration operands. The type of the result is the type of the promoted operand.

重要なところだけをなんとなく訳すと(powered by google翻訳

単項+演算子の引数型は算術型、スコープ無し列挙型、ポインタ型のいずれかでなければならず、結果は引数をそのまま返す。

重要なのは任意のポインタ型に対して単項+演算子が用意されている、という所です。

つまり、単項+ラムダ式に対して特別に用意されているわけではなく、ラムダ式+を適用すると、暗黙の型変換により関数ポインタに変換され、その関数ポインタが結果として返されているわけです。

上記のように他のものに対しての単項+演算子は恒等写像みたいなもので、単項-演算子の対としての役割しかないので、単項+演算子が有効に使える唯一のケースかと思われます(整数昇格される、という所も何か役立つかもしれません)。

使いどころ?

あまり意味がないと言えばそんな気もするのですが、std::functionと関数ポインタを取る関数をオーバーロードとして別々に分けているとき、そこにラムダ式を渡す場合に役立つかもしれません。

template<typename... Args>
void invoke(const std::function<void(Args...)>& f, Args&&... args) {
  std::cout << "call std::function." << std::endl;
  f(std::forward<Args>(args)...);
}

template<typename... Args>
void invoke(void(*f)(Args...), Args&&... args) {
  std::cout << "call function pointer." << std::endl;
  f(std::forward<Args>(args)...);
}

int main()
{
  //オーバーロード候補二つがマッチするため、コンパイルエラー
  invoke( [](int n, double d){ std::cout << n << ", " << d << std::endl; }, 10, 3.14);
  
  //常に関数ポインタを受け取る方が選ばれる
  invoke(+[](int n, double d){ std::cout << n << ", " << d << std::endl; }, 20, 2.72);
}

上 - [Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
下 - [Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

こういうことをしたいときのほかにも、Cなインターフェースを持ったライブラリにラムダを渡そうとするときにも役に立つことがあるかもしれません。

参考文献

謝辞

この記事の9割は以下の方々によるご指摘で成り立っています。

この記事のMarkdownソース

[C++]丸かっこによる集成体初期化

※この記事はC++20を相談しながら調べる会 #1の成果です。

※内容はC++20正式策定までに変化する可能性があります。

集成体初期化(Aggregate Initilization)とは、配列か集成体(Aggregate)となる条件を満たしたクラスに対して行える特別な初期化方法の事です。

C++17まではこれは波かっこ"{}"の時にのみ使用することができ、丸かっこ"()"による初期化は常にその型のコンストラクタを呼び出していました。
しかし、C++20からはその制限がなくなり丸かっこによる初期化時にも集成体初期化が考慮され、必要なら行われるようになります。

struct aggregate {
  int a;
  double b = -1.0;
};

aggregate a{10, 3.14};  //ok
aggregate b(10, 3.14);  //ok

aggregate c = {20, 2.72};  //ok
aggregate d = (20, 2.72);  //ng

aggregate e(30);  //ok e.a == 30, e.b == -1.0

aggregate f();  //ng これは関数宣言となる

int arr1[]{0, 1, 2, 3};  //ok
int arr2[](0, 1, 2, 3);  //ok

int arr3[] = {0, 1, 2, 3};  //ok
int arr4[] = (0, 1, 2, 3);  //ng

int arr5[4](0, 1) //ok 残りの要素は0で初期化

int arr6[4]();  //ng 必ず1つ以上の初期化子が必要

波かっこの時と同じように、初期化子の数が足りないときはデフォルトメンバ初期化で初期化され、それもない場合は値初期化(デフォルトコンストラクタを呼び出すような初期化)されます。
逆に、初期化子の数が多すぎる場合はコンパイルエラーになります。これも波かっこと同じです。

ただし、丸かっこによる初期化を行う場合はその内部の要素(初期化子)の数は1つ以上なければなりません。そうしないと関数宣言と区別がつかないためです。
@yohhoyさんご指摘ありがとうございました!

丸かっこによる集成体初期化はなるべく波かっこによるものと同じように実行されます。一方で、今までの丸かっこによる初期化の持つ意味が変わらないようにもなっています。
そのため、波かっこによる集成体初期化と少し異なる挙動をするところがあります。

コンストラクタとの競合

集成体初期化と従来のコンストラクタ呼び出しが競合する場合はコンストラクタ呼び出しが優先されます。これは波かっこでも同様ですが、集成体はコンストラクタ宣言を行えないので問題となるのはコピー・ムーブコンストラクタとの競合時です。
そして、この時の挙動が少し異なっています。

struct A;

struct C { 
  operator A();  //実装略
};

struct A {
  C c;
};

C c{};  //cを値初期化

{
  A a(c);  //C::operator A()を呼び、その戻り値からaをムーブ(コピー)構築
  A b(a);  //bをaからコピー初期化
}

{
  A a{c};  //A::cをcからコピー初期化
  A b{a};  //bをaからコピー初期化
}

丸かっこによる初期化においては、あらゆる変換が考慮された(通常のオーバーロード解決を行った)うえで、マッチングするコンストラクタが見つからないときに集成体初期化が行われます。

波かっこによる初期化においては、渡された初期化子リストの要素が一つであり、その要素が初期化しようとしている型Tもしくはその派生型である場合にのみ、その要素からTをコピー・ムーブ初期化します。
それ以外の場合はすべて集成体初期化が行われます。

このように微妙ではありますが初期化方法が選択されるまでの手順が異なります。とはいえ、ただ1つの要素で初期化しようとしたときにのみ起こる事なのであまり出会わないでしょう。

これは、丸かっこによる初期化の持つ意味を変更しないようにしているために生じています。
配列は元々丸かっこ初期化を持っておらず挙動が曖昧にはならないため、配列の初期化時はこの問題は起きません。

縮小変換の許可

縮小変換とは変換後の型が変換前の型の表現を受け止めきれないような型の変換です(double -> float, signed -> unsigned 等)。
波かっこによる初期化時は集成体初期化でなくても、縮小変換が禁止されていました。それは思わぬところで変換エラーを引き起こし、特にテンプレート関数の中では波かっこ初期化は非常に使いづらくなってしまっていました。

template<typename T>
float to_float(T v) {
  return float{v};
  //こうするとok
  //return float(v);
}

auto d = to_float(3.14);  //compile error!
auto e = to_float(3.14f); //ok.


constexpr char str[50]{};
constexpr auto begin = std::begin(str);

if (auto [end, err] = std::to_chars(begin, std::end(str), 3.141592653589793); err == std::errc{}) {
  std::cout << std::string_view{begin, end - begin};  //compile error!
  //こう書くとok
  //std::cout << std::string_view(begin, end - begin);
}

これらのエラーは波かっこ初期化時には縮小変換が禁止されていることから発生しています。
しかし、丸かっこによる集成体初期化においてはそのような制限はなく、あらゆる変換が考慮され実行されます。

これは同じ丸かっこによる初期化において、コンストラクタ呼び出しと集成体初期化とで挙動が異なることがないようにするためにこうなっています。

ネストするかっこの省略(できない!)

ネストする波かっこ省略について → 宣言時のメンバ初期化を持つ型の集成体初期化を許可 - cpprefjp

波かっこ初期化時はネストしている内部の型に対する波かっこ初期化時に、一番外側以外の波かっこを省略できます。しかし、丸かっこではできません・・・。
また、ネストする初期化のために丸かっこを使うと意図しない結果になります。何故かというと、ネストする丸かっこにはすでに意味があるからです。

//この二つは同じ意味
int arr1[2][2]{{1, 2}, {3, 4}}; //ok
int arr2[2][2]{1, 2, 3, 4};     //ok

//丸かっこはこうするしかない
int arr3[2][2]({1, 2}, {3, 4}); //ok

//できない・・・
int arr4[2][2](1, 2, 3, 4);    //ng
int arr5[2][2]((1, 2), (3, 4)); //ng (2, 4)と書いたのと同じになるがどのみちできない

おそらく3次元以上の配列の場合は丸かっこ内の波かっこのさらに内側では波かっこを省略できます。そんな配列初期化は普通しないと思うのであまり意味は無いですが・・・

そして、丸かっこ初期化の内側でさらに丸かっこを使う場合は、通常のかっこに囲まれた式として処理されてしまい、内側のカンマはカンマ演算子として解釈されます。

クラス型の集成体の場合

//この二つは同じ意味
std::array<int, 3> arr1{{ 1, 2, 3 }}; //ok
std::array<int, 3> arr2{ 1, 2, 3 };   //ok

//丸かっこはこうするしかない
std::array<int, 3> arr3({ 1, 2, 3 }); //ok

//できない・・・
std::array<int, 3> arr4( 1, 2, 3 );   //ng
std::array<int, 3> arr4(( 1, 2, 3 )); //ng arr4(3)と同じ、どのみちできない

丸かっこ初期化の内側に丸かっこを使えないのはおそらくどうしようもないですが、波かっこ省略はそのうち可能になるような気はします。

一時オブジェクトの寿命延長(されない!)

丸かっこによる集成体初期化時は、渡された初期化子リスト内の一時オブジェクトの寿命が延長されません。ドラフト規格文書より、以下のコードをご覧ください。

struct A {
  int a;
  int&& r;
};

int f() { 
  return -1;
}

int n = 10;

A a1{1, f()};                   // OK, lifetime is extended
A a2(1, f());                   // well-formed, but dangling reference
A a3{1.0, 1};                   // error: narrowing conversion
A a4(1.0, 1);                   // well-formed, but dangling reference
A a5(1.0, std::move(n));        // OK

縮小変換(narrowing conversion)によるエラーはここでは関係なく、a2, a4の初期化後の右辺値参照メンバの状態が問題です。

波かっこによる集成体初期化においては渡された一時オブジェクト(f()の戻り値やリテラル1)が右辺値参照メンバA::rを初期化すると、その一時オブジェクトの寿命は参照A::rの寿命と同じになります(延長される)。

しかし丸かっこによる集成体初期化時はそうはなりません。その初期化式が終了すると、そのような一時オブジェクトはそこで死にます(寿命が尽きる)。
すなわち、そのように初期化された右辺値参照メンバは不正な参照となってしまい、これへのアクセスは未定義動作となります。

a5の初期化にあるように、すでに初期化済みの変数でこういう事をしたい場合にはきちんとmoveすることでこの罠を回避することができます(ただし、リテラルや関数の戻り値はmoveしても回避できない)。

右辺値参照メンバなんてものはそうそう使うことはないでしょうが、これがconst 左辺値参照メンバならばたまに使う事があるでしょう。その際も同じ罠が待ち構えていることになるので注意せねばなりません・・・

少し詳細な考察

サンプルコードのコメントにもある通り、右辺値参照メンバA::rf()の戻り値やリテラル1というprvalueで初期化するときに問題が起きています。

まず、prvalueを右辺値参照(もしくはconst左辺値参照)に束縛する(結びつける)とxvalueな一時オブジェクトに変換されたうえで、結びつけられます。

波かっこ初期化ではそのような一時オブジェクトは集成体要素の右辺値参照(A::r)に直接結び付ける(形になる)ため、その一時オブジェクトの寿命は結びつけた参照の寿命まで延長されます。

しかし、丸かっこによる集成体初期化ではそのような一時オブジェクトの寿命の延長がなく、その一時オブジェクトが生成された後の最初のセミコロンまでしか延長されません(明確に規定されています)。
そのため、初期化の完了後に一時オブジェクトの寿命は尽きることになり、メンバの参照は不正な参照となります。

一方lvaluen)をmoveした後の値(xvalue)は一時オブジェクトではないので、先ほどの規則には当てはまらず、右辺値参照(const左辺値参照)に束縛すればその参照の寿命まで寿命延長されます。

なぜこのような謎な仕様になっているかははっきりとしませんが、従来の丸かっこによるコンストラクタ呼び出しとの一貫性を確保するためだと思われます。
コンストラクタでメンバの右辺値参照を初期化するときは、一時オブジェクト等はコンストラクタ引数の右辺値参照でいったん受けてから、メンバ初期化子リストでmoveすることになります。

struct A {
  A(int&& arg)
    : r(std::move(arg))
  {}

  int&& r;
};


int n = 10;

//共にA::rが不正な参照になることはない
A a(1);
A b(std::move(n));

これであれば、2回寿命延長が入ることで結果的にその参照の寿命まで一時オブジェクトの寿命は延長されることになります。

丸かっこによる集成体初期化時もこの様な挙動を前提にしており、一旦コンストラクタ引数で受けてから各メンバの初期化を行うような挙動をとります(実際にそのように行われる訳ではありません)。その際、moveするかしないかを引数型から推測することには問題があるため、メンバ初期化リストでのmoveは行われず、2度目の寿命延長が発生しません。
その結果初期化終了後に一時オブジェクトの寿命が尽きることになるのだと思われます。

Designated Initialization(できない!!)

Designated Initializationについて → Designated Initialization @ C++ - yohhoyの日記

Designated Initialization(指示付初期化)は同じくC++20より可能になる集成体初期化の新しい形です。
その名の通り、集成体要素を直接指定する形で初期化を行います。

struct aggregate {
  int a = -10;
  double b;
};

aggregate a = {.a = 10, .b = 3.14};

union U {
  char c;
  float f;
};

U u = {.f = 2.72};

この様に、どの変数をどの初期化子で初期化しているのかが見やすくなり、特に共用体においては最初に初期化するアクティブメンバを選択できるようになります。

しかし、残念なことに、このDesignated Initializationは波かっこによる集成体初期化時にしか使えません。丸かっこでは、できません・・・・

//共にコンパイルエラー!!
aggregate a(.a = 10, .b = 3.14);
U u(.f = 2.72);

引数の評価順序

丸かっこによる集成体初期化時、渡された初期化子リスト内の要素の評価順序は左から右と規定されます。
これは波かっこと異なる動作ではなく同じ動作で、むしろ今までの丸かっこによるコンストラクタ呼び出しと異なる動作です。

詳細には、集成体(クラス、配列)のn個の要素を先頭(基底クラス→メンバの順)から1 <= i < j <= nとなるように添え字付けしたとして、i番目の要素の初期化に関連するすべての値の計算(value computation)及び副作用(side effect)は、j番目の要素の初期化の開始前に位置づけられる(sequenced before)、ように規定されます。

つまり、かっこの種類にかかわらず集成体初期化を行う場合は、初期化子に与えた式の順序に依存するようなコードを書いても未規定状態にならず意図したとおりの結果を得ることができます。

{
  int i{};

  int array[]{++i, ++i, ++i, ++i};
  //array = {0, 1, 2, 3}
}

{
  int i{};

  int array[](++i, ++i, ++i, ++i);
  //array = {0, 1, 2, 3}
}

//集成体でない
struct int_4 {
  int_4(int a, int b, int c, int d)
    : a1{a}, a2{b}, a3{c}, a4{d}
    {}

  int a1, a2, a3, a4;
};

{
  int i{};

  int_4 m{++i, ++i, ++i, ++i};
  //m = {0, 1, 2, 3}
}

{
  int i{};

  //unspecified behavior, a1~a4にどの値が入るか(++iがどの順で実行されるか)は未規定
  int_4 m(++i, ++i, ++i, ++i);
}

//集成体
struct agg_int_4 {
  int a1, a2, a3, a4;
};

{
  int i{};

  agg_int_4 m(++i, ++i, ++i, ++i);
  //m = {0, 1, 2, 3}
}

@yohhoyさんご指摘ありがとうございました!

この様に、丸かっこによる初期化時にコンストラクタを呼び出したときは相変わらず未規定の動作となってしまう点は注意です。

この変更の目的

この様になんだか複雑さを増した上に影響範囲がでかそうな変更をなぜ行ったのかというと、STLにおけるmake_~系やemplace系の関数に代表される、内部で要素を構築するような関数において、集成体初期化が行われないことをどうにかするためです。

例えばstd::make_from_tuple関数の実装例を見てみると

template<class T, class Tuple, std::size_t... Index>
constexpr T make_from_tuple_impl(Tuple&& t, std::index_sequence<Index...>){
  //ここで、Tのコンストラクタを呼びだしている
  return T(std::get<Index>(std::forward<Tuple>(t))...);
}

template <class T, class Tuple>
constexpr T make_from_tuple(Tuple&& t) {
  return make_from_tuple_impl(std::forward<Tuple>(t), std::make_index_sequence<std::tuple_size_v<std::decay_t<Tuple>>>{});
}

make_from_tuple_impl内でTを"()"で初期化することでコンストラクタを呼び出しています。"{}"ではないので集成体初期化が行われることはありません。
これは、与えられた引数(この場合はtに含まれる要素)が空か、Tかその派生型のただ一つだけ、で無ければ集成体は構築できないことを意味しています。
じゃあここを"{}"にすればいいじゃん?と思うかもしれませんが、上で述べた縮小変換が禁止されていることによって多くのケースで謎のエラーが発生することになるのでそれは解決にならないのです。

また、もう一つのケースとして集成体を要素とするコンテナを扱う時にも同じ問題が起こります。

//集成体
struct aggregate {
  int n;
  double d;
  char c[5];
};

int main() {
  std::vactor<aggregate> vec{};

  vec.emplace_back(10, 1.0, "abc");   //compile error!
  vec.emplace_back(aggregate{10, 1.0, "abc"});  //ok
}

emplace_backは要素型のコンストラクタ引数を受け取って、内部で直接構築する関数です。その際、呼び出すのは丸かっこによるコンストラクタであり、集成体初期化を行いません。

結果、1つ目のemplace_backコンパイルエラーとなります。しかもこのエラーはSTL内部で発生することになるので、一見すると意味の分からないものになってしまいます。

例:clang 8.0.0のエラー例 [Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

/opt/wandbox/clang-8.0.0/include/c++/v1/memory:1826:31: error: no matching constructor for initialization of 'aggregate'
            ::new((void*)__p) _Up(_VSTD::forward<_Args>(__args)...);
                              ^   ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
/opt/wandbox/clang-8.0.0/include/c++/v1/memory:1718:18: note: in instantiation of function template specialization 'std::__1::allocator<aggregate>::construct<aggregate, int, double, char const (&)[4]>' requested here
            {__a.construct(__p, _VSTD::forward<_Args>(__args)...);}
                 ^

このエラーをよく見ると、placement newによる要素構築時に丸かっこを用いている事が分かるでしょう。

これらのケースだけでなく、他のSTLコンテナやstd::optional等Vocabulary typesにもemplace系関数があり、std::pairのpiecewise constructなど他の直接構築系の操作においても同様の問題が発生しており、この解決のために丸かっこによる集成体初期化が許可されました。

この変更によってこれらの関数は集成体を問題なく内部で構築できるようになり、丸かっこと波かっこの間の初期化に関するセマンティクスの一貫性が少し改善されることになります(むしろ悪化・・・?)。

参考文献

この記事のMarkdownソース

[C++]非型テンプレートパラメータたりうるには

※この内容はC++20より有効なものです。現行(C++17)ではまだ1ミリも利用可能な情報ではありません。また、随時記述が変更される可能性があります。

こちらの記事との関連があるので、予め目を通しておくと良いかもしれません。 onihusube.hatenablog.com

非型テンプレートパラメータとなれるもの

C++17では以下のものが非型テンプレートパラメータとなることができます。

  • 整数型
  • 列挙型
  • オブジェクト・関数ポインタ
  • オブジェクト・関数参照(左辺値参照のみ)
  • メンバポインタ
  • std::nullptr_t

C++20では、主に以下の2つのものが非型テンプレートパラメータとなることができるようになります。

  • strong structural equalityなリテラル
  • 左辺値参照型

減ってるように見えますが、参照型以外が一つ目にまとめられただけです。
そして、任意のクラス型(共用体を含まない)がstrong structural equalityであり、constexprに構築可能であるとき、そのクラスのオブジェクトを非型テンプレートパラメータとして利用することができるようになります。

そのような非型テンプレートパラメータとして渡されたオブジェクトはconstなglvalueオブジェクトとなり、異なる非型テンプレートパラメータ毎に唯一つのインスタンスがプログラム内に存在します。当然、メンバ関数呼び出しやアドレス取得が可能です。

こんなことができる・・・かもしれません。

//何か値のペアを受け取り出力する
template<auto Pair>
void f() {
   std::cout << std::get<0>(Pair) << ", " << std::get<1>(Pair) << std::endl;
}

f<std::pair<int, int>{10, 20}>();
f<std::tuple<int, int>{10, 20}>();

//固定文字列型
template<typename CharT, std::size_t N>
struct fixed_string {
   constexpr fixed_string(const CharT (&array)[N + 1]) {
      std::copy_n(array, N + 1, str);
   }

   auto operator<=>(const fixed_string&) = default;
   //auto operator==(const fixed_string&) = default;
   
   CharT str[N + 1];
};

template<typename CharT, std::size_t N>
fixed_string(const CharT (&array)[N]) -> fixed_string<CharT, N-1>;

//fixed_stringを出力
template<fixed_string Str>
void g() {
   std::cout << Str.str<< std::endl;
}

g<"Hello World!">();
g<"<=> Awesome!">();

strong structural equality(強い構造的等価性)?

ところで、ぽっと出のstrong structural equalityなる横文字は一体何なのでしょうか・・・?

ある型Tがstrong structural equalityであるとは以下のどちらかを満たしているときです。

  • Tがクラス型でない場合
    • cosnt Tの値aについてa <=> aが呼び出し可能
    • その結果となる比較カテゴリ型がstd::strong_­orderingstd::strong_­equalityのどちらか
  • Tがクラス型の場合
    • Tのすべての基底型及び非staticメンバ変数がstrong structural equalityである
    • mutable及びvolatileな非staticメンバ変数を持たない
    • Tの定義の終了点で、cosnt Tの値aについてa == aオーバーロード解決が成功し、publicなものかfrineddefault実装の==が見つかる

Tがクラス型でない場合というのは組み込み型の場合の事で、組み込みの宇宙船演算子の比較カテゴリ型がstd::strong_­equalityに変換可能であればいいわけです。
浮動小数点型およびvoid以外のすべての型がstrong structural equalityになります。

任意のクラス型の場合はpublicfrinedなdefault実装のoperator==を持っていて、すべての非staticメンバ変数がstrong structural equalityで(mutable、volatileはng)、基底クラスもそのようになっていればstrong structural equalityになります。
ただし、この==はあくまでstrong structural equalityであることの表明のために必要なだけで実際に比較をするわけではなく、また実際の比較結果によってstrong structural equalityであるかどうか決定されるわけではありません。

これらの諸条件は、浮動小数点型をメンバに持たずにoperator==をdefault実装していれば、おおよそのケースで満たすことができるはずです。
ただし、参照型がメンバにある時とUnion-likeな型ではdefaultoperator==は暗黙deleteされているので注意です。

その上で、非型テンプレートパラメータとして利用するためにはTリテラル型である必要があります。
C++17基準ならば、コンパイル時に構築可能(初期化が定数式で可能)でなくてはなりません。

クラス型のoperator==のチェック

オーバーロード解決が成功し使用可能な==が見つかる、という遠回りな言い回しになっているのは、defaultで定義されていても削除されていたりアクセスできないケースのためです。 例えば任意の型を保持するようなクラステンプレートでは、その型次第でoperator==は削除されている可能性があります。

そのような型には例えばstd::pairがあります。そして、std::pairは参照型を保持することができます。

//std::pairの参考用簡易実装
template <typename T, typename U>
struct pair {
    T first;
    U second;

    //デフォルト実装のみを提供すると・・・
    friend constexpr bool operator==(const pair&, const pair&) = default;
};

int i = 42, j = 42;
pair<int&, int> p(i, 17);
pair<int&, int> q(j, 17);
assert(p == q);   //C++17までは有効なコード、しかし上記実装だと==はdeleteされているため比較不可能

このような場合にはoperator==のデフォルト実装はもはや出来ず、参照型用の実装を追加で提供する必要があります。

template <typename T, typename U>
struct pair {
    T first;
    U second;

    friend constexpr bool operator==(const pair&, const pair&) = default;
    
    //参照型用の実装、T,Uのどちらかが参照型ならばより特殊化されているこちらが優先される
    friend constexpr bool operator==(const pair& lhs, const pair& rhs)
       requires (is_reference_v<T> || is_reference_v<U>)
    {
       return lhs.first == rhs.first && lhs.second == rhs.second;
    }
};

このようにしておけば参照型に対しても==による比較を提供できますが、同時にdefaultoperator==も存在はしています。 このような場合に、オーバーロード解決を用いて使用可能なoperator==をチェックすることで非型テンプレートパラメータとして使用されてしまうことを防止しています。

なぜstrong structural equalityなリテラル型なのか?

それは、二つのインスタンス化されたテンプレートがあるとき、その等価性を判断するためです。

関数・クラステンプレートはODR遵守のために、複数の翻訳単位にわたってその定義がただ一つとなるようにコンパイルされます。それは多くの場合、コンパイラによる名前マングルとリンカによる重複削除によって実現されます。

非型テンプレートパラメータを与えられた関数やクラス名をどうマングルするのかというと至極簡単で、その型名と定数値をマングル名に埋め込みます。そのうえでリンカがそのマングル名のマッチングにより重複定義を削除し、全ての翻訳単位にわたって定義がただ一つになるようにします。

そのため、非型テンプレートパラメータとなれる値は定数でなければならず、operator==による比較は同値関係ではなく等価関係でなければなりません。つまり、その定数表現(マングル名に埋め込む値の表現)が異なる値に対して==trueとなったり、同じ値となる筈なのにその定数表現が異なっていたりする型は非型テンプレートパラメータとして認めることは出来ません。

そのような型の典型例は浮動小数点型です。浮動小数点の定数表現はおそらくバイナリ列(2進浮動小数点表現)になると思われますが、丸めの影響で10進固定小数点表現とバイナリ列は必ずしも1対1対応せず、計算を行うとその影響はさらに複雑になります。
また、+0.0-0.0のようにバイナリ列も見た目も異なるがoperator==による比較がtrueとなる値があり、NaNという一見同じ値なのに内部表現がいくつもある値も持っており、これらの問題から今後も浮動小数点数を非型テンプレートパラメータとして渡すことは出来ないでしょう・・・。

この様に、何も考えずにクラス型を非型テンプレートパラメータとして許可してしまうと、名前マングルだけではテンプレートの同一性を判定できなくなります。 全基底及びメンバ単位で正確に等価であるかを確認できればその問題は解決されますが、ユーザー定義されたoperator==(or !=)においてそれを判定するのは困難です。また、比較演算子のdefault実装もC++17以前にはありません。

しかしC++20では宇宙船演算子が導入され、組み込み型の宇宙船演算子の戻り値型は比較の種類を表す比較カテゴリ型となります。それがstd::strong_equalityであれば等価性を保証できます(weak_equalityでは同値であることしか保証できません)。

また、クラス型についてはdefault実装のoperator==が導入され、その実装は全基底及びメンバの辞書式比較で実行されます。
したがって、全基底クラスを含めた全メンバが<=>の比較においてstd::strong_equalityを返すような組み込み型に行きつくかぎり、クラス型についてもその等価性を保証できます。

そしてリテラル型であれば、名前マングルを行う段階ではその定数値はすべて確定しており、そのクラスの全メンバの定数表現の列をマングル名とすることで、非型テンプレートパラメータを含めたマングル名の同一性をリンカが判定できるようになります

そしてそのような保証は、ユーザー定義の比較演算子で行うにはあまりにもコンパイラの多大な努力が必要である事が分かるでしょう。

これらの必要な条件をまとめたものが「strong structural equalityなリテラル型」となるわけです。

参考文献

この記事のMarkdownソース

[C++] constexpr関数がインスタンス化されるとき

P0859R0 評価されない文脈でconstexpr関数が定数式評価されることを規定」を理解するためのメモです。

以下の文章内でのconstexpr関数についてのインスタンス化という言葉はテンプレートにおけるインスタンス化と同じ意味、すなわちconstexpr関数の定義の評価が発生するという意味合いで使用しています。

必要な知識

unevaluated operand(未評価オペランド

unevaluated operandsとは、sizeofdecltypenoexcepttypeidオペランド(引数)として指定されるもの(式)の事で、その名の通りそのオペランドは評価されません。

評価されないとは、そのオペランドに含まれるいかなる計算や関数呼び出しも実行されないということで、そこで参照される関数やクラス等の宣言のみを考慮する(定義が必要ない)ことを意味します。

struct S;

template<typename T>
int f();

int main()
{
  //評価されないのでSとfには定義が必要ない
  decltype(f<S>()) n = sizeof(f<S>()); //int n = sizeof(int);と等価
  std::cout << n;
}

ただし、noexcept以外は型名を指定することができ、その場合の型名オペランドはunevaluated operandではありません。

potentially evaluated(おそらく評価される)

ある式がunevaluated operandでなくその部分式(subexpression)でもないとき、その式はpotentially evaluatedと言われます。評価される(evaluated)のでその式に関わる関数や型には定義が必要になる可能性があります。

template<typename T>
int f();

int main()
{
  //f<int>()はpotentially evaluated、fの定義が必要
  auto n = f(0);
  std::cout << n;
}

つまりは、sizeofdecltypenoexcepttypeid、いずれのオペランドでもない式の事だと思っていいでしょう。

potentiallyというのは、例えば以下のようなとき

if(true) {
  f(0);
} else {
  f(1);
}

この場合、f(0)f(1)も評価される可能性のある文脈に現れていますが、f(1)の方は絶対に評価されません。しかし、この場合でもコンパイラは両方のコードをコンパイルします。この様に、評価されるとは思うけど本当にされるかどうかはわからない、という意味合いでpotentially evaluatedなのだと思われます。

odr-used

大さっぱに言えば、potentially evaluatedな式に含まれている変数や関数はほぼodr-usedとなります。つまりは、定義が必要となる使われ方の事で、odr-usedであれば定義が必要になります。

potentially evaluatedであってodr-usedとならない例は、純粋仮想関数の呼び出しやメンバポインタの形で現れるとき、static constなメンバ変数を定数式でrvalueに変換する場合、最終的に結果が捨てられる形(discarded-value expression)になる場合などです。

struct S;

S* ps{};  //ポインタは不完全型でも宣言可能、odr-usedではない

struct S { 
  static const int x = 0; 
};

decltype(&S::x) p{};  //unevaluated operandなので、odr-usedではない

int f() {
  S::x;  //discarded-value expression、odr-usedではない
  return S::x;  //lvalue -> rvalue変換が定数式で可能、odr-usedではない
}
関数

後ほど重要になってくるので関数だけはもう少し詳しく掘り下げておきます。

関数名がpotentially evaluatedな式に現れるとき、以下の場合にodr-usedされます。

  • 関数は名前探索のただ一つの結果、であるか
  • オーバーロード候補の一つである

ただし、以下を満たしていること

  • その関数が純粋仮想関数ならば、明示的な修飾名で呼び出されている
  • 式の結果がメンバポインタとならない

クラスのコピー・ムーブコンストラクタは、それが最適化などの結果によって省略されたとしても、odr-usedされています。

また、純粋仮想関数ではない仮想関数は常にodr-usedされます。

明示的な修飾名で呼び出されている純粋仮想関数とは、以下のような呼び出しの事です。

struct base {
  virtual int f() = 0;
};

int base::f() {
  return 0;
}

struct derived : base {
  int f() override {
    return 10;
  }
};

base* b = new derived{};

auto n = b->f();        //通常の仮想関数呼び出し、odr-usedでない
auto m = b->base::f();  //明示的な修飾名での呼び出し、odr-used
//n == 10, m == 0

つまり、明示的な修飾名での呼び出しはもはや仮想関数呼び出しではなく、通常の純粋仮想関数の呼び出しはodr-usedではありません。

クラスの特殊メンバ関数が実装されるとき

クラスの特殊メンバ関数とは、デフォルト・コピー・ムーブコンストラクタ、コピー・ムーブ代入演算子、デストラクタ、の事です。

ユーザー定義されていないクラスの特殊メンバ関数コンパイラによって暗黙の宣言が行われ、odr-usedされたときに初めて暗黙に定義されます。
仮に最適化等によってそのodr-usedが最終的に消え去ったとしても、その時点で定義されます。

実は、常に定義されているわけではないのです。

odr-usedされたとき、なのでsizeof等の未評価オペランド内では宣言のみで定義がないことになります。

constexpr関数の実行と評価のタイミング

constexpr関数は定数式から呼び出されたときにインスタンス化され、実行されます。
定数式となるには定数式で現れてはいけない構文が現れなければ定数式となり、コンパイル時に実行可能になります。

これを説明しだすと止まらないので、詳しくは以下をご参照ください。
5.22 定数式(Constant expressions) - C++11の文法と機能(C++11: Syntax and Feature)

しかし、定数実行を行うコンテキストについては規定がありません。そのためコンパイラは可能な限り定数式を処理し、コンパイル時に値を確定させようとします。それが未評価オペランドであっても例外ではありません・・・

問題となるコード例

以下はP0859R0によって欠陥修正されていない世界のお話です。 この変更は欠陥の修正なので過去のバージョンにさかのぼって適用されています。なので、clangやgccでは一部の問題が早い段階から修正されているようです。
そのために問題が確認できるコンパイラが古いものであることがあります。Wandbox様々です。

その1

Core Issue 1581より

struct duration {
  constexpr duration() {}
  constexpr operator int() const { return 0; }
};

int main()
{
  //duration d = duration();
  int n = sizeof(short{duration(duration())});
  std::cout << n;
}

コンパイルエラー : [Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

一見コンパイルの通りそうなこのコードは、C++17までの規格に則るとエラーとなります。なぜでしょう?

int n = sizeof(short{duration(duration())});

ここの行に注目すると、sizeofオペランド中のduration(duration())durationクラスのコピー/ムーブコンストラクタを要求しています。durationクラスはそれらを宣言すらしていないので、コンパイラによって暗黙に宣言されています。

しかしsizeofオペランドは未評価オペランドであり、odr-usedではないためdurationのコピー/ムーブコンストラクタは暗黙定義されません。従って、未定義の関数を呼び出す形になるため定数式では無くなります。ただし、宣言はあるのでコンパイルエラーにはなりません。
残るのは、duration -> shortの変換です。これにはduration::operator int()が使われてint -> shortの変換となり、これは縮小変換になるためリスト初期化(波かっこ初期化)において許可されないのでコンパイルエラーになります。

なるほど、一つづつ見ていくと納得の動作ですね。
では次に、その上の行にあるコメントを外してみましょう!

コメントを外すと : [Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

コンパイル通りました。なぜでしょうか・・・

duration d = duration();

この行ではdurationクラスの変数宣言とデフォルト構築を行っています。最終的にはデフォルトコンストラクタのみになるとはいえ、形式的には左辺でデフォルト構築した一時オブジェクトを右辺dにムーブして構築する形になります。つまり、ここでdurationのムーブコンストラクタはodr-usedされるので、暗黙に定義されます。

そして次の行に行くわけですが、durationのムーブコンストラクタは既に定義されており、それは暗黙定義のものであり、メンバや基底も特に無いためconstexprになります。その結果durationの構築からintリテラル0の変換までが定数式で実行可能になります。
そこからshortへの変換ですが、定数式であれば縮小変換であっても、変換元の定数値が変換先の型で表現可能でありさえすれば定数実行可能になります。
この結果、sizeofオペランドはすべて定数実行可能であり、shortの定数値0になります。sizeofは結果としてshortのサイズを恙なく出力し、何のエラーも起こりません。

何とも奇妙な振舞です。
これらの奇妙な振舞の根本は特殊メンバ関数(この場合ムーブコンストラクタ)の暗黙定義のタイミングと定数実行が行われるコンテキストが曖昧であることにあります。

その2

P1065r0および、llvm bug 23135より

template<typename T>
int f(T x)
{
    return x.get();
}

template<typename T>
constexpr int g(T x)
{
    return x.get();
}

int main() {

  // O.K. The body of `f' is not required.
  decltype(f(0)) a;

  // Seems to instantiate the body of `g'
  // and results in an error.
  decltype(g(0)) b;

  return 0;
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

一つ目のdecltype(f(0)) a;int a;と同じ意味であり、f(0)はconstexprでもないので何の問題もなくコンパイル可能です。

decltype(g(0)) b;も同じであるはずです。しかし、g(0)は定数実行可能でありコンパイラが貪欲に定数化しようとした結果、未評価オペランドのはずなのにg<int>(int)インスタンス化(定義が評価)されてしまい、intメンバ関数getを持たないことからコンパイルエラーを引き起こします。

これは明らかにバグですが、根本原因は定数実行が行われるコンテキストが曖昧であることにあります。

その3

Core Issue 1581より

template<int N>
struct U {};

int g(int);

template<typename T>
constexpr int h(T) { return T::error; }

template<typename T>
auto f(T t) -> U<g(T()) + h(T())> {}

int f(...) { return 0;}

int k = f(0);

失敗例: [Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
成功例: [Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

これは少し複雑ですが、最終的にk = f(0);によって呼ばれるfは引数がellipsisである方が想定されます。

template<typename T> auto f(T t) -> U<g(T()) + h(T())> {}

問題となるのはここの関数定義です。特に変数後置部U<g(T()) + h(T())>operator+の評価順によって分岐します。

その左辺g(T())を先に評価する場合、g(int)はconstexprでなく定義もないので定数実行できません。そのため、定数式ではないことが分かります。そのため、Uの非型テンプレートパラメータは確定できず、その時点で以降の式を評価する必要がないことが分かるためh(T())は評価されず、SFINAEによってもう一つのf()を見に行きます。

右辺h(T())が先に評価される場合は必ずコンパイルエラーを引き起こします。
h<int>(int)単体はconstexpr指定されており、定数実行可能である可能性があります。そのためコンパイラは定義を見に行きます。結果、intはメンバ変数errorを持たないためエラーになりますが、このエラーが引き起こされるところはSFINAEによって継続される文脈ではないのでコンパイルエラーを引き起こしてしまいます。

この場合の問題は、プログラマから見てconstexpr関数のインスタンス化が必要かどうかが不明瞭であることです。つまりは、constexpr関数がインスタンス化されるコンテキストが不明瞭であることが原因です。
エラーになるのかならないのか、はっきりしてほしい所です。

その4

Core Issue 1581より

#include <type_traits>

template <class T>
constexpr T f(T t) { return +t; }

struct A { };

template <class T>
decltype(std::is_scalar<T>::value ? T::fail : f(T()))
  g() { }

template <class T>
void g(...);

int main()
{
  g<A>();
}

失敗例: [Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
成功例: [Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

この例はより深淵に踏み込みます。

一つ目のg()の戻り値型decltype(std::is_scalar<T>::value ? T::fail : f(T()))の評価タイミングが問題です。

constexpr関数がいつインスタンス化するのか?すなわち、構文解析時にここが評価されたとき、f(T())インスタンス化されるかどうかで結果が変わります。

構文解析時にconstexpr関数がインスタンス化される場合、f(T())インスタンス化され、型Aには単項+演算子オーバーロードはないのでインスタンス化に失敗しエラーとなります。エラー発生個所はSFINAEによって置き換えられる文脈ではないのでコンパイルエラーとなります。

構文解析の後の)定数評価時にconstexpr関数がインスタンス化される、すなわち構文解析時にconstexpr関数インスタンス化が行われない場合、g()の戻り値を決めようとするとA入れ子T::failがないことからエラーとなり、SFINAEによってもう一つのg()が選ばれます。よってコンパイルは恙なく完了します。

この問題も、constexpr関数のインスタンス化がいつ行われるのか?が曖昧であることが原因で、つまりは定数実行されるコンテキストが明確ではないことが原因です。

解決のための変更

上記の問題に共通することは、定数式が実行されるコンテキストが曖昧であるために、constexpr関数がインスタンス化されるタイミングも不明解になってしまっている、という事です。

では、P0859はこれらをどのように解決するのでしょうか?

named by(指名される)

まず、関数のodr-usedについてが以下のように変更されます。

ある関数が、式もしくは何らかの変換の中でnamed byである(指名される)とは

  • 関数は名前探索のただ一つの結果、であるか
  • その式・変換に際し必要となる関数呼び出しについてのオーバーロード候補の一つである

ただし、以下を満たしていること

  • その関数が純粋仮想関数ならば、明示的な修飾名で呼び出されていること
  • 式の結果がメンバポインタとならない

純粋仮想関数ではない仮想関数は常にodr-usedされ、そうでない関数はpotentially-evaluatedな式・変換から指名された(named by)ときにodr-usedされる。

named byという言葉が間に入っただけで実質あまり変わっていません。named byという言葉は後で使います。

potentially constant evaluated(おそらく定数評価される)

ある式がpotentially constant evaluatedであるとは、以下の時です

  • manifestly constant-evaluatedな(間違いなく定数評価される)式
    • 定数式
    • constexpr if文の条件式
    • consteval関数の呼び出し
    • 制約式(コンセプト)
    • 定数初期化(constant initialization)できる変数の初期化式
    • 定数式で使用可能な変数の初期化式
      • constexpr変数
      • 参照型
      • const修飾された整数型
      • enum
  • potentially evaluatedな式
  • リスト初期化子の直接の部分式
  • テンプレートパラメータに依存する変数名に対するアドレス取得
  • 上記いずれかの部分式が、ネストした未評価オペランドの部分式ではない

このルールは定数式が実行されうるコンテキストを定めたものと言えます。これらのコンテキストでは定数式が実行されるかもしれません。

manifestly constant-evaluatedな式とは、std::is_constant_evaluated() == trueとなる式の事でもあります。

ネストした未評価オペランドの部分式とは、sizeofの中にsizeofがあるような場合です。

constexpr int a = 0, b = 1;

auto s = sizeof(sizeof(a + b));   //この場合、`sizeof(size_t)`であることが明らかなので
                                  //定数式`a+b`はpotentially constant evaluatedではない

needed for constant evaluation(定数評価に必要)

ある関数がpotentially constant evaluatedな式から指名(named by)される時、needed for constant evaluationであると言われます。

また、ある変数の名前がpotentially constant evaluatedな式に現れる時、needed for constant evaluationであると言われます。
ただし、そのような変数はconstexpr変数、参照型、const修飾された整数型のいずれかであるときに限ります。

クラスの特殊メンバ関数が実装されるタイミング

ユーザー定義されていないクラスの特殊メンバ関数コンパイラによって暗黙の宣言が行われ、odr-usedされたときに初めて暗黙に定義される、というのが今までの動作でした。

C++20ではそこに加えて、needed for constant evaluationであるときにも暗黙に定義されるようになります。
これにより、未評価オペランドの定数式内であっても暗黙に定義されるようになります。

(関数)テンプレートのインスタンス

合わせて、テンプレートが暗黙的にインスタンス化されるときも若干変更が入ります。
主に、existence of the definition affects the semantics of the program(定義の存在がプログラムのセマンティクスに影響を与えるとき)という条件が追加されます。

クラステンプレートおよびメンバーテンプレートのメンバー関数・変数が明示的特殊化も明示的インスタンス化もされていないとき、以下のどちらかの場合に暗黙的にインスタンス化されます。

  • そのメンバー定義が必要になるコンテキストで参照されたとき(odr-usedされたとき)
  • メンバー定義の存在がプログラムのセマンティクスに影響を与えるとき

明示的特殊化も明示的インスタンス化もされていない関数テンプレートの特殊化、またはfrinde関数テンプレートの定義から生成された宣言は、以下のどちらかの場合に暗黙的にインスタンス化されます。

  • その関数定義が必要となるコンテキストで参照されたとき(odr-usedされたとき)
  • 定義の存在がプログラムのセマンティクスに影響を与えるとき

frinde関数テンプレートの定義から生成された宣言とは、クラス内でfrinde関数の定義を行った場合にその外部名前空間になされる暗黙の関数宣言の事です(この宣言は明示的に行われない限りADLによってのみ参照可能です)。

変数テンプレートに関してはここでは重要でなく、上記二つとほぼ同じ文言なので省略します。

ではこの、「定義の存在がプログラムのセマンティクスに影響を与えるとき」、という何ともあいまいな条件は一体どんな時でしょうか?

テンプレート変数・関数がある式においてneeded for constant evaluationであるとき、「定義の存在がプログラムのセマンティクスに影響を与える」とみなされます。これには以下の場合も含みます。

  • 式を定数評価する必要がないとき
  • 定数式の評価の際に定義が使われないとき

横文字用語が再帰しまくってよく分からなくなってきました・・・

ある関数がneeded for constant evaluationとは、定数評価されうる式からその関数が参照される事です。
そしてそのような式が定数評価の必要がないか(未評価オペランド等)、定数式の評価の際に定義が使われない場合(条件演算子の絶対に評価されない方、等)でも、その関数の定義が評価されます。

すなわち、テンプレート関数はpotentially constant evaluatedな式に出現する場合に確実にインスタンス化される、ということです。

これらの追加された条件は主に定数式内の関数・変数テンプレートがインスタンス化されるタイミングを定めたものであることが分かります。

一緒に書いてあるサンプルコードを見てみましょう。

template<typename T>
constexpr int f() { return T::value; }

template<bool B, typename T>
void g(decltype(B ? f<T>() : 0));

template<bool B, typename T>
void g(...);

template<bool B, typename T>
void h(decltype(int{B ? f<T>() : 0}));

template<bool B, typename T>
void h(...);

void x() {
  g<false, int>(0);  //OK
  h<false, int>(0);  //compile error!
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

x()内1行目、g<false, int>(0);はまずg()の1つ目の宣言を見に行きます。そこの引数宣言を見るとdecltype(B ? f<T>() : 0)となっています(B = false)。decltype内の式は未評価オペランドなのでB ? f<T>() : 0はpotentially constant evaluatedではありません(ので、needed for constant evaluationでもありません)。
そのため、f<T>()インスタンス化されずdecltypeの結果は両方の式に共通するintとなり、コンパイルは恙なく完了します。

x()内2行目、h<false, int>(0);はまずh()の1つ目の宣言を見に行きます。その引数宣言はdecltype(int{B ? f<T>() : 0})となっています(B = false)。decltype内の式は未評価オペランドですが、int{B ? f<T>() : 0}にはリスト初期化があります。この内側の式はpotentially constant evaluatedになります(ので、needed for constant evaluationでもあります)。
すると、f<T>()の定義が「プログラムのセマンティクスに影響を与える」のでf<T>()インスタンス化されます。しかし、int入れ子T::valueを持たないのでインスタンス化は失敗し、コンパイルエラーとなります。

結局

この様なややこしい規則と用語の導入によって、定数式が評価されるコンテキストと、constexpr関数及び関数テンプレートがインスタンス化される場所・タイミングが明確に定められました。

それによって先の4つの問題も一定の解決が図られます。

1つ目の問題

  //duration d = duration();
  int n = sizeof(short{duration(duration())});

これはdurationクラスのムーブコンストラクタが定義されるタイミングが問題でした。

このコードのsizeof内は未評価オペランドではありますがリスト初期化があるので、その内側の式はpotentially constant evaluatedになります。 durationクラスのムーブコンストラクタはそこから指名(named by)されているので(すなわちneeded for constant evaluation)暗黙の定義がなされます。
結果全ての定数式の実行及びsizeofの評価は恙なく完了し、上の行のコメントを解除しなくてもこのコードはコンパイルが通るようになります。

2つ目の問題

template<typename T>
constexpr int g(T x)
{
    return x.get();
}

decltype(g(0)) b;

このコードでは貪欲な定数実行の結果g(0)インスタンス化が発生してしまうことが原因でした。

しかし、decltypeの内部は未評価オペランドであり、そこの式(g(0))はpotentially constant evaluatedな式の条件に当てはまっておらず、これは定数式ではありません。そのため、「定義の存在がプログラムのセマンティクスに影響を与える」とはみなされません。
よって、g(0)インスタンス化は発生せず、decltype(g(0))intとなりコンパイルが完了します。

3つ目の問題

template<int N>
struct U {};

int g(int);

template<typename T>
constexpr int h(T) { return T::error; }

template<typename T>
auto f(T t) -> U<g(T()) + h(T())> {}

int f(...) { return 0;}

int k = f(0);

このコードは、関数fの戻り値型計算の過程で、+オペランドをどちらから評価するかでエラーになるかならないか決まる物でした。

U<g(T()) + h(T())>の内部ですが、これは非型テンプレートパラメータの初期化式です。未評価オペランドはありませんので、potentially evaluatedな式であり、定数式である筈です。
したがって、g(T())はodr-usedされており、h(T())は「定義の存在がプログラムのセマンティクスに影響を与える」とみなされることから、両方の関数のインスタンス化が発生します。これはどちらのオペランドから評価をしたとしても、両方のインスタンス化が必要になります。
結果、h(T())インスタンス化はint::errorとなる型は存在せず失敗し、SFINAEによる継続が可能な文脈でもないため、このコードは必ずコンパイルエラーになります。

4つ目の問題

template <class T>
constexpr T f(T t) { return +t; }

struct A { };

template <class T>
decltype(std::is_scalar<T>::value ? T::fail : f(T()))
  g() { }

template <class T>
void g(...){}

g<A>();

このコードは、構文解析時と定数式実行時のどちらでconstexpr関数のインスタンス化が発生するかでエラーになるかが決まる物でした。

問題となるのはg()の戻り値型計算部分ですが、decltypeの内部は未評価オペランドとなるのでpotentially evaluatedではありません。したがってconstexpr関数のインスタンス化は一切発生しません。
結果、g()の戻り値を決めようとするとAT::failという静的メンバがないことからエラーとなり、SFINAEによってもう一つのg()が選ばれます。よってコンパイルは恙なく完了します。
構文解析時と定数実行のタイミングが異なるとかは関係なく、ここでは定数実行もインスタンス化も必要無くなったのです。

この様に、定数式実行のコンテキストとconstexpr(テンプレート)関数のインスタンス化タイミングを明確にすることで、C++標準仕様とは無関係なコンパイラの実装によってエラーとなるかが決まるような奇妙な振る舞いが取り除かれ、エラーとなるケースとそうでないケースが明快かつコンパイラ非依存に決定されるようになりました。

参考文献

この記事のMarkdownソース

[C++] constexprなメモリの確保と解放のために(C++20)

※この内容はC++20から利用可能予定の情報であり、内容が変更される可能性があります。また、constexprなアロケータを作る類の内容ではないです。

前回の記事の「コンパイル時メモリアロケーション」の所に入りきらなかったP0784の内容をまとめたものです。

constexprデストラク

C++17まではデストラクタにconstexprを付けることはおろか、リテラル型として振舞えるクラスにデストラクタを定義することができず、デストラクタのコンパイル時実行もできませんでした。
標準コンテナは例外なくデストラクタがtrivialでない(定義してある)ので、標準コンテナをconstexpr対応させるためにもこの制限は撤廃されます。

C++20より、デストラクタにconstexpr指定が可能になり、そのデストラクタはコンパイル時に実行可能になります。ただし、そのようなクラスは仮想基底を持っては(仮想継承しては)ならず、デストラクタの中身は定数式で実行可能である必要があります。

= default;なデストラクタやtrivialなデストラクタは、メンバや基底クラスのデストラクタが全てconstexprであれば、暗黙的にconstexprとなります。

リテラル型の要件変更

そして、この変更に伴ってリテラル型となるクラスの条件が変更となります。

C++17までは(メンバ変数および基底型は全てリテラル型であることを前提として)、 constexprコンストラクタとtrivialなデストラクタを要求していました。
C++20からは、constexprコンストラクタとconstexprデストラクタを持つこと、という要求に少し緩和されます。
つまり、リテラル型のオブジェクトはコンパイル時に構築・破棄可能である必要があります。

//C++17でのリテラル型の例
struct literal17 {
  //constexprなコンストラクタが少なくとも一つ必要
  //かつ、そこからメンバをすべて定数式で初期化できなければならない
  constexpr literal17()
    : m{}
    , d{}
  {}

  //デストラクタは書けてもdefaultまで
  ~literal17() = default;

  //メンバは全てリテラル型
  int m;
  double d;
};

//C++20でのリテラル型の例
struct literal20 {
  //constexprなコンストラクタが少なくとも一つ必要
  //かつ、そこからメンバをすべて定数式で初期化できなければならない
  constexpr literal20()
    : m{}
    , d{}
    , str{"constexpr string"}
  {}

  //constexprであればデストラクタを書ける
  constexpr ~literal20() {
      //しかしこの例では意味のある処理を書くのがムズカシイ・・・
      m = 0;
      d = 0;
      str.clear();
  }

  //もちろんこう書いてもok
  //~literal20() = default;

  //メンバは全てリテラル型
  int m;
  double d;
  std::string str;  //!?
};

std::stringはこれから説明する変更に伴って全メンバconstexpr指定されるようになるので、リテラル型として扱うことができるようになります。

virtual constexpr destructor

すでにconstexprな仮想関数呼び出しは可能になっていますが、それはあくまでリテラル型自動変数のアドレスをその基底クラスのポインタ/参照に移して呼び出すもので、デストラクタがvirtualである必要はありませんでした。
しかし、constexprデストラクタの導入とそれに伴うリテラル型の要件変更、そしてconstexprなメモリアロケーションによってその前提は崩れます。

つまり、コンパイル時にnewによって確保されたオブジェクトが基底クラスのポインタからdeleteされたとき、実行時と同じようにデストラクタ呼び出しの問題が発生します。
皆様ご存知のように、この解決策はデストラクタをvirtualにしておくことです。

virtualでconstexprなデストラクタは(この後の変更のためにも)必要不可欠なため、許可されます。

struct base {
  virtual int f() const = 0;

  //virtual constexprと書ける!
  virtual constexpr ~base() = default;
};

struct derived : base {
  constexpr int f() const override {
    return 10;
  }
};


constexpr int new_sample() {
  //この様なことが可能だったとして
  base* d = new derived{};

  int n = d->f();

  delete_func(d);

  return n;
}

constexpr void delete_func(base* ptr) {
  //derived::~derived()がコンパイル時にも正しく呼ばれる!
  delete ptr;
}

int main() {
  constexpr n = new_sample(); //定数式で実行
}

constexprなnew式/delete式

標準コンテナをconstexpr対応させるとなると一番問題となるのが動的なメモリアロケーションです。これを定数式で認めなければ標準コンテナはコンパイル時に利用できません。そこで、一定の制限の下でコンパイル時の動的メモリ確保が認められるようになります。

constexpr関数等をコンパイル時に実行する際、未定義動作が検出された場合にはコンパイル時実行不可能 になります。そのため、コンパイラはそれを可能な限り検出しようとします。
ところが、動的なメモリ確保につきものなのがvoidポインタから別のポインタへのキャストです。

//operator new / operator delete のうちの一つ
void* operator new(std::size_t);
void  operator delete(void* ptr) noexcept;

//std::malloc / std::free
void* malloc(std::size_t size);
void  free(void* ptr);

通常メモリ確保に使われるこれらは、見てわかるようにvoid*への/からのキャストが必要です。

ポインタのキャストという行為が容易に未定義動作を踏み得る(strict aliasing rulesなど)上にそれを検出しづらいこともあって、現在定数式でそれは許可されていません。そして、C++20でも許可されません。
しかし、C++には見た目上ポインタのキャストを必要とせずにメモリ確保と解放を担う式があります。つまり、new/delete式です。

(new式(new expression)とnew演算子(operator new)の違いについて → 動的メモリ確保 - 江添亮の入門C++

new式は任意の型のメモリ領域の確保と構築、delete式は(new式で確保された)任意の型の破棄とそのメモリ領域の解放を行ってくれます。そして、これらの式の入力及び出力においてはなんらポインタの再解釈は行われません。

このnew/delete式であれば確実に不正なポインタの再解釈は行われない事が分かるため、これらの式に限ってconstexprでコンパイル時実行可能になります。

ただし、呼び出せるのグローバルなoperator newを利用するようなnew式のみで、そうでないnew式の呼び出しはコンパイル時には常に省略されます(クラススコープのoperator newオーバーロードがある場合など)。
この省略はC++14より許可されているnew式の最適化の一環として行われます。省略された場合、別の領域をあてがわれるか別のnew式の確保したメモリを拡張して補われます。
省略とはいっても何もなされなくなるわけではありません。

また、コンパイル時に割り当てたメモリはコンパイル時に確実にdeleteされる必要があり、そうなっていないnew式の呼び出しはコンパイル時実行不可となります。

delete式についても、コンパイル時にnew式で確保されたメモリを開放するもの以外はコンパイル時実行不可となります。

struct base {
  virtual bool f() const = 0;
  
  virtual constexpr ~base() = default;
};

struct derived : base {
  constexpr bool f() const override {
    return false;
  }
};

constexpr bool allocate_test1() {
  base* d = new derived{};
  auto b = d->f();
  delete d;

  return b;
}

constexpr bool allocate_test2() {
  base* d = new derived{};
  auto b = d->f();
  //現実にもよくあるdelete忘れをする
  //delete d;

  return b;
}

constexpr bool b1 = allocate_test1();  //ok
constexpr bool b2 = allocate_test2();  //compile error!

delete忘れるとコンパイルエラー!誰もが望んだことが可能になります。

std::allocator<T>std::allocator_traits

ところで、C++にはもう一つポインタの危険な再解釈を必要とせずに任意の型のメモリ領域を確保/解放する手段があります。それが、std::allocator<T>std::allocator_traits<std::allocator<T>>です。

std::allocator<T>は殆どの標準コンテナで使われているデフォルトのアロケータで、そのメンバ関数によってメモリの確保、解放を行うことができます。それも、その式の入力と出力に際してユーザー側から見てポインタの再解釈は行われません。 そこで、このstd::allocator<T>及びstd::allocator_traitsによるメモリの確保と解放もconstexprに行うことができるようになります。

それに伴ってstd::allocator<T>及びstd::allocator_traitsのすべてのメンバがconstexpr指定されます(とはいえ、std::allocator<T>allocate()/deallocate()以外のメンバ関数はちょうど削除されたので、残ったのは代入演算子とコンストラクタ、デストラクタくらいですが)。

std::allocator<T>allocate()/deallocate()は実際には定数式で呼び出し可能ではないnew/delete演算子を呼び出してしまうのですが、言語機能として特別扱いすることでconstexprに呼び出しができるようになります。

new/delete式と同じように、コンパイル時にstd::allocator<T>::allocate()で確保したメモリはコンパイル時にstd::allocator<T>::deallocate()によって確実に解放される必要があり、std::allocator<T>::deallocate()コンパイル時にstd::allocator<T>::allocate()によって確保されたメモリの解放のみを行う必要があります。
そうでない場合はコンパイル時実行不可となります。

少し注意点ですが、new式で確保したメモリをstd::allocator<T>::deallocate()で解放する、std::allocator<T>::allocate()で確保したメモリをdelete式で解放する、等といったことは定数式ではできません。コンパイルエラーです。

std::construct_atstd::destroy_at

詳しい人はご存知かもしれませんが、std::allocator<T>new/delete式とは違ってメモリの確保と解放しか行いません。オブジェクトの構築・破棄を行ってくれないのです。

std::allocator<T>::allocate()で確保したメモリを利用するにはplaccement newが、std::allocator<T>::deallocate()でメモリの解放を行う前にはpseudo-destructor call(T型のオブジェクトaに対して a.~T()のような形のデストラクタ呼び出し)もしくはstd::destroy_at()の呼び出しが必要になります。

placement new式はvoidポインタの受け入れに伴って再解釈が発生します。また、両方とも定数式では現在許可されておらず、C++20でも許可されません。
std::destroy_at()もconstexpr関数ではなく定数式で実行できません。

これを解決するために、既存のstd::destroy_at()の対となる std::construct_at()を追加し、それらにconstexprを付加します。

//C++20からのそれぞれの宣言
namespace std {
  template<class T, class... Args>
  constexpr T* construct_at(T* location, Args&&... args);

  template<class T>
  constexpr void destroy_at(T* location);
}

std::construct_at()はその呼び出しが、return ::new (location) T(std::forward<Args>(args)...);という式(つまりplacement new)と同じ効果を持つように定義されます。
std::destroy_at()はその呼び出しがlocation->~T()(つまりpseudo-destructor call)と同じ効果を持つと定義されており、特に変更はありません。 そして、両方ともconstexprが付加されコンパイル時実行可能になります。

そして現在、placement new及びpseudo-destructor callを使用しているstd::allocator_traitsconstruct()/destroy()両関数の効果をこれらを使って定義しなおします(std::allocator<T>std::allocator_traitsを通して使われることを前提とするため、構築・破棄に関わるこれらの関数を持ちません)。

これで何が変わるんじゃいという感じですが、placement new及びpseudo-destructor callの呼び出しを避け、std::construct_atstd::destroy_atコンパイラに特別扱いしてもらって定数式で実行してもらうことで、それぞれの問題を解決しています。「同じ効果を持つ」という所がキモです。

このような機能実現方法のことをコンパイラーマジックと呼んだりして、C++11以降いくつかの機能の実現において利用されています。

この涙なしには語れない(コンパイラの)努力によって、std::allocator<T>を用いてコンパイル時にメモリの確保と解放をする事の障害が取り除かれました。

ちなみに、類似のstd::destroy()std::destroy_n()、及びRangeの追加に伴ってstd::range名前空間に追加される同名の関数も同様にされ、定数式で実行できます(std::construct_at()関連も同様)。

瑣末な注意点ですが、定数式でのstd::construct_at()/std::destroy_at()の呼び出し時の第一引数T*std::allocator<T>::allocate()によって確保された領域を指すポインタでなければなりません(当然、new式で確保されたものであってもダメ)。
また、それぞれの関数内で呼び出されるTのコンストラクタおよびデストラクタが定数式で実行可能でなければconstexpr実行不可となります、これはnew/delete式でも同様です。

struct base {
  virtual int f() const = 0;
  
  virtual constexpr ~base() = default;
};

struct derived : base {
  constexpr bool f() const override {
    return false;
  }
};

constexpr bool allocate_test1() {
  std::allocator<derived> alloc{};
  //メモリ確保と構築
  derived* d = alloc.allocate(1);
  base* b = std::construct_at(d);  // b = new(d) derived{};と等価

  auto r = b->f();

  //オブジェクト破棄とメモリ解放
  std::destroy_at(b);  // b->~base();と等価
  alloc.deallocate(d, 1);

  return r;
}

constexpr bool allocate_test2() {
  std::allocator<derived> alloc{};
  //メモリ確保と構築
  derived* d = alloc.allocate(1);
  base* b = std::construct_at(d);  // b = new(d) derived{};と等価

  auto r = d->f();

  //忘れる
  //std::destroy_at(b);
  //alloc.deallocate(d, 1);

  return r;
}

constexpr bool b1 = allocate_test1();  //ok
constexpr bool b2 = allocate_test2();  //compile error!

std::allocator<T>std::construct_at()/std::destroy_at()の組み合わせで、プログラマから見た扱いはnew/delete式とほぼ同じになるわけです。

また、std::allocator_traitsの確保と解放・構築と破棄に関わるメンバが全て同様にconstexpr関数として定数式で実行可能になっているので、メモリ確保周りに関して標準コンテナは追加の作業無しでconstexpr対応をすることができます(他の部分で考慮が必要ではあります)。
C++20では、std::vectorstd::stringがこれらの変更によってconstexpr対応を果たします。

これらの多大なる努力によってコンパイル時メモリ確保に関する障害はほぼ取り除かれ、全人類の夢であったコンパイル時動的メモリ確保が可能になります。

コンパイル時確保メモリの解放タイミング

Transient allocation(一時的な割り当て)

Transient allocationとは、コンパイル時に動的に確保されたメモリのうち、その開放もコンパイル時になされたもののことを言います。
そのようなメモリは実行時に参照されず、できません。

これはほとんど問題ないでしょう。

Non-transient allocation(非一時的な割り当て)

C++20における最終的な仕様では、Non-transient allocationは認められないことになりました(P0784R6で削除されました)。従って、コンパイル時に確保したメモリは確実にコンパイル時に解放されなければなりません。

Non-transient allocationに関する以前の仕様

※以下の記述は、以前の仕様を記したものです。参考に残しておきます・・・・

Non-transient allocationはその名の通り、コンパイル時に確保されたメモリ領域のうち、コンパイル時には解放されない物の事です。
コンパイル時に確保したメモリ領域を実行時に参照したいことがある事からこの様な場合分けがなされています。

そのようなメモリ確保を許可する場合、その領域を実行時にどう扱うのかが問題となります。つまり、実行時に改めてメモリを確保しなおすのかどうかということです。C++のゼロオーバーヘッド原則的にも実行時に確保しなおすのはちょっと・・・、という感じでしょう。

そこで、クラス型内部で確保されるメモリについてのみ特別な条件を課すことでこれを可能にします。その条件とは、あるリテラルTについて

  • Tは非トリビアルconstexprデストラクタを持つ
  • そのデストラクタはコンパイル時実行可能
  • そのデストラクタ内で、Tの初期化時に確保されたメモリ領域(Non-transient allocation)を解放する

そして、これらの条件を満たしていれば、そのNon-transient allocationなメモリ領域は実行時に静的ストレージへ昇格されます。

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

  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]);
    }
  }

  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"を保持する静的配列を参照するようになる

このようなsampleクラスが上の条件を満たしています。

コンパイル時にメモリを確保してそれを実行時まで残すことを話していたのに、なぜか開放している・・・

どういうことかというと、この場合のsmple::~sample()は必要なものですが呼ばれないものです。Non-transient allocationとなる場合に、このデストラクタは見かけ上コンパイル時に確保したメモリを全て解放しているように見せるためにあります。
そして、この様に一旦解放した様に扱ったメモリ領域を最終的には静的ストレージへ移行することで実行時にも参照可能になります(つまりこの場合、const char*な文字列と同じ扱いになる)。実行時にはそのクラスのオブジェクト共々定数となるので、コンパイル時に評価完了した場合は実行時にデストラクタが呼ばれることはありません。

もちろんこの要件を満たしていればTransient allocationとして扱う事にも何ら問題はありません(constexpr関数のローカル変数として利用されるなど)。そして実行時に扱う事にも問題がない事が分かるでしょう。

まどろっこしいですが、この様な規則を導入することで前項の確保・解放関数のコンパイル時実行条件の修正や、コンパイル時動的メモリ→実行時動的メモリの変換、などのさらに煩わしいことを考えなくて済むようになります。

std::mark_immutable_if_constexpr()

ここまでで、コンパイル時に解放され切らないメモリについての扱いは分かりました。しかしそこにはまだ問題があります。

先のsampleクラスがNon-transient allocationとなる場合にはそのデストラクタは見た目だけのもので、それは呼ばれることはありません。そしてそのコンパイル時動的メモリ領域は静的記憶域へ昇格されます。
では、そのメモリの内容はどの時点で決まるのでしょうか?

先のsampleクラスはポインタをパブリックに公開しているのでコンパイル時のどの時点でもそれを書き換えることができます。さてその場合、呼び出し時点の値を実行時に持ち越せばいいのでしょうか?それともコンパイル時の全ての評価を待たねばならないのでしょうか?

利用するプログラマ視点から見るとどうでもいい話かもしれませんが、コンパイラ様から見ると大変です。えっ?コンパイル時に動的確保した領域すべてを監視し続けるんですか!?という感じです。

そのようなコンパイラ様をお助けするために、std::mark_immutable_if_constexpr()という関数が<new>に追加されます。
その役割はある時点以降はそのメモリ領域は不変であることをコンパイラに通知することです。

//宣言
template<class T>
constexpr void mark_immutable_if_constexpr(T* p);

std::mark_immutable_if_constexpr()でマークされた領域は不変であるとして扱われます。おそらく実行時に残る値はstd::mark_immutable_if_constexpr()が呼び出された時点の値となるでしょう。その後で変更しても何も起きないかと思われます(そして、それについての指示が見当たらないことから未定義動作でしょう)。ちなみに実行時に(の文脈で)呼び出しても何の効果もありません。

先のsampleクラスは以下のように修正されます。

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

  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 ~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"を保持する静的配列を参照するようになる

この様にしておけば、strはその後どう弄繰り回されても実行時から見たらHelloを保持するようになります。

なるはずでした・・・

参考文献

この記事のMarkdownソース