ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Concepts 把模板错误拦在编译期:C++20 约束的四种写法与重载偏序

Concepts 把模板错误拦在编译期:C++20 约束的四种写法与重载偏序

模板写错时那屏报错,多半不是你的错,是语言的错:约束只能靠type_traits拼出来、注释里写一句「T 必须是整数类型」,编译器并不知道这条规矩,它只会在实例化失败的地方吐出一串模板替换细节。C++20 的 Concepts(概念)把这条规矩变成了编译器认识的实体:能被声明、能组合、能参与重载决议,错误信息也终于指向「谁不满足什么」。这篇把 Concepts 的写法与规则一次讲透。

这篇所有示例需要 C++20(gcc 10 / clang 12 起支持),每个代码块第一行都标了// verify: std=c++20。

1. 引子:同一段错误,两种报错

先看老写法。想让f只接受整数类型,C++17 的惯用手段是 SFINAE(Substitution Failure Is Not An Error,替换失败并非错误):用enable_if把约束藏在模板参数默认值里(片段,故意编译失败):

// sfinae_fail.cpp(片段)— 编译: g++ -std=c++20 -O2 sfinae_fail.cpp#include<type_traits>template<classT,class=std::enable_if_t<std::is_integral_v<T>>>voidf(T){}voidcall(){f(3.14);}// 反例,不要这么写:double 不是整数类型

gcc 13.2.0 的真实报错(节选):

prog.cc:5:16: error: no matching function for call to 'f(double)' prog.cc:4:6: note: candidate: 'template<class T, class> void f(T)' prog.cc:4:6: note: template argument deduction/substitution failed: /opt/wandbox/gcc-13.2.0/include/c++/13.2.0/type_traits:2610:11: error: no type named 'type' in 'struct std::enable_if<false, void>'

读者要自己翻译:「enable_if<false, void>里没有type」= 「is_integral_v<double>是 false」= 「double不是整数」。而报错最后指向的是标准库头文件 type_traits 第 2610 行,离你写的那行十万八千里。

再看同一件事用 Concepts 写:

// concepts_fail.cpp(片段)— 编译: g++ -std=c++20 -O2 concepts_fail.cpp#include<concepts>template<std::integral T>voidf(T){}voidcall(){f(3.14);}// 反例,不要这么写:double 不是整数类型

同一次编译的真实报错(节选):

prog.cc:5:16: error: no matching function for call to 'f(double)' prog.cc:4:6: note: candidate: 'template<class T> requires integral<T> void f(T)' prog.cc:4:6: note: constraints not satisfied .../concepts:100:24: note: the expression 'is_integral_v<_Tp> [with _Tp = double]' evaluated to 'false' 100 | concept integral = is_integral_v<_Tp>;

关键差别在最后两行:编译器主动告诉你「integral<T>不满足,因为is_integral_v<double>求值为 false」。它甚至能指出std::integral这个 concept 的定义在哪一行。约束从「注释里的君子协定」变成了「编译器能复述的句子」:这就是 Concepts 最直接的收益。

官方文档:Constraints and concepts — cppreference

2. 定义一个 concept

concept(概念)的本质是一个编译期布尔谓词,专门用来约束类型。最基本的形式就是把已有的 trait 包一层:

// 定义一个 concept:T 必须是整数类型#include<concepts>#include<type_traits>template<classT>conceptIntegral=std::is_integral_v<T>;

语法上它长得像变量模板,但有两个区别:concept是关键字(C++20 新增),等号右边必须是一个编译期可求值的bool表达式,而且这个表达式里只允许使用模板参数。第二种更常用的形式把右边的「bool 表达式」换成requires表达式,用来检测「某个表达式能不能编译过」:下一节展开。

一个坑要记住:concept不能显式特化、也不能递归引用自己。想做「分档判断」,正确做法是用constexpr函数或if constexpr,而不是给 concept 写特化。

官方文档:Language support library —

3. 四种使用方式:一次对照

定义好 concept 之后,怎么把它挂到函数(或类)上?标准给了四种等价形式,各有各的适用场景:

形式写法特点适用场景
① 类型约束template <Integral T> void f(T);最简洁,约束写进模板参数表只有一个约束、只约束一个参数
② requires 子句template <class T> requires Integral<T> void f(T);表达复杂逻辑最自然需要&&/||组合多个约束
③ 尾置 requirestemplate <class T> void f(T) requires Integral<T>;约束写在参数表之后约束想引用多个参数、或多重载时更易读
④ 简写约束void f(Integral auto x);参数表里直接写 concept想少写template <class T>,像普通函数

