[C++]モジュールにおける所有権モデル

モジュールにおける所有権とは、名前付きモジュールに属しているエンティティの名前に関する所有権の事で、次のようなコードがどう振舞うかを決定するものです。

/// moduleA.ixx
export module moduleA;

export int munge(int a, int b) {
    return a + b;
}
/// moduleB.ixx
export module moduleB;

export int munge(int a, int b) {
    return a - b;
}
/// libA.h

int libA_munge(int a, int b);
/// libA.cpp

#include "libA.h"
import moduleA;

int libA_munge(int a, int b) {
    return munge(a, b);
}
/// main.cpp

#include "libA.h"
import moduleB;

// ここでは、moduleAはインポートされていない

int main() {
  // それぞれ、どちらが呼ばれる?
  int a = munge(1, 2);      // moduleBのものが呼ばれる?
  int b = libA_munge(1, 2); // moduleAのものが呼ばれる?
}

こちらのサンプルコードを改変しています)

C++の意味論としてはこれは当然明白なことで、コメントにある通りに呼ばれるはずです。しかし、このプログラムはコンパイルされリンクされた結果として一つの実行ファイルになります。C++20の名前付きモジュールはそれそのものが1つの翻訳単位をなすので、そこには2つのモジュール(moduleAmoduleB)をコンパイルした2つのオブジェクトファイルが含まれます。
その時、翻訳単位main.cpplibA.cppはそれぞれのコードの意味論から期待されるモジュールからの関数を呼び出すでしょうか?

これはつまりODR違反が起きている状態です。C++17まではこれは未定義動作であり、リンカがオブジェクトファイルをリンクする順番に依存して結果が変わります。そして、モジュールファイルでもこれは未定義動作とされています。

モジュールの所有権とはこの未定義動作をどう実装するか?という問題の解です。そこには、2つの選択肢があります。

なお、同じ場所で2つの異なるモジュールからの同じ宣言が見えている場合は確実にコンパイルエラーとなります、今回のケースはリンカからしかそれが見えていません。

import moduleA;
import moduleB;

int main() {
  int a = munge(1, 2);  // コンパイルエラー!
}

弱い所有権(weak ownership)モデル

弱い所有権(weak ownership)はこの問題を解決しないモデルです。すなわち、C++17以前と同じくこれは未定義動作であり、リンカがリンクする順番に依存します。

/// main.cpp

#include "libA.h"
import moduleB;

// ここでは、moduleAはインポートされていない

int main() {
  // 弱い所有権モデルの下では未定義動作!!
  int a = munge(1, 2);
  int b = libA_munge(1, 2);
}

強い所有権(strong ownership)モデル

強い所有権(strong ownership)はこれを解決するモデルです。すなわち、異なるモジュールからエクスポートされているものは、その宣言が完全に同一であったとしても、リンク時にもモジュール名によって別のものとして扱われます。

/// main.cpp

#include "libA.h"
import moduleB;

// ここでは、moduleAはインポートされていない

int main() {
  // 強い所有権モデルの下では期待通りに呼ばれる
  int a = munge(1, 2);      // -1(moduleBのものが呼ばれる)
  int b = libA_munge(1, 2); // 3(moduleAのものが呼ばれる)
}

モジュールリンケージ

C++20モジュールにおいてはリンケージ区分にもう一つモジュールリンケージが追加されました。モジュールリンケージは名前付きモジュール内で外部リンケージを持ち得る名前のうち、エクスポートされていないものが持つリンケージです。モジュールリンケージをもつ名前は同じモジュール内からしか参照できません。つまりは、内部リンケージを同じモジュール内部の範囲まで拡張したものです。

export module moduleA;

// モジュールリンケージ
int func1(int a, int b) {
  return a + b;
}

// 外部リンケージ
export int func2(int a, int b) {
  return a + b;
}

// 内部リンケージ
static int func3(int a, int b) {
  return a + b;
}

リンケージの観点からは、2つの所有権モデルの差異は外部リンケージを持つ(エクスポートされている)名前をモジュールが所有するかどうか?という問題とみることができます。

弱い所有権モデルでは、内部リンケージとモジュールリンケージを持つ名前だけをモジュールが所有し、エクスポートされている名前をモジュールは所有しません。

強い所有権モデルでは、エクスポートされている名前を含めたモジュールに属するすべての名前がモジュールによって所有されます。

所有権と名前マングリング

この2つの所有権モデルのどちらを選択するか?という問題は、実装から見ると名前マングリングの問題となるでしょう(他の実装もあり得ますが)。

1つのモジュールは複数の翻訳単位から構成されうるので、リンカは少なくともモジュールリンケージを識別する必要があります。そのため、モジュールに所有されている名前のマングル方法にはいくつかの選択肢が生まれます。そして、現在の実装では次の2つが存在しています。

  1. モジュールリンケージを持つ名前のマングル方法を変更して、それだけをリンカが特別扱いする
    • エクスポートされている名前のマングル方法は従来通り(現在の外部リンケージを持つ名前と同じ)
  2. エクスポートされている名前のマングル方法を変更して、リンカがモジュールを認識することでリンケージを処理する
    • モジュールリンケージを持つ名前のマングル方法は従来通り

1つ目の方法では、エクスポートされている名前は既存の外部リンケージを持つ名前と同じマングルとすることができ、それ以外はこれまで通りです。また、モジュールリンケージの処理に関してもモジュールを構成する時だけ考慮したうえで、モジュールと他の翻訳単位をリンクするときは内部リンケージと同様に扱うことができ、リンカにかかる負担はかなり軽くなります。また、リンク時の扱いもこれまでと大きく変わらなさそうです。

2つ目の方法では、まずリンカはオブジェクトファイルがモジュールファイルなのかどうかを認識し、そこにある名前を見てエクスポートされている名前かどうかを識別します。この方法ではモジュール毎に名前を識別することができますが、コンパイラだけでなくリンカにもそれなりの変更が必要です。

そして、1つ目の方法はまさに弱い所有権モデルの実装であり、2つ目の方法は強い所有権モデルの実装です(他にも方法はあるでしょうが、現状はこの2つに1つです)。外部リンケージを持つ名前に対してマングル名で区別をつけないということは、リンク時に別々のモジュール(翻訳単位)からの同じ名前を区別できません。

名前マングリングの観点から見ると、所有権の強弱はモジュールリンケージを持つ名前とエクスポートされている名前のどちらのマングリング方法を変更するのか?という問題とみることもできます。そしておそらく、そのマングル方法とは単にマングル名にモジュール名とモジュールであることを示す文字列が入るだけでしょう。

実装の選択

現在のC++処理系の主要3実装は既にある程度モジュールを実装しています。そこでは、GCC\Clangは弱い所有権モデルを採用しており、MSVCは強い所有権モデルを採用しています。

例えばGCC/Clangで次のようなモジュールコードをコンパイルすると、外部リンケージを持つ名前はこれまで通りのマングルであるのに対して、内部リンケージとモジュールリンケージを持つ名前のマングル名にはモジュール名が含まれるようになっていることが分かります。また、モジュールリンケージと内部リンケージはマングル名レベルでは同じ扱いをされていることも分かります。

export module moduleA;

// モジュールリンケージ
int func1(int a, int b) {
  return a + b;
}

// 外部リンケージ
export int func2(int a, int b) {
  return a + b;
}

// 内部リンケージ
static int func3(int a, int b) {
  return a + b;
}

GCCのモジュール実装に関するWikiによれば、GCCとClangは互いの相互運用性のために連携してモジュールの実装を進めており、その結果として双方共に弱い所有権モデルを採用しているようです。

一方、MSVCは単独ですでにC++モジュールの実装をいったん完了しており、そこでは強い所有権モデルを採用していると明言されています。

この実装間の差異は、おそらくリンカの扱いから来ています。MSVCは1つの社内でリンカとコンパイラを開発しており、ターゲットはWindowsのみで、MSVCコンパイラは自社のリンカ(link.exe)以外を考慮する必要はありません。そしてそれはリンカ側から見ても同様です。そのため、リンカとコンパイラをより密に連携することができ、リンカに対して思い切った変更を加えることができます。

一方GCC/ClangはMSVCに比べるとリンカとコンパイラの開発体制には距離があります。また、それぞれ使われうるリンカは特定の一つではありません。例えば、GNU ld、lld、goldなどいくつかの実装があります。また、ClangとGCCはマングル名に関してお互いに強く互換性を意識しており、片方でコンパイルされたバイナリをもう片方でコンパイルされたバイナリとリンクすることができます(標準ライブラリ周りは別として)。そのため、モジュールの大部分を実装するコンパイラとしてはなるべくリンカに影響を与えないアプローチをとらざるを得なかったものと思われます。
また、既存のABIを大きく変更しないという方針を取ったものでもあるようです。FFIで他言語から呼び出すときにも、モジュール前後で変更が必要ないはずです。

(この辺りのことは完全に私の想像なので正しくないかもしれません)

明確にこの所有権が問題となるコードを書くことはないと思いますが、結果としてこの問題にあたってしまうことはあり得ます。その時に、実装のこの差異はモジュールコードのポータビリティに問題を与えるかもしれません。

なお、GCC/Clangはモジュールを実装中なので、このことは最終的には変化する可能性があります(低いと思わますが)。

参考文献

この記事のMarkdownソース

[C++]WG21月次提案文書を眺める(2020年8月)

文書の一覧 www.open-std.org

提案文書で採択されたものはありません。全部で21本あります。

N4862 : Business Plan and Convener's Report

C++標準化委員会の全体的な作業の進捗状況や今後の予定などについての報告書。

おそらく、C++を利用している企業などに向けて書かれたものです。

P0288R6 : any_invocable

ムーブのみが可能で、関数呼び出しのconst性やnoexcept性を指定可能なstd::functionであるstd::any_invocableの提案。

std::any_invocableは次の点を除いてほとんどstd::functionです。

  1. ムーブのみ可能
    • std::unique_ptrをキャプチャしたラムダのように、コピー不可能なCallableオブジェクトを受け入れられる
  2. 関数型にconst/参照修飾やnoexceptを指定可能
    • const性を正しく伝播できる
  3. target_type()およびtarget()を持たない
  4. 呼び出しには強い事前条件が設定される
    • これによって、呼び出し時のnullチェックが省略される
#include <any_invocable>  // 専用の新ヘッダ
#include <functional>

struct F {
  bool operator()() {
    return false;
  }
  
  bool operator()() const {
    return true;
  }
};

int main() {
  std::cout << std::boolalpha;
  
  const std::function<bool()> func1{F{}};
  const std::any_invocable<bool()> func2{F{}};
  const std::any_invocable<bool() const> func3{F{}};
  
  std::cout << func1() << '\n';  // false
  std::cout << func2() << '\n';  // false
  std::cout << func3() << '\n';  // true
}

このようにconst性を指定して正しく呼び出しが行えることで、並列処理においてスレッドセーフな呼び出しができるようになります。

他にも、noexceptや参照修飾は次のように指定します。なお、volatile修飾は指定することができません。

#include <any_invocable>

struct F {
  int operator()(int n) const & noexcept {
    return n;
  }
  
  int operator()(int n) && {
    return n + 1;
  }
};

int main() {
  std::any_invocable<int(int) const & noexcept(true)> func1{F{}};
  std::any_invocable<int(int) && noexcept(false)> func2{F{}};

  std::cout << func1(1) << '\n';  // 1
  std::cout << func2(1) << '\n';  // 2
}

これらの修飾や指定は全て省略することもできます。

std::any_invocableでも小さいオブジェクトで動的メモリ確保を避けるように規定されているので、std::functionに比べると若干パフォーマンスが良さそうです。

std::any_invocableはすでにLWGでのレビューに入っていて、C++23に入る可能性が高そうです。

P0881R6 : A Proposal to add stacktrace library

スタックトレースを取得するためのライブラリを追加する提案。

このライブラリの目的はひとえにデバッグをより効率化することにあります。例えば次のような実行時アサーションメッセージを

boost/array.hpp:123: T& boost::array<T, N>::operator[](boost::array<T, N>::size_type): Assertion '(i < N)&&("out of range")' failed. Aborted (core dumped)

次のような出力にできるようにします

Expression 'i < N' is false in function 'T& boost::array<T, N>::operator[](boost::array<T, N>::size_type)': out of range.
Backtrace:
0# boost::assertion_failed_msg(char const, char const, char const, char const, long) at ../example/assert_handler.cpp:39
1# boost::array<int, 5ul>::operator at ../../../boost/array.hpp:124
2# bar(int) at ../example/assert_handler.cpp:17
3# foo(int) at ../example/assert_handler.cpp:25
4# bar(int) at ../example/assert_handler.cpp:17
5# foo(int) at ../example/assert_handler.cpp:25
6# main at ../example/assert_handler.cpp:54
7# 0x00007F991FD69F45 in /lib/x86_64-linux-gnu/libc.so.6
8# 0x0000000000401139

このライブラリはBoost.Stacktraceをベースに設計されています。スタックトレースの一行(すなわちスタックフレーム)はstd::stacktrace_entryというクラスで表現され、std::stacktraceというクラスが一つのスタックトレースを表現します。std::stacktraceはほとんどstd::stacktrace_entrystd::vectorです。

#include <stacktrace> // このヘッダに定義される

void f() {
  // 現在のスタックトレースを取得
  auto st = std::stacktrace::current();

  // スタックトレース全体の出力
  std::cout << st << std::endl;

  // スタックトレースのイテレーション
  for (const auto& entry : st) {
    // 例えばソースファイル名だけを取得
    std::cout << entry.source_file() << std::endl;
  }
}

スタックトレースの取得はプラットフォーム毎にそこでのシステムコールAPI呼び出しにマッピングされます。情報を取り漏らさないためにスタックトレースの取得サイズは可変長としているので動的メモリ確保が伴います。また、取得したスタックトレース情報のデコードはギリギリまで遅延されます。上記でいうと、std::stacktrace_entry::source_file()が呼び出された時、あるいは内部でそれを呼び出すstd::stacktraceの標準出力への出力時に取得した情報が逐次デコードされます。

提案によれば、コンパイラオプションによってABIを変化させずにこれらの関数が何もしないように制御する実装をサポート可能としているようで、これもBoost.Stacktraceから受け継いでいる機能です。

この提案は元々C++20に導入することを目指していたようで(間に合いませんでしたが)、すでにLWGでのレビューが一旦完了しており大きな問題もなさそうなので、C++23に入る可能性が高そうです。

P1787R5 : Declarations and where to find them

規格内でのscopename lookupという言葉の意味と使い方を改善する提案。

ほとんど規格用語の新規定義とそれを用いた既存の表現の変更で、主に名前解決に関連したバグが解決されますがユーザーにはほぼ影響はないはずです。

この提案によるコア言語の文言変更は多岐に渡りますが、これによって61(+19)個のCore Issueを解決できるとの事です。

P1875R1 : Transactional Memory Lite Support in C++

現在のトランザクショナルメモリTS仕様の一部だけを、軽量トランザクショナルメモリとしてC++へ導入する提案。

以前に紹介したP2066R2の元となったもので、提案の動機などが述べられています。P2062はこの提案を規格に反映させるための文言変更点だけをまとめたものです。

onihusube.hatenablog.com

P1949R5 : C++ Identifier Syntax using Unicode Standard Annex 31

識別子(identifier)の構文において、不可視のゼロ幅文字や制御文字の使用を禁止する提案。

以前の記事を参照 onihusube.hatenablog.com

onihusube.hatenablog.com

前回(R3)との変更点は絵文字の問題についての説明が追記されたこととGCC10.1で解決されたUTF-8文字を識別子へ使用できなかったバグについての言及が追記されたことです。

絵文字について

現在C++で絵文字の使用が可能なのは、たまたまC++で許可していたユニコードのコードポイント範囲に絵文字が割り当てられたためで、全てが使用可能であるわけではなく、FFFF未満のコードポイントを持つものなど、一部の絵文字の使用は禁止されています。

例えばそこには女性記号(♀)が含まれています。これは結合文字と共に人を表すタイプの絵文字に作用してその絵文字の性別を変化させます。つまり、現在のC++の仕様では男性の絵文字を使用することは合法ですが、女性の絵文字を使用することはできないのです。

これは意図的なものではなく偶然の産物です。意図的にこのこと及びあらゆる絵文字(例えば、肌の色なども含めて)の使用を許可しようとすると、かなり多くの作業が必要となります。

この提案は識別子で使用可能な文字をUnicodeのUAX31規格を参照して決定するように変更するものであり、UAX31でも絵文字の利用については安定していないのでこの提案では全て禁止とする方向性のようです。

GCCUTF-8文字関連のバグ

GCCは長らくUTF-8文字をソースコード中で使用することについてバグを抱えていたようで、GCC10.1でそれが解決されました。ClangとMSVCではすでに問題なく利用可能だったので、これによってUTF-8文字をソースコード中で使用することはほぼポータブルになります。このことは、この提案をより後押しするものです。

P2013R2 : Freestanding Language: Optional ::operator new

フリースタンディング処理系においては、オーバーロード可能なグローバル::operator newを必須ではなくオプションにしようという提案。

以前の記事を参照 onihusube.hatenablog.com

このリビジョンでの変更は、グローバル::operator newを提供しない場合でもplacment newは利用可能であることを明確にした事と、<new>の宣言やポインタ安全性の文言などに変更はないことを明記した事です。この提案はほぼ規格書に文章を少し追記するだけのものです

EWGでのレビューの結果を次のリビジョンで反映させた上で、CWGに転送される見込みのようです。

P2053R1 : Defensive Checks Versus Input Validation

プログラムの動作の前提条件が満たされないケース2つにカテゴライズし、それらを区別することの重要性を説く報告書。

これは契約プログラミングサポートの議論のために、SG21(Contracts Study Group)のメンバーに向けて書かれたものです。

P2079R1 : Parallel Executor

ハードウェアの提供するコア数(スレッド数)に合わせた固定サイズのスレッドプールを提供するExecutorであるstd::execution::parallel_executorの提案。

C++23を目指して進行中のExecutor提案(P0443R13)では、スレッドプールを提供するExecutorであるstatic_thread_poolが唯一の標準Executorとして提供されています。

しかし、このstatic_thread_poolatach()メンバ関数によってスレッドプールにスレッドを追加することができます。
static_thread_poolがただ一つの標準Executorであることによって、プログラムの様々な場所(例えば外部dll内など)でとありあえずstatic_thread_poolが使われた結果、static_thread_poolはハードウェアが提供するスレッド数を超えて処理を実行してしまう可能性があります。これが起きると実行環境のシステム全体のパフォーマンス低下につながります。

これを防ぐために、ハードウェアの提供するスレッド数固定のスレッドプールを提供するparallel_executorを、標準Executorのもう一つの選択肢として追加しようという提案です。

parallel_executorはデフォルトではstd::thread::hardware_concurrencyに等しい数のスレッドを保持するように構築されます。その後で実行スレッド数の上限を設定することはできますが、ハードウェアの提供するスレッド数を超えて増やすことはできないようです。

P2096R2 : Generalized wording for partial specializations

変数テンプレートの部分特殊化を明確に規定するように文言を変更する提案。

以前の記事を参照 onihusube.hatenablog.com

このリビジョンでは、CWGのレビューを受けて文言を調整したようです。

この提案が導入されると、変数テンプレートはクラステンプレートと同じことができる!という事が規格上で明確になります。C++14からそうだったのですが、あまりそのように認識されていないようで、おそらくそれは規格上で不明瞭だったことから来ているのでしょう・・・。

P2162R1 : Inheriting from std::variant (resolving LWG3052)

std::variantを公開継承している型に対してもstd::visit()できるようにする提案。

以前の記事を参照 onihusube.hatenablog.com

このリビジョンでの変更は、主要な3実装ではこの問題はどうなっているのかが詳細に記述されるようになっています。

