字数约 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>T add(T a, T b){return a + b;}这里的 T 没有任何约束——传 int、std::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>>>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;}这种写法的痛点:
语法绕—— enable_if_t<...>写在返回值位置或模板参数尾巴上,新看一眼根本不懂报错依然烂——约束不满足时,错误信息指向 SFINAE 内部( enable_if_t<false, T>之类),新手根本看不懂可读性差——看一眼代码,得在脑子里把 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>T add(T a, T b){return a + b;}就这一行,语法干净利落:
std::integral是 C++20 标准库提供的 concepttemplate<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>>>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>;注意几点:
concept是关键字Integral是这个 concept 的名字(约定用 PascalCase)= std::is_integral_v<T>是这个 concept 的定义——一个编译期布尔表达式编译期求值结果为 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; | |
typename T::X; | |
T(...); |
requires 子句:在模板声明里用
有了 concept 定义,接下来要把它用在模板参数上。有三种写法:
// C++20:三种"在模板上用 concept"的方式// 写法 1:concept 直接放在 typename 的位置(C++20 推荐)template<std::integral T>T add1(T a, T b){ return a + b; }// 写法 2:用 requires 子句template<typename T> requires std::integral<T>T add2(T a, T b){ return a + b; }// 写法 3:trailing requires(放在参数列表后)template<typename T>T add3(T a, T b)requires std::integral<T> { return a + b; }这三种写法完全等价。区别只在风格——写法 1 最简洁,写法 2 最灵活(可以写复杂表达式),写法 3 在某些场景下可读性更高。
requires 表达式 vs requires 子句:长得像,作用不同
这是新手最容易搞混的:
requires表达式 | requires(T x) { expr; } | 定义 |
requires子句 | template<typename T> requires Concept<T> | 使用 |
// 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>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(1, 10) << '\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>{1, 2, 3}); // 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> 就能用:
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> | |
std::derived_from<T, U> | <concepts> | |
std::default_initializable<T> | <concepts> | |
std::move_constructible<T> | <concepts> | |
std::ranges::range<T> | <ranges> | |
std::ranges::input_range<T> | <ranges> | |
std::formattable<T, CharT> | <format> |
实战里:能用标准库 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>{1, 2}); // 通用版本}整数版本: 42浮点版本: 3.14字符串版本: hello通用版本重载选择规则:
编译器列出所有候选 过滤掉"不满足约束"的 在剩下的候选里,挑约束最具体的(满足的 concept 数量最多 / 范围最窄) 平局 → 报歧义错误
注意:Concepts 不参与 SFINAE 的"替换失败"流程。如果你同时写了 Concepts 约束和 SFINAE 兜底,行为会出乎意料——二选一,不要混。
性能数据:Concepts 是编译期特性,运行时零开销
Concepts 的检查全部在编译期完成——运行时,带 Concepts 的代码和手写 if 一模一样快。
来看个示意:
// C++20template<std::integral T>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::integral、std::floating_point、std::ranges::range 这些都是命名空间里的 concept,不是关键字。要写完整的 std::integral,不能图省事写 integral。
(类似地,requires 才是关键字,concept 也是关键字。这俩关键字可以单独用,但标准库 concept 必须带命名空间前缀。)
总结:5 条行动准则
std::integralstd::floating_point / std::ranges::range 现成能用 | ||
concept Name = ...;requires 表达式写复杂约束 | ||
写在最后
从 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 分钟读完。
夜雨聆风