C 模板元编程实战:顺序、分支与循环代码的编写

C 模板元编程实战:顺序、分支与循环代码的编写

首页休闲益智递阶数列更新时间:2024-07-02

顺序执行的代码

顺序执行的代码书写起来是比较直观的,考虑如下代码:

这一段代码的重点是2-7行,它封装了元函数RemoveReferenceconst_,这个函数内部则包含了两条语句,顺序执行:

(1)第4行根据T计算出inter_type ;

(2)第6行根据inter_type算出type。

同时,代码中的inter_type被声明为private类型,以确保函数的使用者不会误用inter_type这个中间结果作为函数的返回值。

这种顺序执行的代码很好理解,唯一需要提醒的是,现在结构体中的所有声明都要看成执行的语句,不能随意调换其顺序。考虑下面的代码:

这段代码是正确的,可以将fun1与fun2的定义顺序发生调换,不会改变它们的行为。但如果我们将元编程示例中的代码调整顺序:

程序将无法编译,这并不难理解:在编译期,编译嚣会扫描两遍结构体中的代码,第一遍处理声明,第二遍才会深入到函数的定义之中。正因为如此, RunTimeExample是正确的,第一遍扫描时,编译器只是了解到RunTimeExample包含了两个成员函数fun1与fun2 ;在后续的扫描中,编译器才会关注fun1中调用了fun2,虽然fun2的调用语句出现在其声明之前,但正是因为这样的两遍扫描,编译嚣并不会报告找不到fun2这样的错误。

但修改后的RemoveReferenceconst_中,编译器在首次从前到后扫描程序时,就会发现type依赖于一个没有定义的inter-type ,它不继续扫描后续的代码,而是会直接给出错误信息。在很多情况下,我们会将元函数的语句置于结构体或类中,此时就要确保其中的语句顺序正确。

1.3.2分支执行的代码

我们也可以在编译期引入分支的逻辑。与编译期顺序执行的代码不同的是,编译期的分支逻辑既可以表现为纯粹的元函数,也可以与运行期的执行逻辑相结合。对于后者,编译期的分支往往用于运行期逻辑的选择。我们将在这一小节看到这两种情形各自的例子。

事实上,在前面的讨论中,我们已经实现过分支执行的代码了。比如在1.2.2节中,实现了一个Fun-元函数,并使用一个bool参数来决定函数的行为(返回值) :这就是一种典型的分支行为。事实上,像该例那样,使用模板的特化或部分特化来实现分支,是一种非常常见的分支实现方式。当然,除此之外,还存在一些其他的分支实现方式,每种方式都有自己的优缺点-本小节会讨论其中的几种。

使用std::conditional与std::conditional实现分支

conditional与conditional_t是type_traits中提供的两个元函数,其定义如下:

其逻辑行文是:如果B为真,则函数返回T,否则返回F。其典型的使用方式为:

分别定义了int型的变量x与float型的变量y。

conditional与conditional_t的优势在于使用比较简单,但缺点是表达能力不强:它只能实现二元分支(真假分支) ,其行为更像运行期的问号表达式:x=B?T:F;。对于多元分支(类似于switch的功能)则支持起来就比较困难了。相应地, conditional与conditional_t的使用场景是相对较少的。除非是特别简单的分支情况,否则并不建议使用这两个元函数。

使用(部分)特化实现分支

在前文的讨论中,我们就是使用特化来实现的分支。(部分)特化天生就是用来引入差异的,因此,使用它来实现分支也是十分自然的。考虑下面的代码:

代码的第18行根据元函数Fun_的输入参数不同,为h赋予了不同的值-这是一种典型的分支行为。Fun_元函数实际上引入了3个分支,分别对应输入参数为A,B与默认的情况。使用特化引入分支代码书写起来比较自然,容易理解,但代码一般比较长。

在C 14中,除了可以使用上述方法进行特化,还可以有其他的特化方式,考虑下面的代码:

这段代码与上一段实现了相同的功能(唯一的区别是元函数调用时,前者需要给出依赖型名称::value ,而后者则无须如此),但实现简单一些。如果希望分支返回的结果是单一的数值,则可以考虑这种方式。

使用特化来实现分支时,有一点需要注意:在非完全特化的类模板中引入完全特化的分支代码是非法的。考虑如下代码:

这个程序是非法的。原因是Wrapper是一个未完全特化的类模板,但在其内部包含了一个模板的完全特化Fun_<int> ,这是C 标准所不允许的,会产生编译错误。

