C++20 requires表达式详解:四种核心需求与工程实践指南
1. 项目概述为什么C20的requires值得你花时间如果你在C模板编程里摸爬滚打过肯定遇到过这样的场景写了一个模板函数希望它只对某些“合格”的类型生效。比如你写了个add函数理想情况下它应该只接受支持操作的类型。在C20之前我们得靠SFINAE、std::enable_if这些“黑魔法”来实现代码写出来又长又绕像天书一样。我自己就经常在调试一长串typename std::enable_if...::type时感到头疼。C20引入的Concepts概念和其中的核心语法requires就是为了终结这种混乱而来的。它不是什么高深莫测的新范式而是一套让你能清晰、直观地表达模板参数约束的语法工具。简单说requires让你能告诉编译器“我这个模板只欢迎满足这些条件的类型来用。” 代码瞬间就从“魔术”变成了“说明书”。这篇文章就是为你彻底拆解requires的用法。无论你是刚接触C20新特性的新手还是想从SFINAE的泥潭里解脱出来的老手都能在这里找到答案。我会用大量你能直接抄到项目里的示例讲清楚requires表达式的四种核心需求以及如何用它写出更健壮、更易读的模板代码。你会发现用好requires你的模板代码将不再让人望而生畏。2. requires表达式核心语法与设计思路拆解在深入细节之前我们必须先建立正确的认知requires在C20里扮演了两个角色它们看起来很相似但用途截然不同初学者特别容易混淆。第一个角色是requires子句requires-clause。它用在模板声明之后函数签名之前用来为整个模板附加约束条件。你可以把它想象成模板的“入职要求”。templatetypename T requires std::integralT // 这是一个requires子句 T increment(T value) { return value 1; }上面的代码意思是increment函数模板只接受满足std::integral整数类型这个概念的T。第二个角色也是本文的重点是requires表达式requires-expression。它是一个表达式会产生一个布尔类型的编译期常量prvalue。这个表达式本身用于定义约束条件。它通常出现在概念concept的定义中或者直接用在requires子句里形成所谓的“即席约束”。// 这是一个requires表达式它定义了一个概念 templatetypename T concept Addable requires(T a, T b) { a b; // 要求表达式 ab 是合法的 }; // 使用上面定义的概念作为requires子句的约束 templatetypename T requires AddableT // 这里使用的是概念其背后是requires表达式 T add(T a, T b) { return a b; } // 更直接的“即席约束”requires子句里直接嵌入一个requires表达式 templatetypename T requires requires(T a, T b) { a b; } // 第一个requires是子句第二个是表达式 T add2(T a, T b) { return a b; }注意add2函数中那个看起来有点滑稽的requires requires。这不是笔误而是合法的语法第一个requires引入子句第二个requires开始一个表达式。虽然可以这么写但为了可读性通常更推荐先定义好概念再使用。requires表达式的核心设计思路是“描述性测试”而非“执行性代码”。编译器在实例化模板时会评估requires表达式中的一系列“需求requirements”。这些需求检查的是如果用给定的模板参数替换进去那么某些表达式是否格式正确、某些类型是否存在、某些操作是否不抛异常等。requires表达式内部的语句永远不会被真正执行它们只在编译期被分析语法和类型。这种设计带来了巨大的优势编译期错误前移不符合约束的代码在模板被尝试使用的那一刻就会报错错误信息通常会指向约束失败的具体原因而不是深入到模板内部一团糟的实例化错误中。接口即文档模板的约束条件清晰写在声明处任何人一看就知道这个模板对参数有什么要求大大提升了代码的可读性和可维护性。简化重载与特化编译器可以依据约束条件更精确地选择匹配的模板让基于类型的函数重载和模板特化变得更加直观和强大。3. requires表达式的四种核心需求详解一个requires表达式的主体由一系列“需求”构成。C20标准定义了四种基本的需求类型简单需求、类型需求、复合需求和嵌套需求。理解这四种需求你就掌握了requires表达式的全部武器库。3.1 简单需求检查表达式是否合法简单需求是最直接的一种。它的形式就是一条以分号结尾的表达式语句。它断言这个表达式必须是格式正确的well-formed。编译器只检查语法和类型是否有效不关心表达式的值。templatetypename T concept Streamable requires(T obj, std::ostream os) { os obj; // 简单需求检查 obj 是否支持通过 输出到流 }; templatetypename T concept HasSize requires(T cont) { cont.size(); // 简单需求检查 cont 是否有可调用的 .size() 成员函数 cont.empty(); // 可以列出多个需求所有都必须满足 };实操要点与避坑指南它不执行requires { cont.size(); }绝不意味着调用size()函数只是检查cont.size()这个写法是否合法。即使size()函数内部有静态断言或运行时错误只要声明正确需求就满足。注意ADL依赖于实参的查找对于像os obj这样的操作符查找规则和普通代码一样会考虑ADL。这意味着它不仅能检查成员运算符也能检查非成员运算符。避免以requires开头的表达式因为requires关键字会被优先解析为嵌套需求的开始。如果你真想检查一个名为requires的函数需要加括号(obj.requires)();。3.2 类型需求检查嵌套类型或类型别名是否存在类型需求使用typename关键字后跟一个类型名称。它断言这个类型名称必须代表一个有效的类型。这常用于检查类内部是否有特定的嵌套类型如迭代器、值类型等或者某个模板特化是否产生一个合法类型。templatetypename T concept HasValueType requires { typename T::value_type; // 类型需求检查 T 内部是否有名为 value_type 的类型 }; templatetypename Container concept IterableContainer requires { typename Container::iterator; // 必须有迭代器类型 typename Container::const_iterator; // 必须有常量迭代器类型 typename std::iterator_traitstypename Container::iterator::value_type; // 可以组合使用 }; // 检查模板特化是否有效 templatetemplatetypename class Template, typename Arg concept ValidTemplate requires { typename TemplateArg; // 类型需求检查 TemplateArg 是否形成一个合法类型 };实操心得不必是完整类型类型需求只要求该名称是一个类型不要求它是完整类型即不需要知道其大小和布局。这对于进行元编程和 trait 检查非常有用。强大的模板检查工具typename TemplateArg这种形式是检查一个类模板能否用特定参数实例化的利器比旧的SFINAE技巧简洁得多。3.3 复合需求检查表达式的属性返回类型、异常规范简单需求只检查“能否编译”但有时我们还想知道更多“这个表达式返回什么类型”、“它保证不抛异常吗”。复合需求就是干这个的。复合需求将表达式用花括号{}括起来后面可以可选地跟noexcept和/或- type-constraint。{ expression } 检查表达式是否有效同简单需求。{ expression } noexcept 检查表达式是否有效并且该表达式不会抛出异常。{ expression } - type-constraint 检查表达式是否有效并且其结果的类型decltype((expression))满足指定的类型约束type-constraint。{ expression } noexcept - type-constraint 上述两者的结合。templatetypename T concept Dereferenceable requires(T ptr) { { *ptr } - std::same_astypename T::element_type; // 复合需求解引用且返回类型是 element_type }; templatetypename F, typename... Args concept NonThrowingInvocable requires(F func, Args... args) { { std::forwardF(func)(std::forwardArgs(args)...) } noexcept; // 复合需求可调用且不抛异常 }; templatetypename T concept ConvertibleToInt requires(T val) { { val 0 } - std::convertible_toint; // 复合需求表达式有效且结果可转换为int };关键细节与常见问题-后面跟的是约束不是具体类型你不能写- int而应该写- std::same_asint或- std::convertible_toint。std::same_as要求严格匹配std::convertible_to允许隐式转换。这是新手最容易出错的地方。decltype((expression))的双重括号标准规定用于类型约束检查的类型是decltype((expression))。注意里面的双重括号这确保了对于变量xdecltype((x))是引用类型如果x是左值从而能正确检查移动语义等场景。如果你写{x} - std::same_asT而x是T类型这个约束会失败因为decltype((x))是T不是T。你可能需要std::same_asT或std::convertible_toT。noexcept检查是严格的它要求表达式是noexcept(true)的。如果表达式内调用的函数可能抛异常即使当前上下文被try-catch包裹该需求也无法满足。3.4 嵌套需求引入额外的布尔常量表达式约束嵌套需求以requires开头后跟一个布尔常量表达式。它用于在requires表达式内部引入更复杂的逻辑条件这些条件通常依赖于前面检查中引入的或局部参数的类型。templatetypename T concept SignedIntegral requires { requires std::integralT; // 嵌套需求首先必须是整数类型 requires std::is_signed_vT; // 嵌套需求还必须是有符号的 // 也可以写成requires std::integralT std::is_signed_vT; }; templatetypename T concept SmartPointer requires(T p) { *p; // 简单需求可解引用 requires std::same_asdecltype(p nullptr), bool; // 嵌套需求可与nullptr比较并返回bool requires std::constructible_fromT, std::nullptr_t; // 嵌套需求可从nullptr构造 }; templatetypename Iter concept RandomAccessIterator requires(Iter it, int n) { it n; it - n; // 简单需求 requires std::same_asdecltype(it[n]), typename std::iterator_traitsIter::reference; // 嵌套需求下标访问返回引用 requires requires { // 嵌套的requires表达式检查迭代器差值类型可转换为ptrdiff_t { it - it } - std::convertible_tostd::ptrdiff_t; }; };嵌套需求的核心价值逻辑组合它允许你将多个约束用、||等逻辑运算符组合起来放在requires表达式内部使概念的定义更加模块化和内聚。基于局部参数的复杂检查嵌套需求中的表达式可以访问requires表达式引入的局部参数如上面的p,it从而进行更具体的、依赖于值的约束尽管这些参数没有实际值只有类型信息。清晰的语义分组将相关的约束放在一个嵌套需求里可以提高概念定义的可读性。4. 从理论到实践综合应用示例与避坑指南理解了基本部件我们来看看如何用它们搭建坚固的模板约束。我会通过几个逐渐复杂的例子展示requires在实际中的威力并分享我踩过的坑。4.1 示例一构建一个“可排序范围”概念假设我们想写一个泛型的sort算法它应该能处理任何支持随机访问和元素比较的容器。我们可以定义一个SortableRange概念。#include concepts #include iterator #include ranges templatetypename Rng concept SortableRange std::ranges::random_access_rangeRng // 首先得是个随机访问范围 requires(Rng range) { // 使用局部参数 range requires std::totally_orderedstd::ranges::range_value_tRng; // 嵌套需求值类型必须全序 // 或者用复合需求检查具体的比较操作 { std::ranges::begin(range) std::ranges::end(range) } - std::convertible_tobool; // 复合需求迭代器可比较虽然random_access_range已隐含 // 检查是否可交换元素对于某些排序算法很重要 requires requires(std::ranges::iterator_tRng a, std::ranges::iterator_tRng b) { std::iter_swap(a, b); // 嵌套的requires表达式 }; }; // 使用该概念的排序函数模板 template SortableRange Rng void my_sort(Rng range) { // 使用 std::sort 或自定义算法 std::sort(std::ranges::begin(range), std::ranges::end(range)); } // 测试 #include vector #include list int main() { std::vectorint vec {3,1,2}; my_sort(vec); // OK: vector满足SortableRange std::listint lst {3,1,2}; // my_sort(lst); // 编译错误list不是random_access_range概念约束失败错误信息清晰 }这个例子带来的启示组合使用一个强大的概念往往是标准概念std::ranges::random_access_range、类型特征std::totally_ordered和自定义requires表达式的组合。局部参数的作用requires(Rng range)引入了局部参数range让我们能在需求中基于这个“假设的”引用进行更具体的检查比如检查它的迭代器类型。清晰的错误信息当用std::list调用my_sort时编译器会明确指出SortableRange概念不满足并很可能列出是哪部分约束失败了比如std::ranges::random_access_range这比传统的模板实例化错误友好得多。4.2 示例二约束一个“工厂函数”接口我们想创建一个概念约束那些可以像工厂一样使用给定参数构造出对象的类型。这在实际的依赖注入或插件系统中很有用。templatetypename Factory, typename Product, typename... Args concept FactoryFor requires(Factory factory, Args... args) { // 复合需求factory必须能用args...调用且返回类型可转换为Product* { std::forwardFactory(factory)(std::forwardArgs(args)...) } - std::convertible_toProduct*; // 可选检查工厂是否不抛异常对于关键组件很重要 // { std::forwardFactory(factory)(std::forwardArgs(args)...) } noexcept - std::convertible_toProduct*; }; // 一个使用该概念的模板函数 templatetypename Product, FactoryForProduct Factory, typename... Args Product* create_product(Factory factory, Args... args) { // 这里可以添加日志、性能统计等横切关注点 return std::forwardFactory(factory)(std::forwardArgs(args)...); } // 测试 struct Widget { Widget(int, double) {} }; auto lambda_factory [](int i, double d) { return new Widget(i, d); }; auto bad_factory [](int i) - int* { return new int(i); }; int main() { auto* w1 create_productWidget(lambda_factory, 42, 3.14); // OK // auto* w2 create_productWidget(bad_factory, 42); // 编译错误bad_factory的返回类型是int*不能转换为Widget* }避坑技巧完美转发注意我们在requires表达式和函数模板中都使用了std::forward。这在概念定义中很重要因为它能正确检查工厂是否支持右值引用参数确保约束的准确性。requires表达式中的参数声明方式决定了检查的严格程度。-约束的灵活性这里我们用了std::convertible_toProduct*而不是std::same_asProduct*。这给了工厂函数更大的灵活性工厂可以返回Product*、std::unique_ptrProduct如果可转换或其他智能指针。根据你的设计意图选择合适的约束。4.3 示例三实现一个“可哈希”的概念模拟std::hashC标准库为很多类型特化了std::hash但如果你想为自己定义的类型集合提供一个通用的“可哈希”概念可以这样做#include functional #include type_traits // 一个检查是否特化了 std::hash 的简单概念 templatetypename T concept StdHashable requires(const T key) { { std::hashT{}(key) } - std::convertible_tostd::size_t; }; // 一个更宽松的“可哈希”概念允许任何名为“hash”的函数对象 templatetypename T concept Hashable requires(const T key) { // 尝试使用 std::hash { std::hashT{}(key) } - std::convertible_tostd::size_t; } || requires(const T key) { // 使用逻辑或如果上面不满足则尝试下面的 // 检查是否存在一个可调用的 hash 对象可能是ADL找到的 { hash(key) } - std::convertible_tostd::size_t; // 注意这里调用的是 hash(key)不是 std::hash }; // 一个使用该概念的模板容器简化版 templateHashable Key, typename Value class MyHashMap { // ... 实现细节 public: std::size_t get_bucket(const Key key) const { if constexpr (StdHashableKey) { return std::hashKey{}(key) % bucket_count_; } else { // 假设存在一个自定义的 hash 函数 return hash(key) % bucket_count_; } } private: std::size_t bucket_count_; }; // 自定义类型和哈希函数 struct MyType { int id; }; std::size_t hash(const MyType mt) { return std::hashint{}(mt.id); } int main() { static_assert(StdHashableint); // true static_assert(!StdHashableMyType); // true除非我们特化了 std::hashMyType static_assert(HashableMyType); // true因为我们的自定义 hash 函数存在 MyHashMapint, std::string map1; // OKint是StdHashable MyHashMapMyType, std::string map2; // OKMyType是Hashable通过自定义hash }这个例子展示了高级技巧概念中的逻辑或||Hashable概念的定义使用了||运算符。这意味着一个类型只要满足两组需求中的任意一组就被认为是Hashable。这极大地增加了概念的灵活性和适用性。if constexpr与概念协作在模板实现中我们可以利用if constexpr和概念检查为满足不同约束的类型提供不同的实现路径。这比传统的标签分发tag dispatching更清晰。ADL的利用在第二个requires中{ hash(key) }会进行依赖于实参的查找ADL。这意味着如果为MyType在关联的命名空间定义了hash函数它就能被找到并满足约束。这是一种非常强大的、符合C惯用法的设计模式。5. 常见问题、编译错误排查与性能考量即使理解了语法在实际使用requires时你依然会遇到一些棘手的编译错误和设计抉择。这里我整理了一份“避坑清单”。5.1 编译错误诊断指南当你的模板因为约束不满足而编译失败时现代编译器如GCC 10, Clang 10, MSVC 2019 16.3通常会给出相当清晰的错误信息。但有时信息依然很冗长。学会快速定位是关键。典型错误1约束不满足error: no matching function for call to ‘my_sort(std::listint)’ note: candidate template ignored: constraints not satisfied [with Rng std::listint] note: because ‘std::listint’ does not satisfy ‘SortableRange’ note: because ‘std::ranges::random_access_rangestd::listint’ evaluated to false诊断错误信息是自顶向下的。首先告诉你哪个调用失败了然后指出哪个模板被忽略最后逐层告诉你哪个概念评估为false。从最后一行看起往往是最根本的原因。这里明确指出了std::ranges::random_access_range不满足。典型错误2requires表达式内的语法错误templatetypename T concept C requires(T p) { p-foo(); // 假设T可能是指针也可能不是 }; // 如果T是intp-foo()就是无效表达式但概念C会简单地评估为false不会报错。 // 除非... 这个requires表达式不在模板声明中 // requires { p-foo(); } // 如果T是int这里直接编译错误诊断关键规则requires表达式只有在用于声明一个带约束的模板实体如模板函数、模板类时其中的替换失败才会使表达式结果为false。如果requires表达式单独出现例如在非模板上下文中其中的无效表达式会导致硬编译错误。务必确保你的requires表达式逻辑是自洽的。典型错误3-后跟了具体类型而不是概念templatetypename T concept ReturnsInt requires(T func) { { func() } - int; // 错误‘int’不是一个概念 };修正templatetypename T concept ReturnsInt requires(T func) { { func() } - std::same_asint; // 正确 // 或者 { func() } - std::convertible_toint; };5.2 性能与设计权衡requires会影响编译速度吗会但通常是正向的。requires表达式在编译期进行评估增加了编译时的计算量。然而它通过阻止无效的模板实例化避免了大量更深层次、更耗时的实例化错误。总体来看合理使用概念和requires通常能提升大型项目的编译效率因为错误在更早、更廉价的阶段被捕获。应该定义很多细粒度的概念吗这是一个设计问题。我的经验是优先使用标准概念concepts和ranges头文件提供了大量精心设计的概念如std::integral,std::invocable,std::ranges::range等。尽量复用它们。定义有语义的概念不要只为了一两个函数就定义概念。概念应该对应一个抽象的、可复用的语义需求比如EqualityComparable,Serializer,Factory。如果一个约束只在一个地方用到考虑使用requires子句中的即席约束requires requires(...){...}或std::enable_if的现代替代品std::enable_if_t如果概念太重量级。组合优于继承像示例中的Hashable一样通过逻辑运算符,||,!组合现有概念来创建新概念而不是从头重写。requires子句放在哪里可以在模板参数列表后、函数返回类型前也可以在函数声明的末尾C20起。风格上没有绝对的对错但社区有一些趋势// 风格A前置传统清晰地将约束与模板参数关联 templatetypename T requires AddableT auto add(T a, T b); // 风格B后置类似尾返回类型可能更易读尤其是返回类型复杂时 templatetypename T auto add(T a, T b) requires AddableT; // 风格C简写形式最简洁但只能用于类型参数 templateAddable T auto add(T a, T b);我个人的习惯是对于简单的约束用简写形式对于复杂的、由多个概念组合的约束使用前置requires子句让约束一目了然。5.3 一个综合调试案例假设你写了如下代码但编译不通过templatetypename Container concept HasReserve requires(Container c, std::size_t n) { c.reserve(n); }; templateHasReserve Container void prepare_container(Container c, std::size_t size) { c.reserve(size); } std::vectorint vec; std::listint lst; prepare_container(vec, 100); // 期望成功 prepare_container(lst, 100); // 期望失败因为list没有reserve但编译器对prepare_container(lst, 100)报了一堆你看不懂的错误。排查步骤检查概念定义HasReserve要求c.reserve(n)是合法表达式。对于std::listint这显然不合法所以概念应该返回false。检查编译器版本确保你的编译器完全支持C20的Concepts。早期版本的支持可能不完整。简化测试写一个简单的测试来隔离问题static_assert(HasReservestd::vectorint); // 应该通过 static_assert(!HasReservestd::listint); // 应该通过如果static_assert失败说明你的概念定义有问题或者编译器有bug。检查requires参数类型我们用了Container c。如果reserve是一个非常量成员函数通常是而传入的是一个const Container那么约束会失败。但在我们的例子中prepare_container接收的是非const引用所以没问题。查看完整错误信息仔细阅读编译器输出的第一条错误之后的“note”部分。它应该会指出HasReservestd::listint评估为false。如果错误信息指向模板内部说明约束没有正确生效可能是语法错误导致约束未被编译器识别为约束。绝大多数情况下通过static_assert测试概念本身就能快速定位问题是出在概念定义上还是出在模板的使用上。requires表达式是C20赋予我们的强大工具它将模板元编程从“代码魔术”变成了“工程规范”。初学时可能会觉得语法有些古怪尤其是requires requires和-后面的类型约束。但一旦你习惯了这种声明式的约束描述方式就再也回不去了。它带来的代码清晰度、错误信息友好度和设计严谨性的提升是巨大的。开始在你的项目中尝试使用它吧从小处着手比如用它替换掉一两个旧的std::enable_if你会立刻感受到它的美妙之处。