资讯动态

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

发布时间:2026/10/20 2:17:59 来源:尧图企业网站定制
模板写错时那屏报错多半不是你的错是语言的错约束只能靠type_traits拼出来、注释里写一句「T 必须是整数类型」编译器并不知道这条规矩它只会在实例化失败的地方吐出一串模板替换细节。C20 的 Concepts概念把这条规矩变成了编译器认识的实体能被声明、能组合、能参与重载决议错误信息也终于指向「谁不满足什么」。这篇把 Concepts 的写法与规则一次讲透。这篇所有示例需要 C20gcc 10 / clang 12 起支持每个代码块第一行都标了// verify: stdc20。1. 引子同一段错误两种报错先看老写法。想让f只接受整数类型C17 的惯用手段是 SFINAESubstitution Failure Is Not An Error替换失败并非错误用enable_if把约束藏在模板参数默认值里片段故意编译失败// sfinae_fail.cpp片段— 编译: g -stdc20 -O2 sfinae_fail.cpp#includetype_traitstemplateclassT,classstd::enable_if_tstd::is_integral_vTvoidf(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: templateclass 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_iffalse, void读者要自己翻译「enable_iffalse, void里没有type」 「is_integral_vdouble是 false」 「double不是整数」。而报错最后指向的是标准库头文件 type_traits 第 2610 行离你写的那行十万八千里。再看同一件事用 Concepts 写// concepts_fail.cpp片段— 编译: g -stdc20 -O2 concepts_fail.cpp#includeconceptstemplatestd::integral Tvoidf(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: templateclass T requires integralT 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;关键差别在最后两行编译器主动告诉你「integralT不满足因为is_integral_vdouble求值为 false」。它甚至能指出std::integral这个 concept 的定义在哪一行。约束从「注释里的君子协定」变成了「编译器能复述的句子」这就是 Concepts 最直接的收益。官方文档Constraints and concepts — cppreference2. 定义一个 conceptconcept概念的本质是一个编译期布尔谓词专门用来约束类型。最基本的形式就是把已有的 trait 包一层// 定义一个 conceptT 必须是整数类型#includeconcepts#includetype_traitstemplateclassTconceptIntegralstd::is_integral_vT;语法上它长得像变量模板但有两个区别concept是关键字C20 新增等号右边必须是一个编译期可求值的bool表达式而且这个表达式里只允许使用模板参数。第二种更常用的形式把右边的「bool 表达式」换成requires表达式用来检测「某个表达式能不能编译过」下一节展开。一个坑要记住concept不能显式特化、也不能递归引用自己。想做「分档判断」正确做法是用constexpr函数或if constexpr而不是给 concept 写特化。官方文档Language support library —3. 四种使用方式一次对照定义好 concept 之后怎么把它挂到函数或类上标准给了四种等价形式各有各的适用场景形式写法特点适用场景① 类型约束template Integral T void f(T);最简洁约束写进模板参数表只有一个约束、只约束一个参数② requires 子句template class T requires IntegralT void f(T);表达复杂逻辑最自然需要/||组合多个约束③ 尾置 requirestemplate class T void f(T) requires IntegralT;约束写在参数表之后约束想引用多个参数、或多重载时更易读④ 简写约束void f(Integral auto x);参数表里直接写 concept想少写template class T像普通函数四种写法在语义上完全等价选哪种只看可读性。四种一起跑一遍// concepts_forms.cpp — 编译: g -stdc20 -Wall -O2 concepts_forms.cpp -o cf// verify: stdc20#includecstdio#includeconcepts#includetype_traitstemplateclassTconceptIntegralstd::is_integral_vT;// ① 类型约束约束写进模板参数templateIntegral Tvoidf1(T v){std::printf(① 类型约束 f1(%d)\n,static_castint(v));}// ② requires 子句跟在模板头后面templateclassTrequiresIntegralTvoidf2(T v){std::printf(② requires 子句 f2(%d)\n,static_castint(v));}// ③ 尾置 requires跟在参数表后面templateclassTvoidf3(T v)requiresIntegralT{std::printf(③ 尾置 requires f3(%d)\n,static_castint(v));}// ④ 简写约束参数类型写成 Concept autovoidf4(Integralautov){std::printf(④ 简写约束 f4(%d)\n,static_castint(v));}intmain(){f1(1);f2(2);f3(3);f4(4);}① 类型约束 f1(1) ② requires 子句 f2(2) ③ 尾置 requires f3(3) ④ 简写约束 f4(4)①和④读起来最像函数声明②、③则给了你一个「把约束当成 bool 表达式随便拼」的位置比如requires IntegralT (sizeof(T) 8)。四种形式能混用同一个函数上不会互相冲突。4. requires 表达式检测「这个表达式能不能写」真正让 Concepts 从「换个写法的 enable_if」升级成「类型系统工具」的是requires 表达式requires-expression。它不返回某个固定值而是让编译器去试着编译里面列的几行代码// 一个 requires 表达式把里面的每一行都当待验证的表达式templateclassTconceptHasSizerequires(constTt){{t.size()}-std::convertible_tostd::size_t;// 复合要求};requires (const T t) { ... }里的参数列表是可选的写出来相当于给下面用到的名字起个「假变量」编译器只看它能不能通过编译不会真的执行。花括号里可以放四类要求要求类型写法示例检查的是什么简单要求 simple requirementt.size();这个表达式能编译过不求值类型要求 type requirementtypename T::value_type;这个类型存在复合要求 compound requirement{ t.size() } - std::convertible_tostd::size_t;表达式合法且返回类型满足指定 concept嵌套要求 nested requirementrequires HasSizeT;另一个约束也必须成立四类要求跑一遍含嵌套要求与类型要求// concepts_requires.cpp — 编译: g -stdc20 -Wall -O2 concepts_requires.cpp -o cr// verify: stdc20#includecstdio#includeconcepts#includestring#includevector// 复合要求t.size() 合法且能转成 std::size_ttemplateclassTconceptHasSizerequires(constTt){{t.size()}-std::convertible_tostd::size_t;};// 类型要求T 里有嵌套类型 value_typetemplateclassTconceptHasValueTyperequires{typenameT::value_type;};// 嵌套要求T 得同时满足上面两个 concepttemplateclassTconceptSizedContainerrequires{requiresHasSizeT;requiresHasValueTypeT;};templateHasSize Tvoidreport_size(constTt,constchar*name){std::printf(%-6s .size() %zu\n,name,static_caststd::size_t(t.size()));}intmain(){conststd::vectorintv{1,2,3};conststd::string shello;report_size(v,vector);report_size(s,string);std::printf(HasValueTypestd::vectorint %d\n,static_castint(HasValueTypestd::vectorint));std::printf(HasValueTypeint %d\n,static_castint(HasValueTypeint));std::printf(SizedContainerstd::string %d\n,static_castint(SizedContainerstd::string));}vector .size() 3 string .size() 5 HasValueTypestd::vectorint 1 HasValueTypeint 0 SizedContainerstd::string 1注意HasValueTypeint求值为0而不是编译错误requires表达式里的typename int::value_type非法时整条约束只是「不满足」错误被吃掉、转成false。这正是它比裸写T::value_type安全的地方后者会在实例化时直接炸。%-6s是printf的左对齐占位符%.1f、%zu同理写输出示例时把宽度和精度在格式串里定死输出才是确定的、可核对的。官方文档requires expression — cppreference5. concept 的组合与重载偏序concept 之间可以用、||、!组合成新 concept术语叫合取conjunction和析取disjunctiontemplateclassTconceptSignedIntegralstd::integralTstd::signed_integralT;// 合取两个都要满足templateclassTconceptNumericstd::integralT||std::floating_pointT;// 析取满足一个即可组合出来的是「更受约束more constrained」的 concept而重载决议会给它更高的优先级。规则叫约束的偏序partial ordering of constraints只有当两个候选的约束之间构成「子集」关系一个蕴含另一个时才判定更严的那个胜出如果互不蕴含就是二义性错误。// concepts_overload.cpp — 编译: g -stdc20 -Wall -O2 concepts_overload.cpp -o co// verify: stdc20#includecstdio#includeconceptstemplateclassTconceptSignedIntegralstd::integralTstd::signed_integralT;// 三个重载约束一个比一个强templateclassTvoiddescribe(T){std::puts(通用模板无约束);}templatestd::integral Tvoiddescribe(T){std::puts(受约束integral);}templateSignedIntegral Tvoiddescribe(T){std::puts(受约束SignedIntegral更强);}intmain(){describe(1.5);// double只有通用模板能接describe(static_castunsigned(7));// unsignedintegral 成立signed 不成立describe(-3);// int两个都成立选更强的}通用模板无约束 受约束integral 受约束SignedIntegral更强这三行的选择过程就是编译器做约束偏序的流程调用 describe(x) │ ▼ 列出所有候选模板 ├─ describe(T) 约束无 ├─ describe(std::integral T) 约束integralT └─ describe(SignedIntegral T) 约束integralT signed_integralT │ ▼ 逐个检查约束是否满足 不满足 → 淘汰错误信息里写清哪条不满足 │ ▼ 剩下的候选里谁的约束更严格蕴含别人 ├─ 有唯一最严者 → 选它 ├─ 有多个且互相不蕴含 → 二义性编译错误 └─ 没有约束关系 → 退回普通重载决议规则参数匹配、模板特化程度…这套机制解决了一个老问题C17 里想给「更特殊的类型」写更快的重载只能靠enable_if组合条件的「复杂度」碰运气编译器并不真懂谁更严。Concepts 把这个关系形式化了SignedIntegralT蕴含std::integralT是可推导的事实不是人肉约定。官方文档C Core Guidelines — 用 concept 表达模板接口的约束6. 标准库已有 concept 速查不用什么都自己定义concepts已经提供了一批核心 concept很多需求直接抄名字就行concept含义典型用途std::integralT整数类型含 bool、char位运算、计数接口std::floating_pointT浮点类型数值算法std::signed_integralT/std::unsigned_integralT有 / 无符号整数区分「能不能取负」std::same_asT, U类型完全相同精确匹配重载std::convertible_toFrom, To可隐式 / 显式转换检查赋值、参数是不是接得住std::derived_fromD, BD 继承自 B 且可转换接住基类指针的接口std::invocableF, Args...F 能用 Args… 调用接住 lambda、函数对象std::regularT可默认构造 可拷贝 可比较要求「像个值」的类型std::ranges::rangeRR 能begin/end泛型遍历std::ranges::sized_rangeR范围且size()是 O(1)需要长度的一次遍历std::ranges::range在ranges里、不在concepts里这是唯一容易踩的头部问题。7. 完整示例把 Printable 做成一个约束把前面所有要点拼起来。自定义 concept、复合要求、||析取、偏序重载一次覆盖// concepts_full.cpp — 编译: g -stdc20 -Wall -O2 concepts_full.cpp -o cfull// verify: stdc20#includecstdio#includeconcepts#includestring#includevector// 复合要求能取长度、元素能转成 int —— 一个能逐个求和的序列templateclassTconceptPrintablerequires(constTv){{v.size()}-std::convertible_tostd::size_t;{v[0]}-std::convertible_toint;};// 更宽的约束只要能被 range-for 遍历就至少能数元素templateclassTconceptIterablerequires(constTv){{v.begin()}-std::convertible_todecltype(v.begin());};// 偏序Iterable 更宽松Printable 更严格Printable 蕴含 IterabletemplateIterable Tintsum_of(constTv){inttotal0;for(constautox:v){totalstatic_castint(x);}returntotal;}intmain(){conststd::vectorintnums{10,20,30};conststd::string textabc;std::printf(vector 求和 %d\n,sum_of(nums));std::printf(string 求和 %d\n,sum_of(text));std::printf(Printablestd::vectorint %d\n,static_castint(Printablestd::vectorint));std::printf(Printableint %d\n,static_castint(Printableint));std::printf(Iterableint %d\n,static_castint(Iterableint));}vector 求和 60 string 求和 294 Printablestd::vectorint 1 Printableint 0 Iterableint 0std::string的元素是char累加出的是字符编码之和abc 97 98 99 294不是「abc」写这种泛型示例时输出里别放自己没算过的数字要么先想清楚类型要么干脆让它只打印布尔判断。而Printableint/Iterableint都是0int既没有size()也没有begin()两条约束都不满足。8. Concepts 与 SFINAE怎么取舍维度SFINAEC17 及以前ConceptsC20可读性约束藏在模板参数默认值里要对着enable_if反推约束是具名实体读一遍就懂报错质量指向标准库内部no type named type明说「哪个 concept 不满足、哪一步求值为 false」能否参与重载偏序不能只能靠条件复杂度的巧合能且有形式化规则能否给参数逐个加约束只能整个函数一个开关每个参数、每个重载各管各的能否约束类模板要写static_assert或模板参数技巧template std::integral T class C;直接写编译器要求老编译器都支持需要 C20gcc 10 / clang 12 起结论很明确新代码优先用 Concepts它可读、报错准、还能参与重载决议。但 SFINAE 不是可以不学了大量存量库Boost 系、早期 STL 实现、各种老框架还在用它读不懂std::enable_if_tstd::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 能力扩张从 C11 到 C20 的编译期计算》 —— constexpr 从 C11 只能写单 return《C20 核心新特性总览四大特性 gcc 13 实测清单》 —— C20 是继 C11 之后最大的一次改动。《if / switch 初始化语句C17 把变量作用域关进笼子》 —— C17 给 if 和 switch 加了 init-statement10. 一句话总结Concepts 把「模板参数必须满足什么」从注释和enable_if技巧变成了编译器认识的具名实体用四种等价写法把它挂到模板上用 requires 表达式检测「某个表达式能不能编译过」用/||组合出更受约束的 concept 并在重载决议中胜出它零运行期开销、报错直指不满足的那一步代价只是需要 C20。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