摘要訊息 : 千呼萬喚始出來的 Concept.

0. 前言

其實 Concept 在很早就希望被引入 C++ 了. 在 C++ 11 那時候, 針對 Concept 就已經有了不少的 提案. 而且當時 C++ 11 確實是打算引入 Concept 的, 只不過後來因為不少原因都被延後了, 直到 C++ 17... 在 Concept 被重新設計之後, C++ 17 本來引入了 Concept, 就像 C++ 20 本來要引入 Contract 是一樣的. 但是後來, 由於各種原因, Concept 進入 C++ 再次被延後, 直到 C++ 20 才正式進入 C++. 在 C++ 20 之前, 佔據主導的是 SFINAE. 但是 SFINAE 的寫法一直不受歡迎. 就連我自己也認為, 這種寫法可以在 C++ 中被選為最不優雅的程式碼之一. 例如, 如果我想要求樣板引入是一個整型型別, 那麼我就需要這樣寫 :

Code 1. 樣板參數中的 SFINAE
#include <type_traits> struct S { int i; }; template <typename T, typename std::enable_if<std::is_integral<T>::value, T>::type * = nullptr> bool operator==(S &s, T t) { return s.i == t; }

即使 C++ 14 引入了 std::enable_if_tstd::is_integral_v 簡化一些, 但是這種寫法仍然非常複雜. 如果我把 SFINAE 不加在樣板參數列表中, 而是加在函式參數列表的 T 中, 我們是不能直接用 a == b 的形式來呼叫上述的多載轉型運算子的, 因為這是一個不可推導語境. 我們曾在文章《【C++ Template Meta-Programming】到處 SFINAE》中介紹過. 此時, 要想呼叫上面這個轉型運算子, 必須主動給定第一個樣板參數, 才能幫助編碼器進行推導 :

Code 2. 函式參數中的 SFINAE
#include <type_traits> struct S { int i; }; template <typename T> bool operator==(S &s, std::enable_if_t<std::is_integral_v<T>, T> t) { return s.i == t; } int main(int argc, char *argv[]) { S s; auto b1 {s == 0}; // Error : invalid operands to binary expression ('S' and 'int'), candidate template ignored: couldn't infer template argument 'T' auto b2 {operator==<int>(s, 0)}; // OK }

如果我們不小心寫出了 s == 0.0 這樣的判斷式, 並且在引入標頭檔 <iostream> 的時候, Apple Clang 13.1.6 給出的編碼錯誤是這樣的 :

Untitled 2.cpp:13:10: error: invalid operands to binary expression ('S' and 'double')
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__variant/monostate.h:43:16: note: candidate function not viable: no known conversion from 'S' to 'std::monostate' for 1st argument
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/system_error:391:1: note: candidate function not viable: no known conversion from 'S' to 'const std::error_code' for 1st argument
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/system_error:398:1: note: candidate function not viable: no known conversion from 'S' to 'const std::error_code' for 1st argument
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/system_error:398:1: note: candidate function (with reversed parameter order) not viable: no known conversion from 'S' to 'const std::error_condition' for 1st argument
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/system_error:406:1: note: candidate function not viable: no known conversion from 'S' to 'const std::error_condition' for 1st argument
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/system_error:406:1: note: candidate function (with reversed parameter order) not viable: no known conversion from 'S' to 'const std::error_code' for 1st argument
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/system_error:413:1: note: candidate function not viable: no known conversion from 'S' to 'const std::error_condition' for 1st argument
Untitled 2.cpp:8:6: note: candidate template ignored: requirement 'std::is_integral<double>::value' was not satisfied [with T = double]
Untitled 2.cpp:8:6: note: candidate template ignored: requirement 'std::is_integral<S>::value' was not satisfied [with T = S]
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__utility/pair.h:321:1: note: candidate template ignored: could not match 'pair<type-parameter-0-0, type-parameter-0-1>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__iterator/wrap_iter.h:169:6: note: candidate template ignored: could not match '__wrap_iter<type-parameter-0-0>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__iterator/wrap_iter.h:176:6: note: candidate template ignored: could not match '__wrap_iter<type-parameter-0-0>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__iterator/wrap_iter.h:176:6: note: candidate template ignored: could not match '__wrap_iter<type-parameter-0-0>' against 'double'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/tuple:1297:1: note: candidate template ignored: could not match 'tuple<type-parameter-0-0...>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/tuple:1297:1: note: candidate template ignored: could not match 'tuple<type-parameter-0-0...>' against 'double'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__memory/allocator.h:244:6: note: candidate template ignored: could not match 'allocator<type-parameter-0-0>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__memory/allocator.h:244:6: note: candidate template ignored: could not match 'allocator<type-parameter-0-0>' against 'double'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__memory/unique_ptr.h:572:1: note: candidate template ignored: could not match 'unique_ptr<type-parameter-0-0, type-parameter-0-1>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__memory/unique_ptr.h:572:1: note: candidate template ignored: could not match 'unique_ptr<type-parameter-0-0, type-parameter-0-1>' against 'double'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__memory/unique_ptr.h:608:1: note: candidate template ignored: could not match 'unique_ptr<type-parameter-0-0, type-parameter-0-1>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__memory/unique_ptr.h:608:1: note: candidate template ignored: could not match 'unique_ptr<type-parameter-0-0, type-parameter-0-1>' against 'double'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__memory/unique_ptr.h:616:1: note: candidate template ignored: could not match 'unique_ptr<type-parameter-0-0, type-parameter-0-1>' against 'double'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__memory/unique_ptr.h:616:1: note: candidate template ignored: could not match 'unique_ptr<type-parameter-0-0, type-parameter-0-1>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__memory/shared_ptr.h:1121:1: note: candidate template ignored: could not match 'shared_ptr<type-parameter-0-0>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__memory/shared_ptr.h:1121:1: note: candidate template ignored: could not match 'shared_ptr<type-parameter-0-0>' against 'double'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__memory/shared_ptr.h:1175:1: note: candidate template ignored: could not match 'shared_ptr<type-parameter-0-0>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__memory/shared_ptr.h:1175:1: note: candidate template ignored: could not match 'shared_ptr<type-parameter-0-0>' against 'double'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__memory/shared_ptr.h:1183:1: note: candidate template ignored: could not match 'shared_ptr<type-parameter-0-0>' against 'double'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__memory/shared_ptr.h:1183:1: note: candidate template ignored: could not match 'shared_ptr<type-parameter-0-0>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/variant:1613:16: note: candidate template ignored: could not match 'variant<type-parameter-0-0...>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__iterator/istreambuf_iterator.h:96:6: note: candidate template ignored: could not match 'istreambuf_iterator<type-parameter-0-0, type-parameter-0-1>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__iterator/istream_iterator.h:84:1: note: candidate template ignored: could not match 'istream_iterator<type-parameter-0-0, type-parameter-0-1, type-parameter-0-2, type-parameter-0-3>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__iterator/move_iterator.h:105:1: note: candidate template ignored: could not match 'move_iterator<type-parameter-0-0>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__iterator/move_iterator.h:105:1: note: candidate template ignored: could not match 'move_iterator<type-parameter-0-0>' against 'double'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__iterator/reverse_iterator.h:154:1: note: candidate template ignored: could not match 'reverse_iterator<type-parameter-0-0>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__iterator/reverse_iterator.h:154:1: note: candidate template ignored: could not match 'reverse_iterator<type-parameter-0-0>' against 'double'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__functional/function.h:1215:1: note: candidate template ignored: could not match 'function<type-parameter-0-0 (type-parameter-0-1...)>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__functional/function.h:1215:1: note: candidate template ignored: could not match 'function<type-parameter-0-0 (type-parameter-0-1...)>' against 'double'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__functional/function.h:1220:1: note: candidate template ignored: could not match 'function<type-parameter-0-0 (type-parameter-0-1...)>' against 'double'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__functional/function.h:1220:1: note: candidate template ignored: could not match 'function<type-parameter-0-0 (type-parameter-0-1...)>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/string_view:679:6: note: candidate template ignored: could not match 'basic_string_view<type-parameter-0-0, type-parameter-0-1>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/string_view:688:6: note: candidate template ignored: could not match 'basic_string_view<type-parameter-0-0, type-parameter-0-1>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/string_view:688:6: note: candidate template ignored: could not match 'basic_string_view<type-parameter-0-0, type-parameter-0-1>' against 'double'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/string_view:697:6: note: candidate template ignored: could not match 'basic_string_view<type-parameter-0-0, type-parameter-0-1>' against 'double'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/string_view:697:6: note: candidate template ignored: could not match 'basic_string_view<type-parameter-0-0, type-parameter-0-1>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/string:584:6: note: candidate template ignored: could not match 'fpos<type-parameter-0-0>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/string:4068:1: note: candidate template ignored: could not match 'basic_string<type-parameter-0-0, type-parameter-0-1, type-parameter-0-2>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/string:4080:1: note: candidate template ignored: could not match 'basic_string<char, std::char_traits<char>, type-parameter-0-0>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/string:4099:1: note: candidate template ignored: could not match 'const _CharT *' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/string:4099:1: note: candidate template ignored: could not match 'const _CharT *' against 'double'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/string:4112:1: note: candidate template ignored: could not match 'basic_string<type-parameter-0-0, type-parameter-0-1, type-parameter-0-2>' against 'S'
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/string:4112:1: note: candidate template ignored: could not match 'basic_string<type-parameter-0-0, type-parameter-0-1, type-parameter-0-2>' against 'double'

所以 Concept 的引入除了需要簡化程式碼的寫法之外, 還有另一個目標就是希望簡化編碼錯誤.

本文的目錄過長, 可能影響前面章節的閱讀體驗, 故本篇文章的目錄預設為隱藏不展開狀態, 需要閣下手動展開.

更新紀錄 :

  • 2022 年 6 月 13 日進行第一次更新和修正.

