乐于分享
好东西不私藏

C++20 Concepts 概念:给模板参数加约束,从此报错信息能看懂

C++20 Concepts 概念:给模板参数加约束,从此报错信息能看懂

字数约 6000 | 阅读约 10 分钟 | C++20

先说个我自己的"血泪史"。

早年我写过一个排序函数,大概长这样:

template<typename T>voidsort(T& c){    std::sort(c.begin(), c.end());}

看起来人畜无害,对吧?结果有次同事传了一个 std::list<int> 进来——编译器哗啦啦吐了 200 行错误信息,从 std::sort 的内部一路展开到 std::list<int>::iterator 的 operator-,根本不知道哪儿写错了。

这种"模板报错像天书"的体验,C++ 老用户都经历过。Concepts 就是来解决这个问题的。

今天这篇,我们就来好好聊聊 C++20 这门"给模板加约束"的大杀器。

 一句话概念

Concepts 是给模板参数加"约束条件"的语言特性——让编译器在模板实例化前先检查参数是否满足要求,报错信息可读性大大提升。

光看定义可能还抽象,先看个对比:

// 没有 Concepts:模板什么都能接,出错才报template<typename T>voidprint_twice(T x){    std::cout << x << '\n';  // 要求 T 支持输出    std::cout << x << '\n';}// 有 Concepts:编译器先检查 T 是否满足约束,不满足直接报错template<std::integral T>voidprint_twice_concept(T x){    std::cout << x << '\n';    std::cout << x << '\n';}

调用 print_twice_concept("not a number") 时,编译器会直接告诉你:"传入类型不满足 std::integral 约束"——而不是像以前那样,先进函数体,撞到 std::cout << x 才报错。这就把"报错时机从函数体内提前到了签名匹配阶段",错误信息一下子清爽了。

横向演进:从"鸭子类型"到"显式约束"

回顾模板约束的历史,你就能体会 Concepts 的来之不易。

C++98/03:模板是"鸭子类型"

// C++98template<typename T>add(T a, T b){return a + b;}

这里的 T 没有任何约束——传 intstd::string、自定义类都行。只要 T 支持 operator+,编译器才会发现;否则在实例化时报错

这就是"鸭子类型":像不像鸭子,叫声才知道。问题在于——编译器"叫声"的过程,就是吐 200 行错误信息的过程

C++11:enable_if + SFINAE 实现约束

SFINAE(Substitution Failure Is Not An Error)是 C++98 就有的规则,C++11 给了更优雅的写法:std::enable_if

// C++11:用 enable_if 约束 T 必须是整数#include<type_traits>template<typename T,
         typename = std::enable_if_t<std::is_integral_v<T>>>add(T a, T b) {return a + b;}

或者更丑的"返回类型 SFINAE":

// C++11:丑到令人发指template<typename T>autoadd(T a, T b)    -> std::enable_if_t<std::is_integral_v<T>, T> {return a + b;}

这种写法的痛点:

  1. 语法绕——enable_if_t<...> 写在返回值位置或模板参数尾巴上,新看一眼根本不懂
  2. 报错依然烂——约束不满足时,错误信息指向 SFINAE 内部(enable_if_t<false, T> 之类),新手根本看不懂
  3. 可读性差——看一眼代码,得在脑子里把 enable_if 解一遍才知道"哦原来是想约束 T 是整数"

C++14:enable_if_t 让代码短一点

C++14 加了变量模板和 _t 别名,把 typename std::enable_if<...>::type 缩成 std::enable_if_t<...>——稍微缓解了一点。但根本问题没解决:约束依然要靠 SFINAE,依然丑、依然报错烂。

C++17:if constexpr 在函数体内分支

if constexpr 是 C++17 的大杀器,但它解决的是"函数体内按条件分支",不解决"模板参数约束":

// C++17:if constexpr 在函数体内分支template<typename T>voidprocess(T x){ifconstexpr(std::is_integral_v<T>){// 整数特化    } else {// 其他类型    }}

if constexpr 解决的是"我已经接受了这个 T,接下来按 T 的类型分支"。模板要不要接这个 T,它管不了——你得保证 T 至少能进函数体,不然连 process(x) 这一句都过不去。

C++20:Concepts——约束写进模板参数声明

终于,Concepts 来了:

// C++20:用 Concepts 约束#include<concepts>template<std::integral T>add(T a, T b){return a + b;}

就这一行,语法干净利落:

  • std::integral 是 C++20 标准库提供的 concept
  • template<std::integral T> 直接把约束写在模板参数声明里
  • 调用 add(std::string{}, std::string{}) 时,编译器直接告诉你"std::string 不满足 std::integral 约束"

对比三种写法,直观感受 Concepts 的优势:

