c++可变参数模板解析

1,710 阅读6分钟

前言

很高兴见到你。

我们知道,C++模板能力很强大,比起Java泛型这种语法糖来说,简直就是降维打击。而其中,可变参数模板,就是其中一个非常重要的特性。那什么是可变参数模板,以及为什么我们需要他?

首先我们考虑一个经典的场景:

我们需要编写一个函数,来打印变量信息。

比如:

int code = 1;
string msg = "success";
printMsg(code,msg); // 输出: 1,success

而我们需要打印的参数信息是不确定的,也有可能是下面的情况:

float value = 0.8f;
printMsg(code,msg,"main"); // 输出: 1,success,main
printMsg(value,code); // 输出: 0.8,1

printMsg的参数类型、数量都是不确定的,无论是普通模板、还是使用容器,都无法完成这个任务。而可变参数模板,可以非常完美完成这个任务。

可变参数模板,意为该模板的类型与数量都是不确定,能够接收任意的参数匹配,造就了其极高的灵活度。

认识可变模板参数

template<typename T,typename... Args>
void printMsg(T t, Args... args) {}

上述代码为可变参数模板的例子。首先要了解一个概念:模板参数包,函数参数包

typename...表示一个模板参数包类型,在typename后跟了三个点 ,Args是一个模板参数包,他可以是0或多种类型的组合。Args...,表示将这个参数包展开,作为函数的形参,args也称为函数参数包

举个例子:

// T的类型是 int
// Args的类型是 int、float、string 组成的模板参数包
printMsg(1,2,0.8f,"success");

// 模板会被实例化为此函数原型
void printMsg(int,int,float,string);

对于参数包,我们可以使用sizeof... 来获取该参数包中有多少个类型。如sizeof...(args); or sizeof...(Args);

那么,对于这个可变模板参数类型,我们要如何使用它呢?

使用可变模板参数

递归法

递归法利用的是类型匹配原理,将参数包中的参数,一个个给他分离出来。我们从一个实际的例子来理解他。假如我们要实现前言章节中的printMsg函数,那么他的实现代码如下:

template<typename T,typename ...Args>
void printMsg(const T& t, const Args&... args) {
    std::cout << t << ", ";
    printMsg(args...);
}

// 调用
printMsg(1,0.3f,"success");

当我们调用printMsg(1,0.3f,"success")代码时,模板函数被实例化为:

template<int,float,string>
void printMsg(const int& t, const float& arg1, const string& arg2) {
    std::cout << t << ", ";
    printMsg(arg1, arg2); 
}

代码中再次递归调用了printMsg,模板函数被实例化为:

template<float,string>
void printMsg( const float& arg1, const string& arg2) {
    std::cout << t << ", ";
    printMsg(arg2); 
}

发现规律了吗?当我们不断递归调用printMsg时,参数报Args会被一层层解开,并将类型匹配到模板T上,从而将参数包Args中的参数逐一处理。

与此同时,我们也知道一个关键点:递归需要有终止条件。因此,我们需要在只剩下一个参数的时候将其终结:

template<typename T>
void printMsg(const T& t) {
    std::cout << t << std::endl;
}

c++在匹配模板时,会优先匹配非可变参数模板,因此非可变参数模板则成为了递归的终止条件。这样我们就实现了一个函数,能够接受任意数量、任意类型(支持<<运算符)的参数。

特例化

递归法是最为常见的使用可变参数模板的方式。对于参数包来说,除了递归法,其次就为特例化。举个例子,还是我们上面的printMsg函数:

template<>
void printMsg(const int& errorCode,const float& strength,const double& value) {
    std::cout << "errorCode:" << errorCode << " strength:" << strength << " value:" << value << std::endl;
}

printMsg(1,0.8f,0.8);

针对<int,float,double>类型的模板做了一个特例化,则在我们调用此类型的模板时,会优先匹配特例化。这也是一种处理可变模板参数的方式。

除此之外,还有很多对于可变模板参数的神奇用法,进一步提高他的灵活性。

包拓展

这里包,指的是函数参数包以及可变模板参数包。前面的例子中已经存在两个包拓展,但更多的是属于可变参数模板的语法层面,所以并没有展开说。比如上面我们提到的代码:

template<typename T,typename ...Args>
void printMsg(const T& t, const Args&... args) {
    std::cout << t << ", ";
    printMsg(args...);
}

printMsg(1,0.8f,0.8);

这里有两个包拓展:

  1. 函数的形参,在Args& 之后跟了三个点,表示将Args参数包展开,例子中展开后的函数原型是void printMsg(const int&,const float&,const double&);
  2. 第二处展开是在递归调用时,将函数参数包形参展开args...,例子中展开后为printMsg(0.8f,0.8);

