[C++]モジュールインポート時の動的初期化順序

ほぼほぼ、2019年12月のBelfast会議で採択されたP1874R1 Dynamic Initialization Order of Non-Local Variables in Modulesの和訳しただけです。

モジュールについては以前の記事でもご参照ください。 onihusube.hatenablog.com

P1874R1前夜の大問題

P1874R1が採択される以前のモジュールはほとんど完成していたはずですが、以下のような何の変哲も無い?コードが未定義動作に陥るという罠がありました。

import <iostream>;  // <iostream>ヘッダユニットのインポート

struct G {
  G() {
    std::cout << "Constructing\n";
  }
};

G g{};  // Undefined Behaior!?

C++20においては通常のC++標準ライブラリヘッダをヘッダユニットとしてインポートできます。ヘッダユニットはそれが1つのモジュールかつ1つの翻訳単位として個別にコンパイルされ、インポートによってその外部リンケージを持つ宣言がインポート先の翻訳単位の名前探索で可視となります。

ここで問題なのは、翻訳単位が別れた場合にはグローバル変数(正確には静的記憶域期間を持つ変数)の初期化順序が不定になってしまうことです。std::coutは通常グローバルなオブジェクトなので、main()関数の開始前に使用した場合に初期化されているかどうか分かりません。この場合、変数gstd::coutはそれぞれ異なる翻訳単位にあるため、どちらが先に初期化されるのか、あるいは同時に初期化されるのか、コンパイラ様のみぞ知る世界です・・・

同じ翻訳単位にあればほぼその宣言順に初期化されるためこの問題は起きません。つまり、#includeの場合は問題ないわけです。

こんなことしねーよと思われるかもしれませんが、これは通常のモジュールをインポートして使用するときにも、そのインターフェース単位にあるグローバルな変数について同じことが起こります。また、C++20より多用されるカスタマイゼーションポイントオブジェクトを使用するときも気を付けなければならないでしょう・・・

Clangの実験的実装

Clangのモジュールの実装(experimental)では、この問題を見た目通りに直列化することで解決していました。

// H1.h
inline int a = init();

// H2.h
inline int b = init();

// TU.cpp
int c = init();
import "H1.h";  // ヘッダユニットとしてインポート
import "H2.h";  // ヘッダユニットとしてインポート

この場合に、c -> a -> bの順番で初期化されます。これはClang Module(not C++20 Module)から引き継がれたモデルであり、import#includeがなるべく同じ意味を持つことを意図したもののようです。

しかし、C++20のモジュールではimportはその順序が意味を持たないように規定されているため、このように規定するのは嫌がられたようで、これを基にした別のモデルを採用しました。

C++20における順序付け規定

あるモジュールMが他の翻訳単位Uにインポートされる場合、UMにインターフェース依存関係を持ちます。そして、U内の宣言Eはその宣言が現れているところでMへのインターフェース依存関係がある場合、Mのインターフェースの全ての宣言よりも後に順序付けられます(appearance-ordered関係)。

同じ翻訳単位にあるものはその宣言順に初期化されるのは従来通り変わらず、これもまたappearance-ordered関係が成り立ちます。

ある翻訳単位内のグローバル変数(静的記憶域期間を持つ変数)の動的初期化順序は、このappearance-ordered関係の順番通りに行われます。この関係が規定されない場合の動的初期化順序は不定実装依存?)です。

例えば、グローバルな変数V, WV -> Wという順番でappearance-ordered関係がある場合、V -> Wの順に動的初期化が行われます。

規格書該当部分の和訳

サンプル

// H1.h
int a = init();

// H2.h
int b = init();

// H3.h
int c = init();

// TU.cpp
int d = init();
import "H1.h";
import "H2.h";
int e = init();
import "H3.h";

この例では、まずd -> eappearance-ordered関係があり(同じ翻訳単位内にあるためその宣言順)、ヘッダユニットのインポートによるa -> e, b -> eの合計3つのappearance-ordered関係があります。従って、これらの変数の間では、このままの順序で動的初期化が行われます。そして、appearance-ordered関係にない変数間の初期化順序は不定です。

結果、次のような順番で初期化が行われます。

サンプルコードの変数初期化順

変数d, a, bの間、及びe, cの間にはappearance-ordered関係が無いので、その初期化順は不定です。ただ、変数d, a, beよりも前に初期化されることだけは確定しています。また、e, c間およびd, a, bcの間でも順序が規定されないので、cの初期化が一番先に行われる可能性もあったりします。

ヘッダーユニットもモジュールのインターフェースも1つの翻訳単位であり、同じモジュール名を指定するインポート宣言は同じモジュールをインポートします。従って、異なる翻訳単位で同じモジュールをインポートしたときでも、モジュールがコンパイルされるのは一回だけであり、そのグローバル変数の動的初期化が行われるのも一度だけです。
そのため、ある翻訳単位のグローバル変数の初期化よりも前に初期化されていることを保証できても、後に初期化されることを保証することができないのです。

ともあれ、これらの事により冒頭のimport <iostream>なコードには未定義動作はもはやありません。

モジュール実装単位の初期化順序(特に規定されない)

同じ事はモジュールの実装単位においても言えます。しかし、C++20時点ではこちらについては特にケアされていません。

  • 規定したとしても異なるモジュール間で規定するのがせいぜいで影響が少ないと思われる
    • モジュール内で実装単位間の初期化順序を規定すると、1つのモジュールをビルドする際のビルド順序を規定してしまうことになるため
  • モジュール実装単位は、異なるモジュール間の循環する依存関係を構成しうる
    • 自分自身のインターフェースに依存する別のモジュールをインポートできる
  • そもそも実装が無いので(規定するにせよしないにせよ)影響範囲がわからない
  • 実装単位については、この問題(import <iostream>にまつわる冒頭のコード)を解決するために触れる必要はない

これらのような理由によって先送りされたようです。

なお、モジュール実装パーティションを同モジュール内でインポートすることができますが、この場合はインターフェース依存関係が発生するので、先ほどの規定に沿った順序が定義されます。

参考文献

この記事のMarkdownソース

[C++]特殊化?実体化??インスタンス化???明示的????部分的?????

C++のテンプレートの用語は日本語に優しくなく、似た言葉がこんがらがってよく分からなくなります。分からないのでメモしておきます。

