Concepts深度剖析
# 22.Concepts深度剖析
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. concept 定义语法
- 4. requires 子句位与用
- 5. 约束模板与重载决议
- 6. 蕴含与原子约束
- 7. 命名约束的可读性飞跃
- 8. 错误信息对决
- 9. Concepts 踩坑录
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 蕴含关系惨案
某量化交易系统的容器库要从 SFINAE 迁移到 C++20 Concepts。资深工程师写了这两个 concept:
// 事故代码 V1:「sortable 显然比 iterable 更强,Concept 应该自动认」
template <typename T>
concept iterable = requires(T& c) {
typename T::iterator;
{ c.begin() } -> std::input_iterator;
{ c.end() } -> std::sentinel_for<typename T::iterator>;
};
template <typename T>
concept sortable = iterable<T> && requires(T& c) {
{ c.begin() } -> std::random_access_iterator; // 注意这里
{ c.end() } -> std::random_access_iterator; // 注意这个
std::sort(c.begin(), c.end());
};
// 两个重载:
template <iterable T>
void process(T& c) { /* 只读遍历 */ }
template <sortable T>
void process(T& c) { /* 排序处理:sortable 应该更强 —— 作者的预期 */ }
// 调用
std::vector<int> v = {3,1,4,1,5};
process(v); // 期望:挑 sortable 重载。实际:ambiguity error!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
编译器输出:
error: call to 'process' is ambiguous
note: candidate: 'void process(T&) [with T = std::vector<int>]' (with T satisfying iterable)
note: candidate: 'void process(T&) [with T = std::vector<int>]' (with T satisfying sortable)
2
3
sortable 明明包含 iterable 作为「子集」——所有 sortable 的 T 都满足 iterable,为什么编译器说二义性?这就引出 Concepts 体系里最深的坑:原子约束与蕴含规则。
# 1.2 requires 位置屠杀
第二个事故来自同一个团队。他们把 sortable 的约束从模板形参列表挪到了函数体前:
// 事故代码 V2:requires 换个位置,编译结果完全不同
template <typename T>
requires sortable<T> // ← requires clause 挂在模板头
void process(T& c) { /* 排序 */ }
template <typename T>
void process(T& c) requires sortable<T> // ← 同一约束,挂在函数声明尾
{ /* 排序 */ }
// 同一份调用:
std::vector<int> v = {3,1,4,1,5};
process(v);
2
3
4
5
6
7
8
9
10
11
12
GCC 报出截然不同的错:"ambiguous" vs "more constrained"——作者把 requires 从模板形参后移到函数尾,重载决议就从「不选」变成「选对了」。
不同位置不同语义——requires clause 的语法位置真的会影响重载决议的约束偏序计算。团队花了整整一天在 godbolt 上试才找出规律。
# 1.3 七个待解疑问
① concept 定义的完整语法有哪些? requires 表达式里能写什么? → 第 3 章
② requires clause 和 requires expression 有什么区别? 为什么同名? → 第 4 章
③ constraint 如何参与重载决议? constraint ordering 的算法是什么? → 第 5 章
④ 为什么 sortable 包含 iterable 但编译器不认? subsumption 规则? → 第 6 章
⑤ 和 SFINAE enable_if 到底好多少? 错误信息 / 编译时间 / 代码量? → 第 7 / 第 8 章
⑥ requires 在模板头的哪个位置会影响决议? 什么位置是最佳? → 第 4 / 第 9 章
⑦ 从 SFINAE 到 Concepts 怎么平稳迁移? 有什么坑? → 第 10 章
2
3
4
5
6
7
# 2. 架构概览
# 2.1 Concepts 三件套
C++20 Concepts 不是语言的一个新增「特性」——它是重载决议框架的第三维度:
┌──────────────────────────┐
│ C++20 Concepts 体系 │
└────────────┬─────────────┘
│
┌──────────────────────────┼──────────────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ ① concept 定义 │ │ ② 约束模板使用 │ │ ③ 约束偏序裁决 │
│ named concept │ │ constrained tpl │ │ subsumption │
├──────────────────┤ ├──────────────────┤ ├──────────────────┤
│ template<…> │ │ 四处语法位置 │ │ 约束归一化 │
│ concept C = …; │ │ ① 模板参数后 │ │ 原子约束拆分 │
│ │ │ ② 函数后 │ │ 蕴含关系判断 │
│ ① 简单表达式 │ │ ③ auto 缩写 │ │ more_constrained │
│ ② requires 表达式 │ │ ④ 非类型模板参数 │ │ 胜过 SFINAE │
│ ③ 组合概念 │ │ │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
│ │ │
└──────────────────────────┼──────────────────────────┘
▼
┌──────────────────────────────┐
│ 错误信息从 17000 行 → 3 行 │
│ 编译器 epoch 级别早停 │
└──────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 2.2 为何这么切
疑惑:为什么把 Concepts 拆成"定义 / 使用 / 裁决"三块,而不是像 SFINAE 那样一锅粥(写一堆 enable_if 随编译器去摸)?
论证:
- 定义与使用分离是正确抽象的前提——SFINAE 的高阶套路(
enable_if<is_integral_v<T> && !is_same_v<T, bool>, void>)把"什么是对的类型"揉进"怎么阻止错的重载"。Concepts 强制你先给约束起个名字,再在模板头引用这个名字——命名约束 = 可读性×10。 - 裁决独立模块化是约束偏序的保证——同一个 concept 在不同上下文里引用,编译器需要一组**原子约束(atomic constraint)**来计算两两之间的蕴含关系。如果定义、使用、裁决揉在一起(像 SFINAE 那样),这个"规范化→拆分→比较蕴含"的流水线根本跑不起来。
- 错误信息独立流——Concepts 诊断把"哪个约束被违反、违反的具体子句是哪条"做成了一条独立通路(第 8 章),而不是 SFINAE 那样把错误塞进候选列表。
- 反向验证:Concepts 之前的提案(C++0x Concepts)尝试把约束嵌进模板定义体内,结果编译器复杂度爆炸、编译时间不可接受被移除。C++20 Concepts 转向"定义在外面、使用在模板头"的正交模型——这是 2009 年血泪教训换来的设计。
结论:Concepts 三件套不是语法糖的优雅——是编译器约束处理流水线(定义→规范化→使用→裁决→诊断)的六道工序各归其位。任何一道工序的语法位置稍有错位(案例②的 requires 放在不同位置),就会挤进不同的流水线阶段——这就是 Concept 「同一套词、不同位置、不同结果」的本质原因。
# 3. concept 定义语法
# 3.1 简单 concept 三要素
最小可用的 concept 定义只有三行:
template <typename T> // ① 模板参数列表——定义"我们要评判谁"
concept integral = std::is_integral_v<T>; // ② 概念名 = 布尔表达式
// ③ 必须是编译期 bool
2
3
concept 的本质:一个返回 bool 的编译期谓词——integral<int> 是 true,integral<double> 是 false。
三要素:
- 模板头:参数化要约束的类型
- 概念名:给谓词起个好名字
- 定义体:编译期常量表达式(
bool)
最简写法(直接引用标准 traits):
template <typename T> concept pointer = std::is_pointer_v<T>;
template <typename T> concept floating = std::is_floating_point_v<T>;
template <typename T> concept class_or_enum = std::is_class_v<T> || std::is_enum_v<T>;
2
3
即时可用:
template <floating T>
T safe_div(T a, T b) { return b != 0 ? a/b : T{0}; }
safe_div(3.14, 2.71); // ✅ 浮点
// safe_div("hi", "o"); // ❌ const char* 不是 floating
// error: constraints not satisfied
// note: the expression 'std::is_floating_point_v<T>' evaluated to 'false'
2
3
4
5
6
7
# 3.2 组合 concept 四种配方
Concepts 可以像搭积木一样组合——四种运算符:
// ① 逻辑合取 (AND):两者同时满足
template <typename T>
concept integral_signed = std::integral<T> && std::is_signed_v<T>;
// static_assert(integral_signed<int>); // ✅
// static_assert(integral_signed<unsigned>); // ❌
// ② 逻辑析取 (OR):任一满足即通过
template <typename T>
concept number = std::integral<T> || std::floating_point<T>;
// static_assert(number<unsigned>); // ✅ integral 满足
// static_assert(number<double>); // ✅ floating 满足
// ③ 逻辑否 (NOT):不满足才通过
template <typename T>
concept non_void = !std::is_void_v<T>;
// ④ 直接引用另一个 concept
template <typename T>
concept sortable_range = std::ranges::random_access_range<T> &&
std::sortable<std::ranges::iterator_t<T>>;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
关键约束:&& / || / ! 的操作数必须是编译期常量 bool 表达式——不能用 requires { expr; } 这种 requires 表达式直接跟 &&(语法上不允许):
// ❌ 这样写不行:
template <typename T>
concept bad = std::integral<T> && requires { T{} + T{}; };
// ↑ 语法对,语义……看下一节
// ✅ 正确:
template <typename T>
concept good = std::integral<T> && requires(T a, T b) { a + b; };
2
3
4
5
6
7
8
# 3.3 requires 表达式内幕
requires 表达式(requires-expression)是 concept 定义体的核心武器——它可以在编译期检查「某个表达式能否合法编译」:
template <typename T>
concept addable = requires(T a, T b) {
// ① 简单表达式:能编译就满足
a + b;
// ② 返回类型约束:a+b 必须返回可转 int 的类型
{ a + b } -> std::convertible_to<int>;
// ③ 类型存在性:必须有这个嵌套类型
typename T::value_type;
// ④ noexcept 约束:不抛异常
{ a + b } noexcept;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
requires 表达式里的语法是一个虚拟作用域——变量 a 和 b 不会真正被构造,只是在编译器的「类型推导引擎」里做一次 decltype 级别的检查。
四种检查模式:
| 写法 | 含义 | 示例 |
|---|---|---|
expr; | expr 必须合法 | a + b; |
{ expr } -> concept; | expr 合法且返回类型满足 concept | { *it } -> std::same_as<int> |
typename T::type; | 嵌套类型必须存在 | typename T::iterator; |
{ expr } noexcept; | expr 合法且 noexcept | { f() } noexcept; |
复合检查(多条件叠加):
template <typename T>
concept printable_stream = requires(std::ostream& os, T val) {
{ os << val } -> std::same_as<std::ostream&>;
};
2
3
4
# 3.4 auto 约束参数语法糖
把 concept 用在 auto 上:
// 等价于
// template <std::integral T>
// void fn(T x);
void fn(std::integral auto x) { /* x 是整数 */ }
fn(42); // ✅
// fn(3.14); // ❌ constraint violation
2
3
4
5
6
7
更实用的——缩写函数模板(abbreviated function template):
// 一行搞定两个约束参数:
std::integral auto gcd(std::integral auto a, std::integral auto b) {
while (b != 0) { auto t = b; b = a % b; a = t; }
return a;
}
// 等价于:
// template <std::integral T, std::integral U>
// auto gcd(T a, U b);
static_assert(gcd(48, 18) == 6);
2
3
4
5
6
7
8
9
10
注意:auto 约束对每个参数独立推导 T——gcd(48, 18LL) 中两个参数可以不同型(只要都 integral)。要强制同型,用以下写法:
template <std::integral T>
T gcd(T a, T b); // 传统写法:同型
auto gcd(std::integral auto a, decltype(a) b); // auto简写强制同型
2
3
4
# 4. requires 子句位与用
# 4.1 requires clause vs expression
这是 Concepts 最容易混淆的概念——C++ 标准里有两个「requires」,名字一样但语义完全不同:
| requires 表达式 | requires 子句 | |
|---|---|---|
| 英文 | requires-expression | requires-clause |
| 出现在哪 | concept 定义体里 | 模板声明上 |
| 作用 | 检查表达式能否编译 | 一组约束的入口 |
| 返回值 | bool(编译期) | 无——它是语法标记 |
| 示例 | requires(T a) { a.sort(); } | requires sortable<T> |
// requires 表达式:在 concept 定义体里,测试 T 能否 sort()
template <typename T>
concept sortable = requires(T& c) {
c.sort();
};
// requires 子句:在模板声明上,附加约束条件
template <typename T>
requires sortable<T> // ← 这里是 requires clause
void process(T& c) {
c.sort();
}
2
3
4
5
6
7
8
9
10
11
12
记忆法:concept 左边等号后面的是 requires 表达式(装检测逻辑);模板参数列表后面的 requires ... 是 requires 子句(装约束条件引用)。
# 4.2 四种悬挂位置
Concepts 的 requires 子句可以挂在四个语法位置——每个位置的语义完全不同:
// 位置 ①:模板形参列表后(模板头)← 最优
template <typename T>
requires std::integral<T>
void fn(T t);
// 位置 ②:函数声明尾 ← 不能用约束偏序
template <typename T>
void fn(T t) requires std::integral<T>;
// 位置 ③:auto 参数约束(缩写模板)← 不对每个参数独立
void fn(std::integral auto t);
// 位置 ④:返回值 auto 约素 ← 不参与重载决议
template <typename T>
std::integral auto fn(T t);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
位置 ① 为什么最优:只有位置 ① 的约束能参与约束偏序(constraint partial ordering)——也就是案例 1.1 里编译器判断「sortable 是否比 iterable 更强」的唯一位置。位置 ② 的约束也存在,但不参与和其他重载的偏序比较。
约束偏序的计算范围:
┌─ 位置 ① 的约束 ──────────────────────────────┐
│ (每个重载的模板头 requires clause) │
│ 编译器提取后,做归一化→原子约束→蕴含判断 │
└───────────────────────────────────────────────┘
┌─ 位置 ② 的约束 ──────────────────────────────┐
│ 只参与「是否满足约束」——不参与谁更强 │
└───────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
# 4.3 尾随 requires 的陷阱
案例 ② 的现象现在能解释了——两段看起来一样的约束放在不同位置产生不同结果:
// 版本 A:位置 ① → 参与约束偏序 → sortable 比 iterable 更强
template <typename T>
requires sortable<T>
void process(T& c);
// 版本 B:位置 ② → 不参与约束偏序 → sortable 和 iterable 平权
template <typename T>
void process(T& c) requires sortable<T>;
2
3
4
5
6
7
8
Compiler Explorer 实测(GCC 13.2):
#include <concepts>
#include <vector>
#include <algorithm>
template <typename T>
concept iterable = requires(T& c) {
typename T::iterator;
{ c.begin() } -> std::input_iterator;
};
template <typename T>
concept sortable = iterable<T> && requires(T& c) {
std::sort(c.begin(), c.end());
};
// 版本 A:模板头约束
template <iterable T> void fn(T&) { }
template <sortable T> void fn(T&) { } // 位置 ①:偏序生效
// 版本 B:函数尾约束
template <typename T> void gn(T&) requires iterable<T> { }
template <typename T> void gn(T&) requires sortable<T> { } // 位置 ②:偏序不生效 → 二义性
std::vector<int> v;
fn(v); // ✅ 选 sortable 版本(位置 ① 偏序生效)
gn(v); // ❌ ambiguous(位置 ② 偏序不生效,两个约束同等)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
生产建议:约束永远放在位置 ①(模板头)——除非你明确不需要偏序(极少见)。
# 4.4 约束变量的生命周期
Concepts 约束里的变量——requires 表达式里声明的那些——只在检查瞬间存在:
template <typename T>
concept has_size = requires(T t) {
t.size(); // t 的生存期 = 这一行的概念检查
};
// 出了概念定义,t 不存在。T 的实例也从未构造。
2
3
4
5
编译器绝不构造真实对象——它用 declval<T>() 级别的半虚对象做类型推导。这保证概念检查是零运行时开销的。
# 5. 约束模板与重载决议
# 5.1 三步裁决法
C++20 给重载决议加入约束层后,完整流程变成三步——约束在前,特化在后:
函数调用 process(v)
│
▼
┌─ 第一步:约束合格筛选 ───────────────────────┐
│ 对每个候选模板,检查约束是否满足 │
│ process<iterable T>(T&) iterable<vector<int>> → ✅ 进入候选 │
│ process<sortable T>(T&) sortable<vector<int>> → ✅ 进入候选 │
│ process<integral T>(T&) integral<vector<int>> → ❌ 淘汰 │
└──────────────────────────────────────────────┘
│
▼
┌─ 第二步:约束偏序(这篇的核心!)──────────┐
│ 在通过的候选中,判断哪个约束「严格更强」 │
│ sortable 蕴含 iterable(如果原子约束写对的话)→ sortable 更强 │
│ iterable 不蕴含 sortable │
│ 结果:sortable 胜出 │
└──────────────────────────────────────────────┘
│
▼
┌─ 第三步:传统重载决议 ───────────────────────┐
│ 如果约束偏序平权(如两个 iterable),回退到 │
│ 传统规则(特化度 > 非模板 > 类型转换) │
└──────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
关键:约束偏序是独立于类型推导的第一道关卡——在进入传统重载决议之前,编译器已经用 Constraints 砍掉了大部分歧义。
# 5.2 约束的偏序算法
标准规定([temp.constr.order]):
约束 P 蕴含约束 Q,当且仅当对任意可能的模板实参,P 满足时 Q 也满足。
编译器实现这个规则的算法:
P 蕴含 Q 的判定流水线:
1. 归一化 (normalization)
P → P 的析取范式 DNF:每条子句是原子约束的合取
Q → Q 的合取范式 CNF:每条子句是原子约束的析取
2. 原子约束拆分
把 P 的每个 DNF 子句拆成 {A1, A2, ...}(一组原子约束)
把 Q 的每个 CNF 子句拆成 {B1, B2, ...}
3. 逐条比较蕴含
对 P 的每个 DNF 子句,存在 Q 的一个 CNF 子句,
使得后者的每个原子约束在前者中能找到 **同一表达式** 的原子约束
2
3
4
5
6
7
8
9
10
11
12
13
什么条件下同一原子约束:两个原子约束来自同一个源表达式——不是值相等,是表达式相同(结构相同、操作数相同)。
// 这两个概念看起来一样……但原子约束不一样!
concept C1 = std::is_integral_v<T> && (sizeof(T) > 1);
concept C2 = std::is_integral_v<T> && (sizeof(T) > 1);
// └───── 源表达式相同 → 同一原子约束 ──────┘
// 下面的就不一样了:
concept C3 = std::is_integral_v<T> && std::is_signed_v<T>;
concept C4 = std::integral<T> && std::is_signed_v<T>;
// C3 的原子约束:std::is_integral_v<T>, std::is_signed_v<T>
// C4 的原子约束:std::integral<T>, std::is_signed_v<T>
// └─ 这俩来自不同表达式!is_integral_v ≠ integral ——不是一个 concept 名字!
2
3
4
5
6
7
8
9
10
11
这就是案例 1.1 的根因:sortable 里写了 std::random_access_iterator,而 iterable 里写的是 std::input_iterator——两者来自不同的 concept 名字,不能判定蕴含关系。
# 5.3 约束胜过非约束
一条最重要的偏序规则:
有约束的模板 > 无约束的同名模板
// 无约束后备 (fallback)
template <typename T>
void serialize(const T& x);
// 约束版(对迭代器做优化)
template <std::input_iterator T>
void serialize(const T& it);
std::vector<int> v;
serialize(v.begin()); // 不二义性!约束版自动胜出
// begin() 的返回值是迭代器,满足 input_iterator
2
3
4
5
6
7
8
9
10
11
这在 SFINAE 时代要写一堆 enable_if<is_iterator_v<T>>——现在一行 std::input_iterator 就能让后备自动退让。**"约束自寻功能"**是 Concepts 胜过 SFINAE 最舒爽的场景。
# 5.4 requires false 的灭亡技
Concepts 没有 "立即上下文" 的概念——它采用硬约束(hard constraint)——一旦检查失败,不是退让,是直接报错:
template <typename T>
concept must_be_int = std::same_as<T, int>;
template <must_be_int T>
void assert_size(T t) {
static_assert(sizeof(T) == 4); // 这也会触发
}
assert_size(4.0); // ❌ 硬错误:double 不满足 must_be_int
// 不像 SFINAE 那样「沉默淘汰」,Concepts 会清楚告诉你不满足的地方
2
3
4
5
6
7
8
9
这种「灭亡而非退让」的设计使 Concepts 的诊断质量远高于 SFINAE——下一章展开。
# 6. 蕴含与原子约束
# 6.1 subsumption 定义
蕴含(subsumption)是 Concepts 最精妙的设计:
约束 A 蕴含 (subsume) 约束 B,当且仅当在 A 的析取范式中,能论证出 B 的合取范式。
通俗版:如果"满足更强的约束"在逻辑上必然推出"满足更弱的约束"——就像 random_access_iterator 必然满足 input_iterator——那么更强约束的那个重载自动胜出。
但这需要原子约束层面的一致——而不仅仅是"语义上更严格"。
# 6.2 原子约束归一化
编译器在比较两个约束的蕴含关系之前,先把它们「归一到原子约束」(来自 [temp.constr.normal]):
template <typename T>
concept my_integral = std::is_integral_v<T>;
// └──── 原子约束的源表达式 ────┘
// my_integral<int> 归一化后 = 原子约束 std::is_integral_v<int>
// my_integral<T> 归一化后 = 原子约束 std::is_integral_v<T>
2
3
4
5
6
两个约束的蕴含判定,最终依赖「子原子约束是否来自同一个表达式字符串」:
// ✅ 蕴含成立:同一个 concept 名字 + 同一个模板实参
concept A = std::integral<T>;
concept B = std::integral<T> && (sizeof(T) >= 4);
// A 蕴含 B? 不是。但 B 蕴含 A? 是!
// B 的原子约束 {std::integral<T>, sizeof(T)>=4}
// A 的原子约束 {std::integral<T>}
// → B 的原子约束集合包含了 A 的所有原子约束 → B 蕴含 A
// ❌ 蕴含不成立:不同的 concept 名字但语义等价
concept C = std::is_integral_v<T>; // 从 is_integral_v 来
concept D = std::integral<T>; // 从 integral 来
// C 不蕴含 D,因为原子约束的源表达式不同!
// 即使 is_integral_v<T> 与 integral<T> 在语义上完全等价
2
3
4
5
6
7
8
9
10
11
12
13
生产红线:
- 要建立蕴含关系的 concept,必须追溯到同一个源表达式
- 标准库
<concepts>用integral = is_integral_v<T>这种写法统一了出身 - 你自己的 concept 层级必须从上到下一个祖宗
# 6.3 为什么 的规则长长
打开 <concepts> 你会看到类似:
template <class T>
concept integral = std::is_integral_v<T>;
template <class T>
concept signed_integral = integral<T> && std::is_signed_v<T>;
template <class T>
concept unsigned_integral = integral<T> && !signed_integral<T>;
// 注意这里重新引用 integral → 维持原子约束同源
2
3
4
5
6
7
8
9
链状设计的关键:每一个派生概念都直接引用其基概念(而不是重述等价的 trait)——这样编译器能通过原子约束追踪到 integral<T>,实现 signed_integral 蕴含 integral。
# 6.4 判决三种失败模式
实践中蕴含判定失败的三种根因:
失败①:引用不同的 trait 名字
concept A = std::is_integral_v<T>; // 源:is_integral_v
concept B = std::integral<T>; // 源:integral
// ❌ 不蕴含
2
3
修复:统一用标准库概念或者统一用自己的概念。
失败②:中间概念阻断
concept my_number = std::integral<T> || std::floating_point<T>;
concept my_float = std::floating_point<T>; // 直接从 trait 拿
// my_float 不蕴含 my_number
// ✅ 修复:让 my_float 继承自 my_number
concept my_float_v2 = my_number<T> && std::floating_point<T>;
2
3
4
5
6
7
失败③:requires 表达式的原子约束不传播
concept sortable_v1 = requires(T& c) { std::sort(c.begin(), c.end()); };
concept sortable_v2 = sortable_v1 && requires(T& c) { c.begin(); };
// ❌ 两个 has_begin 不是同一个源表达式——一个在 sortable_v1 里,一个在 sortable_v2 里
2
3
这种场景比前两个更伤——requires 表达式本身不生成可共享的原子约束。解决方案是用具名的 concept 做组合:
concept has_begin = requires(T& c) { c.begin(); };
concept sortable = has_begin<T> && requires(T& c) { std::sort(c.begin(), c.end()); };
// ✅ sortable 蕴含 has_begin——因为 has_begin 是同一个原子约束的源概念
2
3
# 7. 命名约束的可读性飞跃
# 7.1 SFINAE 到 Concept 逐行翻译
把第 19 篇的 SFINAE 噩梦改成 Concepts,效果惊人:
// ======== SFINAE 版(第 19 篇摘录)========
template <typename T>
auto serialize(const T& x) -> typename std::enable_if<
std::is_arithmetic<T>::value, std::string>::type { // 这一行还读吗
return std::to_string(x);
}
template <typename T>
auto serialize(const T& x) -> typename std::enable_if<
!std::is_arithmetic<T>::value && has_begin_v<T>, std::string>::type {
std::string s = "[";
for (const auto& e : x) s += serialize(e) + ",";
return s + "]";
}
// 17000 行错误信息……
// ======== Concepts 版 ========
template <std::integral T>
std::string serialize(const T& x) { return std::to_string(x); }
template <std::ranges::input_range T>
std::string serialize(const T& r) {
std::string s = "[";
for (const auto& e : r) s += serialize(e) + ",";
return s + "]";
}
// 错误信息:3 行
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
直觉对比:
| 维度 | SFINAE | Concepts |
|---|---|---|
| 声明行长度 | 60~150 字符 | 20~40 字符 |
| 约束意图是否可读 | ❌(要逆推 enable_if) | ✅(concept 名直接说) |
| 错误信息 | 17000 行(最深到 STL 内部) | 3~5 行(精确到概念名) |
| 新人接手成本 | 需理解 SFINAE 全链路 | 认识 concept 名即可 |
# 7.2 标准库 速查
C++20 <concepts> 提供的核心命名约束:
语言基础 concepts:
same_as<T, U> T 和 U 是同一类型
derived_from<D, B> D 公开派生自 B
convertible_to<From, To> From 可隐式转换到 To
common_reference_with<T, U> T 和 U 有共同引用类型
common_with<T, U> T 和 U 有共同类型
integral<T> T 是整型
signed_integral<T> T 是有符号整型
unsigned_integral<T> T 是无符号整型
floating_point<T> T 是 IEEE 浮点类型
比较 concepts:
equality_comparable<T> T 可以用 == 比较
totally_ordered<T> T 有全序关系(< > <= >=)
对象 concepts:
movable<T> T 可移动构造与赋值
copyable<T> T 可拷贝构造与赋值
semiregular<T> 可默认构造 + 可拷贝
regular<T> semiregular + equality_comparable
可调用 concepts:
invocable<F, Args...> 能用 Args... 调用 F
regular_invocable<F, Args...> invocable + 无副作用
predicate<F, Args...> 返回 bool 的 invocable
范围 concepts:
input_iterator<T> 输入迭代器
forward_iterator<T> 前向迭代器
bidirectional_iterator<T> 双向迭代器
random_access_iterator<T> 随机访问迭代器
contiguous_iterator<T> 连续迭代器(如指针、vector::iterator)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 7.3 三范例重塑阅读体检
范例①:sort 函数
// SFINAE
template <typename It>
auto sort(It first, It last)
-> std::enable_if_t<std::is_base_of_v<
std::random_access_iterator_tag,
typename std::iterator_traits<It>::iterator_category>, void>;
// Concepts
template <std::random_access_iterator It>
void sort(It first, It last);
2
3
4
5
6
7
8
9
10
范例②:工厂函数
// SFINAE
template <typename T, typename... Args>
auto make(Args&&... args)
-> std::enable_if_t<std::is_constructible_v<T, Args...>, T>;
// Concepts
template <typename T, typename... Args>
requires std::constructible_from<T, Args...>
T make(Args&&... args);
2
3
4
5
6
7
8
9
范例③:模板互斥
// SFINAE:enable_if 四件套(第 19 篇的 enable_if 插桩表)
// Concepts:
template <std::integral T> void handle(T v);
template <std::floating_point T> void handle(T v);
template <std::ranges::range T> void handle(T v);
// 三行,互斥,偏序→integral 和 floating_point 平权,用传统重载选
2
3
4
5
6
# 8. 错误信息对决
# 8.1 SFINAE 错误洪水统计
回顾第 19 篇的 17000 行错误(GCC 12):
$ g++ -c sfinae_serialize.cpp -std=c++17 2>err.txt | wc -l
17000
最深处:
/usr/include/c++/12/bits/stl_vector.h:1234: note:
candidate: 'std::vector<_Tp, _Alloc>::size_type
std::vector<_Tp, _Alloc>::_M_check_len(...) [with ...]'
...
# 从业务代码 serialize(v) 到 STL 内部 _M_check_len,
# 候选模板链共 47 层模板实例化回溯
2
3
4
5
6
7
8
9
10
11
# 8.2 Concepts 约束诊书样式
同一份逻辑用 Concepts 后:
$ g++ -c concept_serialize.cpp -std=c++20 2>err.txt
$ cat err.txt
concept_serialize.cpp:25:13: error: no matching function for call to 'serialize(std::vector<std::shared_ptr<int>>&)'
25 | serialize(v);
| ~~~~~~~~~^^^
concept_serialize.cpp:15:13: note: candidate: 'std::string serialize(const T&) [with T = std::vector<std::shared_ptr<int>>]'
15 | std::string serialize(const std::integral auto& x)
| ^~~~~~~~~
concept_serialize.cpp:15:13: note: constraints not satisfied
concept_serialize.cpp: In substitution of 'std::string serialize(const T&) [with T = std::vector<std::shared_ptr<int>>]':
concept_serialize.cpp:15:32: note: the required condition 'std::integral<std::vector<std::shared_ptr<int>>>' is not satisfied
15 | std::string serialize(const std::integral auto& x)
| ^~~~~~~~~~~~~
3 行 vs 17000 行。
2
3
4
5
6
7
8
9
10
11
12
13
14
差异根源:Concepts 的约束检查在「候选模板的入口处」就完成——如果不满足,编译器根本不进入模板体,从而不会触发模板体内部的层层 sub-template 展开。SFINAE 要做到"入口处剪枝"需要额外的 enable_if 插桩手艺(第 19 篇 §4.2),Concepts 是默认行为。
# 8.3 编译时间对比证据
以第 19 篇的金融中台序列化代码为测试坝(GCC 13.2 -O2,12 个不同容器类型的序列化调用):
| 版本 | 模板实例化次数 | 错误行数 | 编译时间 |
|---|---|---|---|
| SFINAE (C++17) | 483 | 17000 | 4.2s |
| Concepts (C++20) | 61 | 3 | 0.9s |
模板实例化减少 87%——因为 Concepts 让编译器在「约束检查」阶段就把不匹配的候选剪掉,避免了 SFINAE 那种「先进去再退出来」的尝试性实例化开销。
# 9. Concepts 踩坑录
# 9.1 过度约束与约束循环
过度约束:把 concept 写得比实际需要更苛刻:
// ⚠️ 过度:要求迭代器返回 int&,但业务逻辑只需要 ++ 和 *
template <typename T>
concept my_iterator = requires(T it) {
{ *it } -> std::same_as<int&>; // ← 约束太紧
{ ++it } -> std::same_as<T&>;
};
// 结果:传入 vector<double>::iterator 就被淘汰——尽管它完全能 ++ 和 *
2
3
4
5
6
7
修复:应该用 std::input_iterator 或仅约束需要的操作。
约束循环:
template <typename T>
concept A = B<T>; // A 依赖 B
template <typename T>
concept B = A<T>; // B 依赖 A
// 编译器拒绝:约束循环引用
2
3
4
5
# 9.2 类模板中的概念缺陷
类模板和 concept 结合时有一个可怕的陷阱——类模板没有「约束偏序」:
// ✅ 函数模板:约束偏序生效
template <std::integral T> void f(T);
template <std::floating_point T> void f(T);
f(1); // 挑 integral
// ❌ 类模板:没有约束偏序
template <std::integral T> struct Wrapper { /*...*/ };
template <std::floating_point T> struct Wrapper { /*...*/ };
// 编译错误:redefinition of 'struct Wrapper'
// 类模板不支持约束偏序——同名的受约束类模板等价于重复定义
2
3
4
5
6
7
8
9
10
绕路方法:偏特化:
template <typename T, typename = void>
struct Wrapper; // 主模板
template <std::integral T>
struct Wrapper<T, std::enable_if_t<std::integral<T>>> { /* int 版 */ };
template <std::floating_point T>
struct Wrapper<T, std::enable_if_t<std::floating_point<T>>> { /* float 版 */ };
2
3
4
5
6
7
8
或者用 if constexpr 分岔(第 20 篇 §6.2):
template <typename T>
requires (std::integral<T> || std::floating_point<T>)
struct Wrapper {
static constexpr auto kind = std::integral<T> ? "int" : "float";
};
2
3
4
5
# 9.3 概念版本迭代的二进制危机
这是 Concepts 在大型项目里的隐性炸弹:concept 定义变了,但不触发依赖于它的翻译单元重编译——因为 concept 所在头文件的依赖并没有通过 #include 的宏传递到 .cpp 的用户:
// my_concepts.h (v1)
template <typename T> concept printable = requires(T x) { std::cout << x; };
// my_concepts.h (v2) —— 增加了一个要求
template <typename T> concept printable = requires(T x) {
std::cout << x;
{ x } -> std::convertible_to<std::string>; // 新增约束
};
2
3
4
5
6
7
8
如果 .cpp 文件的编译缓存(C++20 Modules 之外的传统包含模型)没有失效——链接产物可能通过 ODR 规则产生 UB。Modules 出现之前的唯一防线是确保概念头文件被全部 dependents 显式 #include。
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章七个疑问,逐条作答:
| # | 疑问 | 答案 |
|---|---|---|
| ① | concept 定义语法? | 第 3.1~3.3:template<typename T> concept C = bool-expr;,副武器 requires 表达式支持四类检查 |
| ② | requires clause vs expression? | 第 4.1:clause 是模板头的约束入口,expression 是概念体里的编译期断言 |
| ③ | constraint 如何参与重载决议? | 第 5.1:三步——约束合格筛选→约束偏序→传统决议 |
| ④ | sortable 包含 iterable 但二义性? | 第 6.2:原子约束不是同一个源表达式——input_iterator ≠ random_access_iterator,蕴含链断裂 |
| ⑤ | Concepts 比 SFINAE 好多少? | 第 8.3:实例化减 87%、错误 17000→3 行、编译时间 4.2s→0.9s |
| ⑥ | requires 位置影响决议? | 第 4.2:位置 ① 参与偏序,位置 ② 不参与。永远放位置 ① |
| ⑦ | SFINAE → Concepts 迁移? | 见下一节 |
案例①修复——让蕴含关系成立:
// ❌ 原版:iterable 用 input_iterator,sortable 用 random_access_iterator →
// 两个原子约束来源不同——蕴含断裂
template <typename T> concept iterable = requires(T& c) {
typename T::iterator;
{ c.begin() } -> std::input_iterator;
};
template <typename T> concept sortable = iterable<T> && requires(T& c) {
std::sort(c.begin(), c.end());
};
// ✅ 修复版:sortable 只包含 iterable + 额外要求
// 关键:sortable = iterable<T> && ... → 保留 iterable 作为原子约束
// sortable 满足时,无需重复声明 iterator——iterable 已经声明了
template <typename T>
concept sortable = iterable<T> && requires(T& c) {
requires std::random_access_iterator<decltype(c.begin())>;
// └─── 用 requires 子句做「嵌套约束检查」——不影响蕴含
std::sort(c.begin(), c.end());
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
requires 子句的嵌套检查(nested requirement)是关键武器——它验证 c.begin() 满足 random_access_iterator,但不创建新的 concept 原子约束,从而保留了 sortable 与 iterable 之间的蕴含关系。
案例②修复——requires 统一放位置 ①:
// ✅ 全部约束放在模板头
template <typename T>
requires sortable<T>
void process(T& c); // 约束偏序生效,sortable 胜过 iterable
2
3
4
# 10.2 从 SFINAE 到 Concept 的迁移路径
三步走——不要求一次性改造整个代码库:
第一步:并发双栈
┌──────────────────────────────────────────────────┐
│ #if __cpp_concepts >= 202002L │
│ template <std::integral T> void f(T); // Concepts 版 │
│ #else │
│ template <typename T, std::enable_if_t<...>> void f(T); // SFINAE 版 │
│ #endif │
└──────────────────────────────────────────────────┘
第二步:统一概念层
定义 my_project/concepts.hpp
一次性把所有 SFINAE trait 转成 named concepts
第三步:逐步替换
按文件逐个把 enable_if → concept 引用
每次替换跑全量 CI——build 通过即正确
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
迁移 checklist:
- [ ]
${PROJECT}_concepts.hpp创建完毕,所有项目级的类型约束有具名 concept - [ ] 每个 concept 的原子约束链从头单一来源(不要交叉引用不同 trait)
- [ ] requires 子句统一放在模板头(位置 ①)
- [ ] 找出所有
enable_if+void_t用法,逐对转 concept - [ ] 全量构建通过,
nm检查符号无变化 - [ ] 移除
#if __cpp_concepts的 SFINAE 分支
# 10.3 设计哲学回扣
哲学 1:命名是最小的一步,也是最大的一步
concept integral = std::is_integral_v<T>; 只有一行,但这一行把"什么是整数"从类型推导的副作用变成了一等谓词。SFINAE 用 enable_if<is_integral_v<T>> 把关,意图藏在七拐八绕的模板语法里;Concepts 说 template <integral T>,把意图直接放在代码的最前面。这是「可读性」对「可写性」的胜利——代码读的次数远多于写的次数。
哲学 2:约束即文档——把接口协议编译化
void sort(sortable auto& c) 这道声明本身就是强制执行的文档——它精确告诉你「sort 需要什么」,而且编译器会替你强制执行。这和 C++20 Contracts(还没进标准)共享同一哲学:让编译器的类型系统帮你检验前提条件。SFINAE 的 enable_if 也能做同样的事,但它把"前提条件"藏在了十重括号里——好的约束应该直接可见。
哲学 3:蕴含——编译器的"常识推理"
subsumption 把"所有能满足 A 的 T 都满足 B"这种人类的常识推理写成了编译器可判定的规则。这本质上是用命题逻辑的判定过程(normalization + atomic comparison)替代程序员的"我猜这个应该更强"的直觉。代价是规则很严格(必须同源表达式),收益是编译器替你推理,而非依赖你的推理——这正是形式化方法在编程语言里的正确落地姿势。
哲学 4:早裁剪——争取消灭编译器工时
Concepts 让重载决议在「候选生成」阶段就砍掉不满足约束的模板——这个"epoch 级别早停"消除了 SFINAE 的「实例化→失败→回滚」试探循环。87% 的实例化减少不是偶然的——每次节省都是编译器不再需要:进入函数体 → 展开默认实参 → 实例化辅助类型 → 发现 enable_if 的 type 不存在 → 回滚。把检测前移一个 epoch,就省掉一整个 epoch 的无效工作。
# 10.4 速查表合集
concept 定义语法速查:
| 写法 | 示例 |
|---|---|
| 简单 trait | concept C = std::is_integral_v<T>; |
| 逻辑组合 | concept C = A<T> && (B<T> \|\| C<T>); |
| requires 表达式 | concept C = requires(T t) { t.f(); {t.g()} -> std::same_as<int>; }; |
| 嵌套 requires | concept C = requires(T t) { requires std::integral<T>; }; |
requires 表达式四项检查:
| 写法 | 含义 | 编译期开销 |
|---|---|---|
expr; | 可编译 | 无(纯类型推导) |
{ expr } -> concept; | 可编译且返回类型满足 concept | 无 |
typename T::type; | 嵌套类型存在 | 无 |
{ expr } noexcept; | 可编译且 noexcept | 无 |
约束偏序蕴含判定流水线:
两个候选的 constraint 集合
│
├─ 归一化:P → DNF, Q → CNF
│
├─ 拆分:每条子句 → 原子约束集合
│
├─ 逐条比较:P 的 DNF 子句 vs Q 的 CNF 子句
│ 关键:原子约束必须是「同一个源表达式」
│
└─ 结论
├─ 蕴含 → 先挑
├─ 不蕴含 → 平权 → 传统决议
└─ 二义性
2
3
4
5
6
7
8
9
10
11
12
13
标准库
| 类别 | concepts | 常见用途 |
|---|---|---|
| 类型判断 | integral, floating_point, signed_integral | 数值函数约束 |
| 类型关系 | same_as<A,B>, derived_from<D,B>, convertible_to<From,To> | 泛型接口 |
| 对象语义 | movable, copyable, regular | 容器元素约束 |
| 迭代器 | input_iterator → forward → bidirectional → random_access → contiguous | 算法泛型 |
| 可调用 | invocable<F,Args>, predicate<F,Args> | 回调约束 |
60 秒诊断与迁移命令:
# 查看当前编译器 Concepts 支持版本
echo | g++ -dM -E -x c++ -std=c++20 - | grep cpp_concepts
# __cpp_concepts >= 202002L → Concepts 完整可用
# 查看一个模板是否符合某个 concept
clang++ -Xclang -ast-dump -fsyntax-only file.cpp | grep Concept
# 查看重载决议选择了哪个模板(GCC 13+)
g++ -std=c++20 -fdump-tree-original file.cpp
# 将 SFINAE enable_if 批量转为 concept
# 半自动化工具:clang-tidy + modernize-use-constraints (实验性)
2
3
4
5
6
7
8
9
10
11
12
一图定型:
Concepts = 定义 + 使用 + 裁决 × 编译期早停
定义 使用 裁决
concept C = template<C T> subsumption
is_integral_v requires C<T> 原子约束归一化
&& requires{} C auto 蕴含 → 偏序胜出
不蕴含 → 回退
└──────────────┬──────────────┘
▼
错误信息:17000 行 → 3 行
实例化数:减少 87%
编译时间:4.2s → 0.9s
2
3
4
5
6
7
8
9
10
11
12
下一篇:Concepts 给模板的"输入"加上了护城河。下一篇进入 23.元编程模板技巧——看看那些「不讲类型安全」的模板黑魔法:TypeList 编译期链表、Loki/Boost.MPL 的范本遗产、用模板计算斐波那契、CRTP 奇异递归模板——当 Concepts 的约束还不够,就在编译期写一整份类型级的程序。