[C++]P0588R1を紐解く

C++20にひっそりと採択されているP0588R1 Simplifying implicit lambda captureという提案は、3度見したくらいでは何をしているのか、何がしたいのかさっぱりわかりません。一体これはなんなのでしょうか・・・

P0588R1のやっていること

眺めているとP0588R1ではラムダ式に関連したいくつかのこと・振る舞いを明確化しようとしていることが朧げながら見えてきます。それはおそらく次の4つです

  1. ラムダ式のキャプチャの振る舞いの明確化
  2. 構造化束縛をキャプチャできないことを明確化
  3. ラムダ式がクラスメンバ初期化子で使用された時の挙動の明確化 (CWG1632)
  4. ラムダ式の構文内でキャプチャした対象に対するdecltype((x))の振る舞いの明確化 (CWG1913)

これらの変更は全て、規格上でその振る舞いが不透明だったものをしっかりと記述し直そうとするもののようで、既存の振る舞いを変更することを意図するものではないようです。

そのため規格文書の言い回しを工夫する変更になっており、それが上記4要素分いっぺんに入っているので意味不明度を高めています。

言葉の定義

ここでは、P0588R1で導入されている用語や、それに使用されている用語などの定義をしておきます。なお、ここでの定義はP0588R1の変更を反映したものなので、それ以前の定義とは異なるものです。

エンティティ

エンティティ(entity)とは次のいずれかに該当するものです

  • オブジェクト
  • 参照
  • 構造化束縛
  • 関数
  • 列挙体
  • クラスメンバ
  • ビットフィールド
  • テンプレート
  • テンプレート特殊化
  • 名前空間
  • パラメータパック

C++のコード上で構文以上の何かしらの意味を持つもののことを総じてエンティティと呼びます。

ローカルエンティティ

ローカルエンティティ(local entity)とは、エンティティの中でも次のいずれかに該当するものです

  • 自動記憶域期間(automatic storage duration)にある変数
    • いわゆる自動変数(ローカル変数)
  • 分解対象(構造化束縛宣言の右辺に来るオブジェクト)がローカルエンティティである構造化束縛
  • *thisオブジェクト

ほぼほぼ、一般的にローカル変数と呼ばれるもののことを言っていると思って差し支えありません。

宣言領域

宣言領域(declarative region)とは、あるエンティティを指す名前が有効なコード上の最大の範囲(領域)のことです。

void f() {
  int n = 0;/*
nの宣言領域
*/{/*
nの宣言領域
*/int n = 1;/*
このスコープのnの宣言領域(外側nの宣言領域ではない)
*/}/*
nの宣言領域
*/
}

C++コード上の全ての名前(変数名やクラス名などなど)はそのコード上に宣言領域を持ちます。

スコープ

ある名前のスコープ(scope)とは、その名前が持つ宣言領域のことです。

例えば、変数名の宣言領域とは変数のスコープのことです。

ブロックスコープ

ブロックスコープ(block scope)とは、ブロック内で宣言された名前が持つスコープのことです。

その名前はブロック内でローカルであり、そのスコープは宣言された場所からブロックの終端までです。

ブロックとは構文定義でcompound-statementとして指定されるもので、関数定義の{...}ラムダ式の本体{...}はブロックですが、クラス定義の{...}名前空間定義の{...}はブロックではありません。

namespace N {
  // ブロックスコープではない
}

struct S {
  // ブロックスコープではない
};

export {
  // ブロックスコープではない
}

int main () {
  // ブロックスコープ

  {
    // ブロックスコープ
  }

  try {
    // ブロックスコープ
  } catch(...) {
    // ブロックスコープ
  }

  [] {
    // ブロックスコープ
  };
}

ブロックスコープで宣言された変数はローカル変数です。

関数パラメータスコープ

関数パラメータスコープ(function parameter scope)とは、関数の仮引数名の持つスコープのことです。

関数パラメータスコープは関数の仮引数が宣言された地点から始まるため、関数のローカル引数よりも少しだけ広いスコープを占めます。