为了解决这个问题,我们可以使用部分特化来代替完全特化,将上面的代码修改如下:

这里引入了一个伪参数TDummy ,用于将原有的完全特化修改为部分特化。这个参数有一个默认值void,这样就可直接以Fun_<int>的形式调用这个元函数,无需为伪参数赋值了。

使用std::enable_jf与std::enable_if_t实现分支

enable_if与enable_if_t的定义如下:

对于分支的实现来说,这里面的T并不特别重要,重要的是当B为true时,enable_if元函数可以返回结果type。可以基于这个构造实现分支,考虑下面的代码:

这里引入了一个分支。当IsFeedbackOut为真时, std::enable_if_t<lsFeedbackOut>::type是有意义的,这就使得第一个函数匹配成功;与之相应的,第二个函数匹配是失败的。反之,当lsFeedbackOut为假时,std::enable_if_ t<llsFeedbackOut>::type是有意义的,这就使得第二个函数匹配成功,第一个函数匹配失败。

C 中有一个特性SFINAE ( Substitution Failure Is Not An Error ) ,中文译为"匹配失败并非错误"。对于上面的程序来说,一个函数匹配失败,另一个函数匹配成功,则编译嚣会选择匹配成功的函数而不会报告错误。这里的分支实现也正是利用了这个特性。

通常来说, enable_if与enable_if_t会被用于函数之中,用做重载的有益补充-重载通过不同类型的参数来区别重名的函数。但在一些情况下,我们希望引入重名函数,但无法通过参数类型加以区分,此时通过enable_if与enable_if_t就能在一定程度上解决相应的重载问题。

需要说明的是, enable_if与enable_if_t的使用形式是多种多样的,并不局限于前文中作为模板参数的方式。事实上,只要C 中支持SFINAE的地方,都可以引入enable_if或enable_if_t。有兴趣的读者可以参考C Reference中的说明。

enable_if或enable_if_t也是有缺点的:它并不像模板特化那样直观,以之书写的代码阅读起来也相对困难一些(相信了解模板特化机制的程序员比了解SFINAE的还是多一些的)。

还要说明的一点是,这里给出的基于enable_if的例子就是一个典型的编译期与运行期结合的使用方式。FeedbackOut_中包含了运行期的逻辑,而选择哪个FeedbackOut_则是通过编译期的分支来实现的。通过引入编译期的分支方法,我们可以创造出更加灵活的函数。

编译期分支与多种返回类型

编译期分支代码看上去比运行期分支复杂一些,但与运行期相比,它也更加灵活。考虑如下代码:

这是一个运行期的代码。首先要对第1行的代码简单说明一下:在C 14中,函数声明中可以不用显式指明其返回类型,编译器可以根据函数体中的return语句来自动推导其返回类型,但要求函数体中的所有return语句所返回的类型均相同。对于上述代码来说,其第3行与第4行返回的类型并不相同,这会导致编译出错。事实上,对于运行期的函数来说,其返回类型在编译期就已经确定了,无论采用何种写法,都无法改变。

但在编译期,我们可以在某种程度上打破这样的限制:

wrap2的返回值是什么呢?事实上,这要根据模板参数Check的值来决定。通过C 中的这个新特性以及编译期的计算能力,我们实现了一种编译期能够返回不同类型的数据结果的函数。当然,为了执行这个函数,我们还是需要在编译期指定模板参数值,从而将这个编译期的返回多种类型的函数蜕化为运行期的返回单一类型的函数。但无论如何,通过上述技术,编译期的函数将具有更强大的功能,这种功能对元编程来说是很有用的。

这也是一个编译期分支与运行期函数相结合的例子。事实上,通过元函数在编译期选择正确的运行期函数是一种相对常见的编程方法,因此C 17专门引入了一种新的语法if constexpr来简化代码的编写。

使用if constexpr简化代码

对于上面的代码段来说,在C 17中可以简化为:

其中的if constexpr必须接收一个常量表达式,即编译期常量。编译器在解析到相关的函数调用时,会自动选择if constexpr表达式为真的语句体,而忽略其他的语句体。比如,在编译器解析到第15行的函数调用时,会自动构造类似如下的函数:

使用if constexpr写出的代码与运行期的分支代码更像。同时,它有一个额外的好处,就是可以减少编译实例的产生。使用上一节中编写的代码,编译器在进行一次实例化时,需要构造wrap2与fun两个实例;但使用本节的代码,编译器在实例化时只会产生一个fun函数的实例。虽然优秀的编译器可以通过内联等方式对构造的实例进行合并,但我们并不能保证编译器一定会这样处理。反过来,使用if constexpr则可以确保减少编译器所构造的实例数,这也就意味着在一定程度上减少编译所需要的资源以及编译产出的文件大小。

