C++20初體驗——concepts
引子
凡是涉及STL的錯誤都不堪入目,因為首先STL中有複雜的層次關係,在錯誤資訊中都會暴露出來,其次這麼多類和函式的名字大多都是雙下劃線開頭的,一般人看得不習慣。
一個經典的錯誤是給std::sort
傳入std::list<T>
的迭代器:
#include <list>
#include <algorithm>
int main()
{
std::list<int> list;
std::sort(list.begin(), list.end());
}
GCC 10.1.0給出如下錯誤資訊(沒有開-std=c++20
):
In file included from c:\program files\mingw-w64\include\c++\10.1.0\algorithm:62, from temp.cpp:3: c:\program files\mingw-w64\include\c++\10.1.0\bits\stl_algo.h: In instantiation of 'void std::__sort(_RandomAccessIterator, _RandomAccessIterator, _Compare) [with _RandomAccessIterator = std::_List_iterator<int>; _Compare = __gnu_cxx::__ops::_Iter_less_iter]': c:\program files\mingw-w64\include\c++\10.1.0\bits\stl_algo.h:4859:18: required from 'void std::sort(_RAIter, _RAIter) [with _RAIter = std::_List_iterator<int>]' temp.cpp:9:39: required from here c:\program files\mingw-w64\include\c++\10.1.0\bits\stl_algo.h:1975:22: error: no match for 'operator-' (operand types are 'std::_List_iterator<int>' and 'std::_List_iterator<int>') 1975 | std::__lg(__last - __first) * 2, | ~~~~~~~^~~~~~~~~ In file included from c:\program files\mingw-w64\include\c++\10.1.0\bits\stl_algobase.h:67, from c:\program files\mingw-w64\include\c++\10.1.0\bits\char_traits.h:39, from c:\program files\mingw-w64\include\c++\10.1.0\ios:40, from c:\program files\mingw-w64\include\c++\10.1.0\ostream:38, from c:\program files\mingw-w64\include\c++\10.1.0\iostream:39, from temp.cpp:1: c:\program files\mingw-w64\include\c++\10.1.0\bits\stl_iterator.h:500:5: note: candidate: 'template<class _IteratorL, class _IteratorR> constexpr decltype ((__y.base() - __x.base())) std::operator-(const std::reverse_iterator<_Iterator>&, const std::reverse_iterator<_IteratorR>&)' 500 | operator-(const reverse_iterator<_IteratorL>& __x, | ^~~~~~~~ c:\program files\mingw-w64\include\c++\10.1.0\bits\stl_iterator.h:500:5: note: template argument deduction/substitution failed: In file included from c:\program files\mingw-w64\include\c++\10.1.0\algorithm:62, from temp.cpp:3: c:\program files\mingw-w64\include\c++\10.1.0\bits\stl_algo.h:1975:22: note: 'std::_List_iterator<int>' is not derived from 'const std::reverse_iterator<_Iterator>' 1975 | std::__lg(__last - __first) * 2, | ~~~~~~~^~~~~~~~~ In file included from c:\program files\mingw-w64\include\c++\10.1.0\bits\stl_algobase.h:67, from c:\program files\mingw-w64\include\c++\10.1.0\bits\char_traits.h:39, from c:\program files\mingw-w64\include\c++\10.1.0\ios:40, from c:\program files\mingw-w64\include\c++\10.1.0\ostream:38, from c:\program files\mingw-w64\include\c++\10.1.0\iostream:39, from temp.cpp:1: c:\program files\mingw-w64\include\c++\10.1.0\bits\stl_iterator.h:1533:5: note: candidate: 'template<class _IteratorL, class _IteratorR> constexpr decltype ((__x.base() - __y.base())) std::operator-(const std::move_iterator<_IteratorL>&, const std::move_iterator<_IteratorR>&)' 1533 | operator-(const move_iterator<_IteratorL>& __x, | ^~~~~~~~ c:\program files\mingw-w64\include\c++\10.1.0\bits\stl_iterator.h:1533:5: note: template argument deduction/substitution failed: In file included from c:\program files\mingw-w64\include\c++\10.1.0\algorithm:62, from temp.cpp:3: c:\program files\mingw-w64\include\c++\10.1.0\bits\stl_algo.h:1975:22: note: 'std::_List_iterator<int>' is not derived from 'const std::move_iterator<_IteratorL>' 1975 | std::__lg(__last - __first) * 2, | ~~~~~~~^~~~~~~~~
太長不看,加三告辭。換個Visual Studio 2019:
Severity Code Description Project File Line Suppression State Error C2676 binary '-': 'const std::_List_unchecked_iterator<std::_List_val<std::_List_simple_types<_Ty>>>' does not define this operator or a conversion to a type acceptable to the predefined operator temp C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Tools\MSVC\14.27.29110\include\algorithm 4138 Error C2672 '_Sort_unchecked': no matching overloaded function found temp C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Tools\MSVC\14.27.29110\include\algorithm 4138 Error C2780 'void std::_Sort_unchecked(_RanIt,_RanIt,iterator_traits<_Iter>::difference_type,_Pr)': expects 4 arguments - 3 provided temp C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Tools\MSVC\14.27.29110\include\algorithm 4138
雖然錯誤資訊簡短許多,但仍不能告訴我們錯誤的原因(這些是內部原因)。
我們注意到兩段錯誤都提到了operator-
,實際上編譯器認為錯誤在於std::sort
中會把兩個輸入迭代器所屬型別的例項相減,而std::list<T>::iterator
沒有過載operator-
運算子。這當然不是讓我們來過載這個運算子。
STL原始碼可以提供一些幫助:
/** * @brief Sort the elements of a sequence. * @ingroup sorting_algorithms * @param __first An iterator. * @param __last Another iterator. * @return Nothing. * * Sorts the elements in the range @p [__first,__last) in ascending order, * such that for each iterator @e i in the range @p [__first,__last-1), * *(i+1)<*i is false. * * The relative ordering of equivalent elements is not preserved, use * @p stable_sort() if this is needed. */ template<typename _RandomAccessIterator> _GLIBCXX20_CONSTEXPR inline void sort(_RandomAccessIterator __first, _RandomAccessIterator __last) { // concept requirements __glibcxx_function_requires(_Mutable_RandomAccessIteratorConcept< _RandomAccessIterator>) __glibcxx_function_requires(_LessThanComparableConcept< typename iterator_traits<_RandomAccessIterator>::value_type>) __glibcxx_requires_valid_range(__first, __last); __glibcxx_requires_irreflexive(__first, __last); std::__sort(__first, __last, __gnu_cxx::__ops::__iter_less_iter()); }
在概念上(conceptually),std::list<T>
的迭代器不滿足RandomAccessIterator的要求,所以不能用於std::sort
。然而_RandomAccessIterator
畢竟只是一個名字,編譯器不知道它表示哪些要求,更無法據此輸出錯誤資訊。
但是從C++20開始,編譯器可以掌握這些資訊了,不是通過typename
後面的那個名字,而是由兩個新關鍵詞concept
和requires
支撐起來的。然後對於上面那個錯誤,編譯器會說:“std::random_access_iterator<std::list<int>::iterator>
不成立”(儘管目前我還沒有體驗過這種編譯器)。
如果我們自己寫的模板函式對型別有要求,可以在模板引數列表中寫出:
#include <iterator>
template<std::random_access_iterator Iter>
void func(Iter _first, Iter _last)
{
// ...
}
那麼std::random_access_iterator
是如何實現的呢?
template<typename _Iter>
concept random_access_iterator = bidirectional_iterator<_Iter>
&& derived_from<__detail::__iter_concept<_Iter>,
random_access_iterator_tag>
&& totally_ordered<_Iter> && sized_sentinel_for<_Iter, _Iter>
&& requires(_Iter __i, const _Iter __j,
const iter_difference_t<_Iter> __n)
{
{ __i += __n } -> same_as<_Iter&>;
{ __j + __n } -> same_as<_Iter>;
{ __n + __j } -> same_as<_Iter>;
{ __i -= __n } -> same_as<_Iter&>;
{ __j - __n } -> same_as<_Iter>;
{ __j[__n] } -> same_as<iter_reference_t<_Iter>>;
};
意思看得懂,但不會寫。彆著急,這些語法我們一點點來講。
requires關鍵詞與需求
對模板引數的需求是巢狀的,深入到最底層,都是通過requires
關鍵詞實現的。“s”的存在使程式碼在英語的語法中更加通順一點。
requires
有兩種用法:requires
子句(requires-clause)和requires
表示式。
requires表示式
requires
表示式產生一個bool
值,語法為下列之一:
-
requires { 一系列requirements(需求) }
; -
requires ( 引數列表 ) { 一系列requirements }
。
引數列表
用於建立一系列一定型別的變數,在requirements中使用。這些變數並不真實存在(只有語法功能),它們的作用域到後面的}
為止。
Requirements有四種:簡單需求(simple requirements)、型別需求(type requirements)、複合需求(compound requirements)和巢狀需求(nested requirements)。Requirements之間由分號分隔,只有當每個都滿足時整個表示式才為true
。
我們後面再來看requires
表示式怎麼用,現在我們要了解的是我們可以提出哪些需求。
簡單需求
任意不以requires
關鍵詞開頭的表示式都可以作為簡單需求,當該表示式語法正確時需求滿足。由於引數列表中的變數不實際存在,這個表示式當然也不會被求值。
requires (T a, T b)
{
a + b;
}
型別需求
typename
後跟一個型別名成為型別需求,當該型別存在時需求滿足。型別需求可以用來檢查巢狀型別和模板例項化。
requires
{
typename T::type;
typename S<T>;
}
複合需求
複合需求要求一個表示式合法,且結果型別符合一定約束,並可規定noexcept
:
{ 表示式 } 可選的noexcept -> concept名 可選的<引數列表>;
後面會講型別代入concept的規則,畢竟現在連concept都沒講呢。
requires (T x)
{
{++x} -> std::same_as<T&>;
}
巢狀需求與requires子句
巢狀需求就是requires
子句(這句話不太嚴格,但沒有必要糾結它們的區別)。requires
後跟一個bool
常量成為一個requires
子句,僅當該bool
常量的值為true
時,子句所在的需求被滿足,或所在的模板有效。預告一下,把引數代入一個concept可以得到true
或false
,而一個concept可以包含多個需求,所以巢狀需求就是多條已定義的需求的組合。
requires (T x) // requires表示式
{
requires true; // requires子句
requires std::random_access_iterator<T>; // requires子句,std::random_access_iterator是一個concept
requires requires (std::size_t n) // 第一個是requires子句,後跟bool值;第二個是requires表示式,產生bool值
{
x += n;
};
}
concept
我們一般用concepts(概念)一詞指稱這一套C++20特性。前面介紹了各種需求,它們寫起來比較長,應該用一個名字來概括它,這個名字將成為一個concept
。
concept
的語法很簡單:
template<模板引數列表>
concept 名字 = bool表示式;
bool表示式
當然必須是常量表達式,通常是與模板引數列表有關的requires
表示式,和其他concept
的邏輯組合。concept
可以產生bool
值,想象一下把concept
換成bool
當變數模板就可以了。除此以外,concept
作為concept
可以用在requires
子句和requires
表示式中。我們稍後再來看其他用法。
concept
不能遞迴引用自己。concept
不能單獨宣告,所以不會出現兩個concept
相互引用的情況。下一節將介紹的四種約束,concept
一個都不能有。
標準庫定義了許多concept
,分佈在<concepts>
、<iterator>
和<ranges>
中。它們中的一些與<type_traits>
中is_
開頭的型別有相同的含義,但名字不同(而且不是僅僅去掉is_
)。
分類 | 名稱 | 功能 |
---|---|---|
語言核心 | same_as | 與某型別相同 |
derived_from | 是某型別的子類 | |
convertible_to | 可以轉換為某型別 | |
common_reference_with | 與某型別有common_type |
|
common_with | 與某型別有common_reference |
|
integral | 是整型 | |
signed_integral | 是帶符號整型 | |
unsigned_integral | 是無符號整型 | |
floating_point | 是浮點型別 | |
assignable_from | 可從某型別賦值 | |
swappable | 可swap |
|
swappable_with | 可與某型別swap |
|
destructible | 可析構 | |
constructible_from | 可由某些型別的引數構造 | |
default_initializable | 可預設初始化 | |
move_constructible | 可移動構造 | |
copy_constructible | 可拷貝構造 | |
比較 | equality_comparable | 可== 比較 |
equality_comparable_with | 可與某型別== 比較 |
|
totally_ordered | 可全序比較(== 、< 、<= 等) |
|
totally_ordered_with | 可與某型別全序比較 | |
物件屬性 | movable | 可移動和swap |
copyable | 可拷貝且movable |
|
semiregular | 可預設構造且copyable |
|
regular | equality_comparable && semiregular |
|
可呼叫 | invocable | 可用某些型別的引數呼叫 |
regular_invocable | invocable 且無狀態 |
|
predicate | 是bool 謂詞 |
|
relation | 是二元關係 | |
equivalence_relation | 是等價(== )關係 |
|
strict_weak_order | 是嚴格弱序(< )關係 |
對於最後兩個concept
,除了有各種可呼叫的函式的需求以外,==
運算子必須滿足自反性與對稱性,<
運算子也類似。這些是句法上無法檢查的,所以這兩個concept
更像是一種規約:如果模板引數被這種concept
約束,那麼客戶呼叫時傳入的引數就得滿足這些語義需求。由於concept
不能被特化,這一任務只能落到客戶肩上,並且我不認為C++能進化出語義檢查。
有些資料中的標準庫concept
是帕斯卡命名(PascalCase)的,因為最初的concept提案中是這樣寫的,原因可能是為了讓它看起來屬於新的C++20,或是與模板引數列表中型別大寫的習慣一致。後來幾個C++元老決定把concept
換回C++標準命名法(Rename concepts to standard_case for C++20, while we still can),單片語成也略有修改。後來又有少許修改,以最新標準草稿(寫作時為N4868)為準。
約束
現在到了應用concept
的時候了。Constraint(約束)指定模板引數的需求,是以下需求的邏輯與:
-
模板引數前的concept;
template<Concept T> // `Concept`是一個concept,下同 void f(T);
-
模板引數列表後的
requires
子句;template<typename T> requires Concept<T> void f(T);
-
在簡略函式模板宣告(用
auto
替代模板型別,C++20特性)中,型別佔位符(auto
)前的concept;void f(Concept auto _arg);
說來慚愧,寫C++這麼久,我從來沒有過簡寫模板型別為
auto
的想法,明明是知道泛型lambda的。 -
在函式宣告最後的
requires
子句。template<typename T> void f(T) requires Concept<T>;
這些requirements當然可以同時存在:
template<Concept1 T>
requires Concept2<T>
void f(T) requires Concept3<T>;
Concept2<T>
和Concept3<T>
都在requires
子句中,產生true
或false
,任意一個為false
時該例項化無效。
但是如何理解Concept1 T
呢?把T
插到Concept1
的引數列表的最前面,這裡為空,所以就是Concept1<T>
。另一個應用這一規則的地方是複合需求的返回型別部分,我們寫std::same_as<int>
,其含義為requires std::same_as<T, int>
(但是不能這麼寫)。
如果模板引數代入時出現了不存在的型別或變數,該約束僅僅是不被滿足,而不會產生編譯錯誤。
約束可以用於函式模板、類模板和成員函式,非模板類的非模板成員函式除外。函式模板與類模板的約束是類似的,只有滿足約束時模板才能例項化;對於成員函式的約束,如果它作用於模板類的模板引數,當約束不滿足時,並不是類模板不能被例項化,而是例項化後的模板類沒有這個成員函式:
#include <concepts>
template<std::regular T>
struct Container
{
template<std::same_as<int> U>
void f(U u) { }
void g()
requires std::same_as<T, int>
{ }
};
int main()
{
Container<int> ci;
ci.f(1);
ci.g();
Container<double> cd;
cd.f(1);
cd.g(); // error
}
像特化和偏特化一樣,concept
之間存在的包含關係也能用於過載決議——如果A
成立則B
一定成立,那麼例項化時會優先匹配B
的那一個實現。但是,concept
的包含關係有時會不符合直覺,即兩個concept
看似包含卻不能被編譯器發現:
template<class T> constexpr bool is_meowable = true;
template<class T> constexpr bool is_cat = true;
template<class T>
concept Meowable = is_meowable<T>;
template<class T>
concept BadMeowableCat = is_meowable<T> && is_cat<T>;
template<class T>
concept GoodMeowableCat = Meowable<T> && is_cat<T>;
template<Meowable T>
void f1(T); // #1
template<BadMeowableCat T>
void f1(T); // #2
template<Meowable T>
void f2(T); // #3
template<GoodMeowableCat T>
void f2(T); // #4
void g(){
f1(0); // error, ambiguous:
// the is_meowable<T> in Meowable and BadMeowableCat forms distinct
// atomic constraints that are not identical (and so do not subsume each other)
f2(0); // OK, calls #4, more constrained than #3
// GoodMeowableCat got its is_meowable<T> from Meowable
}
如果Meowable<T>
,那麼一定有is_meowable<T>
,所以BadMeowableCat<T>
也滿足,為什麼不能判斷出Meowable
和BadMeowableCat
之間的包含關係呢?包含關係作用在由&&
和||
連線的邏輯表示式上(實際上是合取與析取),通過深入到判斷兩個原子的(不是&&
或||
連線的)表示式是否相同從而決定包含關係,而只有相同的concept
加上相同的模板引數才是相同,其他表示式即使再長得一樣也是不同的。
在上面的例子中,編譯器認為BadMeowableCat
中的is_meowable
和Meowable
中的那個不一樣,從而兩個concept
之間沒有包含關係,於是f1
的過載決議就是二義的;而GoodMeowableCat
顯然包含了Meowable
,所以對f2
的呼叫就是合法的。
另一方面,包含關係的檢查一定會深入到最底層的concept
,所以沒有必要給所有自定義的concept
進行非常嚴格的層次劃分。但是有一點是原則性的,就是當你需要不同約束程度的concept
時,它們的最底層必須都被有名字的concept
封裝起來。<type_traits>
裡有那麼多變數模板,<concepts>
還要分別用不同的、有些混淆性的名字包裝一下,正是因為這個。
模板升級
面向過程、基於物件、面向物件、泛型和函式式這幾個程式設計正規化是逐漸加入C++的。起初,C++並沒有模板,直到1990年。Bjarne Stroustrup對模板的要求是(以下翻譯了跟沒翻一樣):
-
Full generality/expressiveness
-
Zero overhead compared to hand coding
-
Well-specified interfaces
後來的實現滿足了前兩條:針對第一條,C++模板是圖靈完全的;針對第二條,C++模板帶來更好的執行時效能(相比於qsort
或虛擬函式這一類實現);唯獨第三條沒有解決,導致冗長的模板錯誤,並且衍生出以SFINAE為代表的一些奇技淫巧。它們貫穿我之前寫的<functional>
系列,成功勸退了很多讀者。
C++20帶來了解決方案——concept
與約束。實際上concept
早在零幾年就出現在C++標準的草稿裡了,但在2009年被刪除,沒有進入C++11(這一套工具非常複雜,C++20中只是它的簡化版)。後來組委會又嘗試了concepts lite,但也沒有進入C++17。與此同時有一條支線concepts TS在發展,並在GCC中實現了出來,以此積累經驗。C++20中的concept
與TS還有一定區別,是總結了concept
的各種實現以後選擇的。
現在我們就來看一下concept
如何給模板程式設計進行升級。以下例子來自meds::function
,是我為一個華麗而無用的微控制器專案寫的庫。
Tag Dispatching
首先是還講點道理的tag dispatching。S
是用來放物件的空間的型別,T
是要放的物件的型別,一個T
能否放進一個S
將決定initialize
等一系列操作的方法,而object_manager
對外提供一個介面,在內部進行分類討論:
template<typename S, typename T>
class object_manager
{
private:
using local_storage = std::integral_constant<bool,
std::is_trivially_copy_constructible<T>::value
&& sizeof(T) <= sizeof(S)
&& alignof(S) % alignof(T) == 0
>;
public:
static void initialize(S* _tar, T&& _obj)
{
initialize(_tar, std::move(_obj), local_storage());
}
private:
static void initialize(S* _tar, T&& _obj, std::true_type )
{
new (reinterpret_cast<T*>(_tar)) T(std::move(_obj));
}
static void initialize(S* _tar, T&& _obj, std::false_type)
{
_tar->template reinterpret_as<T*>() = new T(std::move(_obj));
}
};
當T
可以放進S
時,local_storage
將成為true_type
,匹配到第二個initialize
,反之則為第三個。
這種操作還可以接受,但有了concept
以後會更好:
template<typename S, typename T>
concept locally_storable = std::is_trivially_copy_constructible<T>::value
&& sizeof(T) <= sizeof(S)
&& alignof(S) % alignof(T) == 0;
template<typename S, typename T>
class object_manager
{
public:
static void initialize(S* _tar, T&& _obj)
{
reinterpret_cast<T*&>(*_tar) = new T(std::move(_obj));
}
static void initialize(S* _tar, T&& _obj) requires locally_storable<S, T>
{
new (reinterpret_cast<T*>(_tar)) T(std::move(_obj));
}
};
SFINAE
然後就是不講章法的SFINAE了。下面我們要根據一個類的可比較性呼叫不同實現,分為兩步:function_eq_comp
中定義了value
指示模板引數T
型別的兩個例項是否可以用operator==
比較,function_object_compare
根據其結果執行不同操作。
template<typename T>
class function_eq_comp
{
private:
using one = int;
struct two
{
one unused[2];
};
template <typename U,
typename = decltype(std::declval<U>() == std::declval<U>())>
static one test(int);
template <typename>
static two test(...);
public:
static constexpr bool value = sizeof(decltype(test<T>(0))) == sizeof(one);
};
template<typename T>
typename std::enable_if< function_eq_comp<const T&>::value, bool>::type
function_object_compare(const T& _lhs, const T& _rhs)
{
return _lhs == _rhs;
}
template<typename T>
typename std::enable_if<!function_eq_comp<const T&>::value, bool>::type
function_object_compare(const T& _lhs, const T& _rhs)
{
return false;
}
當==
運算子可用時,one test(int)
函式正確定義,test
函式的返回型別將會是one
,value
為true
,否則one test(int)
錯誤,根據SFINAE,test
的呼叫落入two test(...)
,value
為false
。
當兩個const T&
不可比較時,function_eq_comp<const T&>::value
為false
,std::enable_if
沒有定義type
,第一個function_object_compare
的模板型別發生錯誤,根據SFINAE,該過載被忽略;與此同時第二個是可用的。反之,會呼叫到第一個。與tag dispatching中true_type
和false_type
並列出現類似,function_eq_comp<const T&>::value
與它取!
的表示式也都得出現,不能像上面的concept
實現那樣利用兩個函式之間由過載優先順序建立起的層次關係。與上一節相比,這裡的程式碼重複更噁心一點。
用concept
寫會好看很多,尤其是在檢查operator==
可以用std::equality_comparable
的前提下:
template<typename T>
bool function_object_compare(const T& _lhs, const T& _rhs)
{
return false;
}
template<typename T>
bool function_object_compare(const T& _lhs, const T& _rhs)
requires std::equality_comparable<const T&>
{
return _lhs == _rhs;
}
思考題
-
下面這段程式碼錯在哪?
template<typename T, typename U> requires (T t, U u) { t + u; } auto add(T t, U u) { return t + u; }
-
* 查閱資料,寫出一個巢狀需求接受但
template
後requires
子句不接受的表示式。(這道題沒什麼意義,只是想讓你去查點資料。) -
不查閱資料,判斷
std::derived_from
的兩個引數(基類、子類)哪個在前,並給出判斷依據。 -
如何給一個函式新增約束,使得它能接受任意數量的相同型別的引數?
-
試用
concept
改寫一個void_t
技巧的例項。
擴充套件閱讀
C++20: Two Extremes and the Rescue with Concepts等一系列文章
Does constraint subsumption only apply to concepts?
The tightly-constrained design space of convenient syntaxes for generic programming