void f(
  // 関数パラメータスコープ
  int a,
  int b
) {
  // 関数パラメータスコープ
  {
    // 関数パラメータスコープ
    int a = 1;
    // 仮引数aの関数パラメータスコープではない
    // 仮引数bの関数パラメータスコープではある
    // ローカル変数aの関数パラメータスコープでもある
  }
  // 関数パラメータスコープ
}

クラススコープ

クラススコープ(class scope)とは、クラスで宣言された名前の持つスコープです。

クラススコープはその定義内だけでなく、クラス定義外のメンバ関数定義スコープなども含まれます。

struct S {
  // Sのクラススコープ
  int n;

  void f() {
    // Sのクラススコープ
  }

  void g();
};

void S::g() {
  // Sのクラススコープ
}

odr-usable

odr-usableとはまず、ローカルエンティティに対する概念です。

あるローカルエンティティがその宣言領域内で参照される場合

  • そのエンティティは*thisではない、もしくは
  • その場所はクラススコープかラムダ式のものではない関数パラメータスコープに囲われている
    • そのスコープの最も内側のスコープが関数パラメータスコープであるならば、そのスコープは非静的メンバ関数のもの

のどちらかに該当しており(これを前段の条件と呼びます)

そのローカルエンティティが導入される地点とそのローカルエンティティが参照される領域との間に介在している全ての宣言領域について

  • 介在する宣言領域はブロックスコープである、もしくは
  • 介在する宣言領域はラムダ式の関数パラメータスコープであり
    • そのローカルエンティティを明示的にキャプチャしているか、デフォルトキャプチャを持っていて
    • そのラムダ式ブロックスコープ(本体)もまた、介在する宣言領域である

のどちらかに該当する場合に、そのローカルエンティティはodr-usableとなります(この条件を後段の条件と呼びます)。

介在する宣言領域というのは、ローカルエンティティの導入(宣言/定義)地点から、そのローカルエンティティを参照する地点の間に存在している宣言領域(スコープ)です。介在する(intervening)というのは、参照地点から導入地点の間でそのスコープが折り重なっている様を言っているのだと思われます。

そして、ローカルエンティティがodr-usableではない宣言領域でodr-usedとなる場合、プログラムはill-formedです。(odr-usedはとても難しい概念なので深入りはしませんが、ここでは定義が必要になる使われ方、のような意味だと思ってください)

規格書([basic.def.odr]/9)より、サンプルコード

void f(int n) {
  [] { n = 1; };                // #1 error: n is not odr-usable due to intervening lambda-expression

  struct A {
    void f() { n = 2; }         // #2 error: n is not odr-usable due to intervening function definition scope
  };

  void g(int = n);              // #3 error: n is not odr-usable due to intervening function parameter scope

  [=](int k = n) {};            // #4 error: n is not odr-usable due to being
                                // outside the block scope of the lambda-expression

  [&] { [n]{ return n; }; };    // #5 OK
}

この例の場合、ローカルエンティティnは関数fの関数パラメータスコープを宣言領域として導入されていて、*thisではないので、odr-usableの前段の条件はクリアしており、問題となるのは後段の条件のみです。

  1. ローカルエンティティnラムダ式の関数パラメータスコープに囲われて(介在して)いますが、そのラムダ式はキャプチャに何も指定していない(明示的にも暗黙的にもnをキャプチャしていない)ため、この場所でnはodr-usableではありません
  2. ローカルエンティティnA::f()の関数定義スコープ(ブロックスコープ)と関数パラメータスコープとAのクラススコープに囲われています。後2つはブロックスコープではないため(当然ラムダ式の関数パラメータスコープでもないため)、odr-usableではありません
  3. ローカルエンティティng()の関数パラメータスコープに囲われていますが、これも後段2条件のどちらに合致するスコープでもないため、odr-usableではありません
  4. ローカルエンティティnラムダ式の関 数パラメータスコープに囲われていて、そのラムダ式はデフォルトキャプチャを持っています。しかし、そのラムダ式の本体のスコープが介在していない(nが参照される地点は本体の外側の)ため、odr-usableではありません
  5. ローカルエンティティnは2つのラムダ式の関数パラメータスコープに囲われていて、いずれのラムダ式nをキャプチャしており(デフォルトキャプチャ->明示的キャプチャ)、nが参照される地点は2つのラムダ式の本体のブロックスコープの内部です。従って、これはodr-usableです。