それによれば、MSVC STLは完全に対応し、libc++はvalueless_by_exceptionというメンバを上書きしているような型への対応に問題があるもののほぼ対応しており、libstdc++はstd::variantが空になっているかのチェックがstd::variantだけでしか動作しないためGCC9.1から無効化されているようです。

P2187R4 : std::swap_if, std::predictable

より効率的な条件付きswapを行うためのstd::swap_ifと、その使用を制御するstd::predictableの提案。

前回の記事を参照 onihusube.hatenablog.com onihusube.hatenablog.com

前回からの変更は、一部の関数にnoexceptが付加されたことです。

P2192R1 : std::valstat - function return type

関数の戻り値としてエラー報告を行うための包括的な仕組みであるvalstatの提案。 onihusube.hatenablog.com

変更点は文書の文面を変更しただけの様です。

P2197R0 : Formatting for std::complex

std::formatstd::comlexのサポートを追加する提案。

// デフォルト
std::string s1 = std::format("{}", 1.0 + 2i);     // s == "(1+2i)"
// iostream互換
std::string s2 = std::format("{:p}", 1.0 + 2i);   // s == "(1,2)"
// 精度指定
std::string s3 = std::format("{:.2f}", 1.0 + 2i); // s == "1.00+2.00i"

虚数単位にはiが使用され、複素数の数値型Tのフォーマットは再帰的にTのフォーマット指定(std::formatter<T>)に委譲されます。虚数部が0.0の時、虚数部を消して出力するのか0.0を出力するのかはさらなる議論を必要としているようです。

P2205R0 : Executors Review - Polymorphic Executor

Executor提案(P0443R13)のPolymorphic Executor周りのレビューの結果見つかった問題についての報告書。

P0443のPolymorphic Executorには次のものが提案されています。

  • bad_executor
  • any_executor
  • prefer_only

bad_executorは空のExecutorで、execute()は常に例外を投げます。

any_executorは簡単に言えばstd::functionExecutor版で、任意のExecutorを型消去しつつ保持し、実行時に切り替える事が出来るラッパーなExecutorです。

prefer_onlyPolymorphic Executorにテンプレートパラメータでサポートするプロパティを指定する際に、そのプロパティのサポートが必須(require)ではないことを示すのに使用するものです。

この文書はP0443のうちこれらのものをレビューし、見つかった問題点の報告と、修正案を提案するものです。

P2207R0 : Executors review: concepts breakout group report

Executor提案(P0443R13)のコンセプト周りのレビューの結果見つかった問題についての報告書。

P2209R0 : Bulk Schedule

P2181R0にて提案されているbulk_scheduleの設計についての補足となる報告書。

bulk_scheduleは遅延(任意のタイミングで)実行可能なbulk_executeで、bulk_executeはバルク実行を行うexecuteで、executeは任意のExecutorを受けてそのExecutorの指す実行コンテキストで処理を実行するものです。また、バルク実行とはSIMDなどのように複数の処理をまとめて同時実行することです。つまり、bulk_scheduleはバルク処理に対応したExecutor上で、バルク処理を任意のタイミングで実行するためのものです。

using namespace std::execution;

// バルク実行可能なExecutorを取得
bulk_executor auto ex = ...;

// 処理のチェーンを構成する
auto s1 = bulk_schedule(ex, [](){...}, 10); // 並列数10で2番目の引数の処理をex上で実行する
auto s2 = bulk_transform(s1, [](){...});    // バルク実行の結果をバルクに変換する
auto s3 = bulk_join(s2);                    // 各バルク実行の結果を1つに結合する

// この時点では一連の処理は実行されていない

// 一連の処理を実行し、完了を待機(ブロックする)
sync_wait(s3);

executeの遅延実行のためのものはscheduleが対応し、bulk_scheduleはバルク実行という点でscheduleと対称的に設計され、また対称的に使用可能となるようになっています。

// Executorを取得
executor auto ex = ...;

// 処理のチェーンを構成する
auto s1 = schedule(ex, [](){...});  // 2番目の引数の処理をex上で実行する
auto s2 = transform(s1, [](){...}); // 実行結果を変換する
auto s3 = join(s2);                 // 処理を統合する(この場合は意味がない)

// この時点では一連の処理は実行されていない

// 一連の処理を実行し、完了を待機(ブロックする)
sync_wait(s3);

これらの対称性と抽象化を支えているのはsenderと呼ばれる抽象で、senderは未実行の一連の処理を表現するものです(サンプル中のs1, s2, s3)。このsenderを用いる事で、処理のチェーンをどう実行するのかをユーザーやライブラリが定義するのではなく、実行環境となるExecutor自身が定義する事ができ、その実行環境において最適な方法によって安全に処理のDAGを実行する事ができます。

bulk_scheduleによって返されるバルク処理を表現するsenderは、そのバルク処理の完了を通知できる必要があります。すなわち、バルク処理のうち一つが終わった時の通知とは別にバルク処理の全体が終わったことを通知できなければなりません。そうでないと、バルク処理を安全にチェーンする事ができません。バルク処理の実行順は通常不定であるので、このことにもsenderによる抽象が必要です。

そのために、バルク処理を表現するsenderにはset_next操作(これはexecuteなどと同じくカスタマイゼーションポイントオブジェクトです)が追加されます。senderに対するset_nextの呼び出しによって1つのバルク処理全体が完了したことを通知し、それを受けた実行環境はそのsenderにチェーンされている次の処理を開始する事ができます。

そして、そのようなsenderを表現し制約するためのコンセプトとしてmany_receiver_ofコンセプトを追加します。many_receiver_ofコンセプトによって制約される事で、バルク処理ではない普通の処理をバルク処理にチェーンする事(あるいはその逆)は禁止され、コンパイルエラーとなります。

P2210R0 : Superior String Splitting

現状のviews::splitの非自明で使いにくい部分を再設計する提案。

views::splitを使った次のようなコードはコンパイルエラーになります。

std::string s = "1.2.3.4";

auto ints =
    s | std::views::split('.')
      | std::views::transform([](auto v){
          int i = 0;
          std::from_chars(v.data(), v.data() + v.size(), &i);
          return i;
        })

なぜなら、views::splitすると得られるこのvforward_rangeforward iteratorで構成されたrangeオブジェクト)なので、data()size()などというメンバ関数はなく、そのイテレータはポインタですらありません。ついでに言うとそのvcommon_rangebegin()end()イテレータ型が同じrange)でもありません。

文字列に対するviews::splitの結果として直接得られるのは、分割後のそれぞれの文字列のシーケンスとなるrangeオブジェクトです(これを構成するイテレータを外部イテレータと呼びます)。そして、それをデリファレンスして得られるのが分割された1つの文字列を示すrangeオブジェクトで、これが上記のコードのvにあたります(こちらを構成するイテレータは内部イテレータと呼びます)。内部イテレータforward iteratorであり、文字列を直接参照するポインタではありません。

例えば次のように文字列を分割したとき

string s = "abc,12,cdef,3";
auto split = s | std::views::split(",");

生成されるrangeの様子は次のようになります。

https://github.com/onihusube/blog/blob/master/2020/20200918_wg21_paper_202008/view_split.png?raw=true

これを踏まえると、正しくは次のように書かなければなりません。

std::string s = "1.2.3.4";

auto ints =
    s | views::split('.')
      | views::transform([](auto v){
          auto cv = v | views::common;  // イテレータ型と番兵型を合わせる
          return std::stoi(std::string(cv.begin(), cv.end()));
        });
}

主に次の理由により、このような事になっています。

  • views::splitはその処理を可能な限り遅延させる
  • views::splitの後に任意のrangeアルゴリズムをチェーンさせられる
  • views::splitは文字列に限らず任意の範囲について動作する

しかし、文字列の分割という基本的な操作は頻繁に必要になるのに対して、文字列以外の範囲の分割というユースケースは滅多に必要にならず、既存の多くの文字列処理のアルゴリズムstd::from/to_charsstd::regexなど)の多くは少なくとも双方向イテレータbidirectional range)を要求します。

これらのことから、views::splitの現在の仕様は一般化しすぎており、文字列の分割という観点からはとても使いづらいので再設計が必要である、という主張です。

主に次のように変更する事を提案しています。

  • forward rangeまたはより強い範囲をsplitすると、同じ強さの部分範囲が得られる
    • さらに、P1391 Range constructor for std::string_viewが採用されれば、そこから文字列への変換も容易になる
      • std::string_viewC++20ですでにRange constructorが追加されていました・・・
    • input rangeでしかない範囲の分割に関しては現状通り
  • split_viewviews::splitの返すrange)はconst-iterableではなくなる
    • begin()の呼び出しで単にイテレータを返す以上のことをする(しなければならい)ようになる

この変更はC++20でviews::splitを用いた既存のコードを壊すことになりますが、これによって得られる文字列分割での利便性向上という効用は互換性を破壊することによるコストを上回る、と筆者の方は述べています。

この部分は以下の方のご指摘によって構成されています。

P2212R0 : Relax Requirements for time_point::clock

std::chrono::time_pointClockテンプレートパラメータに対する要件を弱める提案。

std::chrono::time_pointは時間軸上の一点を指定する型で、その時間基準(始点(epoch)と分解能)を指定するために時計型と時間間隔を表す2つのテンプレートパラメータを受け取ります。うち1つ目の時計型ClockにはCpp17Clock要件という要求がなされています。これは、std::chrono::system_clockstd::chrono::steady_clockなどと同じ扱いができることを要求しています。

C++20ではローカル時間を表す型としてstd::chrono::local_tが導入されました。これは、std::chrono::time_zoneとともに用いることで任意のタイムゾーンにおけるローカルの時刻を表現することができるものです。

local_tに対する特殊化std::chrono::time_point<local_t, Duration>も提供されますが、local_tは空のクラスでありCpp17Clock要件を満たしていません。規格ではlocal_tだけを特別扱いする事でtime_pointを特殊化することを許可しています。

このlocal_tのように、時計型ではないが任意の時間を表す型を定義し、その時間表現としてtime_pointを特殊化することは有用なのでできるようにしよう、という提案です。

例えば次のような場合に便利であると述べられています

  • 状態を持つ時計型を扱いたい場合(非staticnow()を提供したい)
  • 異なるtime_pointで1日の時間を表現したい場合
    • 年月日のない24時間のタイムスタンプで表現される時刻を扱う
  • 手を出せないシステムが使用しているtime_pointを再現したい場合
  • 異なるコンピュータ間でタイムスタンプを比較する

この変更がなされたとしても既存のライブラリ機能とそれを使ったコードに影響はなく、local_tが既にあることから実装にも実質的に影響はないだろうとのことです。

P2213R0 : Executors Naming

Executor提案(P0443R13)で提案されているエンティティの名前に関する報告書。

P0443R13のエンティティ名及びP1897R3で提案されているアルゴリズム名のうち一部について、代わりとなりうる名前とその理由が書かれています。

P2215R0 : "Undefined behavior" and the concurrency memory model

out-of-thin-airと呼ばれる問題の文脈における、未定義動作とは何かについての報告書。

out-of-thin-airとは定式化されたメモリモデルから導かれる1つの起こり得る振る舞いのことです。さも虚空から値を読みだしているかのように見えることからこの名前がついているようです。

int main() {
  int r1, r2;
  std::atomic_int x = 0;
  std::atomic_int y = 0;

  std::thread t1{[&]() {
    r1 = x.load(std::memory_order_relaxed);
    y.store(r1, std::memory_order_relaxed);
  }};
  std::thread t2{[&](){
    r2 = y.load(std::memory_order_relaxed);
    x.store(r2, std::memory_order_relaxed);
  }};

  t1.join();
  t2.join();

  // r1 == 1, r2 == 1, x == 1, y == 1
  // となることがありうる(現実的にはほぼ起こらない)

  return 0;
}

このように、コード中どこにも表れていないはずの値が読み出されることが起こりうることが理論的に導かれます。この事がout-of-thin-air(またはThin-air reads)と呼ばれています。

C++ではメモリモデルにこの事を禁止する条項を加える事でout-of-thin-airの発生を禁止しています。しかし、メモリモデルの定式化を変更する事でこれが起きないようにしようという試みが続いているようです(Javaはそうしている)。

この文書はおそらく、そのような定式化のためにout-of-thin-airの文脈における未定義動作という現象をしっかりと定義しようとするものです。

この部分は以下の方のご指摘によって構成されています。

多分2週間後くらい

この記事のMarkdownソース

[C++]WG21月次提案文書を眺める(2020年7月)

文書の一覧 www.open-std.org

提案文書で採択されたものはありません。全部で34本あります。

P1068R4 : Vector API for random number generation

<random>にある既存の分布生成器にイテレータ範囲を乱数で初期化するAPIを追加する提案。

既存の分布生成器はスカラーAPI(1度に1つの乱数しか取得できないAPI)しか備えておらず、乱数エンジンが状態を持つことと合わさってコンパイラによる最適化を妨げており、1度に多量の乱数を取得する場合に効率的ではなくなることがあります。一方で、既存の擬似乱数生成アルゴリズムには多数の乱数生成の際にSIMDを利用するような形に書き換えて効率化する事ができるものが多数あります。
そのような用途のためのベクターAPI(1度に多数の乱数を取得できるAPI)を別途追加し、実装がその中で各種分布生成器やエンジンの特性によって最適な実装を行えるようにし、またコンパイラに最適化の機会を提供可能にすることがこの提案の目的です。

当初はSIMDタイプのAPIも同時提案していたようですが、途中でそれは別の提案に分離されたため、この提案ではイテレータによるベクターAPIの追加のみを提案しています。

std::array<float, N> array; 

std::mt19937 gen(777);  // 乱数エンジン
std::uniform_real_distribution dis(1.f, 2.f); // 分布生成器

// 範囲を乱数列で初期化する、現在はこう書くしかない
for (auto& el : array) {
  el = dis(gen);
}

// この提案によるAPI、イテレータ範囲を乱数列で初期化する
dis(array.begin(), array.end(), gen);

これは必ずしも対応するforループによる範囲の要素毎の初期化コードと同じ効果とはならず、より効率的に実装される可能性があります。

P1184R2 : A Module Mapper

モジュール(特にそのインターフェース)の名前とその中間生成物の間の名前のマッピングやモジュールのビルドについてGCCのTS実装に基づいて書かれた報告書。

C++20のモジュールはそのモジュール名とファイル名の間には何ら関連付けの規則が無く、ビルドの際に必要とされるコンパイル済みモジュールインターフェース(モジュール名とexportされているエンティティ名を記録した中間生成物)の命名に関しても当然何ら規則がありません。この論文はGCCのモジュールTSに基づく実装経験から得られたモジュール名と中間生成物の名前のマッピングのための1つのプロトコルについて説明されているものです。

P1272R3 : Byteswapping for fun&&nuf

バイトスワップ(バイトオーダー変換)のための関数std::byteswap()の提案。

C++20より<bit>ヘッダが導入されpopcountやbit rotation等の基本的なビット操作のための関数が標準で用意されるようになりました。しかし、そこにはバイトスワップ(バイトを逆順にする操作)はありません。現代のほとんどのCPUはバイトスワップのための命令を持っており、それが無い環境でも使用可能なビット演算手法が存在しています。それらを標準で用意しておくことで、よりポータブルかつ効率的に(結果として1命令以下で)バイトスワップを行えるようにしようという提案です。

// 提案されている宣言
namespace std {
  constexpr auto byteswap (integral auto value) noexcept;
}

int main() {
  auto r = std::byteswap(0x1234CDEF);
  // r == 0xEFCD3412
}

LWGでの合意は反対無しで取れていてほとんどC++20入りが決まっていたようでしたが、パディングビット(C++標準としては1byte=8bitではなく、奇数幅整数型の実装がありうる)の扱いについてCWGでの議論が必要となりその文言調整に手間取った結果C++20には間に合わなかったようです。おそらくC++23には入りそうです。

P1478R4 : Byte-wise atomic memcpy

アトミックにメモリのコピーを行うためのstd::atomic_load_per_byte_memcpy()/std::atomic_store_per_byte_memcpy()の提案。

この提案では、主にSeqlockと呼ばれるロック手法におけるユースケースを主眼にそこで有用となるアトミックなmemcpyについて提案しています。

// Seqlockにおける読み手の処理の一例
// ロック対象のshared_dataをdataに読み取る
do {
  // クリティカルセクション開始時点のシーケンス番号取得
  int seq1 = seq_no.load(std::memory_order_acquire);

  // データの読み取り
  // shared_dataがatomic変数ではない場合、この読み取り操作はフェンスを越えてseq2の後に変更されうる
  data = shared_data;

  atomic_thread_fence(std::memory_order_acquire);  // seq2の読み取り順序はここより前に変更される事はない

  // クリティカルセクション終了時点のシーケンス番号取得
  int seq2 = seq_no.load(std::memory_order_relaxed);

  // 開始時点と終了時点のシーケンス番号が異なる、あるいは開始時点のシーケンス番号が奇数であるならクリティカルセクション中に書き込みが起きている
} while (seq1 != seq2 || seq1 & 1);

// dataはshared_dataからアトミックにロード完了している事が期待されるが・・・

shared_dataはロック対象である事もあり普通はatomicでは無いはずで、その場合はこのループが正常に終了した場合でもdata変数の内容はデータ競合を起こした結果を読み取っている可能性があります。つまり、データ競合を回避するためにクリティカルセクション中で読み取られたデータは書き込みが起きている場合に破棄されるようになっているにも関わらず、このクリティカルセクション中の読み取り操作はアトミックに行われなければなりません。

多くの場合Seqlock対象のデータはtrivially copyableなオブジェクトであり、その場合そのコピーにはmemcpyが使用されます。しかし、memcpyはアトミックでは無いので、対象オブジェクトをロックフリーなアトミックサブオブジェクトに分解した上でそれらを1つづつコピーすることになります。

// 上記クリティカルセクション中のコピーは例えばこうなる
for (size_t i = 0; i < sizeof(shared_data); ++i) {
  reinterpret_cast<char*>(&data)[i] =
      std::atomic_ref<char>(reinterpret_cast<char*>(&shared_data)[i]).load(std::memory_order_relaxed);
}
std::atomic_thread_fence(std::memory_order_acquire);

この提案によるatomic_load_per_byte_memcpyはこのようなコピー(およびフェンス)を1文で自明に行うものです。

Foo data;  // trivially copyableだとする

do {
  int seq1 = seq_no.load(std::memory_order_acquire);  // このロードより後のあらゆる読み書き操作はこの前に順序変更されない

  // データの読み取り
  std::atomic_load_per_byte_memcpy(&data, &shared_data, sizeof(Foo), std::memory_order_acquire); // このロードより後のあらゆる読み書き操作はこの前に順序変更されない

  int seq2 =  seq_no.load(std::memory_order_relaxed); // 順序変更について制約はないが、先行する2つのacquireロードを超えて順序変更されることはない
} while (seq1 != seq2 || seq1 & 1);

// dataはshared_dataからアトミックにロード完了している

atomic_load_per_byte_memcpyはメモリオーダー指定を受け取りコピー元に対してアトミックにアクセスする事以外はmemcpyと同様に動作します。これを用いると最初の問題のあるSeqlockの読み取り操作はより単純にそして正しく書く事ができるようになります。誤解の種でもあったatomic_thread_fenceを使用する必要も無くなります。

atomic_store_per_byte_memcpyはこのケースの読み取りに関して双対的なもので、memcpyにおいてコピー先へのアクセスをアトミック化します。Seqlockの書き手側の処理などで使用することを想定しているようです。

P1642R4 : Freestanding Library: Easy [utilities], [ranges], and [iterators]

[utility]<ranges><iterator>から一部のものをフリースタンディングライブラリに追加する提案。

前回の記事を参照 onihusube.hatenablog.com