四种写法在语义上完全等价,选哪种只看可读性。四种一起跑一遍:

// concepts_forms.cpp — 编译: g++ -std=c++20 -Wall -O2 concepts_forms.cpp -o cf// verify: std=c++20#include<cstdio>#include<concepts>#include<type_traits>template<classT>conceptIntegral=std::is_integral_v<T>;// ① 类型约束:约束写进模板参数template<Integral T>voidf1(T v){std::printf("① 类型约束 f1(%d)\n",static_cast<int>(v));}// ② requires 子句:跟在模板头后面template<classT>requiresIntegral<T>voidf2(T v){std::printf("② requires 子句 f2(%d)\n",static_cast<int>(v));}// ③ 尾置 requires:跟在参数表后面template<classT>voidf3(T v)requiresIntegral<T>{std::printf("③ 尾置 requires f3(%d)\n",static_cast<int>(v));}// ④ 简写约束:参数类型写成 Concept autovoidf4(Integralautov){std::printf("④ 简写约束 f4(%d)\n",static_cast<int>(v));}intmain(){f1(1);f2(2);f3(3);f4(4);}
① 类型约束 f1(1) ② requires 子句 f2(2) ③ 尾置 requires f3(3) ④ 简写约束 f4(4)

①和④读起来最像函数声明,②、③则给了你一个「把约束当成 bool 表达式随便拼」的位置:比如requires Integral<T> && (sizeof(T) <= 8)。四种形式能混用,同一个函数上不会互相冲突。

4. requires 表达式:检测「这个表达式能不能写」

真正让 Concepts 从「换个写法的 enable_if」升级成「类型系统工具」的,是requires 表达式(requires-expression)。它不返回某个固定值,而是让编译器去试着编译里面列的几行代码:

// 一个 requires 表达式:把里面的每一行都当"待验证的表达式"template<classT>conceptHasSize=requires(constT&t){{t.size()}->std::convertible_to<std::size_t>;// 复合要求};

requires (const T& t) { ... }里的参数列表是可选的,写出来相当于给下面用到的名字起个「假变量」,编译器只看它能不能通过编译,不会真的执行。花括号里可以放四类要求:

要求类型写法示例检查的是什么
简单要求 simple requirementt.size();这个表达式能编译过(不求值)
类型要求 type requirementtypename T::value_type;这个类型存在
复合要求 compound requirement{ t.size() } -> std::convertible_to<std::size_t>;表达式合法,且返回类型满足指定 concept
嵌套要求 nested requirementrequires HasSize<T>;另一个约束也必须成立

四类要求跑一遍(含嵌套要求与类型要求):

// concepts_requires.cpp — 编译: g++ -std=c++20 -Wall -O2 concepts_requires.cpp -o cr// verify: std=c++20#include<cstdio>#include<concepts>#include<string>#include<vector>// 复合要求:t.size() 合法,且能转成 std::size_ttemplate<classT>conceptHasSize=requires(constT&t){{t.size()}->std::convertible_to<std::size_t>;};// 类型要求:T 里有嵌套类型 value_typetemplate<classT>conceptHasValueType=requires{typenameT::value_type;};// 嵌套要求:T 得同时满足上面两个 concepttemplate<classT>conceptSizedContainer=requires{requiresHasSize<T>;requiresHasValueType<T>;};template<HasSize T>voidreport_size(constT&t,constchar*name){std::printf("%-6s .size() = %zu\n",name,static_cast<std::size_t>(t.size()));}intmain(){conststd::vector<int>v{1,2,3};conststd::string s="hello";report_size(v,"vector");report_size(s,"string");std::printf("HasValueType<std::vector<int>> = %d\n",static_cast<int>(HasValueType<std::vector<int>>));std::printf("HasValueType<int> = %d\n",static_cast<int>(HasValueType<int>));std::printf("SizedContainer<std::string> = %d\n",static_cast<int>(SizedContainer<std::string>));}
vector .size() = 3 string .size() = 5 HasValueType<std::vector<int>> = 1 HasValueType<int> = 0 SizedContainer<std::string> = 1

注意HasValueType<int>求值为0而不是编译错误:requires表达式里的typename int::value_type非法时,整条约束只是「不满足」,错误被吃掉、转成false。这正是它比裸写T::value_type安全的地方:后者会在实例化时直接炸。