このサンプルコードをよく見ると、いずれのケースでもこの関数f()の外側にローカルエンティティnを参照しているもの(関数やラムダ式、ローカルクラス)を持ち出すことができます。戻り値をautoにするとか関数ポインタにするとか、std::functionを使用するとか・・・

もしこのng例がokだったとすると、それら外に持ち出したものを介してこれらの関数が呼び出し可能となるため、f()のローカルエンティティnf()の外側から読み書きされることになります。それはあたかもラムダ式における参照キャプチャが暗黙的に行われているようなもので、当然それは正しい振る舞いでも標準が意図する振る舞いでもないため禁止されなければなりません。ng例はいずれもそれが起こる場合を指していることがわかると思います。

逆に、okの最後の例は外に持ち出した時にそのようなことは起こらないことがわかります。参照キャプチャだけを使用した場合は同様の問題がありますが、少なくともそれはコードに表れているため暗黙的には起こりません(そして、その問題を解決しようとすることはまた別の問題でもあります)。

ローカルラムダ式

ローカルラムダ式local lambda expression)とは、ラムダ式であって次のいずれかに該当するものです

  • 宣言された場所を囲む最も内側のスコープがブロックスコープである
  • クラスのデフォルトメンバ初期化子で現れており、囲む最も内側のスコープがそのメンバに対応するクラススコープである
struct S {
  int m = []{ return 0; }(); // ローカルラムダ式
};

int N = []{ return 0; }();  // ローカルラムダ式ではない

int main() {
  []{}; // ローカルラムダ式
}

ローカルラムダ式のみが、デフォルトキャプチャ(= &)と明示的キャプチャ(名前を指定するキャプチャ)を行うことができます。言い換えると、非ローカルなラムダ式では初期化キャプチャのみが行えます。

int N = 10;

// 非ローカルラムダ
int M1 = [=] { return 20; }();  // ng
int M2 = [&] { return 20; }();  // ng
int M3 = [N] { return 20; }();  // ng
int M4 = [n=N] { return n*2; }(); // ok

int main() {

  // ローカルラムダ
  [=] { return 20; };  // ok
  [&] { return 20; };  // ok
  [N] { return 20; };  // ok
}

これは、ラムダ式がキャプチャする(必要がある)ものは常にローカルエンティティであることを反映しています。

ラムダ式のキャプチャに伴うローカルエンティティの参照

ラムダ式のキャプチャのために、式はローカルエンティティを参照する可能性があり、それは次の場合です

  • 1つ以上の非静的クラスメンバを指定し、そのメンバへのポインタを形成するものではないid式(id-expression、単体の識別子名だけからなる式)は、*thisを参照する可能性がある
  • this(式)は、*thisを参照する可能性がある
  • ラムダ式はその明治的キャプチャに指定された名前のローカルエンティティを参照する可能性がある
struct S {
  int m;

  void f() {
    [=] {
      int n = m;  // メンバmを参照する式によって、*thisの参照が発生する
    };

    [=] {
      [this]{};  // this式の使用によって、*thisの参照が発生する
    };
  }

  void g(float) {}

  static void g(int) {}

  void h() {
    [=] {
      g(0); // 結果的に静的メンバ関数が選択されるが、メンバgを参照する式によって*thisの参照が発生する
    };
  }
};

int main() {
  int n = 0;

  [n] {
    int m = n;  // nの明示的キャプチャによるローカルエンティティnの参照が発生する
  };
}

可能性があるのような書き方をしているのは、おそらくそれに該当する場合でも参照されない場合があり得るためです。例えばこの例でも、S::f()の2つ目のラムダ式ではthisを結局使用していないので参照はされないですし、main()ラムダ式中でnを使用しなければキャプチャしていても参照されないでしょう。

暗黙的なキャプチャ

  • ある式が、odr-usableなローカルエンティティを参照する可能性があり
  • その式を囲んでいるtypeid式の効果が無視された場合に評価される可能性がある(potentially evaluated)とき

そのローカルエンティティは、そのローカルエンティティを明示的にキャプチャしないデフォルトキャプチャを持つ介在するラムダ式によって、暗黙的にキャプチャされて(implicitly captured)います。