このリビジョンでの変更は、機能テストマクロが別の提案に分離されたこと、P1641によるフリースタンディング機能指定の文言への変更、uses-allocator構築に関する2つの機能の追加、などです。

P1659R1 : starts_with and ends_with

任意の範囲に対して動作するstd::ranges::starts_with/std::ranges::ends_withの提案。

C++20にてstd::stringstd::string_viewに対してstarts_with/ends_withメンバ関数が追加され、文字列の先頭(末尾)が指定の文字列で始まっている(終わっている)かをbool値で得られます。それらをより一般化して、任意の範囲について同じ事ができるようにするのがこの提案です。

// 共にtrueとなる
bool s = std::ranges::starts_with(std::views::iota(0, 50), std::views::iota(0, 30));
bool e = std::ranges::ends_with(std::views::iota(0, 50), std::views::iota(20, 50));

また、これらは第3引数に述語オブジェクトを渡す事で行われる比較をカスタマイズできます。例えば、先頭や末尾がある値以下であるか?のようなチェックができるようになります。

// 共にtrueとなる
bool s = std::ranges::starts_with(std::views::iota(0, 50), {-1, 0, 1, 2, 3}, std::ranges::greater);
bool e = std::ranges::ends_with(std::views::iota(0, 50), {46, 47, 48, 49, 50}, std::ranges::less);

また、さらにその後ろに各範囲の要素についての射影(Projection)を渡すこともできます。ただ残念なことに、これは普通の関数なので|で繋ぐことが出来ません。

この提案は既にLWGでのレビューをほとんど終えていて、どうやら次のリビジョンとそのレビューでもってC++23入りが確定する様子です。

P1679R3 : String Contains function

std::string, std::string_viewに、指定された文字列が含まれているかを調べるcontains()メンバ関数を追加する提案。

前回の記事を参照 onihusube.hatenablog.com

このリビジョンでの変更は機能テストマクロのの追加と大文字小文字の取り扱いについての説明の追記です。

また、LWGでの全会一致でC++23への導入が承認されたようなので何事もなければC++23に入りそうです。

P1726R4 : Pointer lifetime-end zap

Pointer lifetime-end zapと呼ばれる問題の周知とそれについてのフィードバックを得るための報告書。

Pointer lifetime-end zapとは、現在のC++のポインタセマンティクスが寿命が尽きたオブジェクトを指しているポインタの存在を認めない(その再利用が禁止されている)事で、これによってポインタ再利用を用いると効率的に書けるタイプのアルゴリズム(特に並行アルゴリズム)が未定義動作に陥っている問題の事のようです。

この文書は、Pointer lifetime-end zapは規格としてどのように表現されているかに始まり、なぜそうなっているのか?どこで問題になるのか?そして、この問題をどう解決するのか?を記したものです。
主目的は委員会のメンバに向けてこの問題の周知を図り問題そのものや解決についてのフィードバックを得ることで、何かを提案するものではないですが、この文書を受けてSG1(Concurrency Study Group)はこれを削除することを目指しており、LWGもこの問題の解決に興味を持っているようです。

(内容は私には難しいので識者の方には是非解読をお願いしたい所存)、

P1864R0 : Defining Target Tuplets

ビルドシステムがそのターゲットとなる環境を決めるための名前の列であるTarget Tupletsについて、基礎的な定義を標準に追加する提案。

ビルドシステムの担う最も困難な仕事の一つは、あるプラットフォームに向けてコンパイルする際にどのアーキテクチャ、ツールチェーン、OS、そして実行環境を使用するのかを指定することです。この名前は実装によって好き勝手に選択されており、ツールのベンダーによって異なっています。
この提案は、それらの中でも多少広く使用されているclangの定めるTarget Tripleを基にしてその標準的なものを定義することを目指すものです。

Target Tupletsはビルドのために必要となる次の4つの情報の列として規定されます。

  • Architecture
    • CPUの命令セット(ISA)
    • x86_64, armv8など
  • Vendor
    • C++ツールチェーンを提供する事業体名(組織、個人、PC、なし)
    • apple, nvidia, noneなど
  • Operating system
    • linux, win32など
  • Environment
    • 実行ファイルの形式、ABI、ランタイムのいずれか
    • macho, elfgnu, eabiandroidなど

これらの具体的な名前をこの順番に-で繋いだ文字列がTarget Tupletsとなります。

  • Windows環境の一例 : x86_64-ms-win32-msvc
  • Linux環境の一例 : x86_64-none-linux-elf
  • CUDA環境の一例 : nvptx64-nvidia-cuda-cuda

ただし、標準規格としてTarget Tupletsを定義はせず、具体的なものは別のドキュメントに委ねるようです。規格書に記述してしまうと更新のために時間と手間がかかるためです。

P2000R2 Direction for ISO C++

C++の標準化の方向性を記した文書。

C++標準化の目的や課題、次のバージョンに向けて重視する機能など、より大域的な視点でC++標準化の道筋を示す文書です。今回コロナウィルス流行に伴う社会環境の変化を反映する形で変更されたようです。

P2029R2 : Proposed resolution for core issues 411, 1656, and 2333; escapes in character and string literals

文字(列)リテラル中での数値エスケープ文字('\xc0')やユニバーサル文字名("\u000A")の扱いに関するC++字句規則の規定を明確にする提案。

この提案は以下の3つのIssueを解決するものです。

この提案では、既存の実装をベースとして字句規則をより明確に修正することによってこれらの問題の解決を図ります。

  • Core Issue 411
    • 単一の文字として表現できないユニバーサル文字名のエンコーディングは文字リテラルと文字列リテラルで異なる振る舞いをする、と規定する。
    • 例えば実行文字集合UTF-8である時、'\u0153'は(サポートされる場合)int型の実装定義の値を持つが、"\u0153"\xC5\x93\x00の長さ3の文字配列となる。
  • Core Issue 1656
  • Core Issue 2333
    • UTF-8リテラル中での数値エスケープ文字使用には価値があるので、明確に振る舞いを規定する。

問題範囲としては大きくないのですが、該当する文言をほとんど書き直しているため変更範囲が広くなっています。しかし、よく見るとより明確かつシンプルにこれらのことが表現されるようになっています。

P2075R1 : Philox as an extension of the C++ RNG engines

<random>に新しい疑似乱数生成エンジンを追加する提案。

疑似乱数生成エンジンは次のように様々な側面から特徴付けられます。

  • 乱数の質
  • 生成速度
  • 周期
  • 並列化(ベクトル化)可能性

例えば線形合同法std::linear_congruential_engine)は周期が短く乱数の質が低いのに対して、メルセンヌツイスターstd::mersenne_twister_engine)は周期が長く乱数の質は高いのですが、非常に大きなサイズの状態に依存しており並列化を妨げています。また、準モンテカルロ法をサポートできるような超一様分布を生成するエンジンもありません。

これらの観点から見ると現在のC++に用意されている乱数エンジンだけでは乱数のユースケースを満たすことは出来ず不十分であるので、最新の研究成果に基づいていくつかを検討し追加しようというのが(大本のP1932R0の)狙いです。

この提案ではPhiloxと呼ばれるGPU等のハードウェアによって並列化しやすい特性を持つエンジンに絞って提案されています。

Philoxはカウンタベースと呼ばれるタイプのエンジンで、カウンターベースのエンジンは総じて小さい状態と長い周期が特徴です(Philox4x32はそれぞれ40 [byte]2^130)。Philoxは状態が小さい事と演算が単純であることからSIMDGPUによる並列化が容易であり、intelnVidiaAMDによってそれぞれのハードウェアに最適化されたライブラリ実装が提供されており、それら実装は統計的なテストをパスしています。また、そのような特性を生かして大規模な並列乱数生成が求められる金融や非決定性有限オートマントンのシミュレーションなど、すでに広い使用実績があります。
このように、Philoxエンジンは導入および実装のハードルは低く標準化による利得は大きいため、標準に追加することを提案しています。

そして、Philoxエンジンを導入するに当たっては次の2つのAPIのどちらかを選択することを提案しています。

  • Philoxだけに着目したAPI
    • 既存の乱数エンジンを参考に、1つのPhiloxエンジンのベースとなるクラステンプレートと、そのパラメータ定義済みのいくつかのエイリアスstd::philoxNxM)だけを追加する。
  • カウンタベースエンジンのAPI

現状ではまだどちらを選択するかは議論中のようですが、どちらのAPIが選択されたとしてもユーザーからはstd::philox4x64std::philox4x64_rのような名前で既存の乱数エンジンと同様のインターフェースによって使用可能となります。

P2093R1 : Formatted output

std::formatによるフォーマットを使用しながら出力できる新I/Oライブラリstd::printの提案。

前回の記事を参照 onihusube.hatenablog.com

このリビジョンでの変更は、wchar_tおよびcharN_tオーバーロードの追加に関してを別の提案に分離して、ユニコードテキストのフォーマットについてと共に議論することが明記されたことです。

P2128R2 : Multidimensional subscript operator

多次元コンテナサポートのために添字演算子[])が複数の引数を取れるようにする提案。

前回の記事を参照 onihusube.hatenablog.com

このリビジョンでの変更は、[]が少なくとも1つの引数を取る必要があるという制約を削除したことと、C配列に対するサポートや既存ライブラリに追加することを目指さないことを明言したことです。 C配列や既存のライブラリ機能(std::valarray)でそれらを使用したい場合は、C++26以降に向けて別の提案で議論することを推奨しています。

P2139R2 : Reviewing Deprecated Facilities of C++20 for C++23

C++20までに非推奨とされた機能をレビューし、標準から完全に削除するかあるいは非推奨を取り消すかを検討する提案文書。

まだ検討中で、削除が決まった物は無いようです。

P2146R2 : Modern std::byte stream IO for C++

std::byteによるバイナリシーケンスのI/Oのための新ライブラリ、std::ioの提案。

以前の記事を参照 onihusube.hatenablog.com onihusube.hatenablog.com

このリビジョンでの変更は

  • std::streambufやCのI/O APIの使用例を追加
  • design decisionsの書き直し
  • 参照文書の変更
  • エンディアンを考慮してRIFFファイルを読み取るサンプルコードの追加
  • std::filebufベンチマークを追加

などです。

P2161R2 : Remove Default Candidate Executor

Networking TSのassociated_executorからデフォルトのExecutorを取り除く提案。

以前の記事を参照 onihusube.hatenablog.com onihusube.hatenablog.com

このリビジョンでの変更は、1引数の​defer, dispatch​, ​postがその引数のCompletionTokenから(色々やって)型::executor_typeを取得できることを要求し、それが同様に(色々やって)取得できるget_executor()の戻り値型と一致することを要求するようになった事などです。

P2165R1 : Compatibility between tuple and tuple-like objects

std::pairと2要素std::tupleの間の非互換を減らし比較や代入をできるようにする提案。

前回の記事を参照 onihusube.hatenablog.com

このリビジョンでの変更はstd::tupleの推論ガイドを変更しないことにしたことと、std::tuple_catもより広いtuple-likeな型全般で使用可能となるようにしたことです。

以前のリビジョンではstd::tupleをtuple-likeな型からその要素を分解して構築できるように推論ガイドを変更していたのですが、std::tuple{std::array<int, 2>{}}(この型はstd::tuple<std::array<int, 2>>になるが、変更の下ではstd::tuple<int, int>になってしまう)の様な既存のコードの振る舞いを破壊する事になっていいたためです。ただ、std::pairは以前から特別扱いされていたので変更なしでも2要素tupleへ(現在でも)変換可能です。

P2169R1 : A Nice Placeholder With No Name

宣言以降使用されず追加情報を提供するための名前をつける必要もない変数を表すために_を言語サポート付きで使用できる様にする提案。

前回の記事を参照 onihusube.hatenablog.com

このリビジョンでの変更は_がクラスメンバでも同じような意味をもつようにしたことと、同じスコープで複数回_を使う場合の扱いが変更されたことです。

結局、この特別な_は次の場所で使用することができます。

  • 変数名
  • クラスの非静的データメンバ名
  • 関数の引数名
  • ラムダ式のキャプチャ名
  • 構造化束縛宣言の変数名

そして、前回掲載したサンプルコードは次のように動作が微妙に変わります。

/// ここはモジュール実装単位内ではないとする

namespace a {
  auto _ = f(); // Ok, [[maybe_unused]] auto _ = f();と等価
  auto _ = f(); // NG: _の再定義、モジュール実装単位内ならばok
}

void f() {
  auto _ = 42; // Ok、[[maybe_unused]] auto _ = 42;と等価
  auto _ = 0;  // Ok、再定義(参照しない限りOK)

  {
    auto _ = 1; // Ok, 単に隠蔽する
    assert( _ == 1 ); // Ok
  }

  assert( _ == 42 ); // NG、再定義された名前の参照
}

ただし、この様な再定義が許可されるのは_という名前の変数だけです。他の名前の変数は今まで通り定義が一つでなければなりません。

P2178R1 : Misc lexing and string handling improvements

現在のC++の字句規則をクリーンアップし、ユニコードで記述されたソースコードの振舞を明確にする提案。

前回の記事を参照 onihusube.hatenablog.com

このリビジョンでの変更は、FAQを追加した事と、生文字列リテラル末尾の空白やBOM、ill-formedなcode units sequence、文字列リテラルの連結についての追記が行われたことです。

P2183R0 : Executors Review: Properties

C++23への導入を目指してレビューが進んでいるExecutorライブラリ(P0443R13)におけるプロパティ指定(P1393R0)の利用に関する報告書。

P0688R0によってExecutorに対して導入された組み合わせ爆発を抑えるプロパティ指定の方法は、Executorにとどまらず将来のライブラリに対しても有用であると判断され、よりジェネリックなプロパティ指定のためのAPIとしてExecutorから分離されました(P1393R0)。
この提案は分離されたことによって若干変化したAPIをExecutorがどのように利用するのかをLEWGにおけるレビューのためにまとめたもので、boost.asioにおける先行実装を用いたサンプルコードがいくつか掲載されています。また、その際に発見された小さないくつかの問題についても同時に報告されています。

P2186R0 : Removing Garbage Collection Support

ガベージコレクタサポートのために追加された言語とライブラリ機能を削除する提案。

C++11にて、C++処理系としてGC実装を許可するための最小の文言とライブラリサポートが導入されました。2020年現在、BoehmGCというC/C++向けのGCライブラリ(not処理系)があるほか、主要ブラウザのJavascriptエンジンなどGCをサポートする言語がC++で実装され、GCにより管理されているオブジェクトとC++オブジェクトが密接に連携できるなど、C++から利用可能なGCの実装は一定の成功を収めています。ただしC++の処理系としてのGC実装はなく、これらのC++から利用可能なGC実装はC++11で導入されたGCのための仕組みを利用していません。

また、そのような標準で用意されているGC実装のための仕組みにはいくつも問題があり、実際のGC実装のためにはほとんど役に立ちません。実際、既存のC++から利用可能なGC実装が依存しているのは、現在の規定とは異なる要因に対してです。そして、GCのためのライブラリ機能を使用しているコードはGCCLLVM(つまり実装者)以外には見つからなかったそうです。

C++実装としてGCサポートを認めないわけではなく、少なくとも現在の仕様とライブラリ機能は役に立たないので削除しようという提案です。これらの文言と機能は実装される事も使用されることも無く、非推奨にすることには意味が無いためすぐに削除することを提案しています。

P2187R3 : std::swap_if, std::predictable

より効率的な条件付きswapを行うためのstd::swap_ifと、その使用を制御するstd::predictableの提案。

前回の記事を参照 onihusube.hatenablog.com

R1とR2は公開されておらず、前回のR0からいきなりR3に飛んでいます。

変更点はまず、入力となるTのサイズによってswap_ifを使うかどうかを静的に分岐するようにしたことです。そのための、is_trivially_swappable_vcheaply_swappableコンセプトを追加して以下の様な実装をします。

template <typename T>
constexpr bool is_trivially_swappable_v = std::is_trivially_copyable_v<T>;

template <typename T>
concept cheaply_swappable = (std::is_trivially_swappable_v<T> && sizeof(T) <= N); // ここのNは実装定義

template <typename T>
  requires (cheaply_swappable<T> || std::swappable<T>)
constexpr bool swap_if(bool c, T& x, T& y) noexcept(cheaply_swappable<T> || std::is_nothrow_invocable_v<decltype(std::swap<T,T>), T&, T&>) {
  if constexpr (cheaply_swappable<T>) {
    struct alignas(T) { char b[sizeof(T)]; } tmp[2];

    std::memcpy(tmp[0].b, &x, sizeof(x));
    std::memcpy(tmp[1].b, &y, sizeof(y));
    std::memcpy(&y, tmp[1-c].b, sizeof(x));
    std::memcpy(&x, tmp[c].b, sizeof(x));

    return c;
  }

  // Tのコピーコストが大きいとき、普通に条件分岐してswapする
  if (c) std::swap(x, y);
  return c;
} 

この様にすることで、Tのコピーコストが高くつく場合にも自動的に最適な実装に切り替えることができます。
また、is_trivially_swappable_vはカスタマイゼーションポイントになっていて、trivially copyableとみなされないが安全にmemcpyできるような型をswap_ifにアダプトできます。

そして最後に、イテレータに対して直接swap_ifするiter_swap_ifが追加されています。

このswap_ifが使用されるのはstd::sortなどのアルゴリズム中であり、そこではイテレータを介して実際の値にアクセスします。swap_ifの引数はイテレータを間接参照して渡す事になり、わずかではありますがボイラープレートが発生します。iter_swap_ifイテレータを直接受け取り内部で間接参照しつつswap_ifに渡す簡易なラッパーです。

template <typename Flag, typename I>
bool iter_swap_if(Flag c, I p, I q) {
  return swap_if(c, *p, *q);
}

P2188R1 : Zap the Zap: Pointers are sometimes just bags of bits

現在の標準の無効な(指すオブジェクトの生存期間が終了した後の)ポインタについての矛盾した規定を正す提案。

前回の記事を参照 onihusube.hatenablog.com

EWGでのレビューを受けて、不完全だった提案の文言をいったん削除し、代わりに著者の方が重視している側面について追記されています。

1. 等しいポインタは交換可能であるべき

ポインタはValue Semanticsを持っており、コピーすると元の値と同じになる。従って、2つの値が等しければ、それらは交換して扱うことができるはず。

// pとqは初期化されているとする
void f(int* p, int* q){
  *q = 99;
  bool same = false;

  if (p == q) {
    // ポインタの値が等しいならば、pとqは同じ
    *p = 42;
    same = true;
    assert(*q == 42);
  }

  assert(same ? (*q == 42) : (*q == 99));
}

このコードが動作しないとすれば、ポインタの等値性が壊れていることを意味する。

2. ポインタの等値性は一貫しているべき

同じ2つのポインタの等値性は比較される場所によらないはず、これはValue Semanticsの基本的な側面にすぎない。

bool compare(int* const p, int* const q){
  return p == q;
}

// pとqは初期化されているとする
void f(int* const p, int* const q){
  bool const same = (p == q);
  g(p, q);
  assert(same == (p == q));       // ここがtrueならば
  assert(same == compare(p, q));  // ここもtrue
}

このコードはg()がなんであるかや、翻訳単位やインライン化の有無などに関わらず動作するはず。

3. コンパイラは特定の状況ではエイリアスが無いと仮定しなければならない

他の場所にその参照を渡されることがない関数内のローカル変数は、他の場所から変更されることは無いと仮定できるようにしなければならない。

void f(){
  int x = 42;
  g();
  assert(x == 42);  // 常にパスする
}

アサートが起動することはあってはならない。xへのポインタは存在しないので、g()の中で使用されるいかなるポインタと等しくなることはありえない。

4. 無効なポインタのデリファレンスは未定義動作

そのことを変更するつもりはない(ただし、次の場合は除く)。

