第21章 概念(Concepts):C++20 Concepts基础、定义与使用约束、与SFINAE的对比

说实话,C++模板写了十几年,我最头疼的事情之一就是——编译报错。你写了个模板函数,传进去一个不支持某种操作的类型,编译器给你吐出一页又一页的报错信息。你盯着屏幕,心里骂娘,然后开始逐行分析那堆天书般的错误。

C++20 的 Concepts 就是为了解决这个痛点来的。它让模板的约束变得清晰、可读,而且报错信息友好得多。今天我们就来聊聊它。

21.1 什么是 Concepts?

Concept,说白了就是给模板参数加上的「能力要求」。你告诉编译器:我这个模板只接受那些能做加法、能拷贝、能比较的类型。如果传进来的类型不满足,编译器直接告诉你「这个类型不符合某某概念」,而不是甩给你一堆实例化失败的内部细节。

我个人习惯把 Concepts 理解为「模板参数的接口契约」。就像虚函数规定了派生类必须实现哪些方法一样,Concept 规定了类型必须支持哪些操作。

核心要点: Concepts 是编译期谓词,它在编译时检查类型是否满足一组要求。满足则通过,不满足则给出清晰错误。

21.2 定义自己的 Concept

定义一个 Concept 其实很简单。用 concept 关键字,后面跟一个布尔常量表达式就行。

#include <concepts>
#include <type_traits>

// 定义一个 Concept:类型 T 必须支持加法运算
template <typename T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::convertible_to<T>;
};

// 使用这个 Concept
template <Addable T>
T add(T a, T b) {
    return a + b;
}

你看,requires 表达式里写的就是我们对类型 T 的期望:它得能加,而且加完的结果还能转成 T 类型。如果传进来一个不支持加法的类型,比如 std::string 其实可以,但如果你传个自定义的 MyType 没重载 operator+,编译器就会报错。

我在项目中遇到过一种情况:团队里有人写了个模板函数,参数类型要求有 .size() 方法。结果传了个裸指针进去,编译报错信息长达 200 行。后来我改成用 Concept 约束,报错直接变成「类型不满足 HasSize 概念」,一目了然。

21.3 标准库提供的 Concepts

C++20 标准库已经提供了一组常用的 Concepts,放在 <concepts> 头文件里。常用的有:

Concept 名称 含义
std::same_as<T, U> T 和 U 是同一个类型
std::derived_from<T, U> T 继承自 U
std::convertible_to<T, U> T 可以隐式转换为 U
std::integral<T> T 是整数类型
std::floating_point<T> T 是浮点类型
std::copyable<T> T 可拷贝
std::movable<T> T 可移动

你想想看,有了这些标准概念,写模板的时候直接 template <std::integral T> 就完事了,比写 typename std::enable_if<std::is_integral_v<T>>::type 清爽多少?

21.4 使用约束的几种方式

Concepts 可以用在模板参数列表、函数参数、甚至 auto 变量上。我总结了几种常见用法:

  1. 模板参数约束: template <std::integral T> void func(T t);
  2. auto 参数约束: void func(std::integral auto t); —— 这是 C++20 的缩写语法
  3. requires 子句: template <typename T> requires std::integral<T> void func(T t);
  4. 结合 auto 变量: std::integral auto x = get_value();

我个人最喜欢第二种写法,简洁又直观。你看:

void print_number(std::integral auto value) {
    std::cout << value << '\n';
}

// 调用
print_number(42);   // 没问题
print_number(3.14); // 编译错误:double 不是 integral

嗯,这里要注意:缩写语法虽然方便,但如果约束条件比较复杂,还是用 requires 子句更清晰。

21.5 Concepts 与 SFINAE 的对比

说到这,就不得不提 SFINAE(Substitution Failure Is Not An Error)。它是 C++98/11 时代用来做模板约束的老办法。说白了就是:如果模板实例化时某个替换失败了,编译器不报错,而是把这个模板从重载集合里移除。

但 SFINAE 的问题很明显:

  • 可读性差: 一堆 enable_ifdecltypevoid_t 嵌套,看着就头疼
  • 报错信息恐怖: 失败时编译器给你展开所有模板实例化路径
  • 调试困难: 你很难快速定位到底是哪个约束没满足

来看个对比:

// SFINAE 方式
template <typename T>
typename std::enable_if<std::is_integral_v<T>, void>::type
process(T value) {
    std::cout << "整数处理\n";
}

// Concepts 方式
template <std::integral T>
void process(T value) {
    std::cout << "整数处理\n";
}

哪个更清晰?一目了然。Concepts 不仅写起来舒服,报错也友好得多。我曾经在一个大型项目里把 SFINAE 全部替换成 Concepts,编译时间没怎么变,但团队新人的上手速度明显快了——他们不用再学那套「模板元编程黑魔法」了。

我的建议: 如果你还在用 C++17 或更早版本,SFINAE 是唯一选择。但如果你能用 C++20,请优先使用 Concepts。它不只是语法糖,而是从根本上改善了模板编程的体验。

21.6 知识体系图

下面这张图帮你理清 Concepts 的核心逻辑:

Concepts 核心知识体系 C++20 Concepts 定义 Concept concept Addable = requires(T a, T b) { { a + b } -> convertible_to<T>; }; 使用约束 template <std::integral T> void func(T t); void func(std::integral auto t); 与 SFINAE 对比 SFINAE: enable_if, decltype Concepts: 清晰、可读、友好 报错信息天壤之别 总结:Concepts 是模板约束的未来,SFINAE 是历史遗留

21.7 避坑指南

最后分享几个我踩过的坑:

我曾经犯过的错:

  • 在 requires 表达式里忘了写 -> std::convertible_to<T>,结果概念永远为 true,因为只检查表达式是否合法,不检查返回类型
  • 把 Concepts 和 SFINAE 混用,导致约束逻辑混乱。建议一个模板里只用一种约束方式
  • 过度使用 Concepts:不是每个模板都需要约束。如果模板能接受任意类型,就别加概念

Concepts 的出现,让 C++ 模板编程从「黑魔法」变成了「白魔法」。它降低了门槛,提高了可维护性。如果你还没开始用,我建议你从今天起,把项目里能用 C++20 的地方都换成 Concepts。你会爱上它的。


公众号:蓝海资料掘金营,微信deep3321