C++ concepts 概念:从约束到语义
本文要回答的问题
- 模板出错时那一屏谁也看不懂的报错,到底哪来的?concepts 为什么能治好它?
- "约束一个模板参数"在 C++11 里怎么做?代价是什么?
- C++20 的 concept 到底是个什么东西?和布尔常量、和
enable_if是什么关系? - requires 关键字有几种用法?约束还能参与重载决议?
- concepts 有性能开销吗?编译期和运行期分别怎么算?
一、问题从哪来:模板报错为什么那么难看
写过一点模板的人,都见过这种时刻:不小心给模板函数传了个不合适的类型,编译器甩出一大屏错误,首行是 no matching function,中间是几十行 template argument deduction/substitution failed,最后藏着一个 no type named 'type' in 'struct std::enable_if<false, double>'。你盯着"enable_if 里没有 type"愣了半天,才反应过来:哦,原来这函数只接受整型,我传了个 double。
问题的根源在于:C++11 时代约束模板参数的唯一手段是 SFINAE——让模板在"参数不满足条件"时替换失败,从而被移出重载候选集。这个机制本身很巧妙,但它有两个天然缺陷。第一,错误信息描述的是机制,不是意图:报错说的是"enable_if 内部没有 type"这种实现细节,而不是"你要求整型,我给了 double"。第二,约束逻辑散落在签名里:typename std::enable_if<std::is_integral<T>::value, T>::type 这串东西得贴在函数签名上,读代码时先被它晃一眼,才知道这个函数想要什么。
C++20 的 concepts(概念) 就是冲着这两个缺陷来的。它把"约束"从实现机制提升为一等公民:先给约束起个名字,再把它直接写在模板的声明处。下面两节用同一个例子,把两种写法摊开对比。
二、两种写法,同一件事
要求:写一个 twice,只接受整数类型。C++11 的写法(完整代码见 demos/cpp-expert/concepts-cpp20/):
// C++11:SFINAE 约束,约束藏在返回类型里
template <typename T>
typename std::enable_if<std::is_integral<T>::value, T>::type
twice(T v) { return v * 2; }C++20 的写法,意思完全一样:
// C++20:concepts 约束,约束写在名字上
template <std::integral T>
T twice(T v) { return v * 2; }std::integral 就是标准库提供的 concept——一个"名字"。template <std::integral T> 读作"T 必须是整型",声明即约束,一眼看懂。还有另一种写法,requires 子句:
template <typename T>
requires std::integral<T>
T twice(T v) { return v * 2; }两种写法等价,template <Concept T> 是 template <typename T> requires Concept<T> 的语法糖。concepts 的关键洞察是:"T 是整型"这个条件,本身就是个可以命名、可以复用的东西——它不再是粘贴在签名里的模板表达式,而是个有名字的谓词。
三、concept 的本质:编译期布尔谓词
剥开所有语法,concept 的本质惊人地朴素——一个编译期可求值的布尔表达式:
template <typename T>
concept Integral = std::is_integral<T>::value;
static_assert(Integral<int>); // 编译通过:int 满足 Integral
static_assert(!Integral<double>); // 编译通过:double 不满足Integral 的定义体就是一个编译期常量表达式,用法和 constexpr bool 函数几乎一样,可以在任何常量表达式语境里直接求值。这带来一个此前 SFINAE 做不到的事:约束可以被断言、被组合、被传递。实验里用 static_assert(std::integral<int>) 直接验证了这一点——concept 可以在 static_assert 里当场求值,这是它"谓词"属性的直接证据。
再往深一层:std::is_integral_v<T> 这种 type_traits 和 concept 有什么区别?区别在于表达力。type_traits 只能回答"T 具有某属性"(T 是整型吗),而 concept 可以描述类型之间的关系与操作。标准库里有个更典型的例子:
template <class T>
concept equality_comparable = requires(T a, T b) {
{ a == b } -> std::convertible_to<bool>;
};这里出现了第二个 requires——requires(T a, T b) { ... } 是 requires 表达式,它在编译期检查:给定两个 T 的实例,a == b 这个表达式能不能编译、结果能不能转成 bool。类型之间的关系(可以比较、可以相加、可以哈希)就这样变成了可命名的概念。type_traits 描述"是什么",requires 表达式描述"能做什么"——后者才是泛型编程真正需要的。
四、错误信息:机制 vs 意图
现在回到开头那个痛点。同一份"只接受整型"的约束,故意传错类型,两种写法报错长什么样?服务器上实测(GCC 4.8.5 / GCC 10.2.1,完整输出见 demos/cpp-expert/concepts-cpp20/):
| 场景 | 编译 | 错误行数 | 关键报错 |
|---|---|---|---|
| 单候选 SFINAE | g++ 4.8 / C++11 | 12 | no type named 'type' in 'struct std::enable_if<false, double>' |
| 单候选 concepts | g++ 10 / C++20 | 15 | use of function ... with unsatisfied constraints,evaluated to 'false' |
| 多候选 SFINAE(3 个重载全失败) | g++ 10 / C++11 | 25 | no matching function + 每个候选的替换失败内部细节 |
| 多候选 concepts(3 个重载全失败) | g++ 10 / C++20 | 36 | 每个候选 constraints not satisfied + 求值结果 |
单看行数,concepts 甚至更多——这个结果有点反直觉,但它恰好戳破一个误区:concepts 的价值不在"行数更少",而在"每一行都说得是人话"。对比两组关键输出:
# SFINAE 的"解释"(要读者反向推断)
error: no type named 'type' in 'struct std::enable_if<false, double>'
# concepts 的"解释"(直接点名意图)
error: use of function 'T twice(T) [with T = double]' with unsatisfied constraints
note: the expression 'is_integral_v<_Tp> [with _Tp = double]' evaluated to 'false'SFINAE 报的是机制:"enable_if 实例化失败";concepts 报的是意图:"约束未满足,is_integral 对 double 求值为 false"。前者要你在脑子里完成"enable_if 失败 → 说明条件为假 → 说明类型不对"的推理链,后者把结论直接摆出来。多候选场景下差异更明显:SFINAE 版本每个候选都要列一遍 template argument deduction/substitution failed 的内部细节,concepts 版本则是每个候选干净地一行 constraints not satisfied。报错质量是 concepts 最立竿见影的收益——对模板库的用户来说,错误信息就是文档。
五、requires 的三副面孔
requires 在 C++20 里是个多面手,容易混淆。把它拆成三副面孔就清楚了:
| 用途 | 语法 | 出现位置 | 作用 |
|---|---|---|---|
| 约束子句 | requires Concept<T> | 模板声明的花括号前 | 声明"此模板仅对满足约束的类型可用" |
| requires 表达式 | requires(T a) { a + a; } | 概念定义体内 | 编译期检查一组表达式是否合法 |
| 约束重载 | 多个 requires 子句 | 重载声明 | 按约束的满足情况参与重载决议 |
// 面孔一:约束子句——声明处贴约束
template <typename T>
requires std::integral<T>
T half(T v) { return v / 2; }
// 面孔二:requires 表达式——描述"能做什么"
template <typename T>
concept Addable = requires(T a, T b) {
{ a + b } -> std::same_as<T>;
};
// 面孔三:约束重载——同一函数名,按约束挑实现
template <typename T>
requires std::integral<T>
void classify(T) { std::puts("integral"); }
template <typename T>
requires std::floating_point<T>
void classify(T) { std::puts("floating"); }面孔三值得多说一句。SFINAE 也能做重载约束(实验里的 err2-sfinae.cpp 就是这么干的),但 concepts 多了一个 SFINAE 没有的能力——约束偏序(constraint subsumption)。当两个候选都满足时,编译器能比较约束之间的包含关系来选更具体的那个,例如:
template <typename T>
requires std::integral<T>
void foo(T); // 约束 A:整型
template <typename T>
requires std::integral<T> && std::signed_integral<T>
void foo(T); // 约束 B:有符号整型 = A 且更多
foo(42); // int 同时满足 A、B,选更具体的 BSFINAE 时代这种"更具体"只能靠偏序规则的机缘巧合,concepts 把"约束的更具体"变成显式可推理的规则——约束本身成了有结构、可比较的实体,这是概念设计超越"语法糖"的地方。
六、性能账:运行期零开销,编译期另说
concepts 是纯编译期机制,运行期代码和 SFINAE 完全一致——约束检查发生在类型替换前,不产生任何运行期指令、不增加任何分支。实测数据(同编译器 GCC 10,-g,见 demos/cpp-expert/concepts-cpp20/):
| 指标 | SFINAE(C++11) | concepts(C++20) |
|---|---|---|
| 二进制体积 | 34208 B | 34040 B |
| 编译耗时(3 次取最小) | 0.19 s | 0.40 s |
体积几乎相同(差 168 字节,属符号差异)。编译耗时 concepts 略高,但这个差异要拆开看:concepts 版 #include <concepts> 拉进了一整个 C++20 概念头,标准库本身更重,这个成本与"概念机制"无关——如果你自己手写一个十行的 concept 而不碰标准库头,两种写法的编译耗时基本打平。真正的结论是:concepts 的检查发生在编译早期(实参替换前的约束检查阶段),失败即终止,不会像 SFINAE 那样触发后续的深层实例化失败——这让"编译失败"这件事本身变得更便宜。
不过要诚实地说一句:约束偏序的求解、复杂 requires 表达式的检查,在大型模板库(比如把概念用得很猛的 ranges 库)里确实会让编译时间上升,这是概念机制本身的成本,业界也在持续优化。对绝大多数项目,这个代价换来的可读性与错误质量,是明显划算的。

