yohhoyの日記

技術的メモをしていきたい日記

Concept-basedオーバーロードとSFINAE-unfriendlyメタ関数の落とし穴

C++2a(C++20)で導入されるrequires節を用いた関連制約(associated constraints)により従来SFINAE技法よりも関数テンプレートのオーバーロード制御が容易となるが、戻り値型にSFINAE-unfriendlyなメタ関数を用いるケースでは意図しないコンパイルエラーを引き起こす。

本記事の内容はStackOverflowで見つけた質問と回答に基づく。

下記コードにおいてdouble型の値1.0test関数を呼び出すと、オーバーロード#1の戻り値型導出にて make_signedメタ関数 の必須要件(Mandates)違反によるコンパイルエラーとなる(→id:yohhoy:20200605)。*1

// C++2a
#include <concepts>
#include <type_traits>

// template <typename T>
//   requires std::unsigned_integral<T>と等価
template <std::unsigned_integral T>
auto test(T) -> std::make_signed_t<T>;  // #1

template <typename T>
auto test(T) -> int;  // #2

test(1u);   // OK: #1を選択
test(1.0);  // NG!? (#2を期待)

この振る舞いは 1) 実引数からのテンプレートパラメータ推論とオーバーロード候補除外(SFINAE-based overloading)、2) テンプレートパラメータに依存する戻り値型導出、3) 関連制約に基づくオーバーロード候補除外(Concept-based overloading)という順序に起因している。

前掲コードを古き良きSFINAE技法に書き換えると、オーバーロード#1の戻り値型導出よりも前に候補除外されstd::make_signedメタ関数の必須要件違反は回避される。一方でrequires節に比べると煩雑なコード記述が必要とされる。

#include <concepts>
#include <type_traits>

template <typename T,
          typename = std::enable_if_t<std::unsigned_integral<T>>>
auto test(T) -> std::make_signed_t<T>;  // #1

template <typename T,
          typename = std::enable_if_t<!unsigned_integral<T>>>
auto test(T) -> int;  // #2

test(1u);   // OK: #1を選択
test(1.0);  // OK: #2を選択

StackOverflow回答によれば本件は CWG 2369 にて議論中とのこと。2020年9月現在は最新ステータス非公開...

C++2a DIS(N4861) 13.10.2/p5より引用(下線部は強調)。

The resulting substituted and adjusted function type is used as the type of the function template for template argument deduction. If a template argument has not been deduced and its corresponding template parameter has a default argument, the template argument is determined by substituting the template arguments determined for preceding template parameters into the default argument. If the substitution results in an invalid type, as described above, type deduction fails. [Example:

 template <class T, class U = double>
 void f(T t = 0, U u = 0);

 void g() {
   f(1, 'c');      // f<int,char>(1,'c')
   f(1);           // f<int,double>(1,0)
   f();            // error: T cannot be deduced 
   f<int>();       // f<int,double>(0,0)
   f<int,char>();  // f<int,char>(0,0)
}

-- end example]
When all template arguments have been deduced or obtained from default template arguments, all uses of template parameters in the template parameter list of the template and the function type are replaced with the corresponding deduced or default argument values. If the substitution results in an invalid type, as described above, type deduction fails. If the function template has associated constraints (13.5.2), those constraints are checked for satisfaction (13.5.1). If the constraints are not satisfied, type deduction fails.

関連URL

*1:実際の振る舞いはC++標準ライブラリ内部実装に依存する。処理系によってはオーバーロード#1が ill-formed とならずに、期待通りオーバーロード#2が選択される可能性もある。

memcmp関数 × 宇宙船演算子

C++2a(C++20)で導入される三方比較演算子(three-way comparison operator)<=>(通称:宇宙船演算子(spaceship operator))と、C言語時代の関数インタフェースでよくみられる三方比較結果 “負値/値0/正値” との組合せ利用について。新旧仕様の橋渡し。

例えばmemcmp関数によるメモリブロック比較結果(int型)は、さらに値0と三方比較することでstrong_ordering型の比較結果へと変換できる。

// C++2a
#include <cstring>  // memcmp
#include <compare>

auto memblock_compare(const void* p, const void* q, size_t n)
  -> std::strong_ordering
{
  return std::memcmp(p, q, n) <=> 0;
  // 下記処理に相当
  // int r = std::memcmp(p, q, n);
  // if (r < 0) return std::strong_ordering::less;
  // if (r > 0) return std::strong_ordering::greater;
  // return std::strong_ordering::equal;
}

