C++ concepts

在编写模板代码时,虽然 template <typename T> 可以接收任意类型,但并不是传什么类型都能顺利编译。比如模板内部用到了求余运算符 %,一旦传入 double 浮点数就会报错,而且传统模板给出的报错信息通常很长、很难读懂。

为了解决这个问题,C++20 引入了 “概念(concepts)” 机制。它相当于给模板参数加了一道 “编译期准入门槛”:只有满足条件的类型才允许传进来,不满足条件的类型在调用处就会直接报错,并给出清晰的提示。

C++ 标准库内置 concept

在 C++20 中,一个 concept 本质上就是一组在编译阶段判断真假的类型条件。为了方便日常开发,标准库在 <concepts> 头文件中已经预先定义好了许多常用的内置概念,如下表所示。

常用内置概念
内置概念 约束说明
std::integral<T> 要求类型 T 是整数类型(如 int、long、char、bool)
std::floating_point<T> 要求类型 T 是浮点数类型(如 float、double)
std::same_as<T, U> 要求类型 T 与类型 U 完全相同
std::convertible_to<T, U> 要求类型 T 能隐式转换为类型 U

使用这些内置概念来约束函数模板或类模板时,最简单的方式就是直接在 template 尖括号里,使用概念名替换掉原来的 typename 或 class 关键字。

语法:

#include <concepts>

template <概念名 T>
返回类型 函数名(T 参数名) {
    // 函数体
}

说明:

当我们在尖括号里写成 template <std::integral T> 时,编译器会自动检查传入的类型 T 是否满足 std::integral<T> 约束。只有检查结果为 true 时,模板代码才会正常生成。

示例 1:约束求余函数模板

#include <concepts>
#include <iostream>

template <std::integral T>
T getRemainder(T a, T b) {
    return a % b;
}

int main() {
    std::cout << "17 % 5 = " << getRemainder(17, 5) << '\n';
    std::cout << "20L % 6L = " << getRemainder(20L, 6L) << '\n';
    return 0;
}

运行结果如下。

17 % 5 = 2
20L % 6L = 2

分析:

17 和 5 是 int 类型,20L 和 6L 是 long 类型。它们都满足 std::integral 整数约束,因此两次调用都可以正常编译运行。

相反,如果我们尝试传入 double 浮点数,编译器在检查函数调用时就会直接指出类型不满足 std::integral 约束。下面这句代码就无法通过编译。

double r = getRemainder(7.5, 2.0);    // 不满足整数约束

约束模板参数的 3 种写法

在阅读现代 C++ 代码时,小伙伴们会发现同一个概念约束共有以下 3 种常见写法:

  • 替换 typename:直接写成 template <std::integral T>,代码最简短直观,适合只有单个约束条件的情况。
  • requires 子句:在 template <typename T> 下方加上 requires 条件,适合使用 &&(与)或 ||(或)组合多个概念的场景。
  • 简写函数模板:直接在函数形参里写成 std::integral auto x,连头顶的 template <...> 都可以省掉。

示例 2:约束模板的 3 种写法

#include <concepts>
#include <iostream>

// 写法 1
template <std::convertible_to<double> T>
double toHalf(T x) {
    return static_cast<double>(x) / 2.0;
}

// 写法 2
template <typename T>
    requires std::integral<T> || std::floating_point<T>
T doubleVal(T x) {
    return x * 2;
}

// 写法 3
auto squareInt(std::integral auto x) {
    return x * x;
}

int main() {
    std::cout << "toHalf(9) = " << toHalf(9) << '\n';
    std::cout << "doubleVal(3.5) = " << doubleVal(3.5) << '\n';
    std::cout << "squareInt(6) = " << squareInt(6) << '\n';
    return 0;
}

运行结果如下。

toHalf(9) = 4.5
doubleVal(3.5) = 7
squareInt(6) = 36

分析:

在这个例子中,第 1 个函数 toHalf() 使用了双参数概念 std::convertible_to<double>。当我们使用双参数概念替换 typename 时,只需要在尖括号里写出第 2 个类型,编译器会自动把后面的 T 补到第 1 个位置,也就是检查 std::convertible_to<T, double> 是否成立。整数 9 通过检查后,函数体内再使用 static_cast 将其转为 double 计算。

第 2 个函数 doubleVal() 使用了 requires 子句配合 ||,同时允许整数和浮点数传入。第 3 个函数 squareInt(std::integral auto x) 则直接在形参里使用 auto 关键字触发简写函数模板,写起来像普通函数一样轻巧。

在实际开发中,这 3 种写法我们应该怎么选呢?如果只有一个简单的类型约束,优先使用写法 1 或写法 3。只有当我们需要使用 && 或 || 组合多个条件时,再使用写法 2 的 requires 子句。

C++ 自定义 concept

标准库提供的内置概念主要用来判断基础类型关系。如果我们在实际开发中有特定的要求,比如要求传入的对象不管是字符串还是容器,都必须支持调用 size() 成员函数,就可以使用 concept 和 requires 关键字自定义一个概念。

语法:

template <typename T>
concept 概念名 = requires(T 参数名) {
    测试表达式;
};

说明:

定义概念时,概念名通常采用首字母大写的驼峰命名(如 HasSize),并且花括号末尾必须加上英文分号(;)。等号右边的 requires(T v) { ... } 叫做 “requires 表达式”,我们可以分两部分来理解:

  • 小括号里的 (T v):表示假设现在有一个类型为 T 的参数 v。这里仅用于编译期语法检查,程序运行时不会真正创建对象。
  • 花括号里的表达式:让编译器在编译期试一试,对这个 v 写出花括号里的操作在语法上能不能通过编译。花括号内可以写一条或多条以分号结尾的测试语句,全部合法时结果就是 true,否则就是 false。

示例 3:自定义 HasSize 概念

#include <iostream>
#include <string>
#include <vector>

// 要求类型 T 支持调用 .size()
template <typename T>
concept HasSize = requires(const T& item) {
    item.size();
};

template <HasSize T>
void printSize(const T& container) {
    std::cout << "元素个数:" << container.size() << '\n';
}

int main() {
    std::string title = "Hello C++";
    std::vector<int> nums = {10, 20, 30, 40};

    printSize(title);
    printSize(nums);
    return 0;
}

运行结果如下。

元素个数:9
元素个数:4

分析:

上面代码中,我们自定义了 HasSize 概念,要求传入类型必须支持调用 item.size()。因为 std::string 和 std::vector 都自带 size() 成员函数,所以两次调用都能通过检查并输出元素个数。

相反,如果我们尝试把一个普通整数传给 printSize(),由于 int 没有 size() 成员函数,检查 HasSize<int> 时就会直接报错。

// 正确方式
printSize(nums);

// 错误方式
printSize(100);

另外小伙伴们要注意,所有概念检查都发生在编译阶段,不会增加任何运行期开销。平时如果 <concepts> 标准库里已有现成的概念,我们优先直接使用即可。只有当模板对成员函数或运算有特定组合要求时,再去自定义 concept。

提示

requires 子句:写在 template <typename T> 下方,后面直接跟编译期布尔条件,用来筛选满足要求的模板参数。

requires 表达式:形如 requires(T v) { v.size(); },用来测试花括号里的操作在语法上是否合法并得出 true 或 false,常用于自定义 concept。

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号