一、概念(Concepts):C++20 的约束与概念,如何编写更清晰的模板接口
模板元编程写多了,你一定会遇到这种场景:一个函数模板接收了不该接收的类型,然后编译器吐出一百多行错误信息。你盯着屏幕,从最后一行往上翻,翻到头皮发麻才找到真正的原因——类型不匹配。
我个人习惯把这种体验叫做「模板地狱」。C++20 的概念(Concepts)就是来终结这个地狱的。说白了,它让你能明确告诉编译器:我这个模板参数,必须满足什么条件。
1.1 为什么需要概念?
先看一个老代码的例子:
template <typename T>
T add(T a, T b) {
return a + b;
}
这段代码有什么问题?你传两个 int 没问题,传两个 double 也没问题。但如果你传两个指针呢?或者传两个 std::string?嗯,string 确实支持 +,但如果你传一个自定义类型,它没重载 operator+,编译器就会报错。
问题在于:这个模板接口没有表达出它对类型的要求。调用者只能靠猜,或者靠试。
我在项目中遇到过好几次这种问题。有一次同事写了一个排序模板,传进去一个没有 operator< 的类型,编译报错后他花了半小时才定位到问题。我当时就想:要是能在接口上直接写清楚「这个类型必须可比较」,该多好。
概念就是干这个的。
1.2 概念的基本语法
概念的定义很简单:
template <typename T>
concept Addable = requires(T a, T b) {
{ a + b } -> std::convertible_to<T>;
};
这段代码的意思是:类型 T 必须满足——a + b 合法,并且结果可以转换成 T 类型。
然后你就可以这样用:
template <Addable T>
T add(T a, T b) {
return a + b;
}
现在接口清晰了。调用者一看就知道:这个函数只接受「可相加」的类型。如果传了不满足条件的类型,编译器给出的错误信息也会直接指向这个约束,而不是在模板实例化深处报错。
核心变化:从「隐式约束」变成了「显式约束」。错误信息从几十行变成一两行。
1.3 概念的几种用法
概念可以用在多个地方。我整理了一下:
| 用法 | 示例 | 说明 |
|---|---|---|
| 模板参数约束 | template <Addable T> | 最常用,直接约束类型参数 |
| auto 占位符 | Addable auto x = ... | 变量声明时约束类型 |
| 函数参数 | void f(Addable auto x) | 简化函数模板写法 |
| requires 子句 | requires Addable<T> | 更灵活的约束组合 |
举个例子,用 requires 子句可以组合多个约束:
template <typename T>
requires Addable<T> && std::copyable<T>
T add_and_copy(T a, T b) {
T result = a + b;
return result;
}
你想想看,这种写法是不是比之前清晰多了?每个约束都写在明面上。
1.4 标准库中的概念
C++20 标准库已经提供了一组常用概念。我挑几个常用的:
- std::same_as<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::equality_comparable<T>:T 支持 == 比较
这些概念可以直接用,省得自己写。比如:
template <std::integral T>
T square(T x) {
return x * x;
}
这个函数只接受整数类型。传个 double 进来?编译不过。这就是约束的力量。
小技巧:写模板时,先用标准库概念。不够用再自己定义。标准库的概念经过了充分测试,比你手写的更可靠。
1.5 自定义概念:从简单到复杂
标准库概念不够用怎么办?自己写。我带你从简单到复杂看几个例子。
最简单的概念:
template <typename T>
concept HasSize = requires(T t) {
t.size();
};
这个概念要求类型 T 有 size() 成员函数。任何有 size() 的类型都满足,比如 std::vector、std::string、std::map。
带返回类型约束的概念:
template <typename T>
concept HasSizeReturningSizeT = requires(T t) {
{ t.size() } -> std::same_as<std::size_t>;
};
这个更严格:不仅要有 size(),返回值还必须是 std::size_t。
多参数概念:
template <typename T, typename U>
concept PairLike = requires(T t, U u) {
t.first;
t.second;
{ t.first } -> std::same_as<U>;
};
这个概念检查 T 是否有 first 和 second 成员,并且 first 的类型是 U。
我曾经在写一个序列化库时用过类似的概念。我需要确保传入的类型有 serialize 方法,并且返回值是 std::vector<char>。用概念写出来,接口一目了然。
1.6 概念与重载决议
概念还有一个很实用的功能:影响重载决议。当多个模板函数都匹配时,编译器会选择约束更精确的那个。
template <std::integral T>
void process(T x) {
// 整数版本
}
template <std::floating_point T>
void process(T x) {
// 浮点版本
}
调用 process(42) 走第一个,process(3.14) 走第二个。编译器自动选择最匹配的约束。这在以前你得用 SFINAE 或者 tag dispatch 才能实现,代码又长又难读。
注意:概念之间不能有歧义。如果两个概念同时匹配,编译器会报错。所以设计概念时,要确保它们是互斥的,或者有明确的优先级。
1.7 概念与 requires 表达式
requires 表达式是定义概念的核心工具。它有四种形式:
- 简单要求:检查表达式是否合法
- 类型要求:检查类型是否存在
- 复合要求:检查表达式的结果类型
- 嵌套要求:在 requires 内部再写约束
看个综合例子:
template <typename T>
concept Container = requires(T t, const T ct) {
// 简单要求:有 begin() 和 end()
t.begin();
t.end();
// 类型要求:有 value_type 类型别名
typename T::value_type;
// 复合要求:begin() 返回迭代器
{ t.begin() } -> std::input_iterator;
// 嵌套要求:迭代器指向的类型是 value_type
requires std::same_as<
std::iter_value_t<decltype(t.begin())>,
typename T::value_type
>;
};
这个 Container 概念几乎涵盖了 STL 容器的所有要求。写模板时,你可以用它来约束参数必须是容器类型。
1.8 避坑指南
概念虽好,但用起来也有坑。我踩过几个,分享给你:
- 概念不能递归:概念的定义中不能引用自身。编译器会报循环依赖。
- 概念不能有定义:概念只是约束,不是函数。你不能在概念里写实现逻辑。
- 概念求值是短路求值:requires 表达式中的条件从左到右求值,一旦失败就停止。可以利用这一点优化编译速度。
- 概念不是类型:你不能把概念当作类型用。比如 std::vector<std::integral> 是错的。
我曾经在项目里写了一个递归概念,想检查类型是否满足某种嵌套结构。结果编译器直接崩溃了。嗯,从那以后我记住了:概念不能递归。
1.9 概念的实际应用场景
概念在真实项目中有哪些用武之地?我列几个:
- 算法库:约束算法参数必须是随机访问迭代器
- 序列化框架:约束类型必须有序列化/反序列化方法
- 数学库:约束数值类型必须支持加减乘除
- 容器适配器:约束元素类型必须可拷贝、可比较
- 工厂模式:约束工厂函数返回的类型必须继承自某个基类
每个场景都能让接口更清晰,错误信息更友好。
1.10 本章小结
概念是 C++20 给模板编程带来的一次「接口革命」。它让模板从「黑盒」变成了「白盒」——调用者一眼就能看出参数需要满足什么条件。
我个人建议:新项目尽量用概念。老项目可以逐步迁移。先从标准库概念开始,再慢慢自定义。你会发现,代码的可读性和可维护性都会上一个台阶。
记住一句话:好的接口,应该让正确用法显而易见,让错误用法编译不过。概念就是帮你做到这一点的工具。
核心要点回顾:
- 概念用 requires 表达式定义约束
- 标准库提供了常用概念,优先使用
- 概念影响重载决议,约束更精确的优先
- 概念不能递归,不能有定义
- 概念让模板接口更清晰,错误信息更友好