特殊化(specialization

単に特殊化と言ったら、あるテンプレートに対してそのテンプレート引数を全て埋めた状態のものをさします。つまり、テンプレートを使った時、その使っている(もはやテンプレートではない)テンプレートのことをテンプレートの特殊化と呼びます。

template<typename T>
struct S {
  T t;
};

template<typename T>
T f(T t) {
  return t;
}

// クラステンプレートSの特殊化
S<int> s{};     // S<int>
S s1 = {1.0};   // S<double>

// 関数テンプレートfの特殊化
f(10);          // f<int>
f<float>(1.0);  // f<float>

明示的特殊化(explicit specialization

あるテンプレートについて、テンプレートパラメータを全て埋めた状態の定義を追加することを明示的特殊化と呼びます。関数テンプレートやクラステンプレートについて特定の型に対する特殊処理を追加したい場合に行います。

明示的特殊化は全てのテンプレートパラメータが確定するため、もはやテンプレートではありません。

template<typename T>
struct S {
  T t;
};

template<typename T>
void f(T t) {
  std::cout << t << std::endl;
}

// S<int>は常にこちらが使用される
template<>
struct S<int> {
  int t = 10;
};

// f<int>は常にこちらが使用される
template<>
void f(int t) {
  std::cout << (2 * t) << std::endl;
}

完全特殊化

明示的特殊化のことです。

部分特殊化(partial specialization

明示的特殊化に対して、全てではなく一部のテンプレートパラメータだけを埋めた定義を追加することを部分特殊化と言います。必然的に、2つ以上のテンプレートパラメータを持つテンプレートでだけ行えます。また、これはクラステンプレートと変数テンプレートでしか行えません。

部分的特殊化と呼ばれることもあります。

部分特殊化は全てのテンプレートパラメータが確定していないので、まだテンプレートです。

template<typename T, typename U>
struct P {
  T t;
  U u;
};


// Pの1つ目のパラメータだけを特殊化
template<typename U>
struct P<int, U> {
  int t = 10;
  U u;
};

// Pの2つ目のパラメータだけを特殊化
template<typename T>
struct P<T, double> {
  T t;
  double u = 1.0;
};

クラステンプレートに対して明示的・部分的特殊化を追加することを、クラステンプレートのオーバーロードと言うことがあります。

プライマリテンプレート(primary template

明示的・部分特殊化において、大元の(一番最初に定義された)テンプレートのことをプライマリテンプレートと呼びます。

テンプレートの使用時には、明示的特殊化→部分特殊化→プライマリテンプレート、の順番に考慮されます。つまりは、テンプレート特殊化の半順序においてプライマリテンプレートの優先度は最低です。

インスタンス化(実体化:instantiation

あるテンプレートが特殊化された時、その特殊化にマッチする元のテンプレートのテンプレートパラメータに具体的な型が渡され、テンプレートの(2段階目の)コンパイルが行われることをテンプレートのインスタンス化と言います。これはまた、実体化とも呼ばれます。

template<typename T>
struct S {
  T t;
};

template<typename T>
void f(T t) {
  std::cout << t << std::endl;
}


S<int> s{};     // S<int>がインスタンス化される
S s1 = {1.0};   // S<double>がインスタンス化される

f(10);          // f<int>がインスタンス化される
f<float>(1.0);  // f<float>がインスタンス化される

S<int> s2{};    // S<int>はインスタンス化済
f(20);          // f<int>はインスタンス化済

このようにテンプレート使用時に自動的にインスタンス化が行われるため、これを暗黙的インスタンス化(implicit instantiation)と呼ぶことがあります。

ある特殊化についてのインスタンス化は翻訳単位につき一度だけ行われます。

明示的インスタンス化(explicit instantiation

暗黙的インスタンス化に頼らずに、あらかじめテンプレートをインスタンス化させておくことができます。そのようなインスタンス化(そのもの、もしくは方法・構文)のことを明示的インスタンス化と言います。

普通のテンプレートの宣言と少し変わった形の宣言で行います。

template<typename T>
struct S {
  T t;
};

template<typename T>
void f(T t) {
  std::cout << t << std::endl;
}

// S<int>の明示的インスタンス化
template struct S<int>;

// 関数テンプレートfの2種類の明示的インスタンス化
template void f(int);
template void f<int>(int);

S<int> s{};    // S<int>はインスタンス化済
f(10);         // f<int>はインスタンス化済

テンプレートは明示的インスタンス化をした場所でインスタンス化と定義が行われ、そのテンプレート内部から参照できるものもその明示的インスタンス化した場所に基づいて決定されます。

また、部分特殊化に対して明示的インスタンス化を行うこともできます。

明示的インスタンス化の定義と宣言

実は、いわゆるextern templateもまた明示的インスタンス化に含まれます。この時、externの付かないものを明示的インスタンス化の定義(explicit instantiation definition)、externの付くものを明示的インスタンス化の宣言(explicit instantiation declaration)と呼び分けています。

template<typename T>
struct S {
  T t;
};

template<typename T>
void f(T t) {
  std::cout << t << std::endl
}

// 明示的インスタンス化の定義
template struct S<int>;
template void f(int);

// 明示的インスタンス化の宣言
extern template struct S<bool>;
extern template void f(bool);

ほぼ規格書でしかでてこない用語です。その用語と裏腹?に、意味するところは真逆になる上、使用されるときはdecralationと言う言葉が何を指すのかわかりづらくなる効果を持ちます・・・

参考文献

謝辞

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

この記事のMarkdownソース

[C++] 集成体のテンプレート引数推論

C++17のテンプレートな集成体とテンプレートパラメータ推論

C++17から、クラステンプレートのコンストラクタ引数からそのテンプレート引数を推論出来るようになりました。これによって、クラステンプレートを扱う際にとても便利に書けます。

// std::vector<int>
std::vector vec = {1, 2, 3, 4, 5};

// std::pair<int, double>
std::pair p = {1, 1.0};


template<typename T>
struct vec3 {
  T v1, v2, v3;

  vec3(T a, T b, T c) : v1(a), v2(b), v3(c) {}
};

// vec3<double>
vec3 v3 = {1.0, 2.0, 1.0};

ところで、このvec3のような型は別にコンストラクタをわざわざ書かなくても集成体にしておけば色々楽ができます。しかし、このコンストラクタを消してみると、なにやらコンパイルエラーが起こります。どうやらテンプレート引数の推論ができない様子・・・

template<typename T>
struct vec3 {
  T v1, v2, v3;
};

// compile error! no viable constructor or deduction guide for deduction of template arguments of 'vec3'
vec3 v3 = {1.0, 2.0, 1.0};

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

仕方がないので言われた通りに推論補助を書いておきます。

template<typename T>
struct vec3 {
  T v1, v2, v3;
};

template<typename T>
vec3(T, T, T) -> vec3<T>;


// ok、vec3<double>
vec3 v3 = {1.0, 2.0, 1.0};

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

集成体にはコンストラクタは無いのでコンストラクタ引数から推論できないのはまあ当たり前かもしれません。そして、推論補助は別にコンストラクタと対応している必要はないので集成体でも使えるのでこれでokなのです。

でも、書かなくていいものは書きたくないですよね・・・。

C++20からのテンプレートな集成体

C++20より、集成体に対してはその仮想的なコンストラクタを利用してテンプレート引数推論が行われるようになります。つまり、先程のような場合に推論補助を書かなくてもよくなります。神アプデ!

template<typename T>
struct vec3 {
  T v1, v2, v3;
};

// ok、vec3<double>
vec3 v3 = { .v1 = 1.0, .v2 = 2.0, .v3 = 1.0};

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

C++20からの集成体はこの様に指示付初期化が使える上に、普通に()でも初期化できるようになります。とても便利になります!

仕様詳細

クラステンプレートのテンプレート引数推論は、初期化子からマッチするコンストラクタを関数テンプレート、推論補助を関数として抽出しオーバーロード解決によってその集合から1つを選び出したうえで、関数テンプレートならそのテンプレートパラメータから、関数ならばその戻り値型からテンプレート引数を補います。
C++17までは集成体は推論補助が無ければそこに引っかからなかったため何も推定してくれませんでした。

C++20からは、集成体Cの初期化時の引数リスト(x1, ..., xi)について、対応するCの要素eiが過不足なくぴったりと存在している場合に、Cの要素eiの宣言されている型TiによってC(T1, ..., Ti)という仮想的なコンストラクタを候補として先程の集合に入れます。その後の手順は先程と同様です。

その際、Cのメンバとなっている入れ子の集成体は{}省略されていてもされていなくても問題ありません。ただし、非集成体のクラスは当然として、Cのテンプレートパラメータに依存する集成体が入れ子になっている場合に{}を省略してしまうと初期化子の数と要素数が合わずコンパイルエラーとなります。

集成体内部の集成体はその要素が一番外側の集成体まで展開された形で初期化することができます。ただ推定時に限っては、そのような集成体がテンプレートでありそのパラメータが確定していない場合は展開されず、一つのクラスとして扱われます。

提案文書(規格書ドラフト)より、サンプルコード。

template <typename T>
struct S {
  T x;
  T y;
};

template <typename T>
struct C {
  S<T> s; //テンプレートパラメータTに依存している
  T t;
};

C c1 = {1, 2};        // error
C c2 = {1, 2, 3};     // error、{}省略できない
C c3 = {{1u, 2u}, 3}; // ok, C<int>

template <typename T>
struct D { 
  S<int> s; //テンプレートな集成体だが、型は確定している
  T t; 
};

D d1 = {1, 2};    // error、{}省略するなら初期化子は3つ必要
D d2 = {1, 2, 3}; // ok、{}省略可能

この様に、再帰し外側のテンプレートパラメータに依存する集成体でも、初期化子をきちんと書けばそれら全体にわたってマッチする良い感じな型を推論してくれます。

参考文献

この記事のMarkdownソース

[C++] std::rangesの範囲アクセス関数(オブジェクト)の使いみち

C++20のRangeライブラリ導入に伴って、std::ranges名前空間の下にbegin,end,size,data等の、これまでコンテナアクセスに用いられていた関数が追加されています。しかし、これらの関数はすでにstd名前空間に存在しており、一見すると同じ役割を持つものが重複して存在しているようにも見えます。

従来のstd::begin()/std::end()の問題点

元々、範囲を表すイテレータのペアを取り出す口としてのbegin()/end()は各コンテナのメンバ関数として定義されていました。しかしその場合、メンバ関数を持つことのできない生の配列型だけは特別扱いする必要があり、イテレータのペアを取り出す操作が完全に共通化できていませんでした。

C++11にて、std::begin()/std::end()が追加され、この関数を利用することで配列とコンテナの間で共通の操作によってイテレータのペアを取り出せるようになりました。
しかし、begin()/end()をメンバではなくグローバル関数として定義しているような標準ライブラリ外のユーザー定義型に対してはこれに加えて以下のような一手間を加えなければなりません。

//イテレータのペアを取り出す
template<typename Iterable>
auto get_range(Iterable& range_obj) {
  using std::begin;
  using std::end;

  return std::make_pair(begin(range_obj), end(range_obj));
}

std::begin()/std::end()をそれぞれusingした上でbegin()/end()するのです。これによってメンバではなく同じ名前空間begin()/end()を用意しているユーザー定義型の場合はADLによってそれを発見し、それ以外の型はstd::begin()/std::end()を経由してメンバのものを呼び出すか配列として処理されます。

C++17まではイテレータ範囲をジェネリックに得るにはこうする事がベストでした(はずです)。

std::ranges::begin/std::ranges::end

しかし、先ほどのコードは正直言って意味が分からないし、知らなければああいう風に書くという発想は出てきません。それに何より一々あんなまどろっこしい書き方をしたくないです。

そこで、Rangeライブラリでは独自にbegin()/end()を備えておくことにしました。それが、std::ranges::begin/std::ranges::endの関数(オブジェクト)です。その効果はおおよそ以下のようになります。

  • 配列ならその先頭(終端)のポインタを返す
  • そうではなく、メンバ関数E.begin()/E.end()があればその結果を返す
  • そうでもなく、フリー関数のbegin(E)/end(E)が利用可能ならばその結果を返す
  • そうでもなければill-formed

つまりC++17までのベストプラクティスだったコードで暗黙に行われていた事を全て中でやってくれるすごいやつです。なお、突然出てきたEstd::ranges::begin(E)のように呼び出されたときの引数に当たります。

そして、このstd::ranges::begin/std::ranges::endによってイテレータペアを取得可能な型こそがRangeライブラリの基本要件であるstd::ranges::rangeコンセプトを満足する事ができます。

C++20からは先ほどのコードは以下のように書けるでしょう。

//イテレータのペアを取り出す
template<std::ranges::range Iterable>
auto get_range(Iterable& range_obj) {
  return std::make_pair(std::ranges::begin(range_obj), std::ranges::end(range_obj));
}

std::ranges::swap

begin()/end()と全く同じ問題を抱えているのがswap()関数です。std::swap()は通常ムーブコンストラクタとムーブ代入演算子を用いて典型的なswap操作を行います。標準ライブラリの型はメンバ関数swap()を持っており、std::swap()の特殊化を経由してそれを使ってもらっています。

ユーザー定義型で特殊なswapを行いたい場合は、非メンバ関数としてswap()を定義します(この時、メンバ関数として定義したswap()を非メンバ関数swap()から使うようにしてもokです)。そして、使用するにあたってはさっき見たようなコードを書きます。

template<typename T, typename U>
void my_swap(T&& t, U&& u) {
  using std::swap;

  swap(std::forward<T>(t), std::forward<U>(u));
}

これの問題点は上で説明した通りです。

対して、std::ranges::swap関数(オブジェクト)は以下のような効果を持ちます。

  • 引数E1, E2がクラス・列挙型でフリー関数のswap(E1, E2)が利用可能ならそれを利用
  • そうではなく、引数E1, E2が配列型でstd::ranges::swap(*E1, *E2)が有効なら、std::ranges::swap_ranges(E1, E2)
  • そうでもなく、引数E1, E2が同じ型Tの左辺値でstd::move_­constructible<T>std::assignable_­from<T&, T>両コンセプトのモデルである場合、デフォルトのstd::swap()相当の式によってswapする。
  • そうでもなければill-formed

ユーザー定義swap()関数は1つ目の条件によって発見されます。using std::swapをする必要はありません。もっと言えば、swapを汎用的に行うためにさっきのmy_swap()のような関数を書くは必要ありません、このstd::ranges::swapを使えばいいのです。そして、std::swappable(_with)コンセプトはこのstd::ranges::swapを用いて定義されます。

ちなみに、このstd::ranges::swapはなぜか<range>ヘッダではなく<concepts>ヘッダにあります。多分std::swappableコンセプト定義の関係だと思いますが、見つけるのに少し苦労しました・・・。

std::ranges::size

これも先ほどまでと似たようなものです。標準ライブラリにあるコンテナはほとんど現在抱えている要素数size()メンバ関数で取得できます。しかし、生配列は当然そうではなくbegin()/end()の時と同じくメンバ関数ではなくフリー関数で定義している場合もあるかもしれません。

C++17までは、フリー関数のstd::size()を使えばstd::forward_list以外の標準のコンテナからは要素数を取得できました。また、非メンバで定義されていた場合のケアもしてくれていました。

std::ranges::size関数(オブジェクト)はもう少し頑張って、以下のような効果を持ちます。

  • 配列型ならその要素数を返す
  • そうではなく、メンバ関数E.size()があればその結果を返す
  • そうでもなく、フリー関数のsize(E)が利用可能ならばその結果を返す
  • そうでもなく、std::ranges::end(E) - std::ranges::begin(E)が有効ならばその結果を返す
  • そうでもなければill-formed

フリー関数として定義されている場合のフォローと、定義が無くてもイテレータを使ってサイズを求めてくれています。残念ながら、std::forward_listはサイズを定数時間(O(1))で求められないので、この関数ではサイズを得られません。

std::ranges::empty

これも似たようなものです。標準ライブラリのコンテナは多分全てempty()メンバ関数を備えていますが、生配列とinitilizer_listはそうではありませんでした。

std::ranges::empty関数(オブジェクト)は以下のような効果を持ちます。

  • メンバ関数E.empty()があればその結果を返す
  • そうではなく、std::ranges::size(E) == 0が有効ならその結果を返す
  • そうでもなく、bool(std::ranges::begin(E) == std::ranges::end(E))が有効ならその結果を返す
  • そうでもなければill-formed

すごく頑張ってくれているのが伝わります。2番目に引っかからずに3番目に引っかかるのはempty()を持たないstd::forward_listみたいな型の場合のようです。

std::ranges::data

標準ライブラリでdata()メンバ関数を持つのはその領域にメモリ連続性があるコンテナだけです(イテレータcontiguous iteratorであるということでもあります)。そして、その戻り値はそのような領域の先頭ポインタです。

対して、std::ranges::data関数(オブジェクト)は以下のような効果を持ちます。

  • 引数Eが左辺値であり、メンバ関数E.data()が使用可能ならその結果を返す
  • そうではなく、std::ranges::begin(E)が有効でありその戻り値型がstd::contiguous_iteratorコンセプトのモデルである場合は、std::to_address(std::ranges::begin(E))
  • そうでもなければill-formed

基本的にはdata()を利用できる型にその抱える要素列がメモリ連続性を満たしていることを要求する点は変わりませんが、謎の頑張りによってその対象を広げています。

std::to_address()はポインタもしくはポインタとみなせる型(スマートポインタ等、ファンシーポインタと呼ぶらしい)のオブジェクトからアドレスを取得するものです。
std::contiguous_iteratorコンセプトはrandom access iteratorでありメモリ連続性を持つイテレータが満たす事の出来るコンセプトです。

おそらく、std::ranges::begin()でその内部のメモリ領域へのファンシーポインタを返すような型を意識しているのだと思います。ユーザー定義型ならばもしかしたらこれが有効なケースがあるかもしれません。標準ライブラリには無いはず(もしかしたらRangeライブラリにはこのような事を行う何かが潜んでいるのかもしれません)・・・

結論

このように、これらの範囲アクセス関数(オブジェクト)は今まで用意されていた同名の関数と比べて、さらにもう少し頑張ってその目的を達成しようとするものです。そして、その目的を達するために非自明な追加の操作を要求しません。
またこれらはカスタマイゼーションポイントオブジェクトと呼ばれる関数オブジェクトであって関数ではありません。そのため、その呼び出しに際して効果を発揮する際に要求するコンセプトのチェックが確実に行われます(ユーザーが巧妙に迂回することができない)。

C++20からは従来の関数のことは忘れてstd::ranges名前空間の下にあるこれらの関数(オブジェクト)を使いましょう。

参考文献

謝辞

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

この記事のMarkdownソース

[翻訳]なぜそんなに確信が持てるのか?

前書き

この記事はC++標準化委員会の2019年12月公開の論文の1つ、Bjarne Stroustrupさんが書かれた「P1962R0 How can you be so certain?」という論文の和訳です。

この文章はC++標準化委員会における機能追加時の議論を念頭において、C++標準化委員会メンバーに向けて書かれています。したがって、読むにあたってはC++の機能などについてある程度知っている必要があるかと思います。

私の英語力はひよこ以下なので訳の正確性には全く保証がありません。特に、細部のニュアンスの解釈は大いに間違っている可能性があります(修正してやる!という方がいましたら、この記事のMarkdownソースからお願いします)。

以下の方に修正を賜りました

なお、翻訳の公開についてBjarne Stroustrupさんに連絡を取った所、問題ないとのお返事をいただいております。

以下本文

How can you be so certain? - Bjarne Stroustrup

私はJohn McPheeの本を読んでいてこの引用を見つけた。

「これが真実だ」ではなく「だから私には、私が今見ていると思うものを見ているように思う」と言おう。
--- 世界最高の地質学者の一人であるDavid Loveの言葉より

少し複雑かもしれないが、考えさせられた。私はよく、一部の人々がどのように彼らが示すほどの確信を得ているのか不思議に思っていたためだ。

我々はもっと良く考える必要がある

言語(およびライブラリ)拡張に関しては、根本的な問題の解決よりも技術的な詳細を詰める(どのキーワードが最も害が小さいかや、機能がどのように実装されるべきかを決定するなど)のに多くの時間を費やしているようだ。

確かに、我々は委員会初期の頃よりもはるかに優れた技術者集団となったが、大きな変更に関する議論がやや表面的なものになってはいないかと心配している。
私はときどき、査読済み学術論文 – それら論文の少なくとも一部は実証的な観察に基づいている – の厳密性が恋しくなる。私は度々、ある機能や拡張についての利点と欠点を慎重な計量が欠けているのを見る。
私は度々、我々のエビデンスへの計量が十分に徹底されており一貫しているかどうか、さらに言えば完全な言語と標準ライブラリにまたがる懸念にそもそも注意を払ったのかどうか、疑問に思うことがある。

  • 「そう思う」は技術的な根拠ではない
  • 「私の会社ならやる」は決定的な根拠ではない
  • 「私はどうしてもそれを必要としている」は決定的な根拠ではない
  • 「他のモダンな言語にはそれがある」は決定的な根拠ではない
  • 「我々はそれを実装することができる」は必要な要件ではあるが、機能追加のための十分な理由ではない
  • 「その部屋のほとんどの人はそれを気に入っていた」は十分な理由ではない

最後のポイントは反民主主義的に思えるかもしれないがそうではない。
部屋とはどの部屋であるのか?そこにいた人々はC++標準化委員会メンバーの代表だったのか?あるいはC++コミュニティの代表?彼らは何を好んだのか?それを好んだ理由は何だったのか?誰も反対しなかったのか?(反対した人がいたとしたら)その人はなぜ反対したのか?
(そのような)部屋にいる人々は概して自分の意思でそこにおり、通常、拡張を支持する人々は現状維持を支持する人々よりもはるかに雄弁でやる気に満ち溢れている。

実際のところ、単純に票を合計して3:1の得票差があるかを見るのは間違っていると思う。

コンセンサスを得るにはこのような(できればこれ以上の)得票差が必要であるが、それだけでは十分ではない。特に、部屋に十分に人数がおらず、そこにいる人々は週末で疲れている状態であり、主要なメンバーやベテランのメンバーが他の場所にいる場合などは十分ではない。誰が何に対して強く反対しているのかを検討することには常に価値がある。

強い支持者も強い反対者も必ずしも正しいとは限らない。時には強い感情が論理的な根拠の欠如を覆い隠してしまうこともある。
我々は今後何十年も使用される言語を定義している、もう少し謙虚さが必要だ。

あの頃を覚えているだろうか?

  • 全ての関数をメンバ関数にすることが一般的だった頃
  • 仮想関数はクールだったため、全ての関数は仮想化されるべきだと多くの人が主張していた頃
  • publicメンバは時代遅れだったため、全てのデータメンバは隠蔽されるべきだと多くの人が主張していた頃
  • ガベージコレクタは不可欠だと思われていた頃

私は今日のC++標準化委員会を構成する委員たちならこれらのような流行に乗ってしまったのではないかと疑っている。

今日、我々は多くの流行に囲まれているが、その中で何が長期的に有用で何が「単に流行っているだけ」なのかを判断するのは依然として困難である。そして、現在流行しているものに心を奪われるのは簡単なことだ。
どの問題に解決する価値があり、それはどれほど流行に左右されるのか。

裁決を遅くすべきだとか早くすべきだとか主張しているわけではない。例えば、コンセプトは遅すぎたし、<=>(一貫比較)は早すぎたと思っている。

私は、我々がより組織的で慎重で一貫した理由付けを行うことを提案する。

提案

解決策を提案するよりも問題を指摘する方が簡単だろう。

瓶に詰められるような設計の「魔法の源」はなく、実際に使用されるための言語設計(これは我々が議論していること)は単純な予測可能プロセスではない。
「問題」とは何か、どの問題に対処が必要か、多くの解決策のうちどれが最善なのか、についての完全な合意を得ることはできない。

ただし、それら対応策毎に過去の対応において上手く行ったことや、パッチの上にパッチを必要とするような長引く問題の原因などについてを学ばなければならない。
異なる提案にはそれぞれ異なる種類、異なる量の仕事が必要となる。

少なくとも議論・検討の初期段階では使用パターンとインターフェースに目を向けるべきであり、実装詳細にはあまり目を向けないでおくことを提案する。

ユーザーインターフェースと使用モデルがクリーンであるならば、実装は何年(何十年)かかけて改善される傾向にある。「どのように(実装される)?」よりも、「何が?」「なぜ?」(必要か)に集中する必要がある。
現在の技術の下では、最適なパフォーマンスよりも安定したインターフェースの方が重要である。標準は何十年にも渡って安定であることが要求される点で、多くのプロダクトと異なるためだ。

最近のC++標準化委員会での議論はきめ細かい制御によりプログラマーに「どのように?」をかなり具体的にさせることに集中しすぎており、そのため、使用パターンを進化させ実装を改善するということを難しくさせているように感じている。

確かに、きめ細かい制御と高レベルで一般的なインターフェースとの選択は意思決定プロセスの問題ではなく設計の問題であるが、一般的なインターフェースを選択することは、実装の議論ですべての実装詳細を突き詰めるのではなく限定しない選択肢を決めて済ませることを可能にし、標準化をシンプルにできる。

他の機能から完全に分離された言語(ライブラリ)機能は存在せず(少なくとも現在は存在していないはず)、そのような機能間の相互作用は最も難しい問題の一つであるが、多くの場合は問題としても有用なものとしても過小評価されている。
我々は、そのような他の言語機能や標準ライブラリコンポーネントとの相互作用を常に考慮しなければならない。特に、他の機能が開発中である場合これはとても難しい。

このことは、C++に大きな改善が施された後、常に小さなクリーンアップと小さなサポート機能の追加が必要になる理由の一つである。大規模な機能の導入から結果として得られる教訓を初めに予想することは出来ない。可能なあらゆるニーズ(初めは予想されていないような)に対応するために、機能はあまり精巧なものにしてはならない。
(そうてしまおうとすれば)まず、できない。次に、やろうとすれば、肥大化を生じ、我々はそれと永遠に付き合わなければならない。

C++標準化委員会における「委員会による設計(より正確には委員の連合による設計)」プロセスでは一般に、表明されたすべてのニーズを包含するような設計に落ち着く傾向にあり、そのように設計された承認当初の機能は酷いもので、肥大化している。表明されたすべてのニーズが多くのC++プログラマにとって(直接・間接的にも)現実的で重要というわけではない。

我々は、最小限の機能からスタートしてフィードバックに基づいて機能を成長させていくべきだ。
「最小限の機能」には、何が基本的で、何がクリーンで、何が不可欠であるのかに焦点を当てる必要がある。 オリジナルのUnixのことを考えてみてほしい(そして、現代の派生と比較してみてほしい)。

ある機能の、最初の最小限のコアから成熟したファシリティへの成長とは、一般化と他の言語・ライブラリの(すでに成長を終えた)ファシリティとの統合でなければならない。間違っても、パッチの上にパッチを重ね続けるような特殊ケースを追加するものであってはならない。
そのようなパッチをしなければならない特殊ケースが発生したとしたら、初期設計に欠陥があったということだ。例えば、(C++標準完成間近のような)土壇場で行われる機能の「改善」などは気がかりだ。

設計において回避不能な不確実性にアプローチするために2つの基本的な方法がある

  • 誰もが役に立つと感じるまで「改善」し続ける
  • 原則的で基本的なものだけが残るまで機能を削ぎ落とす

どちらを選んだとしても、これらのアプローチの後で得られた経験により変更(プレリリース)と追加(リリース後にできるすべてのこと)の必要性が明らかになるだろう。私は疑いもなく2番目のアプローチを支持しており、これこそが原則とフィードバックに基づいた適切なエンジニアリングであると考えている。私は前者をハッキングと政治と考える。

標準化のプロセスには確かに妥協が必要だが、そうした妥協が単に合意が取れなかっただけであるとか機能を肥大化させただけ、となることが無いように保証しなければならない。
ある問題について検討するときは、我々は常に「誰が利益を得るか?」「どのように利益をもたらすか?」「その利益にはどれほど意義があるのか?」を明確にするようにしなければならない。例えばP1700R0 Audience Tablesのように。
また、「誰が新しい問題に苦しむことになるのか?」も明確かつ具体的であること。「平均的なユーザーは〜できない」という主張はどちらも欠けている。

提出された提案の利点は誰にとっても自明でもなければ明白でも無い。その問いに対する最初の回答を用意するのは提案者の仕事である(すべての提案にはコストがかかっていることに注意されたい。委員会の時間、実装、文書化、教育、古い実装や文書の扱いなど)。

通常、目標の設定は機能の詳細設計と実装よりもはるかに困難だ。我々は優れた技術者であり、一度目標が設定されれば必要な作業を行うための理論と経験を持ち合わせている。しかし残念なことに、我々はその目標を明確にしその合意を得ることが得意では無い。多くの場合、そのような合意は複雑な要件に帰着してしまう。

言語設計は製品開発では無く、我々には基本的な優先事項を決定するより高いレベルの管理者が存在していない。所属する会社がそのような事業を行なっている訳でも無い限り、我々の中でこのような(言語設計のような)職場経験を持つ人はほとんど居ない。ここ数年、そのような製品開発のアナロジーによって規格化を進めることが度を越しているように思う。

根拠もデータもそれ自体では決定的では無い。我々は巧妙な根拠で自分を騙すことに長け過ぎていて、データには常に解釈が必要である。学術文献が得意なことの一つは実験によるデータの解釈だ。
我々の経験は必然的に狭く、不完全だ。C++の世界はあまりに広大で、誰もがその全てを知ることはできない。にも関わらず、問題とその解決のスタイルは時間とともに変化していく。

あなたの所属する会社や業界に対する認識は必ずしも正しく無いかもしれず、仮に正しかったとしてもC++コミュニティ全体にとっては決定的では無いかもしれない。 あなたのニーズを完全に満たそうとすることは、C++コミュニティ全体にとって害かもしれない。これは我々委員会のメンバー全員に当てはまる事だ。

いくつもの「完璧な」言語は失敗してきた、注意を怠ればC++もまた失敗するかもしれない。我々には柔軟さと責任感が必要だ。すなわち、我々の設計は世界が変化しても意味を持つようなものでなくてはならない(世界が変化した場合に、ではなく)。 重要な問題に正しく対処しそれが将来の機能改善を妨げないことを100%確約できないため、あらゆる設計にはリスクが伴うことを理解しなければならない。しかし、それが我々を麻痺させるものであってはならない。何もしないこともまた(良きにせよ悪しきにせよ)結果をもたらす。リスクを取ることは不可避であるが、それは意図的で考え抜かれたリスクにしなければならない。

完璧を主張していては進歩できない。機能することが分かっているものに基づいて慎重に初期設計を進め、あとから磨き上げる必要がある。これは、どこに行きたいかについてかなり明確な考えを持っている場合にのみ可能である。そのような大まかな展望が欠けていれば、拡張は単なるハッキングであり、パッチにパッチを重ねることになるだろう。現在稼働している大規模なシステムは、常にいくつかの小さなシステムの仕事の結果の集大成となっている。

設計と改善について完全に自由な選択肢はない。 「世の中」には数十億行のコードがあり、数百万の教科書や人気のブログがあり、多くの古い知識は数百万の頭の中に保存されている。 また、新旧のファシリティが円滑に相互運用されるためには、既存の言語機能と型システムを尊重する必要がある。

言語が安定であることは特徴であると同時に設計上の重大な制約でもある。古いコードや設計アプローチが「進歩」の邪魔になると常にイライラするが、人々はコードが壊れることを本当に嫌う。 多くのコードは非標準の機能、もしくはバグ(コード、コンパイラ、または標準による)と言ってよいほど曖昧な機能に依存しているため、ある程度の破損は避けられない。

私は多くの人の態度を要約できる

  • C++は複雑すぎる。もっと小さく、単純で、きれいにする必要がある。
  • そして、この2つの機能を追加してほしい。
  • そして、何をしたとしても私のコードを壊さないで!

私もそう思うが、もちろんこれは不可能だ。ユーザーは古いバージョンをサポートするコンパイラを要求するため、機能の非推奨化でさえ実際には機能しなかった。主要な機能を廃止することは不可能であり、小さな機能を廃止することは大した益のない面倒なことである。
20年前のC++コードは今日でも実行できる、これはC++を利用する大きな理由でもある。その理由の1つは、今日書いたコードは20年後にも動作するという期待を抱かせるためだ。

互換性は常に過敏な問題である。言語自体が保証できる範囲を超えてシンプルさと正しさを両立するために、コーディングガイドラインと静的解析に注力することをお勧めする。C++コアガイドラインはそのいい例だと思っている。

我々は型安全でリソース安全なC++を書くことができ、そうすべきだ。 言語の進化はこの理想を支えるものでなくてはならない(「The Design and Evolution of C++」や「 Direction for ISO C++」によって文書化されている)。
既存の巨大なコードベースに型安全性とリソース安全性を確保するための手法や新機能を適用するのは非常に困難だが、ひどく互換性のない複数の言語バージョンを扱うよりもはるかに管理しやすい。

通常、実装経験は設計の改善に有効だが、実装作業は設計を凍結する傾向にあるため、早期実装をした場合には代替案や改善は無視されるかもしれない。一般に、基本的要件と原則が明確に記述され(できれば書面で)、主要なユースケースが選択される前に実装してしまうのは賢明ではない。

使用経験の報告は最も価値があるが、取得することは困難であり大規模なものは不可能だ。通常、我々は自分達で選んだ、(通常その道を極めたC++愛好家の)小さなグループの経験で間に合わせなければならない。ただし、実装経験と同様に(単一の小さくまとまったグループのみが関与する場合は特に)初期の経験報告は疑ってかかる必要がある。

「あと2つの機能だけ」を望むのは委員会のメンバーだけではない。300人以上のメンバーで構成される委員会があり、メンバーは皆基本的にC++に入れたい機能を1つか2つは持っていて、多くのメンバーは更にいくつか持っている。 あまりに多くの機能を追加してしまえば「C++が沈む」という意見を私は変えていない(P0977R0 Remember the Vasa!)。実際P0977を書いて以降、新しい提案は洪水のように増加していると感じる。

我々はあまりにも急ぎすぎている。我々は多くのことをするか、もしくは少ないことを速くすることができる。両方を行いながら品質と一貫性を維持することはできはしない。我々はより抑制的で選択的にならなければならない。
全ての設計には長所、短所、および制限がある。可能性のある問題や代替案について真剣かつ誠実に議論しないまま設計を提示してはならない。可能性のある問題について調査するのは提案者の仕事の一つであり、「販売するだけの仕事(提案するだけ)」は知的に誠実ではない。

「対立陣営」の人々によって書かれたコルーチンに関する「賛否両方の立場からの論文」は非常に有益だった(Coroutines: Use-cases and Trade-offsCoroutines: Language and Implementation Impact)。
理想的な提案はいくつかの理論といくつかの実経験の両方を反映したものであり、関連する文献(多くの場合学術的なもの)の考慮もより慎重にならねばならない。主要な提案には常にいくつかの関連文献が記されている。

設計という作業には、様々な懸念事項と原則とのバランスを取ることが伴う。盲目的に従うことのできる絶対的で破ることのできない原則などは存在しない。これが、「The Design and Evolution of C++」において数十の「経験則」をリストアップしている理由だ。

ただ残念なことに、最終的には必ず「嗜好」になり、群衆にはそれがない。しかし、この文書には隠されたチェックリストが含まれているように見えるかもしれない – それもまた、「嗜好」によって解釈される。

モチベーション

この文書を書くきっかけとなった最近の議論には次のものがある

なぜあなたはそんなに確信が持てるのか?

誰かが私にやんわりと指摘するかもしれない:「あなたはしばしば強い主張を表明しており、他の人々があなたの意見を受け入れなかった時には腹を立てていることさえあった」と
もちろん、私は怒りを見せるべきではない。申し訳ない。
それが露になるとき、それは大抵、長年の仕事の後の焦りか、全ての人や議論が同じ基準に立っているわけではないという感覚の結果である。また、長年の仕事の後では、全ての新しい人々に対して寛大であることは困難だ。
怒りを見せないようにするだけでなく怒りを覚えないようにしているが、私は聖人ではないため、こうした問題をいつも気に掛けている。

我々が規格化のためにどのような話題を検討しているのか、私は100%把握してはいない。ただ、100%の把握が論理的に可能だと考えていないので、経験に基づく推測の必要性を受け入れる。これまでのところは、我々/C++はそれほどまずいことをしてはいないようだ。

私がかなりの確信を持つ時、私の信念は通常、何十年もの先公技術、理論、経験、および思想に基づいている。例えば、

  • コンストラクタとデストラクタの組み合わせとRAII
    多くの人にとってそれは1979年にどこからともなく現れたかのように見えていたが、それはOSでの経験に深いルーツを持っており、当時の言語ではこれらの確立された経験を直接表現できなかった私の無力感を反映していた。これはC++の基礎だと考えている。
  • コンセプト
    1980年代半ばに総称型とそのアルゴリズムの引数を指定する方法を検討し始めた。それ以前に、初期のC++で使用されていたマクロベースの手法はスケールしないことに気づき、10年以上に渡って文献を追っていた。
    現在の設計は2003年(当時公開された)およびそれ以前の仕事にルーツを持つ。その仕事には、実験、実装、学術出版、委員会での論文、実使用、教育、などが含まれていた。2015年ごろから後にはユーザ向けの大きな改善は見られなくなった(専門家/詳細実装者向けの改善とは対照的に)。
    コンセプトはテンプレートの設計を完了するために必要であり、ジェネリックプログラミングサポートに必須なものだと考えている。
  • 例外
    この問題をここで詳細に論じるにはあまりに炎上しているが、問題の原因は多数のエラー報告メカニズムにある。議論と研究は少なくとも1974年まで遡る(例えば、P1947R0 C++ exceptions and alternativesを参照)。
  • 契約プログラミング
    様々な形式の不変条件の使用は1970年代半ばのPeter Naur’sの仕事に遡り、多くの現代の思想はBertrand MeyerによるEiffelでの仕事を反映している(それを真似ることとそのいくつかの面に反対することの両方において(例えば、catcallやクラス不変条件))。他にも、アサーションとインターフェース指定によってサポートされる静的解析の仕事にもルーツを持つ。
    一方、「継続」に関する仕事は比較的新しいもので、ほとんどがブルームバーグにおける論証と経験に基づいている。
    C++20での提案が失敗する前にも、2つの試みが失敗していたことに注意されたい。その理由を考えなければならない。
  • ドット演算子
    私のC++においての基本的な目標の一つは(1979年当初からの)、ユーザー定義型と組み込み型の両方を適切にサポートすることだ。例えば、あらゆる面で組み込みのintと同等の整数型を定義可能にしたい(コンパイル時間は例外として)。しかし、publicメンバーを持つ型ではoperator.()がないことから、そのような型の制御方法とアクセス方法に空白が残っている(例えば、シンプルで汎用的なスマート参照プロクシクラスを作成できない)。1980年の最初の拡張提案はoperator.()に関するものだった。
    C++operator.()ないしは同等なもの無くしては完全ではない。
  • 統一的な関数呼び出し
    x.f(y)f(x, y)の表記上の差は、ある操作には常に単一の最重要オブジェクトが存在するというオブジェクト指向の欠陥概念に基づいている。私はその採用を失敗してしまった。当時の理解は浅かった(しかし、非常に流行していた)。それでも、sqrt(2)x + yをその考えが引き起こす問題の例として挙げた。
    ジェネリックプログラミングにおいては、x.f(y)f(x, y)の区別はライブラリ設計とその使用において問題となる(柔軟でない)。コンセプトによって、このような問題は形式化される。
    繰り返しになるが、問題と解決策は数十年も前から存在している。f(x, y, z)において仮装引数を許可すればマルチメソッドが得られる。

契約プログラミング以外のものは、C++がどうあるべきか?ということについての私の長期的な視点である。ただ、契約プログラミングにおける静的解析にまつわる部分はそこに属している。
私は契約プログラミングにおける実行時チェックを嫌ってはいないが、それを長期的な視点の一部として主張することは出来ない。例えば、「The Design and Evolution of C++」では契約プログラミングについて触れていないが、当時から契約プログラミングという物を把握していた。

結論

単純明快な結論を出すことはできない。優れた設計を保証するために誰もが従うことのできる単純なルールのセットが存在するとは思えないためだ。
この論文は知的謙虚さを駆り立てる偉大な経験主義者からの引用に動機付けられた。他の所では、彼は自然の観察によって立証されていない理論的・学術的な概念に対して警告していた。

ある意味ではそれが結論であろう:自分の思う事実やそれに基づく理論を過信してはならない。事実を調査しその事実に調和する理論を構築せよ。

謝辞

この文書の草案についてメーリングリストでの議論に参加してくれた全員に感謝する。

[C++] [[nodiscard]]の言いたいこと

関数の戻り値に対するnodiscard

一番基本的な使い方です。この場合は単に戻り値を捨てないでね?という意思表示をし、それをコンパイラに通知してもらいます、そのままです。

[[nodiscard]] int f() {
  return 0;
}

int main() {
  f();  //コンパイラが警告を出してくれるはず
}

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

型に対するnodiscard

[[nodiscard]]は実は型に対してもつけることができます。

struct [[nodiscard]] S {
  int status;
};

S f() {
  return {};
}


int main() {
  f();  //コンパイラが警告をしてくれるはず
  S{};  //警告してくれるはず・・・
}

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

関数の戻り値となるときには似たような意味を持つことになるのですが、この場合の[[nodiscard]]のお気持ちはSのオブジェクトを捨てないでね?という意味です。

従って、関数からオブジェクトを返さない場合は特にエラーになりません。

struct [[nodiscard]] S {
  int status;
};

const S& f() {
  static S s{};
  return s; //参照を返す
}


int main() {
  f();  //コンパイラは警告しない
}

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

戻り値を捨てて欲しく無いのではなく、Sのオブジェクトを捨てて欲しく無いことを表明するのです。

コンストラクタに対するnodiscard

[[nodiscard]]はコンストラクタに対してもつけることができ(るようになり)ます。

struct S {
  int status;

  [[nodiscard]] S(int st) : status(st) {}

  S() = default;
};

S f() {
  return {};
}

S g() {
  return {1};
}


int main() {
  f();  //コンパイラは警告しない
  g();  //コンパイラが警告をしてくれる・・・と、思うじゃん?

  S{};  //コンパイラは警告しない
  S{1}; //コンパイラが警告をしてくれるはず
}

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

この場合は特定のコンストラクタを通して構築されたときは、そのオブジェクトを捨てないでね?という意味になります。
状況によってはオブジェクトを捨てても良いけど、また状況によっては捨て欲しくないことを表明します。エラーを表現する型やリソースを保持するような型などに利用するといいかもしれません・・・

しかし、上記g()の呼び出しのように関数の戻り値になった時は特に警告してくれないようです、罠っぽいです・・・

ちなみに、C++17時点では[[nodiscard]]はコンストラクタに対して意味を持ちませんでしたが、C++20でこれが修正され明確な意味を持つようになりました。
ただし、C++17に対する欠陥修正であるためC++17にさかのぼって適用されます。しかし、GCC以外はどうやらこれに対応していなかったようなので、実質的に使えるのはC++20からになります。

理由を添えるnodiscard

C++20より、[[nodiscard("Why did you throw it away?")]]のように戻り値ないしはオブジェクトを捨ててほしくない理由を書いておくことができるようになります。
これを利用すると、上記のように暗黙的なお気持ち表明に頼らずに[[nodiscard]]の意図を表明できます。

struct [[nodiscard("Will i die?")]] S {
  int status;
};

[[nodiscard("Look carefully!")]]
int f() {
  return 0;
}

S g() {
  return {};
}


int main() {
  f();  //コンパイラが警告をしてくれるはず
  g();  //コンパイラが警告をしてくれるはず

  S{};  //コンパイラが警告をしてくれるはず
  S{1}; //コンパイラが警告をしてくれるはず
}

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

大変良いので積極的に利用したいですね、早くC++20に移行しましょう!

ちなみに、これを利用するとコンパイラの警告メッセージにかわいいAAを貼れます。まあstatic_assertでもできるので今更です・・・
[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

参考文献

この記事のMarkdownソース

[C++]コンセプトの文脈におけるmeet、satisfyとmodelの使い分けについて

この3つの言葉はどれも、あるコンセプトもしくは制約を満たすという意味で使われています。同じ意味のように思えますが標準ライブラリ中では明確な使い分けがなされています。

old conceptに対するmeet

meetが使われるのはC++17までの名前付き要件を使用しているところです。それはC++20からはCpp17CopyConstructibleのような名前になっています。

これらの名前付き要件はコンセプトのように構文的にチェックされるわけではなく、満たすべき要件は規格書に記述されており、それを使用する所に入ってくる型は暗黙にそれを満たしているものとして扱います。
この制約には型が満たすべき静的なものと、実際の値が満たすべき動的なものの2種類が含まれます。

特にイテレータが絡む所でのForwardIteratorなどがお馴染みでしょうか。

この制約を仮に満たさなかった場合、プログラムはill-formdとなりますが必ずしもそれはコンパイルエラーになるとは限りません。ひょっとしたら実行時にすらエラーとはならないかもしれません。未定義動作の世界です。

これは原初のSTL時代からの制約手法でありますが、C+20以降はコンセプトを使用することが望ましく、標準ライブラリからも次第に姿を消していくでしょう・・・

syntactic conceptに対するsatisfy

satisfyが使われるのはコンセプトを使用している文脈です。
特に、「Constraints: T satisfies C」みたいに使われるようです。これは、(テンプレートパラメータに対する)制約として型TはコンセプトCを満たしていること、のような意味です。

この場合の満たしている(satisfy)とは、そのコンセプトによってチェックされる構文的(Syntactic)な制約を満たしていること、という意味です。

構文的な制約はコンセプトによってコンパイル時にチェックされるため、制約を満たさない場合は必ずコンパイルエラーになります。
このことからもわかるように、構文的な制約にはその型が満たしているべき静的な制約だけが含まれます。

semantic conceptに対するmodel

modelが使われるのはコンセプトを定義、使用している文脈の両方です。

コンセプト定義の文脈では、「T models C only if 条件列」のように使用されます。これは、条件をすべて満たす場合に限り型TはコンセプトCのモデルである、というような意味です。

ライブラリ中で使用される際は、「Preconditions: T models C.」のように使用されます。これは、事前条件として、型TはコンセプトCのモデルであること、というような意味です。

このmodelとは、構文的な制約に加えてそのコンセプトを規定する文書によって指定される意味論的(Semantic)な制約を満たしていること、という意味です。

この意味論的な制約は必ずしもコンパイル時にチェックできるわけではないため、一切チェックされません。仮に満たしていなかったとしてもコンパイルエラーにはならず、もしかしたら実行時にもエラーは出ないかもしれません。
ただし、標準ライブラリにおいてはmodelであることを要求することがあるため、コンセプトを満たす型を定義する場合はそのモデルとなるようにしておくべきです。

このように、意味論的な制約にはその型の値が満たしているべき動的な制約が含まれています。

こうして見ると、C++20からのコンセプトはC++17まで使用していた型及びその値に対する暗黙の要件を構文的なものと意味論的なものに分解したうえで、構文的なものをコンセプトによってコンパイル時にチェックし、意味論的なものはこれまで通り文書で指定し暗黙に要求する、という運用となっていることが分かります。
そして、型がコンセプトのモデルであるとはその両方を満足しているものの事を指しています。

おそらく、型がコンセプトのモデルとなっているかをチェックするのはContractsの役割だったはずです。順調に行っていればC++23の標準ライブラリはModuleでConceptとContractsなものになっていたのかもしれません・・・

実際の利用例

文書で言われてもイメージ付かないので、C++20規格書中の例を見てみましょう。

コンセプト定義例

コンセプトの定義例として、booleanコンセプトの定義を見てます。

まず最初に目に入るのはbooleanの定義そのものでしょうか。

template<class B>
  concept boolean =
    movable<remove_cvref_t<B>> &&       // (see [concepts.object])
    requires(const remove_reference_t<B>& b1,
             const remove_reference_t<B>& b2, const bool a) {
      { b1 } -> convertible_to<bool>;
      { !b1 } -> convertible_to<bool>;
      { b1 && b2 } -> same_as<bool>;
      { b1 &&  a } -> same_as<bool>;
      {  a && b2 } -> same_as<bool>;
      { b1 || b2 } -> same_as<bool>;
      { b1 ||  a } -> same_as<bool>;
      {  a || b2 } -> same_as<bool>;
      { b1 == b2 } -> convertible_to<bool>;
      { b1 ==  a } -> convertible_to<bool>;
      {  a == b2 } -> convertible_to<bool>;
      { b1 != b2 } -> convertible_to<bool>;
      { b1 !=  a } -> convertible_to<bool>;
      {  a != b2 } -> convertible_to<bool>;
    };

意味としては、movableコンセプトを満たしており、かつその次の行以降のrequires式内に書かれている式が使用可能であり、戻り値型がそれぞれの制約を満たすこと、のような意味です。

これが構文的な制約であり、これらの制約は全てコンパイル時にチェックされます。型がこれらの制約を満たさない(satisfyでない)場合はコンパイルエラーとなります。

さて、次にその下の文書に目を向けると次のように書かれています。

For some type B, let b1 and b2 be lvalues of type const remove_­reference_­t<B>. B models boolean only if

  • bool(b1) == !bool(!b1).
  • (b1 && b2), (b1 && bool(b2)), and (bool(b1) && b2) are all equal to (bool(b1) && bool(b2)), and have the same short-circuit evaluation.
  • (b1 || b2), (b1 || bool(b2)), and (bool(b1) || b2) are all equal to (bool(b1) || bool(b2)), and have the same short-circuit evaluation.
  • bool(b1 == b2), bool(b1 == bool(b2)), and bool(bool(b1) == b2) are all equal to (bool(b1) == bool(b2)).
  • bool(b1 != b2), bool(b1 != bool(b2)), and bool(bool(b1) != b2) are all equal to (bool(b1) != bool(b2)).

なんとなく訳せば

Bに対してconst remove_­reference_­t<B>の左辺値として定義する値t, uについて、次の条件をすべて満たしている場合に限って型Bbooleanのモデルである

  • (b1 && b2), (b1 && bool(b2)), (bool(b1) && b2)の式は全て (bool(b1) && bool(b2))と等値であり、短絡評価されるかどうかも一致する。
  • (b1 || b2), (b1 || bool(b2)), (bool(b1) || b2)の式は全て (bool(b1) || bool(b2))と等値であり、短絡評価されるかどうかも一致する。
  • bool(b1 == b2), bool(b1 == bool(b2)), bool(bool(b1) == b2) の式は全て (bool(b1) == bool(b2))と等値である
  • bool(b1 != b2), bool(b1 != bool(b2)), bool(bool(b1) != b2) の式は全て (bool(b1) != bool(b2))と等値である

これがbooleanコンセプトのモデルとなる型が満たしているべき意味論的な制約です。このようにコンセプト定義そのものと分けて書かれており、その内容的にも一つ一つチェックするのは困難であることが分かります。

そして最後に例としてこんなことが書かれています。

The types bool, true_­type ([meta.type.synop]), and bitset<N>​::​reference ([template.bitset]) are boolean types. Pointers, smart pointers, and types with only explicit conversions to bool are not boolean types.

なんとなく訳すと

bool, std::true_type, std::bitset<N>​::​referenceboolean型である。しかし、ポインタ、スマートポインタや明示的にboolに変換できるだけの型はboolean型ではない。

とあります。コンセプト定義に書かれている構文的な制約だけならポインタ等の型でも満たすことはできそうですが、モデルとなるための意味論的な制約は満たすことができないものが含まれています(後ろから2つの条件)。

そして、boolean型であると上げられている型を見るとモデルとなる条件を満たしている事が分かるでしょう。
これらのことから、あるコンセプトのモデルとはそのコンセプトの構文的な制約を満たす型のうち典型的・理想的な型の事であると言え、意味論的な制約はそのコンセプトを満たすならば自然に要求されることを記述しているものであると言えるでしょう(とはいえ要求事項は割とコーナーケースな気もします)。

制約の利用例

次に標準ライブラリでこれらコンセプト、ないしは制約を利用しているところを見てみましょう。C++20から導入されるformatライブラリがこれらの3つの例をすべて確認できるためそれを見てみます。

std::format_to_n関数は全ての例を含んでいるので見てみます。4つあるオーバーロードのうち1つだけコピペしておきます。

template<class Out, class... Args>
  format_to_n_result<Out> format_to_n(Out out, iter_difference_t<Out> n,
                                      string_view fmt, const Args&... args);

まず、テンプレートパラメータに対する制約としてこう書かれています。

Constraints: Out satisfies output_­iterator<const charT&>.

Outoutput_­iterator<const charT&>コンセプトを満たしていること、という意味ですがこの場合は構文的な制約を満たすことしか要求されていません。
このConstraintsな制約はコンセプト機構によってコンパイル時にチェックされます。満たしていなければコンパイルエラーです。

ちなみに、output_­iteratorコンセプトは2引数を取るコンセプトであり、この場合に正確に書くとoutput_­iterator<Out, const charT&>という構文になります。requires式内の戻り値型制約を書くときも同様に第一引数を省略し、その定義文脈から適切な型を第一引数に渡す、というようになっているのでそれに倣っているようです。

次に、事前条件としてこうあります。

Preconditions: Out models output_­iterator<const charT&>,
and formatter<Ti, charT> meets the Formatter requirements ([formatter.requirements]) for each Ti in Args.

Outoutput_­iterator<const charT&>のモデルであり、引数型Args...内の各型Tiについてformatter<Ti, charT>Formatter要件を満たしていること、みたいな意味です。

これによって結局、型Outoutput_­iterator<const charT&>コンセプトの構文的、意味論的な制約の両方を満たしていることが要求されていることが分かります。ただし、意味論的な制約はチェックされず暗黙に満たしているものとして扱われます。満たしていなければ未定義動作です・・・

Formatter要件は長いので割愛しますが、デフォルト構築可能とかコピー可能とかスワップ可能とかの構文的な制約と、そのイテレータの値についての意味論的な制約をごっちゃ煮にした制約が文書で指定されています。
この要件は全て明確にチェックされるものではありません。これもまた満たしていなければ未定義動作です。

参考文献

謝辞

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

この記事のMarkdownソース