欢迎大家来到IT世界,在知识的湖畔探索吧!
写这个文章是有一些犹豫的,一方面是 Type traits 本身是有一些复杂的、一方面是我自己对Type traits的理解并不能算非常深入,毕竟学透这个东西必须了解C++的一些“黑暗角落”,比如编译器模板匹配算法等。不过另一方面 Type traits 的能力又很强大,对于现代 C++ 来说非常重要,感兴趣的朋友还是要了解了解,所以最后还是决定写一篇相关的文章,算是抛砖引玉。
题外话:虽然各路新语言叫嚣的厉害,C++却依然宝刀未老而且还超越了C语言从第三位跃升到第二位,对于这门年龄比我还大好多的语言来说确实也不容易呐。
Type traits 准确的中文翻译比较难搞,用一些不太精确的方式替代容易让人误解,后文我就继续保持这个原来的英文名吧。
模板
现代 C++ 一大核心思路的改进就是模板化编程,或者也叫元编程(meta-programming),接受这一点是相当重要的,毕竟 C++ 标准库的实现重度依赖了这些技术。模板化编程相比于过去“面向对象(OOP)”最大的差异在于:
- 面向对象,是在运行时实现多态
- 模板化编程,是在编译时实现多态
后面我会用一个例子详细的比较告诉大家两者的区别,模板化编程最大的好处就是可以充分利用编译阶段的能力,比如根据类型特化实现,至少 C++20 还没有运行时反射机制,很难针对类型做什么;充分的在编译阶段优化代码性能,比如运行时多态需要函数指针和调用,编译时多态甚至可以全部内联。应该是C++13之后增加了更多编译时运行指令,比如 constexpre;C++20引入 concept,这些都是在编译时运行的逻辑,让模板化编程更加灵活强大了。用好 C++ 的模板超能力是能大大加快开发速度、代码性能的。
下面我举个例子分别展示同一个功能,用面向对象的方法和模板化的方法的实现差异,主要是方便还不太熟悉模板的朋友对模板有个初步的理解。
#include <iostream> // // OOP // class BaseClass { public: virtual void run() = 0; }; class SubClass0 : public BaseClass { public: virtual void run() override { // Override std::cout << "SubClass0::run" << std::endl; } }; class SubClass1 : public BaseClass { public: virtual void run() override { // Override std::cout << "SubClass1::run" << std::endl; } }; void run_class(BaseClass* ptr) { ptr->run(); } // // Template // template <size_t I> void run() = delete; template <> void run<0>() { std::cout << "Template0::run" << std::endl; } template <> void run<1>() { std::cout << "Template1::run" << std::endl; } template <size_t I> void run_template() { run<I>(); } // // Test // int main() { SubClass0 c0; SubClass1 c1; run_class(&c0); run_class(&c1); run_template<0>(); run_template<1>(); return 0; } /* Outputs: SubClass0::run SubClass1::run Template0::run Template1::run */
欢迎大家来到IT世界,在知识的湖畔探索吧!
上面这个代码很简单,用两种技术分别实现用不同的分支输出0和1。OOP的方法很容易理解,就是继承 + 重载;模板化的方法就是首先定义一个模板化参数size_t,表示是第几种实现,然后分别特化0/1两种实现,最后在main函数里调用。
模板化编程在这个例子里最大的优势是什么?我觉得是性能,因为编译后实际上汇编代码里不存在run<0> / run<1>两个函数,这两个函数的汇编代码都被内联到了main函数中,性能更好。而OOP的方法就没什么特别的优化了,需要实现两个run()函数,并且保存函数指针到类型的函数表中,通过基类调用函数时查表然后跳转到对应的函数起始位置开始执行 —— 你看,仅仅是描述一下就已经复杂很多了。
但是模板化编程最大的缺陷是:调用哪个模板特化必须是在编译时确定的,不能在运行时传入模板参数。所以你可以看到run_class这个类是可以在运行时接受任何BaseClass的子类的,但是run_template不行,其必须在编译阶段确定好调用哪个方法。当然对于一般灵活性需求来说,这么写就行了:
欢迎大家来到IT世界,在知识的湖畔探索吧!// Good if (num == 0) { run_template<0>(); } else (num == 1) { run_template<1>(); } // Bad,除非num是constexpr run_template<num>();
不能写成 run_template<num>() 的原因就是 num 不是一个编译时常量,C++后来引入的 constexpr 的含义之一就是这是一个编译时常量,可以用在模板参数上。
好,以上我们基本理解了同样的功能分别用两种方式实现的差异以及模板化的基本用法,接下来我们就要真正进入type traits的知识了。
Type Traits
很难用一句话彻底讲清楚 Type traits是干嘛的(或者我功力不够),我的理解就是:编译时的类型检查和特化的能力,其可以实现包括但不限于如下能力:
- 约束模板特化参数必须具备的性质,比如必须包含某些子类型、成员或者函数
- 编写通用算法时可以依靠传入的模板参数的特点选择不同的处理方式(AKA. 编译时多态)
- 根据类型定义的情况判断使用什么类型(也属于编译时多态,稍微复杂一些)
我们什么时候需要使用type traits可以从上面的三种情况来判断,下面我举一些例子:
// 该方法要求传入的类型T必须包含一个静态函数:run template <typename T> void run() { T::run(); } // 该方法判断传入的类型T是否包含静态函数run,如果包含就运行,否则执行默认逻辑 template <typename T> void run() { if constexpr (HasRun<T>) { T::run(); } else { ... } }
以上能力不用模板反而不好实现,C++还没有一个运行时反射机制(即使有了估计性能代价也不小),而用OOP的思想就要定义大量的interface,然后不可避免的会出现多继承,而一旦集成的interface里存在相同的方法,就更麻烦了;但是模板来做就轻而易举了,因为在编译阶段,编译器知道所有的信息。
下面我介绍如何判断模板参数是否满足要求,这里我们要使用到C++20引入的Concept机制,代码如下:
欢迎大家来到IT世界,在知识的湖畔探索吧! #include <concepts> #include <type_traits> struct s0 { static void run() {} }; struct s1 {}; template <typename T> concept MustHasRun = requires(T t) { { t.run() } -> std::same_as<void>; }; template <MustHasRun T> void run() { T t; t::run(); } int main() { run<s0>(); run<s1>(); }
这段代码里17行模板函数run要求其传入的模板类型T必须包含一个静态函数run且该函数返回void,所以23行是正确的,24行会报编译错误,因为类型s1并没有实现静态函数run。实现这个检查的关键就是11 – 14行代码,这里定义了一个concept,其判断类型T是否包含run这个静态函数。
这里需要注意的是11 – 14行代码完全是在编译时运行的,不存在任何运行时的代码,因此这些检查不会带来任何额外的成本(要说成本也是有的,就是编译时间……)。
下面我们再看一个更实际的场景如何使用 type traits 的能力:假设你在写一个通用算法(比如二分查找),这个算法对所有数据结构有统一的实现逻辑,但是如果存在某一种特殊的数据结构,其有更优的实现方法,那么我们应当使用这个更优的版本。这个需求用OOP的方式来实现就需要一系列继承关系和几个方法来判定“是否有更优版本”,但是用 type traits 技巧就非常简单了,看代码:
#include <iostream> #include <type_traits> template <bool USE_OPTIMIZED> struct algorithm_implementation { template <typename T> static void run(const T& obj) { std::cout << "Run not optimized algorithm" << std::endl; } }; template <> struct algorithm_implementation<true> { template <typename T> static void run(const T& obj) { std::cout << "Run optimized algorithm" << std::endl; obj.optimized_codes(); } }; template <typename T> struct if_supports_optimised_algorithm : std::false_type {}; struct ObjectA {}; struct ObjectB { static void optimized_codes() { std::cout << "ObjectB: optimized_codes" << std::endl; } }; template <> struct if_supports_optimised_algorithm<ObjectB> : std::true_type {}; template <typename T> void run_algorithm(const T& obj) { algorithm_implementation<if_supports_optimised_algorithm<T>::value>::run(obj); } int main() { ObjectA a; ObjectB b; run_algorithm(a); run_algorithm(b); }
algorithm_implementation 实现了一个默认的算法,对于ObjectA来说因为它没有优化版本所以运行默认算法,但是ObjectB提供了一个优化版本,所以最终算法会调用这个优化的方法。 run_alogrithm 封装了对不同类型的执行差异,使得对任意类型的调用方式都是一致的。代码细节就不解释了很容易读懂,一些不了解的关键字可以到 cppreference 上查一查。
除了上面针对特定类型特化的版本,还有一类场景是 type traits 比较常出现的,我总结就叫 optional type,可选类型:如果存在类型A那么就用A,如果不存在就用B。这种需求用OOP更加难实现,需要一些运行时的工厂方法注册的套路,而用 type traits 又是小事一桩了,我们继续看代码:
#include <iostream> #include <type_traits> struct algorithm_implementation { struct implementation { static void run() { std::cout << "Run default implementation" << std::endl; } }; }; template <typename T> struct algorithm_implementation_traits { algorithm_implementation_traits() = delete; }; template <typename T> concept is_algorithm_implementation_specialized = requires { algorithm_implementation_traits<T>(); }; template <> struct algorithm_implementation_traits<int> { struct implementation { static void run() { std::cout << "Run a int specialized algorithm implementation" << std::endl; } }; }; template <typename T> void run() { if constexpr (is_algorithm_implementation_specialized<T>) { return algorithm_implementation_traits<T>::implementation::run(); } else { return algorithm_implementation::implementation::run(); } } int main() { run<float>(); run<int>(); }
这个段代码演示的是,我们对 algorithm_implementation_traits 这个模板类型有一个 int 类型的特化。那么在 run 方法执行时,如果类型 T 没有对应的模板特化那么就使用 algorithm_implementation 类型,如果有特化就使用特化的类型。这段代码可以写的更清晰一些就是再定义一个类型把根据是否有特化的结果得到的类型 typedef 到一个子类型上,这样下游在用起来就极为的方便了。
这里建议大家动动手,看看怎么改成这样更加理想的实现方式?
另外多说一句,截止到C++20标准我们判断是否存在一个类型(或者更严谨的说:判断一个类型是否被完整的定义,因为类型是可以 forward declaration 的,但是只有这样的定义是不能用的)有多个方法:我这里的方法是要求对应类型必须有默认构造函数,所以你可以看到我在 12 行把模版类型的默认构造函数删掉了,如果这里不删掉那么所有特殊化都是有效的……
还有一些方法比如判断是否有有效的 sizeof 算子,因为一个不完备的定义是不能计算 sizeof 的等等……这里大家感兴趣可以自己搜搜看~
另外再继续强化一下模板化编程的优势,在编译后 26 – 32 行基本是不存在的,其逻辑都是在编译时运行的,真正实现的代码基本都被内联到 main 函数中了,速度非常快。
课外题:我们是否能通过模板编程、不定参数函数来实现对确定循环次数的for循环的内联?从而极大加快运行速度?大家可以试试,这个招数非常的简单却有效。
C++20 引入的 concept 是非常强大的编译时能力,这里最难的还是编程思想从 OOP 到模板化编程的转变(当然最后很多时候都是二者结合的,毕竟编译时多态不能解决所有问题)。我必须承认,在这方面我仍然还处在学习之中,后续有新的心得再写出来给大家分享~
喜欢就收个藏、点个赞、关个注吧!
免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://itzsg.com/63384.html