1. 基本概念

1.1 關鍵字

Concept 中引入了兩個新的關鍵字 : conceptrequires, 這兩個關鍵字都是 C++ 20 之前沒有的.

1.2 定義

要定義一個 Concept 非常簡單 :

Code 3. Concept 定義
template <template-parameters> concept concept-name = constraint-expression;

其中, template-parameters 是樣板參數列表, concept-name 為 Concept 的名稱, constraint-expression 為制約表達式. 制約表達式必須可以在編碼期間被計算, 其結果為一個布林常數, 在下面的小節中, 我們將詳細介紹制約表達式. 在這裡, 大家只需要了解 truefalse 是最簡單的制約表達式 :

Code 4. 制約表達式
template <typename> concept C1 = true; template <typename T> concept C2 = sizeof(T) > 0;

這裡需要注意的是, 任意一個 Concept 必定是一個樣板. 也就是說, concept C = true; 這樣的 Concept 是錯誤的.

所有樣板的限制在 Concept 上也可以體現出來. 例如 Concept 不能在函式之內被定義, 因為樣板就不能被定義在函式之內, 甚至連宣告都不行. 除了這個限制之外, 一個 Concept 不能被特製化, 包括偏特製化 :

Code 5. Concept 不能被特製化
template <typename T> concept C = true; template <> concept C<int> = false; // Error : name defined in concept definition must be an identifier

一個 Concept 的結果是一個布林常數表達式, 並且是一個純右值. 因此, 就可以這樣使用 Concept :

Code 6. Concept 的簡單用法
template <typename T> concept C = true; constexpr auto b {C<int>}; // OK static_assert(C<char>); // OK static_assert(not C<void *>, "static assert failed!") // Error : static assert failed!

於類別樣板和函式樣板都可以被宣告而暫不定義, 但是 Concept 不能被僅宣告, 只要它出現的地方必定是一個 Concept 的定義, 否則就會產生編碼錯誤. 另外, Concept 在自己的定義是無法找到自己的名稱的 :

Code 7. 遞迴的 Concept
template <typename T> concept C = C<T *>;

這也是由 Concept 無法特製化的性質決定的. 否則的話, Concept 的遞迴就無法結束. 換句話說, Concept 無法遞迴.

2. 進階

2.1 制約 Constraint

制約是隨著 Concept 一起引入的, 一般來說它和 Concept 是一起出現的. 制約是指針對樣板的制約. Concept 的引入就是為了制約樣板, 然而 C++ 20 還提供了另外一種方式來制約樣板, 也就是 requires 表達式.

2.1.1 requires 表達式

requires 表達式是制約表達式中的一種. 一個 requires 表達式的組成如下 :

Code 8. requires 表達式
requires(requires-parameters) { requirement-sequences; }

其中, requires-parametersrequires 表達式的參數, 這個參數並不是樣板參數, 而是類似於函式參數 (或者說, 它幾乎等同於函式參數), 這個參數列表是可選的; requirement-sequences 是一系列的需求, 它至少包括了一個需求, 也就是說 requires 表達式不能為空. 需要注意的是, requires 表達式中的需求序列都處於不可估量語境 (參考《【C++】Lambda 表達式合集》第 7 節) 中.

一個 requires 表達式的結果就是純右值的布林常數表達式, 也就是說它是一個制約表達式. 那麼自然地, requires表達式就可以出現在 Concept 中 :