5. ポインタの有効性は比較によって伝染する

ポインタpを、有効である事が分かっているポインタqと比較した結果等しければ、pも有効でなければならない(上記1に従う)。

void f(){
  int * const p = new int(42);
  delete p;
  int * const q = new int(99);

  if (p == q) {
    assert(*p == 99); // p == qならば指す先は同じ
  }
}

実装がこの場合のp == qが成り立たないようにすることを推奨するが、もしp == qであるならば、pqは同じオブジェクトを指していなければならない。

逆に言うと、現在のC++はこれらの事を必ずしも保証できていないという事です。

P2191R0 : Modules: ADL & GMFs do not play together well (anymore)

グローバルモジュールフラグメントのあらゆる宣言が、モジュール外からのテンプレートのインスタンス化時に参照されないようにする提案。

グローバルモジュールフラグメントとは、モジュール内部に用意される主としてヘッダをインクルードするための領域です。その領域にあるものがそのモジュール内から参照されなかった場合、モジュールのコンパイルの段階で破棄されます。モジュールのビルドはテンプレートを残す必要があるため完全に完了するわけではありませんが、破棄された宣言はその後のテンプレートインスタンス化時には一切参照できなくなります。これはモジュールのインターフェースの肥大化を抑えつつ、モジュール内で#includeを行うための仕組みです。

一方、従来の#includeディレクティブはヘッダユニットのインポートとして書くことができ、コンパイラによって自動的に置換される可能性があります。グローバルモジュールフラグメント内でもヘッダユニットのインポートが発生する可能性があり、その場合はヘッダファイルが一つのモジュールとしてインターフェース依存関係を構築するため、そのimportが破棄されることはありません。すると、同じヘッダを(グローバルモジュールフラグメント内で)#includeしたときと異なり、あらゆる宣言は破棄されません。

そして、モジュール内で定義されエクスポートされているテンプレートがモジュール外でインスタンス化されたとき、そのインスタンス化経路(テンプレートのインスタンス化に伴ってインスタンス化される関連テンプレートを繋いでいったグラフのようなもの)上からグローバルモジュールフラグメント内にあって外部リンケージを持ち破棄されていない宣言はADLによって参照することができます。
この時、コンパイラによってグローバルモジュールフラグメント内の#includeimportに置換されていた場合、ADLによって参照可能な宣言が異なることになります。どのようなヘッダをヘッダユニットとして扱い、いつ#includeimportに置換するのかは実装定義とされているため、このことはコンパイラによって異なる可能性があります。また、ユーザーがそれを制御する手段はありません。

他の問題として、このようなグローバルモジュールフラグメントの仕様によって、実装はモジュールの第一段階のコンパイル後にもグローバルモジュールフラグメントの中身と依存関係グラフを保持し続けなければならず、モジュールパーティションによって複数のグローバルモジュールフラグメントが間接的に外部にエクスポートされる時、それをどのように統合すべきか?という問題が生じます。また、それによってはモジュール内部のパーティションへの分割が外部から観測可能となってしまうことになり得ます。

/// header.hpp

#include <iterator> // このインクルードは実装によってimportに変換されるかもしれない
/// mymodule.cpp
module;
// グローバルモジュールフラグメント

#include "header.hpp" // このインクルードは実装によってimportに変換されるかもしれない

export module mymodule;

template<typename C>
export void f(C&& container) {
  auto it = begin(container);  // 依存名なのでここではstd::beginは参照されているとみなされない
}

// 説明のために実装単位などを省略
/// main.cpp
import mymodule;

#include <vector>

int main() {
  std::vector<int> v{};

  f(v);
  // mymoduleのグローバルモジュールフラグメント内の#includeがそのままならばこれはエラー
  // mymoduleのグローバルモジュールフラグメント内で#includeがimportになっているとコンパイルは通る
}

この提案では、これらの問題を解決するためにグローバルモジュールフラグメント内のあらゆる宣言は、モジュールに属するテンプレートのインスタンス化時のADLにおいて可視とならない、と規定を変更することを提案しています。
これによって、グローバルモジュールフラグメントは完全にモジュール内部で完結するものになり、モジュール外部からは気にしなくてもよくなります。また、実装はモジュールのコンパイル後にグローバルモジュールフラグメント内のものを残すかどうかを自由に選択できるようになります。

ただし、グローバルモジュールフラグメント内の宣言を参照しているようなモジュールに属するテンプレートの利用のためには、モジュール内でusingしておくか、そのモジュールを使う側で必要な宣言が含まれているヘッダをインクルードしなければならなくなります。

/// mymodule.cpp
module;
// グローバルモジュールフラグメント
// この提案の下では、ここの宣言は全て外部から参照されることは無い

#include <vector>
#include "header.hpp"

export module mymodule;

template<typename C>
export void f(C&& container) {
  using std::begin; // beginを後から使用するためにはこうする (1)

  auto it = begin(container);
}

// 説明のために実装単位などを省略
/// main.cpp
import mymodule;

#include <vector>
#include <iterator> // mymodule内部のstd::beginを有効化するために明示的に追加する (2)

int main() {
  std::vector<int> v{};

  f(v); // (1)か(2)のどちらかをしておかないとコンパイルエラー
}

P2192R0 std::valstat - function return type

関数の戻り値としてエラー報告を行うための包括的な仕組みであるvalstatの提案。

