C++ 泛型编程(一) —— 可变参数模板

2020-07-14  本文已影响0人  进击的Lancelot

可变参数模板函数

可变参数模板是 C++ 11 中引入的一个新特性,它允许我们定义一个可以接受可变数目参数的模板函数或模板类。

在了解模板函数和模板类之前,我们需要先知道两个概念:

一个典型的可变参数模板定义如下:

template <typename ... Args>
void func(Args& ... rest){
    /* 函数体 */
}

与一般的模板相同,当编译器遇到可变参数模板函数的调用时,会根据调用时所传递的实参来推断模板参数类型以及包中参数的数目。例如:

int i = 10;
double pi = 3.14;
string str = "hello world!";
func(i, pi, str);   //包中含有 3 个参数
func(pi, str);      //包中含有 2 个参数
func(str);          //包中含有 1 个参数
func();             //包中含有 0 个参数(空包)

根据上述调用方式,编译器会为 func 实例化出以下四个不同版本

void func(int&, double&, string&);
void func(double&, string&);
void func(string&);
void func();

包扩展

对于一个可变参数函数模板而言,我们无法直接获取参数包中的参数,只能通过展开参数包的方式来访问参数包中的所有参数。C++ 中允许我们对参数包做执行两种操作:

在 print 函数当中,我们进行了两次包扩展操作。第一次扩展操作扩展了模板参数包 Args, 编译器将模式 const Args& 应用到了 Args 中的每个元素,最终的结果式得到了一个用逗号分隔的零个或多个类型的参数列表。例如:

int i = 10;
double d = 3.14;
string str = "hello world";
print(cout, i, d, str); 
//将 ostrea& print(ostream&, const T&,const Args&...)扩展为: ostream& print(ostream &os, const int&, const double&, const string&);

第二次扩展则是发生在 print 的递归调用中,此时模式为函数参数包的名字 rest。该模式扩展出一个由包中元素组成并用逗号分隔的列表,因此最终的效果相当于: print(cout, d, str)

扩展参数包的两种方法

方法一:递归扩展

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

//递归终止条件
template <typename T>
ostream& print(ostream &os, const T &t){
    return os << t;
}

//递归体
template <typename T, typename... Args>
ostream& print(ostream &os, const T &t, const Args&... rest){
    os << t << ", ";
    return print(os, rest...);
}
int main(void){
    int i = 10;
    double d = 3.14;
    string str = "hello world";
    print(cout, i, d, str); 
    return 0;
}

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

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

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

方法二:利用逗号表达式来扩展

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

template <typename T>
void print(ostream& os, const T &t){
    os << t << " ";
}
template <typename... Args>
void expand(ostream& os, Args&&... args){
    initializer_list<int>{(print(os, std::forward< Args>(args)),0)...};
}
int main(void){
    expand(cout, 1, 3.14, "hello world");
    return 0;
}

逗号表达式能够按顺序执行逗号前面的表达式,并返回逗号后边的值。例如:

d = (a = b, b);     //先执行 a = b,然后将 b 返回给 d

因此,expand 的函数体的功能

initializer_list<int>{(print(os, std::forward< Args>(args)),0)...}; //定义一个长度为 sizeof...(Args) 的整型数组,并统统初始化为 0。在初始化的同时,执行 print 函数

若使用 C++ 14 中的泛型 lambda,则可以将 print 函数转换为 lambda 表达式,使得代码更加简洁,如下:

template <typename F, typename... Args>
void expand(const F& f, ostream& os, Args&&... args){
    initializer_list<int>{(f(os, std::forward< Args>(args)),0)...};
}
int main(void){
    expand([](ostream& os, auto i){os << i << " ";}, cout, 1, 3.14, "hello world");
    return 0;
}

两种方法的优缺点

递归包扩展方式:

优点:实现更加灵活,我们可以针对递归终止条件进行不同于递归体函数的操作。
缺点:
1. 递归函数会反复压栈弹栈,因此运行时会消耗更多资源
2. 若递归终止条件没有声明在递归体的作用域内,则会导致无限循环。(不过所幸的是编译器可以检查出这样的问题。)

就地包扩展方式:

优点:执行的效率高于递归的方式
缺点:
1. 只能适用于对参数包中的每一个参数都执行相同操作的场景。
2. 浪费了一部分的内存空间,构造出来的初始化列表没有任何作用。

参考资料:

  1. 《C++ Primer》

  2. 泛化之美--C++11可变模版参数的妙用

上一篇下一篇

猜你喜欢

热点阅读