引言在上一章中我们了解到 C 模板不仅具备强大的泛化能力自身也是一种“图灵完备”的语言掀起了 C 之父 Bjarne Stroustrup 自己都没料到的“模板元编程”这一子领域。但是使用模板做泛型编程最大的问题就是缺少良好的接口一旦使用过程中出现偏差报错信息我们难以理解甚至无从下手。更糟的是使用模板的代码几乎无法做到程序 ABI 层面兼容。这些问题的根本原因是 C 语言本身缺乏模板参数约束能力因此既能拥有良好接口、高性能表达泛化又能融入语言本身是非常困难的。好在 C20 标准及其后续演进中为我们带来了 Concepts 核心语言特性变更来解决这一难题。那么它能为我们的编程体验带来多大的革新能解决多少模板元编程的历史遗留问题今天我们一起探究 Concepts。课程配套代码点击这里即可获取GitHub - samblg/cpp20-plus-indepth: This is the repo that contains the source code for Cpp20Plus course · GitHub定义 Concepts首先我们看看 Concepts 是什么它可不是横空出世的C20 为模板参数列表添加了一个特性——约束采用约束表达式对模板参数进行限制。约束表达式可以使用简单的编译期常量表达式也可以使用 C20 引入的 requires 表达式并且支持约束的逻辑组合这是对 C20 之前 enable_if 和 type_traits 的进一步抽象。在约束的基础上C20 正式提出了 Concepts也就是由一组由约束组成的具名集合。我们可以将一组通用的约束定义为一个 concept并且在定义模板函数与模板类中直接使用这些 concept 替换通用的 typename 和 class所以 concept 的定义必定是约束的表达式定义方式就像这样。template 参数模板 concept 名称 约束表达式;看一个最简单的例子如何定义一个 concept使用 type_traits 的简单版本。templateclass T, class U concept Derived std::is_base_ofU, T::value;这里定义了一个名为 Derived 的 concept有两个类型参数 T 和 U其中的约束定义为 std::is_base_of::value也就是判定 U 是否为 T 的基类。相比于传统基于 SFINAE 和 enable_if 的方式这种约束定义明显更加清晰。我们再来看一个更加具体的 concept。class BaseClass { public: int32_t getValue() const { return 1; } }; templateclass T concept DerivedOfBaseClass std::is_base_of_vBaseClass, T;在这段代码中我先定义了一个基类 BaseClass该类定义了一个成员函数 getValue。我又定义了名为 DerivedOfBaseClass 的 concept。需要注意的是我在这里使用了一个 C17 标准之后引入的工具变量模板 is_base_of_v相当于 is_base_of::value。简单来说这里定义的 concept可以判定模板参数 T 是否为 BaseClass 的派生类通过一些现代 C 语法变换我们定义的 concept 更易读和使用。有了定义好的 concept如何使用呢我写了一个例子。template DerivedOfBaseClass T void doGetValue(const T a) { std::cout Get value: a.getValue() std::endl; } class DerivedClass: public BaseClass { public: int32_t getValue() const { return 2; } }; int32_t c2() { DerivedClass d; doGetValue(d); BaseClass b; doGetValue(b); return 0; }我们先从代码的第 6 行开始定义一个名为 DerivedClass 的类它继承 BaseClass并重新定义了函数 getValue 的具体实现。接着在函数 c2 中我们定义了两个类型分别为 DerivedClass 和 BaseClass 的对象并调用函数 doGetValue在编译时进一步验证我们编写的基于 concept 的代码。doGetValue 是代码开头定义的一个模板函数它的模板参数很特别采用 DerivedOfBaseClass 定义了 T而非 typename/class这个意思是实例化时传入的模板参数 T必须符合 DerivedOfBaseClass 这个 concept 的要求。现在我们编译运行这段代码可以看到输出是后面这样。那么如果参数不是 BaseClass 的派生类会发生什么呢我们来看看后面这段代码。class NonDerivedClass { public: int32_t getValue() const { return 3; } }; int32_t c2() { NonDerivedClass n; doGetValue(n); return 0; }这段代码在编译时会报错报错信息是这样。这段报错信息中我们很容易知道由于 NonDerivedClass 并非 BaseClass 的派生类编译时发生了错误。从这个案例看如果 C 模板通过 concept 进行了“约束”调用者再也不需要从难以理解的模板编译错误中寻找问题根源了。基于 concept 的模板编译错误信息极具指导性足够简单、易于理解和纠错。不过讲到这里关于这段代码模板参数 T 的概念是 DerivedOfBaseClass函数 doGetValue 的参数必须符合 DerivedOfBaseClass 这个概念。templateclass T concept DerivedOfBaseClass std::is_base_of_vBaseClass, T;你可能会有一个疑问为何不使用虚函数来解决这个问题呢如果将 getValue 定义成虚函数并将 doGetValue 的参数类型设定成 const BaseClass不也可以实现一样的效果吗事实上虚函数是基于虚函数表等特性来实现的会对调用性能产生一定的损耗也可能因为不同编译器内存模型产生 ABI 兼容性问题。但是如果用模板编译器就可以通过编译期判定直接消除虚函数造成的性能副作用同时编译器也可以充分利用各种跨函数调用的优化方式生成性能更好的代码有效提升生成代码的质量。因此很多工程场景下这种方法比基于虚函数实现的多态更加合理。所以我们可以看到通过约束与 concept 这两个 C 核心语言特性变更高级抽象实现了对模板参数列表与参数的约束的逻辑分离。这不仅能提升模板函数或类接口的质量还可以彻底提升代码的可读性。如果从语言设计的角度进一步探讨Concepts本质就是让开发者能够定义在模板参数列表中直接使用的“类型”与我们在函数的参数列表上使用的由 class 定义的类型理论上讲是一样的。所以在面向对象的编程思想中我们思考的如何设计清晰且可复用的 class那从此以后在泛型编程中我们就需要转变一下思考如何设计清晰且可复用的 concept。可以说从 C20 标准及其演进标准之后concept 之于 C 泛型编程正如 class 之于 C 面向对象。了解了使用 Concepts 的优点接下来我们看看它的高级用法。我们会从 requires 关键字定义的约束表达式开始掌握逻辑操作符的组合用法之后会了解一下 requires 子句的概念、约束顺序规则涵盖 Concepts 的各个重要方面。约束表达式定义 Concepts 时我们提到一个 concept 被定义为约束表达式constraint expression。那什么是约束表达式呢从定义上来说约束表达式是“用于描述模板参数要求的操作符与操作数的序列”你也可以简单理解为布尔常量表达式。约束表达式本身是通过逻辑操作符的方式进行组合的用于定义更复杂的 concept。约束的逻辑操作符一共有三种。合取式conjunctions析取式disjunctions原子约束atomic constraints编译器实例化一个模板函数或者模板类时会按照一定顺序逐一检查模板参数是否符合所有的约束要求检查顺序具体可参考课后小知识。我们来深入理解一下这几种逻辑操作符。合取式合取conjunctions通俗易懂的说法就是“逻辑与”AND。在约束表达式中合取式就是通过 操作符把两个约束表达式连接到一起的。我们来看三个例子templateclass T concept Integral std::is_integral_vT; templateclass T concept SignedIntegral IntegralT std::is_signed_vT; templateclass T concept UnsignedIntegral IntegralT !SignedIntegralT;首先定义了一个名为 Integral 的 concept表示参数模板类型需要为整型。接着定义了名为 SignedIntegral 的 concept表示参数模板类型需要为有符号整型。定义体就是一个合取式 表示这个 concept 必须同时满足 Integral 这一 concept 和 std::is_signed_v 这一编译时常量表达式。最后我还定义了名为 UnsignedIntegral 的 concept表示参数模板类型需要为无符号整型。其定义体表示必须满足 Integral 和 !SignedIntegral 这两个 concept。编译器在处理合取式的时候要求左右两侧约束都必须满足。检测过程遵循逻辑与表达式自左向右的短路运算原则也就是说如果左侧表达式不满足要求右侧表达式也不会执行。因此即使右侧表达式执行存在问题也不会被执行引发检测失败。你可以结合后面的代码加深理解。templatetypename T constexpr bool get_value() { return T::value; } templatetypename T requires (sizeof(T) 1 get_valueT()) void f(T) { std::cout template version std::endl; } void f(int32_t) { std::cout int version std::endl; } void c15() { f(A); }我们在调用函数 f 的时候由于 A 的类型为 char其 sizeof 为 1因此 sizeof(T) 1 为 false。所以 get_value() 是不会执行的这里并不会引发编译错误char 类型没有::value。虽然有些反直觉但这就是合取式的短路运算原则。析取式析取disjunctions就是“逻辑或”OR。在约束表达式中析取式就是通过 || 操作符将两个约束表达式连接到一起。具体我们来看代码。template class T concept Integral std::is_integral_vT; template class T concept FloatingPoint std::is_floating_point_vT; template class T concept Number IntegralT || FloatingPointT;这里定义了三个 conceptIntegral、FloatingPoint 和 Number其中 Number 这个 concept 通过 || 将 Integral 和 FloatingPoint 这两个 concept 连接在一起表达只要为整型或者浮点型即可。编译器在处理析取式的时候要求左右两侧约束满足其一即可检测过程遵循逻辑或表达式自左向右的短路运算原则也就是只要左侧表达式满足要求右侧表达式就不会执行。因此即使这时右侧表达式执行存在问题也不会被执行引发检测失败。原子约束原子约束atomic constraints是最后一种约束表达式本身是一个很简单的概念但是对编译器解析约束表达式非常重要我们单独讲一下。原子约束由表达式 E 与 E 的参数映射组成。参数映射指的是 E 中受约束实体的模板参数template parameter与实例化时使用的模板实参template argument之间的映射关系。原子约束是在约束规范化过程中形成的一个原子约束不能包含逻辑与 / 或表达式。编译器在实例化过程中会检查参数是否满足原子约束。编译器会根据参数映射关系将模板实参替换成表达式 E 中的形参。如果替换后的表达式是一个非法类型或者非法表达式说明当前实例化参数不满足约束。否则编译器会对表达式的值进行左右值转换只有得到的右值类型是 bool 类型并且值为 true 时编译器才认定为满足约束否则就是不满足约束。值得一提的是E 的值必须是 bool 类型不允许通过任何隐式转换变为 bool 型这个和 C 中的 if 不一样。听起来有些复杂我们看一段代码很好理解。templatetypename T struct S { constexpr operator bool() const { return true; } }; templatetypename T requires (ST{}) void f1(T) { std::cout Template std::endl; } templatetypename T requires (1) void f2(T) { std::cout Template std::endl; } templatetypename T requires (static_castbool(ST{})) void f3(T) { std::cout Template std::endl; }在这段代码中如果调用 func 同时匹配 #1 和 #2会相互冲突导致编译失败而同时匹配 #1 和 #3 不会。这是因为BadNumber 中第一个 is_floating_v 和 Floating 中的 is_floating_v并不会识别为相同的原子约束导致编译器认为匹配了两个版本不知道选择哪个版本而失败。Number 中第一个原子约束直接使用了 Floating所以 Number 属于 Floating 的派生约束。虽然两个约束都能匹配但 Number 是比 Floating 更精准的匹配所以编译器最后会选择 #3 版本并不会发生编译错误。作为补充编译器为了后续进行统一的语法和语义分析会在约束表达式的解析过程中对约束表达式进行规范化。学习了三种约束表达式我们讨论几个重要细节包括 requires 表达式、requires 子句以及约束顺序等高级话题。requires 关键字我们在前面已经看到了由 type_traits 和 requires 构成的 concept针对 requires 表达式和 requires 子句这两个概念我们简单说明一下。requires 表达式跟 type_traits 类似requires 表达式本身就是一个谓词。requires 与普通约束表达式不同如果其定义体在完成参数替换后存在非法的类型或表达式或者 requires 定义中的约束存在冲突时会返回 false。反之如果完成参数替换后语法检查以及约束检查全部成功之后才会返回 true。定义方式是这样。requires (可选参数) { // 表达式结果必须为 bool 类型 表达式_1 表达式_2 ... }“可选参数”声明了一系列局部变量不支持提供默认参数大括号中的所有表达式都可以访问这些变量如果表达式使用了未声明变量编译时会报错。requires 大括号内可以定义几种不同的表达式分别用于约束接口或函数行为、变量、类型同时还可以对约束进行组合和嵌套。在这里我用一个例子来说明这几种不同表达式的用法。templatetypename T concept Histogram requires(T h1, T h2) { h1.getMoments(); // 要求有getMoments接口 T::count; // 要求有静态变量count h1.moments; // 要求有成员变量moments h1 h2; // 要求对象能够进行操作 typename T::type; // 要求存在类型成员type typename std::vectorT; // 要求能够模板实例化并与std::vector组合使用 { h1.getSubHistogram() } - same_asT; // 要求接口返回类型与T一致 { h1.getUnit() } - convertible_tofloat; // 要求接口返回类型能转换成float本质上接口返回类型可能是double { h1 std::move(h2) } noexcept; // 要求表达式不能抛出异常 requires sizeof(T) 4; };requires 表达式定义中的约束分为四种类型分别是基本约束第 3 到 6 行这种独立的、不以关键词开头的表达式语句都是基本约束只会进行词法、语法和语义的正确性检查并不会真实执行。编译器检查通过则约束检查通过否则检查失败。类型约束第 8 到 9 行这种使用 typename 开头的表达式语句是类型约束表达式用于描述一个类型如果类型存在则约束检查通过否则检查失败。组合约束第 11 到 13 行这种类似“{} [noexcept] - 约束”形式的都是组合约束编译器会执行{}中的语句并检查其结果类型是否符合后续约束如果符合约束则检查通过否则检查失败。此外还可以通过可选的 noexcept 检查表达式是否会抛出异常。嵌套约束第 15 行requires 开头的就是嵌套约束用于嵌套新的 requires 表达式如果 requires 表达式结果为 true 则检查通过否则检查失败。requires 子句下面我们看一看相较于 requires 表达式这一谓词requires 中出现的另一个关键字——requires 子句又是怎么回事由于 requires 子句和 requires 表达式并不是相同的概念所以我们可能会看到这种代码export template typename T1, typename T2 requires requires (T1 x, T2 y) { x y; } std::common_typeT1, T2 func( T1 arg1, T2 arg2 ) { return arg1 arg2; }我们在模板头上定义了一条 requires 子句它表达了模板参数应该在什么条件下工作在这里我们还可以定义更复杂或具体的约束表达式。这里有两个 requires但是含义完全不同requires (T1 x,T2 y) { x y; } 就是 requires 子句而前面的 requires 就是 requires 子句的开头后面所需的是一个约束表达式只不过 requires 表达式是约束表达式的一种所以这是合法的代码。requires 子句存在的意义是判断它所约束的声明在“上下文”中是否可行。所谓上下文分为三种。函数模板是在执行重载决议中进行的。模板类在决策适合的特化版本当中。模板类中的成员函数决策当显式实例化时是否生成该函数约束顺序在前面我们看到了给模板施加约束后受约束的版本比未受约束的版本更优。但是如果两个版本同样含有约束且都满足哪个最优呢编译器在后续分析前会将模板中所有的具名 concept 和 requires 表达式都替换成其定义接着进行正规化直到所有的约束变成原子约束及其合取式或析取式为止。然后分析约束之间的蕴含关系并根据约束偏序选择最优的版本。这里解释一下蕴含关系。针对约束 P 和约束 Q只有通过 P 和 Q 中的原子约束证明 P 蕴含 Q才认定约束 P 蕴含约束 Q编译器并不会分析表达式和类型来判定蕴含关系比如 N0 并不蕴含 N0。蕴含关系非常重要决定了约束的偏序。如果声明 D1 所受约束蕴含 D2 所受约束或者 D2 不受约束并且声明 D2 所受约束并不蕴含声明 D1 所受约束我们就可以认为声明 D1 的约束比声明 D2 的约束更加精准。这说明当编译器选择声明版本时如果参数同时符合 D1 和 D2 所受约束编译器会选择 D1也就不会引起编译错误。只有了解并利用约束的偏序规则我们才能更好地组织代码。总结今天我们了解了什么是 Concepts它是由一组由约束组成的具名集合约束支持普通编译期常量表达式同时支持采用 requires 表达式对模板参数进行更复杂的约束检查并且支持约束的逻辑组合这是对 C20 之前 enable_if 和 type_traits 的进一步抽象。在现代 C20 标准及其后续演进中约束的顺序通过概念约束进行决策而约束的合取式、析取式以及 Concepts在模板函数重载决议与类模板特化决策过程中扮演了核心角色。编译器通过约束的偏序规则决策出最优解。Concepts 这种高级抽象妥善解决了模板接口的类型与约束定义难题同时也改进了约束顺序决策。结合 C 泛型编程约束表达是一个较为复杂的议题如何正确且有效地利用这一全新特性呢下一章我们将通过实战案例来学习。