現在のC++にはいくつものエラー報告のための方法がありますが、エラーを報告する側と受け取り側にとって単純かつ柔軟な方法はなく、戻り値の形でエラー報告を行う際の標準的な1つの型もありません。

  • 例外機構
    • 例外を報告する最も簡易な方法ではあるが、受け取る側では何が飛んでくるか分からない
    • オーバーヘッドが大きい
  • errnoあるいはstd::errc
    • エラーしか報告できない
  • std::error_code
    • 動的メモリ確保を伴うなどいくつか問題がある(P0824R1
    • エラーしか報告できない
  • outcome<T>/expected<T, E>
    • これらは非常に複雑な型となり、必要以上の複雑さを導入することになる(例えば、Boost.outcomは7223行)
    • エラーかそうでないかの2つの情報しか表現できない

一方、C++標準としてはエラー報告については次のようなコンセンサスがあるようです。

  • 処理(関数)の結果はエラーかそうでないかの2値ではない
    • 例えば非同期処理のキャンセルなど
  • エラーかそうでないかのバイナリロジックを回避すれば複雑さが低減される
    • 早期リターンが失敗ではない場合のエラーがある
  • (エラー報告の結果の)戻り値を消費することは、必然的に複雑となる
    • 戻り値型は多様なので、そのような消費ロジックは複雑にならざるをえない
    • 統一的な戻り値消費処理を決定し、それに従うようにすることが有益

valstatはこれらのコンセンサスに基づいた、戻り値でエラーハンドリングを行うためのコンセプト(概念)とそれに従うものの総称です。次のように使用することができます。

// valstatは何か特定の戻り値型ではない
auto [ value, status ] = valstat_enabled_function();

// valstatは戻り値として4つの状態を表現する
if (   value &&   status )  { /* info */ }
if (   value && ! status )  { /* ok   */ }
if ( ! value &&   status )  { /* error*/ }
if ( ! value && ! status )  { /* empty*/ } 

valstat(に適合する戻り値型)は処理結果と処理のステータスのペアであり、その組み合わせによってMeta Stateを表現します。そして、その状態は!&&によって判別できます。

Meta Stateは処理結果と処理のステータスの有無によって次の4つの状態の1つを表現します

処理結果 \ 処理ステータス 有り 無し(空)
有り Info OK
無し(空) Error Empty
  • OK
    • 関数は期待される値を返し完了した
  • Error
    • 関数は期待通りに完了しなかった(失敗した)
    • ステータスには関連情報が格納される
  • Info
    • 関数は期待される値を返し完了した
    • ステータスには追加の情報が格納されている
  • Empty
    • 戻り値は空
    • (これがどのように使われるのかは提案文書に書いてない、多分重要なのは上3つ)

これはvalstatのコンセプトの一部であり、列挙値などで表現されるものではなく、valstatの任意の実体がこれを明示的に実装するわけではありません。valstatにアダプトした型は必然的にその状態によってこのMeta Stateを表現することになります。

この提案としてはこのようなコンセプトとそれを表現するための標準実装であるstd::valstatを標準ライブラリに導入することを提案しています。それは次のように実装可能です。

#include <optional>

namespace std {
  template<tpyename T, typename S>
  struct [[nodiscard]] valstat {
    using value_type = T;
    using status_type = S;

    optional<T> value;
    optional<S> status;
  };
}

ただし、valstatは特定の型に縛られるものではなく、std::valstatと同じように使用可能なvalstatコンセプトに適合する任意の型によって実装可能です。それは純粋なC言語においてもポインタを利用することで実装でき、そのようなライブラリをC++から利用するときにはvalstatコンセプトに従って統一的にエラー報告を処理できるようになります。

P2193R0 : How to structure a teaching topic

R1が同時に出ているので↓でまとめて。

P2193R1 : How to structure a teaching topic

C++を教えるにあたってのカリキュラムを書く際のガイドラインとなる文書。

ある1つのトピック(C++の言語・ライブラリ機能)についてそこに何をどのように書くか等を説明し、カリキュラム中のトピック説明においての共通の文書構造を提供するものです。

P2196R0 : A lifetime-extending forwarder

std::forwardのように与えられた引数を完全転送し、CV修飾を記憶しつつその生存期間を延長し、必要になった場所で完全転送しつつ取り出すことのできるクラステンプレートであるstd::forwarderの提案。

おおよそ次のように動作するものです(提案文書からの引用。一部正しくないコードが含まれていますが、イメージを掴む分には問題ないのでそのままにしています)。

// オブジェクトの値カテゴリによって呼ばれるメンバ関数が異なる
struct object {
  void test() const & {std::clog << "void test() const &\n";}
  void test() const && {std::clog << "void test() const &&\n";}
};

// Forwarding referenceで入力を受ける
template <class T>
auto function(T&& t) {
  // 引数tの型を記憶し生存期間を延長する
  auto fwd = forwarder<T>(t);
  
  /∗ ... ∗/

  return fwd;
}

int main(int argc, char∗ argv[]) {
  object x;
  auto fwd0 = function(x);
  auto fwd1 = function(object{});
  
  /∗ ... ∗/

  // 構築時の値カテゴリとCV修飾を復元して取り出す
  // (提案文書にはこのように書かれているが実際この呼び出しは出来ない)
  fwd0().test(); // void test() const &
  fwd1().test(); // void test() const &&
  return 0;
}

オブジェクトの値カテゴリとCV修飾を保存したまま持ち運び、必要な時にそれを復元しつつ取り出すことができます。ただし、提案ではメンバ関数は型変換演算子しか定義されておらず、operator.オーバーロード不可能なので実際には上記の.によるメンバ呼び出しみたいなことはできません。

筆者の方は別の提案であるP1772R1(いわゆるoverloadedの提案)の作業中にこのような完全転送と生存期間延長を行うラッパー型の必要性に遭遇し、それがより広範に適用可能なものであったため独立した提案として提出したとのことです。

P2198R0 Freestanding Feature-Test Macros and Implementation-Defined Extensions

フリースタンディング処理系でも使用可能なライブラリ機能について、機能テストマクロを追加する提案。

P1641R3P1642R3の以前のリビジョンで同時に提案されていたものを分離して一つにまとめた提案です。

P2199R0 : Concepts to differentiate types

2つの型が同じではない、2つの型は類似の型(CV修飾を無視して同じ型)である、あるいは2つの型は類似していない、という事を表現する3つの新しいコンセプトを追加する提案。

それぞれ、std::different_from, std::similar_to, std::distinct_fromという名前が当てられています。

2つの型が異なる、CV修飾の違いを除いて異なるとき、のようなテンプレートパラメータに対する制約は標準ライブラリの中でも頻出しています(例えばstd::optionalstd::variantの変換コンストラクタや代入演算子など)。この様なテンプレートパラメータに対する制約はよくある物なのでユーザーコードでも当然頻出し、ユーザーは各々同じコンセプトを最発明することになります。標準でこれらを定義しておくことでそのような手間を省くことができ、また標準ライブラリに対する制約もこれらのコンセプトを直接用いて簡略化することができます。

提案ではそれぞれ次のように定義されます。

namespace std {
  template<typename T, typename U>
  concept different_from = not same_as<T, U>;

  template<typename T, typename U>
  concept similar_to = same_as<remove_cvref_t<T>, remove_cvref_t<U>>;

  template<typename T, typename U>
  concept distinct_from = not similar_to<T, U>;
}

この定義だけを見てもよく使いそうなのは分かるでしょう。

この提案では同時に、different_fromdistinct_fromによって置換可能な標準ライブラリ内の制約条件を書き換えることも行っています。
また、differentdistinctの使い分けが曖昧なので、LWG/LEWGの判断次第では名前が入れ替わる可能性があるとのことです。

P2201R0 : Mixed string literal concatenation

異なるエンコードプレフィックスを持つ文字列リテラルの連結を禁止する提案。

文字列リテラルが2つ並んでいるとき、それらはコンパイラによって連結され1つの文字列リテラルとして扱われます。

int main() {
  auto str = "abc" "def" "ghi"; // "abcdefghi"として扱われる
}

このとき、そのエンコード指定(u8 L u U)が異なっている場合の動作は実装定義で条件付きでサポートされるとされており、実際には主要なC++コンパイラはエラーとします。ただ、UTF-8文字列リテラルのその他のリテラルとの連結は明示的に禁止されています。

int main() {
  auto str1 = L"abc" "def" U"ghi";   // 実装定義
  auto str2 = L"abc" u8"def" U"ghi"; // u8がある場合はill-formed
}

ただ、SDCC(Small Device C Compiler)というCコンパイラはこれをサポートしており、最初のエンコード指定で連結するようです。

このような異なるエンコード指定の文字列リテラル連結のユースケースはないので、明示的にill-formedであると規定しようという提案です。

P2202R0 : Senders/Receivers group Executors review report

Executor提案(P0443R13)のsenderreciever周りのレビューの結果見つかった問題についての報告書。

Executor提案はレビューの効率化のために構成要素に分割したうえで、それぞれ別々の人々によってレビューされているようです。

P2203R0 : LEWG Executors Customization Point Report

Executor提案(P0443R13)のカスタマイゼーションポイント周りのレビューのまとめと、生じた疑問点についての報告書。

この文書は筆者(レビュワー)の方々がP0443のカスタマイゼーションポイント(オブジェクト)について読み込み、どんなカスタマイゼーションポイントがあるかやそれらの枠割についてまとめた部分が主となっています。Executorライブラリのカスタマイゼーションポイントについて比較的簡単にまとめられているので興味のある人は見てみるといいかもしれません。

onihusube.hatenablog.com

この記事のMarkdownソース

Github Actions上のDockerコンテナ上のGCCの最新版でテストを走らせたかった

Github Actionsで用意されているubuntu 20.04環境に用意されているソフトウェアを見るとGCCは9.3が用意されています(2020年7月24日現在)。しかしGCC9.3はC++20対応がほぼなされていないので使い物になりません(個人の感想です)。いつかは追加されるでしょうが、GCC10.2(もう出た)、GCC 11.1とか言いだすと永遠に待ち続けることになりそうです・・・

一方、Docker公式のGCCコンテナは既にGCC10.1が利用可能です(2020年7月24日現在)。

つまり、この2つを組み合わせれば最新のGCCを使いながらGithub Actionsできるはず!

※以下ではGithub ActionsとかDocker関係の用語が必ずしも正しく使われていない可能性があります、ご了承ください。

Dcokerコンテナを起動しその上でビルドとテストを走らせるActionを作成する

この資料にはDockerコンテナを使ってCIを走らせるための初歩初歩の事が書いてあります。ちゃんと読むと、Dockerコンテナを起動し何かするためのオリジナルのActionを作成し、それをテストしたいリポジトリのymlファイルから呼び出す形になる事が分かります。

(なお、私はちゃんと読まなかったのでわかりませんでした)

というわけで、まずはGCCのDockerコンテナを起動してビルドとテストを行うActionを作成します。

Dockerファイルの作成

まずは所望の動作を達成するためのDockerファイルを作ります。

From gcc:latest
RUN apt-get update
RUN apt-get install -y python3-pip
RUN pip3 install meson ninja
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

私はビルドシステムに専らmesonを使っているので、mesonもインストールしておきます。上記Dcokerfileの2-4行目はお好きなビルドシステムのインストールに置き換えてください。

ここのRUNで全部やっても良いのですがあまり汎用性が無くなるので、ここでは最低限の環境構築に留めて、メインの処理は別に書いたシェルスクリプト(ここではentrypoint.sh)に記述します。

5行目で用意したシェルスクリプトをDockerインスタンス上にコピーして、6行目で開始時に実行するように指示します。

シェルスクリプトの作成

このページにあるものを参考にして書きます。ただこのページだけ見ても、テストしたいリポジトリのクローンをどうするのかが分かりません。試してみると別にクローンされてはいないので自分でクローンしてくる必要があります。なので、そのために必要な情報を引数として受け取るようにします。

公式のActionにはcheckoutというのがありますが、今回はDocker上で実行するため利用できなさそうです。

#!/bin/sh -l

git clone $2
cd $1
meson build
meson test -C build -v

第一引数にリポジトリ名(=ディレクトリ名)、第二引数にリポジトリのURLを受けるようにしています。5-6行目はmeson特有のものなので、お使いのビルドシステムのビルドコマンドと成果物の実行コマンドに置き換えてください。

ファイル名は先程Dockerfileに書いたentrypoint.shと合わせます。chmod +x entrypoint.shで実行可能にすることを忘れすに、 Windowsの場合は次のページが参考になります。

Actionファイルの作成

次にActionとして呼ばれたときに何をするかをaction.ymlというファイルに記述します。試してないですがファイル名は多分固定だと思われます。

name: 'build & test'

inputs:
  name:
    description: 'Name of target repository'
    required: true
  uri:
    description: 'URI of target repository'
    required: true

runs:
    using: 'docker'
    image: 'Dockerfile'
    args:
    - ${{ inputs.name }}
    - ${{ inputs.uri }}

inputsで先程のシェルスクリプトに渡す引数を定義しています。上から順番に第一引数(name)、第二引数(uri)で内容は先ほどの通り。descriptionは説明で無くても良いです。requiredは引数が省略可能かを表しており、今回は省略してほしくないのでtrueに設定しておきます。

他にも出力を定義できたりします。出来ることは次のページを参照。

runsにActionとして呼ばれたときにやることを書いておきます。usingは何のアプリケーションを使用するのかを指示するもので、ここではDockerを指定します。imageには使いたいDockerイメージ名を指定します。Docker Hubにあるイメージ名を指定しても良いようですが、今回は先ほど用意したDockerfile(のファイル名)を指定します。
そして、argsで引数をどの順で受け取るかを指定しています。先程inputsで定義した引数名をinputs.nameの様に参照しています。シェルスクリプトにはここで書いた順番に引数が渡されることになります。

Actionリポジトリの作成

ここまで用意したDockerfile、やることを記述したシェルスクリプトentrypoint.sh、Actionを記述したaction.ymlの3つをGithub上の公開リポジトリに保存しておき、リリースタグを打っておくことで、他のリポジトリからActionとして参照できるようになります。
ここまではリポジトリのトップに全てをぶちまける事を前提に書いてありますので、今回はそうします。

私の場合は次のようになりました。

Publish this Action to Marketplaceなるメッセージが表示されていたら、きちんとActionとして認識されています。もし自作のActionをMarketplaceに公開したい場合はreadmeを適切に整えておく必要がありそうです。

テストしたいリポジトリのワークフロー中でActionを呼ぶ

こうして用意した自作Docker ActionをテストしたいリポジトリGithub Actions ワークフローから呼び出します。

name: Test by GCC latest  # ワークフローの名前
on: [push, pull_request]  # 何をトリガーにして実行するか

jobs:
  test:
    runs-on: ubuntu-latest  # Dockerを実行することになるベース環境
    name: run test
    steps:
    - name: run test in docker
      uses: onihusube/gcc-meson-docker-action@v6
      with:
        name: 'harmony'
        uri: 'https://github.com/onihusube/harmony.git'

実物がこちらです。今回対象のリポジトリは私が衝動で書いていたC++のライブラリです。

ベースは普通のGithub Actionsのワークフローを書くのと変わらないはず。今回重要なのは先程作成したDocker Actionを適切に呼び出すことです。

steps中のusesで任意のActionを呼び出すことができます。先程作成したActionのリポジトリ参照する形で作ったユーザー名/リポジトリ名@タグというように指定します(上記タグがv6とかなってるのは試行錯誤の名残です・・・)。
その次のwithで順番に引数を指定します。指定出来るのはActionのymlに書いたものだけで、ここに書いた引数がDockerインスタンス上で実行されるシェルスクリプトに渡されることになります。

でこれをプッシュするとDocker上のGCCでビルドされテストが実行されたので、どうやら目的を果たせたようです(参考の結果)。

参考文献

謝辞

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

この記事のMarkdownソース

[C++]Forwarding referenceとコンセプト定義

コンセプトを定義するとき、あるいはrequires節で制約式を書くとき、はたまたrequires式でローカルパラメータを使用するとき、その型パラメータがForwarding referenceから来ているものだと使用する際に少し迷う事があります。

// operator*による間接参照が可能であること
template<typename T>
concept weakly_indirectly_readable = requires(T& t /*👈ローカルパラメータ*/) {
  *t;
};

template<weakly_indirectly_readable T>
void f(T&& t) {
  auto v = *std::forward<T>(t);
}

int main() {
  std::optional<int> opt{10};

  f(opt); // 左辺値を渡す
  f(std::optional<int>{20});  // 右辺値を渡す
}

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

さてこの時、weakly_indirectly_readableは右辺値に対してきちんと制約出来ているでしょうか?値カテゴリの情報まできちんと伝わっていますか??
残念ながらこれでは右辺値に対して適切に制約出来ていません。例えば右辺値の時は呼べないような意地の悪い型を用意してあげると分かります。

struct dereferencable_l {
  int n = 0;

  int& operator*() & {
    return n;
  }

  int&& operator*() && = delete;
};


int main() {
  dereferencable_l d{10};

  f(d); // 左辺値を渡す
  f(dereferencable_l{20});  // 右辺値を渡す、エラー
}

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

エラーにはなりますが、それは右辺値を渡したときにf()の定義内で発生するハードエラーです。コンセプトによる制約はすり抜けています。

ではこの時どのようにすれば左辺値と右辺値両方に対して適切な制約を書けるのでしょう・・・?

Forwarding referenceと参照の折り畳み

Forwarding referenceとは先程のf()の引数にあるT&&のような引数宣言の事です。これはそこに引数として左辺値が来ても右辺値が来ても、テンプレートの実引数推定時にいい感じの型に変化してくれる凄い奴です。

そのルールは単純で、Forwarding referenceに対応する引数の値カテゴリによって次のようになります。

  • 引数として左辺値(lvalue)が渡されたとき
    • テンプレートパラメータTは渡された型をTとしてT&になる
  • 引数として右辺値(xvalue, prvalue)が渡されたとき
    • テンプレートパラメータTは渡された型そのままのTになる
template<typename T>
void f(T&& t);

int n = 0;

f(n);  // intの左辺値を渡す、T = int&
f(1);  // intの右辺値を渡す、T = int

ではこの時、引数宣言のT&&はどうなっていて、引数のtはどうなっているのでしょう?
これは参照の折り畳み(reference collapsing)というルールによって、元の値カテゴリの情報を表現するように変換されます。

  • 引数に左辺値が渡されたとき
    • テンプレートパラメータはT -> T&となり、引数型はT&&& -> T&となる
    • 引数tは左辺値参照
  • 引数に右辺値が渡されたとき
    • テンプレートパラメータはT -> Tとなり、引数型はT&& -> T&&となる
    • 引数tは右辺値参照

これ以降の場所でこのTを使用する場合も同様になります。参照の折り畳みは別にForwarding reference専用のルールではないのでそれ以外の場所でも同様に発動します。
そして、参照の折り畳みによって右辺値参照が生成されるのは、T&&&&を付けた時だけです。それ以外はすべて左辺値参照に折りたたまれます。

using rawt = int;
using lref = int&;
using rref = int&&;

using rawt_r  = rawt&;   // int&
using rawt_rr = rawt&&;  // int&&
using lrefr   = lref&;   // int& & -> int&
using lrefrr  = lref&&;  // int& && -> int&
using rrefr   = rref&;   // int&& & -> int&
using rrefrr  = rref&&;  // int&& && -> int&&

最適解

Forwarding referenceに対して制約を行うコンセプトの定義内、あるいはrequires節の制約式では上記の様に折り畳まれた後の型が渡ってきます。つまりは、それを前提にして書けばいいのです。先程のweakly_indirectly_readableを書き直してみると次のようになります。

// operator*による間接参照が可能であること
template<typename T>
concept weakly_indirectly_readable = requires(T&& t) {
  *std::forward<T>(t);
};

template<weakly_indirectly_readable T>
void f(T&& t) {
  auto v = *std::forward<T>(t);
}

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

f()に左辺値を渡したとき、weakly_indirectly_readableに渡る型は左辺値参照型T&です。weakly_indirectly_readableのローカルパラメータtは参照の折り畳みによってT&& & -> T&となり、左辺値参照になります。std::forwardに渡しているTT&なので、ここではムーブされません。

f()に右辺値を渡したとき、weakly_indirectly_readableに渡る型は単にTです。weakly_indirectly_readableのローカルパラメータtはそのまま&&が付加されてT&&となり、右辺値参照になります。std::forwardに渡しているTTなので、ムーブされることになります。

これはコンセプト定義内ではなく、requires節で直接書くときも同様です。

template<typename T>
  requires requires(T&& t) {
    *std::forward<T>(t);
  }
void f(T&& t) {
  auto v = *std::forward<T>(t);
}

先程の意地の悪い例を渡してみてもコンセプトによるエラーが発生するのが分かります。

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

この様に、Forwarding referenceと参照の折り畳みを考慮することで、値カテゴリを適切に処理しつつ制約を行うことができます。

値カテゴリを指定する制約

先程完璧に仕上げたweakly_indirectly_readableですが、それをあえて右辺値と左辺値パターンで分けて書いてみます。

template<typename T>
concept weakly_indirectly_readable = requires(T& t) {
  *t;             // 左辺値に対する制約
  *std::move(t);  // 右辺値に対する制約
};

これは先ほどのT&&std::forwardを使った制約と同じ意味になります。

あえてこのように書くことで、ローカルパラメータとその型の参照修飾を用いて制約式に値カテゴリの制約を指定している様を見ることができます。すなわち、weakly_indirectly_readableのモデルとなる型はoperator*()が右辺値・左辺値の両方で呼べること!という制約を表現しています。これを略記すると先ほどのT&&std::forwardを使った制約式になるわけです。

例えば制約したい対象の式(ここではoperator*)が左辺値でだけ呼べれば良いのであれば右辺値に対する制約は必要なく、逆に右辺値だけをチェックすればいい場合は左辺値に対する制約は必要ありません(ただし、そのようなコンセプト定義は適切ではないかもしれません)。

さらに、ローカルパラメータをCV修飾することで、値カテゴリに加えてCV修飾を指定した制約を行えます。

template<typename T>
concept weakly_indirectly_readable = requires(const T& t) {
  *t;             // const 左辺値に対する制約
  *std::move(t);  // const 右辺値に対する制約
};

ちなみにこの場合に、非constに対する制約も同時に行いたい場合は以下のようにします。

template<typename T>
concept weakly_indirectly_readable = requires(T& t) {
  *t;             // 左辺値に対する制約
  *std::move(t);  // 右辺値に対する制約
  *const_cast<const T&>(t);             // const 左辺値に対する制約
  *std::move(const_cast<const T&>(t));  // const 右辺値に対する制約
};

constのローカルパラメータを取ってconst_castします。requires式を分けても良い気がしますが、標準ライブラリのコンセプトはこの様に定義されるようです。

これらのように、requires式のローカルパラメータのCV・参照修飾を用いて制約式に対するCV修飾と値カテゴリの制約を表現する事ができます。そして、標準ライブラリのコンセプトは全てそのように書かれています。

結局

脱線しながら長々と語ってきましたが、コンセプトを定義するあるいはrequires節で制約をする際に意識すべきことは一つだけです。

  • コンセプト定義あるいはrequires節において、引数となる型パラメータは常にCV修飾無し、参照修飾無しの完全型が渡ってくると仮定して書く

これに近いことは規格書にも書いてあります。この仮定を置いてそれに従って書けば、結果的に適切なCV修飾と値カテゴリによって制約を行うことができます。これは実際に渡ってくる型が云々と悩むのではなく、そう思って書け!という事です。

標準ライブラリのコンセプトはそのように定義されており、これを意識の端っこに置いておけばそのようなコンセプトを利用しやすくなり、自分で定義する際もすっきり書くことができるようになるかもしれません。

参考文献

この記事のMarkdownソース

[C++]WG21月次提案文書を眺める(2020年6月)

文書の一覧 www.open-std.org

提案文書で採択されたものはありません。全部で28本あります。

P1401R3 : Narrowing contextual conversions to bool

constexpr ifstatic_assertの引数でのみ、整数型からbool型への暗黙の縮小変換を定数式で許可する提案。

現在定数式での整数の暗黙変換では、文脈的なbool変換も含めて縮小変換が禁止されており次のようなコードはコンパイルエラーになります。

enum Flags { Write = 1, Read = 2, Exec = 4 };

template <Flags flags>
int f() {
  if constexpr (flags & Flags::Exec) // 縮小変換が起きるためコンパイルエラー
  // if constexpr (bool(flags & Flags::Exec)) とするとok
    return 0;
  else
    return 1;
}

int main() {
  return f<Flags::Exec>();  // コンパイルエラー
}
template <std::size_t N>
class Array {
  static_assert(N, "no 0-size Arrays"); // 縮小変換が起きるためコンパイルエラー
  // static_assert(N != 0); とするとok

  // ...
};

Array<16> a;  // コンパイルエラー

一方対応する実行時コードは普通にコンパイルでき、期待通りに動作します。

if (flags & Flags::Exec) // ok
  {}

assert(N); // ok

このような一貫しておらず直感的ではない挙動を修正するために、constexpr ifstatic_assertの条件式に限って、文脈的なbool変換時の縮小変換を許可しようというものです。

そもそもこれらの条件式でさえもboolへの縮小変換が禁止されていたのは、noexcept式での縮小変換を禁止した時に巻き込まれてしまったためのようです。関数f()の例外仕様が別の関数g()と同じ(あるいはそれに従う)場合、noexceptを二つ重ねて書きます。しかし、その場合に書き間違えて関数名だけを書いたり、1つにしてしまってg()constexpr関数だったりすると思わぬバグを生みます。

int f() noexcept(noexcept(g()));  // 書きたいこと、noexcept指定指の中にnoexcept式を書く

int f() noexcept(g);    // 関数ポインタからの暗黙変換、でもこう書きたさもある・・・
int f() noexcept(g());  // 定数評価の結果bool値へ変換されると・・・

このような些細な、しかし気づきにくいバグを防ぐために定数式での文脈的なbool変換の際は縮小変換を禁止することにしました。しかし、noexcpet以外のところではこれによって(最初に上げたような)冗長なコードを書くことになってしまっていました。

この欠陥報告(CWG 2039、C++14)を行ったRichard Smithさんによると、本来はnoexcept式にだけ適用するつもりで、static_assertには表現の改善のみで縮小変換禁止を提案してはいなかったそうですが、その意図に反して両方で縮小変換が禁止されてしまいました。結果、おそらくその文言を踏襲する形でconstexpr ifexplicit(bool)にも波及したようです。

P1450R3 : Enriching type modification traits

型の修飾情報などを操作するためのいくつかの新しいメタ関数の提案。

主に次の2種類のものが提案されています。

  • remove_all_pointers
  • 型に付いている情報をコピーするcopy_*メタ関数
using remove_ptr = std::remove_all_pointers_t<int************>;     // int
using copy_ptr1 = std::copy_all_pointers_t<int***, double>;         // double***
using copy_ptr2 = std::copy_all_pointers_t<int***, double*>;        // double****
using copy_r1 = std::copy_reference_t<int&, double>;                // double&
using copy_r2 = std::copy_reference_t<int&, double&>;               // double&&
using copy_r3 = std::copy_reference_t<int&, double&&>;              // double&
using copy_const1 = std::copy_concst_t<const int, double>;          // const double  
using copy_const2 = std::copy_const_t<const volatile int, double>;  // const double  
using copy_cvr = std::copy_cvref_t<const volatile int&&, double>;   // const volatile double&&

この他にもcopy_volatileとかcopy_extentcopy_pointerなどが提案されています。

copy_*系メタ関数の引数順は<From, To>になっており、全てに_t付きのエイリアスが用意されています。また、対象の修飾がコピー先にすでに付いている場合はそれはそのままに追加でコピーする形になり、コピー元に対象の修飾がない場合は何もコピーしません。

筆者の方々の経験からプロクシクラス作成やカスタムオーバーロードセットを構築するツールの実装に有用であった型特性を提案しているそうです。

P1467R4 : Extended floating-point types and standard names

C++コア言語/標準ライブラリに拡張浮動小数点型のサポートを追加する提案。

機械学習(特にディープラーニング)では多くの場合それほど高い精度が求められないため、float(32bit浮動小数点数)よりもより小さい幅の浮動小数点型(時には整数型)を利用することでその時間的/空間的なコストを抑えることが行われており、それを支援する形でハードウェア(GPU/CPU)やソフトウェア(CUDA/LLVM-IR)でのサポートが充実してきています。

現在のC++には3種類の浮動小数点型だけが定義されておりそれ以外のものは何らサポートがあリません。そのため、拡張浮動小数点型は算術・変換演算子オーバーロードして組み込み型に近い挙動をするようなクラス型を定義することでサポートされています。しかし、そのような方法は完全ではなく面倒で、効率化のためにインラインアセンブラコンパイラ固有のサポートが必要とされます。
これらの問題はユーザー定義のライブラリで解決できるものではなく、コア言語でのサポートが必要です。そして、拡張浮動小数点型が求められる場所ではC++が使用される事が多くこれらの問題を解決するに足る価値(ポータビリティや効率性の向上など)があるので、拡張浮動小数点型の言語/ライブラリサポートを追加しようという提案です。

ただし、現在のところ拡張浮動小数点型のスタンダードとなるものは確定しておらず、将来どれが使われていく(あるいは廃れる)のか予測することは困難であるため、何がいくつ定義されるかは実装定義とされます。そのため、bfloat16とかfloat16みたいな具体的な型は提供されませんが、代わりに似た形のエイリアスが実装定義で提供されます。

変更は既存の浮動小数点型の振る舞いを保ったままで追加の浮動小数点型をより安全に利用可能かつ拡張可能である(ハードウェアに依存しない)ようにされています。

  • 拡張浮動小数点型はdoubleに昇格されない
  • 拡張浮動小数点型の関わる暗黙変換では縮小変換を許可しない
    • ただし、定数式では許可される
  • 式のオペランドとなっている2つの浮動小数点型を統一する算術型変換(Usual arithmetic conversions)では、どちらのオペランドももう片方の型に変換できない場合はill-formed
    • float, double等は従来のルール通りにより幅の広い型に自動昇格する
float f32 = 1.0;
std::float16_t f16 = 2.0;
std::bfloat16_t b16 = 3.0;

f32 + f16; // OK、f16はfloatに変換可能、結果の型はfloat
f32 + b16; // OK、b16はfloatに変換可能、結果の型はfloat
f16 + b16; // NG、2つの型の間に互換性がなく、どちらの型ももう片方の型に値を変更することなく変換できない

std::float16_t x{2.1};  // OK、2.1は2進浮動小数で表現可能ではないため縮小変換が発生している、定数式なのでok

// <charconv>が使用可能
char out[50]{};
if (auto [ptr, ec] = std::to_chars(out, std::end(out), f16); ec == std::errc{}) {
  std::cout << std::string_view(out, ptr - out) << std::endl;

  std::float16_t outv;
  if (auto [_, ec2] = std::from_chars(out, ptr, outv); ec2 == std::errc{}) {
    std::cout << outv << std::endl;
  }
}

// ユーザー定義リテラルが用意される
using namespace std::float_literals;

// complexも規定される
std::complex<std::bfloat16_t> z = {1.0bf16, 2.0bf16};

これら型エイリアスが定義されるヘッダには<fixed_float><stdfloat>という名前を提案しているようですが、筆者の方々はあまり気に入っていないようでより良い名前やふさわしい既存のヘッダを考慮中のようです。いい名前が思いついたらコントリビュートチャンスです。

P1468R4 : Fixed-layout floating-point type aliases

この提案文書は拡張浮動小数点型の既知のレイアウトとその名前についての提案でしたが、今回P1467R4(1つ前のの拡張浮動小数点型に対する提案)にマージされたため、内容は空です。それが行われたことを記しておくために存在しているようです。

P1642R3 : Freestanding Library: Easy [utilities], [ranges], and [iterators]

[utility]<ranges><iterator>から一部のものをフリースタンディングライブラリに追加する提案。

[utility]は範囲が広いですが、ほとんどpair, tupleを対象にしています。また、動的メモリ確保を必要としたり例外を送出しうるもの、iostreamのようにOSのサポートを必要とするものは当然含まれていません。<utility>, <tuple>, <ratio>ヘッダの全てと、特筆する所ではstd::unique_ptrstd::functionのフリースタンディング化が提案されています。
<ranges><iterator>からは(i|o)stream_iterator(i|o)streambuf_iteratoristream_view等に関わるもの以外の全てを追加することが提案されています。

また、これらのものには機能テストマクロが用意されています。

筆者の方は、<optional><variant>などを今後別の提案で詳細に検討していくつもりのようです。

ライブラリ機能のフリースタンディング化に慎重な検討が必要になるのは、フリースタンディング処理系とホスト処理系とである関数呼び出し時のオーバーロードセットが変化することで暗黙のうちに動作が変わってしまうことを防ぐためです。変わったとしてもちゃんとエラーになるのかや選択されるオーバーロードが変化しないかなどを慎重に検討せねばならないようです。とても大変そうです・・・

P1944R1 : Add Constexpr Modifiers to Functions in cstring and cwchar Headers

<cstring><cwchar>の関数にconstexprを追加する提案。

これらのヘッダに定義されている文字列操作関数をconstexprにすることを意図しています。ただし、ロケールやグローバルオブジェクトの状態に依存したり、スレッドローカルな作業域を持つような関数は除外しています。また、std::memcpystd::memmoveなどのメモリ操作系の関数もconstexprにすることが提案されています。これらは引数にvoidポインタを取るためそのままだと定数式で使えないのですが、コンパイラマジックにより定数式で使用可能にしてもらうようです。

<cstring><cwchar>C++として独自実装している処理系とCのコードを流用している処理系が存在しているようですが、前者はそのままconstexprを付加し、後者はコンパイラの特別扱いによってABIを破損することなくconstexpr対応できるだろうということです。

P1949R4 : C++ Identifier Syntax using Unicode Standard Annex 31

識別子(identifier)の構文において、不可視のゼロ幅文字や制御文字の使用を禁止する提案。

前回の記事を参照 onihusube.hatenablog.com

前回(R3)との変更点は文書にSummaryが追加されたことと、提案する字句トークン(プリプロセッシングトークン)のEBNF定義の修正だけのようです。

この提案はすでにCWGでの文言レビューを待つだけになっており、C++23に導入される可能性は高いです。その場合、C++においては識別子(クラス・変数・関数等の名前)に絵文字を使用できなくなります(欠陥報告になる可能性があるので以前のバージョンも含めて)。

P1990R1 : Add operator[] to std::initializer_list

std::initializer_listに添字演算子[]data()関数を追加する提案。

std::initializer_listは軽量な生配列のプロキシクラスではありますが、要素アクセスにはイテレータを使用するしかないなど少し使いづらい所があります。その解消のために、添字アクセスと先頭領域へのポインタ取得をサポートしようという提案です。

struct Vector3 {
  int x, y, z;

  // C++20現在
  Vector3(std::initializer_list<int> il) {
    x = *(il.begin() + 0);
    y = *(il.begin() + 1);
    z = *(il.begin() + 2);
  }

  // 提案
  Vector3(std::initializer_list<int> il) {
    x = il[0];
    y = il[1];
    z = il[2];
  }
};


void f(std::initializer_list<int> il) {
  // C++20 現在
  const int* head = il.begin();

  // 提案
  const int* head = il.data();
}

P2025R1 : Guaranteed copy elision for return variables

NRVO(Named Return Value Optimization)によるコピー省略を必須にする提案。

C++17からはRVO(Return Value Optimization)によるコピー省略が必須となり、関数内で戻り値型をreturnステートメントで構築する場合にコピーやムーブを省略し、呼び出し元の戻り値を受けている変数に直接構築するようになっています。一方、関数内で変数を構築してから何かしてその変数をreturnする場合のコピー省略(NRVO)は必須ではなく、コンパイラの裁量で行われます(とはいえ、主要なコンパイラは大体省略します)。

// コピーが重いクラス
struct Heavy {
  int array[100]{};

  Heavy() {
    std::cout << "default construct\n"
  }

  Heavy(const Heavy& other) {
    std::copy_n(other.array, 100, array);
    std::cout << "copy construct\n"
  }

  Heavy(Heavy&& other) {
    std::copy_n(other.array, 100, array);
    std::cout << "move construct\n"
  }

};

Heavy rvo() {
  return Heavy{}; // RVOが必ず行われる
}

Heavy nrvo() {
  Heavy tmp{};

  for (int i = 0; i < 100; ++i) {
    tmp.array[i] = i;
  }

  return tmp; // NRVOはオプション
}

int main() {
  Heavy h1 = rvo();   // 結果はh1に直接格納され、デフォルトコンストラクタが一度だけ呼ばれる
                      // コピーやムーブコンストラクタは呼ばれない。

  Heavy h2 = nrvo();  // NRVOが行われない場合、デフォルト・ムーブコンストラクタが一回づつ呼ばれる
                      // 正確にはデフォルト構築→コピー→ムーブとなるが、returnでのコピー後のprvalueはRVOの対象なので最後のムーブコンストラクタは省略される
                      // NRVOが行われた場合、デフォルトコンストラクタが一度だけ呼ばれる
}

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

(言語バージョンをC++14にすると完全にコピー省略のない世界を見ることができます。また、-fno-elide-constructorsを外すとコピー省略された結果を見ることができます。)

このような場合のnrvo()の呼び出しのようにNRVOが可能なケースではNRVOを必須にしよう、という提案です。NRVo可能なケースというのは簡単に言うと全てのreturn文が同じオブジェクトを返すことが分かる場合の事で、提案文書にはこの提案によっていつNRVOが保証されるかのいくつかのサンプルが掲載されています。

この提案はCWGでの文言調整フェーズに進んでおり、C++23に入る可能性が高そうです。

P2034R2 : Partially Mutable Lambda Captures

ラムダ式の全体をmutableとするのではなく、一部のキャプチャだけをmutable指定できるようにする提案。

前回の記事を参照 onihusube.hatenablog.com

前回との差分は一部のサンプルコードが変更されたことと初期化キャプチャ時パック展開でのmutable指定が提案に含まれた事、EWGIの議論で示された懸念事項が追記された事です。

C++20よりラムダ式の初期化キャプチャ時にパラメータパックをキャプチャ出来る様になっているので、そこでもmutableが出来るようにしようとしています。これを用いるとパラメータパックだけをラムダ式中で再ムーブする時に、全部をmutableにしなくても良くなります。

template <class... Args>
auto delay_invoke_foo(Args... args, State s) {
  return [s, mutable ...args = std::move(args)] {
    return foo(s, std::move(args)...);
  };
}

追加された懸念事項は、明示的なconstキャプチャをする場合に、ラムダ式のムーブで暗黙にコピーが行われるようになる事です。クラスのメンバにconstメンバがあってもムーブコンストラクタ自体は使用可能ですが、constメンバはコピーされます。コピーコンストラクタは多くの場合例外を投げうるので、これによって思わぬところで例外が発生するようになってしまう可能性があります。

auto l1 = [const str = std::string{"not movable"}](){return str;};
auto l2 = std::move(l1);  // キャプチャしたメンバstrはコピー構築される、場合によっては例外を投げうる

この提案によってもたらされるラムダ式の対称性と一貫性の向上による効用と、このような足を撃ち抜く可能性を導入することによる弊害のどちらがより大きいのかは解決されておらず、より議論が必要となりそうです。

P2037R1 : String's gratuitous assignment

std::stringの単一の文字代入を非推奨とする提案。

std::stringにはchar1文字を受け取る代入演算子が定義されています。

// char1文字を代入する
constexpr basic_string& operator=(charT c);

しかし、この代入演算子は特に制約されておらず、charに暗黙変換可能な型に代入を許します。その代表的なものはintdoubleの数値型です。

std::string s{};

s = `A`;  // s == A
s = 66;   // s == B
s = 67.0; // s == C

すなわち、intdoubleへの暗黙変換を実装している任意のユーザー定義型も代入可能です。

そもそもstd::stringchar1文字を代入できる必要があることが疑わしい上にコンストラクタのインターフェースとも一貫しておらず、この様な変換が起きることはほとんどの場合意図したものではなくバグの原因であるので非推奨にしようという主張です。
ただし、削除することまでは提案されていません。

ほかの選択肢としては

  • 削除する
    • その場合、nullptrの代入が可能になってしまうのでケアする必要がある
  • コンセプトによる制約を行う cpp template<same_as<charT> T> constexpr basic_string& operator=(T c) ;
  • intからの変換だけを許可するようにして、他の変換は不適格とする。
    • 筆者の方が見てきたこれらの変換が問題となっていたケースはほぼ全てintからの変換だったので解決策としては弱いだろう、とのこと

R0の際に行われたLEWGでの投票では、非推奨とすることに合意が取れていて、今回はそれを受けて標準のための文言を追加したようです。

P2093R0 : Formatted output

std::formatによるフォーマットを使用しながら出力できる新I/Oライブラリstd::printの提案。

C++20で導入されたstd::formatはフォーマットを指定しつつ文字列を構成できるものですが、その結果はstd::stringで得られ出力機能は備えていません。そのままiostreamを使えば出力できますが、一時オブジェクトの確保が必要になる上、iostreamによってフォーマット済み文字列を再びフォーマットすることになり非効率です。

// 一時オブジェクトが作成され、内部で再フォーマットされ、バッファリングされうる
std::cout << std::format("Hello, {}!", name);

// nameはnull終端されていなければならない、型安全ではない
std::printf("Hello, %s!", name);

// 一時オブジェクトが作成される、c_str()と個別I/O関数の呼び出しが必要になる
auto msg = std::format("Hello, {}!", name);
std::fputs(msg.c_str(), stdout);

この提案では、このような場合に一時オブジェクトを作成せず、フォーマットとI/Oで別の関数を呼び出す必要もなく、より効率的な出力を行うstd::print関数を提案しています。

// 一時オブジェクトは作成されず、フォーマットは一度だけ、直ちに出力する
std::print("Hello, {}!", name);

これはすなわちiostreamに変わる新しい出力ライブラリとなります。このライブラリは次のことを目標にしています。

これはすでに{fmt}にて実装されていて、その実装により得られたベンチマーク結果が掲載されています。既存のI/Oと比較すると速度とバイナリフットプリントの両面で良好な結果を得られているようです(ただ、純粋なフットプリントだけはprintfに及ばないようです)。

P2138R2 : Rules of Design<=>Wording engagement

CWGとEWGの間で使用されているwording reviewに関するルールの修正と、それをLWGとLEWGの間でも使用するようにする提案。

前回の記事を参照 onihusube.hatenablog.com

このリビジョンでの変更は、CWG/LWGにおける標準のための文言レビューと本会議での投票の間に、最終確認のためのTentatively Readyという作業フェーズを追加することを提案している点です。

P2139R1 : Reviewing Deprecated Facilities of C++20 for C++23

C++20までに非推奨とされた機能をレビューし、標準から完全に削除するかあるいは非推奨を取り消すかを検討する提案文書。

この提案は規格書中のAnnex.Dというセクションに記載されている機能だけを対象としていて、そこにあるもの以外を削除するわけでもなく、そこに新しく追加する機能について検討するものでもありません。

まだ検討中で、削除が決まった物は無いようです。

P2141R0 : Aggregates are named tuples

集成体(Aggregate)を名前付きのstd::tupleであるとみなし、標準ライブラリにおけるstd::tupleのサポートを集成体に拡張する提案。

std::tupleは任意個数の型をひとまとめにして扱える大変便利なものではありますが、コア言語のサポートが無く全てをライブラリ機能によって実現しているため使いづらい事が多くあります。一方、集成体はC言語から引き継がれたいくつかの条件を満たした構造体で、std::tupleを利用するシーンでは集成体を利用した方が便利だったりする事が多々あります。

// 集成体
struct auth_info_aggreagte {
  std::int64_t id;
  std::int64_t session_id;
  std::int64_t source_id;
  std::time_t valid_till;
};

// std::tuple
using auth_info_tuple = std::tuple<
  std::int64_t,
  std::int64_t,
  std::int64_t,
  std::time_t
>;

template <class T>
constexpr bool validate() {
    static_assert(std::is_trivially_move_constructible_v<T>);
    static_assert(std::is_trivially_copy_constructible_v<T>);
    static_assert(std::is_trivially_move_assignable_v<T>);
    static_assert(std::is_trivially_copy_assignable_v<T>);
    return true;
}

// std::tupleは特殊メンバ関数をほぼ自前定義しているので、trivialではない
constexpr bool tuples_fail = validate<auth_info_tuple>();
constexpr bool aggregates_are_ok = validate<auth_info_aggreagte>();

ただ、集成体には言語サポート(集成体初期化、必然的なtrivial性など)がある代わりに、ほぼライブラリサポートがありません。std::getなどを利用できず、ジェネリックなコードにおいては少し使いづらい事があります。

namespace impl {
  // ストリームから読みだしたデータでtupleを初期化する
  template <class Stream, class Result, std::size_t... I>
  void fill_fileds(Stream& s, Result& res, std::index_sequence<I...>) {
    (s >> ... >> std::get<I>(res)); // 集成体はstd::getを使用できないため、コンパイルエラー
  }
}

template <class T>
T ExecuteSQL(std::string_view statement) {
  std::stringstream stream;

  // ストリームにデータを入力するステップ、省略

  T result;
  impl::fill_fileds(stream, result, std::make_index_sequence<std::tuple_size_v<T>>());
  return result;
}

constexpr std::string_view query = "SELECT id, session_id, source_id, valid_till FROM auth";

const auto tuple_result = ExecuteSQL<auth_info_tuple>(query); // ok
const auto aggreagate_result = ExecuteSQL<auth_info_aggreagte>(query); // error!

std::getstd::tupleに対するライブラリサポートはtuple-likeな型(例えばstd::pairstd::array)ならば利用可能であるので、一般の集成体をtuple-likeな型として利用可能にすることで集成体にライブラリサポートを追加しよう、という提案です。

tuple-likeな型の条件はstd::tuple_sizeによってその長さが、std::tuple_elementによってその要素型が、そしてstd::getによってインデックスに応じた要素を取得できる事です。標準ライブラリにおいて、任意の集成体に対してこれらを用意(あるいは自動生成?)しておくようにする事で集成体にライブラリサポートを追加します。コア言語に変更は必要ありませんが、コンパイラによるサポートは必要そうです。
そして、それによってstd::tupleを用いている既存のコードは一切変更する事なく集成体でも利用できるようになります。

constexpr std::size_t elems = std::tuple_size<auth_info_aggreagte>::value;  // 4
using e2_t = std::tuple_element_t<2, auth_info_aggreagte>;  // std::int64_t

auth_info_aggreagte a = { 1, 2345, 6789, {}};
auto& e3 = std::get<3>(a);  // 6789

P2146R1 : Modern std::byte stream IO for C++

std::byteによるバイナリシーケンスのI/Oのための新ライブラリ、std::ioの提案。

前回の記事を参照 onihusube.hatenablog.com

このリビジョンでの変更は、いくつかの機能の追加とそれを用いた既存機能の修正などです。

P2152R0 : Querying the alignment of an object

alignofを型だけではなくオブジェクトに対しても使用出来るようにする提案。

alingasによってオブジェクトと型に対してアライメントを指定する事ができますが、alignofでアライメントを取得できるのは型だけです。この挙動は一貫しておらず、GCCではオブジェクトに対してもalignof出来るようになっているためC++標準としても正式に許可しようとする提案です。

struct alignas(32) S {};  // ok

int main () {
  alignas(64) S obj{};  // ok

  std::size_t type_alignment = alignof(S);    // ok、32
  std::size_t  obj_alignment = alignof(obj);  // 現在はエラー
}

さらに、既存のアライメントに関しての空白部分やC言語との非互換性を改善する提案も同時に行なっています。

オブジェクトの型のアライメントとオブジェクトのアライメント指定について。

// 32バイト境界にアラインするように指定 in C
typedef struct U U;
struct U {
}__attribute__((aligned (32)));
// C++での等価な宣言
// struct alignas(32) U {};

int main() {
  // C言語の挙動
  _Alignas(16) U u; // ng、型のアライメント要求よりも弱いアライメント指定
  _Alignas(64) U v; // ok
  _Alignof(v);      // GNU拡張、64

  // 等価なはずのコードのC++での挙動
  alignas(16) U u;  // GCCとMSVCはok、Clangはエラー (1)
  alignof(u);       // GCCのみok、16
  alignas(64) U v;  // ok
  alignof(v);       // GNU拡張、64 MSVCはエラー (2)
}
  • (1) : 型よりも弱いアライメントを指定するalignasではオブジェクトを定義できないはずだが、C++にはこの場合の規定がない
    • 型のアライメント要求よりも弱いアライメント指定はエラーと明確に規定する
  • (2) : オブジェクト型に対するalignofは現在許可されていない
    • この提案のメインの部分によって許可する

型のアライメントとメンバ変数のアライメントについて。

typedef struct V V;
typedef struct S S;
typedef struct U U;

struct V {} __attribute__((aligned (64)));
struct S {} __attribute__((aligned (32)));
struct U {
  S s;
  V v;
} __attribute__((aligned (16))); // GGCおよびclangはこのアライメント要求を無視する

/*
C++での等価な宣言
struct alignas(32) S {};
struct alignas(64) V {};
struct alignas(32) U {  // GCCはこのアライメント要求を無視、clangはエラー、MSVCは警告 (1)
  S s;
  U u;
};
*/

int main() {
  // C言語の挙動
  _Alignof(U);  // ok、64

  // 等価なはずのコードのC++での挙動
  alignof(U);   // GCCとMSVCではok、64  (2)
}
  • (1) : 型へのアライメント要求がそのメンバのアライメント要求よりも弱い場合の規定がC++にはない
    • 型へのアライメント要求がそのメンバのアライメント要求よりも弱い場合はエラーと明確に規定する
  • (2) : (1)の場合にアライメントをどうするのかの規定もない(ただし、構造体のアライメントはメンバのアライメントによって制限されるということを示す記述はある)
    • エンティティ(型)のアライメントはそのメンバと同じかそれよりも強くなければならない、と明確に規定する。

こうしてみると、C言語がしっかりとしている一方でC++は深く考えてなかった感があります・・・

P2161R1 : Remove Default Candidate Executor

Networking TSのassociated_executorからデフォルトのExecutorを取り除く提案。

前回の記事を参照 onihusube.hatenablog.com

このリビジョンでは、単純にassociated_executorからsystem_executorを削除してしまうと、Networking TS内にある別の機能である​defer, dispatch​, ​postが深刻な影響を受けてしまうようで、それについての問題点と対策が追記されています。他には、5月に行われたSG4でのレビューについて追記されています。

P2164R1 : views::enumerate

元のシーケンスの各要素にインデックスを紐付けた要素からなる新しいシーケンスを作成するRangeアダプタviews::enumrateの提案。

前回の記事を参照 onihusube.hatenablog.com

このリビジョンでは、インデックスの型の指定が変更されました。以前は1つ前の範囲の差分型(difference type)をインデックスの型に使用していましたが、1つ前の範囲のranges::size()の返す型が取得できる場合はそれを、できない場合は差分型と同じ幅の符号なし整数型を使用する、という風に変更されました。要は常に符号なし整数型を使用するようになったという事でしょう。

P2166R0 : A Proposal to Prohibit std::basic_string and std::basic_string_view construction from nullptr

std::stringstd::string_viewnullptrから構築できないようにする提案。

std::stringstd::string_viewにはconst char*を受けるコンストラクタ用意されており、nullptrを直接渡すとそのコンストラクタが選択され、未定義動作に陥ります。こんなコードは書かないだろうと思われるのですが、筆者の方の調査によればLLVMを含む少なくとも7つのプロジェクトでこのようなコードが発見されたそうです。

実装によっては実行時アサーションによってエラーにするものもあるようですが、std::nullptr_tを受けるコンストラクタをdeleteする事でそのような診断をコンパイル時に行おうとするものです。

P2176R0 : A different take on inexpressible conditions

契約プログラミングにおいて事前/事後条件をC++コードとして記述する際、チェックされない条件を記述する構文についての提案。

現在C++標準ライブラリでは処理の事前条件や事後条件を文章で指定していますが、契約プログラミングによってそれらをC++コードとして記述することが出来るようになります(予定)。その際、実行時であってもそのチェックが難しいか出来ない条件については、注釈という形で書いておくことが出来るようになっています。例えば、文字列のnull終端要求や、イテレータendへの到達可能性などがあります。

bool is_null_terminated(const char *); // 定義しない

// 文字列はnullでなくnull終端されている、という2つの事前条件が契約されている
void use_str(const char* s)
  [[expect: s != nullptr]]                  // この条件はチェックされる
  [[expect axiom: is_null_terminated(s)]];  // この条件は注釈であり、チェックされない

// 文字列はnullであるかnull終端されている、という事前条件が契約されている
void use_opt_str(const char* s)
  [[expect axiom: s == nullptr || is_null_terminated(s)]]; // この条件全体は注釈であり、チェックされない

この様に、axiomと指定された条件は注釈であり実行時にチェックされません。

この提案はこの構文を変更し、事前・事後条件に注釈であることを書くのではなく、関数宣言の方に注釈のためのものであることを表示するようにするものです。

// axiomをこっちに付ける
axiom is_null_terminated(const char *); // 定義なし

void use_str(const char* s)
  [[expect: s != nullptr]]           // この条件はチェックされる
  [[expect: is_null_terminated(s)]]; // この条件は注釈であり、チェックされない

void use_opt_str(const char* s)
  [[expect: s == nullptr || is_null_terminated(s)]]; // nullチェックは行われるが、is_null_terminatedは注釈でありチェックされない

このようにする事で、注釈となる条件とそうでないものを混ぜて書きながら実行可能な条件をチェックしてもらう事が出来るようになリます。OR条件の場合は条件を複数に分割して書く訳にもいかないので特に有用です。

axiomとマークされた関数は契約の構文の中でのみ使用でき、何らかの述語としてbool値を返す関数だがチェックが困難であることを表現し、実行時には単にtrueを返す条件として扱われます。それ以外はほとんど通常の関数と同様に扱えるものです。ただし、そのために記述する順番には制約がかかります。

void use_opt_str(const char* s)
  [[expect: is_null_terminated(s) || s == nullptr]];  // ng、チェック可能な条件を先に書く必要がある

P2178R0 : Misc lexing and string handling improvements

現在のC++の字句規則をクリーンアップし、ユニコードで記述されたソースコードの振舞を明確にする提案。

現在のC++の字句規則はユニコード以前の世界で定義されたもので、文字コードを具体的に指定せずに構成されています。しかし、それによって実装定義の部分が広くなり実装による差異が多く発生していたり、そもそも人間に理解しづらかったりしています。
この提案はそれらを改善しユニコードの振る舞いをより明確にしつつ、実装間の差異をなるべく縮小することを目指したものです。全部で12個の提案が含まれています。

C++コンパイラ書く人とかC++コンパイラになりたい人は読んでみると面白いかもしれません。

P2179R0 : SG16: Unicode meeting summaries 2020-01-08 through 2020-05-27

SG16(Unicode Study Group)のミーティングにおける議論の要旨をまとめた文書。

例えば先ほど出てきていたP1949: C++ Identifier Syntax using Unicode Standard Annex 31などの提案やIssue等についての議論の様子が記載されています。

P2181R0 : Correcting the Design of Bulk Execution

進行中のExecutor(P0443)提案中のbulk_executeのインターフェースを改善する提案。

bulk_executeはバルク実行のためのカスタマイゼーションポイントオブジェクトで、カスタムされたbulk executorを使用することによってハードウェアやOSが提供するバルク実行API(例えば、SIMDやスレッドプール)によって効率的なバルク処理を行う事を可能にするためのものです。

// P0443R13より、サンプル
// ex = executor, f = バルク処理, rng = バルク処理の対象となるデータのシーケンス
template<class Executor, class F, class Range>
void my_for_each(const Executor& ex, F f, Range rng) {
  // バルク実行を要求し、senerを取得する
  // ここで、exにカスタムbulk executorを渡せばバルク実行をカスタマイズできる
  sender auto s = execution::bulk_execute(ex, [=](size_t i) {
    f(rng[i]);
  }, std::ranges::size(rng));

  // 実行を開始し処理の完了を待機
  execution::sync_wait(s);
}

ただ、bulk_executeは提案の初期から存在しており、P0443は途中で遅延実行のためにsender/recieverによるアプローチを採用しましたが、bulk_executeはそれらの変更に追随しておらずインターフェースが一貫していませんでした。この提案はそれを解決するものです。主に以下の3点を変更します。

  • 既存のexecute(CPO)とセマンティクスを統一し、bulk_executeは与えられた作業を即座に実行する実行用インターフェースとする
  • 遅延実行用bulk_executeであるbulk_schedule(CPO)を導入する(executeに対するscheduleと同様)
  • bulk_scheduleによって返されるsenderに対する要件を制約し明確化するmany_receiver_ofコンセプトを導入する
    • このsenderではset_value()が繰り返し呼び出される事を許可する

これらの変更の提案はP0443R13に対してのもので、現在のExecutorライブラリの要件やコンセプト、セマンティクスを大きく変更しません。bulk_executeexecute/schedulesender/recieverとのセマンティクスの一貫性を改善し、Executorライブラリをより使いやすくするものです。

namespace std::execution {
  // bulk_executeの宣言
  void bulk_execute(executor auto ex,
                      invocable<executor_index_t<decltype(ex)> auto f,
                      executor_shape_t<decltype(ex)> shape);
}

// 任意のexecutorと処理対象データ列
auto executor = ...;
std::vector<int> ints = ...:

// intのvectorを変更する作業をexecutorに投入する、ただし実行タイミングは実装定義
bulk_execute(executor,
             [&](size_t idx) { ints[i] += 1; },
             vec.size());

// ここでintsを他の処理に使用する場合、同期等の配慮が必要になるかもしれない
namespace std::execution {
  // bulk_scheduleの宣言
  sender auto bulk_schedule(executor auto ex,
                            executor_shape_t<decltype(ex)> shape,
                            sender auto prologue);
}

// 任意のexecutorと処理対象データ列
auto executor = ...;
std::vector<int> ints = ...:

// intのvectorを変更する作業を構成する、まだ実行はされない
auto increment =
    bulk_schedule(executor, vec.size(), just(ints)) |
    transform([](size_t idx, std::vector<int>& ints) {
        ints[i] += 1;
    });

// ここでのintsの変更は安全

// 作業を開始する、ここでは処理をハンドルしないのでnull_receiverに接続する
execution::submit(increment, null_receiver{});

// ここでは処理はすべて終了している

P2182R0 : Contract Support: Defining the Minimum Viable Feature Set

C++20で全面的に削除されたContractsのうち、議論の余地がなく有用であった部分と削除の原因となった論争を引き起こした部分とに仕分けし、有用であった部分だけを最初のC++ Contractsとして導入する事を目指す提案。

C++20において最終的にContractsが削除されることになってしまったのは、主に以下の機能が議論を巻き起こし合意が取れなくなったためです。

  • 継続モード
  • ビルドレベル
  • 上記も含めた、制御がグローバルであること
  • Literal semantics(in-source controls)
    • 個々の契約に対して個別にチェックするか否かを指定したり、それがグローバルフラグの影響を受けないようにしていた
  • Assumption
    • (上記の事によって)axiomではないのにチェックされていない契約条件の存在が想定される

提案ではC++20Contractsからこれらの部分を除いた広く合意の取れていた有用な部分をMVP(Minimum Viable Product)と呼称し、MVPを最初のContractsとして導入し、そうでない部分(上記の5項目)についてはより時間をかけて議論し、追加の機能として導入していくことを提案しています。

P2184R0 : Thriving in a crowded and changing world: C++ 2006-2020

2020年6月のHistory Of Programming Languages (HOPL) で発表されるはずだったBjarne StroustrupさんによるC++の歴史をまとめた論文の紹介文書。

6月のHOPLカンファレンス延期されましたが論文は公開されているようです。英文PDF168Pの超大作ですが、とても興味深そうな内容です(翻訳お待ちしております)。

HOPLは15年毎に開催されるようで、C++はHOPLで3回紹介されたただ一つの言語になり、BjarneさんはHOPLで3回論文を書いたただ一人の人になったようです。次は2035年ですが、C++はそこでも登場することができるでしょうか・・・?

P2185R0 : Contracts Use Case Categorization

Contractsユースケースを「何のために使用するか」と「どうやって使用するか」2つにカテゴライズし、報告されている既存のユースケースをカテゴライズする文書。

これは提案文書ではなく、SG21(Contracts Studt Group)での議論のための報告書です。

P2187R0 : std::swap_if, std::predictable

新しい標準アルゴリズムであるstd::swap_ifstd::predictableの提案。

std::sortに代表される標準ライブラリ中の多くのアルゴリズムには次のような典型的な条件付きswapが頻出します。

if (*right < pivot) {
  std::swap(*left, *right);
  ++left;
} 

この様なswap-if操作は実装を少し変更するだけで、分岐予測のミスによるパイプラインストールを回避しパフォーマンスを2倍以上改善できるらしく、std::swap_ifはそのためのより効率的なswap-if操作を提供するものです。

次のような実装になるようです。

template <movable T>
bool swap_if(bool c, T& a, T& b) {
  T tmp[2] = { move(a), move(b) };
  b = move(tmp[1-c]), a = move(tmp[c]);
  return c;
}

bool値がfalse == 0true == 1であることを利用して、条件分岐を配列のインデックスに帰着させています。
これを用いると先ほどの典型的な操作は次のように書けます。

left += swap_if(*right < pivot, *left, *right);

ただし、現在のC++コンパイラはこの様なコードに対して必ずしも最適な(cmovを使った)コードにコンパイルすることができず、せいぜい次善のコードを出力する場合が多いようです。ただ、その場合でも通常のswap-ifによるstd::sortよりも高速なので、標準ライブラリとしてstd::swap_ifを規定し効率的な実装が提供されるだけでも典型的なswap-if操作の性能向上が図れます。

また、std::swap_ifを規定することはコンパイラによるのぞき穴最適化の機会を提供することに繋がり、将来的に多くのコンパイラが最善のコードを出力できるようになるかもしれません。

ただし、std::swap_ifの上記の様な実装は多くのケースでは高速ですが、特定のデータに対してはかえって低速になります(例えば、ほとんどソート済みの配列のようなデータ列など)。それが事前に予測できる場合、通常の分岐によるswap-if操作にフォールバックできる必要があります(現在のハードウェアでは、その閾値は90%以上の確度が必要)。

2つ目のstd::predictableはそのための述語ラッパー型です。

template <predicate Predicate, bool is = true>
struct predictable {
  std::remove_reference<Predicate>::type pred; // 名前は自由

  explicit predictable(Predicate&& p) : pred(p) {}

  template <typename... Args>
  constexpr bool operator()(Args&&... args) { 
    return ::std::invoke(p, args...);
  }
};

// predictableを検出する変数テンプレート
template <typename>
constexpr bool is_predictable = false;

template <predicate P, bool is>
constexpr bool is_predictable<predictable<P,is>> = is;

標準ライブラリの述語を引数に取るアルゴリズムでは、これを用いて述語をラップして渡し、アルゴリズム中でそれを検出してstd::swap_ifを使用するかをコントロールします。

auto v = std::vector{ 3, 5, 2, 7, 9 };

std::sort(v.begin(), v.end()); // swap_ifを使用する
std::sort(v.begin(), v.end(),  // swap_ifを使用しない
          std::predictable([](int a, int b) { return a > b; }));

std::predictableは単なる述語ラッパーであるため、従来の述語を取るアルゴリズムは何ら変更することなくこれを受け入れ、使用できます。一方で、std::swap_ifを使用しかつ述語を取るアルゴリズムでは、これを検出することで最適な実装を選択できるようになります。
これによって、標準ライブラリにstd::swap_ifを使用するかしないかを選択するための従来のアルゴリズム名それぞれに対応する新しい名前を導入したり、既存のアルゴリズムの規定を変更したりすることなく、標準アルゴリズムの多くでパフォーマンス向上と最適な実装の選択を同時に達成できるようになります。

P2188R0 : Zap the Zap: Pointers should just be bags of bits

現在の標準の無効な(指すオブジェクトの生存期間が終了した後の)ポインタについての矛盾した規定を正す提案。

[basic.stc] p4には、「Any other use of an invalid pointer value has implementation-defined behavior.(無効なポインタ値の他の使用には実装定義の振舞がある)」とあり、その注釈には「Some implementations might define that copying an invalid pointer value causes a system-generated runtime fault.(一部の実装では、無効なポインタのコピーを行うとシステム生成の実行時エラーが発生する、と定義している場合がある)」とあります(これらの規定のことをpointer zapと呼んでいるようです)。
一方でこの事は、[basic.types] p3にある規定及びポインタ型がtrivially copyableであることと明らかに矛盾しています。

提案はいくつかの例を示すとともに、これら規定を削除して無効なポインタはtrivially copyableであり比較可能と規定するか、ポインタ型はtrivially copyableではないと規定するか、どちらかを選択すべきと主張しています。提案としては前者が提案されています。

#include <assert.h>
#include <string.h>

int main() {
  int* x = new int(42);
  int* y = nullptr;
  
  // ポインタの値(参照先ではない)をx -> yへコピーする
  memcpy(&y, &x, sizeof(x));

 // ポインタyは有効化される
  assert(x == y);
  assert(*y == 42);
}

[basic.types] p3にある例をint*に特殊化したコードで、ポインタ型はtrivially copyableであるためこのコードは有効であり、yxと同じものを指すようになります。

#include <assert.h>
#include <string.h>

int main() {
  int* x = new int(42);
  int* y = nullptr;
  unsigned char buffer[sizeof(x)];

  //ポインタの値(参照先ではない)をbufferを介してx -> yへコピーする
  memcpy(buffer, &x, sizeof(x));
  memcpy(&y, buffer, sizeof(x));

  // ポインタyは有効化される
  assert(x == y);
  assert(*y == 42);
}

先ほどのサンプルを中間bufferを介して行ったもの。[basic.types] p2にあるように、ポインタ型はtrivially copyableであるためこのコードは有効です。

#include <assert.h>
#include <string.h>
#include <stdint.h>

int main() {
  int* x = new int(42);
  int* y = nullptr;

  // ポインタ値を対応する数値表現に変換したうえでx -> yにコピーする
  uintptr_t temp = reinterpret_cast<uintptr_t>(x);
  y = reinterpret_cast<int*>(temp);

  // ポインタyは有効化される
  assert(x == y);
  assert(*y == 42);
}

[expr.reinterpret.cast] p5にあるように、ポインタ値を整数型にキャストしてから再びポインタ値に戻した場合でもポインタとしては有効であり続けます。

他にも込み入った例が全部で10パターン紹介されています。しかしここで見ただけでもわかるように、標準は少なくとも有効なポインタから無効なポインタへのその値のコピーは有効であることを示しており、([basic.stc] p4にあるような)無効なポインターのコピーが実装定義であるという規定を削除すべきという主張のようです。

onihusube.hatenablog.com

この記事のMarkdownソース

[C++]カスタマイゼーションポイントオブジェクト(CPO)概論

C++20以降の必須教養となるであろうカスタマイゼーションポイントオブジェクトですが、その利便性の高さとは裏腹に理解が難しいものでもあります。これはその理解の一助となるべく私の頭の中の理解を書き出したメモ帳です。

C++17までのカスタマイゼーションポイントの問題点

C++17までにカスタマイゼーションポイントとなっていた関数(例えばstd::begin()/std:::end(), std::swap()など)にはアダプトして動作をカスタマイズするためのいくつかの方法が用意されており、より柔軟に自分が定義した型を適合できるようになっています。しかしその一方で、それによって使用するときに少し複雑な手順を必要としていました。例えばstd::begin()で見てみると

// イテレート可能な範囲を受けて何かする関数
template<typename Container>
void my_algo(Container&& rng) {
  using std::begin;

  // 先頭イテレータを得る
  auto first = begin(rng);
}

真にジェネリックに書くためにはこのように「std::begin()usingしてから、begin()名前空間修飾なしで呼び出す」という風に書くことで、std名前空間のもの及び配列にはstd::begin()が、ユーザー定義型に対しては同じ名前空間内にあるbegin()あるいはstd::begin()を通してメンバ関数begin()が呼び出されるようになります。しかし、手順1つ間違えただけでそのbegin()の呼び出しはたちまち汎用性を失います。これはstd:::end(), std::swap()等他のカスタマイゼーションポイントでも同様です。

C++17までのカスタマイゼーションポイントにはこのように、その正しい呼び出し方法が煩雑でそれを理解するにはC++を深めに理解する事が求められるなど、使いづらいという問題があります。

また、このようなカスタイマイゼーションポイントは標準ライブラリをよりジェネリックにするために不可欠な存在ですが、標準ライブラリはそのカスタマイゼーションポイントの名前(関数名)だけに着目して呼び出しを行うため、同名の全く異なる意味を持つ関数が定義されていると未定義動作に陥ります。特に、ADLが絡むとこれは発見しづらいバグを埋め込む事になるかもしれません。したがって、カスマイゼーションポイントを増やすと言う事は実質的に予約されている名前が増える事になり、ユーザーは注意深く関数名を決めなければならないなど負担を負うことになります。

C++20からのコンセプトはそのような問題を解決します。その呼び出しにおいてコンセプトを用いて対象の型が制約を満たしているかを構文的にチェックするようにし、カスタマイゼーションポイントに不適合な場合はオーバーロード候補から外れるようにする事で、ユーザーがカスタマイゼーションポイントとの名前被りを気にしなくても良くなります。結果的に、標準ライブラリにより多くのカスタマイゼーションポイントを設ける事ができるようになります。

しかし、コンセプトによって制約されたC++20カスタマイゼーションポイントの下では、先程のC++17までのカスタマイゼーションポイント使用時のベストプラクティスコードがむしろ最悪のコードになってしまうのです。

namespace std {

  // rangeコンセプトを満たす型だけが呼べるように制約してある新しいbegin()関数とする
  template<std::ranges::range C>
  constexpr auto begin(C& c) -> decltype(c.begin());  // (1)
}

namespace myns {

  struct my_struct {};

  // イテレータを取得するものではないbegin()関数
  bool begin(my_struct&);  // (2)
}


template<typename Container>
void my_algo(Container&& rng) {
  using std::begin;

  // 先頭イテレータを得る、はずが・・・
  auto first = begin(rng);  // my_structに対しては(2)が呼び出される
}

int main() {
  myns::my_struct st{};

  my_algo(st);  // ok、呼び出しは適格
}

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

このように、せっかくコンセプトで制約したにも関わらずADL経由で制約を満たさないbegin()が呼ばれています。別の見方をすれば、コンセプトによる制約を簡単に迂回できてしまっています。

これでは結局ユーザーはカスタマイゼーションポイント名を気にしてコードを書かなければならなくなるし、カスタマイゼーションポイントがコンセプトによって制約してあっても意味がなくなってしまいます・・・・

Customization Point Object(CPO)

カスタマイゼーションポイントオブジェクト(Customization Point Object)はこれら2つの問題を一挙に解決しつつ、将来的なカスタマイゼーションポイントの拡張も可能にしている素敵な魔法のようなすごいやつです!

例えば、これまでのstd::begin()に対応するカスタマイゼーションポイントオブジェクトであるstd::ranges::beginは次のように定義されます。

namespace std::ranges {
  inline namespace /*unspecified*/ {

    inline constexpr /*unspecified*/ begin = /*unspecified*/;
  }
}

unspecifiedなところは名前や型が規定されていない(実装定義である)ことを意味します。そして、このstd::ranges::beginは関数オブジェクトです。std::ranges::begin(E)のように呼び出してさも関数であるかのように使います。

std::ranges::begin(E)のように呼ばれた時、その引数の式Eによって以下のいずれかの処理を実行します(以下、TEの型、tは式Eの結果となる左辺値)。上から順番にチェックしていきます。

  1. Eが右辺値であり、std::ranges::enable_borrowed_range<remove_cv_t<T>> == falseならば、呼び出しは不適格。
  2. Tが配列型であり、 std::remove_all_extents_t<T>が不完全型ならば、呼び出しは不適格(診断不要)。
  3. Tが配列型であれば、std::ranges::begin(E)は式t + 0expression-equivalent
  4. decay-copy(t.begin())が有効な式であり、その結果の型がstd::input_or_output_iteratorコンセプトのモデルとなる(満たす)場合、std::ranges::begin(E)decay-copy(t.begin())expression-equivalent
  5. Tがクラス型か列挙型であり、decay-copy(begin(t))が有効な式であり、その結果の型がstd::input_or_output_iteratorコンセプトのモデルとなり、非修飾のbegin()に対する名前探索が以下2つの宣言だけを含むコンテキストでオーバーロード解決が実行される場合、std::ranges::begin(E)はそのコンテキストで実行されるオーバーロード解決を伴うdecay-copy(begin(t))expression-equivalent
// std::begin()を含まないコンテキストでオーバーロード解決をするということ
void begin(auto&) = delete;
void begin(const auto&) = delete;

「式Aは式Bとexpression-equivalent」というのは簡単に言うと式Aの効果は式Bと等価であり、式Aが例外を投げるかと定数実行可能かどうかも式Bと等価と言うことです。この場合の式Bは引数E由来なので、std::ranges::begin(E)の呼び出しが例外を投げるかどうかと定数実行可能かどうかは引数の型次第と言うことになります。

詳しく見ていくと、1,2番目の条件はまず呼び出しが適格ではない事が型レベルで分かるものを弾く条件です。enable_borrowed_rangeと言うのは右辺値のrangeであってもイテレータを取り出して操作する事が安全に行えるかを示すbool値です(たぶん)。
3番目以降がstd::ranges::beginの主たる効果です。3番目は配列の先頭のポインタを返します。t + 0というのは明示的にポインタにしてるようです。
4番目はメンバ関数として定義されたbegin()を呼び出します。標準ライブラリのほとんどの型がこれに当てはまります。
5番目はTと同じ名前空間にあるフリー関数のbegin()を探して呼び出すものです(Hidden friendsもここで探し出されます)。この時、std::begin()を見つけないようにするためにオーバーロード解決についての指定がなされています。

ユーザーがこのstd::ranges::beginにアダプトするときは、4番目か5番目に適合するようにしておきます。つまり、従来とやることは変わりません。一方、このstd::ranges::beginを使用する場合は逆に従来のような煩雑コードを書かなくてもよくなります。これまでやっていたことと同等(以上)のことを中で勝手にやってくれるようになります。

template<typename Container>
void my_algo(Container&& rng) {
  // using std::beginとかをしなくても、同じことを達成でき、よりジェネリック!
  auto first = std::ranges::begin(rng);
}

これによってまず、1つ目の問題(呼び出しが煩雑、使いづらい)が解消されている事がわかるでしょう。

さらに、ユーザー定義型に対しても行われうる4,5番目の処理では、戻り値型にコンセプトによる制約が要求されています。std::input_or_output_iteratorはインクリメントや間接参照等イテレータに要求される最小限のことを制約するコンセプトで、これによって使用されるbegin()イテレータを返さない場合にstd::ranges::begin(E)の呼び出しが不適格になります。そして、カスタマイゼーションポイントの呼び出しが診断可能な不適格となる場合は単にオーバーロード解決の候補から外れ、他に候補があれば別の適切な関数が呼び出されることになります。

namespace myns {

  struct my_struct {};

  // イテレータを取得するものではないbegin()関数
  bool begin(my_struct&);
}

int main() {
  myns::my_struct st{};

  std::ranges::begin(st);  // ng、戻り値型がinput_or_output_iteratorを満たさないためコンパイルエラー
}

こうして、2つ目の問題の一部(別の意味を持つ関数も呼び出してしまう)も解決されている事がわかりました。

関数オブジェクトとADL

最後に残ったのは、ADLによってカスタマイゼーションポイント呼び出しをフックできる、あるいは要求される型制約を無視できてしまう問題です。これはCPOが関数オブジェクトである事によって防止されます。

C++における名前探索では修飾名探索と非修飾名探索を行なった後、引数依存名前探索(ADL)を行いオーバーロード候補集合を決定します。この時、非修飾名探索の結果に関数以外のものが含まれているとADLは行われません。逆に言うと、ADLは関数名に対してしか行われません。つまり、関数オブジェクトに対してはADLは発動しません(6.5.2 Argument-dependent name lookup [basic.lookup.argdep])。

カスタマイゼーションポイントオブジェクトが関数オブジェクトであることによって、usingして使った時でも同名の関数によってADLでフックする事は出来なくなります。

namespace myns {

  struct my_struct {};

  // イテレータを取得するものではないbegin()関数
  bool begin(my_struct&); // (2)
}


template<typename Container>
void my_algo(Container&& rng) {
  using std::ranges::begin;

  // 先頭イテレータを得る
  auto first = begin(rng);  // std::ranges::beginが呼び出され、(2)は呼び出されない
                            // 戻り値型がinput_or_output_iteratorコンセプトを満たさないためコンパイルエラー
}

int main() {
  myns::my_struct st{};

  my_algo(st);  // ng
}

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

これらのように、カスタマイゼーションポイントオブジェクトではC++17までのカスタマイゼーションポイントに存在した問題が全て解決されている事が確認できたでしょう。

Template Method

遥か未来の世界で、イテレータを取得するのにbegin()だけではなく別の方法が追加された場合を考えてみます。例えば、first()関数が今のbegin()と同じ意味を持ったとします。その世界で統一的な操作としてstd::ranges::beginを使い続けるにはどうすればいいでしょうか?また、ユーザーは何をすべきでしょう?

答えは簡単です。先ほど5つほど羅列されていたstd::ranges::beginの条件にもう2つほど加えるだけです。標準ライブラリの実装は修正が必要ですが、それを利用するユーザーが何かをする必要はありません。first()関数がイテレータを返すようになった世界でもstd::ranges::beginを使い続けていれば何も変更する事なくイテレータを得る事ができます。

このように、C++20のカスタマイゼーションポイントオブジェクトはカスタマイゼーションポイントを追加する方向の変更に対して閉じています(そして、おそらく削除する変更は行われない)。ユーザー目線で見れば、そのような変更が行われたとしてもカスタマイゼーションポイントオブジェクトのインターフェースは常に安定しています。

このように、カスタマイゼーションポイントオブジェクトはよりジェネリックかつ静的なTemplate Methodパターン(あるいはNVI)だと見る事ができます。

inline名前空間

標準ライブラリのカスタマイゼーションポイントオブジェクトは、先ほど見たようになぜかinline名前空間に包まれています。

これはおそらく、将来行われうる変更に対してもABI互換性を維持するための布石です。

正しくは、CPOがstd名前空間にあるとき、標準ライブラリにあるクラスで定義されているHidden friends関数とCPOとで名前が衝突するため、それを回避するためのものです。

namespace mystd {
  
  namespace cpo_impl {
    
    // swap CPOの実装クラス
    struct swap_cpo {
      
      template<typename T, typename U>
      void operator()(T&, U&) const;
    };
  }

  // swap CPO #1
  inline constexpr cpo_impl::swap_cpo swap{};
  
  struct S {
    
    // Hidden friendsなswap関数 #2
    friend void swap(S& lhs, S& rhs);
  };
}

この例では#1と#2の異なる宣言が同じ名前空間にあるためにコンパイルエラーになっています。

この様な問題はメンバ関数との間では起こらず、非メンバ関数との間で起こります。正確にはswapなどほとんどのCPOはstd::ranges名前空間にありますが、Rangeライブラリのviewなど、Hidden friendsでCPOにアダプトする型との間で同様の問題が発生します。

この問題は、CPOをinline名前空間で囲むことによって解決されます。

namespace mystd {
  
  namespace cpo_impl {
    
    // swap CPOの実装クラス
    struct swap_cpo {
      
      template<typename T, typename U>
      void operator()(T&, U&) const;
    };
  }

  // CPO定義をinline名前空間で囲う
  inline namespace cpo {
    // swap CPO #1
    inline constexpr cpo_impl::swap_cpo swap{};
  }
  
  struct S {
    
    // Hidden friendsなswap関数 #2
    friend void swap(S& lhs, S& rhs);
  };
}

こうしてもmystd::swapという名前でswapCPOを参照できますし、CPOの内部からSに対するswapをADLによって正しく呼び出すことができます。しかし、#1と#2のswapは別の名前空間にいるために名前は衝突していません。

この様な事情から、標準ライブラリにあるCPOはほとんどのものがinline名前空間に包まれています。

その他の性質

標準ライブラリのカスタマイゼーションポイントオブジェクトは全て、リテラル型かつsemiregularであると規定されています。これはつまり、constexprにコピー・ムーブ・デフォルト構築/代入可能であると言う事です。

そしてそれら複数のインスタンスのカスタマイゼーションポイントオブジェクトとしての効果は呼び出しに使うインスタンスによって変化しない事も規定されています。

これらの性質によって、高階関数など関数オブジェクトを取るユーティリティでカスタマイゼーションポイントオブジェクトを自由に利用する事ができます。これはまた、従来のカスタマイゼーションポイント関数と比較した時のメリットでもあります(関数ポインタはジェネリックではいられないなど)。

実装してみよう!

言葉で語られても良く分からないのでここまで説明に使ってきたstd::ranges::beginを実装してみましょう。百聞は一見に如かずです。とはいってもstd名前空間ではなくオレオレ名前空間に書いてみます。

#include <concepts>

namespace mystd {
  
  namespace detail {
    
    // beginの実装型
    struct begin_impl {
      
      // 関数呼び出し演算子オーバーロードで定義していく
      template<typename E>
      constexpr auto operator()(E&&) const;
    };
  }
  
  inline namespace cpo {
    
    // ターゲットのカスタマイゼーションポイントオブジェクト begin
    inline constexpr detail::begin_impl begin{};
    
  }
}

概形はこんな感じで、detail::begin_implクラスの関数呼び出し演算子オーバーロードすることで実装していきます。

以下では、GCC10.1の実装を参考にしてます。

ケース1 右辺値

これは不適格な呼び出しとなり、該当する候補があるとハードエラーになってしまうので何も定義しません。ただし、右辺値かつstd::ranges::enable_borrowed_range<remove_cv_t<T>> == falseの場合は呼び出し不可でそうでない場合は呼び出し可能なのでそのようにしておきます。

struct begin_impl {

  template<typename T>
    requires std::is_lvalue_reference_v<T> or std::ranges::enable_borrowed_range<std::remove_cv_t<T>>
  constexpr auto operator()(T&&) const;
};

右辺値かつstd::ranges::enable_borrowed_range<remove_cv_t<T>> == falseの時は呼び出しはill-formedにするので、その否定の場合は通すようにします。全体を否定してド・モルガンをした条件を制約しておきます。なぜ左辺値参照判定してるかというと、テンプレート引数推論の文脈ではT&&に右辺値が渡ってくるとTはそのままTに、左辺値が渡ってくるとTT&になって全体としてT&&& -> T&となるからです。つまり、この場合のテンプレートパラメータTは左辺値が渡された場合は左辺値参照となります。

この状態で右辺値vectorを入れると呼び出し可能な関数が無いとエラーになるので上手くいっていそうです。

ケース3 配列型、ケース2 不完全型の配列

先程の仮実装に配列判定を入れましょう。配列の要素型が不完全型である場合はここで弾いてやります。

// 不完全型の配列判定
template<typename T>
concept is_complete_array = requires {
  sizeof(std::remove_all_extents_t<std::remove_reference_t<T>>);
};

struct begin_impl {

  template<typename T>
    requires (std::is_lvalue_reference_v<T> or std::ranges::enable_borrowed_range<std::remove_cv_t<T>>) and
              std::is_array_v<std::remove_reference_t<T>>
  constexpr auto operator()(T&& t) const noexcept {
    static_assert(is_complete_array<std::remove_all_extents_t<T>>, "Array element type is incomplete");
    return t + 0;
  }
};

不完全型の配列の時は診断不用とあり、SFINAEすることも求められないのでstatic_assertでハードエラーにします。

この状態で右辺値vectorを入れると適切にエラーになり、左辺値配列を入れると呼び出しは成功します。どうやら上手くいっているようです。

ケース4 ユーザー定義型のメンバbegin

コンセプトでメンバbeginが呼び出し可能かどうかを調べてやります。decay-copyauto戻り値型が勝手にやってくれるはず・・・

struct begin_impl {

  template<typename T>
    requires (std::is_lvalue_reference_v<T> or std::ranges::enable_borrowed_range<std::remove_cv_t<T>>) and
             requires(T t) { {t.begin()} -> std::input_or_output_iterator; }
  constexpr auto operator()(T&& t) const noexcept(noexcept(t.begin())) {
    return t.begin();
  }
};

requires節の中のrequires式でメンバ関数beginが呼び出し可能かどうかをチェックします。ついでに戻り値型の制約もチェックしておきます。この場合でも右辺値でenable_borrowed_rangetrueならば呼び出しは可能(そうでなければSFINAEする)なので先程の条件を同時に指定しておく必要があります。

expression-equivalentというのもconstexpr指定と呼び出す式によるnoexcept二段重ねで自動化できます。

左辺値のvectorとかを入れてやるとエラーにならないので行けてそうですね。

ケース5 ユーザー定義型の非メンバbegin()

オーバーロードに関わる部分はstd::beginを含まないコンテキストで、という事なのでstd名前空間の外で実装するときには触らなくてよかったりします。それ以外は先ほどのメンバ関数ケースの時と同様に書けます。

struct begin_impl {

  // ケース5 メンバ関数begin
  template<typename T>
    requires (std::is_lvalue_reference_v<T> or std::ranges::enable_borrowed_range<std::remove_cv_t<T>>) and
             (not requires(T t) { {t.begin()} -> std::input_or_output_iterator; }) and
             requires(T t) { {begin(t)} -> std::input_or_output_iterator; }
  constexpr auto operator()(T&& t) const noexcept(noexcept(begin(t))) {
    return begin(t);
  }
};

ただし素直にやると先ほどのケース4と曖昧になってしまうので、メンバ関数begin()を持たない場合、と言う条件を付け加えます(ケース4で追加した制約式の否定)。

適当にstd::vectorをラップしたような型を作って非メンバでbegin()を用意してやるとテストできます。大丈夫そうです。

完成!

#include <concepts>
#include <ranges>

namespace mystd {
  
  namespace detail {

    // 不完全型の配列判定
    template<typename T>
    concept is_complete_array = requires {
      sizeof(std::remove_all_extents_t<std::remove_reference_t<T>>);
    };
    
    struct begin_impl {
      
      // ケース3 配列型
      template<typename T>
        requires (std::is_lvalue_reference_v<T> or std::ranges::enable_borrowed_range<std::remove_cv_t<T>>) and
                  std::is_array_v<std::remove_reference_t<T>>
      constexpr auto operator()(T&& t) const noexcept {
        // ケース2をエラーに
        static_assert(is_complete_array<std::remove_all_extents_t<T>>, "Array element type is incomplete");
        return t + 0;
      }
      
      // ケース4 メンバ関数begin
      template<typename T>
        requires (std::is_lvalue_reference_v<T> or std::ranges::enable_borrowed_range<std::remove_cv_t<T>>) and
                 requires(T t) { {t.begin()} -> std::input_or_output_iterator; }
      constexpr auto operator()(T&& t) const noexcept(noexcept(t.begin())) {
        return t.begin();
      }
      
      // ケース5 非メンバ関数begin
      template<typename T>
        requires (std::is_lvalue_reference_v<T> or std::ranges::enable_borrowed_range<std::remove_cv_t<T>>) and
                 (not requires(T t) { {t.begin()} -> std::input_or_output_iterator; }) and
                 requires(T t) { {begin(t)} -> std::input_or_output_iterator; }
      constexpr auto operator()(T&& t) const noexcept(noexcept(begin(t))) {
        return begin(t);
      }
    };
  }
  
  inline namespace cpo {
    
    // オレオレstd::ranges::beginカスタマイゼーションポイントオブジェクト!
    inline constexpr detail::begin_impl begin{};
    
  }
}

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

正しいかはともかく、それっぽいものができました。カスタマイゼーションポイントオブジェクトの多くは大体この様に実装できます。コンセプトを使うと非常に簡単になるだけで、C++17以前の環境でもSFINAEを駆使するなどして実装することができます。この例ではオーバーロードに分けましたが、関数1つにまとめてconstexpr ifを使うと言う手もあります(GCCの実装はそっち)。

実装を知ってみれば、素敵な魔法というよりはそこそこ愚直な力業でできており、少し不透明さが取り払えることでしょう。

これまでのカスタマイゼーションポイント

本来は従来のカスタマイゼーションポイントとなっている関数をカスタマイゼーションポイントオブジェクトに置き換えたかったようですが、それは互換性の問題からできなかったようです。そのため、カスタマイゼーションポイントオブジェクトは別の名前空間に同名で定義されています。

ここまで見たことから分かるように、関数ではコンセプト時代のカスタマイゼーションポイントとしてはふさわしくないため、残っているのはほとんど後方互換のためでしょう。C++20以降だけをターゲットに出来るなら、それらの関数を使わずにカスタマイゼーションポイントオブジェクトを使うべきです。

C++20のカスタマイゼーションポイントオブジェクト

C++17のカスタマイゼーションポイントとC++20からのカスタマイゼーションポイントオブジェクトの対応と一覧を載せておきます。

C++17のカスタマイゼーションポイント関数 C++20のCPO 効果
std::begin() std::ranges::begin 範囲の先頭を指すイテレータを取得する
std::end() std::ranges::end 範囲の終端を指すイテレータを取得する
std::cbegin() std::ranges::cbegin 範囲の先頭を指すconstイテレータを取得する
std::cend() std::ranges::cend 範囲の終端を指すconstイテレータを取得する
std::rbegin() std::ranges::rbegin 逆順範囲の先頭を指すイテレータを取得する
std::rend() std::ranges::rend 逆順範囲の終端を指すイテレータを取得する
std::crbegin() std::ranges::crbegin 逆順範囲の先頭を指すconstイテレータを取得する
std::crend() std::ranges::crend 逆順範囲の終端を指すconstイテレータを取得する
std::size() std::ranges::size 範囲の長さを取得する
std::ssize() (C++20) std::ranges::ssize 範囲の長さを符号付き整数型で取得する
std::empty() std::ranges::empty 範囲が空であるかを取得する
std::data() std::ranges::data 範囲の領域先頭へのポインタを取得する
std::ranges::cdata 範囲の領域先頭へのconstポインタを取得する
std::swap() std::ranges::swap 二つのオブジェクトの内容を入れ替える
std::ranges::iter_move イテレータの指す要素をムーブする
std::ranges::iter_swap イテレータの指す要素をswapする
std::strong_order 全順序の上での三方比較を行う
std::weak_order 弱順序の上での三方比較を行う
std::partial_order 半順序の上での三方比較を行う
std::strong_order_fallback <=>が無い場合に< ==にフォールバックするstd::strong_order
std::weak_order_fallback <=>が無い場合に< ==にフォールバックするstd::weak_order
std::partial_order_fallback <=>が無い場合に< ==にフォールバックするstd::partial_order

もしかしたらほかにもあるかもしれません。

C++23以降の標準ライブラリ

カスタマイゼーションポイントを増やしづらかった時代はコンセプトとカスタマイゼーションポイントオブジェクトによって終わりを告げたため、これからのライブラリはそれらを中心として設計されるでしょう。提案文書のレベルでは、新規提案のほとんどが何かしらの形でコンセプトを用いており、規模の大きめな新規ライブラリ提案ではカスタマイゼーションポイントオブジェクトが普通に用いられています。

特にC++23に導入されるのがほぼ確実視されているExecutorライブラリは、現段階ですでにコンセプトとカスタマイゼーションポイントオブジェクトベースの非常にジェネリックな最先端のライブラリです。C++23とそれ以降の標準ライブラリではカスタマイゼーションポイントオブジェクトとコンセプトは空気のような存在になるでしょう。

onihusube.hatenablog.com

カスタマイゼーションポイントオブジェクトという言葉

カスタマイゼーションポイントオブジェクトの効果はそれぞれ異なりますが、おおよそ全般的に共通しているものがあり、単にCPOやカスタマイゼーションポイントオブジェクトと呼んだ時にはそのような性質を暗黙的に仮定していることがあります。

任意のカスタマイゼーションポイントオブジェクトの名前をcpo_nameとすると

  • (標準)ライブラリ側で用意されている関数オブジェクトである
  • カスタマイゼーションポイントオブジェクトによる処理は特定の型に限定されない
  • 呼び出しに当たっては引数あるいは戻り値型に(その文脈で)適切なコンセプトによる制約を行う
  • 少なくとも、cpo_nameと同じ名前のメンバ関数と非メンバ関数Hidden Friends含む)を捜索して呼び出すように定義される

これらの事を頭の片隅に入れておくと、カスタマイゼーションポイントオブジェクトが出て来た時にその意味を理解しやすくなるかもしれません。

参考文献

この記事のMarkdownソース