ノート:C++標準ライブラリstd::basic_string, std::basic_string_view, std::filesystem::pathcompareメンバ関数も三方比較結果を戻り値 “負値/値0/正値” として返すが、C++2aでは<=>演算子オーバーロードも直接提供するため本テクニックの利用機会はないだろう。

関連URL

todo!とunimplemented!の違い

プログラミング言語Rustのtodo!マクロとunimplemented!マクロの違い。

  • todo!:作業途中のコード。あとで実装する。
  • unimplemented!:現行コードでは未実装(実装するとは言っていない)

The difference between unimplemented! and todo! is that while todo! conveys an intent of implementing the functionality later and the message is "not yet implemented", unimplemented! makes no such claims. Its message is "not implemented". Also some IDEs will mark todo!s.

関連URL

複合要件とsame_as/convertible_toコンセプト

C++2a(C++20)コンセプト requires式 に記述する 複合要件(compound-requirement) では「ある式の評価結果が特定コンセプトを満たすこと」を制約するが、このとき標準コンセプトstd::same_asstd::convertible_toを適切に使い分ける必要がある。特に “データメンバ型の制約” には注意すること。

下記コードのコンセプトC0では式t.dataint型へと変換可能(convertible_to<int>)と制約しているが、コンセプトC1のようにint型と等しい(same_as<int>)と制約するとプログラマの期待通りに動作しない。

// C++2a
#include <concepts>

struct S {
  int data;   // int型のデータメンバ
  int& mf();  // int&型を返すメンバ関数
};

template <typename T> concept C0 = requires (T t) {
  // T型の値tはデータメンバdataをもち、int型へと変換可能
  { t.data } -> std::convertible_to<int>;
  // T型の値tはメンバ関数mf()をもち、呼び出し結果はint&型に等しい
  { t.mf() } -> std::same_as<int&>;
};
static_assert( C0<S> );  // OK

template <typename T> concept C1 = requires (T t) {
  // T型の値tはデータメンバdataをもち、"式t.data"はint型に等しい
  { t.data } -> std::same_as<int>;
  // ...
};
static_assert( C1<S> );  // NG: 式t.dataの型はint&