ここでの介在するは、odr-usableの宣言領域に対する条件の場合と同様にラムダ式がネストしている様を表しています。

要するに、ラムダ式がデフォルトキャプチャ(&/=)を持っていて、その本体内で外側のエンティティを参照する場合に自動で行われるキャプチャの事です。

規格書([expr.prim.lambda.capture]/7)より、サンプルコード

void f(int, const int (&)[2] = {});         // #1
void f(const int&, const int (&)[1]);       // #2

void test() {
  const int x = 17;
  auto g = [](auto a) {
    f(x);                       // OK: #1を呼び出す、xをキャプチャしない
  };

  auto g1 = [=](auto a) {
    f(x);                       // OK: #1を呼び出す、xをキャプチャする
  };

  auto g2 = [=](auto a) {
    int selector[sizeof(a) == 1 ? 1 : 2]{};
    f(x, selector);             // OK: #1か#2のどちらかを呼び出す、xをキャプチャする
  };

  auto g3 = [=](auto a) {
    typeid(a + x);              // OK: a + xが評価されないオペランドであるかどうかに関わらず、xをキャプチャする
  };
}

ラムダ式gの例では、xが左辺値から右辺値への変換(lvalue-to-rvalue conversion)の対象となるためxの使用(参照)はodr-usedではなく(そのためodr-usableである必要がなく)、キャプチャしなくても参照可能になります。

g1~g3の例ではラムダ式内からのxの参照はodr-usableであり、xは暗黙キャプチャされています。

ただし、g1の場合はxが左辺値から右辺値への変換の対象であるため、xの参照はodr-usedではなく、それによって実装がxのキャプチャを最適化(キャプチャしないように)することが許可されています(どうやら、g2, g3の使用ではxは左辺値から右辺値への変換の対象とならないようです)。

また、この暗黙的なキャプチャはローカルエンティティが破棄されるステートメントから参照される場合でも発生することがあります

template<bool B>
void f(int n) {
  [=](auto a) {
    if constexpr (B && sizeof(a) > 4) {
      (void)n;  // Bとsizeof(int)の値に関係なく、ローカルエンティティnをキャプチャする
    }
  }(0);
}

これらの事は、暗黙的にキャプチャされるエンティティは構文的に決定されることを言っています。

また、この提案でAnnex Cに追加されている互換性レポート(破壊的変更を記録している章)によると、この提案の規定による暗黙的なキャプチャは以前(C++17)ではキャプチャしなかったローカルエンティティをキャプチャする可能性がある、としており、これはルールを単純化constexpr ifとの相互作用を解決するため、とされています。

これも、暗黙的にキャプチャされるエンティティは構文的に決定される事を意味しており、暗黙的なキャプチャはconstexpr ifの分岐によって変化しないということだと思われます。

ラムダ式のキャプチャの振る舞いの明確化

定義した概念の中で、この作業に関連するのは

  • odr-usable
  • ローカルラムダ式
  • ラムダ式のキャプチャに伴うローカルエンティティの参照
  • 暗黙的なキャプチャ

の4つです。

特に4つ目は、以前は暗黙的/明示的キャプチャの定義が曖昧でそれがいつ起きて何をするのか不透明だったところを、暗黙的なキャプチャ(と明示的なキャプチャ)がどういうものでいつ起こるのかを明確に定義するようになっています。

それに加えて、次のような規定によってラムダ式の明示的キャプチャに対応する名前の探索がローカルエンティティだけを発見することが明確化されます

明示的なキャプチャに指定された識別子は、非修飾名探索(unqualified name lookup)の通常のルールを使用して探索される。この探索では、識別子に対応するローカルエンティティを発見しなければならない

以前は単に、エンティティを発見する、のようになっていたためキャプチャ対象が不透明だったのをローカルエンティティという言葉を用いて明確化した形です。

また、ラムダ式のキャプチャは次のように定義されていました

エンティティは明示的または暗黙的にラムダ式によってキャプチャされたとき、エンティティはキャプチャされる(captured)。
ラムダ式にキャプチャされたエンティティは、そのラムダ式を含むスコープでodr-usedとなる。

