[C++]定数式における未知の参照の利用の許可

C++23から、定数式における、非定数式な参照の読み取り(特にコピー)が定数式において許可されるようになります。

定数式における非定数式の参照

定数式における非定数式の参照という一見意味の分からないものは、生配列に対するstd::size()の使用で容易に出会うことができます

void check(int const (&param)[3]) {
  int local[] = {1, 2, 3};

  constexpr auto s0 = std::size(local); // ok
  constexpr auto s1 = std::size(param); // ng
}

https://wandbox.org/permlink/gaYeDLdSKZtFrENY

このs0, s1はともにconstexprローカル変数であり、その初期化式(ここではstd::size()の呼び出し)は定数式でなければなりません。しかし、ここでは変数localは定数式として扱われるのに対して、変数paramは定数式として扱われておらず、それによってエラーになっています。

これは定数式で禁止されている事項に引っかかっているためにこの差が出ています

N4861 7.7 Constant expressions [expr.const]/5.12より

  • 参照に先行して初期化されていて次のどちらかに該当するものを除いた、変数の参照または参照型データメンバであるid-expression
    • 定数式で使用可能である
    • その式の評価の中でlifetimeが開始している

[expr.const]/5では定数式で現れてはいけない式が列挙されています。これはそのうちの一つです。

loaclおよびparamという名前を参照する式はid-expressionであり、localは参照に先行していて初期化されていて定数式で使用可能である参照である(ただし参照先の読み取りはできない)のに対して、paramaは初期化されていないためこの事項に即抵触しています。

他にも、値ではなくその型にしか関心が無い場合に変数の型の取得のために関数テンプレートの引数推論を利用することはよくあると思います。このような場合にも、同様の問題に遭遇する可能性があります

template <typename T, typename U>
constexpr bool is_type(U &&) {
  return std::is_same_v<T, std::decay_t<U>>;
}

これは例えば次のように使用することを意図しています

auto visitor = [](auto&& v) {
  // vは非定数式の参照
  if constexpr(is_type<Alternative1>(v))  // ng
  {
    ...
  }
  else if constexpr(is_type<Alternative2>(v)) // ng
  {
    ...
  }
};

この例のvは定数式ではないので、if constexprの条件式(当然定数式でなければならない)の中で他の関数に渡すことはできません。この場合、参照vそのものには関心が無いのが分かりやすいでしょう。

ラムダ式constexprにしてもこれは解決できません。

また別のところでは、this引数として出会うこともできます

struct S {
  int buffer[2048];

  void foo() {
    constexpr size_t N = std::size(buffer); // ng

    ...
  }
};

https://wandbox.org/permlink/slJqqHKLw6bJmBqn

このエラーは今度は、std::size(buffer)の引数においてthisが定数式で使用できないと怒られています。ここのbufferはメンバ変数なので、その参照にはthisが必要ですが、このコンテキスト(Nの初期化式)でのthisは定数式ではないため定数式で使用できずに怒られています。

この場合、foo()constexprにしても変わりません。

また、冒頭のコードをstd::array.size()メンバ関数呼び出しに置き換えると、thisによる同様のエラーに出会えます

void check(const std::array<int, 3>& param) {
  std::array<int, 3> local = {1, 2, 3};

  constexpr auto s0 = local.size(); // ok
  constexpr auto s1 = param.size(); // ng
}

https://wandbox.org/permlink/XLH8LNnIUZ4X8gaN

このthisの使用でエラーの起きている2例はいずれも、thisを介して(間接参照して)アクセスしようとしているわけではありません。

thisとよく似た場合で、あるクラスのオブジェクトを通してそのクラスの静的メンバにアクセスする場合もこの問題に出会うことができるかもしれません

struct S {
  static constexpr bool f() {
    return true;
  }

  static constexpr int constant = 1;
};

void call(S& s) {
  // sは非定数式の参照
  constexpr bool b = s.f();     // ng
  constexpr int N = s.constant; // ng
}

https://wandbox.org/permlink/48AsQDbo7v3COWOw

この場合、s.は構文上の略記でしかなく、sの具体的な値に興味がありません。にもかかわらず、sが非定数式であることによってsを介した定数式の呼び出しがエラーになっています。

やりたいこととそうじゃないこと

これが例えば次のような例であれば、これができないことは仕方のないことだと理解できます

constexpr int read(auto& array) {
  return array[0];
}

void check(int const (&param)[3]) {
  int local[] = {1, 2, 3};

  constexpr auto s0 = read(local); // ng
  constexpr auto s1 = read(param); // ng
}

しかし先ほど一通り見ていた例はいずれも、このようなことがやりたいわけではありません。なんなら、参照(this)の参照先どころか参照そのものに対してすら全く興味がありません。

一連のコード例で重要なのは参照の型情報であって、参照そのものはどうでもいいのです。

しかしいずれの例でも、定数式の実行に際して非定数式である参照値の読み取り(別の関数への受け渡し、コピー)が発生しており、これによって全体の式の定数式としての実行が妨げられてしまっています。ここでの読み取りとは参照そのものの値に対してであって、参照先にアクセスしようとするものではありません。

