夜雨聆风学习资料网

ARTICLE · 1034888

C++20: Concepts概念:给模板加上真正的约束

C++20: Concepts概念:给模板加上真正的约束

约束写进代码,编译器才会在第一道关就拦住不对的类型。

上一篇 C++20 总览里提过,Concepts 是这次大版本的四大件之一。这一篇就专门把它讲清楚。

先从一个老问题说起。

写 C++17 及之前的模板时,“这个类型必须满足什么要求”这件事,只能活在三个地方:

  • 文档注释里
  • 你的脑子里
  • 编译器爆炸的错误信息里

比如:

template <typenameT>Taverage(T a, T b) {return (a + b) / 2;}

这里默认 支持 和 ,但代码里没有任何地方把这件事说出来。传一个不合适的类型进来,编译器会钻进函数体深处,报出一串没人想看的错误。模板层次一深,这个问题会指数级恶化。T+/

Concepts 解决的就是这件事:

把“模板参数必须满足的要求”变成代码里显式的、命名的、可复用的约束。

这篇会覆盖:

  • Concept 怎么定义、怎么用
  • 标准库自带的概念
  • requires表达式怎么检查成员和表达式
  • 多类型 Concept 和临时约束
  • 几个容易踩的坑

一、最核心的理解

如果只记一句话,我建议记这句:

Concept 是一个“编译期布尔值”,它回答的问题是:这个类型(或这组类型)满不满足我写下的要求。

所以它有三个直接推论:

  • 可以参与重载决议:满足就走这个版本,不满足就走别的
  • 可以用在 里:直接把检查写死在代码里static_assert
  • 可以组合:、 随便拼&&||

先看最小的例子。

二、定义一个 概念

template <typenameT>conceptNumber = std::integral<T> || std::floating_point<T>;

读法很直白:一个类型是 ,当且仅当它是整数类型或浮点类型。Number

右边那串是一个“约束表达式”,编译期求值,结果必须是 。bool

定义好之后,最直接的用法有两个。

1. 约束模板参数

template <NumberT>Ttwice(T value) {return value * 2;}

Number 直接替换了原来的 。传进来的类型不满足约束时,这个模板压根不参与选择,编译器会直接告诉你“没找到匹配的重载”,而不是函数体深处的一百行错误。typename

2. 编译期检查

static_assert(Number<int>);static_assert(!Number<std::string>);

Concept 本质是个布尔常量,所以 拿它做检查非常自然。写库的时候,这一招可以提前把“哪些类型能用”钉死。static_assert

有了 Concept,重载分发从「碰运气」变成「按条件排队」。

三、用 Concept 做重载分发

Concepts 一个非常顺手的用法:同一个名字,按约束不同给不同实现。

voiddescribe(std::integral auto value) {    std::cout << "integer: " << value << '\n';}voiddescribe(std::floating_point auto value) {    std::cout << "floating point: " << value << '\n';}

这里用了标准库自带的 和 ,函数参数写法是 。std::integralstd::floating_pointconcept auto

调用 走第一个, 走第二个。和 C++17 时代用标签分发、 那一套比,代码可读性完全不是一个量级。describe(42)describe(3.14)enable_if

顺带说一下 里常用的几个自带 Concept,基本不用自己造轮子:<concepts>

  • std::integral、:算术类型std::floating_point
  • std::same_as<T, U>:两个类型完全相同
  • std::convertible_to<From, To>:能隐式转换
  • std::invocable<F, Args...>:能这样调用
  • std::regular:能默认构造、拷贝、比较的“正常类型”

四、 表达式:检查成员和表达式requires

上一节的条件还是“类型分类”级别的。真正让 Concepts 强大的,是 表达式——它可以直接检查“这个类型身上有没有这些东西”。requires

template <typenameT>conceptContainerLike = requires(T container) {    { container.size() } -> std::convertible_to<std::size_t>;    { container.begin() };    { container.end() };};

读法:

  • requires(T container) 是在说“假设我有一个 类型的对象叫 container”,注意这个对象是假想的,不会真的构造T
  • 大括号里的每一条,都是要求能合法写出来的表达式
  • { container.size() } -> std::convertible_to<std::size_t> 这种写法更进一层:不仅要求这个表达式合法,还要求它的返回类型满足后面的约束

于是就有了这样一个“只看能力不看血统”的函数:

template <ContainerLikeC>std::size_t count_elements(constC& container) {return container.size();}