(この定義に変更はありません)

そのうえで、次のような規定

ラムダ式がodr-usableではないエンティティを明示的にキャプチャする場合、プログラムはill-formed

を追加し

また、odr-usableのところで触れましたが

ローカルエンティティがodr-usableではない宣言領域でodr-usedとなる場合、プログラムはill-formed

という規定も追加しています。

結局、この提案ではこれらの変更によって

  • ラムダ式がいつローカルになるのかを明確化し、それによってラムダ式が非初期化キャプチャ(暗黙的/明示的キャプチャ)を行える場所を明確化
    • ローカルラムダ式のみが、暗黙的/明示的キャプチャを行える
  • 暗黙的なキャプチャがいつ起こるのかを明確化
    • 暗黙的なキャプチャはローカルエンティティに対してのみ起こる
  • 明示的なキャプチャにおける名前探索の対象を明確化
    • 明示的なキャプチャはローカルエンティティに対してのみ起こる
  • ローカルエンティティのodr-usableによって、ラムダ式がキャプチャできない(しない)ものを明確化
    • odr-usableではないローカルエンティティをodr-usedしようとするとill-formed
      • ラムダ式がキャプチャしたものはodr-usedとなるため、odr-usableではない明示的/暗黙的なキャプチャはill-formed
    • ラムダ式がキャプチャしないものは、odr-usableとならない
      • キャプチャしないものがラムダ式の宣言領域でodr-usedとなる場合ill-formed

このような、キャプチャ周りの挙動を明確になるように規定の修正を行っています。

エンティティがいつodr-usedになるかは難しいですが、評価されない文脈やインスタンス化していないテンプレートなどの内部を除いてほとんどの場合にodr-usedになると思っていいと思います。

thisのキャプチャ

odr-usableの後段の条件の2つ目

  • その場所はクラススコープかラムダ式のものではない関数パラメータスコープに囲われている
    • そのスコープの最も内側のスコープが関数パラメータスコープであるならば、そのスコープは非静的メンバ関数のもの

*thisがodr-usableとなる場合の前提条件を言っています。読み解けば、*thisがodr-usableとなるのはクラススコープ(デフォルトメンバ初期化子)か、非静的メンバ関数の関数パラメータスコープのどちらかです。

odr-usableでなければキャプチャできないので、*thisをキャプチャできるのはクラススコープ(デフォルトメンバ初期化子内)か、非静的メンバ関数内のどちらかです。

また、ラムダ式のキャプチャに伴うローカルエンティティの参照

  • メンバを参照する場合*thisを参照する
  • this*thisを参照する

の定義と、この提案で追加される規定

thisもしくは*thisの明示的なキャプチャは、ローカルエンティティ*thisを明示的にキャプチャする

より、thisのキャプチャも*thisのキャプチャもローカルエンティティ*thisをキャプチャします。

この*thisをコピーしてキャプチャするか参照キャプチャするのかは、この後でキャプチャの仕方(構文)から決定されます。

構造化束縛をキャプチャできないことを明確化

C++17導入時点では構造化束縛をラムダ式がキャプチャできるかどうかは不透明でした。この提案ではそれが明確に禁止されます。これは次のように規定されることによります

ラムダ式が明示的もしくは暗黙的に構造化束縛をキャプチャする場合、プログラムはill-formed

ただし、これはとりあえず振る舞いを明確化するのが目的であって禁止することが意図ではないようです。おそらく、それは別の問題でありここではなく別の提案で解決することを意図していたのでしょう。実際に、最終的にC++20では構造化束縛をラムダ式でキャプチャできるようになっています。

詳しくは以前の記事をご覧ください。

ローカルクラス

ラムダ式はローカルクラスの特殊な場合でもあり、外側のエンティティの参照に関してほぼ同じ問題があります。そのため、ラムダ式同様にローカルクラスはローカルエンティティを参照できません。

ローカルクラスはその宣言内で囲むスコープのローカルエンティティをodr-usedできない

と規定されます。より正確には、これはローカルクラススコープが介在することでローカルエンティティの参照がodr-usableではなくなるためにodr-used出来なくなります。

