C++进阶:可变参数函数模板

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第12天,点击查看活动详情

概述

C++11的新特性--可变模版参数(variadic templates)是C++11新增的最强大的特性之一,它对参数进行了高度泛化,它能表示0到任意个数、任意类型的参数。相比C++98/03,类模版和函数模版中只能含固定数量的模版参数,可变模版参数无疑是一个巨大的改进。然而由于可变模版参数比较抽象,使用起来需要一定的技巧,所以它也是C++11中最难理解和掌握的特性之一。虽然掌握可变模版参数有一定难度,但是它却是C++11中最有意思的一个特性,本文希望带领读者由浅入深的认识和掌握这一特性,同时也会通过一些实例来展示可变参数模版的一些用法。

可变参数模板

可变参数模板和普通模板的语义是一样的,只是写法上稍有区别,声明可变参数模板时需要在typenameclass后面带上省略号...

template<class ... Args>
复制代码

有两个比较重要的概念如下:

  • 模板参数包:使用 typename/class ... Args 来指出 Args 是一个模板参数包,其中可能包含了零个或多个模板参数(注意包含0个);
  • 函数参数包:使用 Args ... rest 来指出 rest 是一个函数参数包,其中可能包含了零个或多个函数参数;

看下面一个简单例子:

#include <iostream>
​
template <class ... Args>
void Print(Args ... rest) {
    std::cout << sizeof...(rest) << std::endl;
}
int main() {
    Print();
    Print(1);
    Print(1, "ab");
    Print(1.0, "23", 4);
    return 0;
}
复制代码

上面的程序,分别调用了Print的四个重载函数,第一个参数包中参数个数为0,第二个为1,第三个为2,最后一个参数是4个。所以在输出的时候结果分别为0,1,2,3。编译器会为 Print 实例化出以下四个不同版本,我们看下上面程序的汇编代码:

push    rbp
mov     rbp, rsp
call    void Print<>()  # 1
mov     edi, 1
call    void Print<int>(int)  # 2
mov     esi, OFFSET FLAT:.LC0
mov     edi, 1
call    void Print<int, char const*>(int, char const*)  # 3
mov     rax, QWORD PTR .LC2[rip]
mov     esi, 4
mov     edi, OFFSET FLAT:.LC1
movq    xmm0, rax
call    void Print<double, char const*, int>(double, char const*, int)  # 4
mov     eax, 0
pop     rbp
复制代码

另外,如果需要不包含0个的变长参数模板,则可以采用以下的定义:

template<class Head, class ... Tail>
复制代码

本质上,...可接纳的模板参数个数仍然是0个及以上的任意数量,但由于多了一个Head类型,由此该模板可以接纳1个及其以上的模板参数

参数包展开方式

参数包的展开方式有两种,分别是递归函数方式展开、逗号表达式和函数初始化列表方式展开。

递归扩展

扩展一个参数包最常见的方法是递归。既然用到了递归,自然就免不了递归体和递归终止条件。例如:

#include <iostream>
​
// 递归终止条件
void Print() {
    std::cout << "Print()" << std::endl;
}
// 递归体
template<typename T, typename ... Args>
void Print (T first, Args ... rest)
{
    std::cout << "arg: " << first << std::endl;
    Print(rest...);
}
​
int main(void){
    Print(1, 2, 3); 
    return 0;
}
复制代码

在递归体函数中,我们将函数参数包的首个元素打印出来,然后利用剩余参数调用自身,直到最后当参数包为空时,调用非可变参数版本的 Print 函数退出递归。

注意:在使用递归方式进行包扩展时,将非可变参数版本 (递归终止条件) 必须要声明在可变参数版本 (递归体) 的作用域当中,否则会导致无限递归!!

这是因为当非可变参数版本的函数声明在可变版本的作用域中时,在执行递归终止条件时会进行函数匹配,根据特例化原则会优先考虑调用可变参数版本。若将非可变参数版本的声明放到可变参数版本的作用域之外,则在执行递归终止条件时只会匹配到可变参数版本,从而造成无限递归。

递归终止模板类可以有多种写法,比如上例的递归终止模板类还可以这样写:

#include <iostream>
​
// 递归终止条件
template<typename T>
void Print(T t) {
    std::cout << "Print(): " << t << std::endl;
}
​
// 递归体
template<typename T, typename ... Args>
void Print (T first, Args ... rest)
{
    std::cout << "arg: " << first << std::endl;
    Print<T>(rest...);
}
​
int main(void){
    Print(1, 2, 3); 
    return 0;
}
复制代码