std::vector 能传, 能传,任何有 的自定义容器都能传。这就是所谓的“鸭子类型,但是编译期受控的”。std::stringsize()、begin()、end()

同理可以定义“能打印到流上”的约束:

template <typenameT>conceptPrintable = requires(std::ostream& os, constT& value) {    { os << value };};template <PrintableT>voidprint_line(constT& value) {    std::cout << value << '\n';}

注意一个细节: 表达式里声明的参数默认是 的吗?不是,这里 是显式写出来的。如果你想允许非常量成员函数,就用非常量引用;想更严格,全用 。检查出来的约束就跟着变。requiresconstconst T& valueconst

五、多类型 Concept 与临时约束

1. 多类型 Concept

Concept 可以带多个类型参数:

template <typenameFromtypenameTo>conceptConvertibleTo = std::convertible_to<FromTo>;

使用时有两种写法。第一种是老实的 子句:requires

template <typenameTotypenameFrom>requiresConvertibleTo<FromTo>Toconvert_to(constFrom& value);

第二种更优雅,把第二个参数直接用 Concept 约束:

template <typenameToConvertibleTo<ToFrom>Toconvert_to(constFrom& value) {returnstatic_cast<To>(value);}

ConvertibleTo<To> From读作: 这个类型必须满足 。类型参数之间有依赖关系时,这种写法特别清爽。FromConvertibleTo<From, To>

2. 临时约束

不是所有约束都值得起名字。一次性、和类型分类无关的约束,直接写在 子句里就行:requires

template <typenameT>requires (sizeof(T) >= 4)voidonly_big_types(constT& value) {    std::cout << "sizeof = " << sizeof(T) << ", value = " << value << '\n';}

requires (条件)里可以放任何编译期布尔表达式,调用 Concept 也行,算 也行。原则很简单:sizeof

会复用、有语义名字的,做成 Concept;一次性的,直接 子句。requires

六、约束不满足时,错误信息什么样

这是 Concepts 最实际的收益之一,值得专门说。

C++17 时代,把 传给上面那种 ,报错是从函数体里炸出来的——因为编译器先选定了模板,实例化到一半才发现 不存在。std::stringaverage+

C++20 有约束之后,路径变了:

编译器先看约束,不满足就认为“这个候选不存在”,直接在调用点报“没有匹配的函数”。

错误从“实例化失败”变成了“候选被排除”,报错位置和原因都直观得多。模板库越复杂,这个差距越大。

七、完整示例代码

下面这份示例把上面所有用法放进了一个程序:

  • 定义 并用 验证Numberstatic_assert
  • 用 Concept 约束模板函数
  • 按约束做重载分发
  • requires表达式检查容器接口和打印能力
  • 多类型 Concept 依赖写法
  • 临时 子句requires
#include <concepts>#include <iostream>#include <string>#include <vector>voidprint_section(const std::string& title) {    std::cout << "\n== " << title << " ==\n";}template <typenameT>conceptNumber = std::integral<T> || std::floating_point<T>;static_assert(Number<int>, "int should be a Number");static_assert(!Number<std::string>, "string is not a Number");template <NumberT>Ttwice(T value) {return value * 2;}voiddescribe(std::integral auto value) {    std::cout << "integer: " << value << '\n';}voiddescribe(std::floating_point auto value) {    std::cout << "floating point: " << value << '\n';}template <typenameT>conceptContainerLike = requires(T container) {    { container.size() } -> std::convertible_to<std::size_t>;    { container.begin() };    { container.end() };};template <ContainerLikeC>std::size_t count_elements(constC& container) {return container.size();}template <typenameT>conceptPrintable = requires(std::ostream& os, constT& value) {    { os << value };};template <PrintableT>voidprint_line(constT& value) {    std::cout << value << '\n';}template <typenameFromtypenameTo>conceptConvertibleTo = std::convertible_to<FromTo>;template <typenameToConvertibleTo<ToFrom>Toconvert_to(constFrom& value) {returnstatic_cast<To>(value);}template <typenameT>requires (sizeof(T) >= 4)voidonly_big_types(constT& value) {    std::cout << "sizeof = " << sizeof(T) << ", value = " << value << '\n';}voidbasic_concept_demo() {print_section("basic concept");    std::cout << twice(21) << '\n';    std::cout << twice(2.5) << '\n';}voidoverload_dispatch_demo() {print_section("overload dispatch by concepts");describe(42);describe(3.14);}voidrequires_expression_demo() {print_section("requires expression");const std::vector<int> data{12345};const std::string text{"concepts"};    std::cout << count_elements(data) << '\n';    std::cout << count_elements(text) << '\n';}voidprintable_demo() {print_section("printable concept");print_line(7);print_line("printable");print_line(std::string("modern c++"));}voidmulti_type_concept_demo() {print_section("multi-type concept");constdouble value = convert_to<double>(42);    std::cout << value << '\n';}voidad_hoc_constraint_demo() {print_section("ad-hoc constraint");only_big_types(3.14);only_big_types(std::string("big enough"));}intmain() {basic_concept_demo();overload_dispatch_demo();requires_expression_demo();printable_demo();multi_type_concept_demo();ad_hoc_constraint_demo();print_section("summary");    std::cout << "Concepts turn template requirements into named, checkable and reusable code.\n";return0;}