この提案の修正では、ローカルクラスが参照できないエンティティをローカルエンティティという言葉で規定し直すことで、構造化束縛の参照が禁止されるようになっています。

規格書([class.local]/1)より、サンプルコード

void f() {
  int x;
  const int N = 5;
  int arr[2];
  auto [y, z] = arr;

  struct local {
    int g() { return x; }       // error: odr-use of non-odr-usable variable x
    int m() { return N; }       // OK: not an odr-use
    int* n() { return &N; }     // error: odr-use of non-odr-usable variable N
    int p() { return y; }       // error: odr-use of non-odr-usable structured binding y
  };
}

(サンプルコードは一部省略しています)

この例はいずれも、ローカルエンティティをローカルクラス内から参照しようとしています。それにあたってはodr-usableであるかどうかが問題となり、いずれもクラススコープが介在していることからodr-usableではありません。

ただし、m()の例だけは、式Nが左辺値から右辺値への変換の対象となることからNの参照はodr-usedではなくなり、odr-usableであるかは問題とならなくなります。他の例は全てローカルエンティティをodr-usedしようとしています。

ラムダ式がクラスメンバ初期化子で使用された時の挙動の明確化

これは、CWG Issue 1632を解決するための作業です。

これは以前のローカルラムダ式の定義がクラススコープを考慮していなかったために起きている問題でしたが、この提案によってローカルラムダ式の定義には

クラスのデフォルトメンバ初期化子で現れており、囲む最も内側のスコープがそのメンバに対応するクラススコープである

という条件が追加されており、これによってクラスメンバ初期化子でラムダ式が使用可能であり、キャプチャもできることが明確になっています。

ただし、

  • ローカルエンティティの定義
    • *thisはローカルエンティティだがメンバ変数はそうではない
  • ラムダ式のキャプチャに伴うローカルエンティティの参照
    • メンバを参照する場合*thisを参照する
    • this*thisを参照する

の定義と、この提案で追加される規定

thisもしくは*thisの明示的なキャプチャは、ローカルエンティティ*thisを明示的にキャプチャする

などから、クラスのデフォルトメンバ初期化子内のラムダ式が行えるキャプチャは、デフォルトキャプチャかthis*this)の明示的キャプチャのどちらかに限られます。odr-usableの条件から、ローカルクラス内ラムダ式がその外側のエンティティを参照することもできません。

struct S {
  int n = 1;

  // これらはok
  int m1 = [=] { return n + 1; }();
  int m2 = [&] { return n + 1; }();
  int m3 = [this] { return n + 1; }();
  int m4 = [*this] { return n + 1; }();
  int m5 = [n = n] { return n + 1; }();

  // これはng
  int ng = [n] { return n + 1; }();
};

この場合、ラムダ式内部から参照されているnはローカルエンティティではなく、クラスメンバnの参照を介した*thisのキャプチャによって使用可能となっています。

ただし、thisをキャプチャできるため、初期化前に未初期化メンバを参照するコードが書けてしまいます。

struct S {
  int n = 1;
  int m = [this] { return n + l; }();  // 💀 UB!!
  int l = 3;
};

この場合、初期化前のメンバ変数はコンストラクタすら呼ばれていないので読み書き共に未定義動作になります。

ラムダ式の構文内でキャプチャした対象に対するdecltype((x))の振る舞いの明確化

これは、CWG Issue 1913を解決するための作業です。

問題となっているのはラムダ式内部でdecltype((x))した時の結果を指定するところで、キャプチャした変数xクロージャ型のメンバであるかのように扱って結果を求める、のようにしていました。しかしこれは、ラムダがxをキャプチャしていない場合やxを参照キャプチャしている場合を考慮できておらず、不正確な規定でした。

この提案ではラムダ式内部での特別扱いをやめ、非修飾名xを指定するid式の型について次のように規定することでこの問題を解決します。

