1. 程式人生 > 實用技巧 >C++20初體驗——concepts

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後面的那個名字,而是由兩個新關鍵詞conceptrequires支撐起來的。然後對於上面那個錯誤,編譯器會說:“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可以得到truefalse,而一個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(約束)指定模板引數的需求,是以下需求的邏輯與:

  1. 模板引數前的concept;

    template<Concept T> // `Concept`是一個concept,下同
    void f(T);
    
  2. 模板引數列表後的requires子句;

    template<typename T>
        requires Concept<T>
    void f(T);
    
  3. 在簡略函式模板宣告(用auto替代模板型別,C++20特性)中,型別佔位符(auto)前的concept;

    void f(Concept auto _arg);
    

    說來慚愧,寫C++這麼久,我從來沒有過簡寫模板型別為auto的想法,明明是知道泛型lambda的。

  4. 在函式宣告最後的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子句中,產生truefalse,任意一個為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>也滿足,為什麼不能判斷出MeowableBadMeowableCat之間的包含關係呢?包含關係作用在由&&||連線的邏輯表示式上(實際上是合取與析取),通過深入到判斷兩個原子的(不是&&||連線的)表示式是否相同從而決定包含關係,而只有相同的concept加上相同的模板引數才是相同,其他表示式即使再長得一樣也是不同的。

在上面的例子中,編譯器認為BadMeowableCat中的is_meowableMeowable中的那個不一樣,從而兩個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函式的返回型別將會是onevaluetrue,否則one test(int)錯誤,根據SFINAE,test的呼叫落入two test(...)valuefalse

當兩個const T&不可比較時,function_eq_comp<const T&>::valuefalsestd::enable_if沒有定義type,第一個function_object_compare的模板型別發生錯誤,根據SFINAE,該過載被忽略;與此同時第二個是可用的。反之,會呼叫到第一個。與tag dispatching中true_typefalse_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;
}

思考題

  1. 下面這段程式碼錯在哪?

    template<typename T, typename U>
        requires (T t, U u) { t + u; }
    auto add(T t, U u)
    {
        return t + u;
    }
    
  2. * 查閱資料,寫出一個巢狀需求接受但templaterequires子句不接受的表示式。(這道題沒什麼意義,只是想讓你去查點資料。)

  3. 不查閱資料,判斷std::derived_from的兩個引數(基類、子類)哪個在前,並給出判斷依據。

  4. 如何給一個函式新增約束,使得它能接受任意數量的相同型別的引數?

  5. 試用concept改寫一個void_t技巧的例項。

擴充套件閱讀

Constraints and concepts

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