%-6s是printf的左对齐占位符,%.1f、%zu同理:写输出示例时把宽度和精度在格式串里定死,输出才是确定的、可核对的。

官方文档:requires expression — cppreference

5. concept 的组合与重载偏序

concept 之间可以用&&、||、!组合成新 concept(术语叫合取(conjunction)和析取(disjunction)):

template<classT>conceptSignedIntegral=std::integral<T>&&std::signed_integral<T>;// 合取:两个都要满足template<classT>conceptNumeric=std::integral<T>||std::floating_point<T>;// 析取:满足一个即可

组合出来的是「更受约束(more constrained)」的 concept,而重载决议会给它更高的优先级。规则叫约束的偏序(partial ordering of constraints):只有当两个候选的约束之间构成「子集」关系(一个蕴含另一个)时,才判定更严的那个胜出;如果互不蕴含,就是二义性错误。

// concepts_overload.cpp — 编译: g++ -std=c++20 -Wall -O2 concepts_overload.cpp -o co// verify: std=c++20#include<cstdio>#include<concepts>template<classT>conceptSignedIntegral=std::integral<T>&&std::signed_integral<T>;// 三个重载:约束一个比一个强template<classT>voiddescribe(T){std::puts("通用模板(无约束)");}template<std::integral T>voiddescribe(T){std::puts("受约束:integral");}template<SignedIntegral T>voiddescribe(T){std::puts("受约束:SignedIntegral(更强)");}intmain(){describe(1.5);// double:只有通用模板能接describe(static_cast<unsigned>(7));// unsigned:integral 成立,signed 不成立describe(-3);// int:两个都成立,选更强的}
通用模板(无约束) 受约束:integral 受约束:SignedIntegral(更强)

这三行的选择过程,就是编译器做约束偏序的流程:

调用 describe(x) │ ▼ 列出所有候选模板 ├─ describe(T) 约束:无 ├─ describe(std::integral T) 约束:integral<T> └─ describe(SignedIntegral T) 约束:integral<T> && signed_integral<T> │ ▼ 逐个检查:约束是否满足? 不满足 → 淘汰(错误信息里写清哪条不满足) │ ▼ 剩下的候选里,谁的约束"更严格"(蕴含别人)? ├─ 有唯一最严者 → 选它 ├─ 有多个且互相不蕴含 → 二义性,编译错误 └─ 没有约束关系 → 退回普通重载决议规则(参数匹配、模板特化程度…)

这套机制解决了一个老问题:C++17 里想给「更特殊的类型」写更快的重载,只能靠enable_if组合条件的「复杂度」碰运气,编译器并不真懂谁更严。Concepts 把这个关系形式化了:SignedIntegral<T>蕴含std::integral<T>是可推导的事实,不是人肉约定。

官方文档:C++ Core Guidelines — 用 concept 表达模板接口的约束

6. 标准库已有 concept 速查

不用什么都自己定义:<concepts>已经提供了一批核心 concept,很多需求直接抄名字就行:

concept含义典型用途
std::integral<T>整数类型(含 bool、char)位运算、计数接口
std::floating_point<T>浮点类型数值算法
std::signed_integral<T>/std::unsigned_integral<T>有 / 无符号整数区分「能不能取负」
std::same_as<T, U>类型完全相同精确匹配重载
std::convertible_to<From, To>可隐式 / 显式转换检查赋值、参数是不是接得住
std::derived_from<D, B>D 继承自 B 且可转换接住基类指针的接口
std::invocable<F, Args...>F 能用 Args… 调用接住 lambda、函数对象
std::regular<T>可默认构造 + 可拷贝 + 可比较要求「像个值」的类型
std::ranges::range<R>R 能begin/end泛型遍历
std::ranges::sized_range<R>范围且size()是 O(1)需要长度的一次遍历

std::ranges::range在<ranges>里、不在<concepts>里,这是唯一容易踩的头部问题。

7. 完整示例:把 Printable 做成一个约束

把前面所有要点拼起来。自定义 concept、复合要求、||析取、偏序重载,一次覆盖:

