东方青龙,
concepts
龙腾四海。
C++20的更新量级极大:其开创了大量新的编程模式,引入了大量语法点、标准库等等,对C++体系的影响可以与C++11相提并论。
其中更有“四大金刚”——concepts
、modules
、ranges
和coroutines
。本篇将深入浅出地探讨concepts
这一大特性。
其实concepts是C++20最“简单”的大特性之一了
Past——SFINAE
concepts其实并不是一个新的“概念”(???)。
模板(泛型)编程的课题可以占据C++的半壁江山。极高的设计自由度、一望无际的复用可能性、牵扯到的语法之繁杂、相关库的多样性使其一直以来都是C++语言的最重要课题,几乎没有之一。
所谓“C++其实是两门语言,带template
的是一种,不带template
的是另一种”的说法丝毫不为夸张。
而作为一门编译型语言,在Metaprogramming中我们自然希望对模板的参数有一定约束,让函数按设计者期望的方式进行调用。
而在传统C++中,除了一些语言上的小“华点”被复用,并没有太好的办法对模板参数进行高可读性、易于维护/复用、明确的约束。
C++11中提出了SFINAE(Substitution Failure Is Not An
Error)的概念。
SFINAE在模板编程中的应用在于完成模板参数替换(即将实例化)时,如果代入Ty = ...
会产生一些非法的表达式,则编译器会放弃这个重载(而不是继续试图在绝路上狂奔编译错误),转而试着寻找下一个重载进行匹配。
与SFINAE相搭配的内容还有:
- 对于全特化(full specialization)/偏特化(partial
specialization)的模板类,其与满足条件(可自动推导)的模板参数匹配优先级很高,且绑定后不会受SFINAE影响放弃。
- C++中,对于自动模板参数,其推导遵循最简原则(即优先尝试将令参数不带任何 cvref 修饰来实例化)。
(感觉又噎着了,赶紧上代码润润嗓子)
最简推导原则: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25//完美转发的场景
template<class Ty>
void fun(Ty&& arg);
int x = 2;
fun(1); //Ty = int,虽然Ty = int&&同样“能用”,但最简原则,不加引用优先
fun(x); //Ty = int&(比较显然,毕竟int就不能编译了)
//一些其它的函数调用
template<class Ty>
void bar(Ty arg);
bar(1); //Ty = int,虽然Ty确实可以是int&&,依然是“避重就轻”
bar(x); //Ty仍然是int,虽然Ty依然可以是int&,但毕竟int arg{x};是合法的,所以编译器不会“闲着没事干”
//std::forward
template<class Ty>
std::remove_reference_t<Ty>& forward(std::remove_reference_t<Ty>& arg) noexcept
{
return arg;
}
常见的非法表达式:
- (实例化代码中)建立了对void的引用;
- 试图对
void
或其它 不完整的类型 计算sizeof
;- 试图访问类的某个
typedef
或静态成员等,但该成员不存在;(SFINAE中最为常用之者)
C++中,在<algorithm>
的老STL算法库中经常能看到std::enable_if_t
的声明,其封装了std::enable_if
这一struct
。
C++中一定要分清模板函数的重载(overload)和特化(specialization)两个概念。特化有全特化(full specialization)和偏特化(partial specialization)两种。
模板函数只有“重载”和“全特化”,模板类只有“全特化”和“偏特化”。
对于模板函数,C++在匹配参数时若发现一个模板函数存在重载,则按照以下步骤决策:
- 先判定函数列表是否匹配,排除不匹配的重载选项;
- 若重载选项中一个是模板函数,一个是非模板函数,且非模板函数的形参列表和当前参数不需要隐式转换时,选择非模板函数;
- 否则,若可以通过自动匹配模板函数来令模板函数的形参列表和当前参数匹配,且不需要隐式转换时,选择实例化一个模板函数;
- 否则认为是存在歧义而编译失败。
注意:任何
template
的声明(变量、函数、类)无论基本还是特化,自身都不会产生任何编译代码;必须有调用方实例化之时,编译的程序才会有这部分代码(比如对于函数来说,存在指向该函数的指针)。
核心点就是特化(specialization)和实例化(instanitation)是不同的概念。
相对地,任何非template
的声明则都会产生编译代码(除非在Release版本中被编译器优化掉)。
1 |
|
模板类则有全特化和偏特化之分,标志为是否是
template<>
。
一旦出现template<>
,说明其指定了模板的全部参数,则为全特化,否则为偏特化。 C++匹配模板参数时,若该模板存在特化,则匹配优先级为全特化>偏特化>基本模板,其即为<type_traits>
中诸多utility的实现支撑原理之一。
且C++的模板参数匹配遵循最简原则(无需隐式转换的原始类型优先级最高,引用、指针、const
/volatile
修饰等优先级后置)。
<type_traits>
中提供了一个remove_reference_t
封装remove_reference
,来在模板编程中获取一个类型名移除引用信息的结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24template<class Ty>
struct remove_reference
{
using type = Ty;
};
template<class Ty>
struct remove_reference<Ty&>
{
using type = Ty;
};
template<class Ty>
struct remove_reference<Ty&&>
{
using type = Ty;
};
template<class Ty>
using remove_reference_t = typename remove_reference<Ty>::type;
static_assert(std::is_same_t<remove_reference_t<int&>,int>); //注意比较类型不能用operator==
Present——concepts?
C++20与"concepts"有关的关键字有两个:concept
和requires
。其中,
-
concept
声明一个“概念”,其本质是一个编译期的模板bool
变量。其必须作为一个模板变量声明,并以编译期常量(compile-time
constant)初始化之。如: 1
2
3
4
5
6
7
8
9
10
11template<class Ty>
concept example = true; //一个永远为真的concept
//其实它的本质就是
template<class Ty>
constexpr bool equivlant_cpvar = true;
template<class Ty>
concept small = sizeof(Ty) < 4; //对于sizeof()小于4的类型,获得一个true的编译期常量
static_assert(small<int>);
static_assert(!small<double>);
requires
则既可以作为specifier修饰一个模板(进行约束),亦是定义concepts元素的一部分。
requires作为约束提示字
作为前者时,其可以出现在template的多种可能位置(C++20为了避免过多争端,对各方势力采取绥靖政策而接受了多种不同写法)。
通过requires显式约束时,变量可以是一个concept
或编译期的bool
变量:
1
2
3
4
5
6
7
8
9
10//#1
template<class Ty> requires(small<Ty>)
class smallvector{};
//#2
template<class Ty> void func(Ty arg) requires(small<Ty> && std::is_nothrow_destructible<Ty>)
//Main body can be omitted(just declaration)
{
}(所以可能需要对一些旧的interface手动套皮)。
对于单模板变量的约束,其空置变量数必须为1(具体地,实例化时会将该模板参数填入该concept的第一个模板参数中,若其仍有后续非默认参数则必须指定之):
1
2
3
4
5template<class Tx,class Ty>
concept two_template_parameters = true;
template<two_template_parameters<int> Ty> //相当于约束 two_template_parameters<Ty,int>
void func(){}
C++14起大幅扩大了auto的适用范围,约束auto模板变量的方法也非常奇怪简单:
1
2
3
4void func(std::default_formattable<char> auto x)
{
}J
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必须能通过编译(和“简单约束”相同);
应用实例: 1
2
3
4
5
6
7
8
9
10
11
12
13//比如现在约束一个迭代器类,要求:
//1. 其必须可以与同类对象比较并返回bool,且不能抛出异常;
//2. 其必须支持(前置)自增和自减运算;
//3. 其必须可以解引用并返回一个int&;
//4. 其大小必须不大于8。
template<class Ty>
concept biint_iterators = requires(Ty it) //这里“幻化”出了一个变量it,可以在下文约束中使用。
{
{it != it} noexcept -> std::same_as<bool>;
++it; --it;
{*it} -> std::same_as<int&>;
requires sizeof(it) < 8;
};
显然concepts的约束比起SFINAE等方法可读性、维护性提升了数个数量级。