手记

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

顺序执行的代码

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

这一段代码的重点是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_ ,这是C++标准所不允许的,会产生编译错误。

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

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

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

enable_if与enable_if_t的定义如下:

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

这里引入了一个分支。当IsFeedbackOut为真时, std::enable_if_t::type是有意义的,这就使得第一个函数匹配成功;与之相应的,第二个函数匹配是失败的。反之,当lsFeedbackOut为假时,std::enable_if_ t::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行是用于终止循环的分支:当输入数组为空时,会匹配这个函数的模板参数。

0人推荐
随时随地看视频
慕课网APP