Code 9. Concept 中的 requires 表達式
template <typename> concept C = requires { // ... };

需求序列包含了簡單需求, 型別需求, 複合需求和巢狀需求.

2.1.1.1 簡單需求

簡單需求是指檢查一個表達式. 編碼器會檢查這個表達式在當前的語境中是否合法. 換句話說, 假如這個表達式如果出現在實際應用中, 編碼器就要去檢測會不會產生編碼錯誤 :

Code 10. 簡單需求
template <typename T, typename U> concept C = requires(T t, U u) { t + u; };

這裡並不是去計算 t + u 的值, 因為一個 Concept 不可能被呼叫, tu 的值永遠是未知的 (這就是為甚麼 requires 表達式中的需求序列都是處於不可估量語境中的原因). 因此, 這裡只能檢查 t + u 是否合法. 如果表達式 t + u 可以產生結果, 那麼 requires 表達式的結果就是 true; 否則, 這個 requires 表達式的結果為 false. 這裡需要說明的是, 如果某個名稱到 Concept 處沒有出現, 那麼這也不會直接導致編碼錯誤 :

Code 11. 檢查函式是否存在
#include <utility> template <typename T> concept C = requires(T &&t) { func(std::forward<T>(t)); }; static_assert(C<int>); // Error : static_assert failed, because 'int' does not satisfy 'C', because 'func(std::forward<T>(t))' would be invalid: use of undeclared identifier 'func'

編碼器不會真的嘗試去呼叫函式 func, 而是嘗試去匹配 func, 看看能不能找到這個函式. 如果匹配失敗, 並不會產生編碼錯誤, 而是讓 requires 表達式回傳一個 false 的結果. 但是對於變數就沒有那麼幸運了 :

Code 12. requires 表達式無法檢測變數是否存在
template <typename> concept C = requires { ++some_var; // Error : use of undeclared identifier 'some_var' };

如果要針對外部的變數進行操作, 那麼編碼器會首先尋找那個變數是否存在. 如果變數不存在, 就會直接產生編碼錯誤, 而不是 requires 表達式回傳 false. 不過, 在這裡並不要求這個變數一定是編碼期可計算的變數.

2.1.1.2 型別需求

型別需求是檢查一個型別是否合法 :

Code 13. 型別需求
template <typename T> using reference = T &; template <typename T> struct S; template <typename T> concept C = requires { typename T::type; // 檢查型別 T 內部是否有一個名為 type 的型別 typename reference<T>; // 檢查型別 reference<T> 是否合法, 即 T & 是否存在 typename S<T>; // 檢查型別 S<T> 是否合法 typename T; // 檢查型別 T 本身是否合法, 這個檢查結果永遠為 true };

要檢查一個型別是否合法, 前面必須要有 typename. 如果缺失 typename, 就變成了檢查型別內部是否存在某個可存取的成員. 另外, 通過 Code 13 中的 typename S<T>; 這一需求, 我們知道, 在 requires 表達式中, 並不一定要求型別是完整型別, 可以是未實作的型別.

2.1.1.3 複合需求

複合需求稍微複雜一些, 也是一種新的語法 : {expression} noexcept -> Concept;. 其中, expression 是一個表達式, 使用大括號囊括, noexcept-> Concept 是可選的. 我們通過下面實例來解釋 :

Code 14. 複合需求
template <typename T, typename U> struct is_same { constexpr static auto value {false}; }; template <typename T> struct is_same<T, T> { constexpr static auto value {true}; }; template <typename T, typename U> concept same_as = is_same<T, U>::value; template <typename T> concept C = requires { {T {}.f()}; // 等同於簡單需求 T {}.f(); {T {}.g()} noexcept -> same_as<int>; };

首先, requires 表達式檢查 T {}.f() 這個函式呼叫是否合法, 對於 T {}.g() 也是類似的. 編碼器對 T {}.g() 除了檢查函式呼叫是否合法之外, 會再檢查這個表達式是否是不擲出任何例外情況的並且檢查表達式的回傳結果是否滿 same_as, 即能否讓 same_as 回傳 true. 只有所有的檢查都滿足的時候, 複合需求 {T {}.g()} noexcept -> same_as<int>; 才會回傳 true; 如果有一項不滿足, 則複合需求的回傳結果為 false.

在一開始, 尾置回傳型別的檢查不要求必須是 Concept, 也可以是一個型別 T :

Code 15. 最開始的複合需求
template <typename T> concept C = requires { {T {}.func()} -> int; };

也就是檢查 T {}.func() 是否合法並且回傳值是否為 int 或者可以通過隱含性別轉換轉型為 int. 這樣的寫法本來是可以的. 但是, 這種寫法有一個歧異 : 如果我想要 T {}.func() 的結果必須是 int, 而不是通過隱含型別轉化而轉型到 int; 又或者說, 我想要 T {}.func() 的結果可以通過隱含型別轉化轉型為 int, 而並不一定是 int. 這種寫法是無法消除這樣的歧異的. 曾經有人提議引入新的運算子 => 來解決, 但是這會增加 C++ 語言的複雜度, 也沒有必要為了這麼小的一個特性就引入新的運算子. 因此, 最終兩個 C++ 20 提案 P1084R2P1452R2 決定移除這種寫法, 僅保留使用 Concept 制約回傳型別那種寫法.

現在, 如果我要求回傳型別必須是 int, 我可以這樣寫 :

Code 16-1. 無隱含型別轉換的需求
template <typename T> concept C = requires { {T {}.func()} -> same_as<int>; };

如果我要求回傳型別可以通過隱含型別轉化轉型為 int, 我可以這樣寫 :

Code 16-2. 有隱含型別轉換的需求
#include <type_traits> template <typename T, typename U> concept convertible_to = std::is_convertible_v<T, U>; template <typename T> concept C = requires { {T {}.func()} -> convertible_to<int>; };

當然, 也不一定必須使用這種方法 :

Code 16-3. 另一種寫法
template <typename T> concept C = requires(T (*fp)(int)) { fp(T {}.func()); };

2.1.1.4 巢狀需求

對於一些有能力在編碼器計算的表達式, 我們希望它們被放入 requires 表達式中進行判斷, 就像

Code 17. 檢查型別大小的 Concept
template <typename T> concept C = sizeof(T) == 4;

這樣 C 就有能力檢查給定的型別 T 的大小是否為 4. 如果直接把 sizeof(T) == 4 放入 requires 表達式中呢?

Code 18. 把型別大小的檢查放入 requires 中
template <typename T> concept C = requires { sizeof(T) == 4; }; static_assert(C<char>); // OK

顯然, 這個結果並不是我們想要的. 不過, 這也並不完全出乎意料. 正所謂 requires 簡單需求都處於不可估量語境中, 編碼器只是檢查表達式 sizeof(T) == 4 是否合法, 不會真正去計算 sizeof(T) 是否為 4. 因此, 上面的程式碼可以通過編碼是在意料之內的. 那麼, 如何讓 requires 表達式有能力檢查表達式的計算結果呢? 要在 requires 表達式中使用判斷式, 需要借助 requires 的幫助, 即巢狀需求 : requires constraint-expression;. 其中, constraint-expression 為制約表達式. 巢狀需求會去計算 constraint-expression 中表達式的結果是否滿足相應的制約. 現在, 如果我們想要檢查表達式的計算結果, 我們可以這樣去寫 :

Code 18. 巢狀 requires
template <typename T> concept C = requires { requires sizeof(T) == 4; }; static_assert(C<char>); // Error : static_assert failed, because 'char' does not satisfy 'C', 'sizeof(char) == 4' (1 == 4) evaluated to false

巢狀需求本來還可以更加複雜 :

Code 19. 複雜的巢狀 requires
template <typename T> concept C = requires { requires requires(T t) { ++t; }; }; template <typename T> requires requires(T t) { ++t; } void func();

如果僅僅把 requires requires(T t) 改成了 requires(T t), 那麼只是檢查裡面的 requires 表達式是否合法. 而所有的 requires 表達式只要不擲出編碼錯誤, 就必定是合法的. 因此, C++ 20 提案 P2092R0《Disambiguating Nested-Requirements》 提出移除這種寫法. 也就是說, 現在上面這種寫法是可能產生編碼錯誤的. (說可能產生編碼錯誤是因為截止發文為止, Clang 還沒有實作 P2092R0 中的內容, 因此在 Clang 中這種寫法只會得到警告. 不過相信在不久的將來, 在 Clang 支援之後, 這種寫法在 Clang 中是不被支援的. 在 GCC 中, 這種寫法已經會導致編碼器擲出編碼錯誤).

巢狀需求除了可以檢查 requires 表達式之外, 還可以檢查具有編碼期計算能力的函式和變數的能力 :

Code 20. 使用巢狀需求檢查具有編碼器計算能力的函式
#define ONE 1 enum E { E1, E2, E3 }; constexpr auto a {0}; constexpr int f1() noexcept { return 1; } consteval int f2() noexcept { return 2; } constexpr auto b {f1() + f2() + ONE + E1 - E2}; template <typename> concept C = requires { requires a == 0; requires f1() == 1; requires f2() == 2; requires b == 3; }; static_assert(C<int>); // OK

2.1.2 制約樣板

2.1.2.1 使用 Concept 制約樣板

Concept 的結果雖然是一個純右值的布林常數表達式, 但是我們可以將 Concept 放到樣板中來制約樣板 :

Code 21. 使用 Concept 制約樣板參數
#include <type_traits> template <typename T> concept Integral = std::is_integral_v<T>; template <typename T, Integral U> void func(const T &, U);

上述 Code 21 中, 我們使用 Integral 制約了函式樣板 func 中的樣板參數 U. 這個制約的意思為我們要求任意給定到樣板參數 U 的型別引數必須是一個整形型別, 如果這個制約不被滿足, 那麼函式將會從函式多載的候選集合中被排除 :

Code 22. Concept 與 SFINAE
#include <iostream> template <typename T> concept Integral = is_integral_v<T>; template <typename T, Integral U> void func(const T &, U) { std::cout << "constrained template parameter U by concept Integral" << std::endl; } template <typename T, typename U> void func(const T &, const U &) { std::cout << "typename U" << std::endl; } int main(int argc, char *argv[]) { func(0, 0.0); // 輸出 : typename U func(0, 0); // 輸出 : constrained template parameter U by concept Integral }

這種寫法比使用 SFINAE 要方便得多, 但是本質上它仍然是 SFINAE, 制約不滿足不會導致編碼器直接擲出編碼錯誤, 而是會繼續嘗試尋找可以匹配的函式. 這種制約不但在函式樣板中有效, 在類別樣板中同樣有效 :

Code 23. 類別樣板參數中的制約
#include <iostream> template <typename T> concept Integral = is_integral_v<T>; template <typename T> struct S { S() { std::cout << "default" << std::endl; } }; template <Integral T> struct S<T> { S() { std::cout << "Integral" << std::endl; } }; int main(int argc, char *argv[]) { S<int> s1; // 輸出 : Integral S<float> s2; // 輸出 : default }

而在 C++ 20 之前, 類別 S 的實作則要複雜得多 :

Code 24. C++ 20 之前的實作方式
#include <type_traits> template <typename T, bool> struct S_impl { S_impl(); }; template <typename T> struct S_impl<T, true> { S_impl(); }; template <typename T> struct S : S_impl<T, std::is_integral_v<T>> {};

2.1.2.2 使用 requires 表達式制約樣板

第 2.1.2.1 節中討論的方法只是制約的一種方法, 要想制約樣板還有另外一種方法, 即使用 requires 表達式. 在函式樣板中, 我們有多種方法使用 requires 表達式制約樣板 :

Code 25. 使用 requires 表達式制約樣板
#include <type_traits> template <typename T> concept Integral = std::is_integral_v<T>; template <typename T> requires is_integral_v<T> void f1(); template <typename T> requires Integral<T> void f2(); template <typename T> void f3() requires std::is_integral_v<T>; template <typename T> void f4() requires Integral<T>;

也就是說在函式樣板中, 我們可以直接將 requires 表達式寫在樣板參數列表或者函式參數列表之後. 除此之外, requires 之後可以不跟隨一個布林常數表達式, 而跟隨一個 Concept, 我們稱這種 requires 表達式為為 requires 條款 (requires clause), 它也是制約表達式的一種.

需要注意的是, requires 表達式可以出現在函式的宣告或者定義中, 但是不能出現在和函式相關的型別, 變數或者參數中. 對於尾置回傳型別的函式, requires 表達式要求出現在尾置回傳型別之後. 虛擬函式由於本身不可被樣板化, 也就不能對虛擬函式使用 requires 表達式 :

Code 26. 使用 requires 表達式制約樣板的限制
void (*fp)() requires true {nullptr}; // Error : trailing requires clause can only be used when declaring a function void f(void () requires true); // Error : trailing requires clause can only be used when declaring a function auto p {new (void (*)() requires true)}; // Error : expected expected ')' auto f1() requires false -> bool; // Error : trailing return type must appear before trailing requires clause auto f2() -> bool requires false; // OK struct S { virtual void func() requires true; // Error : virtual function cannot have a requires clause };

對於類別樣板來說, requires 表達式只能出現在樣板參數列表之後. 除此之外, 類別樣板要求樣板中的制約也要嚴格匹配 :

Code 27. 類別樣板中的 requires 表達式制約
#include <type_traits> template <typename> concept C = true; template <typename T> requires std::is_integral_v<T> struct S { void func(); void g() requires C<T>; template <C U> void h(); template <C U> requires true struct X; }; template <typename T> // Error : requires clause differs in template redeclaration void S<T>::func() {} template <typename T> requires std::is_integral_v<T> // OK void S<T>::func() {} template <typename T> requires std::is_integral_v<T> void S<T>::g() {} // Error : out-of-line declaration of 'g' does not match any declaration in 'S<T>' template <typename T> requires std::is_integral_v<T> void S<T>::g() requires C<T> {} // OK template <typename T> requires std::is_integral_v<T> template <typename U> // Error : out-of-line definition of 'h' does not match any declaration in 'S<T>' void S<T>::h() {} template <typename T> requires std::is_integral_v<T> template <C U> // OK void S<T>::h() {} template <typename T> requires std::is_integral_v<T> template <typename U> // Error : type constraint differs in template redeclaration struct S<T>::X {}; template <typename T> requires std::is_integral_v<T> template <C U> requires true // OK struct S<T>::X {};

2.1.3 制約對函式和類別的影響

2.1.3.1 函式樣板制約的本質

對函式加入制約之後, 對函式的匹配機制產生了一些影響. 前面我們已經提到過, 加入了制約的函式樣板本質上是 SFINAE, 而並非是函式多載. 也就是說, 樣板參數被制約的函式樣板不能算為函式多載. 除此之外, 相同的函式的宣告或者定義的過程中, 我們要求制約表達式相同. 由於制約不對函式型別產生影響, 所以就導致了有時候可能產生模稜兩可的呼叫 :

Code 28. 帶有制約的函式宣告
template <typename T> requires (sizeof(T) == 4) void func() = delete; template <typename T> requires (sizeof(T) >= 4) void func() {} int main(int argc, char *argv[]) { func<int>(); }

Code 28 中, 就有多個可以匹配的函式, 於是產生了模稜兩可的呼叫, 編碼器會擲出編碼錯誤. 因為編碼器並不知道選擇哪一個函式比較好, 即使第一個函式被設定為被刪除的函式.

2.1.3.2 最佳滿足原則

Code 29. 最終的輸出是什麼?
#include <iostream> template <typename> concept C = true; template <typename T> requires C<T> void func() { std::cout << "requires C<T>" << std::endl; } template <typename T> requires (C<T> and true) void func() { std::cout << "requires C<T> and true" << std::endl; } int main(int argc, char *argv[]) { func<int>(); }

很顯然, 如果把 Code 29 中的 func 視為函式多載, 那麼在函式多載中, 兩個函式樣板 func 都是等效的, 編碼器並無法決定哪一個函式更好. 但是在制約中, 編碼器會選中那個更加滿足制約的函式. 也就是說, 如果滿足的制約越多, 那麼這個函式就是越好的匹配. 在 requires C<T> 中, 我們僅要求 T 滿足 C; 然而, 在 requires (C<T> and true) 中, 我們不但要求其滿足 C, 後面還結合了 true. 因此, 第二個滿足的制約更多. 理所應當地, 第二個函式成為了被選中的呼叫函式. 那麼第一個函式什麼時候可以被選中呢? 在第二個函式一直在可視範圍內的情況下, 第二個函式永遠比第一個函式滿足的制約更多, 因此第一個函式永遠不會被選中.

對於都帶有制約的相同函式, 編碼器會根據最佳滿足原則, 選取滿足更多制約的那一個函式, 也就是選擇最受制約的那一個. 對於類別樣板特製化的匹配來說, 帶有制約的類別在匹配的過程中同樣滿足最佳滿足原則 :

Code 30. 類別中的最佳滿足原則
#include <iostream> template <typename> concept C = true; template <typename> struct S { S() { std::cout << "default" << std::endl; } }; template <typename T> struct S<T *> { S() { std::cout << "pointer" << std::endl; } }; template <C T> struct S<T> { S() { std::cout << "concept" << std::endl; } }; template <C T> requires true struct S<T> { S() { std::cout << "concept with requires" << std::endl; } }; int main(int argc, char *argv[]) { S<int *> s1; // 輸出 : pointer S<int> s2; // 輸出 : concept with requires }

對於指標型別來說, S<T *>, S<C T>S<C T> requires true 都滿足匹配, 但是顯然 S<T *> 滿足的型別範圍比 S<C T>S<C T> requires true 要更少, 因此 S<T *> 更加匹配. 而對於一般型別來說, S<T>S<C T> 永遠不會被匹配, 因為 S<C T> requires true 比它們滿足的制約更多. 因此, 根據最佳匹配原則, 對於其它任意型別, S<C T> requires true 永遠是匹配的第一選擇.

2.1.3.3 友誼函式

對於普通的非樣板函式, 我們可以將制約使用 requires 表達式添加在函式的參數列表之後. 但是針對友誼函式來說, 非樣板的友誼函式不能帶有任何制約.

2.1.3.4 制約 Concept

很遺憾, 由於 Concept 無法遞迴, 所以在 C++ 20 中任何嘗試制約 Concept 樣板參數的方法都是不允許的 :

Code 31. 直接制約 Concept 並不允許
template <typename> concept C1 = true; template <C1> // Error : concept cannot have associated constraints concept C2 = true; template <typename> requires true // Error : concept cannot have associated constraints concept C3 = true;

但是, 我們可以通過實作另一個 Concept, 並且將其加入制約 :

Code 32. 多重 Concept
#include <iostream> using namespace std; template <typename> concept C1 = true; template <typename T> concept C2 = C1<T> and true; template <C1 T> void func() { cout << "C1" << endl; } template <C2 T> void func() { cout << "C2" << endl; } int main(int argc, char *argv[]) { func<int>(); //輸出 : C2 }

顯然, C2 滿足的制約更多, 因此在函式匹配的過程中會選擇第二個函式 func顯然, C2 滿足的制約更多, 因此在函式匹配的過程中會選擇第二個函式 func.

說一個個人的設想 : 我其實比較希望允許 Concept 的遞迴和特製化. 那樣, 樣板超編程會變得更加有趣.

2.1.3.5 巢狀類別

對於巢狀類別來說, 如果其制約未被滿足, 那麼在它沒有被具現化之前, 編碼器無需對此發出任何警告, 甚至標準都不要求編碼器對此進行診斷 :

Code 33. 巢狀類別中的制約
template <typename> struct S { template <typename T> requires false // 可以通過編碼, 但是這個巢狀類別不能被具現化 struct inner1 {}; template <typename T> requires (sizeof(T [-sizeof(T)]) > 1) // 同樣可以通過編碼, 但是這個巢狀類別不能被具現化 struct inner2 {}; };

但是如果進行具現化, 那麼就會產生編碼錯誤, 因為 S::inner1S::inner2 的制約永遠不會被滿足.

2.1.4 requires 表達式的參數列表

參數列表中對 Concept 和 requires 表達式存在一些限制 :

  • 函式的參數列表中並不支援直接使用 Concept;
  • requires 表達式的參數列表不支援使用 Concept;
  • auto 不支援在 requires 表達式的參數列表中使用;
  • C 風格可變參數也是不允許出現在 requires 表達式參數列表的最後的 (如果要使用可變參數, 就需要使用 C++ 11 的可變參數樣板);
  • requires 表達式的參數列表中的任何參數都不允許帶有預設引數.
Code 34. 參數列表中 Concept 和 requires 的限制
#include <utility> template <typename> concept C1 = true; template <typename T> concept C2 = requires(C1<T> t) { // Error ++t; }; template <typename T> concept C3 = requires(auto t) { // Error : 'auto' not allowed in requires expression parameter t++; }; template <typename T> concept C4 = requires(T t, ...) { // Error : varargs not allowed in requires expression t + 1; }; template <typename F, typename ...Args> concept C5 = requires(F f, Args &&...args) { // OK f(std::forward<Args>(args)...); }; template <typename T> concept C6 = requires(T t = {}) { // Error : default arguments not allowed for parameters of a requires expression *t; }; template <typename T> void func(C1<T> t); // Error

由於 requires 表達式處於不可估量語境中, 因此參數列表中的這些變數也不存在連結, 存儲和生命週期.

如果在 requires 表達式的參數列表中使用類別型別別名, 本來必須要這樣寫 :

Code 35. requires 參數列表中的類別型別別名
template <typename T> concept C = requires(typename T::type t) { t + t; };

然而我們知道, 參數 t 前面必定是 t 的型別, 因此 T::type 在此處必定是型別而非一個成員變數或者成員函式. 因此, C++ 20 提案 P2092R0《Disambiguating Nested-Requirements》提出讓此處的 typename 變為可選的.

我們可以將 SFINAE 用在 requires 表達式的參數列表中. 如果 SFINAE 出現在 requires 表達式的參數列表中, 替換失敗不會導致編碼錯誤, 而是導致 requires 表達式回傳 false :

Code 36. requires 參數列表中的 SFINAE
template <typename T> concept C = requires(std::enable_if_t<std::is_integral_v<T>, T> t) { +t; }; static_assert(C<int>); // OK static_assert(C<float>); // Error : static_assert failed, because 'float' does not satisfy 'C', substituted constraint expression is ill-formed: failed requirement 'std::is_integral_v<float>'; 'enable_if' cannot be used to disable this declaration

但是 SFINAE 不能用於 Concept 的參數樣板中 :

Code 37. SFINAE 不能用於 Concept 的樣板參數中
#include <type_traits> template <typename T, typename = std::enable_if_t<std::is_integral_v<T>>> // Error: no type named 'type' in 'std::__1::enable_if<false>'; 'enable_if' cannot be used to disable this declaration concept C = true; static_assert(C<float>);

2.2 非型別參數樣板

對於樣板中的非型別參數, Concept 和制約同樣適用於它們. 但是, Concept 作為樣板參數的時候, 其必須是型別參數, 而不能是非型別參數 :

Code 38. 非型別參數的制約
template <int I> concept C = I > 42; template <int I> requires (I > 42) void f(); template <C I> // Error : concept named in type constraint is not a type concept void g(); template <int I> requires C<I> struct s;

2.3 樣板參數中的樣板

和型別參數不同, 樣板參數中的樣板並不支援使用 requires 表達式進行制約 :

Code 39. 樣板參數中的樣板不能使用 requires 表達式制約
template <template <typename T> requires true typename TT> // Error void func();

對於樣板參數中的樣板, 其參數限制需要通過 Concept 來完成 :

Code 40. 樣板參數中的樣板可以使用 Concept 制約
template <typename> concept C = true; template <template <C T> typename TT> // OK void func();

C++ 20 提案 P1616R1《Using unconstrained template template parameters with constrained templates》解決了一個樣板參數中的樣板無法接受帶有制約的樣板的情況, 這個情況本來對於 SFINAE 是不存在的. P1616R1 提出讓下面的程式碼可以通過編碼 :

Code 41. 樣板樣板參數的匹配
template <typename, typename> struct unconstrained {}; template <typename T, typename, typename = std::enable_if_t<std::is_integral_v<T>>> struct SFINAE {}; template <typename T, typename> requires std::is_integral_v<T> struct constrained {}; template <template <typename ...> typename T> struct temp {}; template <template <typename, typename> typename TT> struct S { template <typename T> using type = TT<T, int>; }; template <template <typename T, typename, typename = std::enable_if_t<std::is_integral_v<T>>> typename TT> struct S_SFINAE { template <typename T> using type = TT<T, int>; }; temp<unconstrained> t1; // OK temp<SFINAE> t2; // OK temp<constrained> t3; // Error, 因為 constrained 的樣板參數帶有制約, 因此 temp 暫時還不能接受它, P1616R1 提出可以讓 temp 接受 constrained. S<unconstrained>::type<int> s1; // OK S_SFINAE<SFINAE>::type<int> s2; // OK S<constrained>::type<int> s3; // Error, 同樣因為 constrained 的樣板參數帶有制約

C++ 17 提案 P0522R0《Matching of template template-arguments excludes compatible templates》中已經提出, 讓 Code 41 中的類別 S 相容類別 SFINAE, 也就是讓 S 的樣板參數可以接受 SFINAE 作為引數. 因此, 本來 Code 41 可以簡化為 :

Code 42. P0522R0 下的 Code 41
template <typename, typename> struct unconstrained {}; template <typename T, typename, typename = std::enable_if_t<std::is_integral_v<T>>> struct SFINAE {}; template <typename T, typename> requires std::is_integral_v<T> struct constrained {}; template <template <typename ...> typename T> struct temp {}; template <template <typename, typename> typename TT> struct S { template <typename T> using type = TT<T, int>; }; temp<unconstrained> t1; temp<SFINAE> t2; temp<constrained> t3; S<unconstrained>::type<int> s1; S<SFINAE>::type<int> s2; S<constrained>::type<int> s3;

但是由於 Clang 目前沒有支援 (我也在文章《C++ 17 Paper 特性合集 (二)》中指出了這點), 因此我並沒有採用上面的寫法. 上面的寫法在 GCC 中是可以通過編碼的.

2.4 Partial Concept

Code 14Code 16-2 中, 我故意略去了一個問題 : 對於 Code 16-2 中的 convertible_to 這兩個 Concept, 當其用於複合需求的時候, convertible_to<int> 中的 int 是去替換 Concept 樣板參數中的 T 還是 U? 這個問題對於Code 14 中的 same_as 同樣存在.

這種被部分替換的 Concept 被稱為局域 Concept (Partial Concept). 對於一個具有 NN 個樣板參數的 Concept :

template <typename T1T_{1}, typename T2T_{2}, ..., typename TNT_{N}>
concept C;

其局域 Concept C<T1T_{1}, T2T_{2}, ..., TN1T_{N - 1}> 實際上可以表示為

template <typename A>
concept C<A, T1T_{1}, T2T_{2}, ..., TN1T_{N - 1}>;

也就是說, 局域 Concept 中的 N1N - 1 個樣板參數被按順序替換到了原始 Concept 中最後的 N1N - 1 個樣板參數中, 而保留了第一個樣板參數作為佔位. 對於前面 convertible_to 這個實例中, int 是被替換到了樣板參數 U 的位置, 而樣板參數 T 的位置作為保留. 對於複合需求 {t.func()} -> convertible_to<int>; 中, t.func() 的回傳型別被替換到了樣板參數 T 所在的位置. 因此, 複合需求 {t.func()} -> convertible_to<int>; 可以寫為巢狀需求 :

Code 43. Code16-2 的另一種寫法
#include <type_traits> template <typename T, typename U> concept convertible_to = std::is_convertible_v<T, U>; template <typename T> concept C = requires(T t) { requires convertible_to<decltype(t.func()), int>; };

到目前為止, 若某個 Concept 具有 NN 個樣板參數, 那麼其局域 Concept 必定要用 N1N - 1 個引數去按順序替換 Concept 中後面 N1N - 1 個樣板參數. 如果想僅僅用 N2N - 2 個引數去按順序替換 Concept 中後面 N2N - 2 個樣板參數是未被 C++ 20 支援的. 也就是說, 下面的虛擬碼是錯誤的 :

template <typename T1T_{1}, typename T2T_{2}, ..., typename TNT_{N}>
concept C;

template <typename A, typename B>
concept C<A, B, T1T_{1}, T2T_{2}, ..., TN2T_{N - 2}>;

2.5 可變參數樣板

可變參數樣板和 Concept 結合的情況比一般情況稍微複雜一些.

2.5.1 Concept 不是可變參數的

在 Concept 不是可變參數的, 但是函式樣板或者類別樣板是使用了 Concept 的可變參數的情況下, 可以用以下程式碼描述這種情況 :

Code 44. 帶有制約的可變樣板參數
template <typename> concept C = true; template <C ...Ts> void func();

此時, 對於 func<T1T_{1}, T2T_{2}, ..., TnT_{n}>(), 我們要求對於任意 TiT_{i}, 我們都要求 C<TiT_{i}>true. 其中, i=1,2,...,ni = 1, 2, ..., n.

2.5.2 Concept 是可變參數的

2.5.2.1 樣板不是可變參數的

對於函式樣板或者類別樣板不是可變參數的這種情形, 我們可以使用

Code 45. 樣板不是可變參數的但 Concept 是可變參數的
template <typename ...> concept C = true; template <C T> void func();

來描述. 這種情況很簡單, 就是要求函式樣板 f 的樣板參數 T 滿足 C<T>. 只不過 C 是一個可以接受任意數量的樣板參數的 Concept 罷了.

2.5.2.2 樣板也是可變參數的

Code 46. 樣板和 Concept 都是可變參數的
template <typename ...> concept C = true; template <C ...Ts> void func();

這種情形可能初步理解比較困難, 但是只要將其轉換一下, 換成易於理解的虛擬碼即可 :

template <typename ...>
concept C = true;

template <typename T1T_{1}, typename T2T_{2}, ..., typename TNT_{N}>
void func() requires C<T1T_{1}> and C<T2T_{2}> and ... and C<TNT_{N}>;

這樣我們就可以看出 C 只是一個可以接受任意數量的樣板參數的 Concept 罷了.

2.5.3 折疊表達式

對於僅僅接受單個樣板參數的 Concept, 如果我們想讓一個引數包中的所有樣板引數都參數滿足這個 Concept, 此時我們不能簡單地進行引數包展開, 而需要借助折疊表達式 :

Code 47. Concept 中的折疊表達式
template <typename> concept C1 = true; template <typename ...Ts> concept C2 = C1<Ts>...; // Error template <typename ...Ts> concept C2 = (C1<Ts> and ...); // OK

2.6 預設引數

Concept 的樣板參數列表中的參數也可以有預設引數, 這個和函式樣板或者類別樣板是一樣的. 但是針對使用了 Concept 制約樣板參數的函式樣板或者類別樣板, 如果樣板參數受到了 Concept 的制約並且帶有預設引數, 我們要求這些預設引數也要滿足 Concept. 但是一般來說, 在樣板沒有具現化之前, 編碼器是不會因為樣板的預設引數未滿足制約而擲出編碼錯誤. 然而一旦其被具現化, 就會產生編碼錯誤.

Code 48. Concept 中的預設引數
template <typename T> concept C = sizeof(T) > 2; template <C T = int> // OK void func(T); template <C T = char> // OK, 不會擲出編碼錯誤, 因為它還沒有被具現化 void func();

例如通過 func<>(); 來呼叫函式就會產生編碼錯誤 no matching function for call to 'func'. 如果主動給定了滿足制約的引數, 就不會產生編碼錯誤, 例如 func<long>();.

3. 雜項

如果閣下僅僅希望了解一下 Concept, 並不打算深入學習的話, 可以省略本節的閱讀.

3.1 參數影射

為了理解後面的原子制約, 我們首先使用一個實例來演示參數影射 :

Code 49. 參數影射範例
template <typename T> concept A = T::value; template <typename U> concept B = A<U *>; template <typename V> concept C = B<V &>;

對於 B 來說, 它直接將 A 中的樣板參數 T 影射為 U * (T \rightarrow U *), 然後得到 (U *)::value; 對於 C 來說, 它首先將 B 的樣板參數 U 影射為 V & (U \rightarrow V &), 然後再將 A中的樣板參數 T 影射為 V & * (T \rightarrow V & *), 最後得到 (V & *)::value. 顯然, BC 對應的影射到最後會導致錯誤的程式碼, 但是在 Concept 未被用在制約且制約未被檢查之前, C++ 標準並不要求編碼器必須對上述程式碼發出錯誤警告, 甚至不要求進行診斷. 顯然, 對於 C 來說, 在影射結果得到 V & * 的時候, 就已經出現錯誤了, 因為並不存在參考的指標.

3.2 制約序列

一個制約序列可能存在多個制約表達式, 最終制約的結果取決於這些子制約表達式的結果的某種邏輯運算, 制約序列的最終結果是一個 bool 型別的常數表達式. 例如制約表達式 requires (sizeof(int) == 4 and sizeof(long) == 8) 這個制約表達式結合了兩個子制約表達式, 分別是 requires (sizeof(int) == 4)(requires sizeof(long) == 8). 於是, 根據邏輯操作的分類, C++ 20 標準將制約序列進行了分類 : 結合 (conjunction) 制約, 分離 (disjunction) 制約和原子 (atomic) 制約.

3.2.1 結合制約和分離制約

結合制約和分離支援分別對應了邏輯運算中的 \wedge\vee, 也就是對應了 C++ 運算子中的 andor, 亦即 &&||. 對於結合制約來說, 計算順序從左至右, 若且唯若所有制約都被滿足, 那麼結合制約就被滿足; 對於分離制約來說, 計算的順序也是從左至右, 只要有一個制約被滿足, 那麼整個分離制約就被滿足.

3.2.2 原子制約

一個原子制約由一個表達式和出現在表達式中樣板參數至樣板引數的影射, 這個樣板引數和受制約實體的樣板參數有關. 兩個原子制約若且唯若它們由同一個表達式組成, 並且參數影射的目標也相同的時候, 我們稱兩個原子制約相等.

Code 49 中, 對於 B 來說, 它要做的影射實際上是針對 A 的樣板參數 TU * 的影射, U * 作為 A 的樣板引數, 表達式為 A<U *>, 受制約的實體為 B, A 的樣板引數 U * 和受制約實體 B 的樣板參數 U 相關聯.

原子制約的結果必須是型別為 bool 的常數表達式, 其中不能存在隱含型別轉化 :

Code 50. 原子制約的結果必須是 bool 型別
struct S { constexpr operator bool() const noexcept { return true; } constexpr bool operator()() const noexcept { return true; } }; void func() requires (S {}); // Error : atomic constraint must be of type 'bool' (found 'S') void func() requires (S {}()); // OK

另外, 原子制約是制約表達式中最小的制約. 也就是說可以由原子制約組成其它類型的制約, 但是無法再將原子制約拆分為更小的制約.

3.3 制約正規化

實際上, 給定一個制約表達式, 編碼器在計算制約表達式的結果之前, 首先要對制約表達式進行制約正規化. 制約正規化的結果是制約表達式的範式的組合. 對於一個表達式的範式的定義如下 :

  • 表達式 (E) 的範式是 E;
  • 表達式 E1 and E2 (E1 && E2) 的範式是 E1E2 的合取 (conjunction) : E1 \wedge E2;
  • 表達式 E1 or E2 (E1 || E2) 的範式是 E1E2 的析取 (disjunction) : E1 \vee E2;
  • 一個 Concept C<A1A_{1}, A2A_{2}, ..., ANA_{N}> 的範式是一個關於 C 的制約表達式的範式. 對於每個原子制約在參數影射時, 我們使用 A1A_{1}, A2A_{2}, ..., ANA_{N} 去替換 C 中對應的樣板參數;
  • 其它表達式的範式是其對應的原子制約. 其參數影射為到自身的影射 (這裡包括了折疊表達式, 一切以 andor (&&||) 運算子進行的折疊都算在內).

Tip : 這裡特別說明一下, 到自身的影射就是用自己的樣板參數去替換自己, 這是概念上的而非實際上的. 所以此處同樣不存在遞迴的影射. 另外, 所有用戶自訂的 andor (&&||) 運算子在制約正規化中都無效.

我們可以看到, 每一個範式最終都是一個原子制約. 制約正規化的過程就是將制約表達式轉換成原子制約的析取或者合取.

3.4 制約轉換

在制約正規化中, 我們已經提到了編碼器在處理制約表達式的時候, 要首先將制約進行正規化 :

  • 如果一個樣板僅僅具有一個制約, 那麼制約就會轉換為這個制約表達式的範式;
  • 如果一個樣板具有多個制約, 編碼器會以如下順序將所有制約表達式轉換為一個關於 and (&&) 運算的範式 :
    • 在樣板參數中, 如果某些參數是被 Concept 制約的, 那麼按照樣板參數的順序首先處理這些參數對應的制約表達式;
    • 在樣板參數之後, 如果使用了 requires 表達式進行制約, 那麼處理這些制約表達式;
    • 在函式參數之後, 如果還存在 requires 表達式, 最後處理這些制約表達式.
Code 51. 制約轉換演示範例
template <typename> concept C = true; template <typename T, C U> requires C<T> void func() requires (sizeof(T) > 1 and sizeof(U) > 1);

首先, 編碼器處理函式樣板 func 的樣板參數列表中的第二個樣板參數 C U, 它的制約表達式為 C<U>, 範式為 C<U>; 然後, 編碼器處理樣板參數列表之後的 requires 制約, 它的制約表達式就是 C<T>, 範式為 C<T>; 最後, 編碼器會處理位於函式參數列表之後的 requires 制約, 其制約表達式為 sizeof(T) > 1 and sizeof(U) > 1, 範式為 sizeof(T) > 1 \wedge sizeof(U) > 1. 最終, 編碼器會檢查各個範式的合取 C<U> and C<T> and sizeof(T) > 1 and (sizeof(T) > 1 \wedge sizeof(U) > 1) 是否滿足. 如果範式滿足並且函式的引數和函式的參數匹配, 那麼這個函式會被加入函式呼叫候選集合中.

3.5 制約的偏序關係

我們說某個制約 PP 歸入制約 QQ 若且唯若制約 PP 可以推導出制約 QQ, 即 PQP \Rightarrow Q. 如果設 AA 為滿足制約 PP 的型別集合, BB 為滿足制約 QQ 的型別集合, 只要 PQP \Rightarrow Q 成立, 那麼必有 ABA \subseteq B.

需要注意的是, 對於原子制約來說, 設制約數量為 NN, 那麼 N0N \geq 0 並不一定歸入到 N>0N > 0. 考慮下面實例 :

Code 52. 制約的偏序關係特殊情形
template <typename> concept C1 = false; template <typename ...Ts> concept C2 = (C1<Ts> and ...); static_assert(C2<>); // OK static_assert(C2<int>); // Error : static_assert failed, because 'int' does not satisfy 'C2', 'int' does not satisfy 'C1'

因為當引數包為空的時候, and 運算子的折疊表達式永遠回傳 false; or 運算子的折疊表達式永遠回傳 true.

要考察制約 PP 是否歸入制約 QQ, 首先要通過制約轉換, 將 PP 轉換為一系列範式的析取, 將 QQ 轉換為一系列範式的合取. 例如設 AA, BBCC 都是原子制約, 制約 A(BC)A \vee (B \wedge C) 是一系列範式合取之後的析取 (首先看括號內, 然後看是什麼將括號連結到了一起). 如果要將其轉換為一系列範式的析取, 我們只需要將括號內的合取拆開 : A(BC)(AB)(AC)A \vee (B \wedge C) \Rightarrow (A \vee B) \wedge (A \vee C), 就變成了範式的合取. 然後, 我們檢查制約 PPQQ 中是否存在相同的原子制約, 如果不存在, 那麼制約 PPQQ 不存在任何偏序關係. 如果制約 PP 和制約 QQ 中存在相同的原子制約, 我們說制約 PP 歸入制約 QQ 若且唯若對於 PP 的範式中的任意析取子句都可以歸入 QQ 的範式中的任意合取子句. 其中, PP 的範式中的任意析取子句的原子制約都可以歸入 QQ 的範式中的任意合取子句的原子制約. 對於原子制約的歸入, 實際上就是檢查原子制約的相等性. 設 AABB 都是原子制約, 制約 P=ABP = A \wedge B, 制約 Q=AQ = A. 顯然, 如果兩個制約都得到了滿足, 那麼 PQP \Rightarrow Q, 即 PP 歸入 QQ. 直觀來說, 更嚴格的制約都得到了滿足, 那麼放鬆一些的制約必定可以滿足. 從上面的定義來說, 如果制約 PPQQ 都得到了滿足, 那麼原子制約 AABB 也都得到了滿足, 自然地, PP 中的任何子句及其子句的原子制約都可以歸入 QQ 中的任何子句及其子句的原子制約. 只不過在這個例子裡面, PPQQ 的子句都為原子制約罷了.

制約之間的歸入關係定義了制約的偏序關係, 它用來決定 :

  1. 非樣板函式的最佳選擇;
  2. 針對函式指標, 非樣板函式地址的最佳選擇;
  3. 樣板參數中的樣板的匹配;
  4. 類別樣板特製化的偏序;
  5. 函式樣板的偏序.

對於兩個制約 D1D_{1}D2D_{2}, 我們說 D1D_{1}D2D_{2} 更受制約若且唯若滿足

  • 制約 D1D_{1}D2D_{2} 都不為空, 制約 D1D_{1} 可以歸入 D2D_{2}, 即 D1D2D_{1}\Rightarrow D_{2};
  • 制約 D2D_{2} 為空, 即 D2D_{2} 沒有受到任何制約.

上述兩個條件中的任意一個條件.

之前我們說最佳滿足原則的時候, 大家只是直觀地從程式碼中感覺, 只要約束表達式中的條件越多, 就越滿足. 最後編碼器在選擇函式的時候, 一定會選擇約束條件最多的那一個. 但是如果我們將第 2.1.3.2 節中的 Code 29

Code 29 副本. 最終的輸出是什麼?
#include <iostream> template <typename> concept C = true; template <typename T> requires C<T> void func() { std::cout << "requires C<T>" << std::endl; } template <typename T> requires (C<T> and true) void func() { std::cout << "requires C<T> and true" << std::endl; } int main(int argc, char *argv[]) { func<int>(); }

修改一下, 把 template <typename T> requires (C<T> and true) 改為 template <typename T> requires is_integral_v<T> and true :

Code 53. 更改 Code 29
#include <iostream> template <typename T> concept C = is_integral_v<T>; template <typename T> requires C<T> void func() { std::cout << "requires C<T>" << std::endl; } template <typename T> requires is_integral_v<T> and true void func() { std::cout << "requires C<T> and true" << std::endl; } int main(int argc, char *argv[]) { func<int>(); // Error : call to 'func' is ambiguous }

就會出現編碼錯誤. 也就是說, requires C<T> and trueis_integral_v<T> and true 代表著不同的制約表達式, 即使它們產生了相同的布林常數值. 因此, 編碼器總是選擇約束條件更多的那一個函式並不是正確的. 在引入了制約序列, 制約正規化和制約的偏序關係之後, 我們就可以對編碼器如何作出選擇更加清楚.

對於 Code 29, 在呼叫時, 第一個函式樣板 func 的制約為 C<T>, 將其轉化為範式為 is_integral_v<T>, 它是一個原子制約, 我們設為 PP. 即制約表達式 is_integral_v<T> 經過制約正規化之後為 PP. 再來看第二個函式樣板 func, 其制約為 C<T> and true, 將其轉化為範式為 is_integral_v<T> \wedge true. 而 is_integral_v<T>true 本身也是一個原子制約, 我們分別設為 QQRR. 即制約表達式 C<T> and true 經過正規化之後的範式為 QRQ \wedge R. 這裡需要提醒一下, 第一個 func 中的 is_integral_v<T> 和第二個 func 中的 is_integral_v<T> 我們分別設為了 PPQQ, 而不是同一個原子制約. 但是根據原子制約相等的定義, 顯然原子制約 PPQQ 相等, 即制約 C<T>C<T> and true 中存在相同的原子制約. 接下來, 我用原子制約 PP 來替代原子制約 QQ. 現在, 我們討論制約 PPPRP \wedge R 的偏序關係. 直觀上, 制約 PRP \wedge RPP 更受到約束, 因此我們設 AAPRP \wedge R, BBPP. 即直觀上, 我們有 ABA \Rightarrow B. 根據歸入的嚴格定義, 我們將制約 AA 寫成一系列範式的析取 : P¬RP \vee \neg R. 要有 ABA \Rightarrow B, 我們要求 PPP \Rightarrow P 並且 ¬RP\neg R \Rightarrow P. 任何制約都可以歸入到自己本身, 因此 PPP \Rightarrow P 顯然成立. 而 ¬RP\neg R \Rightarrow P 同樣成立, 這是因為 ¬R\neg RPP 更受制約. 這可能不太直觀, 直接一些, ¬R\neg R 只有一種結果 : ¬R={trueR 為 falsefalseR 為 true.\displaystyle {\neg R = \begin {cases} \text {true} & {R \text { 為 false}} \\ \text {false} & {R \text { 為 true}}. \end {cases}} 而對於 PP, 也就是 C<T>, 亦即 is_integral_v<T> 卻有兩種結果 : truefalse. 因此, P¬RP \vee \neg R 成立, 編碼器選擇第二個 func.

Code 53 中在呼叫時, 第一個函式樣板 func 的制約為 C<T>, 將其轉化為範式為 is_integral_v<T>, 它是一個原子制約, 我們設為 PP. 即制約表達式 is_integral_v<T> 經過制約正規化之後為 PP. 再來看第二個函式樣板 func, 其制約為 is_integral_v<T> and true, 將其轉化為範式為 is_integral<T> \wedge true, 我們設為 QQ. is_integral_v<T>true, 我們分別設為 <code>Q<code>QRR. 即制約表達式 is_integral_v<T> and true 經過正規化之後的範式為 QRQ \wedge R. 根據原子制約相等的定義, 原子制約 PPQQ 並不相等, 因為它們一個由 Concept 組成, 另一個由一個表達式組成, 儘管它們是同一個意思; 原子 PPRR 顯然不相等. 那麼制約 C<T>is_integral_v<T> and true 之間沒有任何偏序關係, 沒有誰歸入誰. 因此, 在函式呼叫 func<int>() 匹配的過程中, 由於這兩個制約不存在偏序關係, 那麼這兩個制約自然也不存在誰比誰更滿足這一層關係, 於是編碼器無法選擇哪一個更好或者更加滿足制約的函式, 擲出了編碼錯誤.

再考慮下面程式碼 :

Code 54. 看似更加制約
template <int> constexpr auto atomic {true}; template <int N> concept C = atomic<N>; template <int N> concept add = C<N + 1>; template <int N> void func() requires add<N * 2>; template <int N> void func() requires add<2 * N> and true; int main(int argc, char *argv[]) { func<42>(); }

N * 22 * N 我們看來是一樣的, 因此看起來第二個函式樣板更受制約. 但是實際上, add<N * 2>add <2 * N>會被認為是毫無關聯的原子制約 (P2103R0). 因此, add<N * 2>add <2 * N> and true 沒有任何偏序關係, 因為原子制約相等性並不滿足, 那麼也就不存在誰更受制約的說法, 大家都是受到同一個制約, 並且制約都滿足. 最終, 編碼器並無法決定選擇哪一個函式, 擲出編碼錯誤.

Tip : 目前 Clang 和 GCC 都針對此擲出了編碼錯誤, 儘管 Clang 沒有把 Concept 的部分完全實作, 但是我認為 Clang 並不會更改這個程式碼會產生編碼錯誤的結果. 因為根據 C++ 20 提案 P2103R0《Core Language Changes for NB Comments at the February, 2020 (Prague) meeting》, C++ 20 標準並不要求編碼器對此擲出編碼錯誤, 甚至不要求診斷.

不過, 具現化的過程是必定會讓編碼器無法選擇哪一個函式更好的, 但是僅僅定義是沒有問題的. 但是如果制約也相同, 就會產生編碼錯誤 :

Code 55. 不能存在制約也相同的重複定義
template <typename> concept C = true; template <typename T> void func() requires C<T> {} template <typename T> void func() requires true {} // OK template <typename T> void func() requires C<T> {} // Error : redefinition of 'func'

3.6 Template-Head

C++ 20 為樣板引入了一個新的概念, 叫做樣板頭部 (template-head). 要判定兩個樣板頭部相同, 必須滿足

  • 樣板的參數列表中擁有相同數量的參數;
  • 每一個樣板參數都是同一種類的 : 如果某個位置宣告了一個型別參數, 非型別參數, 樣板參數, 帶有 Concept 限制的參數和引數包, 那麼另外一個樣板頭部對應的位置也應該和此樣板頭部該位置的參數種類相同;
  • 如果樣板被 requires 表達式所制約, 那麼 requires 表達式也要相同.

3.7 帶有制約樣板的使用

我們已經知道, 如果使用帶有制約的樣板, 如果需要對其具現化, 那麼制約也必須滿足. 對於樣板特製化也是如此. 但是有一種特殊情況是例外的, 就是沒有具現化的類別內部如果使用不滿足制約的具現化樣板, 並不一定會有編碼錯誤, C++ 標準甚至不要求對此進行診斷 :

Code 56. 具現化和特製化中的制約
template <typename T> concept C = sizeof(T) not_eq 2; template <C T> struct S {}; template <> struct S<char [2]> {}; // Error : constraints not satisfied for class template 'S' [with T = char [2]] template <C T> using array = T [1]; template <typename T, template <C> typename U> struct S { U<short> u; // 在 S 沒有被具現化之前, 這裡可能不會產生編碼錯誤 array<unsigned char [2]> arr; // 同上 };

3.8 函式指標和制約

一個函式指標不能指向一個不滿足制約的函式, 包括使用 delctype 或者 auto 去推導, 針對不滿足制約的函式進行型別推導是會產生編碼錯誤的 :

Code 57. 函式指標和制約
#include <type_traits> void f1() requires true; void f2() requires false; void (*p1)() = f1; // OK void (*p2)() = f2; // Error : invalid reference to function 'f2': constraints not satisfied auto p3 {f1}; // OK auto p4 {f2}; // Error : invalid reference to function 'f2': constraints not satisfied decltype(f2) *p5 {f2}; // Error : invalid reference to function 'f2': constraints not satisfied void f() requires false; static_assert(std::is_same_v<decltype(f), void ()>); // Error : invalid reference to function 'f': constraints not satisfied

3.9 Lambda 表達式

本來 C++ 20 是不讓 Lambda 表達式支援制約的, 因為 Lambda 表達式的一個宗旨就是做一個簡單的函式. 我們曾在文章《【C++】Lambda 表達式合集》提到, C++ 20 為 Lambda 表達式增加了樣板參數. 因此, 讓 Lambda 表達式也支援制約似乎也是合理的. 有時候, Lambda 表達式並不只是方便, 它還可以將其限定在某個可視範圍之內. 如果 Lambda 表達式不支援制約, 那麼對於本來有參數制約要求的 Lambda 表達式我們必須寫成函式. 這反而違反了 Lambda 表達式的宗旨. 因此, C++ 20 提案 P0857R0《Wording for “functionality gaps in constraints”》提出讓 Lambda 表達式支援 requires 制約. 對於 Lambda 表達式來說, 它支援在兩個地方放入 requires 制約 : 一個是樣板參數列表之後, 另一個是尾置回傳型別之後.

Code 58. Lambda 表達式中的制約
#include <type_traits> auto f {[]<typename T> requires std::is_integral_v<T>(auto value, T t) -> T requires std::is_same_v<decltype(value), void *> {}};

3.10 auto 與 Concept auto

C++ 17 為樣板引入了非型別引數的型別推導 (參考《C++ 17 特性合集 (二)》第 2 節), 也就是使用 auto 推導非型別引數. 因此 C++ 20 提案 P0857R0《Wording for “functionality gaps in constraints”》提出為使用 auto 推導的非型別引數增加 requires 制約 :

Code 59. 對樣板參數中 auto 佔位的非型別參數的制約
#include <type_traits> template <auto V> requires std::is_integral_v<decltype(V)> struct S {};

雖然到了 C++ 17 為止, auto 已經很類似於一個佔位符, 但是在普通的函式參數列表中並不允許使用 auto. C++ 20 提案 P1141R2《Yet another approach for constrained declarations》提出了 Concept auto, 徹底讓 auto 成為了一個佔位符. Concept auto 是指受到 Concept 制約且需要型別推導的參數, 給定的引數對應的型別需要滿足 Concept 制約 :

Code 60. Concept auto
#include <type_traits> template <typename T> concept C = std::is_pointer_v<T>; void func(C auto p) { if(not p) { return; } auto &ref = *p; //... } int main(int argc, char *argv[]) { func(&argc); // OK func(0); // Error : no matching function for call to 'func', because 'int' does not satisfy 'C', candidate template ignored: constraints not satisfied [with p:auto = int] }

Concept auto 對於 Lambda 表達式特別有用, 因為給 Lambda 表達式增加樣板參數和 requires 表達式卻是比較麻煩, 而且這也會導致 Lambda 表達式不美觀. 有了 Concept auto 之後, 我們就可以將 Code 58 改為更加優美的形式 :

Code 61. 對 Code 58 的改進
#include <type_traits> template <typename T> concept integral = std::is_integral_v<T>; template <typename T, typename U> concept same_as = std::is_same_v<T, U>; auto f {[](same_as<void *> auto value, integral auto v) -> decltype(v) { return v; }};

根據局域 Concept, 上面的 same_as<void *> 相當於 same_as<T, void *>, 其中 T 為引數 value 對應的型別.

不過 auto 的推導並不是總是令人滿意的, 它會省略掉 const, volatile 和參考限定. 為此, 可以使用 C++ 14 引入的 decltype(auto) :

Code 62. Concept decltype(auto)
#include <iostream> using namespace std; template <typename T> concept C = is_integral_v<T>; int main(int argc, char *argv[]) { int a {0}; int &b {a}; volatile int c {0}; const int &d {0}; //C auto floating_point {0.0}; // Error : deduced type 'double' does not satisfy 'C' C auto auto_a {a}; cout << is_same_v<decltype(auto_a), int> << endl; // 輸出 : 1 C auto auto_b {b}; cout << is_same_v<decltype(auto_b), int &> << endl; // 輸出 : 0 C auto auto_c {c}; cout << is_same_v<decltype(auto_c), volatile int> << endl; // 輸出 : 0 C auto auto_d {d}; cout << is_same_v<decltype(auto_d), const int &> << endl; // 輸出 : 0 C decltype(auto) decl_a {a}; cout << is_same_v<decltype(decl_a), int> << endl; // 輸出 : 1 C decltype(auto) decl_b {b}; cout << is_same_v<decltype(decl_b), int &> << endl; // 輸出 : 1 C decltype(auto) decl_c {c}; cout << is_same_v<decltype(decl_c), volatile int> << endl; // 輸出 : 1 C decltype(auto) decl_d {d}; cout << is_same_v<decltype(decl_d), const int &> << endl; // 輸出 : 1 }

這不僅僅可以用於變數推導, 還可以用於函式, 樣板和 Lambda 表達式的參數列表中.

auto 變為佔位符之後, 就可以使用 operator auto 來多載轉型運算子 :

Code 63-1. 使用 operator auto 來多載轉型運算子
template <typename T> concept C = is_integral_v<T>; template <typename T> struct S { operator C auto() { return T {}; } }; int main(int argc, char *argv[]) { S<int> s1; int i {s1}; // i == 0 S<float> s2; // OK, 因為 operator C auto 未被具現化, 暫時不檢查制約 float f {s2}; // Error : deduced type 'float' does not satisfy 'C', because 'is_integral_v<float>' evaluated to false, in instantiation of member function 'S<float>::operator C auto' requested here }

為了使得 Code 63-1 可以通過編碼, 我們可能想到為類別 S 增加一個沒有制約的轉型運算子 :

Code 63-2. 使用 operator auto 來多載轉型運算子
template <typename T> concept C = is_integral_v<T>; template <typename T> struct S { operator C auto() { return T {}; } operator auto() { return T {} + 1; } }; int main(int argc, char *argv[]) { S<int> s1; int i {s1}; // Error S<float> s2; float f {s2}; // Error }

但是現在連變數 i 的初始化都會產生編碼錯誤. 貌似這是不合理的, 但是實際上這和 SFINAE 的行為是一致的 :

Code 63-3. 和 SFINAE 版本的比較
template <typename T> concept C = is_integral_v<T>; template <typename T> struct S { operator enable_if_t<is_integral_v<T>, T>() { return T {}; } operator T() { return T {} + 1; } }; int main(int argc, char *argv[]) { S<int> s1; int i {s1}; // Error : multiple overloads of 'operator int' instantiate to the same signature 'int ()' S<float> s2; float f {s2}; // Error : no type named 'type' in 'std::__1::enable_if<false>'; 'enable_if' cannot be used to disable this declaration }

本來 C++ 20 是支援使用 Concept 制約非型別樣板參數的 :

Code 64. 對非型別參數的 Concept 制約
template <int I> concept C1 = I > 0; template <int ...Is> concept C2 = (C1<Is> and ...); template <C1 I> struct S1 {}; template <C2 I> // I 要求滿足 C2<I> struct S1_with_C2 {}; template <C2 ...Is> // Is 要求滿足 C2<Is...> struct S2 {};

但是, 這本身會導致歧義. 樣板參數中的型別參數我們使用 typename, 非型別參數我們使用引數對應的型別. 但是受到制約的樣板參數到底應該是個型別參數還是非型別參數呢? 因此, C++ 20 提案 P1141R2《Yet another approach for constrained declarations》提出, 受到制約的樣板參數應該只對型別有效, 移除 Concept 針對非型別樣板參數的支援.

3.11 特殊成員函式

由於普通函式也支援制約, 因此特殊成員函式也支援制約. 在 C++ 中, 特殊成員函式有 : 建構子, 複製建構子, 移動建構子, 複製指派運算子, 移動指派運算子和解構子. C++ 20 提案 P0848R3《Conditionally Trivial Special Member Functions》引入一個新的概念, 叫做可行特殊成員函式 (eligible special member function), 用來決定最終到底選擇哪一個. 對於兩個特殊成員函式來說, 首先要看它們是不是同一種類的特殊成員函式, 然後再根據制約決定哪一個是可行特殊成員函式. 對於兩個特殊成員函式, 它們是否屬於同一種類的特殊成員函式, 取決於 :

  • 它們都是預設建構子;
  • 它們的參數列表的第一個參數是相同型別的複製建構子或者移動建構子;
  • 它們的參數列表的第一個參數是相同型別的複製指派運算子或者移動指派運算子.

一個可行特殊成員函式必須滿足 :

  1. 必須是一個特殊成員函式;
  2. 它不是一個被刪除的函式;
  3. 所有相關的制約必須滿足;
  4. 沒有同種類的特殊成員函式比這個函式更滿足制約.
Code 65. 特殊成員函式中的制約
#include <iostream> template <typename T> struct S { ~S() noexcept requires (sizeof(T) == 4) { std::cout << "sizeof T is 4" << std::endl; } ~S() noexcept requires (sizeof(T) not_eq 4) { std::cout << "sizeof T isn't 4" << std::endl; } }; int main(int argc, char *argv[]) { S<int> s1; S<char> s2; } /* 輸出 : sizeof T isn't 4 sizeof T is 4 */

其它特殊成員函式可以是多載的, 但是一個類別的解構子有且唯有一個, 因此引入制約之後的解構子同樣要滿足這個條件.

C++ 20 提案 P0848R3《Conditionally Trivial Special Member Functions》還解決了一個問題, 即本來帶有制約的建構, 複製, 移動和解構都不會被編碼器認為是平凡的 :

Code 66. 帶有制約的特殊成員函式本來是不平凡的
#include <iostream> using namespace std; struct S1 { S1() requires true = default; }; template <typename T> struct S2 { S2() requires (sizeof(T) == 1) = default; S2() = default; }; int main(int argc, char *argv[]) { cout << is_trivially_default_constructible_v<S1> << endl; // 輸出 : 0 cout << is_trivially_default_constructible_v<S2<int>> << endl; // 輸出 : 0 cout << is_trivially_default_constructible_v<S2<char>> << endl; // 輸出 : 0 }

也就是說, 之前的 C++ 2a 標準草案對某個操作是否為平凡的限制過於嚴格. 很顯然, 上面的兩個類別的建構, 複製, 移動和解構都可以是平凡的. 因此, P0848R3 也解決了這個問題.

3.12 帶有制約的手動具現化

之前的 C++ 2a 標準草案並沒有規定在帶有制約的情況下, 手動具現化編碼器應該選擇哪個函式 :

Code 67. 手動具現化的選擇問題
template <typename T> concept C = true; template <typename T> struct S { template <typename U> U f(U) requires C<typename T::type>; // Error : type 'int' cannot be used prior to '::' because it has no members template <typename U> U f(U) requires C<T>; }; template <> template <typename U> U S<int>::f(U u) requires C<int> { return u; }

C++ 20 提案 P2103R0《Core Language Changes for NB Comments at the February, 2020 (Prague) meeting》解決了這個問題, 使得上述程式碼可以通過編碼.

3.13 擴展的檢查

在 C++ 20 提案 P1980R0《Declaration matching for non-dependent requires-clauses》之前, 在宣告匹配的過程中, 編碼器會檢查 requires 表達式是否相同 :

Code 68. 具有爭議的重定義問題
template <int N> void func() requires (N + 2 > 0) {} template <int N> void func() requires (N + 2 > 0) {} // Error : redefinition of 'func'

但是這個檢查僅限於樣板參數中. P1980R0 提出讓這個檢查不僅僅限於樣板參數的制約中, 從制約擴展至所有的表達式. 例如, 任何未經過計算的操作 : 設 A 是一個接受 int 型別的樣板引數的類別樣板, 難道 A<42>A<40 + 2>應該被認為是不同的類別嗎? 為此, 如果說兩個未經計算的操作是不等價的但卻是功能上等價的, 若且唯若對於給定的樣板引數集合, 表達式可以以相同的順序針對實體有相同的操作. 例如表達式 4240 + 2 顯然是表面上不相同的, 但是對於

template <int>
struct A {};

產生了相同型別的實體, 那麼我們說 4240 + 2 這兩個表達式是不等價但功能等價.

3.14 運算子多載時的參數交換

C++ 20 提案 P2113R0《Proposed resolution for 2019 comment CA 112》提出讓下面程式碼通過編碼 :

Code 69. 運算子多載時的參數交換
template <typename> concept C = true; template <typename T, typename U> struct X {}; template <typename T, C U, typename V> bool operator==(X<T, U>, V) = delete; template <C T, C U, C V> bool operator==(T, X<U, V>); X<int, char> x; auto b {x == 0}; // Error : overload resolution selected deleted operator '=='

顯然, 表達式 x == 0 選中的函式是 operator==(X<int, char>, int), 但是這個函式被刪除. P2113R0 希望通過嘗試交換參數, 讓編碼器檢查 0 == x 是否可以通過編碼, 如果可以的話, 就採用 0 == x 的結果. 而 0 == x 選中的函式是 operator==(int, X<int, char>), 這個函式是沒有被刪除的.

Tip : 不過這個特性沒有被 GCC 所支援, 我覺得 Clang 很可能也不會支援, 因為這個特性本身就是反直覺的.

4. 總結

Concept 的入門是挺簡單的, 但是進階並不簡單. 從雜項開始, 可能很多人第一時間無法理解, 但是一般情況下, 根本不需要考慮那麼複雜的情況. 由於 Concept 可以很大程度上減少編碼錯誤的提示, 並且 Concept 的寫法比 SFINAE 要簡單得多, 因此本人建議大家在 C++ 20 之後, 盡量採用 Concept, 而不是 SFINAE.

除了引入 Concept 這個語言特性之外, C++ 20 還引入了標頭檔 <concepts>, 上面我們自己實作的 same_asconvertible_to 都可以在這個標頭檔中找到.

截至發文和第一次修改為止, GCC 基本支援了 C++ 20 標準的 Concept, 而 Clang 做了大部分. 因此, 上面介紹的一些特性可能無法通過 Clang++ 的編碼.