id式の結果は、その識別子で示されるエンティティである。
エンティティがローカルエンティティで、その非修飾名が現れる宣言領域内の評価されない文脈の外側から(その名前を)指名すると、介在するラムダ式がコピーによってそのローカルエンティティをキャプチャする事になる場合
そのid式の型は、最も内側のラムダ式クロージャオブジェクトでそのキャプチャのために宣言されている非静的メンバ変数を指定したクラスメンバアクセス式の結果の型となる。
[Note: ラムダ式mutableと宣言されていない場合、そのid式の型は通常const修飾される。]
それ以外の場合、id式の型は式の結果(ローカルエンティティ)の型。
[Note: この型がCV修飾されているか参照型である場合、その型は[expr.type]で説明されているように調整される。]

1つ目のNoteの直前の文は、xをコピーキャプチャしている場合のラムダ式内でdecltype((x))した時の結果型を言っており、2つ目のNoteの直前の文は、xを参照キャプチャしている場合のラムダ式内でdecltype((x))した時の結果型を言っています。

2つ目のNoteにある[expr.type]で説明されている型調整とは、いわゆるdecayのようなもので、autoやテンプレートパラメータの推論において行われるCV・参照修飾の調整の事です。これはコピーキャプチャしている場合のid式の型には適用されません。

xをコピーキャプチャしている場合にのみクロージャ型のメンバアクセスとして扱って、クラスメンバアクセスの結果と一致させ、参照キャプチャの場合は特別扱いせずに通常と同様の結果を返します。

提案文書より、サンプルコード

void f() {
  float x, &r = x;
  [=] {
    decltype(x) y1;             // y1の型はfloat
    decltype((x)) y2 = y1;      // y2の型はfloat const& (このラムダはmutableではなく、xはlvalueのため)
    decltype(r) r1 = y1;        // r1の型はfloat&
    decltype((r)) r2 = y2;      // r2の型はfloat const&
  };
}

decltype()オペランドが評価されない文脈であるため、この例のラムダ内ではx, rはodr-usedではなく、このラムダ式x, rをキャプチャしていません。にもかかわらず、decltype((expr))の型の決定においてはx, rはキャプチャされているかのように扱われます。それは

その非修飾名が現れる宣言領域内の評価されない文脈の外側から(その名前を)指名すると、介在するラムダ式がコピーによってそのローカルエンティティをキャプチャする事になる場合

というのによります。評価されない文脈の外から名前を参照したかのようにして型を求めるので、キャプチャされているかは関係なかったりするわけです。つまりは、キャプチャされてない場合でもキャプチャされているかのように扱います。

decltype((expr))では、まずオペランド(expr)の値カテゴリに応じて結果の値カテゴリ(参照修飾)が決まり、(expr)が左辺値式なら左辺値&(expr)が右辺値式なら右辺値&&となります。その修飾を付加する対象の型は式(expr)の型と指定されており、(expr)の型はexprの型であり、この例ではexprはid式xもしくはrです。

id式の型の決定過程は上に引用した通りで、前述のとおりこの例ではx, rは共にコピーキャプチャしている場合と同様に扱われるため、クロージャオブジェクトのクラスメンバx, rへのアクセスの結果型となり、このラムダ式は非mutableのため、id式x, rの型はconst floatになります。また、ラムダ式の関数呼び出し演算子は左辺値修飾であるので、id式x, rの値カテゴリは左辺値です。

よって、decltype((x))decltype((r))の結果型は、どちらもfloat const&const float&)となります。

一方、decltype(x)decltype(r)の型の決定はそれとはまるで異なり、decltypeの行う()で囲まれていないid式(unparenthesized id-expression)に対する型の決定過程に従い、そこではid式x, rの結果エンティティの型を取得します。id式の結果はその識別子が示すエンティティであり、このラムダ式内部において式x, rの示すエンティティとはラムダ式の外側にあるf()のローカル変数x, rです。

このエンティティの型は宣言されたとおりの型であり、decltype(x)floatdecltype(r)float&となります。

なお、この節に関連する規格の記述はC++23において少し厄介な変更が入っているため、C++20 DIS(N4861)を参照しています。

DR?

これらの変更はおそらく、規格で不透明だったものの最初から意図されていた挙動を明確化することを目的としており、既存の挙動を変更するようなものではありません。したがって、これらの変更は対応する機能が追加された時点(ほぼC++11、構造化束縛関連はC++17)に対するDRであると思われます。

参考文献

この記事のMarkdownソース