// C++98:无约束template<typename T> T add(T a, T b)return a + b; }// C++11/14:enable_iftemplate<typename T, typename = std::enable_if_t<std::is_integral_v<T>>>add(T a, T b) { return a + b; }// C++20:Conceptstemplate<std::integral T> T add(T a, T b)return a + b; }

Concepts 把"约束"从函数的内部细节,提升到了函数签名的一部分——可读性的提升是数量级的。

底层机制:Concept 本质是"编译期谓词"

来看个最简单的 concept 定义:

// C++20#include<type_traits>template<typename T>concept Integral = std::is_integral_v<T>;

注意几点:

  1. concept 是关键字
  2. Integral 是这个 concept 的名字(约定用 PascalCase)
  3. = std::is_integral_v<T> 是这个 concept 的定义——一个编译期布尔表达式
  4. 编译期求值结果为 true 的类型满足这个 concept

所以 concept 本质就是一个"编译期谓词"(compile-time predicate)——给它一个类型,它告诉你这个类型满不满足条件。

但 concept 比 std::is_integral_v 强大多了——它可以用 requires 表达式 写复杂约束。

requires 表达式:写复杂约束的"瑞士军刀"

// C++20#include<concepts>template<typename T>concept Addable = requires(T a, T b) {    a + b;  // 要求 T 类型的两个对象能用 + 运算};

这个 requires(...) 块叫 requires 表达式(注意和"requires 子句"的区别,后面讲)。它能检查的东西非常多:

// C++20:复杂的 requires 表达式template<typename T>concept Printable = requires(T x) {    std::cout << x;  // x 能用 cout 输出};

requires 表达式能检查的语法清单(常用):

语法
含义
expr;
表达式必须能编译(不关心结果)
{ expr } -> Concept;
表达式结果必须满足某个 concept
typename T::X;
嵌套类型必须存在
T(...);
必须能用给定参数构造

requires 子句:在模板声明里用

有了 concept 定义,接下来要把它用在模板参数上。有三种写法:

// C++20:三种"在模板上用 concept"的方式// 写法 1:concept 直接放在 typename 的位置(C++20 推荐)template<std::integral T>add1(T a, T b)return a + b; }// 写法 2:用 requires 子句template<typename T> requires std::integral<T>add2(T a, T b)return a + b; }// 写法 3:trailing requires(放在参数列表后)template<typename T>add3(T a, T b)requires std::integral<T> return a + b; }

这三种写法完全等价。区别只在风格——写法 1 最简洁,写法 2 最灵活(可以写复杂表达式),写法 3 在某些场景下可读性更高。

requires 表达式 vs requires 子句:长得像,作用不同

这是新手最容易搞混的:

名字
语法
作用
requires表达式requires(T x) { expr; }定义
一个 concept 时用,返回 bool
requires子句template<typename T> requires Concept<T>使用
一个 concept 时用,作为约束
// requires 表达式(在 concept 定义里)template<typename T>concept Addable = requires(T a, T b) {  // <-- 这是 requires 表达式    a + b;};// requires 子句(在模板上)template<typename T> requires Addable<T>  // <-- 这是 requires 子句voidf(T x)/* ... */ }

长得像,作用完全不同——新手经常在概念定义里写 requires 子句、在模板声明里写 requires 表达式,结果就是编译错误或行为不对。

实战用法:5 个常用场景

实战 1:基础 concept 定义

// C++20#include<concepts>#include<functional>#include<iostream>#include<vector>#include<string>// 自定义一个"可哈希"的 concepttemplate<typename T>concept Hashable = requires(T x) {    std::hash<T>{}(x);  // T 必须能调用 std::hash<T>{}(x)};// 用法template<Hashable T>voidstore_in_unordered_set(T key){    std::cout << "存 hash: " << std::hash<T>{}(key) << '\n';}intmain(){store_in_unordered_set(42);        // OK:int 可哈希store_in_unordered_set(std::string{"hello"}); // OK:string 可哈希// store_in_unordered_set(std::vector<int>{});// 编译错误:vector<int> 不满足 Hashable}

实战 2:用 concept 约束模板参数

// C++20#include<concepts>#include<iostream>// 约束 T 必须是"支持 += 算术运算"的类型template<typename T>concept Addable = requires(T a, T b) { a += b; };template<Addable T>sum_range(T begin_val, T end_val){    T total{};for (T i = begin_val; i != end_val; ++i) {        total += i;    }return total;}intmain(){    std::cout << "sum 1..10 = " << sum_range(110) << '\n';  // OK// sum_range("a", "z");// 编译错误:const char* 不满足 Addable}

实战 3:requires 子句写复杂约束

有时候 concept 不够用,要在模板声明里直接写 requires 子句:

