SFINAE范式过于复杂且不优雅,C++20中引入了足以完全取代 SFINAE 的新语言特性:概念(concepts)。
概念用于为模板指定模板参数的约束条件。
引入
例如,我们想为“数字”类型(int、double、float等)编写一个计算两个数字的和的函数模板sum()
。代码如下:
看起来似乎没有任何问题。但如果现在我将两个std::string
字符串作为参数传给sum()
会怎么样呢?
虽然没有考虑到参数为std::string
字符串的情况,稍稍有点出乎意料,但程序仍然运行良好。
但假如我现在传给sum()
的不是std::string
字符串,而是const char*
字符串,会怎么样呢?
程序编译出错了,并且出错信息不够直观,并不会直接告诉我们编译出错是因为我们传入了错误的类型,而是间接地说,const char* const
不是运算符+
的合法的操作数。
因为这个程序很短,所以程序的出错原因还能比较快的找出来,但如果程序更大一些,就没那么容易了。
假如现在程序中有一个类Custom
,我将类Custom
的实例传给函数模板sum()
又会发生什么呢?
程序编译出错,并且这次的错误信息足足有 265 行,要上下滚动窗口好久才能完整浏览一遍。因为很长,我不直接贴在文章中,可以打开这里查看。
虽然错误信息很长,但也能比较轻松的看出,错误信息是说,编译出错是因为类Custom
没有重载运算符+
。而不会直接说函数模板sum()
不接受类型为Custom
的参数。
从上面的例子可以看出,往函数模板sum()
里传什么类型的参数都行,对类型没有任何限制,即便传入预料之外的类型的参数,程序也能接受,不过有可能可以编译成功,也有可能编译失败。并且编译出错信息很不直观。
有没有一种办法,可以让程序只能接受指定的一些类型的参数,如果传的参数是别的类型,直接失败,并且能够直接指出编译失败的原因就是传入了错误类型的参数?
这就是概念(concepts)的用途。
对于上面的例子,我们的设计针对的是数字类型,也就是整数(int、long、short等)或浮点数(float、double等)。可以像下面这样为函数模板sum()
添加约束。
上面使用的reqires
关键字是概念(concepts)的语法的一种。新添加的requires
表达式的意思是T
需要满足“是整数”或“是浮点数”,否则就是不满足概念的条件约束。std::integral
和std::floating_point
是标准库<concepts>
中的预定义概念,所有的预定义概念可以看这里和这里。
对于数字类型的参数,程序正常编译;如果传入了不支持的类型(比如自定义类Custom
),将同样会编译出错,但错误信息短了不少,并且能直接知道错误原因是参数类型不满足条件约束:
概念(concepts)与类型特征(type traits)有什么区别和联系?
区别:C++规范允许概念与模板交互(而类型特征显然不能)。
如可以用概念代替模板声明语法中的typename
关键字。
又如上面出现过的requires
子句,可以出现在模板声明之后。
联系:类型特征可以作为requires
子句的一部分。
观察概念的实现可以知道,概念的底层是通过类型特征实现的。如概念std::same_as
的实现:
使用概念(concepts)的方式
约束模板参数(constrained template parameters)
在模板参数列表中,可以用概念名称替换模板声明template <typename T>
中的typename
关键字。这表示,类型T
必须满足概念中的约束条件。
例子 两个整数相加(无论是int
、short
还是long
)
requires 子句
例子 两个整数相加(无论是int
、short
还是long
),使用 requires 子句
文章开头的例子使用了 requires
子句,其中的约束条件是两个概念的组合(使用||
,两个概念满足其中之一即可),这里把代码再贴一遍:
例子 只接受指定类的子类,使用类型特征(type traits)
传入函数func()
的参数的类型,必须派生自类Player
或类Monster
。这个例子中使用了类型特征std::is_base_of_v
,可以用概念std::derived_from
替代。代码如下(注意T
和基类的位置颠倒了):
许多用于确定类型是否满足要求的标准库类型特征,都可以用相应的概念替代,大多数具有布尔成员value
的类型特征都属于这一类。
尾随 requires 子句(Trailing Requires Clause)
requires
子句可以放在函数签名和函数体之间。
这种语法的一种常见用法是,基于模板类型参数禁用类模板的某个成员函数。
例子 对某类型禁用类模板的某个成员函数
类模板Container
的实例Container<int>
有两个成员函数execute()
和run()
,而实例Container<double>
只有一个成员函数run()
,没有execute()
。
概念在缩写函数模板(abbreviated function template)中的使用
使用缩写函数模板语法可以很方便地创建模板函数,只需要将一个或多个函数参数的类型设置为auto
即可。如下:
若要使用概念约束函数参数,只需将概念放在auto
前即可,如下:
上面这段代码约束函数average()
的参数x
和y
必须是整数。
组合多种使用方式
多种概念(concepts)的使用方式可以组合使用。如下:
函数模板average()
使用了requires 子句、尾随 requires 子句,并且是缩写函数模板。
自定义概念
例子 在函数模板中使用自定义概念
这段代码没有实用性,只是为了说明自定义概念的语法。Integer
就相当于是标准库预定义概念std::integral
的别名。
例子 在if constexpr
中使用自定义概念
例子 结合两个标准库概念
当std::integral
和std::floating_point
满足其中之一,自定义概念Numeric
就成立。
例子 在 requires {}
中组合多个 requires
语句
requires {}
的大括号{}
中是多个独立的 requires
语句。
在上面这个例子中,如果类型T
能够和int
互转,则打印一些文字;如果不能,则打印另一些文字。
例子 无效的约束
requires {}
的{}
中的语句是 valid 的(有效的,合法的)即可,并不要求计算结果为true
。使用时可能在这个地方出错,如下:
虽然编译时编译器给出了一个警告,但仍编译成功了,并且在控制台打印了The concept is satisfied.
。为什么?不是应该什么都不打印吗?明明std::string
不是整数呀,明明 std::integral<T>
的结果是false
。
对于任意的T
,无论 std::integral<T>;
的结果是false
还是true
,它都是一个合法的(有效的,valid)语句。requires {}
仅仅只会检查大括号{}
内的语句是否有效,如果里面的语句都有效,就视为满足这个概念。
在 requires {}
中,如果不仅想要语句是有效的(valid),并且结果还要是true
,应该在语句前面添加requires
关键字,如下:
例子 组合requires
语句及其否定
requires
表达式中可以使用布尔表达式||
, &&
, !
。
下面的概念定义,要求类型T
满足:可以从int
转换为T
,但是不能从T
转换为int
,如下:
下面的概念定义,要求类型T
满足:T
不能是可以同时从int
转换为和转换到的类型,(可以在一个方向上转换,或两个方向都不能转换),如下:
例子 “函数式requires
”语法
类型T
必须实现加法+
运算符,如下:
类型T
必须实现运算符+
, -
, *
, /
,如下:
类型T
必须实现加法+
运算符,并且必须实现/
运算符,并且/
的右操作数必须是一个int
,例如2
。如下:
一个完整的例子:
例子 具有多个模板参数的概念
要求类型T1
和T2
可以进行加法+
运算,如下:
当概念用于约束模板参数时,会自动为第一个参数提供值。也就是说,如果概念只需要一个参数,则就不用为概念提供参数;如果概念需要多个参数,则就不用提供第一个参数,只需提供后续的参数。
例如,将std::integral
用于布尔表达式时需要显式提供模板参数,如std::integral<int>
,需要手动提供模板参数int
;而当将其用于约束模板时,参数在替换过程中会自动填充,如template <std::integral T>
,不需要手动提供模板参数,直接用std::integral
就可以。
例如,自定义的概念AddableTo
需要两个模板类型参数,用于布尔表达式时需要提供两个模板参数,用于约束模板时只需要提供一个模板参数,如下:
将概念和类一起使用
例子 使用 requires
表达式检查类(class)是否具有特定的成员变量或函数
关键代码:
完整代码:
另一种做法,使用“函数式 requires
表达式”,如下:
例子 类型要么有一个 size
成员,要么同时具有 width
和 height
成员
例子 约束成员变量的类型,成员size
的类型限定为整数
或者:
例子 约束成员变量的类型为确切的类型,检查成员size
的类型是否确定为int
或者:
意思是,类T
的数据成员size
去除const
和引用限定符后,应当与int
类型相同。也就是,size
的类型可以是int
,const int
,int&
, const int&
。如果不加std::remove_cvref_t
,就太严苛了些,size
的类型只能是int
,带const
, &
都不行。
例子 约束类必须具有特定成员函数,要求类具有一个不带参数的可调用的render()
成员函数
较为自然的方法是,使用函数式requires
表达式,然后提供使用这些函数成员的语句。
例子 约束类型是可调用的(函数指针)
在C++中,函数也有类型,就像任何其他类型一样,我们可以使用取地址运算符 &
来创建指向它们的指针。
在上面的例子中,call()
函数接收一个函数指针作为参数,然后调用指向的函数。
可以使用概念std::invocalble
来约束它:
上面将 std::invocalble
用于约束模板类型参数,它所需的模板参数值自动填充。如果将 std::invocalble
用作独立的表达式,它所需的模板参数值需要手动填写,如:
例子 约束类型是可调用的(类的成员函数)
如果要测试的函数是类的成员函数,需要使用范围解析运算符::
限定其确切位置,如SomeType::SomeMethod
;还需要为概念std::invocable
提供第二个模板参数,该参数是指向该成员函数所属的类的指针类型。
或者:
例子 约束类的成员函数只能使用特定类型的参数来调用
可以简单地通过在语句中传递示例值做到。
完整代码:
关键代码:
或者,将假设参数(上面是1
和2
)添加到函数式requires
语句的参数列表中,这样就可以显式指定参数类型,并可以为变量命名:
或者,使用std::declval()
创建假设值,如下:
概念的模板参数列表并不局限于只能使用单个参数,因此可以像下面这样定义和使用概念:
这也可以通过使用带参数的std::invocable
做到,如下:
或者:
关于std::invocable<>
尖括号里面的T*
指针类型,可以看前面 例子 约束类型是可调用的(类的成员函数)。
例子 约束普通函数只能使用特定类型的参数来调用
当使用概念约束模板类型参数时,概念的第一个模板参数是自动提供的,因此只写成template <std::invocable<std::string, int> T>
,而不用写成template <std::invocable<decltype(&T), std::string, int> T>
。
例子 约束成员函数具有特定的返回类型
要求对象有一个render()
成员函数,该函数不接受参数,函数的返回值满足概念std::integral
,如下:
注意,C++规范要求,在->
后面需要是一个概念,而不能是类型。如果想要约束函数返回某种特定类型(比如int
),可以使用标准库概念std::same_as
和std::convertible_to
,如下:
下面的例子,要求render()
成员函数接收类型为模板参数类型A
的参数,返回值类型为可转换另一个模板参数类型R
的类型,如下:
例子 测试类型是否实现特定运算符,如相等比较运算符,例如 ==
和 !=
运算符本质上是函数(或成员函数),所以可以用测试是否具有符合要求的普通函数(或成员函数)的做法来测试是否具有符合要求的运算符。
要求类型实现==
运算符,如下:
要求类型实现==
运算符,并且返回值可转换为布尔值,如下:
为概念另外添加一个模板类型参数,让使用者可以指定要对象与哪个类型进行比较,如下:
含义是,Container
和 int
两个类型之间可以进行比较,重载了相关的 ==
和 !=
运算符,并且比较的结果可以转换为bool
值。
标准库提供了与上面我们自定义的概念类似的概念,如下:
参考