ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
实验平台:Win7,VS2013 Community,GCC 4.8.3(在线版) 所谓元编程就是编写直接生成或操纵程序的程序,C++ 模板给 C++ 语言提供了元编程的能力,模板使 C++ 编程变得异常灵活,能实现很多高级动态语言才有的特性(语法上可能比较丑陋,一些历史原因见下文)。普通用户对 C++ 模板的使用可能不是很频繁,大致限于泛型编程,但一些系统级的代码,尤其是对通用性、性能要求极高的基础库(如 STL、Boost)几乎不可避免的都大量地使用 C++ 模板,一个稍有规模的大量使用模板的程序,不可避免的要涉及元编程(如类型计算)。 本文就是要剖析 C++ 模板元编程的机制。 **C++模板的语法** 函数模板(function template)和类模板(class template)的简单示例如下: ~~~ #include <iostream> ~~~ 程序输出如下: ~~~ 1 ~~~ 关于模板(函数模板、类模板)的**模板参数**(详见文献\[1\]第3章): * 类型参数(type template parameter),用 typename 或 class 标记; * 非类型参数(non-type template parameter)可以是:整数及枚举类型、对象或函数的指针、对象或函数的引用、对象的成员指针,非类型参数是模板实例的常量; * 模板型参数(template template parameter),如“template class A> someclass {};”; * 模板参数可以有默认值(函数模板参数默认是从 C++11 开始支持); * 函数模板的和函数参数类型有关的模板参数可以自动推导,类模板参数不存在推导机制; * C++11 引入变长模板参数,请见下文。 **模板特例化**(template specialization,又称特例、特化)的简单示例如下: ~~~ // 实现一个向量类 ~~~ 所谓模板特例化即对于通例中的某种或某些情况做单独专门实现,最简单的情况是对每个模板参数指定一个具体值,这成为完全特例化(full specialization),另外,可以限制模板参数在一个范围取值或满足一定关系等,这称为部分特例化(partial specialization),用数学上集合的概念,通例模板参数所有可取的值组合构成全集U,完全特例化对U中某个元素进行专门定义,部分特例化对U的某个真子集进行专门定义。 更多模板特例化的例子如下(参考了文献\[1\]第44页): ~~~ template<typename T, int i> class cp00; // 用于模板型模板参数 ~~~ 关于模板特例化(详见文献\[1\]第4章): * 在定义模板特例之前必须已经有模板通例(primary template)的声明; * 模板特例并不要求一定与通例有相同的接口,但为了方便使用(体会特例的语义)一般都相同; * 匹配规则,在模板实例化时如果有模板通例、特例加起来多个模板版本可以匹配,则依据如下规则:对版本AB,如果 A 的模板参数取值集合是B的真子集,则优先匹配 A,如果 AB 的模板参数取值集合是“交叉”关系(AB 交集不为空,且不为包含关系),则发生编译错误,对于函数模板,用函数重载分辨(overload resolution)规则和上述规则结合并优先匹配非模板函数。 对模板的多个实例,**类型等价**(type equivalence)判断规则(详见文献\[2\] 13.2.4):同一个模板(模板名及其参数类型列表构成的模板签名(template signature)相同,函数模板可以重载,类模板不存在重载)且指定的模板实参等价(类型参数是等价类型,非类型参数值相同)。如下例子: ~~~ #include <iostream> ~~~ ~~~ 1 ~~~ 关于**模板实例化**(template instantiation)(详见文献\[4\]模板): * 指在编译或链接时生成函数模板或类模板的具体实例源代码,即用使用模板时的实参类型替换模板类型参数(还有非类型参数和模板型参数); * 隐式实例化(implicit instantiation):当使用实例化的模板时自动地在当前代码单元之前插入模板的实例化代码,模板的成员函数一直到引用时才被实例化; * 显式实例化(explicit instantiation):直接声明模板实例化,模板所有成员立即都被实例化; * 实例化也是一种特例化,被称为实例化的特例(instantiated (or generated) specialization)。 隐式实例化时,成员只有被引用到才会进行实例化,这被称为推迟实例化(lazy instantiation),由此可能带来的问题如下面的例子(文献\[6\],文献\[7\]): ~~~ #include <iostream> ~~~ 所以模板代码写完后最好写个诸如显示实例化的测试代码,更深入一些,可以插入一些模板调用代码使得编译器及时发现错误,而不至于报出无限长的错误信息。另一个例子如下(GCC 4.8 下编译的输出信息,VS2013 编译输出了 500 多行错误信息): ~~~ #include <iostream> ~~~ ~~~ sh-4.2# g++ -std=c++11 -o main *.cpp ~~~ 上面的错误是因为,当编译 aTMP 时,并不判断 N==0,而仅仅知道其依赖 aTMP(lazy instantiation),从而产生无限递归,纠正方法是使用模板特例化,如下: ~~~ #include <iostream> ~~~ ~~~ 3228800 ~~~ 关于模板的**编译和链接**(详见文献\[1\] 1.3、文献\[4\]模板): * 包含模板编译模式:编译器生成每个编译单元中遇到的所有的模板实例,并存放在相应的目标文件中;链接器合并等价的模板实例,生成可执行文件,要求实例化时模板定义可见,不能使用系统链接器; * 分离模板编译模式(使用 export 关键字):不重复生成模板实例,编译器设计要求高,可以使用系统链接器; * 包含编译模式是主流,C++11 已经弃用 export 关键字(对模板引入 extern 新用法),一般将模板的全部实现代码放在同一个头文件中并在用到模板的地方用 #include 包含头文件,以防止出现实例不一致(如下面紧接着例子); 实例化,编译链接的简单例子如下(参考了文献\[1\]第10页): ~~~ // file: a.cpp ~~~ ~~~ // file: b.cpp ~~~ ~~~ a.cpp: 1 ~~~ 上例中,由于 a.cpp 和 b.cpp 中的 print 实例等价(模板实例的二进制代码在编译生成的对象文件 a.obj、b.obj 中),故链接时消除了一个(消除哪个没有规定,上面消除了 b.cpp 中的)。 关于 **template**、**typename**、**this**关键字的使用(文献\[4\]模板,文献\[5\]): * 依赖于模板参数(template parameter,形式参数,实参英文为 argument)的名字被称为依赖名字(dependent name),C++标准规定,如果解析器在一个模板中遇到一个嵌套依赖名字,它假定那个名字不是一个类型,除非显式用 typename 关键字前置修饰该名字; * 和上一条 typename 用法类似,template 用于指明嵌套类型或函数为模板; * this 用于指定查找基类中的成员(当基类是依赖模板参数的类模板实例时,由于实例化总是推迟,这时不依赖模板参数的名字不在基类中查找,文献\[1\]第 166 页)。 一个例子如下(需要 GCC 编译,GCC 对 C++11 几乎全面支持,VS2013 此处总是在基类中查找名字,且函数模板前不需要 template): ~~~ #include <iostream> ~~~ ~~~ global f() ~~~ **C++11 关于模板的新特性**(详见文献\[1\]第15章,文献\[4\]C++11): * “>>” 根据上下文自动识别正确语义; * 函数模板参数默认值; * 变长模板参数(扩展 sizeof...() 获取参数个数); * 模板别名(扩展 using 关键字); * 外部模板实例(拓展 extern 关键字),弃用 export template。 在本文中,如无特别声明将不使用 C++11 的特性(除了 “>>”)。 **2** ****模板元编程概述**** 如果对 C++ 模板不熟悉(光熟悉语法还不算熟悉),可以先跳过本节,往下看完例子再回来。 C++ 模板最初是为实现泛型编程设计的,但人们发现模板的能力远远不止于那些设计的功能。一个重要的理论结论就是:C++ 模板是**图灵完备**的(Turing-complete),其证明过程请见文献\[8\](就是用 C++ 模板模拟图灵机),理论上说 C++ 模板可以执行任何计算任务,但实际上因为模板是编译期计算,其能力受到具体编译器实现的限制(如递归嵌套深度,C++11 要求至少 1024,C++98 要求至少 17)。C++ 模板元编程是“意外”功能,而不是设计的功能,这也是 C++ 模板元编程语法丑陋的根源。 C++ 模板是图灵完备的,这使得 C++ 成为**两层次语言**(two-level languages,中文暂且这么翻译,文献\[9\]),其中,执行编译计算的代码称为静态代码(static code),执行运行期计算的代码称为动态代码(dynamic code),C++ 的静态代码由模板实现(预处理的宏也算是能进行部分静态计算吧,也就是能进行部分元编程,称为宏元编程,见 Boost 元编程库即 BCCL,文献\[16\]和文献\[1\] 10.4)。 具体来说 C++ 模板可以做以下事情:编译期数值计算、类型计算、代码计算(如循环展开),其中数值计算实际不太有意义,而类型计算和代码计算可以使得代码更加通用,更加易用,性能更好(也更难阅读,更难调试,有时也会有代码膨胀问题)。编译期计算在编译过程中的位置请见下图(取自文献\[10\]),可以看到关键是模板的机制在编译具体代码(模板实例)前执行: ![图片](https://mmbiz.qpic.cn/mmbiz_png/JeibBY5FJRBGq5G0Zia9iafVntL2ZV1RWrR8wSWiavbmKqpy1xsR2O0Vesh8yASJXNCgEXib3WX5BSdib5G6XuLS7eCQ/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1 "C++ 模板元编程") 从编程范型(programming paradigm)上来说,C++ 模板是**函数式编程**(functional programming),它的主要特点是:函数调用不产生任何副作用(没有可变的存储),用递归形式实现循环结构的功能。C++ 模板的特例化提供了条件判断能力,而模板递归嵌套提供了循环的能力,这两点使得其具有和普通语言一样通用的能力(图灵完备性)。 从**编程形式**来看,模板的“<>”中的模板参数相当于函数调用的输入参数,模板中的 typedef 或 static const 或 enum 定义函数返回值(类型或数值,数值仅支持整型,如果需要可以通过编码计算浮点数),代码计算是通过类型计算进而选择类型的函数实现的(C++ 属于静态类型语言,编译器对类型的操控能力很强)。代码示意如下: ~~~ #include <iostream> ~~~ ~~~ 8 ~~~ C++ 模板元编程**概览框图**如下(取自文献\[9\]): ![](data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQImWNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==) 下面我们将对图中的每个框进行深入讨论。 **3** **编译器数值计算** **第一个 C++ 模板元程序**是 Erwin Unruh 在 1994 年写的(文献\[14\]),这个程序计算小于给定数 N 的全部素数(又叫质数),程序并不运行(都不能通过编译),而是让编译器在错误信息中显示结果(直观展现了是编译期计算结果,C++ 模板元编程不是设计的功能,更像是在戏弄编译器,当然 C++11 有所改变),由于年代久远,原来的程序用现在的编译器已经不能编译了,下面的代码在原来程序基础上稍作了修改(GCC 4.8 下使用 -fpermissvie,只显示警告信息): ~~~ // Prime number computation by Erwin Unruh ~~~ ~~~ sh-4.2# g++ -std=c++11 -fpermissive -o main *.cpp ~~~ 上面的编译输出信息只给出了前一部分,虽然信息很杂,但还是可以看到其中有 10 以内全部素数:2、3、5、7(已经加粗显示关键行)。 到目前为止,虽然已经看到了阶乘、求和等递归数值计算,但都没涉及原理,下面以求和为例讲解 C++ 模板编译期数值计算的原理: ~~~ #include <iostream> ~~~ ~~~ 15 ~~~ 当编译器遇到 sumt 时,试图实例化之,sumt 引用了 sumt 即 sumt,试图实例化 sumt,以此类推,直到 sumt,sumt 匹配模板特例,sumt::ret 为 0,sumt::ret 为 sumt::ret+1 为 1,以此类推,sumt::ret 为 15。值得一提的是,虽然对用户来说程序只是输出了一个编译期常量 sumt::ret,但在背后,编译器其实至少处理了 sumt 到 sumt 共 6 个类型。 从这个例子我们也可以窥探 C++ 模板元编程的函数式编程范型,对比结构化求和程序:for(i=0,sum=0; i::ret,sumt::ret,...,sumt::ret 。函数式编程看上去似乎效率低下(因为它和数学接近,而不是和硬件工作方式接近),但有自己的优势:描述问题更加简洁清晰(前提是熟悉这种方式),没有可变的变量就没有数据依赖,方便进行并行化。 **4** **模板下的控制结构** 模板实现的条件 **if**和**while 语句**如下(文献\[9\]): ~~~ // 通例为空,若不匹配特例将报错,很好的调试手段(这里是 bool 就无所谓了) ~~~ IF\_<> 的使用示例见下面: ~~~ const int len = 4; ~~~ ~~~ 4 ~~~ WHILE\_<> 的使用示例见下面: ~~~ // 计算 1^e+2^e+...+n^e ~~~ ~~~ 385 ~~~ 为了展现编译期数值计算的强大能力,下面是一个更复杂的计算:最大公约数(Greatest Common Divisor,GCD)和最小公倍数(Lowest Common Multiple,LCM),经典的辗转相除算法: ~~~ // 最小公倍数,普通函数 ~~~ ~~~ 900 ~~~ 上面例子中,定义一个类的整型常量,可以用 enum,也可以用 static const int,需要注意的是 enum 定义的常量的字节数不会超过 sizeof(int) (文献\[2\])。 **5** **循环展开** 文献\[11\]展示了一个**循环展开**(loop unrolling)的例子 -- 冒泡排序: ~~~ #include <utility> // std::swap ~~~ 对循环次数固定且比较小的循环语句,对其进行展开并内联可以避免函数调用以及执行循环语句中的分支,从而可以提高性能,对上述代码做如下测试,代码在 VS2013 的 Release 下编译运行: ~~~ #include <iostream> ~~~ ~~~ 2.38643 0.926521 ~~~ 上述结果表明,模板元编程实现的循环展开能够达到和手动循环展开相近的性能(90% 以上),并且性能是循环版本的 2 倍多(如果扣除 memcpy 函数占据的部分加速比将更高,根据 Amdahl 定律)。这里可能有人会想,既然循环次数固定,为什么不直接手动循环展开呢,难道就为了使用模板吗?当然不是,有时候循环次数确实是编译期固定值,但对用户并不是固定的,比如要实现数学上向量计算的类,因为可能是 2、3、4 维,所以写成模板,把维度作为 int 型模板参数,这时因为不知道具体是几维的也就不得不用循环,不过因为维度信息在模板实例化时是编译期常量且较小,所以编译器很可能在代码优化时进行循环展开,但我们想让这一切发生的更可控一些。 上面用三个函数模板 IntSwap()、 IntBubbleSort()、 IntBubbleSort<>() 嵌入其他模板内部,因为函数不允许嵌套,我们只能用类模板: ~~~ // 整合成一个类模板实现,看着好,但引入了 代码膨胀 ~~~ 上面代码看似很好,不仅整合了代码,借助类成员的访问控制,还隐藏了实现细节。不过它存在着很大问题,如果实例化 IntBubbleSortC、 IntBubbleSortC、 IntBubbleSortC,将实例化成员函数 IntBubbleSortC::IntSwap()、 IntBubbleSortC::IntSwap()、 IntBubbleSortC::IntSwap()、 IntBubbleSortC::IntSwap()、 IntBubbleSortC::IntSwap()、 IntBubbleSortC::IntSwap(),而在原来的看着分散的代码中 IntSwap() 只有一个。这将导致**代码膨胀**(code bloat),即生成的可执行文件体积变大(代码膨胀另一含义是源代码增大,见文献\[1\]第11章)。不过这里使用了内联(inline),如果编译器确实内联展开代码则不会导致代码膨胀(除了循环展开本身会带来的代码膨胀),但因为重复编译原本可以复用的模板实例,会增加编译时间。在上一节的例子中,因为只涉及编译期常量计算,并不涉及函数(函数模板,或类模板的成员函数,函数被编译成具体的机器二进制代码),并不会出现代码膨胀。 为了清晰证明上面的论述,我们去掉所有 inline 并将函数实现放到类外面(类里面实现的成员函数都是内联的,因为函数实现可能被包含多次,见文献\[2\] 10.2.9,不过现在的编译器优化能力很强,很多时候加不加 inline 并不影响编译器自己对内联的选择...),分别编译分散版本和类模板封装版本的冒泡排序代码编译生成的目标文件(VS2013 下是 .obj 文件)的大小,代码均在 VS2013 Debug 模式下编译(防止编译器优化),比较 main.obj (源文件是 main.cpp)大小。 类模板封装版本代码如下,注意将成员函数在外面定义的写法: ~~~ #include <iostream> ~~~ 分散定义函数模板版本代码如下,为了更具可比性,也将函数放在类里面作为成员函数: ~~~ #include <iostream> ~~~ 程序中条件编译都未打开时(#if 0),main.obj 大小分别为 264 KB 和 211 KB,条件编译打开时(#if 1),main.obj 大小分别为 1073 KB 和 620 KB。可以看到,类模板封装版的对象文件不但绝对大小更大,而且增长更快,这和之前分析是一致的。 **6** **表达式模板,向量运算** 文献\[12\]展示了一个**表达式模板**(Expression Templates)的例子:、 ~~~ #include <iostream> // std::cout ~~~ ~~~ -0 -0.707107 -1.1547 -1.5 -1.78885 -2.04124 -2.26779 -2.47487 -2.66667 -2.84605 ~~~ 代码有点长(我已经尽量压缩行数),请先看最下面的 main() 函数,表达式模板允许我们以 “-x / sqrt( 1.0 + x )” 这种类似数学表达式的方式传参数,在 evaluate() 内部,将 0-10 的数依次赋给自变量 x 对表达式进行求值,这是通过在 template<> DExpr 类模板内部重载 operator() 实现的。我们来看看这一切是如何发生的。 在 main() 中调用 evaluate() 时,编译器根据全局重载的加号、sqrt、除号、负号推断“-x / sqrt( 1.0 + x )” 的类型是 Dexpr, DApNeg>>,Dexpr, Dexpr, DApAdd>>, DApSqrt>>, DApDiv>>(即将每个表达式编码到一种类型,设这个类型为 ultimateExprType),并用此类型实例化函数模板 evaluate(),类型的推导见下图。在 evaluate() 中,对表达式进行求值 expr(i),调用 ultimateExprType 的 operator(),这引起一系列的 operator() 和 Op::apply() 的调用,最终遇到基础类型 “表达式类型” DExprLiteral 和 DExprIdentity,这个过程见下图。总结就是,请看下图,从下到上类型推断,从上到下 operator() 表达式求值。 ![图片](https://mmbiz.qpic.cn/mmbiz_png/JeibBY5FJRBGq5G0Zia9iafVntL2ZV1RWrRaEg8Rpv0Ny8pxRP5h7vfDnyBiblXH5DymOOG7TehVsTvqBXUicpxBwpQ/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1 "表达式模板,Expression Templates") 上面代码函数实现写在类的内部,即内联,如果编译器对内联支持的好的话,上面代码几乎等价于如下代码: ~~~ #include <iostream> // std::cout ~~~ ~~~ -0 -0.707107 -1.1547 -1.5 -1.78885 -2.04124 -2.26779 -2.47487 -2.66667 -2.84605 ~~~ 和表达式模板类似的技术还可以用到向量计算中,以避免产生临时向量变量,见文献\[4\] Expression templates 和文献\[12\]的后面。传统向量计算如下: ~~~ class DoubleVec; // DoubleVec 重载了 + - * / 等向量元素之间的计算 ~~~ 模板代码实现向量计算如下: ~~~ template<class A> DVExpr; ~~~ 不过值得一提的是,传统代码可以用 C++11 的右值引用提升性能,C++11 新特性我们以后再详细讨论。 我们这里看下文献\[4\] Expression templates 实现的版本,它用到了**编译期多态**,编译期多态示意代码如下(关于这种代码形式有个名字叫 curiously recurring template pattern, CRTP,见文献\[4\]): ~~~ // 模板基类,定义接口,具体实现由模板参数,即子类实现 ~~~ 简化后(向量长度固定为1000,元素类型为 double)的向量计算代码如下: ~~~ #include <iostream> // std::cout ~~~ ~~~ 18 ~~~ “alpha\*(u - v)” 的类型推断过程如下图所示,其中有子类到基类的隐式类型转换: ![](data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQImWNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==) 这里可以看到基类的作用:提供统一的接口,让 operator- 和 operator\* 可以写成统一的模板形式。 **7** **特性、策略、标签** 利用迭代器,我们可以实现很多通用算法,迭代器在容器与算法之间搭建了一座桥梁。求和函数模板如下: ~~~ #include <iostream> // std::cout ~~~ ~~~ 4950 ~~~ 我们想让 mysum() 对指针参数也能工作,毕竟迭代器就是模拟指针,但指针没有嵌套类型 value\_type,可以定义 mysum() 对指针类型的特例,但更好的办法是在函数参数和 value\_type 之间多加一层 -- 特性(traits)(参考了文献\[1\]第72页,特性详见文献\[1\] 12.1): ~~~ // 特性,traits ~~~ ~~~ 10 ~~~ 其实,C++ 标准定义了类似的 traits:std::iterator\_trait(另一个经典例子是 std::numeric\_limits) 。特性对类型的信息(如 value\_type、 reference)进行包装,使得上层代码可以以统一的接口访问这些信息。C++ 模板元编程会涉及大量的类型计算,很多时候要提取类型的信息(typedef、 常量值等),如果这些类型的信息的访问方式不一致(如上面的迭代器和指针),我们将不得不定义特例,这会导致大量重复代码的出现(另一种代码膨胀),而通过加一层特性可以很好的解决这一问题。另外,特性不仅可以对类型的信息进行包装,还可以提供更多信息,当然,因为加了一层,也带来复杂性。特性是一种提供元信息的手段。 **策略**(policy)一般是一个类模板,典型的策略是 STL 容器(如 std::vector> class vector;)的分配器(这个参数有默认参数,即默认存储策略),策略类将模板的经常变化的那一部分子功能块集中起来作为模板参数,这样模板便可以更为通用,这和特性的思想是类似的(详见文献\[1\] 12.3)。 **标签**(tag)一般是一个空类,其作用是作为一个独一无二的类型名字用于标记一些东西,典型的例子是 STL 迭代器的五种类型的名字(input\_iterator\_tag, output\_iterator\_tag, forward\_iterator\_tag, bidirectional\_iterator\_tag, random\_access\_iterator\_tag),std::vector::iterator::iterator\_category 就是 random\_access\_iterator\_tag,可以用第1节判断类型是否等价的模板检测这一点: ~~~ #include <iostream> ~~~ ~~~ 1 ~~~ 有了这样的判断,还可以根据判断结果做更复杂的元编程逻辑(如一个算法以迭代器为参数,根据迭代器标签进行特例化以对某种迭代器特殊处理)。标签还可以用来分辨函数重载,第5节中就用到了这样的标签(recursion)(标签详见文献\[1\] 12.1)。 **8** **更多类型计算** 在第1节我们讲类型等价的时候,已经见到了一个可以判断两个类型是否等价的模板,这一节我们给出更多例子,下面是判断一个类型是否可以隐式转换到另一个类型的模板(参考了文献\[6\] Static interface checking): ~~~ #include <iostream> // std::cout ~~~ ~~~ 1 ~~~ 下面这个例子检查某个类型是否含有某个嵌套类型定义(参考了文献\[4\] Substitution failure is not an erro (SFINAE)),这个例子是个内省(反射的一种): ~~~ #include <iostream> ~~~ ~~~ 1 ~~~ 这个例子是有缺陷的,因为不存在引用的指针,所以不用用来检测引用类型定义。可以看到,因为只涉及类型推断,都是编译期的计算,不涉及任何可执行代码,所以类的成员函数根本不需要具体实现。 **9** **元容器** 文献\[1\]第 13 章讲了元容器,所谓元容器,就是类似于 std::vector<> 那样的容器,不过它存储的是元数据 -- 类型,有了元容器,我们就可以判断某个类型是否属于某个元容器之类的操作。 在讲元容器之前,我们先来看看**伪变长参数模板**(文献\[1\] 12.4),一个可以存储小于某个数(例子中为 4 个)的任意个数,任意类型数据的元组(tuple)的例子如下(参考了文献\[1\] 第 225~227 页): ~~~ #include <iostream> ~~~ ~~~ 10 m 1.2 ~~~ C++11 引入了变长模板参数,其背后的原理也是模板递归(文献\[1\]第 230 页)。 利用和上面例子类似的模板参数移位递归的原理,我们可以构造一个存储“类型”的元组,即**元容器**,其代码如下(和文献\[1\]第 237 页的例子不同): ~~~ #include <iostream> ~~~ ~~~ 3 ~~~ 上面例子已经实现了存储类型的元容器,和元容器上的查找算法,但还有一个小问题,就是它不能处理模板,编译器对模板的操纵能力远不如对类型的操纵能力强(提示:类模板实例是类型),我们可以一种间接方式实现存储“模板元素”,即用模板的一个代表实例(如全用 int 为参数的实例)来代表这个模板,这样对任意模板实例,只需判断其模板的代表实例是否在容器中即可,这需要进行**类型过滤**:对任意模板的实例将其替换为指定模板参数的代表实例,类型过滤实例代码如下(参考了文献\[1\]第 241 页): ~~~ // 类型过滤,meta_filter 使用时只用一个参数,设置四个模板参数是因为,模板通例的参数列表 ~~~ 现在,只需将上面元容器和元容器查找函数修改为:对模板实例将其换为代表实例,即修改 meta\_container::ret\_type type;”,修改 find::ret\_type”。修改后,下面代码的执行结果是: ~~~ template<typename, typename> class my_tmp_2; ~~~ ~~~ 2 ~~~ **10** **总结** 博文比较长,总结一下所涉及的东西: * C++ 模板包括函数模板和类模板,模板参数形式有:类型、模板型、非类型(整型、指针); * 模板的特例化分完全特例化和部分特例化,实例将匹配参数集合最小的特例; * 用实例参数替换模板形式参数称为实例化,实例化的结果是产生具体类型(类模板)或函数(函数模板),同一模板实参完全等价将产生等价的实例类型或函数; * 模板一般在头文件中定义,可能被包含多次,编译和链接时会消除等价模板实例; * template、typename、this 关键字用来消除歧义,避免编译错误或产生不符预期的结果; * C++11 对模板引入了新特性:“>>”、函数模板也可以有默认参数、变长模板参数、外部模板实例(extern),并弃用 export template; * C++ 模板是图灵完备的,模板编程是函数编程风格,特点是:没有可变的存储、递归,以“<>”为输入,typedef 或静态常量为输出; * 编译期数值计算虽然实际意义不大,但可以很好证明 C++ 模板的能力,可以用模板实现类似普通程序中的 if 和 while 语句; * 一个实际应用是循环展开,虽然编译器可以自动循环展开,但我们可以让这一切更可控; * C++ 模板编程的两个问题是:难调试,会产生冗长且难以阅读的编译错误信息、代码膨胀(源代码膨胀、二进制对象文件膨胀),改进的方法是:增加一些检查代码,让编译器及时报错,使用特性、策略等让模板更通用,可能的话合并一些模板实例(如将代码提出去做成单独模板); * 表达式模板和向量计算是另一个可加速程序的例子,它们将计算表达式编码到类型,这是通过模板嵌套参数实现的; * 特性,策略,标签是模板编程常用技巧,它们可以是模板变得更加通用; * 模板甚至可以获得类型的内部信息(是否有某个 typedef),这是反射中的内省,C++ 在语言层面对反射支持很少(typeid),这不利于模板元编程; * 可以用递归实现伪变长参数模板,C++11 变长参数模板背后的原理也是模板递归; * 元容器存储元信息(如类型)、类型过滤过滤某些类型,它们是元编程的高级特性。