概念与约束(C++20):concept的定义,requires子句,约束的排序与组合

说实话,C++模板写多了,最头疼的是什么?是编译错误。那几百行的报错信息,简直能把人看哭。C++20 引入的 concept,说白了就是给模板参数加了一道「安检门」——类型不对,直接告诉你哪里不对,而不是等到实例化时炸出一堆天书。

我个人习惯把 concept 理解为「类型的能力清单」。你要求这个类型能加、能减、能拷贝,那就写个 concept 列清楚。编译器在调用点就帮你检查,而不是等到模板内部展开才报错。

concept 的定义

先看一个最简单的例子。我想定义一个 concept,要求类型 T 可以相加:

template<typename T>
concept Addable = requires(T a, T b) {
    a + b;
};

这里 requires 后面跟的是一个表达式约束。它检查 a + b 这个表达式是否合法。合法就通过,不合法就报错。

我在项目中遇到过一个问题:有人把 concept 写成了运行时检查。注意,concept 是编译期求值的,它不会生成任何运行时代码。你写 if constexpr (Addable<T>) 没问题,但别想着在运行时动态判断。

核心要点:concept 是一个编译期谓词,返回 true 或 false。它只检查语法合法性,不检查语义正确性。

再复杂一点,我们可以组合多个要求:

template<typename T>
concept Comparable = requires(T a, T b) {
    a == b;
    a != b;
    { a < b } -> std::convertible_to<bool>;
};

注意最后一行,{ a < b } -> std::convertible_to<bool> 表示:表达式 a < b 的结果必须能隐式转换为 bool。这种写法比单纯写 a < b 更严格,也更安全。

requires 子句的多种用法

requires 子句其实有四种形式,我列个表方便你对照:

形式 示例 说明
简单要求 a + b; 表达式合法即可
类型要求 typename T::value_type; 类型存在即可
复合要求 { expr } noexcept -> concept; 检查表达式类型和异常说明
嵌套要求 requires Addable<T>; 在 requires 内部再套 concept

嗯,这里要注意:类型要求经常被忽略。比如你想检查一个容器是否有 iterator 类型:

template<typename T>
concept HasIterator = requires {
    typename T::iterator;
};

我曾经在重构旧代码时,发现有人用 SFINAE 写了十几行才能做到的事,concept 三行就搞定了。这就是差距。

约束的排序与组合

约束可以组合,也可以排序。组合用 &&||

template<typename T>
concept Sortable = std::ranges::random_access_range<T> && Comparable<std::ranges::range_value_t<T>>;

排序呢?说白了就是「哪个约束更特殊,哪个优先级更高」。C++20 有一套约束偏序规则:

  • Addable && Comparable 比单独的 Addable 更特殊
  • 如果两个模板都能匹配,编译器选更特殊的那一个
  • 如果无法判断谁更特殊,编译报歧义错误

举个例子:

template<Addable T>
void process(T v) {
    std::cout << "addable\n";
}

template<Addable T> requires Comparable<T>
void process(T v) {
    std::cout << "addable and comparable\n";
}

如果你传一个 int,它会调用第二个版本。因为 int 既满足 Addable 又满足 Comparable,而第二个约束更严格。编译器会自动选择约束更强的那个。

小技巧:写模板重载时,尽量用 concept 代替 enable_if。代码可读性提升不止一个档次。我团队里现在禁止在新代码中使用 enable_if,除非有特殊理由。

约束的原子性与合取/析取

每个 concept 定义本身就是一个「原子约束」。当你用 && 组合时,形成合取约束;用 || 组合时,形成析取约束。

这里有个坑:

template<typename T>
concept A = true;

template<typename T>
concept B = true;

template<A T> void foo(T);  // (1)
template<B T> void foo(T);  // (2)

这两个模板的约束是等价的吗?不是。虽然 AB 都求值为 true,但它们是不同的原子约束。所以 (1) 和 (2) 是歧义的——编译器不知道选哪个。

我曾经在代码评审中看到有人这么写,然后抱怨「为什么编译不过」。原因就在这里:原子约束的身份由 concept 名称和模板参数决定,而不是由求值结果决定。

知识体系图

下面这张图总结了本章的核心逻辑:

概念与约束 concept 定义 template<typename T> concept C = requires { ... }; requires 子句 简单要求: a + b; 类型要求: typename T::type; 复合要求: { expr } -> C; 嵌套要求: requires C<T>; 排序与组合 合取: C1 && C2 析取: C1 || C2 偏序: 更特殊优先 原子约束身份 编译期类型能力检查 替代 SFINAE,提升代码可读性

实际项目中的避坑指南

最后分享几个我踩过的坑:

警告:concept 不能递归定义自己。比如 concept C = requires(T t) { requires C<T>; }; 是无限递归,编译器会报错。

我曾经在写一个序列化库时,想用 concept 检查类型是否可序列化。结果不小心写了递归引用,编译直接卡死。排查了半天才发现问题。

另一个常见问题是:concept 中的 requires 表达式不会实例化模板。也就是说,如果你写 requires { std::vector<T>{}; },它不会真的构造一个 vector,只是检查语法是否合法。这跟 decltype 的行为类似。

嗯,concept 这东西,用好了是神器,用不好就是坑。建议你从简单的开始,先定义几个基础的 concept,比如 AddableComparable,慢慢再组合成复杂的。别一上来就想搞个万能 concept,那反而容易把自己绕进去。


公众号:蓝海资料掘金营,微信deep3321