但if constexpr也有缺点。首先,如果我们在编程时忘记书写constexpr ,那么某些函数也能通过编译,但分支的选择则从编译期转换到了运行期-此时,我们还是会在运行期引入相应的分支选择,无法在编译期将其优化掉。其次, if constexpr的使用场景相对较容:它只能放在一般意义上的函数内部,用于在编译期选择所执行的代码。如果我们希望构造元函数,通过分支来返回不同的类型作为结果,那么if constexpr就无能为力了。该在什么情况下使用if constexpr ,还需要针对特定的问题具体分析。

1.3.3循环执行的代码

一般来说,我们不会用while, for这样的语句组织元函数中的循环代码-因为这些代码操作的是变量。但在编译期,我们操作的更多的则是常量、类型与模板,为了能够有效地操纵元数据,我们往往会使用递归的形式来实现循环。

还是让我们参考一个例子:给定一个无符号整数,求该整数所对应的二进制表示中1的个数。在运行期,我们可以使用一个简单的循环来实现。在编译期,我们就需要使用递归来实现了:

1-4行定义了元函数Onescount,第6行则使用了这个元函数计算45对应的二进制包含的1的个数。

你可能需要一段时间才能适应这种编程风格。整个程序在逻辑上并不复杂,它使用了C 14中的特性,代码量也与编写一个while循环相差无几。程序第2行0nesCount<(Input / 2)>是其核心,它本质上是一个递归调用。读者可以思考一下,当input为45或者任意其他的数值时,代码段第2行的行为。

般来说,在采用递归实现循环的元程序中,需要引入一个分支来结束循环。上述程序的第4行实现了这一分支:当将输入减小到0时,程序进入这一分支,结束循环。

循环使用更多的一类情况则是处理数组元素。我们在前文中讨论了数组的表示方法,在这里,给出一个处理数组的示例:

1-6行定义了一个元函数: Accumulate ,它接收一个size_t类型的数组,对数组中的元素求和并将结果作为该元函数的输出。第8行展示了该元函数的用法:计算res的值15.

正如前文所述,在元函数中引入循环,非常重要的一点是引入一个分支来终止循环。程序的第2行是用于终止循环的分支:当输入数组为空时,会匹配这个函数的模板参数<size_t...1nputs> ,此时Accumulate返回0。而4-6行则组成了另一个分支:如果数组中包含一个或多于一个的元素,那么调用Accumulate将匹配第二个模板特化,取出首个元素,将剩余元素求和后加到首个元素之上。

事实上,仅就本例而言,在C 17中可以有更简单的代码编写方法,即使用其所提供的fold expression技术:

fold expression本质上也是一种简化的循环写法,它的使用具有一定的限制。本书不对其进行重点讨论。

编译期的循环,本质上是通过分支对递归代码进行控制的。因此,上一节所讨论的很多分支编写方法也可以衍生并编写相应的循环代码。典型的,可以使用if constexpr来编写分支,这项工作就留给读者进行练习了

1.3.4 小心:实例化爆炸与编译崩溃

回顾一下之前的代码:

考虑一下,编译器在编译这一段时,会产生多少个实例。

在第6行以7为模板参数传入时,编译器将使用7、3、1、0来实例化OnesCount ,构造出4个实例。接下来第7行以15为参数传入这个模板,那么编译器需要用15,7、3、1、0来实例化代码。通常,编译器会将第-次使用7, 3,1、0实例化出的代码保留起来,这样一来,如果后面的编译过程中需要使用同样的实例,那么之前保存的实例就可以复用了。对于一般的C 程序来说,这样做能极大地提升编译速度,但对于元编程来说,这可能会造成灾难。考虑以下的代码:

这段代码结合了前文所讨论的分支与循环技术,构造出了Wrap_类模板。它是一个元函数,接收参数A返回另一个元函数。后者接收参数ID,并计算

在编译第18行代码时,编译器会因为这条语句产生Wrap_ <3>::imp的一系列实例。不幸的是,在编译第19行代码时,编译器无法复用这些实例,因为它所需要的是Wrap_ <10>::imp的一系列实例,这与Wrap_ <3>::imp系列并不同名。因此,我们无法使用编译器已经编译好的实例来提升编译速度。

