Chapter 41: Concepts
模板代码的问题经常出现在调用点和真正失败点之间。调用者写下一个看似普通的泛型函数调用,编译器却在函数体、算法内部循环、iterator 运算或 traits 展开深处报错。Concepts 把模板实参必须满足的条件提升到接口层,使调用者能在函数声明处看到“这个模板接受什么类型”。
本章讨论 C++20 concepts 如何描述模板接口条件。读完后应能定位 requires 写在声明上的作用,区分 requires clause 和 requires expression,解释约束如何参与候选过滤和重载选择,并判断 std::same_as、std::integral、std::ranges concepts 各自适合表达哪类标准库要求。
贯穿本章的材料是一个收集记录 ID 的泛型函数。它接受一个 range,range 中的每个元素都要有整数型 id 成员。这个例子足够小,可以看清概念定义、约束表达、候选过滤、精确类型收窄和 ranges 接口之间的关系。
#include <concepts>
#include <ranges>
#include <type_traits>
#include <utility>
#include <vector>
template<class T>
using id_member_t = std::remove_cvref_t<decltype(std::declval<T>().id)>;
template<class T>
concept HasIntegralId =
requires(T&& value) {
value.id;
} &&
std::integral<id_member_t<T>>;
template<class R>
concept IdRange =
std::ranges::input_range<R> &&
HasIntegralId<std::ranges::range_reference_t<R>>;
template<IdRange R>
auto collect_ids(R&& records)
{
using id_type = id_member_t<std::ranges::range_reference_t<R>>;
std::vector<id_type> result;
for (auto&& record : records) {
result.push_back(record.id);
}
return result;
}
这段代码的核心结论是:collect_ids 的接口已经说明输入必须是可遍历的 range,并且元素引用类型必须能提供整数型 id。编译器在选择这个模板前检查这些条件;满足条件才进入函数体实例化,条件失败时诊断会落在 IdRange、HasIntegralId 或 std::integral 这类接口约束上。
41.1 concepts 为什么出现
Concepts 出现的直接动机,是把模板要求从“使用时碰巧失败”改成“接口上显式声明”。在传统模板中,函数体里写了 record.id,编译器只有在实例化函数体时才知道实参类型是否具备这个成员。调用者看到的是 template<class R> auto collect_ids(R&&),接口没有暴露 R 的能力要求。
SFINAE 已经能把部分失败转成候选淘汰,例如把 decltype(std::declval<T>().id) 放进返回类型、默认模板参数或 traits 检测中。它解决了“失败要淘汰候选”的问题,却常把接口条件分散到 enable_if、辅助类型和表达式检测里。调用者阅读函数声明时,需要反向追踪多个模板别名才能还原真实要求。
Concepts 给模板增加了一个接口层级。template<IdRange R> 直接表达“这个模板接收 IdRange”,IdRange 再展开成 std::ranges::input_range<R> 和元素约束。这个层级让标准库算法可以把“需要随机访问 range”“需要可排序 iterator”“需要谓词能接受元素引用”写进声明,调用失败时也能把错误定位到不满足的约束。
在 STL 工程阅读中,concepts 的价值体现在三个位置。第一,算法声明不再只是一组模板参数,而是带有输入能力说明的接口。第二,iterator、range、predicate、relation 这类语义类别可以组合成可复用的命名条件。第三,重载集可以根据约束强弱选择更精确的实现路径,源码读者能从声明推断算法入口的边界。
回到 collect_ids,HasIntegralId 将“元素有 id 成员”和“id 的类型属于整数族”合并成一个命名要求。调用方传入 std::vector<User> 时,编译器先确认 vector 是 input_range,再确认 User& 对应的 id 成员满足 std::integral。这个检查发生在模板体展开前,函数体中的 record.id 不再承担接口诊断的第一责任。
41.2 requires
requires 有两种常见形态:requires clause 把约束附着到声明上,requires expression 在编译期检查一组类型或表达式要求并产生可用于约束的布尔结果。两者经常一起出现,但承担的角色不同。
requires clause 出现在模板参数列表之后或函数声明尾部,用来说明当前声明参与候选集的条件。下面两个声明把 HasIntegralId<T> 附着到函数模板上,表达的是同一类接口边界。
template<class T>
requires HasIntegralId<T>
auto id_of(const T& value)
{
return value.id;
}
template<class T>
auto id_of_checked(const T& value) requires HasIntegralId<T>
{
return value.id;
}
前置 requires clause 靠近模板参数,适合表达类型参数自身的约束。尾置 requires clause 靠近函数声明末尾,适合处理成员函数、返回类型和参数声明之后才能自然读出的条件。标准语义上,两者都会成为声明关联约束的一部分,并参与候选过滤。
requires expression 出现在概念定义或约束表达式中,用来检查表达式是否合法、类型是否存在、返回类型是否满足附加约束。HasIntegralId 中的第一段就是一个 requires expression。
template<class T>
concept HasIdExpression = requires(T&& value) {
value.id;
};
这段表达式只检查 value.id 是否可形成合法表达式。它没有规定 id 的类型,也没有规定读取该成员是否有业务语义。后面的 std::integral<id_member_t<T>> 才把类型族要求接进来。
requires expression 内部可以写简单要求、类型要求、复合要求和嵌套要求。简单要求检查表达式能否形成;类型要求检查嵌套类型或别名是否存在;复合要求可以检查 noexcept 和返回类型约束;嵌套要求把额外布尔条件放入表达式体内。
template<class T>
concept CompactRecord = requires(T value) {
typename T::key_type;
{ value.key() } -> std::same_as<typename T::key_type>;
requires sizeof(T) <= 64;
};
这个例子说明 requires expression 负责把“能写哪些表达式”变成编译期约束。typename T::key_type; 查询类型成员,{ value.key() } -> std::same_as<typename T::key_type>; 查询表达式结果的精确类型,requires sizeof(T) <= 64; 查询额外常量条件。真正的函数是否可调用,还取决于这些要求被哪个 requires clause 或 concept 使用。
在 collect_ids 中,requires(T&& value) { value.id; } 是最低层的表达式合法性检查。它先确认成员访问可用,然后 id_member_t<T> 才有意义。这个顺序让约束表达式能安全组合:先建立表达式存在性,再询问类型属性,再组合成 range 级别约束。
41.3 constraint
Constraint 是约束表达式形成的编译期条件。Concept 是命名后的约束集合。函数模板、类模板、变量模板、别名模板和类模板成员函数都可以关联约束,编译器用这些约束判断候选是否可用,并在多个候选同时可用时做更精确的重载选择。
IdRange 展开后包含两个主要条件:std::ranges::input_range<R> 检查输入整体是否提供 range 接口,HasIntegralId<std::ranges::range_reference_t<R>> 检查元素引用类型是否提供整数型 id。这两个条件通过逻辑与连接,任何一个失败都会使 collect_ids 从可行候选中移出。
约束检查可以按下面的路径理解。图中的“候选过滤”发生在模板函数体真正展开之前,“普通重载决议”接着处理仍然可行的候选。
这个路径解释了 concepts 和函数体错误的分离。函数体里的 result.push_back(record.id) 仍然可能因为其他原因失败,例如 id_type 无法放入 vector、record.id 的访问控制受限、allocator 行为触发额外约束。但“有没有 id”和“id 是否属于整数类型”已经在进入函数体之前被检查。
约束还会影响重载优先级。下面的两个重载都可以接受 std::vector<User>,其中 std::ranges::random_access_range 比 std::ranges::input_range 表达更强的 range 能力,因此 vector 会选择更强约束的重载。
template<std::ranges::input_range R>
void scan(R&& records);
template<std::ranges::random_access_range R>
void scan(R&& records);
这个选择依赖标准定义的约束蕴含关系,也就是一个约束在语义上比另一个更强。编译器会对约束做归一化,把表达式拆成原子约束,再判断候选之间的偏序关系。工程上需要记住一个边界:编译器并非任意布尔定理证明器。两个看起来等价的手写表达式,如果原子约束来源和写法无法建立蕴含关系,重载选择可能出现歧义。
因此,STL 和泛型库应优先复用标准概念或共享的自定义概念。IdRange 把输入 range 和元素条件封装成一个命名概念,后续重载继续复用 IdRange,可以让候选关系更稳定。手写散落的 requires 表达式越多,读者和编译器都越难判断约束强弱关系。
41.4 std::same_as
std::same_as<T, U> 表达两个类型完全相同。它适合用于需要精确类型边界的接口,例如要求成员函数返回 bool、要求 ID 成员声明为 int、要求迭代器引用类型与某个内部类型一致。它比“可转换到某类型”更窄,能把隐式转换、代理对象和宽类型排除在接口外。
把贯穿例子收窄成“只接受 int id”时,可以定义 HasIntId。
template<class T>
concept HasIntId =
requires(T&& value) {
value.id;
} &&
std::same_as<id_member_t<T>, int>;
template<class R>
concept IntIdRange =
std::ranges::input_range<R> &&
HasIntId<std::ranges::range_reference_t<R>>;
HasIntegralId 接受 short、int、long、unsigned、bool 等整数类型。HasIntId 只接受声明类型归一化后为 int 的成员。这个差异会改变接口承诺:前者表达“任何整数 ID 都可被收集”,后者表达“结果容器和后续处理只围绕 int ID 设计”。
std::same_as 还经常出现在复合要求中。复合要求使用 { expression } -> Concept; 的形式检查表达式结果。这里要注意一个类型细节:复合要求检查的是表达式的 decltype((expression)) 形态,成员访问和函数调用结果可能带有引用与 cv 限定。
template<class T>
concept HasConstIntIdReference = requires(const T& value) {
{ value.id } -> std::same_as<const int&>;
};
这段约束要求 const T& 上访问 id 得到 const int&。它比 id_member_t<T> 更贴近表达式层面的返回形态。若只关心成员声明类型,id_member_t<T> 更直接;若关心某个调用表达式在当前值类别和 cv 条件下产生什么结果,复合要求配合 std::same_as 更精确。
STL 中的精确类型约束常用于控制重载边界。比如一个泛型接口要求谓词返回值满足布尔测试语义时,通常选择语义概念;要求某个类型转换后的结果与目标类型完全一致时,才使用 std::same_as。工程判断顺序是:先确认接口要的是语义族,还是精确类型;语义族用 std::convertible_to、std::integral、range / iterator concepts;精确类型才用 std::same_as。
41.5 std::integral
std::integral<T> 表达 T 是整数类型。它在语义上对应 std::is_integral_v<T>,并以 concept 形式参与约束、重载和诊断。它适合表达“这个模板只接受整数族参数”,例如下标、计数、标志位、枚举映射前的整数值、ID 字段等。
贯穿例子中的 HasIntegralId 使用 std::integral<id_member_t<T>>,说明 id 的成员声明类型属于整数族。
struct User {
int id;
};
struct Order {
unsigned long id;
};
struct Label {
const char* id;
};
static_assert(HasIntegralId<User>);
static_assert(HasIntegralId<Order>);
static_assert(!HasIntegralId<Label>);
这里 User 和 Order 都满足约束,因为 int 和 unsigned long 都是整数类型。Label 失败,因为 const char* 是指针类型。这个边界比手写 std::same_as<id_member_t<T>, int> 更宽,适合收集多种来源的数字 ID。
std::integral 的宽度也会带来设计责任。bool 满足 std::integral,字符类型也满足 std::integral。如果接口把 bool ID 或字符 ID 视为有效输入,std::integral 是正确选择;如果接口只接收可参与算术大小比较的整数,应增加额外约束。
template<class T>
concept NonBoolIntegral =
std::integral<std::remove_cvref_t<T>> &&
!std::same_as<std::remove_cvref_t<T>, bool>;
这个自定义概念把整数族中的 bool 排除。继续收窄时还可以使用 std::signed_integral 或 std::unsigned_integral。这种写法比把条件藏在函数体里的 static_assert 更清晰,因为调用者在候选选择阶段就能得到接口级反馈。
在泛型库中,std::integral 的工程用法应围绕“类型族”展开。它适合替代 std::enable_if_t<std::is_integral_v<T>> 这类 traits 条件。它不负责表达数值范围、溢出安全、符号位业务规则或 ID 是否真实存在于数据库中;这些运行时条件仍然需要普通代码检查。
41.6 std::ranges concepts
std::ranges concepts 把传统 iterator + algorithm 的隐式要求提升为可组合的类型条件。传统算法通常接收一对 iterator,算法内部假设 iterator 支持某些操作;ranges 算法可以直接约束 range、iterator、sentinel、谓词和投影,使接口说明更贴近调用对象。
std::ranges::input_range<R> 可以理解为两层要求的组合:R 能通过 ranges 机制取得 begin 和 end,并且 ranges::iterator_t<R> 满足 input iterator 要求。它描述“可以从头到尾读取一次”的最低 range 能力。IdRange 选择它,是因为 collect_ids 只做一次顺序遍历和读取成员。
template<class R>
concept IdRange =
std::ranges::input_range<R> &&
HasIntegralId<std::ranges::range_reference_t<R>>;
这个概念没有要求随机访问,也没有要求 size 可用。传入 std::list<User>、std::vector<User> 或某些输入 view 时,只要元素引用满足 HasIntegralId,函数都能工作。若把约束改成 std::ranges::random_access_range<R>,链表和单遍输入范围会从候选集中移出。约束的强弱应由算法实际使用的操作决定。
std::ranges::view 表达一种轻量、可移动、可作为 range 适配器结果的范围对象。view 通常服务惰性管线,例如过滤、转换、截断。collect_ids 接受 input_range,因此它可以消费普通容器,也可以消费满足元素条件的 view。它不要求输入对象必须是 view,因为函数只需要遍历,不需要表达“这是一个适配器产生的轻量范围”。
Iterator concepts 和 range concepts 是分层关系。std::input_iterator、std::forward_iterator、std::bidirectional_iterator、std::random_access_iterator、std::contiguous_iterator 描述位置对象的操作能力;std::ranges::input_range、forward_range、random_access_range 等把这些能力套到 ranges::begin 返回的 iterator 上。读 STL ranges 声明时,应先看算法约束的是 range 整体,还是 iterator / sentinel / callable 的局部角色。
对于自定义容器或 view,满足 ranges concepts 的关键在接口形状和语义承诺。类型需要提供可被 std::ranges::begin / std::ranges::end 找到的边界,iterator 要满足相应 category 的操作、引用和递增规则。概念检查能覆盖大量语法要求,语义承诺仍由类型实现承担,例如单遍 range 的多次遍历结果、悬垂引用风险和元素生命周期。
41.7 concepts 与 SFINAE 对比
Concepts 和 SFINAE 都能让模板候选根据类型条件参与或退出重载集。二者的工程差异在于约束表达的位置、诊断的入口、可读性和重载偏序。SFINAE 以“替换失败使候选淘汰”为核心规则,concepts 以“声明关联约束是否满足”为核心规则。
| 维度 | SFINAE | concepts | 工程影响 |
|---|---|---|---|
| 约束位置 | 常分散在返回类型、默认模板参数、辅助 traits 中 | 直接写在模板参数、requires clause 或 concept 定义中 | concepts 更容易从声明读出接口条件 |
| 错误入口 | 可能落在内部表达式、traits 展开或算法实现细节中 | 通常落在未满足的命名约束上 | 调用者更容易定位缺失的类型能力 |
| 表达粒度 | 偏向表达“某个替换是否成功” | 偏向表达“类型属于哪个语义类别” | 标准库可用 range、sortable、predicate 组织接口 |
| 重载关系 | 依赖普通重载规则和替换结果 | 约束强弱可参与偏序 | 更强概念可以自然选择更专门实现 |
| 版本边界 | C++11 起大量使用 | C++20 起进入核心语言和标准库 | 旧代码仍会保留 SFINAE,现代接口优先 concepts |
用 SFINAE 写 HasIntegralId,通常会出现检测别名、std::void_t、std::enable_if_t 等辅助层。读者需要先确认检测表达式,再确认 is_integral_v,最后推断这个条件用于哪个函数。Concepts 把这条链整理成命名接口。
template<class T>
concept HasIntegralId =
requires(T&& value) {
value.id;
} &&
std::integral<id_member_t<T>>;
这段概念定义的可读性来自两个事实:表达式要求和类型族要求并排出现;函数声明可以直接写 template<IdRange R>。对于 STL 读者来说,看到 std::ranges::input_range、std::sortable、std::predicate、std::indirectly_copyable 这类概念名,应立即把它们当作算法输入契约读取。
SFINAE 在兼容旧标准、实现底层 traits、处理部分历史接口时仍然存在。C++20 之后的新泛型接口,应优先把对外约束写成 concepts。这样做的判断依据很直接:接口条件属于调用者需要知道的内容,concepts 让这些条件进入声明;内部实现分支属于库作者控制的内容,可以继续使用 traits、if constexpr 或局部辅助模板。
41.8 concepts 对 STL 的影响
Concepts 改变了 STL 接口的阅读方式。过去读一个算法声明,常需要从模板参数名、iterator category、内部 helper 和错误信息中反推要求。现在读 ranges 算法、iterator 工具、callable 约束和 view 适配器时,应先读 concepts 名称,因为它们就是接口契约的一部分。
第一个影响是算法声明更接近真实输入条件。传统 std::sort(first, last) 的模板参数名提示它需要随机访问 iterator;ranges 版本的声明可以用 concept 表达 range、iterator、sentinel、比较器和投影的组合要求。源码阅读时,约束列表往往比函数体前几行更能说明“谁可以调用这个算法”。
第二个影响是用户类型接入 STL 的检查点更明确。一个自定义 range 想进入 ranges 算法,需要先满足 range 边界,再满足 iterator 概念,再满足算法要求的元素操作。一个自定义谓词想进入筛选或排序算法,需要满足 invocable、predicate、relation 或 strict weak order 之类的 callable 约束。Concepts 把这些检查点命名,减少从模板报错中猜测缺失操作的成本。
第三个影响是标准库实现可以把重载分层写得更清楚。面对同一个算法,输入只满足 input_range 时只能顺序扫描;满足 random_access_range 时可以使用索引和距离;满足 contiguous_range 时实现还能利用连续存储的额外信息。标准语义不要求某个实现必须采取特定优化,但 concepts 给实现提供了稳定的分派条件。
第四个影响是错误定位更靠近调用点。传入 std::vector<Label> 给 collect_ids 时,失败原因会集中在 HasIntegralId 或 std::integral。若同样逻辑写成未约束模板,报错更可能落在 result.push_back(record.id) 或 id_member_t 展开处。接口层约束让“类型缺少什么能力”成为诊断重点。
阅读和设计 concepts 接口时,可以使用固定判断顺序:先确定算法真正使用的操作;再选择最小足够的标准 concept;然后用 requires expression 补足项目特有表达式;接着用 std::same_as、std::convertible_to、std::integral 等概念限定结果类型;最后检查多个重载之间是否存在清晰的强弱关系。这个顺序能把接口条件、实现假设和错误诊断连接起来。
在 collect_ids 中,这个顺序对应为:函数只顺序遍历,所以选择 input_range;函数读取 record.id,所以定义 HasIntegralId;结果容器的元素类型来自 id_member_t,所以用 std::integral 控制 ID 类型族;若业务只接受 int,再替换成 std::same_as<id_member_t<T>, int>。Concepts 的最终作用,是让这些判断停留在声明层,而非散落到函数体失败点。
最小自检任务
阅读下面代码,判断三个 range 是否能调用 collect_ids 与 collect_int_ids,并说明每个判断对应哪个约束。
#include <concepts>
#include <ranges>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>
template<class T>
using id_member_t = std::remove_cvref_t<decltype(std::declval<T>().id)>;
template<class T>
concept HasIntegralId =
requires(T&& value) {
value.id;
} &&
std::integral<id_member_t<T>>;
template<class T>
concept HasIntId =
requires(T&& value) {
value.id;
} &&
std::same_as<id_member_t<T>, int>;
template<class R>
concept IdRange =
std::ranges::input_range<R> &&
HasIntegralId<std::ranges::range_reference_t<R>>;
template<class R>
concept IntIdRange =
std::ranges::input_range<R> &&
HasIntId<std::ranges::range_reference_t<R>>;
template<IdRange R>
auto collect_ids(R&& records);
template<IntIdRange R>
auto collect_int_ids(R&& records);
struct User {
int id;
};
struct Token {
bool id;
};
struct Label {
std::string id;
};
需要判断:std::vector<User>、std::vector<Token>、std::vector<Label> 分别调用两个函数时的结果。
答案要点
std::vector<User> 可以调用 collect_ids,也可以调用 collect_int_ids。原因是 vector 满足 std::ranges::input_range,元素引用类型对应的 id_member_t 是 int,因此同时满足 std::integral<int> 和 std::same_as<int, int>。
std::vector<Token> 可以调用 collect_ids,调用 collect_int_ids 会失败。原因是 bool 满足 std::integral,所以 HasIntegralId<Token&> 成立;但 id_member_t<Token&> 是 bool,与 int 类型不同,因此 HasIntId<Token&> 失败。这个判断也说明 std::integral 是类型族约束,std::same_as 是精确类型约束。
std::vector<Label> 调用两个函数都会失败。原因是 vector 作为 range 的条件成立,但元素的 id_member_t 是 std::string,它不满足 std::integral,也不满足 std::same_as<int>。失败点应归入元素类型约束,而非 range 边界约束。
完整判断顺序是:先看整体是否满足 std::ranges::input_range;再取 std::ranges::range_reference_t<R>;接着检查成员访问 value.id 是否可形成;最后判断 id_member_t<T> 满足整数族约束还是精确 int 约束。
本章知识点总结
- concepts 定位:concepts 把模板实参要求提升到接口层,使类型能力在候选过滤前被检查。
- 接口诊断:约束失败通常落在命名 concept 或 requires 条件上,调用者更容易定位缺失能力。
- requires clause:requires clause 把布尔约束附着到模板声明,并决定声明是否进入可行候选。
- requires expression:requires expression 检查表达式、类型、复合结果和嵌套条件,并把检查结果用于概念定义。
- constraint 角色:constraint 是模板声明关联的编译期条件,参与候选过滤和受约束重载选择。
- 约束归一化:编译器会把约束表达式拆成原子约束来判断满足关系,复用命名概念能稳定重载关系。
- same_as 边界:
std::same_as表达两个类型完全相同,适合精确限制返回类型、成员类型和重载边界。 - integral 边界:
std::integral表达整数类型族,适合替代手写is_integraltraits 条件。 - bool 风险:
bool满足std::integral,排除布尔类型时需要额外组合std::same_as或自定义概念。 - range concepts:
std::ranges::input_range等概念把 begin / end、iterator 能力和 range 语义组合成算法输入契约。 - SFINAE 对比:SFINAE 依赖替换失败淘汰候选,concepts 通过声明约束表达接口条件。
- STL 判断顺序:设计或阅读 concepts 接口时,应先看算法使用的操作,再选标准概念,再补表达式要求,最后检查重载强弱关系。