Skip to main content

Chapter 41: Concepts

模板代码的问题经常出现在调用点和真正失败点之间。调用者写下一个看似普通的泛型函数调用,编译器却在函数体、算法内部循环、iterator 运算或 traits 展开深处报错。Concepts 把模板实参必须满足的条件提升到接口层,使调用者能在函数声明处看到“这个模板接受什么类型”。

本章讨论 C++20 concepts 如何描述模板接口条件。读完后应能定位 requires 写在声明上的作用,区分 requires clauserequires expression,解释约束如何参与候选过滤和重载选择,并判断 std::same_asstd::integralstd::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。编译器在选择这个模板前检查这些条件;满足条件才进入函数体实例化,条件失败时诊断会落在 IdRangeHasIntegralIdstd::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_idsHasIntegralId 将“元素有 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_rangestd::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 接受 shortintlongunsignedbool 等整数类型。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_tostd::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>);

这里 UserOrder 都满足约束,因为 intunsigned 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_integralstd::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 机制取得 beginend,并且 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_iteratorstd::forward_iteratorstd::bidirectional_iteratorstd::random_access_iteratorstd::contiguous_iterator 描述位置对象的操作能力;std::ranges::input_rangeforward_rangerandom_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 以“声明关联约束是否满足”为核心规则。

维度SFINAEconcepts工程影响
约束位置常分散在返回类型、默认模板参数、辅助 traits 中直接写在模板参数、requires clause 或 concept 定义中concepts 更容易从声明读出接口条件
错误入口可能落在内部表达式、traits 展开或算法实现细节中通常落在未满足的命名约束上调用者更容易定位缺失的类型能力
表达粒度偏向表达“某个替换是否成功”偏向表达“类型属于哪个语义类别”标准库可用 rangesortablepredicate 组织接口
重载关系依赖普通重载规则和替换结果约束强弱可参与偏序更强概念可以自然选择更专门实现
版本边界C++11 起大量使用C++20 起进入核心语言和标准库旧代码仍会保留 SFINAE,现代接口优先 concepts

用 SFINAE 写 HasIntegralId,通常会出现检测别名、std::void_tstd::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_rangestd::sortablestd::predicatestd::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 时,失败原因会集中在 HasIntegralIdstd::integral。若同样逻辑写成未约束模板,报错更可能落在 result.push_back(record.id)id_member_t 展开处。接口层约束让“类型缺少什么能力”成为诊断重点。

阅读和设计 concepts 接口时,可以使用固定判断顺序:先确定算法真正使用的操作;再选择最小足够的标准 concept;然后用 requires expression 补足项目特有表达式;接着用 std::same_asstd::convertible_tostd::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_idscollect_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_tint,因此同时满足 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_tstd::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_integral traits 条件。
  • bool 风险bool 满足 std::integral,排除布尔类型时需要额外组合 std::same_as 或自定义概念。
  • range conceptsstd::ranges::input_range 等概念把 begin / end、iterator 能力和 range 语义组合成算法输入契约。
  • SFINAE 对比:SFINAE 依赖替换失败淘汰候选,concepts 通过声明约束表达接口条件。
  • STL 判断顺序:设计或阅读 concepts 接口时,应先看算法使用的操作,再选标准概念,再补表达式要求,最后检查重载强弱关系。