// C++20#include<concepts>#include<ranges>#include<vector>#include<string>#include<iostream>// 约束 T 必须是"范围 + 元素可打印"template<typename T>requires std::ranges::range<T> &&requires(typename T::value_type x){                 std::cout << x;             }voidprint_all(const T& container){for (constauto& x : container) {        std::cout << x << '\n';    }}intmain(){print_all(std::vector<int>{123});            // OKprint_all(std::vector<std::string>{"a""b"});   // OK// print_all(42);// 编译错误:int 不是 range}

&& 和 || 可以组合多个 concept,语法和布尔表达式一致。!Concept<T> 表示"不满足这个 concept",但实战里很少见。

实战 4:标准库 concepts 速查

C++20 标准库自带了一批常用 concept,直接 #include <concepts> 或 <ranges> 就能用:

Concept
头文件
含义
std::integral<T><concepts>
整数类型
std::floating_point<T><concepts>
浮点类型
std::signed_integral<T><concepts>
有符号整数
std::unsigned_integral<T><concepts>
无符号整数
std::same_as<T, U><concepts>
两个类型相同
std::convertible_to<T, U><concepts>
T 能隐式转 U
std::derived_from<T, U><concepts>
T 公有继承自 U
std::default_initializable<T><concepts>
T 能默认构造
std::move_constructible<T><concepts>
T 能移动构造
std::ranges::range<T><ranges>
T 支持范围 for
std::ranges::input_range<T><ranges>
T 是 input range
std::formattable<T, CharT><format>
T 能用给定字符集格式化(C++23)

实战里:能用标准库 concept 就别自己写——既标准、又经过委员会反复推敲、还跨编译器一致。自己写的 concept 万一有 bug 或者语义偏差,排查起来比标准库费劲得多。

实战 5:约束与重载——满足多 concept 时,选最具体的

Concepts 不只能约束,还能影响重载选择。关键规则:满足多 concept 时,选"最具体"的那个(不是任意的)。

// C++20#include<concepts>#include<string>#include<vector>#include<iostream>// 三个重载:从最宽松到最严格template<typename T>voiddescribe(T x){    std::cout << "通用版本" << '\n';}voiddescribe(std::integral auto x){    std::cout << "整数版本: " << x << '\n';}voiddescribe(std::floating_point auto x){    std::cout << "浮点版本: " << x << '\n';}voiddescribe(std::same_as<std::string> auto x){    std::cout << "字符串版本: " << x << '\n';}intmain(){describe(42);                              // 整数版本describe(3.14);                            // 浮点版本describe(std::string{"hello"});            // 字符串版本describe(std::vector<int>{12});          // 通用版本}
整数版本: 42浮点版本: 3.14字符串版本: hello通用版本

重载选择规则:

  1. 编译器列出所有候选
  2. 过滤掉"不满足约束"的
  3. 在剩下的候选里,挑约束最具体的(满足的 concept 数量最多 / 范围最窄)
  4. 平局 → 报歧义错误

注意:Concepts 不参与 SFINAE 的"替换失败"流程。如果你同时写了 Concepts 约束和 SFINAE 兜底,行为会出乎意料——二选一,不要混

性能数据:Concepts 是编译期特性,运行时零开销

Concepts 的检查全部在编译期完成——运行时,带 Concepts 的代码和手写 if 一模一样快

来看个示意:

// C++20template<std::integral T>add(T a, T b)return a + b; }// 编译后等价于:// (编译器已经把 std::integral<T> 验证过了)// 运行时只剩 return a + b;intadd_int(int a, int b)return a + b; }

汇编层面,Concepts 约束过的模板函数和不约束的、普通的非模板函数完全等价——因为 concept 检查在编译期"消化"掉了,运行时无任何残留。

编译时间影响:Concepts 比 SFINAE 略快。

为什么?

  • SFINAE:编译器要"试着替换参数",失败就 SFINAE-out,可能尝试多个候选
  • Concepts:约束是"硬约束"——不满足直接编译失败,不参与替换流程

实际工程里,大型模板库(比如 Ranges)从 SFINAE 迁到 Concepts 后,编译时间能缩短 10%-30%。如果你维护的是大型泛型库,把 SFINAE 换成 Concepts 几乎是"免费的午餐"。

陷阱与误解

陷阱 1:Concepts 不是运行时检查

Concept 是纯编译期特性——运行时不能用。

// 错:运行时不能判断 conceptvoidf(auto x){if (std::integral<decltype(x)>) {  // 编译错误:integral 是 concept,不是运行期函数// ...    }}// 对:运行时用 std::is_integral_vvoidg(auto x){ifconstexpr(std::is_integral_v<decltype(x)>){// ...    }}

std::integral 和 std::is_integral_v 的区别:

  • std::integral<T>:concept,用在模板约束里,编译期谓词
  • std::is_integral_v<T>:constexpr bool,用在 if constexpr 里,编译期值

