模板写错时那屏报错,多半不是你的错,是语言的错:约束只能靠type_traits拼出来、注释里写一句「T 必须是整数类型」,编译器并不知道这条规矩,它只会在实例化失败的地方吐出一串模板替换细节。C++20 的 Concepts(概念)把这条规矩变成了编译器认识的实体:能被声明、能组合、能参与重载决议,错误信息也终于指向「谁不满足什么」。这篇把 Concepts 的写法与规则一次讲透。
这篇所有示例需要 C++20(gcc 10 / clang 12 起支持),每个代码块第一行都标了
// verify: std=c++20。
1. 引子:同一段错误,两种报错
先看老写法。想让f只接受整数类型,C++17 的惯用手段是 SFINAE(Substitution Failure Is Not An Error,替换失败并非错误):用enable_if把约束藏在模板参数默认值里(片段,故意编译失败):
// sfinae_fail.cpp(片段)— 编译: g++ -std=c++20 -O2 sfinae_fail.cpp#include<type_traits>template<classT,class=std::enable_if_t<std::is_integral_v<T>>>voidf(T){}voidcall(){f(3.14);}// 反例,不要这么写:double 不是整数类型gcc 13.2.0 的真实报错(节选):
prog.cc:5:16: error: no matching function for call to 'f(double)' prog.cc:4:6: note: candidate: 'template<class T, class> void f(T)' prog.cc:4:6: note: template argument deduction/substitution failed: /opt/wandbox/gcc-13.2.0/include/c++/13.2.0/type_traits:2610:11: error: no type named 'type' in 'struct std::enable_if<false, void>'读者要自己翻译:「enable_if<false, void>里没有type」= 「is_integral_v<double>是 false」= 「double不是整数」。而报错最后指向的是标准库头文件 type_traits 第 2610 行,离你写的那行十万八千里。
再看同一件事用 Concepts 写:
// concepts_fail.cpp(片段)— 编译: g++ -std=c++20 -O2 concepts_fail.cpp#include<concepts>template<std::integral T>voidf(T){}voidcall(){f(3.14);}// 反例,不要这么写:double 不是整数类型同一次编译的真实报错(节选):
prog.cc:5:16: error: no matching function for call to 'f(double)' prog.cc:4:6: note: candidate: 'template<class T> requires integral<T> void f(T)' prog.cc:4:6: note: constraints not satisfied .../concepts:100:24: note: the expression 'is_integral_v<_Tp> [with _Tp = double]' evaluated to 'false' 100 | concept integral = is_integral_v<_Tp>;关键差别在最后两行:编译器主动告诉你「integral<T>不满足,因为is_integral_v<double>求值为 false」。它甚至能指出std::integral这个 concept 的定义在哪一行。约束从「注释里的君子协定」变成了「编译器能复述的句子」:这就是 Concepts 最直接的收益。
官方文档:Constraints and concepts — cppreference
2. 定义一个 concept
concept(概念)的本质是一个编译期布尔谓词,专门用来约束类型。最基本的形式就是把已有的 trait 包一层:
// 定义一个 concept:T 必须是整数类型#include<concepts>#include<type_traits>template<classT>conceptIntegral=std::is_integral_v<T>;语法上它长得像变量模板,但有两个区别:concept是关键字(C++20 新增),等号右边必须是一个编译期可求值的bool表达式,而且这个表达式里只允许使用模板参数。第二种更常用的形式把右边的「bool 表达式」换成requires表达式,用来检测「某个表达式能不能编译过」:下一节展开。
一个坑要记住:concept不能显式特化、也不能递归引用自己。想做「分档判断」,正确做法是用constexpr函数或if constexpr,而不是给 concept 写特化。
官方文档:Language support library —
3. 四种使用方式:一次对照
定义好 concept 之后,怎么把它挂到函数(或类)上?标准给了四种等价形式,各有各的适用场景:
| 形式 | 写法 | 特点 | 适用场景 |
|---|---|---|---|
| ① 类型约束 | template <Integral T> void f(T); | 最简洁,约束写进模板参数表 | 只有一个约束、只约束一个参数 |
| ② requires 子句 | template <class T> requires Integral<T> void f(T); | 表达复杂逻辑最自然 | 需要&&/||组合多个约束 |
| ③ 尾置 requires | template <class T> void f(T) requires Integral<T>; | 约束写在参数表之后 | 约束想引用多个参数、或多重载时更易读 |
| ④ 简写约束 | void f(Integral auto x); | 参数表里直接写 concept | 想少写template <class T>,像普通函数 |
四种写法在语义上完全等价,选哪种只看可读性。四种一起跑一遍:
// concepts_forms.cpp — 编译: g++ -std=c++20 -Wall -O2 concepts_forms.cpp -o cf// verify: std=c++20#include<cstdio>#include<concepts>#include<type_traits>template<classT>conceptIntegral=std::is_integral_v<T>;// ① 类型约束:约束写进模板参数template<Integral T>voidf1(T v){std::printf("① 类型约束 f1(%d)\n",static_cast<int>(v));}// ② requires 子句:跟在模板头后面template<classT>requiresIntegral<T>voidf2(T v){std::printf("② requires 子句 f2(%d)\n",static_cast<int>(v));}// ③ 尾置 requires:跟在参数表后面template<classT>voidf3(T v)requiresIntegral<T>{std::printf("③ 尾置 requires f3(%d)\n",static_cast<int>(v));}// ④ 简写约束:参数类型写成 Concept autovoidf4(Integralautov){std::printf("④ 简写约束 f4(%d)\n",static_cast<int>(v));}intmain(){f1(1);f2(2);f3(3);f4(4);}① 类型约束 f1(1) ② requires 子句 f2(2) ③ 尾置 requires f3(3) ④ 简写约束 f4(4)①和④读起来最像函数声明,②、③则给了你一个「把约束当成 bool 表达式随便拼」的位置:比如requires Integral<T> && (sizeof(T) <= 8)。四种形式能混用,同一个函数上不会互相冲突。
4. requires 表达式:检测「这个表达式能不能写」
真正让 Concepts 从「换个写法的 enable_if」升级成「类型系统工具」的,是requires 表达式(requires-expression)。它不返回某个固定值,而是让编译器去试着编译里面列的几行代码:
// 一个 requires 表达式:把里面的每一行都当"待验证的表达式"template<classT>conceptHasSize=requires(constT&t){{t.size()}->std::convertible_to<std::size_t>;// 复合要求};requires (const T& t) { ... }里的参数列表是可选的,写出来相当于给下面用到的名字起个「假变量」,编译器只看它能不能通过编译,不会真的执行。花括号里可以放四类要求:
| 要求类型 | 写法示例 | 检查的是什么 |
|---|---|---|
| 简单要求 simple requirement | t.size(); | 这个表达式能编译过(不求值) |
| 类型要求 type requirement | typename T::value_type; | 这个类型存在 |
| 复合要求 compound requirement | { t.size() } -> std::convertible_to<std::size_t>; | 表达式合法,且返回类型满足指定 concept |
| 嵌套要求 nested requirement | requires HasSize<T>; | 另一个约束也必须成立 |
四类要求跑一遍(含嵌套要求与类型要求):
// concepts_requires.cpp — 编译: g++ -std=c++20 -Wall -O2 concepts_requires.cpp -o cr// verify: std=c++20#include<cstdio>#include<concepts>#include<string>#include<vector>// 复合要求:t.size() 合法,且能转成 std::size_ttemplate<classT>conceptHasSize=requires(constT&t){{t.size()}->std::convertible_to<std::size_t>;};// 类型要求:T 里有嵌套类型 value_typetemplate<classT>conceptHasValueType=requires{typenameT::value_type;};// 嵌套要求:T 得同时满足上面两个 concepttemplate<classT>conceptSizedContainer=requires{requiresHasSize<T>;requiresHasValueType<T>;};template<HasSize T>voidreport_size(constT&t,constchar*name){std::printf("%-6s .size() = %zu\n",name,static_cast<std::size_t>(t.size()));}intmain(){conststd::vector<int>v{1,2,3};conststd::string s="hello";report_size(v,"vector");report_size(s,"string");std::printf("HasValueType<std::vector<int>> = %d\n",static_cast<int>(HasValueType<std::vector<int>>));std::printf("HasValueType<int> = %d\n",static_cast<int>(HasValueType<int>));std::printf("SizedContainer<std::string> = %d\n",static_cast<int>(SizedContainer<std::string>));}vector .size() = 3 string .size() = 5 HasValueType<std::vector<int>> = 1 HasValueType<int> = 0 SizedContainer<std::string> = 1注意HasValueType<int>求值为0而不是编译错误:requires表达式里的typename int::value_type非法时,整条约束只是「不满足」,错误被吃掉、转成false。这正是它比裸写T::value_type安全的地方:后者会在实例化时直接炸。
%-6s是printf的左对齐占位符,%.1f、%zu同理:写输出示例时把宽度和精度在格式串里定死,输出才是确定的、可核对的。
官方文档:requires expression — cppreference
5. concept 的组合与重载偏序
concept 之间可以用&&、||、!组合成新 concept(术语叫合取(conjunction)和析取(disjunction)):
template<classT>conceptSignedIntegral=std::integral<T>&&std::signed_integral<T>;// 合取:两个都要满足template<classT>conceptNumeric=std::integral<T>||std::floating_point<T>;// 析取:满足一个即可组合出来的是「更受约束(more constrained)」的 concept,而重载决议会给它更高的优先级。规则叫约束的偏序(partial ordering of constraints):只有当两个候选的约束之间构成「子集」关系(一个蕴含另一个)时,才判定更严的那个胜出;如果互不蕴含,就是二义性错误。
// concepts_overload.cpp — 编译: g++ -std=c++20 -Wall -O2 concepts_overload.cpp -o co// verify: std=c++20#include<cstdio>#include<concepts>template<classT>conceptSignedIntegral=std::integral<T>&&std::signed_integral<T>;// 三个重载:约束一个比一个强template<classT>voiddescribe(T){std::puts("通用模板(无约束)");}template<std::integral T>voiddescribe(T){std::puts("受约束:integral");}template<SignedIntegral T>voiddescribe(T){std::puts("受约束:SignedIntegral(更强)");}intmain(){describe(1.5);// double:只有通用模板能接describe(static_cast<unsigned>(7));// unsigned:integral 成立,signed 不成立describe(-3);// int:两个都成立,选更强的}通用模板(无约束) 受约束:integral 受约束:SignedIntegral(更强)这三行的选择过程,就是编译器做约束偏序的流程:
调用 describe(x) │ ▼ 列出所有候选模板 ├─ describe(T) 约束:无 ├─ describe(std::integral T) 约束:integral<T> └─ describe(SignedIntegral T) 约束:integral<T> && signed_integral<T> │ ▼ 逐个检查:约束是否满足? 不满足 → 淘汰(错误信息里写清哪条不满足) │ ▼ 剩下的候选里,谁的约束"更严格"(蕴含别人)? ├─ 有唯一最严者 → 选它 ├─ 有多个且互相不蕴含 → 二义性,编译错误 └─ 没有约束关系 → 退回普通重载决议规则(参数匹配、模板特化程度…)这套机制解决了一个老问题:C++17 里想给「更特殊的类型」写更快的重载,只能靠enable_if组合条件的「复杂度」碰运气,编译器并不真懂谁更严。Concepts 把这个关系形式化了:SignedIntegral<T>蕴含std::integral<T>是可推导的事实,不是人肉约定。
官方文档:C++ Core Guidelines — 用 concept 表达模板接口的约束
6. 标准库已有 concept 速查
不用什么都自己定义:<concepts>已经提供了一批核心 concept,很多需求直接抄名字就行:
| concept | 含义 | 典型用途 |
|---|---|---|
std::integral<T> | 整数类型(含 bool、char) | 位运算、计数接口 |
std::floating_point<T> | 浮点类型 | 数值算法 |
std::signed_integral<T>/std::unsigned_integral<T> | 有 / 无符号整数 | 区分「能不能取负」 |
std::same_as<T, U> | 类型完全相同 | 精确匹配重载 |
std::convertible_to<From, To> | 可隐式 / 显式转换 | 检查赋值、参数是不是接得住 |
std::derived_from<D, B> | D 继承自 B 且可转换 | 接住基类指针的接口 |
std::invocable<F, Args...> | F 能用 Args… 调用 | 接住 lambda、函数对象 |
std::regular<T> | 可默认构造 + 可拷贝 + 可比较 | 要求「像个值」的类型 |
std::ranges::range<R> | R 能begin/end | 泛型遍历 |
std::ranges::sized_range<R> | 范围且size()是 O(1) | 需要长度的一次遍历 |
std::ranges::range在<ranges>里、不在<concepts>里,这是唯一容易踩的头部问题。
7. 完整示例:把 Printable 做成一个约束
把前面所有要点拼起来。自定义 concept、复合要求、||析取、偏序重载,一次覆盖:
// concepts_full.cpp — 编译: g++ -std=c++20 -Wall -O2 concepts_full.cpp -o cfull// verify: std=c++20#include<cstdio>#include<concepts>#include<string>#include<vector>// 复合要求:能取长度、元素能转成 int —— 一个"能逐个求和"的序列template<classT>conceptPrintable=requires(constT&v){{v.size()}->std::convertible_to<std::size_t>;{v[0]}->std::convertible_to<int>;};// 更宽的约束:只要能被 range-for 遍历,就至少能数元素template<classT>conceptIterable=requires(constT&v){{v.begin()}->std::convertible_to<decltype(v.begin())>;};// 偏序:Iterable 更宽松,Printable 更严格(Printable 蕴含 Iterable)template<Iterable T>intsum_of(constT&v){inttotal=0;for(constauto&x:v){total+=static_cast<int>(x);}returntotal;}intmain(){conststd::vector<int>nums{10,20,30};conststd::string text="abc";std::printf("vector 求和 = %d\n",sum_of(nums));std::printf("string 求和 = %d\n",sum_of(text));std::printf("Printable<std::vector<int>> = %d\n",static_cast<int>(Printable<std::vector<int>>));std::printf("Printable<int> = %d\n",static_cast<int>(Printable<int>));std::printf("Iterable<int> = %d\n",static_cast<int>(Iterable<int>));}vector 求和 = 60 string 求和 = 294 Printable<std::vector<int>> = 1 Printable<int> = 0 Iterable<int> = 0std::string的元素是char,累加出的是字符编码之和('a'+'b'+'c'= 97 + 98 + 99 = 294),不是「abc」:写这种泛型示例时输出里别放自己没算过的数字,要么先想清楚类型,要么干脆让它只打印布尔判断。而Printable<int>/Iterable<int>都是0:int既没有size()也没有begin(),两条约束都不满足。
8. Concepts 与 SFINAE:怎么取舍
| 维度 | SFINAE(C++17 及以前) | Concepts(C++20) |
|---|---|---|
| 可读性 | 约束藏在模板参数默认值里,要对着enable_if反推 | 约束是具名实体,读一遍就懂 |
| 报错质量 | 指向标准库内部:no type named 'type' | 明说「哪个 concept 不满足、哪一步求值为 false」 |
| 能否参与重载偏序 | 不能,只能靠条件复杂度的巧合 | 能,且有形式化规则 |
| 能否给参数逐个加约束 | 只能整个函数一个开关 | 每个参数、每个重载各管各的 |
| 能否约束类模板 | 要写static_assert或模板参数技巧 | template <std::integral T> class C;直接写 |
| 编译器要求 | 老编译器都支持 | 需要 C++20,gcc 10 / clang 12 起 |
结论很明确:新代码优先用 Concepts,它可读、报错准、还能参与重载决议。但 SFINAE 不是可以不学了:大量存量库(Boost 系、早期 STL 实现、各种老框架)还在用它,读不懂std::enable_if_t<std::is_same_v<...>>这类签名,维护老代码时会寸步难行。实用方案是:写新代码用 Concepts,读老代码认识 SFINAE,两者是同一件事的两代表达,句法不通、语义相通。
还有一个性能视角容易被忽略:Concepts完全不产生运行期开销:约束在编译期求值,替换成哪份实现编译期就定死了。它对性能的帮助是「间接」的,但很实在:requires表达式让「这个类型有没有 O(1) 的size()」这类复杂度承诺也能写进接口,从而让你放心地在算法里依赖它,而不用退回最慢的通用实现。
9. 延伸阅读
- Constraints and concepts — cppreference —— 四种约束写法的权威对照,含「约束的偏序」的完整判定规则
- requires expression — cppreference —— 四类要求(简单 / 类型 / 复合 / 嵌套)逐个说明,这篇第 4 节的依据
- Language support library —
<concepts>—— 标准库 concept 的完整清单,写约束前先来这里查有没有现成的 - C++ Core Guidelines —— 搜 T.* 系列,看官方对「用 concept 表达模板接口前提条件」的建议
- Compiler Explorer —— 想看约束替换后到底生成了哪份实现、有没有内联,把代码贴上去看汇编最快
本知识库内的相关篇目:
- 《constexpr 能力扩张:从 C++11 到 C++20 的编译期计算》 —— constexpr 从 C++11 只能写单 return
- 《C++20 核心新特性总览:四大特性 + gcc 13 实测清单》 —— C++20 是继 C++11 之后最大的一次改动。
- 《if / switch 初始化语句:C++17 把变量作用域关进笼子》 —— C++17 给 if 和 switch 加了 init-statement
10. 一句话总结
Concepts 把「模板参数必须满足什么」从注释和enable_if技巧变成了编译器认识的具名实体:用四种等价写法把它挂到模板上,用 requires 表达式检测「某个表达式能不能编译过」,用&&/||组合出更受约束的 concept 并在重载决议中胜出;它零运行期开销、报错直指不满足的那一步,代价只是需要 C++20。