上图把 concepts 的定位画清楚:左边是它要取代的 SFINAE(约束散落签名、报错讲机制),右边是它编译期与运行期的账——编译期约束检查在模板实参替换之前,失败直接终止;运行期与手写模板无差别,零指令开销。中间是它真正的革命:约束第一次有了名字,可以被组合、被偏序、被断言。
七、C 对照:C 里没有概念,但有等价思想
C 语言没有模板,自然没有 concepts,但"约束类型"的思想以更朴素的形式存在:
| 维度 | C | C++20 concepts |
|---|---|---|
| 约束机制 | 无;靠函数注释、命名约定、_Generic 宏碰运气 | 一等公民:命名谓词 + requires |
| 检查时机 | 无编译期检查;错误在运行时才暴露 | 编译期实参替换前,失败即终止 |
| 错误信息 | 隐式转换/警告,不指明意图 | constraints not satisfied,直接点名约束 |
| 泛型实现 | _Generic、void* + 回调,运行时转型 | 约束 + 模板,编译期决议 |
| 类型关系 | 无 | 约束偏序:可比较"谁更具体" |
有意思的是,C23 引入的 typeof、_Generic 强化了"按类型分派"的能力,但始终没有"类型必须满足某约束"的语言机制——约束的显式化需要泛型系统作为前提,这正是 C 与 C++ 的分水岭之一。
一句话总结
concept 是给模板参数起的"类型要求"的名字——编译期布尔谓词、运行期零开销;它把 SFINAE 藏在签名里的约束变成声明处的一行字,把"enable_if 内部没有 type"的机制报错换成"约束未满足"的意图报错,还捎带了约束偏序这个 SFINAE 没有的能力。
上一篇:协程机制 下一篇:内存序与 atomic 深入