concept / requires
没有实测,下面的内容来自标准规则和实现知识。
一、是什么
- concept(概念):给模板参数的要求起一个名字,本质是一个编译期的布尔谓词。
- requires 子句:把这个谓词挂到模板上,不满足就不参与重载。
- requires 表达式:检查"某些表达式对这个类型是否合法",求值结果是
true或false。
concept Hashable = ;
一个 concept 可以用四种写法挂到函数上,效果相同:
void ; // ① 直接作为类型约束
requires Hashable<T> void ; // ② requires 子句
void requires Hashable<T>; // ③ 尾置 requires
void ; // ④ 简写函数模板
二、解决了什么问题
以前(SFINAE、enable_if) |
现在(concept) |
|---|---|
template <class T, std::enable_if_t<std::is_integral_v<T>, int> = 0> |
template <std::integral T> |
| 报错发生在模板实例化的深处,错误信息几百行 | 在调用处报错:"约束不满足,因为某个表达式不合法" |
| 对模板的要求只能写在注释里 | 要求就写在声明上,本身就是接口文档 |
两个 enable_if 重载的条件有重叠时产生二义性,需要手动写互斥条件 |
子句包含(subsumption):约束更严格的重载自动胜出 |
用 tag dispatch 按能力分派(比如 iterator_category) |
直接写几个受约束的重载 |
auto 也可以加约束:std::integral auto n = count(); 读代码的人一眼就能看出 n 是个整数。
三、requires 表达式的四种要求
concept Container = ;
四、子句包含:按约束的严格程度选择重载
concept Animal = ;
concept Dog = Animal<T> && ;
void
void // Dog 包含了 Animal,更严格
; // 两个都满足,选 #2,不会二义
; // 只满足 Animal,选 #1
标准库的 std::advance 就可以这样写:给 random_access_iterator 写一个 O(1) 的版本,给 bidirectional_iterator 写一个逐步前进的版本,编译器会自动选中最具体的那个。
五、使用场景
| 场景 | 例子 |
|---|---|
| 泛型算法库 | std::ranges 的所有算法都带约束。std::ranges::sort(list) 会直接报"不满足 random_access_range",不再是模板内部几百行的错误 |
| 按能力分派重载 | 迭代器分类、"可序列化"和"不可序列化"走不同路径,取代 tag dispatch 和 enable_if |
| 约束数值模板 | template <std::floating_point T> T lerp(T a, T b, T t); |
| 约束回调参数 | void on_event(std::invocable<const Event&> auto&& cb);、std::predicate<T> |
| 类模板里按条件提供成员函数 | void print() const requires Printable<T>; 只有 T 可打印时才有这个成员 |
| 按条件平凡的特殊成员函数 | std::optional 的实现:~optional() requires std::is_trivially_destructible_v<T> = default; 和一个非平凡的 ~optional() 并存,于是 optional<int> 仍然是平凡可析构的。以前要靠多层基类技巧才能做到 |
| 在函数内部检测能力 | if constexpr (requires { c.reserve(n); }) c.reserve(n); 容器支持 reserve 才调用 |
| 确认自己写的类型满足接口 | static_assert(std::ranges::random_access_range<MyVec>);,可以取代 CRTP 里手写的接口检查 |
| 标准库的其他地方 | std::formattable(C++23)、std::regular、std::totally_ordered |
常用的标准 concept:
<concepts>:same_as、derived_from、convertible_to、integral、floating_point、movable、copyable、regular、equality_comparable、totally_ordered、invocable、predicate<iterator>:input_iterator、forward_iterator、random_access_iterator、contiguous_iterator、sentinel_for<ranges>:range、sized_range、view、random_access_range、contiguous_range
六、常见坑
1. 在简单要求里写布尔条件,永远为真
concept Small = requires ;
2. 复合要求检查的是 decltype((expr)),要注意引用
-> std::; // 失败:front() 返回的是 value_type&
-> std::; // 通常应该这样写
3. 子句包含只认命名的 concept
requires std::is_integral_v<T> void ; // #1
requires std::is_integral_v<T> && std::is_signed_v<T> void ; // #2
; // OK:#2 包含 #1
requires void ;
requires && void ;
; // 二义:两个 requires 表达式即使写得一模一样,也被当成不同的原子约束
包含关系只在"约束来自同一个出处"时成立,也就是引用同一个 concept 或同一个表达式。所以要把约束提取成命名的 concept 再组合。另外,std::integral<T> 和 std::is_integral_v<T> 之间不存在包含关系。
4. concept 只检查语法,不检查语义,也不检查模板函数体
std::regular要求"相等比较满足等价关系",但编译器只能检查==能不能编译,语义要求只是文档约定。- 函数体里用到了 concept 没有要求的操作,不会在定义处报错,而是到实例化时才报错。这和 Rust trait 的"定义时检查"不同。C++0x 最早的 concepts 设计是有定义检查的,2009 年被整体撤下,C++20 版本没有这个功能。
5. 访问权限按 concept 所在的上下文检查
requires 表达式里访问一个 private 成员,结果是"不满足"(false),不会报错,friend 声明也帮不上忙。这可能导致重载悄悄选中另一个版本。
6. 满足性结果在同一个编译单元里只算一次
对一个不完整类型检查某个 concept,之后类型变完整了,或者后来又加了一个重载,同一个 concept 对这个类型的结果可能改变。这时程序是 ill-formed, no diagnostic required(不要求编译器报错)。
7. requires requires
requires void ;
第一个 requires 是子句,第二个是表达式。这样写合法,但可读性很差,应该提取成一个命名的 concept。
七、面试速答
- concept 是什么? 给模板参数的要求起的名字,是一个编译期谓词。不满足的模板不参与重载,报错直接指向调用处。
- 比 SFINAE 好在哪? 可读,报错清晰,而且能按约束的严格程度自动选择重载,这是
enable_if做不到的。 - requires 有哪几种用法? requires 子句用来挂约束;requires 表达式用来检查表达式是否合法,里面有简单、类型、复合、嵌套四种要求。
- concept 会检查语义吗? 不会,只检查语法层面的合法性,也不检查模板函数体。
- 有什么典型的坑? 在简单要求里写布尔条件永远为真,必须用嵌套的
requires;子句包含只认命名的 concept。
暂无评论,欢迎留下第一条评论。