concepts是C++20最“简单”的大特性之一了
C++20的更新量级极大:其开创了大量新的编程模式,引入了大量语法点、标准库等等,对C++体系的影响可以与C++11相提并论。
其中更有“四大金刚”——concepts
、modules
、ranges
和coroutines
。本篇将深入浅出地探讨concepts
这一大特性。
Past——SFINAE
"Substitution Failure Is Not An Error"
其在元编程(metaprogramming)和模板重载决议时发挥重要作用:
当将推导的模板参数,或将指定的模板参数代入模板列表“失败”时,被代入的模板将从重载备选集中被移除。
SFINAE详解
当实例化一个模板时,显然需要对该模板内所有带模板参数的元素,以给定的模板参数进行代入。
SFINAE阶段,代入的目标对象只包括函数类型和其它模板参数中的所有类型(types)及表达式(expressions);只有在此阶段遇到的错误会被视为
SFINAE Errors
,触发重载决议前的筛选机制(将之移除而非卡出一个编译错误)。
C++会先将显式给定的模板参数进行代入,随后再执行模板参数推导(template parameter deduction),再代入推导的参数及默认参数。
代入目标对象“失败”示例:
1 | //Example of substituting template parameters in types and expressions used to determine function type |
可以发现,运用SFINAE的重点在于将潜在的错误点置于模板参数列表、函数类型涉及的表达式中,而非函数体内(函数体内的代码解析优先级非常低)。
常见的 SFINAE Errors 有:
- 数组类:尝试创建一个非正整数长度的数组、函数数组、引用数组、
void
类型的数组;
- 域解析符
::
类:当尝试使用域解析符时,- 左操作数不是一个类名/命名空间名;
- 右操作数试图访问的成员不存在;
- 右操作数试图访问的成员属性不符(预期成员属性是一个类型/非类型/模板,但访问到的成员属性不匹配);
- 左操作数不是一个类名/命名空间名;
- 引用类:尝试创建指向引用的指针、
void
的引用;
- 其它:对非类型的模板参数,赋予了一个不合法的类型(包括不能实现转换)
注意代入模板参数后,出现的“引用的引用”会触发引用折叠(reference collasping)而非错误,详见C++——右值引用和移动语义
<type_traits>
有关
C++11引入的<type_traits>
带来了极大量基于SFINAE和模板匹配、可用于识别给定类型参数的traits。几个示例如下:
1 | template<class Ty,std::enable_if_t<!std::is_array_v<Ty>,int> = 0> |
对于初次接触SFINAE的读者而言,上述模板声明会显得非常拗口难解。实际上其作用就是约束了func
的参数,要求其不能是数组类型。
std::is_array_v
是一个bool
类型的变量模板,接受一个类型参数实例化,返回该类型参数是否为数组类型;
而std::enable_if_t
(及其C++14前的原型std::enable_if
)则是一个由类型偏特化(partial
specialization)铸就的非常精巧的traits之一。其实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14template<bool Cond, class Ty = void>
struct enable_if
{
};
template<class Ty>
struct enable_if<true,Ty>
{
using type = Ty;
};
template<bool Cond,class Ty>
using enable_if_t = typename enable_if::type; //No need for "typename" since C++20
可见enable_if
存在一个偏特化的模板,当第一个模板参数Cond
为true
时,匹配的偏特化模板中存在一个别名声明type = Ty
;否则匹配的一般模板为空。
而enable_if_t
则利用了SFINAE,若Cond
为true
,则enable_if_t
这个语句是合法的,否则不合法(域解析符试图访问的成员不存在)。
在应用时,可以将待判定条件Cond
置为其第一参数,int
置为其第二参数,并赋予默认值= 0
:如此即可实现Cond
为true
时,剩余模板参数自动推导并匹配成功(宏观上,该模板可以被实例化);Cond
为false
时,该模板不能被实例化。
这个
0
的int
型模板参数没有任何意义,只是为了满足语法要求。理论上完全可以采用其它模板参数来满足语法限制,比如将类型替换为char
:template<class Ty,std::enable_if_t<Ty,char> = 0>
。注意不能替换为void
(因为不允许创建void
类型的对象)。
梳理至此,读者应也不难一眼看出该模板对Ty
的约束了。
但很显然利用SFINAE进行模板约束时,写出的代码可读性非常堪忧,毕竟上述还是非常简单的单一约束的状况。
来看一个(相对)很简单的标准库中双约束示例(std::make_unique
实现):
1 | _EXPORT_STD template <class _Ty, enable_if_t<is_array_v<_Ty> && extent_v<_Ty> == 0, int> = 0> |
宏看不懂没关系,模板的约束条件是:Ty
是一个数组类型,并且其大小(Extent)为零(形如int[]
)。
出路在何?
SFINAE应用中依然存在着诸多缺点,因而C++也在不断探索,并为程序设计者提供了诸多出路:tag
dispatch,C++17的if constexpr
,以及下方将述的重头戏——C++20的concepts
。
上述几种方式都是辅助“决议”型的方法。若只是希望在一定条件下产生一个编译错误,可以改用
static_assert
。
但这亦为其不能用于模板实例化选择的原因:static_assert
的生效域是在函数体/类体的内部(对于模板而言),任何含有static_assert(false)
的语句被实例化(将被编译)会立即产生编译错误,且显然根据定义其不属于 SFINAE Errors。
唯一的例外是结合if constexpr
使用(本质上依然是令该行不被实例化)。详见C++——编译期运算
Present——concepts
C++20与"concepts"有关的关键字有两个:concept
和requires
。
concept
concept
声明一个“概念”,其本质是一个编译期的模板bool
变量。其必须以一个编译期布尔常量(compile-time boolean constant)初始化之。如:1
2
3
4
5
6
7
8
9
10
11
12template<class Ty>
concept example = true; //A concept that always evaluate to be true
//Its equivalent compile-time boolean constant is like:
template<class Ty>
constexpr bool equivlant_cpvar = true;
template<class Ty>
concept small = sizeof(Ty) < 4; //For sizeof(Ty) < 4, evaluates to a compile-time boolean constant of true
static_assert(small<int>);
static_assert(!small<double>);requires
则既可以作为specifier修饰一个模板(进行约束),亦是定义concepts元素的一部分。
requires
作为约束提示字
通过requires显式约束模板变量时,操作数可以是任意于编译期能解析为bool
的变量。
C++11引入的
<type_traits>
中定义了极大量用于解析类型特征,并生成constexpr bool
的变量模板。
1 | template<class Ty> requires (small<Ty>) |
requires
作为concepts一部分
requires可以发起一个子句,语法是requires (init_params){...}
。
其中,init_params为可选项,如果加上了括号,则其中可以放置一些参数(如同写出函数的实参一样),可在下面的内嵌约束中使用。
大括号内可以添加任意数量的内嵌约束语句(nested
constraints),以分号分隔且存在逻辑与的关系(当且仅当内嵌约束全部满足时,该requires解析为true
)。
Nested constraints的分类如下:
- 简单约束:一个任意的表达式,该表达式合法(可以通过编译)时通过;
- 变量约束:
requires
加上一个编译期的bool
变量,该变量为true
时通过;
- 复合约束:语法为
{expr} (noexcept) (-> [concept])
,通过条件如下:- expr必须能通过编译(和“简单约束”相同);
- 若声明了noexcept,则expr必须被声明为
noexcept(true)
;
- 若指定了后继的
-> [concept]
(所谓"trailing type"-后继类型),则按以下的步骤校验:- 将
decltype(expr)
代入至后继的concept中(作为第一个变量,规则可以参考模板隐式约束), - 该concept也必须最终解析为
true
。
- 将
false
。 - expr必须能通过编译(和“简单约束”相同);
如现在希望约束一个迭代器类,要求:
- 其必须可以与同类对象比较并返回bool,且不能抛出异常;
- 其必须支持(前置)自增和自减运算;
- 其必须可以解引用并返回一个int&;
- 其大小必须不大于8。
1 |
|
显然concepts的约束比起SFINAE等方法可读性、维护性提升了数个数量级。
concepts的无数花式用法
前文提到C++标准委员会的绥靖政策导致了大量奇奇怪怪的concepts用法被选入标准。以对函数模板的约束为例,如下将具述之:
假设存在一个一元模板cpt
,
常规写法:
在p1或p2插入1
template<class Ty> /*p1*/ void func(Ty arg) /*p2*/ {}
requires cpt<Ty>
皆可(总结:在模板参数/函数形参声明后)。融合写法:
1
template<cpt Ty> void func(Ty arg) {}
本质上这种写法是将
Ty
代入cpt
的第一个参数,因此若cpt
是一个多元模板,可以手动指定后续参数,如template<cpt<int> Ty>
等价于施加了requires cpt<Ty,int>
的约束。
注意为避免编译链接冲突等,C++规定在这种“融合”写法中默认模板参数无效(因而若存在默认模板参数,你仍需手动指定之)。最“实用”的例子形如:template<std::formattable<char> Ty>
。
- 和
auto
结合的写法,直接约束auto
推导的模板形参、非类型模板参数、模板变量:
1
2
3void func(cpt auto arg); //constraining function template's argument list
template<cpt auto arg> void func(); //constraining non-type template parameter
cpt auto bar = ...; //constraining variable template
注意事项
concept
自身不能被约束,如果需要复用既有concept
,请在初始化表达式中指明:
1
2
3
4
5
6
7
8
9template<class>
concept one = true;
template<one Ty>
concept two = true; //Not compile: concepts cannot be constrained, disregarding implicitly(as this) or explicitly using `requires`
template<class Ty>
concept three = one<Ty> && true; //OK, Correct approach of using existing conceptsconcept
不能重载或特化;
concept
一定是模板,不能空手套白狼定义一个模板参数个数少于1的concept
。
若一个模板
requires
的(expr)
被解析为false
,则其行为类似于 SFINAE Error,即会将之从重载候选表中移除;但如果表达式expr
的解析本身是非法的,则其不属于SFINAE的范畴,会无视条件直接报编译错误(即使甚至没有对该concept
涉及的模板进行重载决议,如下述情况中存在非模板的重载void f(int)
)。解析非法除了表达式出现了类似" SFINAE Error "的情况外,其类型只要不是bool
亦会触发这一机制(不会进行隐式转换):1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16template<class Ty>
struct S
{
constexpr operator bool() const { return sizeof(Ty) <= 4; }
};
template<class Ty> requires (S<Ty>{})
void f(Ty); // #1
void f(int); // #2
void g()
{
f(0); // error: S<int>{} does not have type bool when checking #1,
// even though #2 is a better match
}修改方式为将
requires
子句改为requires (static_cast<bool>(S<Ty>{}))
。
再考虑下面这种情况,复用上述的struct S
:1
2
3
4
5
6template<class Ty> requires (bool(S<Ty>{}))
void f();
template<>
void f();
Future——浅谈底层原理和进阶应用
concept
和requires
是SFINAE、constexpr bool
的进阶,但后者在元编程(metaprogramming)中的作用依然不可动摇。
模板特化和重载
传统模板接受一个模板参数后,
- 若存在既有重载,则会优先选择之(即不会进行模板实例化);
- 否则,若存在模板特化,则会优先选择匹配的模板特化,并且特化程度越高,优先级越高;
- 最后再选择最为通用的模板。
函数模板重载(overload)和特化(specialization)的区别:
若函数模板被重载(重载的函数不是模板函数),则该重载函数必定会生成可执行代码,且在程序运行时会具有地址入口(除非其因未被调用等原因被编译器优化掉)。
但若函数模板被特化,其依然不一定会生成可执行代码,取决于是否有对该特化模板函数的调用(导致其发生了实例化)。同时,被特化的函数模板必须能够由原模板所“生成”(相当于,若以参数
Ty
特化原模板,则该函数的签名必须形如将Ty
代入原模板后,生成的函数签名);
而重载则不存在此限制,可以自由指定返回值、形参列表等等。无论是何种模板,其必须实例化(instanlize)后才会生成可执行代码,存在函数地址入口。
对于一个未被实例化的模板,其中允许存在部分不能通过编译的语句。
如:(为展示重载相关,以函数为例)
1 | template<class Ty> |
编译后生成的代码中,只会有后两个void func()
和void func(int)
的地址入口(不考虑优化)。
concepts和constexpr bool
目前<concepts>
提供的许多标准“概念”的实现,或多或少都转发了<type_traits>
的既有constexpr bool
变量模板。
由于后者的本质还是模板变量和模板变量的特化,因此模板的偏特化通过给出特化程度的差分,实现匹配优先级的区别。
下面给出两个(模仿)<type_traits>
的实例,其分别使用了变量模板特化和类模板特化:
1 | template<class Ty> |
相对地,concept
不支持重载/特化,试图重定义一个concept
(或“特化”之)会导致编译错误。
从SFINAE到requires
SFINAE的一大缺点是,对于应用了同样的约束条件的同名类模板,任意两个模板的SFINAE约束条件的交集必须是空集(或,“互斥”)。
否则当代入的模板参数处在二者交集时,由于SFINAE只会将匹配失败的模板列表从备选项中移除(而不会进行优先级判断等),故会因歧义而产生编译错误。
带来的最大问题出现在一个约束是另一个约束子集的情况:
1 | template<class Ty,std::enable_if_t<!std::is_array_v<Ty>,int> = 0> |
因此,标准库中不少既有实现都为了避此嫌而在enable_if
中“大排长龙”,如书接上回的make_unique
,三个重载的签名如下:
1 | std::unique_ptr<_Ty, std::default_delete<_Ty>> <typename _Ty, std::enable_if_t<std::is_array_v<_Ty> && std::extent_v<_Ty, 0U> == 0, int> <unnamed> = 0> make_unique(size_t _Size); |
而对于requires
,其用法相对就讲究了不少:若进行模板匹配时,给定的模板参数可以满足两个模板的concept
约束要求,则满足一个模板的约束蕴含了另一个模板的约束时,能够选择该约束更高的模板;否则将因歧义而编译失败。
定义约束A蕴含约束B:将二者都拆解成原子约束(atomic
constraint)后,逻辑上B是A的真子集。
如此做的优点非常明显,即若欲定义一个具有更严格约束的模板,只需在requires
或定义concept
时,在既有约束后以逻辑与&&
追加一些东西即可,不需要回头考虑既有模板的问题。
经典转发<type_traits>
警告
1 | template<class Ty> |
C++认为每个
concept
都仅由三个部件组成:逻辑与、逻辑或、原子约束。
注意原子约束具有唯一性,只有同名的约束才是相同的。换言之,即使两个原子约束解析的结果一定一致,其也属于不同的原子约束,如定义一个concept B = A;
,A
和B
依然属于不同的原子约束。
同时原子约束虽有其名,但其不一定是concept
,应理解作最小的constexpr bool
单元。如:requires(true && std::convertible_to<int,char> || std::is_trivial_v<const char*>)
,其中出现了字面量-true
、concept
-std::convertible_to
、constexpr bool
模板变量-std::is_trivial_v
,三者都是原子约束。
什么?你问为什么requires
后面可以接不是concept
的表达式?那自然是因为requires
的一定是concept
,若表达式自身不然,则会自动转化成匿名concept
这里就出现了一些非常奇怪的坑,引用cppreference的例子:
1 | template<class T> |
虽然Meowable
和is_meowable
的解析结果必定相同(事实上前者直接转发了后者),但其依然属于不同的原子约束,因而在对f1
实例化阶段进行模板匹配时,会因存在歧义而报错。
总结
concept
对于元编程(metaprogramming)是一次巨大更新,其极大提高了模板约束的复用性、可读性与可维护性,让本晦涩难懂的模板约束语句变得极为清晰。
学习concept
前仍因掌握与模板、SFINAE等有关的基础知识,concept
并非完全取代既有的几种模板约束机制,而是对其的有益补充。