顺序执行的代码书写起来是比较直观的,考虑如下代码:
(1)第4行根据T计算出inter_type ;
同时,代码中的inter_type被声明为private类型,以确保函数的使用者不会误用inter_type这个中间结果作为函数的返回值。
这段代码是正确的,可以将fun1与fun2的定义顺序发生调换,不会改变它们的行为。但如果我们将元编程示例中的代码调整顺序:
但修改后的RemoveReferenceconst_中,编译器在首次从前到后扫描程序时,就会发现type依赖于一个没有定义的inter-type ,它不继续扫描后续的代码,而是会直接给出错误信息。在很多情况下,我们会将元函数的语句置于结构体或类中,此时就要确保其中的语句顺序正确。
我们也可以在编译期引入分支的逻辑。与编译期顺序执行的代码不同的是,编译期的分支逻辑既可以表现为纯粹的元函数,也可以与运行期的执行逻辑相结合。对于后者,编译期的分支往往用于运行期逻辑的选择。我们将在这一小节看到这两种情形各自的例子。
使用std::conditional与std::conditional实现分支
其逻辑行文是:如果B为真,则函数返回T,否则返回F。其典型的使用方式为:
conditional与conditional_t的优势在于使用比较简单,但缺点是表达能力不强:它只能实现二元分支(真假分支) ,其行为更像运行期的问号表达式:x=B?T:F;。对于多元分支(类似于switch的功能)则支持起来就比较困难了。相应地, conditional与conditional_t的使用场景是相对较少的。除非是特别简单的分支情况,否则并不建议使用这两个元函数。
在前文的讨论中,我们就是使用特化来实现的分支。(部分)特化天生就是用来引入差异的,因此,使用它来实现分支也是十分自然的。考虑下面的代码:
在C++ 14中,除了可以使用上述方法进行特化,还可以有其他的特化方式,考虑下面的代码:
使用特化来实现分支时,有一点需要注意:在非完全特化的类模板中引入完全特化的分支代码是非法的。考虑如下代码:
为了解决这个问题,我们可以使用部分特化来代替完全特化,将上面的代码修改如下:
使用std::enable_jf与std::enable_if_t实现分支
对于分支的实现来说,这里面的T并不特别重要,重要的是当B为true时,enable_if元函数可以返回结果type。可以基于这个构造实现分支,考虑下面的代码:
C++中有一个特性SFINAE ( Substitution Failure Is Not An Error ) ,中文译为"匹配失败并非错误"。对于上面的程序来说,一个函数匹配失败,另一个函数匹配成功,则编译嚣会选择匹配成功的函数而不会报告错误。这里的分支实现也正是利用了这个特性。
需要说明的是, enable_if与enable_if_t的使用形式是多种多样的,并不局限于前文中作为模板参数的方式。事实上,只要C++中支持SFINAE的地方,都可以引入enable_if或enable_if_t。有兴趣的读者可以参考C++ Reference中的说明。
还要说明的一点是,这里给出的基于enable_if的例子就是一个典型的编译期与运行期结合的使用方式。FeedbackOut_中包含了运行期的逻辑,而选择哪个FeedbackOut_则是通过编译期的分支来实现的。通过引入编译期的分支方法,我们可以创造出更加灵活的函数。
编译期分支代码看上去比运行期分支复杂一些,但与运行期相比,它也更加灵活。考虑如下代码:
但在编译期,我们可以在某种程度上打破这样的限制:
这也是一个编译期分支与运行期函数相结合的例子。事实上,通过元函数在编译期选择正确的运行期函数是一种相对常见的编程方法,因此C++ 17专门引入了一种新的语法if constexpr来简化代码的编写。
对于上面的代码段来说,在C++ 17中可以简化为:
使用if constexpr写出的代码与运行期的分支代码更像。同时,它有一个额外的好处,就是可以减少编译实例的产生。使用上一节中编写的代码,编译器在进行一次实例化时,需要构造wrap2与fun两个实例;但使用本节的代码,编译器在实例化时只会产生一个fun函数的实例。虽然优秀的编译器可以通过内联等方式对构造的实例进行合并,但我们并不能保证编译器一定会这样处理。反过来,使用if constexpr则可以确保减少编译器所构造的实例数,这也就意味着在一定程度上减少编译所需要的资源以及编译产出的文件大小。
1.3.3循环执行的代码
还是让我们参考一个例子:给定一个无符号整数,求该整数所对应的二进制表示中1的个数。在运行期,我们可以使用一个简单的循环来实现。在编译期,我们就需要使用递归来实现了:
你可能需要一段时间才能适应这种编程风格。整个程序在逻辑上并不复杂,它使用了C++ 14中的特性,代码量也与编写一个while循环相差无几。程序第2行0nesCount<(Input / 2)>是其核心,它本质上是一个递归调用。读者可以思考一下,当input为45或者任意其他的数值时,代码段第2行的行为。
循环使用更多的一类情况则是处理数组元素。我们在前文中讨论了数组的表示方法,在这里,给出一个处理数组的示例:
正如前文所述,在元函数中引入循环,非常重要的一点是引入一个分支来终止循环。程序的第2行是用于终止循环的分支:当输入数组为空时,会匹配这个函数的模板参数<size_t...1nputs> ,此时Accumulate返回0。而4-6行则组成了另一个分支:如果数组中包含一个或多于一个的元素,那么调用Accumulate将匹配第二个模板特化,取出首个元素,将剩余元素求和后加到首个元素之上。
fold expression本质上也是一种简化的循环写法,它的使用具有一定的限制。本书不对其进行重点讨论。
1.3.4 小心:实例化爆炸与编译崩溃
考虑一下,编译器在编译这一段时,会产生多少个实例。
这段代码结合了前文所讨论的分支与循环技术,构造出了Wrap_类模板。它是一个元函数,接收参数A返回另一个元函数。后者接收参数ID,并计算
在编译第18行代码时,编译器会因为这条语句产生Wrap_ <3>::imp的一系列实例。不幸的是,在编译第19行代码时,编译器无法复用这些实例,因为它所需要的是Wrap_ <10>::imp的一系列实例,这与Wrap_ <3>::imp系列并不同名。因此,我们无法使用编译器已经编译好的实例来提升编译速度。
下幸的是,编译器的设计往往是为了满足一般性的编译任务,对于元编程这种目前来说使用情形并不多的技术来说,优化相对较少。因此编译器的开发者可能不会考虑编译过程中保存在内存中的实例数过多的问题(对于非元编程的情况,这可能并不是一个大问题)。但另一方面,如果编译过程中保存了大量的实例,那么可能会导致编译器的内存超限,从而出现编译失败甚至崩溃的情况。
那么如何解决这个问题呢?其实很简单:将循环拆分出来。对于上述代码,我们可以修改为如下内容:
但这种修改还是有不足之处的:在之前的代码中, imp被置于Wrap_中,这表明了二者的紧密联系;从名称污染的角度上来说,这样做不会让imp污染Wrap_外围的名字空间。但在后一种实现中, imp将对名字空间造成污染:在相同的名字空间中,我们无法再引入另一个名为imp的构造,供其他元函数调用。
即使选择后一种方式,我们也应当尽力避免名字污染。为了解决这个问题,在后续编写深度学习框架时,我们会引入专用的名字空间,来存放像imp这样的辅助代码。
减少编译期实例化的另一种重要的技术就是引入短路逻辑。考虑如下代码:
虽然这段代码的逻辑非常简单,但足以用于讨论本节中的问题了。考虑一下在上述代码中,为了进行判断,编译器进行了多少次实例化。在代码段的第7行,系统进行了递归的实例化。给定N作为AllOdd的输入时,系统会实例化出N+1个对象。
以下是这个程序的改进版本(这里只列出了修改的部分) :
本文节选自《C++模板元编程实战:一个深度学习框架的初步实现》