在涉及到函数调用、函数声明时,都需要用到上面这两个包拓展语法。但我们会发现并没有什么可以操作的空间,他更多就是一个可变模板函数的固定语法。但除此之外,包拓展可以有一个更加神奇的操作。

还是上面的例子,但是这里我们需要对打印的数据进行一轮过滤,对int数据超过99、float数据超过0.9进行预警报告,其他数据不做处理。那么这个怎么处理呢?

理论上说,我们需要对每个参数包中的每个数据进行处理,那我们可以在递归中,判断T的类型,再根据不同的类型进行处理。这种方式是可行的,但c++提供了更加好用的另一种方式。看下面的代码:

template<typename T>
const T& filterParam(const T& t) { return t; }

template<>
const int& fileterParam(const int& t) {
    if (t > 99) { onWarnReport(); }
    return t;
}

template<>
const float& fileterParam(const float& t) {
    if (float > 0.9) { onWarnReport(); }
    return t;
}

template<typename... Args>
void printMsgPlug(const Args&... args) {
    printMsg(filterParam(args)...);  //关键代码
}

printMsgPlus(1,0,3f,1.8f);

可以看到我们的关键代码在于printMsg(filterParam(args)...);这一行,他等价于printMsg(filterParam(1),filterParam(0.3f) ,filterParam(1.8f)); 三个小点移动到了函数调用的后面,即可以实现这样的效果。

这种方式的优点在于,他可以将过滤相关的逻辑,抽离到另外一个函数中去单独处理,利用模板的特性对数据进行统一或者单独处理。而且,使用typeId判断类型的方式并不总是可靠的,这种方式会更加稳定。

此外,针对双重过滤的方式,包拓展的解决方案也会更加优雅。假如,我们在打印数据之前,需要对数据进行一次转换,之后再对转换结果进行过滤判断是否需要预警报告。那么我们的伪代码可以是如下:

template<typename T>
T filterParam(const T& t) {
    T result = convertParam(t);
    if()...
    return result;
}

template<typename T>
T convertParam(const T& t) {...}

template<typename... Args>
void printMsgPlug(const Args&... args) {
    printMsg(filterParam(args)...);  //关键代码
}

而如果使用递归结合typeid的方式,可能就需要更多个switch进行类型匹配嵌套解决,且其结果总是不可靠的。

最后,并不是所有可变模板函数,都能使用递归去解决问题。例如我们需要一个能够构建unique_ptr的函数,他的简化版可以是这样的:

template<typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&... args) {
    return std::unique_ptr<T>(new T(fileterParam(args)...));
}

这个写法是不够完善的,但是方便我们理解。这个时候,如果我们需要对参数进行过滤,那么递归的方式,就无法在这里使用了,而必须使用包拓展。

完美转发

完美转发在可变模板中非常常见,他的作用在于保持原始的数据类型。参考我们上面的make_unique函数,在移除fileterParam函数之后,,我们希望,传给make_unique函数的数据,能够原封不动地,传递给T的构造函数。那么他的实现如下:

template<typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
  1. Args&& 表示通用引用,他能接收左值引用,也可以接收右值引用。
  2. std::forward 表示保持参数的原始类型。因为我们知道,右值引用本身是左值,所以我们需要将其转为右值传递给构造函数。

这样,我们就能够原封不动地将数据传递给构造函数,而不修改数据类型。这部分类型属于右值与引用的范畴,这里不详细展开解析。

但是对于可变模板来说,这里有一个关键需要注意一下:通用引用的本身,是 引用类型。假如我们传递了一个int类型进来,那么转化之后就变成了int&。此时如果我们使用Args类型去做模板匹配,很容易发生匹配失败的问题,会提示int&无法匹配到int类型,需要多加注意一下。要解决这个问题也比较简单,将其引用类型移除即可。在c++11中,可以使用以下代码移除所有的修饰与引用,保持基础的数据类型:

template<typename T>
using remove_cvRef = typename std::remove_cv<typename std::remove_reference<T>::type>::type;

std::vector<decltype(remove_cvRef<T>)> v;

在匹配模板的时候,可以使用decltype来获取移除后的类型进行匹配。

总结

可变参数模板在实际的使用中,更多还是结合完美转发来使用,实现对象的统一构造或者接口调用封装等。可变参数的存在,使得模板接口的灵活度提升了一个档次,如果你在实际开发中遇到类似的需求,不妨使用一下,会给你带来惊喜的。

全文到此,原创不易,觉得有帮助可以点赞收藏评论转发。 有任何想法欢迎评论区交流指正。 如需转载请评论区或私信沟通。 另外欢迎光临笔者的个人博客:传送门