实际情况可能会更糟,编译嚣很可能会保留Wrap _<3>::imp的一系列实例,因为它会假定后续可能还会出现再次需要该实例的情形。上例中Wrap_中包含了一个循环,循环所产生的全部实例都会在编译器中保存。如果我们的元函数中包含了循环嵌套,那么由此产生的实例将随循环层数的增加呈指数的速度增长-这些内容都会被保存在编译器中。

下幸的是,编译器的设计往往是为了满足一般性的编译任务,对于元编程这种目前来说使用情形并不多的技术来说,优化相对较少。因此编译器的开发者可能不会考虑编译过程中保存在内存中的实例数过多的问题(对于非元编程的情况,这可能并不是一个大问题)。但另一方面,如果编译过程中保存了大量的实例,那么可能会导致编译器的内存超限,从而出现编译失败甚至崩溃的情况。

这并非危言耸听。事实上,在作者编写深度学习框架时,就出现过对这个问题没有引起足够重视,而导致编译内存占用过多,最终编译失败的情况。在小心修改了代码之后,编译所需的内存比之前减少了50%以上,编译也不再崩溃了.

那么如何解决这个问题呢?其实很简单:将循环拆分出来。对于上述代码,我们可以修改为如下内容:

在实例化Wrap _<3>::value<2>时,编译器会以5、4,3,2,1、0为参数构造imp,在随后实例化Wrap _<10>::value_<2>时,之前构造的东西还可以被使用,新的实例化次数也会随之变少,

但这种修改还是有不足之处的:在之前的代码中, imp被置于Wrap_中,这表明了二者的紧密联系;从名称污染的角度上来说,这样做不会让imp污染Wrap_外围的名字空间。但在后一种实现中, imp将对名字空间造成污染:在相同的名字空间中,我们无法再引入另一个名为imp的构造,供其他元函数调用。

如何解决这种问题呢?这实际上是一种权衡。如果元函数的逻辑比较简单,同时并不会产生大量实例,那么保留前一种(对编译器来说比较糟糕的)形式,可能并不会对编译器产生太多负面的影响,同时使得代码具有更好的内聚性。反之,如果元函数逻辑比较复杂(典型情况是多重循环嵌套) ,又可能会产生很多实例,那么就选择后一种方式以节省编译资源。

即使选择后一种方式,我们也应当尽力避免名字污染。为了解决这个问题,在后续编写深度学习框架时,我们会引入专用的名字空间,来存放像imp这样的辅助代码。

1.3.5 分支选择与短路逻辑

减少编译期实例化的另一种重要的技术就是引入短路逻辑。考虑如下代码:

这段代码的逻辑并不复杂。1-2行引入了一个元函数is _odd ,用来判断一个数是否为奇数。在此基础上, Allodd_用于给定数N,判断0~N的数列中是否每个数均为奇数。

虽然这段代码的逻辑非常简单,但足以用于讨论本节中的问题了。考虑一下在上述代码中,为了进行判断,编译器进行了多少次实例化。在代码段的第7行,系统进行了递归的实例化。给定N作为AllOdd的输入时,系统会实例化出N 1个对象。

上述代码判断的核心是第8行:一个逻辑“与"操作。对于“与"来说,只要有一个操作数不为真,那么就该返回假。但这种逻辑短路的行为在上述元程序中并没有得到很好地利用-无论is_cur_odd的值是什么,Allodd_都会对is _pre_ odd进行求值,这会间接产生若干实例化的结果,虽然这些实例化可能对系统最终的求值没什么作用。

以下是这个程序的改进版本(这里只列出了修改的部分) :

这里引入了一个辅助元函数AndValue :只有当该元函数的第一个操作数为true时,它才会实例化第二个操作数 ;否则将直接返回false,代码段的10~ 11行使用了AndValue以减少实例化的次数,同时也减少了代码的编译成本。

本文节选自《C 模板元编程实战:一个深度学习框架的初步实现》

本书将以一个深度学习框架的实现为例,讨论如何在一个相对较大的项目中深入应用元编程,为系统性能优化提供了更多的可能。本书分8章,前两章讨论了一些元编程与编译期计算的基本技术,后面六章则讨论了元编程在深度学习框架中的实际应用,涉及到富类型与标签体系、表达式模板、复杂元函数的编写等多个主题,详尽地展示了如何将面向对象与元编程相结合以构造复杂系统。

查看全文
大家还看了
也许喜欢
更多游戏

Copyright © 2024 妖气游戏网 www.17u1u.com All Rights Reserved