尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C++20概念与约束进阶:组合约束、requires表达式与实战应用

C++20概念与约束进阶:组合约束、requires表达式与实战应用 1. 项目概述从“能用”到“好用”的模板编程革命如果你写过C模板尤其是稍微复杂一点的泛型代码大概率经历过这样的痛苦一个编译错误抛出来报错信息能滚动好几屏核心问题却藏在模板实例化栈的最深处像大海捞针。或者你精心设计了一个模板接口希望使用者传入“可迭代”的类型但因为没有编译期的强制约定用户传了个整数进来错误直到链接阶段甚至运行时才诡异出现。C20引入的“概念与约束”就是为了终结这种混沌状态。它不是什么锦上添花的新语法糖而是一次对模板编程范式的根本性升级让泛型代码从“理论上能工作”变得“意图清晰、安全可控”。简单来说概念是对一组要求的命名比如“可比较的”、“可默认构造的”。约束则是这些要求的具体布尔表达式用于在编译时校验模板参数。你可以把它们理解为给模板参数设立的“入职标准”。在C20之前我们只能用std::enable_if和复杂的SFINAE技巧来模拟类似功能代码晦涩难懂被戏称为“模板黑魔法”。现在有了语言级别的直接支持我们可以用更直观、更强大的方式来表达“这个模板只接受满足这些条件的类型”。这篇文章是“C20概念与约束”系列的第三篇我们将深入探讨约束的进阶用法。如果你已经了解了基础的概念定义和requires子句那么接下来我们将一起解锁如何组合约束、编写精细化的要求序列以及如何在实际的类模板偏特化、编译期分派等场景中优雅地运用这些工具写出既健壮又易于理解的现代C代码。2. 约束的组合与逻辑运算构建精确的“类型过滤器”单一的概念往往不足以描述复杂的需求。在实际开发中我们经常需要表达“既是A又是B”或者“是A或者是B”这样的复合条件。C20的约束天然支持逻辑运算这让构建精确的类型过滤器变得异常简单。2.1 合取约束用表达“且”关系合取约束通过运算符实现只有所有连接的约束都满足时整体约束才满足。它的求值是短路求值的这不仅是性能优化更是避免编译错误的关键。#include concepts #include type_traits // 基础概念是整数 templatetypename T concept Integral std::is_integral_vT; // 复合概念是有符号整数 templatetypename T concept SignedIntegral IntegralT std::is_signed_vT; // 另一个复合概念是小端存储的有符号整数假设我们有自己的 traits templatetypename T concept SignedLittleEndianIntegral SignedIntegralT requires { requires std::is_same_vstd::endian, std::little_endian; // 简化示例实际需更严谨 }; void process_signed(SignedIntegral auto val) { // 这里可以安全地假设 val 是有符号整数 if (val 0) { // 处理负数 } } process_signed(42); // OK: int 是有符号整数 process_signed(42u); // 错误未满足关联约束因为 unsigned int 不满足 std::is_signed_v实操心得短路求值的妙用短路求值意味着如果左侧的约束不满足编译器根本不会去尝试计算右侧的约束。这可以防止右侧约束中的表达式在类型不匹配时导致硬编译错误即非SFINAE友好错误。例如templatetypename T concept HasTypeMember requires { typename T::internal_type; // 检查嵌套类型 }; templatetypename T concept MyComplexConcept std::is_integral_vT HasTypeMemberT; struct BadType { /* 没有 internal_type */ }; // 尝试使用 MyComplexConceptBadType 进行约束检查 // 由于 BadType 不是整数std::is_integral_vBadType 为 false // 因此短路求值发生编译器不会去计算 HasTypeMemberBadType从而避免了因访问不存在的 internal_type 而导致的编译错误。这个特性让我们可以安全地组合约束而不用担心右侧约束中的表达式会因为左侧类型不匹配而“爆炸”。2.2 析取约束用||表达“或”关系析取约束通过||运算符实现只要任意一个连接的约束满足整体约束即满足。它同样支持短路求值。#include concepts // 概念是数值类型整数或浮点数 templatetypename T concept Number std::integralT || std::floating_pointT; templateNumber T T square(T x) { return x * x; } auto result1 square(5); // OK, int 满足 std::integral auto result2 square(3.14); // OK, double 满足 std::floating_point // auto result3 square(hello); // 错误未满足关联约束一个关键陷阱析取约束中的“病态”表达式析取约束的短路求值同样重要。考虑以下情况struct Widget { // 没有静态成员 value }; templatetypename T constexpr bool has_value() { return T::value; } // 如果T没有::value此函数体实例化时会出错 templatetypename T concept Tricky (sizeof(T) 4) || has_valueT(); Widget w; constexpr bool test TrickyWidget; // 这行能通过编译吗答案是能。因为sizeof(Widget)很可能大于4取决于其成员左侧约束(sizeof(T) 4)被评估为true。由于是||运算短路求值发生编译器根本不会去实例化并计算has_valueWidget()。因此即使has_valueWidget()的函数体在实例化时会导致错误因为Widget::value不存在这个错误也不会被触发TrickyWidget的结果就是true。注意has_valueT()的声明本身是有效的只有当我们尝试在TrickyWidget为false的路径上实例化它的函数体时错误才会发生。短路求值保护了我们。这展示了约束求值与模板实例化的一个细微差别约束检查只进行“立即语境”下的替换和检查。2.3 约束的否定与补充除了和||我们还可以使用!运算符来对约束取反。templatetypename T concept NotPointer !std::is_pointer_vT; templateNotPointer T void handle(T val) { // 确保 val 不是指针类型 } handle(10); // OK handle(new int(10)); // 错误未满足关联约束更常见的用法是结合标准概念库定义互补的概念templatetypename T concept UnsignedIntegral std::integralT !std::signed_integralT; void f(std::signed_integral auto); // 处理有符号整数 void f(UnsignedIntegral auto); // 处理无符号整数 // void f(auto); // 处理其他类型如果需要这种定义方式清晰地将类型空间进行了划分使得重载决议的意图一目了然。3.requires表达式的深度解析定义约束的“显微镜”requires表达式是定义概念时最强大、最灵活的工具。它不是一个语句而是一个可以在编译期产生布尔值的表达式用于描述一系列针对模板参数的语法要求。理解它的四种形式是掌握高级约束的关键。3.1 简单要求检查表达式是否合法简单要求是最直接的形式它断言某个表达式是良构的well-formed。编译器只检查语法的正确性并不对表达式进行求值。templatetypename T concept Streamable requires(T obj, std::ostream os) { os obj; // 要求存在 operator(std::ostream, const T) 或类似重载 }; templatetypename T concept HasSize requires(T cont) { cont.size(); // 要求 cont 有一个名为 size 的成员函数可以无参调用 { cont.size() } - std::convertible_tostd::size_t; // 进阶我们还关心返回类型这是复合要求见下文 }; struct MyVec { std::size_t size() const { return data_.size(); } private: std::vectorint data_; }; static_assert(HasSizeMyVec); // 通过 static_assert(HasSizeint); // 失败int 没有 .size() 成员注意事项ADL实参依赖查找在简单要求中的作用在检查os obj时编译器会进行ADL。这意味着operator可以定义在T所在的命名空间而不一定是全局或std命名空间。这完美匹配了C中流操作符的常见定义方式。namespace mylib { struct CustomData { int id; }; std::ostream operator(std::ostream os, const CustomData d) { return os CustomData{ d.id }; } } static_assert(Streamablemylib::CustomData); // 通过ADL 找到了 mylib 中的 operator3.2 类型要求检查嵌套类型与别名类型要求使用typename关键字用于验证某个指名的类型是否存在且有效。templatetypename T concept HasValueType requires { typename T::value_type; // 检查 T 是否有名为 value_type 的嵌套类型 }; templatetypename T concept IsSpecializationOfVector requires { typename std::vectorT; // 检查 std::vectorT 是否是一个有效的类型即 vector 模板能接受 T // 注意这并不检查它是否是某个已存在的特化只检查语法上是否可实例化。 }; // 一个更实际的例子检查容器特性 templatetypename C concept Container requires { typename C::value_type; typename C::reference; typename C::const_reference; typename C::iterator; typename C::const_iterator; typename C::difference_type; typename C::size_type; // 还可以加上迭代器有效性等约束 };实操心得typename在requires表达式中的特殊性在普通的模板代码中对于待决名如T::value_type我们必须使用typename来告诉编译器这是一个类型。在requires表达式的类型要求中typename已经是语法的一部分所以typename T::value_type整体作为一个要求。但在requires表达式的其他位置如简单要求中如果出现待决名且需要指明为类型仍然可能需要typename。templatetypename T void ambiguous() { // 在函数体内这是错误的因为编译器不知道 T::SubType 是类型还是静态成员 // T::SubType * ptr; // 正确 typename T::SubType * ptr; } templatetypename T concept CheckType requires { typename T::SubType; // 正确类型要求语法 // T::SubType; // 如果作为简单要求这会被解释为表达式检查 T::SubType 这个“东西”是否存在可能是静态成员。 };3.3 复合要求检查表达式及其属性复合要求是功能最全面的形式它允许我们同时检查表达式的有效性、是否不抛出异常(noexcept)、以及返回类型是否满足某个约束。其语法为{ expression } noexcept(可选) - 类型约束;#include concepts #include memory templatetypename T concept Allocatable requires(std::size_t n) { { new T[n] } - std::same_asT*; // 要求 new T[n] 返回 T* { delete[] std::declvalT*() } noexcept; // 要求数组删除表达式存在且不抛异常 }; templatetypename Ptr concept SmartPointer requires(Ptr p) { { *p } - std::same_astypename Ptr::element_type; // 解引用返回对应引用 { p.reset() } noexcept; // reset 函数存在且不抛异常 { p.get() } - std::same_astypename Ptr::element_type*; // get() 返回原始指针 }; // 测试 static_assert(Allocatableint); static_assert(SmartPointerstd::unique_ptrint); static_assert(SmartPointerstd::shared_ptrint);关键点解析返回类型约束-后面的部分是一个类型约束它通常是一个概念。编译器会做两件事计算decltype((expression))。注意这里有两层括号是为了保证对于x这样的标识符表达式decltype((x))得到的是引用类型符合表达式值类别而decltype(x)得到的是声明类型。检查decltype((expression))是否满足-后面概念所要求的约束。例如{ *p } - std::same_astypename Ptr::element_type;会检查decltype((*p))是否与typename Ptr::element_type是同一类型。这比简单要求*p有效要严格得多。关于noexcept的细节noexcept检查的是表达式是否被声明为noexcept或者编译器是否可以推断它为不抛出异常。对于析构函数这是一个特别有用的检查点因为标准库很多组件如容器都依赖于析构函数不抛异常来保证异常安全。templatetypename T concept NothrowDestructible requires(T* ptr) { { ptr-~T() } noexcept; }; struct Safe { ~Safe() noexcept default; // 明确声明不抛异常 }; struct Unsafe { ~Unsafe() {} // 可能抛异常尽管不是好习惯 }; static_assert(NothrowDestructibleSafe); // 通过 static_assert(!NothrowDestructibleUnsafe); // 失败3.4 嵌套要求在requires内再使用requires嵌套要求用于在requires表达式内部基于局部形参施加额外的约束。它以requires开头后跟一个约束表达式。templatetypename T concept ArithmeticWithSelf requires(T a, T b) { a b; // 简单要求加法有效 a - b; // 简单要求减法有效 requires std::is_arithmetic_vdecltype(a b); // 嵌套要求加法的结果类型是算术类型 // 等价于requires requires { std::is_arithmetic_vdecltype(a b); } }; templatetypename Iter concept RandomAccessIterator requires(Iter it, std::ptrdiff_t n) { // ... 其他随机访问迭代器要求如 it[n], itn 等 requires requires { // 嵌套的 requires 表达式 // 要求迭代器的值类型是可交换的一个假设概念 requires std::is_swappable_vtypename Iter::value_type; }; };为什么会有requires requires这是初学者最容易困惑的地方。第一个requires是**requires子句的关键字它引入了一个约束条件。第二个requires是requires表达式**的关键字它开始了一个产生布尔值的编译期表达式。所以requires requires { ... }的意思是“要求requires子句后面的requires表达式求值为真”。templatetypename T requires requires (T x) { x.serialize(); } // 第一个requires是子句第二个是表达式 void save(T obj);这个函数模板要求类型T必须满足约束存在一个无参的、名为serialize的成员函数或可通过ADL找到的自由函数。4. 约束在模板特化与重载决议中的实战应用约束最强大的威力体现在模板特化和函数重载上。它提供了比SFINAE更清晰、更强大的编译期多态机制。4.1 类模板的偏特化选择类模板的偏特化现在可以直接用概念来约束使得特化选择的条件声明变得直观。#include iostream #include concepts #include ranges // 基础模板 templatetypename T struct DataSerializer { static void serialize(const T) { std::cout 通用序列化可能不支持\n; } }; // 偏特化1针对有 to_json 成员函数的类型 templatetypename T concept HasToJson requires(const T obj) { { obj.to_json() } - std::convertible_tostd::string; }; templateHasToJson T struct DataSerializerT { static void serialize(const T obj) { std::cout 使用 to_json() 序列化: obj.to_json() \n; } }; // 偏特化2针对标准范围容器/视图 templatestd::ranges::range R struct DataSerializerR { static void serialize(const R range) { std::cout 序列化范围大小: std::ranges::size(range) \n; for (const auto elem : range) { DataSerializerstd::ranges::range_value_tR::serialize(elem); } } }; // 测试类型 struct MyData { std::string to_json() const { return R({name: test}); } }; int main() { DataSerializerint::serialize(42); // 通用序列化 DataSerializerMyData::serialize(MyData{}); // 使用 to_json() 序列化 DataSerializerstd::vectorint::serialize({1,2,3}); // 序列化范围 }在这个例子中编译器会根据传入的模板参数T选择最匹配的DataSerializer特化版本。约束提供了清晰的选择标准。4.2 函数模板的重载与约束排序当多个函数模板重载时编译器会根据约束的“满意度”来选择最特化的版本。约束更严格即要求更多的版本是更特化的。#include concepts #include iostream // 版本1最通用任何类型都可以无约束 void process(auto val) { std::cout 处理通用类型\n; } // 版本2约束为整数类型 void process(std::integral auto val) { std::cout 处理整数: val \n; } // 版本3约束为浮点数类型 void process(std::floating_point auto val) { std::cout 处理浮点数: val \n; } // 版本4更严格的约束既是整数又在特定范围内通过合取约束 templatetypename T concept SmallInt std::integralT sizeof(T) 2; void process(SmallInt auto val) { std::cout 处理小整数 (size 2): static_castint(val) \n; } int main() { process(hello); // 匹配版本1通用类型 process(1000); // 匹配版本2整数 (int) process(3.14); // 匹配版本3浮点数 process(static_castshort(5)); // 匹配版本4SmallInt (short 是整数且sizeof2) process(static_castlong(5)); // 匹配版本2整数 (long)但不满足SmallInt(sizeof2) }重载决议规则 编译器会尝试所有候选函数。对于模板它会检查约束是否满足。在所有可行的候选函数中编译器选择“最受约束”的那个。如果两个模板的约束无法区分谁更特化即互不包含则会导致歧义编译错误。templatetypename T concept A sizeof(T) 4; templatetypename T concept B alignof(T) 4; void foo(A auto); // 版本A void foo(B auto); // 版本B // foo(int{}); // 错误歧义int 可能同时满足 A 和 B但编译器无法判断 A 和 B 哪个更特化。4.3 在可变参数模板中使用约束约束也可以应用于可变参数模板实现对参数包的灵活约束。// 要求所有参数类型都相同 templatestd::same_asint... Args int sum_all(Args... args) { return (args ...); } // 要求参数包中至少有一个参数 templatetypename First, typename... Rest requires (sizeof...(Rest) 0) // 约束Rest包不能为空 auto average(First first, Rest... rest) { return (first ... rest) / (1.0 sizeof...(Rest)); } // 使用折叠表达式检查参数包中所有类型是否都满足某个概念 templatetypename... Ts concept AllIntegral (std::integralTs ...); // C17 折叠表达式 概念 templatetypename... Args requires AllIntegralArgs... auto integral_sum(Args... args) { return (args ...); } auto s1 sum_all(1, 2, 3); // OK // auto s2 sum_all(1, 2.0); // 错误参数类型不完全相同int vs double auto a average(1, 2, 3); // OK // auto b average(1); // 错误不满足约束 (sizeof...(Rest) 0) auto is integral_sum(1, 2u, static_castshort(3)); // OK // auto fs integral_sum(1, 2.0); // 错误double 不满足 std::integral5. 高级技巧与实战避坑指南掌握了基本语法和常见模式后我们来看看一些进阶技巧和实际开发中容易踩的坑。5.1 约束的原子性与分解一个复杂的约束应该被分解成多个小的、可复用的概念。这提高了代码的可读性和可维护性。反面教材templatetypename T concept MessyConcept requires(T t, std::ostream os, std::istream is) { typename T::value_type; typename T::iterator; { t.begin() } - std::same_astypename T::iterator; { t.end() } - std::same_astypename T::iterator; { os t }; { is t } - std::same_asstd::istream; requires std::regulartypename T::value_type; };优化后// 分解为多个原子概念 templatetypename T concept HasValueType requires { typename T::value_type; }; templatetypename T concept HasIterator requires(T t) { typename T::iterator; { t.begin() } - std::same_astypename T::iterator; { t.end() } - std::same_astypename T::iterator; }; templatetypename T concept Streamable requires(T obj, std::ostream os) { os obj; }; templatetypename T concept InputStreamable requires(T obj, std::istream is) { { is obj } - std::same_asstd::istream; }; templatetypename T concept RegularValueType HasValueTypeT std::regulartypename T::value_type; // 组合概念 templatetypename T concept CleanContainer HasValueTypeT HasIteratorT StreamableT InputStreamableT RegularValueTypeT;5.2 约束与auto的协同概念可以用于约束auto推导的类型这在泛型lambda、简写函数模板和变量声明中非常有用。// 泛型lambda约束参数类型 auto print_integral [](std::integral auto x) { std::cout Integral: x \n; }; // 简写函数模板 std::integral auto add(std::integral auto a, std::integral auto b) { return a b; // 返回类型自动推导且受 integral 约束 } // 约束变量类型C20 std::floating_point auto pi 3.14159; // std::integral auto bad 3.14; // 错误推导类型 double 不满足 integral 约束 // 约束非类型模板参数 templatestd::integral auto N struct FixedSizeArray { std::arrayint, N data; }; FixedSizeArray10 arr1; // OK // FixedSizeArray5.0 arr2; // 错误5.0 不是整数类型5.3 约束的惰性求值与SFINAE友好性约束检查是SFINAE友好的。如果替换模板参数到约束表达式导致硬错误不在“立即语境”中那么该约束被认为“未满足”而不是导致编译失败。这继承了传统SFINAE的优点。templatetypename T struct DependentFalse : std::false_type {}; templatetypename T concept Tricky requires { // 如果 T 没有 ::type那么 typename T::type 会导致替换失败但这是SFINAE友好的。 // 然而如果 DependentFalseT::value 被实例化且特化导致 false这没问题。 // 但如果 DependentFalseT::value 的实例化本身导致错误比如访问私有成员则可能不是SFINAE友好。 requires !DependentFalseT::value; // 这通常是安全的因为 DependentFalseT::value 是 false。 }; // 一个更微妙的例子依赖的 sizeof templatetypename T concept HasSizeofFour (sizeof(T) 4); // 对于不完整的类型sizeof 是病态的硬错误但概念检查发生在实例化之前。 struct IncompleteType; // 前向声明不完整类型 // static_assert(!HasSizeofFourIncompleteType); // 这行会直接导致编译错误因为 sizeof 不完整类型是病态的。 // 因此在定义概念时要避免对模板参数进行可能“病态”的操作除非你能确保它在检查时是完整的。重要经验在编写概念时尽量确保约束表达式中的操作对于任何潜在的类型替换都是“良构的检查”而不是“必然导致实例化错误”。对于可能涉及模板实例化的检查如DependentFalseT::value要确保其模板定义对任何T都是有效的。5.4 调试约束查看约束是否满足当约束不满足时编译器错误信息通常比旧的SFINAE错误更清晰但有时仍需要手动检查。可以使用static_assert或直接使用概念作为布尔常量。templatetypename T concept MyConcept requires(T t) { t.foo(); }; struct Good { void foo() {} }; struct Bad {}; static_assert(MyConceptGood); // 通过 static_assert(!MyConceptBad); // 通过 // 或者在编译时打印 std::cout std::boolalpha MyConceptGood \n; // true std::cout std::boolalpha MyConceptBad \n; // false // 更复杂的调试查看 requires 表达式中哪部分失败了 templatetypename T void debug_concept() { constexpr bool has_foo requires(T t) { t.foo(); }; constexpr bool has_bar requires(T t) { t.bar(); }; std::cout has_foo: has_foo , has_bar: has_bar \n; }5.5 性能与编译时开销概念和约束是在编译时进行求值的它们会增加编译器的负担。然而与传统的SFINAE相比它们通常能产生更清晰、更易于编译器优化的中间表示。过度复杂或递归的约束可能导致编译时间显著增加。一个实用的建议是保持概念简洁定义小而专一的概念然后组合它们。避免深度嵌套的requires表达式过于复杂的嵌套requires会影响编译器的解析效率。重用标准概念concepts和ranges头文件提供了大量预定义的概念如std::integral,std::movable,std::ranges::range等应优先使用它们。注意约束的评估顺序利用和||的短路求值将最廉价、最可能失败的检查放在前面。6. 从理论到实践一个完整的自定义容器适配器案例让我们设计一个SafeVector它包装std::vector但通过概念约束其元素类型确保元素是可默认构造、可拷贝赋值且可交换的。同时我们为满足std::semiregular即可默认构造、可拷贝/移动、可比较相等的元素类型提供额外的operator。#include vector #include concepts #include utility #include cassert // 基础元素要求 templatetypename T concept Element std::default_initializableT std::copy_assignableT std::swappableT; // 扩展要求半正则支持相等比较 templatetypename T concept SemiRegularElement ElementT std::semiregularT; templateElement T class SafeVector { private: std::vectorT data_; public: using value_type T; using size_type typename std::vectorT::size_type; SafeVector() default; explicit SafeVector(size_type count) : data_(count) {} // 依赖 T 可默认构造 // 只有元素可拷贝构造时这个构造函数才启用 templatetypename... Args requires std::constructible_fromT, Args... SafeVector(size_type count, Args... args) : data_(count, std::forwardArgs(args)...) {} T operator[](size_type pos) { assert(pos size()); return data_[pos]; } const T operator[](size_type pos) const { assert(pos size()); return data_[pos]; } size_type size() const noexcept { return data_.size(); } bool empty() const noexcept { return data_.empty(); } // 只有元素类型满足 SemiRegularElement 时才提供相等比较 friend bool operator(const SafeVector, const SafeVector) requires SemiRegularElementT default; }; // 测试 struct BasicType { int x; BasicType() default; BasicType operator(const BasicType) default; // 没有 operator }; static_assert(ElementBasicType); static_assert(!SemiRegularElementBasicType); // 缺少 operator struct AdvancedType { int x; AdvancedType() default; AdvancedType operator(const AdvancedType) default; bool operator(const AdvancedType) const default; }; static_assert(SemiRegularElementAdvancedType); int main() { SafeVectorBasicType sv1(5); // OK可默认构造 // SafeVectorBasicType sv2(5, BasicType{1}); // 错误BasicType 不可拷贝构造假设我们没定义 // sv1 sv1; // 错误BasicType 不满足 SemiRegularElement无 operator SafeVectorAdvancedType sv3(5); SafeVectorAdvancedType sv4(5, AdvancedType{42}); bool equal (sv3 sv4); // OKAdvancedType 满足 SemiRegularElement }这个案例展示了如何将约束用于类模板的模板参数(templateElement T)成员函数模板的约束(带requires的构造函数)友元函数的约束(条件式默认的operator) 通过约束我们清晰地表达了SafeVector对其元素类型的要求并在接口层面提供了条件化的功能代码既安全又意图明确。约束的进阶用法远不止于此它们还能与constexpr if、标签分发等模式结合在编译期构建出极其灵活而健壮的泛型系统。核心思想始终是用代码表达意图让编译器为你验证。当你下次再设计模板时不妨先问问自己“我对这个模板参数有什么要求”然后用概念和约束把它写下来。
返回列表