简单记:带"v"的是值,带"concept 名"的是约束

陷阱 2:Concept 不是函数

// C++20template<typename T>concept Addable = requires(T a, T b) { a + b; };intmain(){// 错:不能"调用"conceptbool b1 = Addable(42);            // 编译错误// 对:concept 用作类型谓词static_assert(Addable<int>);      // OK,作为编译期谓词使用static_assert(!Addable<std::vector<int>>);}

Concept 只能用作约束(模板参数上的修饰符、模板元函数判断)——它不是函数,不能调用。Addable(42) 这种"调用"语法在 C++ 里是给函数/可调用对象用的,concept 不在这个范畴。

陷阱 3:requires 表达式 vs requires 子句

前面已经强调过一次,但这个误区太常见,再列一遍:

// requires 表达式:在 concept 定义里,返回一个 booltemplate<typename T>concept C1 = requires(T x) { x.foo(); };  // <-- 表达式// requires 子句:在模板声明里,作为约束template<typename T> requires C1<T>  // <-- 子句voidf(T x)/* ... */ }

长得像,作用完全不同——新手经常在概念定义里写 requires 子句、在模板声明里写 requires 表达式,结果就是编译错误或行为不对。

实操心法:看到 concept Name = ...; 等号右边,就是 requires 表达式;看到 template<...> 后面接的 requires,就是 requires 子句。

陷阱 4:满足多 concept 时,选最具体的

前面实战 5 演示过——但要警惕:"最具体"不是"最严格"。来看反例:

// C++20#include<concepts>#include<iostream>// 一个自定义 concept,范围比 std::integral 更窄template<typename T>concept SmallInt = std::integral<T> && sizeof(T) <= 4;voidf(std::integral auto x){ std::cout << "integral" << '\n'; }voidf(SmallInt auto x){ std::cout << "SmallInt" << '\n'; }intmain(){f(42L);     // 输出:integral(long 是 integral 但 sizeof=8,不满足 SmallInt)f(short{1}); // 输出:SmallInt(short 满足 SmallInt)}

要点:

  • SmallInt 比 integral 更具体(范围更窄)
  • 满足 SmallInt 时,优先选 SmallInt 版本
  • 满足 integral 但不满足 SmallInt,只能选 integral 版本
  • 没有匹配 → 编译错误

这个"具体优先"规则,是 Concepts 重载的灵魂。记住它,你就能写出"粗版 + 细版"双层 API,编译器自动帮你挑最合适的。

陷阱 5:标准库 concepts 不是关键字

// 错:你以为 std::integral 是关键字template<integral T>  // 编译错误:integral 不是关键字voidf(T x);// 对:std::integral 是命名空间里的 concepttemplate<std::integral T>  // OKvoidf(T x);

std::integralstd::floating_pointstd::ranges::range 这些都是命名空间里的 concept,不是关键字。要写完整的 std::integral,不能图省事写 integral

(类似地,requires 才是关键字,concept 也是关键字。这俩关键字可以单独用,但标准库 concept 必须带命名空间前缀。)

总结:5 条行动准则

#
准则
一句话
1
模板约束
用 Concepts 不用 SFINAE——写在签名上,可读性高一个量级
2
标准库优先
std::integral
 / std::floating_point / std::ranges::range 现成能用
3
自定义 concept
concept Name = ...;
 定义,用 requires 表达式写复杂约束
4
编译期特性
Concepts 是编译期,运行时零开销,编译时间反而略快
5
重载选择
满足多 concept 时,选最具体的(不是任意的)

写在最后

从 C++98 的"鸭子类型",到 C++11 的 enable_if+SFINAE,再到 C++20 的 Concepts——模板约束这件事,终于在 C++20 有了一个"干净利落"的写法

搞懂 Concepts,你在写模板库、写泛型算法、写"既要又要"的接口时,就能:

  • 用一个 concept 一行说清"我要什么类型的参数"
  • 让编译器在你写错的那一刻告诉你"传错了"
  • 不用再面对 SFINAE 报错的"天书"

下周开始,我们进入 C++20 的另一大块——Ranges。这套库是 Concepts 的"第一个大客户",很多 range 算法都用 concept 约束。学会 Concepts 之后,看 Ranges 的源码会顺很多。

下期预告

Concepts 是 C++20 给模板加约束的大杀器——但模板之外,C++ 还有些"小而美"的语法糖默默提升可读性。下期我们轻松一下,聊聊 C++14 的二进制字面量与数字分隔符:0b1010 写二进制、1'000'000 让大数字一眼能数。敬请期待。

公众号:我爱C嘎嘎 · 每天一个 C++ 新特性,2 分钟读完。