定数式における参照そのものの読み取りの許可

このような問題の原因は、定数式の実行においては未定義動作が許可されないことによって、定数式で使用されるすべての参照は有効なものでなくてはならないためです。関数の中からでは関数引数の参照の有効性は判定できないためそのような参照の有効性は未知であり、最初の方で引用したルールはそれをあらかじめ弾いておくためのものでした。

従って、この問題は参照(言語参照、this、および一般のポインタ)でのみ起こります。ほぼ同等のコードであっても、それが値であれば起こりません。

void check(int const (&param)[3]) {
  int local[] = {1, 2, 3};

  constexpr auto s0 = std::size(local); // ok、参照のコピー渡し
  constexpr auto s1 = std::size(param); // ng、未知の参照のコピー渡し
}

void check_arr_val(std::array<int, 3> const param) {
  std::array<int, 3> local = {1, 2, 3};

  constexpr auto s3 = std::size(local); // ok、値の参照渡し
  constexpr auto s4 = std::size(param); // ok、値の参照渡し
}

https://wandbox.org/permlink/JCc3rgIcRXSc8UO7

もちろん値の場合でもこのように渡した先の関数内でその具体的な値にアクセスすることはできません。しかし、今回問題となっているケースでは参照先には全く関心が無いため、参照もこれと同じ扱いになってもよさそうです。

この問題はP2280によって補足され、P2280R4はC++23に対して採択されました。

P2280R4では、ここまで示したような定数式における未知の参照及びthisポインタそのもの読み取り(コピー、not間接参照)を許可します。これによりここまでいくつか示してきたような、参照そのものに興味はないが未知の参照がコピーされることによって定数実行できなかったコードがすべて定数式で実行可能になります。

void check(int const (&param)[3]) {
  int local[] = {1, 2, 3};

  constexpr auto s0 = std::size(local); // ok
  constexpr auto s1 = std::size(param); // ng -> ok
}
auto visitor = [](auto&& v) {
  if constexpr(is_type<Alternative1>(v))  // ng -> ok
  {
    ...
  }
  else if constexpr(is_type<Alternative2>(v)) // ng -> ok
  {
    ...
  }
};
struct S {
  int buffer[2048];

  void foo(){
    constexpr size_t N = std::size(buffer); // ng -> ok

    ...
  }
};
void check(const std::array<int, 3>& param) {
  std::array<int, 3> local = {1, 2, 3};

  constexpr auto s0 = local.size(); // ok
  constexpr auto s1 = param.size(); // ng -> ok
}
struct S {
  static constexpr bool f() {
    return true;
  }

  static constexpr int constant = 1;
};

void call(S& s) {
  // sは非定数式の参照
  constexpr bool b = s.f();     // ng -> ok
  constexpr int N = s.constant; // ng -> ok
}

ただし、この緩和の対象は参照とthisポインタのみで、より一般のポインタに対しては適用されません。

void f(std::array<int, 3>& r, std::array<int, 4>* p) {
  static_assert(r.size() == 3);    // #1、ok(この提案による)
  static_assert(p->size() == 4);   // #2、ng
  static_assert(p[3].size() == 4); // #3、ng
  static_assert(&r == &r);         // #4、ng
}

ポインタは参照と比べるとできることが多いため、定数式における未知のポインタに対して何の操作が適用可能で何がそうではないのかを規定しなければならず、作業が複雑になることからP2280R4では当初の目標である未知の参照とthisだけに的を絞って緩和されました。

欠陥報告

P2280R4はDR(Defect Report)として以前のすべてのバージョン(この場合constexprが初めて導入されたC++11まで)に対してさかのぼって適用されます。したがって、P2280R4を実装したコンパイラではC++23モードだけでなく、C++11やC++20モードなどでもこの問題が解決されます。

この記事を書いている時点では、GCC14だけがこれを実装しています。

requires式の引数

この未知の参照概念、基本的に関数引数で遭遇することが多いと思われるのですが、なんとrequires式の引数の参照さえもその対象です。

template<typename T>
concept to_char_narrowing_check = requires(T&& t) {
  { std::int8_t{ t.size() } };  // -128 ~ +127 の範囲内はtrue
};

static_assert(to_char_narrowing_check<std::array<int, 1>>);
static_assert(to_char_narrowing_check<std::array<int, 127>>);
static_assert(to_char_narrowing_check<std::array<int, 128>> == false);

GCC14: https://wandbox.org/permlink/ikcs2twJfdfumqa7
clang15: https://wandbox.org/permlink/dRmTPwTNeGMMrs0e

このto_char_narrowing_checkコンセプトは、std::int8_t{ t.size() }という式の有効性をチェックしています。引数型がすべてstd::arrayであるとすると、その.size()の戻り値型はstd::size_tなので常に縮小変換となり、{}初期化では縮小変換が許可されないことから常にコンパイルエラーになります(P2280R4以前は)。

ただし、このような縮小変換は定数式で行われた場合にのみ、変換元の値が変換先の型で表現可能であれば許可されます。

