ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C++20 requires表达式详解:四种核心需求与工程实践指南

C++20 requires表达式详解:四种核心需求与工程实践指南

1. 项目概述:为什么C++20的requires值得你花时间?

如果你在C++模板编程里摸爬滚打过,肯定遇到过这样的场景:写了一个模板函数,希望它只对某些“合格”的类型生效。比如,你写了个add函数,理想情况下它应该只接受支持+操作的类型。在C++20之前,我们得靠SFINAE、std::enable_if这些“黑魔法”来实现,代码写出来又长又绕,像天书一样。我自己就经常在调试一长串typename std::enable_if<...>::type时感到头疼。

C++20引入的Concepts(概念)和其中的核心语法requires,就是为了终结这种混乱而来的。它不是什么高深莫测的新范式,而是一套让你能清晰、直观地表达模板参数约束的语法工具。简单说,requires让你能告诉编译器:“我这个模板,只欢迎满足这些条件的类型来用。” 代码瞬间就从“魔术”变成了“说明书”。

这篇文章,就是为你彻底拆解requires的用法。无论你是刚接触C++20新特性的新手,还是想从SFINAE的泥潭里解脱出来的老手,都能在这里找到答案。我会用大量你能直接抄到项目里的示例,讲清楚requires表达式的四种核心需求,以及如何用它写出更健壮、更易读的模板代码。你会发现,用好requires,你的模板代码将不再让人望而生畏。

2. requires表达式核心语法与设计思路拆解

在深入细节之前,我们必须先建立正确的认知:requires在C++20里扮演了两个角色,它们看起来很相似,但用途截然不同,初学者特别容易混淆。

第一个角色是requires子句(requires-clause)。它用在模板声明之后,函数签名之前,用来为整个模板附加约束条件。你可以把它想象成模板的“入职要求”。

template<typename T> requires std::integral<T> // 这是一个requires子句 T increment(T value) { return value + 1; }

上面的代码意思是:increment函数模板只接受满足std::integral(整数类型)这个概念的T

第二个角色,也是本文的重点,是requires表达式(requires-expression)。它是一个表达式,会产生一个布尔类型的编译期常量(prvalue)。这个表达式本身用于定义约束条件。它通常出现在概念(concept)的定义中,或者直接用在requires子句里(形成所谓的“即席约束”)。

// 这是一个requires表达式,它定义了一个概念 template<typename T> concept Addable = requires(T a, T b) { a + b; // 要求表达式 a+b 是合法的 }; // 使用上面定义的概念作为requires子句的约束 template<typename T> requires Addable<T> // 这里使用的是概念,其背后是requires表达式 T add(T a, T b) { return a + b; } // 更直接的“即席约束”:requires子句里直接嵌入一个requires表达式 template<typename 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表达式内部的语句永远不会被真正执行,它们只在编译期被分析语法和类型。

这种设计带来了巨大的优势:

  1. 编译期错误前移:不符合约束的代码在模板被尝试使用的那一刻就会报错,错误信息通常会指向约束失败的具体原因,而不是深入到模板内部一团糟的实例化错误中。
  2. 接口即文档:模板的约束条件清晰写在声明处,任何人一看就知道这个模板对参数有什么要求,大大提升了代码的可读性和可维护性。
  3. 简化重载与特化:编译器可以依据约束条件更精确地选择匹配的模板,让基于类型的函数重载和模板特化变得更加直观和强大。

3. requires表达式的四种核心需求详解

一个requires表达式的主体由一系列“需求”构成。C++20标准定义了四种基本的需求类型:简单需求、类型需求、复合需求和嵌套需求。理解这四种需求,你就掌握了requires表达式的全部武器库。

3.1 简单需求:检查表达式是否合法

简单需求是最直接的一种。它的形式就是一条以分号结尾的表达式语句。它断言:这个表达式必须是格式正确的(well-formed)。编译器只检查语法和类型是否有效,不关心表达式的值。