输出:

arg: 1
arg: 2
Print(): 3
复制代码

对比第一个程序少了一个函数调用,第一个程序输出:

arg: 1
arg: 2
arg: 3
Print()
复制代码

逗号表达式扩展

包扩展的第二种方法则是借助逗号表达式和初始化列表来实现。还是以前面的 Print 函数为例,使用逗号表达式和初始化列表来实现:

#include <iostream>
​
template<typename T>
void Print(T t) {
    std::cout << "end: " << t << std::endl;
}
​
template<typename ... Args>
void Print (Args ... args)
{
    //逗号表达式+初始化列表
    int arr[] = { (Print(args), 0)... };
}
​
int main(void){
    Print(1, 2, 3); 
    return 0;
}
复制代码

展开的代码依次:

(Print(1), 0) // 逗号表达式
(Print(2), 0) // 逗号表达式
(Print(3), 0) // 逗号表达式
复制代码

可以看到,每个元素都是一个逗号表达式,以 (Print(1), 0) 为例,它会先计算 Print(1),然后将 0 作为整个表达式的值返回给数组,因此 arr 数组最终存储的都是 0。arr 数组纯粹是为了将参数包展开,没有发挥其它作用。

注意:使用借助初始化列表来实现的方式,虽然可以定义一个接受可变数目实参的函数,但这些参数必须具有同一类型

遐(假/瞎)想

介绍到这里,可能有些人觉得不管是递归方式还是逗号表达式方式展开,可变参数模板它总是需要定义2次,有没有更优雅的办法呢?C++11引入了sizeof...操作符,可以得到可变参数的个数,当然sizeof...的参数只能是parameter pack,不能是其它类型的参数。会不会有人这样想了,如果通过sizeof...判断出这个parameter pack的个数为零了,那就退出递归调用不好吗?就像下面这样:

#include <iostream>
​
//void Print() {
//    std::cout << "Print()" << std::endl;
//}
​
// 递归体
template<typename T, typename ... Args>
void Print (T first, Args ... rest)
{
    std::cout << "arg: " << first << std::endl;
    if(sizeof...(rest) > 0) {  // 增加这个判断
        Print(rest...);
    }
}
​
int main(void){
    Print(1, 2, 3); 
    return 0;
}
复制代码

你看,Print只定义了1次,我们在函数体里使用了sizeof...,如果parameter pack为零了,我们就停止递归调用。但不幸的是,编译程序报错:

error: no matching function for call to 'syszuxPrint()'
复制代码

为什么呢?为啥sizeof...if条件表达式没有生效?还在试图调用空的Print?这是因为,可变参数模板Print的所有分支都被实例化了,并不会考虑上面那个if表达式。编译期间,编译器发现是可变参数包就会实例化所有的函数,包括参数包为0的情况,所以针对上面语句Print(rest...)会进行如下特例化:

void Print<int, int, int>(int, int, int);
void Print<int, int>(int, int);
void Print<int>(int);
void Print();
复制代码

一个实例化的代码是否有用,是在runtime时决定的,而所有的实例化过程是在编译时决定的。所以Print()空参数版本照样被实例化出来了,而当Print()被实例化的时候并没有发现对它应的实现,于是编译期报错。

两种方法的优缺点

  • 递归包扩展方式:

    优点:实现更加灵活,我们可以针对递归终止条件进行不同于递归体函数的操作。

    缺点:

    • 递归函数会反复压栈弹栈,因此运行时会消耗更多资源;
    • 若递归终止条件没有声明在递归体的作用域内,则会导致无限循环(不过所幸的是编译器可以检查出这样的问题)。
  • 逗号表达式扩展方式:

    优点:执行的效率高于递归的方式;

    缺点:

    • 只能适用于对参数包中的每一个参数都执行相同操作的场景;
    • 浪费了一部分的内存空间,构造出来的初始化列表没有任何作用。

总结

使用可变模版参数的这些技巧相信你们看了会有耳目一新的感觉,使用可变模版参数的关键是如何展开参数包。展开参数包的过程很是精妙且多样化,体现了泛化之美、递归之美,当然可变模版参数的作用远不止文中列举的那些作用,其他的需要你们慢慢挖掘了。下一章我们介绍可变参数类模板中的应用。

猜你喜欢

转载自juejin.im/post/7107903738836156424