八、怎么理解这份示例

1.basic_concept_demo()

twice的约束只有一句 , 和 都能走。如果传一个 ,编译器会在调用点直接说:没有匹配的重载。这就是和 C++17 最大的体验差别。Numberintdoublestd::string

2.overload_dispatch_demo()

同一个 ,整数走整数版本,浮点走浮点版本。以前实现这个效果要么写两个名字,要么上 标签分发,现在约束本身就是重载规则。describeenable_if

3.requires_expression_demo()

count_elements只关心“有没有 ”,根本不管你是 还是 还是手写容器。这条还额外锁死了返回类型。size()、begin()、end()vectorstring{ container.size() } -> std::convertible_to<std::size_t>

4.printable_demo()

Printable检查的是“能写入 ”这个能力。注意 传的是 ,它也满足约束, 本来就重载了它。ostreamprint_line("printable")const char*<<

5.multi_type_concept_demo()

convert_to<double>(42)显式指定目标类型 , 被推导成 ,约束 在参数列表里就完成了检查。doubleFromintConvertibleTo<To> From

6.ad_hoc_constraint_demo()

only_big_types的约束就是一句 ,、、 都能过; 就过不了。不值得为它起名字,直接写在 子句里刚刚好。sizeof(T) >= 4intdoublestd::stringcharrequires

九、几个容易踩的点

1. 表达式里的对象是假想的requires

requires(T container)不会真的构造一个 。它只是让编译器在“假想对象”上检查表达式是否合法。所以别在里面写有副作用的东西,那不是它的用途。T

2. 概念约束的是“接口”,不是“语义”

ContainerLike保证了有 ,但不保证 返回的是你想象中的含义。约束检查的是表达式合法性,业务语义还是靠文档和命名。size()size()

3. 参数和模板函数是两回事concept auto

void describe(std::integral auto value)是一个函数模板的简写,每个不同类型的调用会实例化不同版本。它没有类型擦除,想要“运行期多态”还是得走虚函数或 。std::variant

4. 约束顺序影响重载选择

如果两个重载都满足,其中约束更“强”的那个(是另一个的子集)优先。两个互不包含的约束同时满足时是歧义错误,设计重载集时要注意让约束形成包含关系。

5. 别把所有机械翻译成 概念enable_if

多数情况可以直接替换,但有些 SFINAE 技巧依赖的是推导过程中的细节行为,翻译前最好验证一下行为没变。新代码直接写 Concept 没问题,老代码建议边改边测。

十、总结

把这篇压缩成一句最实用的话:

Concepts 让“模板参数的要求”从注释和运气,变成了可命名、可复用、可检查的代码。

它带来的三个最直接的变化:

  • 报错从函数体深处挪到调用点,可读性质变
  • 按约束做重载分发,取代 标签分发enable_if
  • “只看能力不看血统”的泛型接口,第一次有了标准化写法

学习顺序建议:

  • 先把 里的自带 Concept 用熟<concepts>
  • 再学自己定义 Concept 和 表达式requires
  • 最后理解约束参与重载决议的规则,那是后面写泛型库的基础

下一篇接着讲 子句与约束表达式的更多写法,把这块的工具箱补全。requires

本文是「现代 C++ 系列」中的一篇,示例代码都来自实际可编译运行的工程。如果这篇对你有帮助,欢迎点个「在看」,也欢迎留言聊聊你在项目里是怎么用的。

相关学习资料