template<typename T> concept Streamable = requires(T obj, std::ostream& os) { os << obj; // 简单需求:检查 obj 是否支持通过 << 输出到流 }; template<typename 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关键字,后跟一个类型名称。它断言:这个类型名称必须代表一个有效的类型。这常用于检查类内部是否有特定的嵌套类型(如迭代器、值类型等),或者某个模板特化是否产生一个合法类型。

template<typename T> concept HasValueType = requires { typename T::value_type; // 类型需求:检查 T 内部是否有名为 value_type 的类型 }; template<typename Container> concept IterableContainer = requires { typename Container::iterator; // 必须有迭代器类型 typename Container::const_iterator; // 必须有常量迭代器类型 typename std::iterator_traits<typename Container::iterator>::value_type; // 可以组合使用 }; // 检查模板特化是否有效 template<template<typename> class Template, typename Arg> concept ValidTemplate = requires { typename Template<Arg>; // 类型需求:检查 Template<Arg> 是否形成一个合法类型 };

实操心得:

  • 不必是完整类型:类型需求只要求该名称是一个类型,不要求它是完整类型(即不需要知道其大小和布局)。这对于进行元编程和 trait 检查非常有用。
  • 强大的模板检查工具typename Template<Arg>这种形式是检查一个类模板能否用特定参数实例化的利器,比旧的SFINAE技巧简洁得多。

3.3 复合需求:检查表达式的属性(返回类型、异常规范)

简单需求只检查“能否编译”,但有时我们还想知道更多:“这个表达式返回什么类型?”、“它保证不抛异常吗?”。复合需求就是干这个的。

复合需求将表达式用花括号{}括起来,后面可以可选地跟noexcept和/或-> type-constraint

  • { expression }: 检查表达式是否有效(同简单需求)。
  • { expression } noexcept: 检查表达式是否有效,并且该表达式不会抛出异常
  • { expression } -> type-constraint: 检查表达式是否有效,并且其结果的类型(decltype((expression)))满足指定的类型约束(type-constraint)。
  • { expression } noexcept -> type-constraint: 上述两者的结合。
template<typename T> concept Dereferenceable = requires(T ptr) { { *ptr } -> std::same_as<typename T::element_type&>; // 复合需求:解引用,且返回类型是 element_type& }; template<typename F, typename... Args> concept NonThrowingInvocable = requires(F&& func, Args&&... args) { { std::forward<F>(func)(std::forward<Args>(args)...) } noexcept; // 复合需求:可调用且不抛异常 }; template<typename T> concept ConvertibleToInt = requires(T val) { { val + 0 } -> std::convertible_to<int>; // 复合需求:表达式有效,且结果可转换为int };

关键细节与常见问题:

  1. ->后面跟的是约束,不是具体类型:你不能写-> int,而应该写-> std::same_as<int>-> std::convertible_to<int>std::same_as要求严格匹配,std::convertible_to允许隐式转换。这是新手最容易出错的地方。
  2. decltype((expression))的双重括号:标准规定,用于类型约束检查的类型是decltype((expression))。注意里面的双重括号!这确保了对于变量xdecltype((x))是引用类型(如果x是左值),从而能正确检查移动语义等场景。如果你写{x} -> std::same_as<T>,而xT&类型,这个约束会失败,因为decltype((x))T&,不是T。你可能需要std::same_as<T&>std::convertible_to<T>
  3. noexcept检查是严格的:它要求表达式是noexcept(true)的。如果表达式内调用的函数可能抛异常,即使当前上下文被try-catch包裹,该需求也无法满足。

3.4 嵌套需求:引入额外的布尔常量表达式约束

嵌套需求以requires开头,后跟一个布尔常量表达式。它用于在requires表达式内部引入更复杂的逻辑条件,这些条件通常依赖于前面检查中引入的或局部参数的类型。

template<typename T> concept SignedIntegral = requires { requires std::integral<T>; // 嵌套需求:首先必须是整数类型 requires std::is_signed_v<T>; // 嵌套需求:还必须是有符号的 // 也可以写成:requires std::integral<T> && std::is_signed_v<T>; }; template<typename T> concept SmartPointer = requires(T p) { *p; // 简单需求:可解引用 requires std::same_as<decltype(p == nullptr), bool>; // 嵌套需求:可与nullptr比较并返回bool requires std::constructible_from<T, std::nullptr_t>; // 嵌套需求:可从nullptr构造 }; template<typename Iter> concept RandomAccessIterator = requires(Iter it, int n) { it + n; it - n; // 简单需求 requires std::same_as<decltype(it[n]), typename std::iterator_traits<Iter>::reference>; // 嵌套需求:下标访问返回引用 requires requires { // 嵌套的requires表达式!检查迭代器差值类型可转换为ptrdiff_t { it - it } -> std::convertible_to<std::ptrdiff_t>; }; };

嵌套需求的核心价值

  • 逻辑组合:它允许你将多个约束用&&||等逻辑运算符组合起来,放在requires表达式内部,使概念的定义更加模块化和内聚。
  • 基于局部参数的复杂检查:嵌套需求中的表达式可以访问requires表达式引入的局部参数(如上面的p,it),从而进行更具体的、依赖于值的约束(尽管这些参数没有实际值,只有类型信息)。
  • 清晰的语义分组:将相关的约束放在一个嵌套需求里,可以提高概念定义的可读性。

4. 从理论到实践:综合应用示例与避坑指南

理解了基本部件,我们来看看如何用它们搭建坚固的模板约束。我会通过几个逐渐复杂的例子,展示requires在实际中的威力,并分享我踩过的坑。

4.1 示例一:构建一个“可排序范围”概念

假设我们想写一个泛型的sort算法,它应该能处理任何支持随机访问和元素比较的容器。我们可以定义一个SortableRange概念。

#include <concepts> #include <iterator> #include <ranges> template<typename Rng> concept SortableRange = std::ranges::random_access_range<Rng> && // 首先得是个随机访问范围 requires(Rng& range) { // 使用局部参数 range requires std::totally_ordered<std::ranges::range_value_t<Rng>>; // 嵌套需求:值类型必须全序 // 或者用复合需求检查具体的比较操作 { std::ranges::begin(range) < std::ranges::end(range) } -> std::convertible_to<bool>; // 复合需求:迭代器可比较(虽然random_access_range已隐含) // 检查是否可交换元素(对于某些排序算法很重要) requires requires(std::ranges::iterator_t<Rng> a, std::ranges::iterator_t<Rng> 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::vector<int> vec = {3,1,2}; my_sort(vec); // OK: vector满足SortableRange std::list<int> 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 示例二:约束一个“工厂函数”接口

我们想创建一个概念,约束那些可以像工厂一样,使用给定参数构造出对象的类型。这在实际的依赖注入或插件系统中很有用。

template<typename Factory, typename Product, typename... Args> concept FactoryFor = requires(Factory&& factory, Args&&... args) { // 复合需求:factory必须能用args...调用,且返回类型可转换为Product* { std::forward<Factory>(factory)(std::forward<Args>(args)...) } -> std::convertible_to<Product*>; // 可选:检查工厂是否不抛异常(对于关键组件很重要) // { std::forward<Factory>(factory)(std::forward<Args>(args)...) } noexcept -> std::convertible_to<Product*>; }; // 一个使用该概念的模板函数 template<typename Product, FactoryFor<Product> Factory, typename... Args> Product* create_product(Factory&& factory, Args&&... args) { // 这里可以添加日志、性能统计等横切关注点 return std::forward<Factory>(factory)(std::forward<Args>(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_product<Widget>(lambda_factory, 42, 3.14); // OK // auto* w2 = create_product<Widget>(bad_factory, 42); // 编译错误:bad_factory的返回类型是int*,不能转换为Widget* }

避坑技巧

  • 完美转发:注意我们在requires表达式和函数模板中都使用了std::forward。这在概念定义中很重要,因为它能正确检查工厂是否支持右值引用参数,确保约束的准确性。requires表达式中的参数声明方式决定了检查的严格程度。
  • ->约束的灵活性:这里我们用了std::convertible_to<Product*>而不是std::same_as<Product*>。这给了工厂函数更大的灵活性:工厂可以返回Product*std::unique_ptr<Product>(如果可转换)或其他智能指针。根据你的设计意图选择合适的约束。

4.3 示例三:实现一个“可哈希”的概念(模拟std::hash)

C++标准库为很多类型特化了std::hash,但如果你想为自己定义的类型集合提供一个通用的“可哈希”概念,可以这样做:

#include <functional> #include <type_traits> // 一个检查是否特化了 std::hash 的简单概念 template<typename T> concept StdHashable = requires(const T& key) { { std::hash<T>{}(key) } -> std::convertible_to<std::size_t>; }; // 一个更宽松的“可哈希”概念,允许任何名为“hash”的函数对象 template<typename T> concept Hashable = requires(const T& key) { // 尝试使用 std::hash { std::hash<T>{}(key) } -> std::convertible_to<std::size_t>; } || requires(const T& key) { // 使用逻辑或:如果上面不满足,则尝试下面的 // 检查是否存在一个可调用的 hash 对象(可能是ADL找到的) { hash(key) } -> std::convertible_to<std::size_t>; // 注意:这里调用的是 hash(key),不是 std::hash }; // 一个使用该概念的模板容器(简化版) template<Hashable Key, typename Value> class MyHashMap { // ... 实现细节 public: std::size_t get_bucket(const Key& key) const { if constexpr (StdHashable<Key>) { return std::hash<Key>{}(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::hash<int>{}(mt.id); } int main() { static_assert(StdHashable<int>); // true static_assert(!StdHashable<MyType>); // true,除非我们特化了 std::hash<MyType> static_assert(Hashable<MyType>); // true,因为我们的自定义 hash 函数存在 MyHashMap<int, std::string> map1; // OK,int是StdHashable MyHashMap<MyType, std::string> map2; // OK,MyType是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::list<int>&)’ note: candidate template ignored: constraints not satisfied [with Rng = std::list<int>] note: because ‘std::list<int>’ does not satisfy ‘SortableRange’ note: because ‘std::ranges::random_access_range<std::list<int>>’ evaluated to false

诊断:错误信息是自顶向下的。首先告诉你哪个调用失败了,然后指出哪个模板被忽略,最后逐层告诉你哪个概念评估为false。从最后一行看起,往往是最根本的原因。这里明确指出了std::ranges::random_access_range不满足。

典型错误2:requires表达式内的语法错误

template<typename T> concept C = requires(T p) { p->foo(); // 假设T可能是指针也可能不是 }; // 如果T是int,p->foo()就是无效表达式,但概念C会简单地评估为false,不会报错。 // 除非... 这个requires表达式不在模板声明中: // requires { p->foo(); } // 如果T是int,这里直接编译错误!

诊断关键规则requires表达式只有在用于声明一个带约束的模板实体(如模板函数、模板类)时,其中的替换失败才会使表达式结果为false。如果requires表达式单独出现(例如在非模板上下文中),其中的无效表达式会导致硬编译错误。务必确保你的requires表达式逻辑是自洽的。

典型错误3:->后跟了具体类型而不是概念

template<typename T> concept ReturnsInt = requires(T func) { { func() } -> int; // 错误!‘int’不是一个概念 };

修正

template<typename T> concept ReturnsInt = requires(T func) { { func() } -> std::same_as<int>; // 正确 // 或者 { func() } -> std::convertible_to<int>; };

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子句放在哪里?可以在模板参数列表后、函数返回类型前,也可以在函数声明的末尾(C++20起)。风格上没有绝对的对错,但社区有一些趋势:

// 风格A:前置(传统,清晰地将约束与模板参数关联) template<typename T> requires Addable<T> auto add(T a, T b); // 风格B:后置(类似尾返回类型,可能更易读,尤其是返回类型复杂时) template<typename T> auto add(T a, T b) requires Addable<T>; // 风格C:简写形式(最简洁,但只能用于类型参数) template<Addable T> auto add(T a, T b);

我个人的习惯是:对于简单的约束用简写形式,对于复杂的、由多个概念组合的约束,使用前置requires子句,让约束一目了然。

5.3 一个综合调试案例

假设你写了如下代码,但编译不通过:

template<typename Container> concept HasReserve = requires(Container& c, std::size_t n) { c.reserve(n); }; template<HasReserve Container> void prepare_container(Container& c, std::size_t size) { c.reserve(size); } std::vector<int> vec; std::list<int> lst; prepare_container(vec, 100); // 期望成功 prepare_container(lst, 100); // 期望失败,因为list没有reserve

但编译器对prepare_container(lst, 100)报了一堆你看不懂的错误。

排查步骤:

  1. 检查概念定义HasReserve要求c.reserve(n)是合法表达式。对于std::list<int>,这显然不合法,所以概念应该返回false
  2. 检查编译器版本:确保你的编译器完全支持C++20的Concepts。早期版本的支持可能不完整。
  3. 简化测试:写一个简单的测试来隔离问题:
    static_assert(HasReserve<std::vector<int>>); // 应该通过 static_assert(!HasReserve<std::list<int>>); // 应该通过
    如果static_assert失败,说明你的概念定义有问题,或者编译器有bug。
  4. 检查requires参数类型:我们用了Container& c。如果reserve是一个非常量成员函数(通常是),而传入的是一个const Container&,那么约束会失败。但在我们的例子中,prepare_container接收的是非const引用,所以没问题。
  5. 查看完整错误信息:仔细阅读编译器输出的第一条错误之后的“note”部分。它应该会指出HasReserve<std::list<int>>评估为false。如果错误信息指向模板内部,说明约束没有正确生效,可能是语法错误导致约束未被编译器识别为约束。

绝大多数情况下,通过static_assert测试概念本身,就能快速定位问题是出在概念定义上,还是出在模板的使用上。

requires表达式是C++20赋予我们的强大工具,它将模板元编程从“代码魔术”变成了“工程规范”。初学时可能会觉得语法有些古怪,尤其是requires requires->后面的类型约束。但一旦你习惯了这种声明式的约束描述方式,就再也回不去了。它带来的代码清晰度、错误信息友好度和设计严谨性的提升是巨大的。开始在你的项目中尝试使用它吧,从小处着手,比如用它替换掉一两个旧的std::enable_if,你会立刻感受到它的美妙之处。

返回列表