// concepts_full.cpp — 编译: g++ -std=c++20 -Wall -O2 concepts_full.cpp -o cfull// verify: std=c++20#include<cstdio>#include<concepts>#include<string>#include<vector>// 复合要求:能取长度、元素能转成 int —— 一个"能逐个求和"的序列template<classT>conceptPrintable=requires(constT&v){{v.size()}->std::convertible_to<std::size_t>;{v[0]}->std::convertible_to<int>;};// 更宽的约束:只要能被 range-for 遍历,就至少能数元素template<classT>conceptIterable=requires(constT&v){{v.begin()}->std::convertible_to<decltype(v.begin())>;};// 偏序:Iterable 更宽松,Printable 更严格(Printable 蕴含 Iterable)template<Iterable T>intsum_of(constT&v){inttotal=0;for(constauto&x:v){total+=static_cast<int>(x);}returntotal;}intmain(){conststd::vector<int>nums{10,20,30};conststd::string text="abc";std::printf("vector 求和 = %d\n",sum_of(nums));std::printf("string 求和 = %d\n",sum_of(text));std::printf("Printable<std::vector<int>> = %d\n",static_cast<int>(Printable<std::vector<int>>));std::printf("Printable<int> = %d\n",static_cast<int>(Printable<int>));std::printf("Iterable<int> = %d\n",static_cast<int>(Iterable<int>));}
vector 求和 = 60 string 求和 = 294 Printable<std::vector<int>> = 1 Printable<int> = 0 Iterable<int> = 0

std::string的元素是char,累加出的是字符编码之和('a'+'b'+'c'= 97 + 98 + 99 = 294),不是「abc」:写这种泛型示例时输出里别放自己没算过的数字,要么先想清楚类型,要么干脆让它只打印布尔判断。而Printable<int>/Iterable<int>都是0:int既没有size()也没有begin(),两条约束都不满足。

8. Concepts 与 SFINAE:怎么取舍

维度SFINAE(C++17 及以前)Concepts(C++20)
可读性约束藏在模板参数默认值里,要对着enable_if反推约束是具名实体,读一遍就懂
报错质量指向标准库内部:no type named 'type'明说「哪个 concept 不满足、哪一步求值为 false」
能否参与重载偏序不能,只能靠条件复杂度的巧合能,且有形式化规则
能否给参数逐个加约束只能整个函数一个开关每个参数、每个重载各管各的
能否约束类模板要写static_assert或模板参数技巧template <std::integral T> class C;直接写
编译器要求老编译器都支持需要 C++20,gcc 10 / clang 12 起

结论很明确:新代码优先用 Concepts,它可读、报错准、还能参与重载决议。但 SFINAE 不是可以不学了:大量存量库(Boost 系、早期 STL 实现、各种老框架)还在用它,读不懂std::enable_if_t<std::is_same_v<...>>这类签名,维护老代码时会寸步难行。实用方案是:写新代码用 Concepts,读老代码认识 SFINAE,两者是同一件事的两代表达,句法不通、语义相通。

还有一个性能视角容易被忽略:Concepts完全不产生运行期开销:约束在编译期求值,替换成哪份实现编译期就定死了。它对性能的帮助是「间接」的,但很实在:requires表达式让「这个类型有没有 O(1) 的size()」这类复杂度承诺也能写进接口,从而让你放心地在算法里依赖它,而不用退回最慢的通用实现。

9. 延伸阅读

  • Constraints and concepts — cppreference —— 四种约束写法的权威对照,含「约束的偏序」的完整判定规则
  • requires expression — cppreference —— 四类要求(简单 / 类型 / 复合 / 嵌套)逐个说明,这篇第 4 节的依据
  • Language support library —<concepts>—— 标准库 concept 的完整清单,写约束前先来这里查有没有现成的
  • C++ Core Guidelines —— 搜 T.* 系列,看官方对「用 concept 表达模板接口前提条件」的建议
  • Compiler Explorer —— 想看约束替换后到底生成了哪份实现、有没有内联,把代码贴上去看汇编最快

本知识库内的相关篇目:

  • 《constexpr 能力扩张:从 C++11 到 C++20 的编译期计算》 —— constexpr 从 C++11 只能写单 return
  • 《C++20 核心新特性总览:四大特性 + gcc 13 实测清单》 —— C++20 是继 C++11 之后最大的一次改动。
  • 《if / switch 初始化语句:C++17 把变量作用域关进笼子》 —— C++17 给 if 和 switch 加了 init-statement

10. 一句话总结

Concepts 把「模板参数必须满足什么」从注释和enable_if技巧变成了编译器认识的具名实体:用四种等价写法把它挂到模板上,用 requires 表达式检测「某个表达式能不能编译过」,用&&/||组合出更受约束的 concept 并在重载决议中胜出;它零运行期开销、报错直指不满足的那一步,代价只是需要 C++20。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进