戻り値型を制約する複合要件{ E } -> C<U>;は、2つの制約E; requires C<decltype((E)), U>;*1と等価である。ここでdecltype指定子オペランドが括弧付きの式(E)となることに留意。(→id:yohhoy:20200817

template <typename T> concept C1 = requires (T t) {
  // { t.data } -> std::same_as<int>; と等価な制約
  t.data;
  requires std::same_as<decltype((t.data)), int>;
  // ...
};

クラスのデータメンバ型をsame_asコンセプトで制約する場合は、下記コンセプトC2のように入れ子要件(nested-requirement)を利用する。

template <typename T> concept C2 = requires {
  // メンバT::dataはint型に等しい
  requires std::same_as<decltype(T::data), int>;
  // ...
}
static_assert( C2<S> );  // OK

下記コンセプトC3のように複合要件とsame_asコンセプトを組み合わせた場合、データメンバ型はintまたはint&いずれも制約を満たす。これはsame_asコンセプト利用意図からすると、不適切な制約表現といえる。*2

struct S1 {
  int data;   // int型のデータメンバ
};
struct S2 {
  int& data;  // int&型のデータメンバ
};

template <typename T> concept C3 = requires (T t) {
  { t.data } -> std::same_as<int&>;
};

// OK: データメンバが int型 または int&型 であれば制約を満たす
static_assert( C3<S1> && C3<S2> ); 

N4861 7.5.7.3/p1, 18.4.2, 18.4.4/p1より一部引用。

compound-requirement:
  { expression } noexceptopt return-type-requirementopt ;
return-type-requirement:
  -> type-constraint


A compound-requirement asserts properties of the expression E. Substitution of template arguments (if any) and verification of semantic properties proceed in the following order:

  • Substitution of template arguments (if any) into the expression is performed.
  • If the noexcept specifier is present, E shall not be a potentially-throwing expression (14.5).
  • If the return-type-requirement is present, then:
    • Substitution of template arguments (if any) into the return-type-requirement is performed.
    • The immediately-declared constraint (13.2) of the type-constraint for decltype((E)) shall be satisfied. [Example: Given concepts C and D,
requires {
  { E1 } -> C;
  { E2 } -> D<A1, ..., An>;
};

is equivalent to

requires {
  E1; requires C<decltype((E1))>;
  E2; requires D<decltype((E2)), A1, ..., An>;
};

(including in the case where n is zero). -- end example]

template<class T, class U>
  concept same-as-impl = is_same_v<T, U>;  // exposition only
template<class T, class U>
  concept same_as = same-as-impl <T, U> && same-as-impl <U, T>;

[Note: same_as<T, U> subsumes same_as<U, T> and vice versa. -- end note]

Given types From and To and an expression E such that decltype((E)) is add_rvalue_reference_t<From>, convertible_to<From, To> requires E to be both implicitly and explicitly convertible to type To. The implicit and explicit conversions are required to produce equal results.

template<class From, class To>
  concept convertible_to =
    is_convertible_v<From, To> &&
    requires(add_rvalue_reference_t<From> (&f)()) {
      static_cast<To>(f());
    };

関連URL

*1:単純要件(simple-requirement)と入れ子要件(nested-requirement)の2つで表現される。

*2:ソースコード上は “参照型と等しい” と読み取れる記述がなされ、実際には参照型/値型の両方を許容するコンセプトはトラブルの元になる可能性が高い。

decltype(auto) as non-type template-parameter

非型テンプレートのプレースホルダ型にはautoまたはdecltype(auto)いずれも利用できる。素直にautoを使うべき。

本記事の内容はStackOverflowで見つけた質問と回答に基づく。

template <auto N>
struct S { /*...*/ };
// または
template <decltype(auto) N>
struct S { /*...*/ };

S<42> obj;

C++17 10.1.7.4/p1, p5, 17.1/p4より一部引用(下線部は強調)。

1 The auto and decltype(auto) type-specifiers are used to designate a placeholder type that will be replaced later by deduction from an initializer. The auto type-specifier is also used to introduce a function type having a trailing-return-type or to signify that a lambda is a generic lambda (8.1.5.1). The auto type-specifier is also used to introduce a structured binding declaration (11.5).

5 A placeholder type can also be used in the type-specifier-seq in the new-type-id or type-id of a new-expression (8.3.4) and as a decl-specifier of the parameter-declaration's decl-specifier-seq in a template-parameter (17.1).

4 A non-type template-parameter shall have one of the following (optionally cv-qualified) types:

  • (snip)
  • a type that contains a placeholder type (10.1.7.4).

関連URL

decltype(e)とdecltype((e))のキモチ

プログラミング言語C++における decltype指定子(decltype-specifier) の振る舞いについてメモ。

int x = 41;

// 括弧なしの変数名 x
decltype( x ) y = x;  // int 型
// 括弧付きの式 (x)
decltype((x)) z = x;  // int& 型

一見すると奇妙に思えるdecltype(e)型導出ルールは、「式eの型宣言を探す」という指針に基づいている。

  • 変数名xソースコード上に直接登場する変数x宣言時の型intとなる。
  • (x):式(x)ソースコード上の宣言には現れない。式(x)の左辺値としての性質(lvalueness)を保持する参照型int&となる。*1

C++11言語仕様へのdecltype導入当時の提案文書(PDF) N2115 Decltype (revision 6): proposed wording より一部引用。*2

2.2 Semantics of decltype
Determining the type decltype(e) build on a single guiding principle: look for the declared type of the expression e. If e is a variable or formal parameter, or a function/operator invocation, the programmer can trace down the variable's, parameter's, or function's declaration, and find the type declared for the particular entity directly from the program text. This type is the result of decltype. For expressions that do not have a declaration in the program text, such as literals and calls to built-in operators, lvalueness implies a reference type.

おまけ:括弧付きの変数名のみからなる式(e)の扱いは検討当時も紆余曲折あったようで、(PDF)N1705(rev.4)時点ではdecltype((e))decltype(e)は等価とされていたが、その後のN2115(rev.6)にて現行仕様へと再変更されている。

C++17 10.1.7.2/p4より引用(下線部は強調)。*3

For an expression e, the type denoted by decltype(e) is defined as follows:

  • if e is an unparenthesized id-expression naming a structured binding (11.5), decltype(e) is the referenced type as given in the specification of the structured binding declaration;
  • otherwise, if e is an unparenthesized id-expression or an unparenthesized class member access (8.2.5), decltype(e) is the type of the entity named by e. If there is no such entity, or if e names a set of overloaded functions, the program is ill-formed;
  • otherwise, if e is an xvalue, decltype(e) is T&&, where T is the type of e;
  • otherwise, if e is an lvalue, decltype(e) is T&, where T is the type of e;
  • otherwise, decltype(e) is the type of e.

The operand of the decltype specifier is an unevaluated operand (Clause 8).
[Example:

const int&& foo();
int i;
struct A { double x; };
const A* a = new A();
decltype(foo()) x1 = 17;   // type is const int&&
decltype(i) x2;            // type is int
decltype(a->x) x3;         // type is double
decltype((a->x)) x4 = x3;  // type is const double&

-- end example] [Note: The rules for determining types involving decltype(auto) are specified in 10.1.7.4. --end note]

関連URL

*1:C++文法上は変数宣言時に括弧を記述できるが(例:int (y) = 42;)、この冗長な括弧は単に無視されてdecltypeの振る舞いには影響を与えない。https://gist.github.com/yohhoy/7b63eafcc42e078f34294857698adfd9

*2:C++仕様には後続文書(PDF)N2343採択されている。

*3:1番目のBulletは 構造化束縛(structured binding) に対するルール。構造化束縛で導入される名前の型導出は、通常の変数宣言よりも複雑な規則となっている。

C++コンセプトとド・モルガンの法則

C++2a(C++20)コンセプトでは制約式(constraint-expression)に論理積(&&)/論理和(||)/論理否定(!)を表現できるが、制約式を用いたオーバーロード解決では通常の論理演算で期待される ド・モルガンの法則(De Morgan's laws) は適用されない。*1

超要約:否定演算!を使ったコンセプトには要注意!

// C++2a
#include <concepts>  // integral, same_asコンセプト

template <typename T>
  requires (!std::integral<T>)
void f(T) { /*...*/ }  // #1

template <typename T>
  requires (!std::integral<T> && std::same_as<T, float>)
void f(T) { /*...*/ }  // #2

f(3.14 );  // OK: #1を選択(T=double)
f(3.14f);  // NG: オーバーロード解決が曖昧(T=float)

2番目の関数呼び出し(T=float)では関数fの#1, #2両オーバーロードとも制約式を満たすが、包摂関係(subsumption relation)は成り立たないためオーバーロード解決が曖昧となる。これは#1, #2の制約式に含まれる部分式!std::integral<T>が、それぞれ異なる原始制約(atomic constraint)と解釈されるため。(「・ω・)「

後述例のように、コンセプトnot_integralを介して “同一の式から構成される原始制約” とすれば期待通り動作する。ここでは#2の制約式not_integral<T> && std::same_as<T, float>は#1の制約式not_integral<T>を包摂(subsume)している。(」・ω・)」

// C++2a
#include <concepts>

template <typename T>
concept not_integral = !std::integral<T>;

template <typename T>
  requires not_integral<T>
void f(T) { /*...*/ }  // #1

template <typename T>
  requires not_integral<T> && std::same_as<T, float>
void f(T) { /*...*/ }  // #2

f(3.14 );  // OK: #1を選択(T=double)
f(3.14f);  // OK: #2を選択(T=float)

C++2a DIS n4861 13.5.1.1/p5より引用(下線部は強調)。注:13.7.6.1=Function template overloading, 13.5.4=Partial ordering by constraints

[Note: A logical negation expression (7.6.2.1) is an atomic constraint; the negation operator is not treated as a logical operation on constraints. As a result, distinct negation constraint-expressions that are equivalent under 13.7.6.1 do not subsume one another under 13.5.4. Furthermore, if substitution to determine whether an atomic constraint is satisfied (13.5.1.2) encounters a substitution failure, the constraint is not satisfied, regardless of the presence of a negation operator. [Example:

template <class T> concept sad = false;
template <class T> int f1(T) requires (!sad<T>);
template <class T> int f1(T) requires (!sad<T>) && true;
int i1 = f1(42);  // ambiguous, !sad<T> atomic constraint expressions (13.5.1.2)
                  // are not formed from the same expression

template <class T> concept not_sad = !sad<T>;
template <class T> int f2(T) requires not_sad<T>;
template <class T> int f2(T) requires not_sad<T> && true;
int i2 = f2(42);  // OK, !sad<T> atomic constraint expressions both come from not_sad

template <class T> int f3(T) requires (!sad<typename T::type>);
int i3 = f3(42);  // error: associated constraints not satisfied due to substitution failure

template <class T> concept sad_nested_type = sad<typename T::type>;
template <class T> int f4(T) requires (!sad_nested_type<T>);
int i4 = f4(42);  // OK, substitution failure contained within sad_nested_type

Here, requires (!sad<typename T::type>) requires that there is a nested type that is not sad, whereas requires (!sad_nested_type<T>) requires that there is no sad nested type. --end example] --end note]

関連URL