• C++20之Concept(概念部分)


    为何要引入Concept?

    我们在进行模板元编程的时候,经常会遇到一个问题:如何处理意料之外的类型的实例化?
    举例来说:

    template <typename T>
    bool IsEqual(T left, T right) {
      return left == right;
    }
    

    T实例化为intdoublechar甚至std::string都不会有什么问题,但是如果遇到字符串常量:

    if (IsEqual("abc", str)) {}
    

    这里的意义就有可能发生改变,我们知道在C++中,字符串常量会作为const char [N]类型出现的,而如果这里的str也恰好是C风格的字符串的话,那么这里就会成为「比较指针」是否相等,而不是「比较值」。

    所以,这时我们就需要对模板进行限定,并不要所有的类型都可以用于实例化IsEqual,而是必须“符合某些条件”才可以。

    因此,Concept要解决的问题,就是对模板的实例化进行限定,只有满足条件的类型才可以进行实例化,否则编译器将会拦截。

    什么是Concept?

    这里笔者又双叒叕要进行吐槽了……「concept」翻译成中文是「概念」的意思,有点太抽象了,不好理解。(当然也有可能是笔者英语不好吧,不太能够体会这个词有没有什么意会不可言传的含义~)。况且,这个表意如果放到中文里,很容易出现歧义,比如说我说「关于这个概念」,那请问这里的「概念」到底泛指的是「某种抽象」还是特指「concept」?好吧,为了避免这种歧义,笔者将在后面的叙述中,不将「concept」这个词进行翻译,而是直接保留原文;而如果出现「概念」的话,那就指抽象概念了,希望这里不要误导大家。

    这里的concept最直白的解释就是「一个静态的bool类型」,如果它为true,那么表示模板可以实例化,如果为false则表示不允许实例化。

    Concept语句需要书写在模板参数之后,模板实体之前,并且需要跟在requires关键字之后。我们举个最简单的例子:

    template <typename T>
    requires true
    struct Test1 {};
    
    template <typename T>
    requires false
    struct Test2 {};
    

    上例中,Test1后跟了一个Concept语句,并且恒为true,那么表示,对于任何情况下,Test1都是允许实例化的,比如TestTestTest甚至Test都是合法的实例。自然,这种恒true的情况下就可以省略Concpet,也就是说它和下面是等价的:

    template <typename T>
    struct Test1 {};
    

    而对于Test2来说,它的Concept语句是恒false,也就是说任何情况下都不允许实例化。

    当然了,直接这样写肯定是没意义的,我们需要让这个Concept成为一些用于判断的语句,它才有价值。例如对于一个模板类,当T的大小小于等于8时才可以实例化,那么就可以写作:

    template <typename T>
    requires (sizeof(T) <= 8)
    struct Test {};
    

    可以看出,Concept基本就是在代替C++20之前的std::enable_if,解决的问题相同,但实现的思路却不同。std::enable_if利用的是SFINAE原则,按照更合适的模板匹配并进行实例化,把允许的「特例」进行实现,而「通用模板」则不实现,那么就可以似的通过SFINAE匹配到的类型可以正常编译,而其他类型则由于找不到通用的实现而无法通过编译(找不到type)。我们用C++17的标准改写上面的实例就是:

    template <typename T, typename = std::enable_if_t<sizeof(T) <= 8, void>>
    struct Test {};
    

    而当实例化的T不满足(例如用std::string实例化)时,会报错,因为找不到std::enable_if::type

    Concept则是通过语言本身来解决这个问题的,自然会更加清晰直观。它其实就是在模板定义之前,单独搞了一个专门的区域,用于定义模板可实例化的条件,这样就不会出现满屏乱飞的std::enable_if和及其难以理解的模板嵌套。

    使用STL工具来做Concept

    既然Concept就是一个静态布尔值,那么STL当中的一些返回静态布尔类型的工具就天然可以作为Concept了,例如:

    template <typename T>
    requires std::is_trivial_v<T> // 要求T是平凡的
    struct Test1 {};
    
    template <typename T>
    requires std::is_base_of_v<google::protobuf::Message, T> // 要求T必须是protobuf的Message子类
    struct Test2 {};
    

    那么符合Concept怎么办?比如说要求「平凡 且 长度小于指针」,那么同样,可以利用合取工具:

    template <typename T>
    requires std::conjunction_v<std::is_trivial<T>, 
    						    std::bool_constant<sizeof(T) <= sizeof(void *)>>
    struct Test {};
    

    但这个时候我们就会发现,代码又一次开始有“爆炸”的趋势,它比使用std::enable_if也强不到哪去了。既然C++20提出Concept的概念,那么一定会有更优雅的语法形式。

    Concept块

    直接上代码,我们看看如何用「块」的形式表示上一节当中conjunction的表达式:

    template <typename T>
    requires requires {
      std::is_trivial_v<T>;
      sizeof(T) <= sizeof(void *);
    }
    struct Test {};
    

    这里出现了两个requires大家不要惊讶,前面章节我说过,concept书写的位置在模板参数之后,模板实体之前,并且要有requires标记。所以这里的第一个requires就是语法结构上的这个标记,用于表明,后面要跟一个concept。

    而第二个requires则是用于定义一个concept中需要满足的条件列表,也就是说,它是用来修饰后面的大括号的,表示这个大括号里应当是concept块,而不是普通的代码块。

    好吧算了……不吐槽了,大家适应一下这个语法~

    在concept块中可以定义一组静态布尔语句,之间用分号隔开,它们之间是“逻辑与”的关系,也就是说所有的条件都需要满足,才认为这个concept是满足的。

    需要注意的是,一个concept块本质就是一个静态布尔表达式,所以多个concept之间是可以用逻辑符来拼接的,例如:

    template <typename T>
    requires requires {
      std::is_trivial_v<T>;
      sizeof(T) <= sizeof(void *);
    } || std::is_trivially_copyable_v<T>
    struct Test {};
    

    这段例程也间接解答了“逻辑或”关系的concept之间如何表示。

    当然了,后面的concept也可以替换成concept块:

    template <typename T>
    requires requires {
      std::is_trivial_v<T>;
      sizeof(T) <= sizeof(void *);
    } || requires {
      std::is_trivially_copyable_v<T>;
      sizeof(T) <= 2 * sizeof(void *);
    }
    struct Test {};
    

    通过这段代码,也是希望读者能够体会到两个requires含义的不同。

    独立定义Concept

    如果每次我们都直接在模板里定义concept会有两个潜在的问题:一是有可能会由于concept过长而导致模板定义过长;二是如果多个模板需要使用同样的concept则无法复用。

    因此,C++也提供了独立定义concept的方法:

    template <typename T>
    concept Available = requires {
      std::is_trivial_v<T>;
      sizeof(T) <= sizeof(void *);
    };
    

    终于,concept不再仅仅是一种概念,还成为了C++20中的新关键字。用concept关键字可以定义一个独立的concept,独立定义的concept可以直接出现在模板的concept语句中:

    template <typename T>
    requires Available<T>
    struct Test {};
    

    这里需要注意的是,与C++17中直接定义静态布尔类型不同,concept本身就是一个静态布尔类型了,所以不需要取value。如果用C++17的方式定义上面的则应该是:

    template <typename T>
    struct Available : std::conjunction_v<
    				     std::is_trivial<T>, 
    					 std::bool_constant<sizeof(T) <= sizeof(void *)>
    					> {}
    
    template <typename T>
    constexpr inline bool Available_v = Available<T>::value;
    

    因此在concept中,我们再也不用考虑所谓“traits类型本身”“traits结果类型”“traits静态value”这些乱七八糟的概念,也不用考虑什么时候该加_t,什么时候该加_v

    更加强大的concept

    前面我们介绍的是一些concept的简单用法,但其实concept远不止如此,它还可以更优雅地解决很多复杂的需求。

    判断某个成员是否存在

    例如,我们要求类型T需要实现desc方法,如果用C++17的方法,是这样的:

    template <typename T, typename = void>
    struct Test;
    
    template <typename T>
    struct Test<T, std::void_t<decltype(T::desc)>> {};
    

    思路就是用SFINAE匹配,如果T::desc存在,那么会被std::void_t转化为void,并成功命中下面的偏特化。而如果T::desc不存在,则会命中上面的通用模板,又因为通用模板是未定义的,因此不能通过编译。但显然,写出这样的代码需要很高的门槛,必须对模板元编程烂熟于心,并熟练掌握SFINAE的匹配原则,然后给编译期玩出这样的文字游戏。

    但有了concept,一切都变得简单了,现在我们可以这样写:

    template <typename T>
    requires requires {
      T::desc; // 表示T::desc是合理语句
    }
    struct Test {};
    

    在concept语句中,可以单纯写一个表达式,用于表示「可以执行这样的表达式」,用上面的例子来说就是,对于一个类型T,如果我能取到T::desc,那么就认为它符合要求。

    然而这样带来了另一个问题就是,我不能确定T::desc到底是个什么,如果是个成员变量那也能通过:

    template <typename T>
    requires requires {
      T::desc;
    }
    struct Test {};
    
    struct T1 {
      static int desc;
    };
    
    struct T2 {
      static void desc();
    };
    
    void Demo() {
      Test<T1> t1; // OK
      Test<T2> t2; // OK
    }
    

    这种情况我们可以用std::is_function来解决:

    template <typename T>
    requires requires {
      std::is_function_v<decltype(T::desc)>;
    }
    struct Test {};
    

    判断非静态成员变量

    刚才的例程仅仅对静态成员生效,但如果我要求desc是非静态成员函数呢?用C++17的方法是这样:

    template <typename T, typename = void>
    struct Available : std::false_type {};
    
    template <typename T>
    struct Available<T, std::void_t<decltype(&T::desc)>> :
      std::is_member_function_pointer<decltype(&T::desc)> {};
    
    template <typename T, typename = std::enable_if_t<Available<T>::value>>
    struct Test {};
    

    而如果用concept,则会是这样:

    template <typename T>
    requires requires(T t) {
      t.desc();
    }
    struct Test {};
    

    隆重介绍concept语句的「参数列表」。在concept的requires后可以跟一个列表,来定义一些用于静态判断的“变量”,注意这里的变量是没有实际意义的,它也不会在实际执行时被初始化,它仅仅是用于承担“静态语法判断”的工作。

    那么上面的例子可以解释为“对于一个T类型的变量,如果它可以执行t.desc()这样的语句,那么就视为合法”,那么我们也就达成了“判断T是否含有一个非动态成员函数desc”的目的。

    判断某个语句的返回值

    那如果我要求desc是个非静态成员函数,并且返回值是void,参数为空,那怎么办?如果用C++17的方法,就要开始“花式炫技”了:

    template <typename T, typename = void>
    struct Available : std::false_type {};
    
    template <typename T>
    struct Available<T, std::void_t<decltype(&T::desc)>> :
    std::disjunction<
      std::is_same<decltype(&T::desc), void (T::*)(void)>,
      std::is_same<decltype(&T::desc), void (T::*)(void) const>,
      std::is_same<decltype(&T::desc), void (T::*)(void) noexcept>,
      std::is_same<decltype(&T::desc), void (T::*)(void) const noexcept>
    > {};
    
    template <typename T, typename = std::enable_if_t<Available<T>::value>>
    struct Test {};
    
    struct T1 {
      void desc();
    };
    
    struct T2 {
      int desc();
    };
    
    struct T3 {
      static int desc;
    };
    
    void Demo() {
      Test<T1> t1; // OK
      Test<T2> t2; // ERR
      Test<T3> t3; // ERR
    }
    

    对于函数是否含有constnoexcept都需要考虑到。但有了concpet,可以很优雅地解决这个问题:

    template <typename T>
    requires requires(T t) {
      t.desc();
      std::is_same_v<decltype(t.desc()), void>;
    }
    struct Test {};
    

    emmm…怎么说呢,好像也不算优雅。尽管它比disjunction的方式优雅多了,但这个返回值的判断仍然还是很奇怪。

    由此,concept提供了用于语句返回值判断的语法,那么上面的代码可以改写成这样:

    template <typename T>
    requires requires(T t) {
      {t.desc()}->std::same_as<void>;
    }
    struct Test {};
    

    用一个大括号括起来的语句,表示取这个语句的返回值,当然,它的前提是这个语句能正常执行。那么,后面这个std::same_as又是何方神圣呢?

    这是STL提供的一些concept之一,它的定义是:

    template <typename T1, typename T2>
    concept same_as = std::is_same_v<T1, T2>;
    

    没什么特别的,就是把std::is_same_v定义成了concept,但这却让它发挥了神奇的功效。在使用大括号表示语句返回值后,返回值类型会传递给后面concept的第一个参数,也就是说{t.desc()}->std::same_as是把t.desc()语句的返回值传给了std::same_asT1,而尖括号里的void则是传给了T2

    所以对于一个concept:

    template <typename T>
    concept C1 = requires(T t) {
      {t.desc()}->std::same_as<void>;
    };
    

    就等价于:

    template <typename T>
    concept C2 = requires(T t) {
      std::is_same_v<decltype(t.desc()), void>;
    };
    

    这下,concept的写法确实对得起“优雅”一词了。

    总结

    总结一下:

    • concept是enable_if的替代方案,用于约束模板是否允许实例化;
    • concept本质是一个静态布尔类型表达式,可以直接用布尔常量、或是静态布尔类型变量来代替;
    • C++17中的一些_v结尾的工具通常可以直接作为concept使用;
    • concept可以用concept关键字来独立定义;
    • 在concept块中,表达式均表示“能够这样执行”的含义,并且表达式之间是逻辑与的关系;
    • concept块可以有参数列表,来定义“语法解析”层面的“变量”;
    • concept块中可以用大括号返回表达式的返回值,并把返回值类型交给->后的concept的第一个模板参数。

    本篇主要介绍concept的概念和一些基本用法,下一篇讲介绍concept更高级的用法,后面还会有专门篇幅介绍各种场景下的concept实例。

    第二篇已经脱稿,请看C++20之Concept(概念部分,之二)

  • 相关阅读:
    帆软BI开发-Day2-趋势图的多种变形
    软件教程 | Jupyter&stata之stata_kernel攻略
    聪明的红绿灯,已经学会主动给你开路了
    Java I/O 的 OutputStream 输出流相关知识点详解
    音乐免费下载mp3格式+音频格式转换+剪辑音频+合并音频教程
    别问怎么下载,金蝶云星空SaaS BI系统不用下载
    PHP:NULL 合并运算符
    Redis数据类型——set类型数据交并差操作
    系统集成|第九章(笔记)
    linux进程管理
  • 原文地址:https://blog.csdn.net/fl2011sx/article/details/127059299