ただ前述のように、P2280R4以前はstd::array::size()thisポインタのコピーが必要になり、それが定数式ではないので定数式における縮小変換のチェックは行われませんでした。P2280R4以前の世界では、この例の上2つのstatic_assertが満たされることはありません(clang15の例)。しかし、P2280R4以降ではこの例の上2つのstatic_assertは満たされるようになります(GCC14の例)。

何が起きているかというと、requires式内部のstd::int8_t{ t.size() }の式の妥当性チェックの際に、定数式で縮小変換が可能かどうかがチェックされており、P2280R4の緩和によってそれを妨げるものが無くなったことで、t.size()の値が取得されてその値がチェックされるようになっています。

ただしここでは、arrayのオブジェクト(tの参照先)は具体的に使用されておらず、t.size()の値はtの型情報から取得されています。

std::declval()

P2280R4の緩和はC++17以前の世界にももたらされます。その世界で、requires式の引数のような役割を担っていたのはstd::declval()でした。残念なことにstd::declval()は定数式で呼べないため、その返す参照はP2280R4の緩和対象ではありません。そのため、SFINAEの世界では先程のto_char_narrowing_checkと同等の制約を表現でき・・・ないこともありません。

要は何とかして、std::declval()を使用していたところを関数引数から取ってくるように書き換えればいいわけです。

template <typename T>
auto to_char_narrowing_check_helper(T&& t)
  -> decltype(std::int8_t{ t.size() });   // 先程のrequires式の例と同等のチェックはここで行われる

template<typename T, typename = void>
constexpr bool to_char_narrowing_check_v = false;

template<typename T>
constexpr bool to_char_narrowing_check_v<
  T,
  std::void_t<decltype(to_char_narrowing_check_helper(std::declval<T&>()))> // std::declval()を使用して、ヘルパ関数を呼ぶ(呼んではいない)
> = true;

static_assert(to_char_narrowing_check_v<std::array<int, 1>>);
static_assert(to_char_narrowing_check_v<std::array<int, 127>>);
static_assert(to_char_narrowing_check_v<std::array<int, 128>> == false);

GCC14: https://wandbox.org/permlink/doHxvy8q1Mgw9C5g
clang15: https://wandbox.org/permlink/0tqFtsGEJSMF620m

かなり複雑な実装ですが、GCC14の実行結果を見れば、SFINAEの制約においても縮小変換が定数式でチェックされていることが分かります。

std::void_tを利用した検出テクニックについては説明を割愛します。

従来のSFINAEなら、このto_char_narrowing_check_helper()のような余分な関数は必要なく、void_tの中で直接std::void_t<decltype(std::uint8_t{ std::declval<T&>().size() })>のようにしてしまえば十分でした。ただ、P2280R4の緩和を考えると、チェックしたい式に直接std::declval()が現れるのは良いこととは言えません(前述のようにdeclval()の呼び出しは常に定数式ではないため)。

そこで、std::declval()を使用してdecltype()内でTの参照を取得するものの、それを別の関数(to_char_narrowing_check_helper())の引数に渡すことで参照の出所をロンダリングします。呼び出された関数内では、その引数の参照は未知の参照として出所に関わらず使用できる(参照先を見に行かない限り)ので、あとはそちらのSFINAEの文脈で対象の式をチェックするようにするわけです。このようにすると、従来のSFINAEの文脈でもP2280R4の緩和の恩恵にあずかることができます。

ここでは例としてstd::arraysize()を使っていたわけですが、実際にはそれに対してこういう事をする需要というのはほぼ皆無でしょう。より実用的なのは、std::integral_constantのような定数値クラスに対してです。

すでに、std::variantの初期化においてstd::integral_constantのような型の値からの変換時にこのようにP2280R4を考慮して縮小変換を定数式で検査するようにする提案(P3146)が提案されています。

using IC = std::integral_constant<int, 42>;

IC ic;
std::variant<float> v = ic; // C++23でもすべての実装でエラー
                            // P3146では値に応じて初期化できるようにすることを提案

この際に問題となったのがまさに、std::variantの制約の実装がSFINAEによって行われていることで、ここで説明していることはP3146R1でより詳細に解説されています。特に、SFINAEでもP2280R4の恩恵にあずかれるようにするテクニックは私が思いついたものではなく、この提案に例として載っているものをより簡易に直しただけです。

そして、この恩恵は提案中のconstexpr_tのような型(汎用的なconstexpr引数のための型)でより重要になってくるでしょう。

余談

P2280R4の変更は、定数式における未知の参照(とthis)の利用を許可するというよりも、それらのものが実際にその参照先にアクセスするまでエラーにしないようにするという性質のものです。元々参照されるされないにかかわらず危険な可能性があるとしてまず使用が禁止されていたものが、実際に危険になるまでは様子見するようにした感じです。

これはC++20以降の定数式の制限緩和に見られる、定数式をエラーにするのは実際に実行してみて実行できないものに出会ってから、という方針に従ったものです。

このことはあまり明言されてこなかったのですが、C++23のP2448R2の採択によって明示的にされました。P2448R2では、定数式におけるコンパイルエラーの発生条件を可能な限り遅延し、実行してみて実行できなかった場合にのみエラーにするという様に変更するものです。

参考文献

この記事のMarkdownソース