本来是想接着上文继续来说明如何使用C++模板来简化Visitor设计模式的,但这里涉及到了C++ 11中引入的可变参数模板,这个特性很有用,所以值得专门写一篇文章来介绍一下C++中的可变参数模板。可变参数模版比较特殊,跟其它语言比如C#里面的params相比,似乎更加灵活和强大。

当然我没有能力对某个特性做全面的解读,可变参数模板有很多用处,这里列举了侯捷老师在《 C++新标准:C++11&14》课程里对可变参数模版的解读里面举的几个例子,这几个例子比较经典,完美的解释了可变参数模版的用法。这篇文章算是一个简单的笔记,C++里面有些特性需要反复的学习和复习才能掌握。

可变参数模板


引用《Primer C++》中的定义,可变参数模板(variadic template)是一个接受可变数目参数的模板函数或模板类。可变数目的参数被称为参数包(parameter packet)。有两种参数包:

  • 模板参数包(template parameter packet),表示零个或多个模板参数。
  • 函数参数包(function parameter packet),表示零个或多个函数参数。

用一个省略号来指出一个模板参数或函数参数表示一个包。比如下面的函数:

void func() {}

template <typename T, typename... types>
void func(const T &firstArg, const types &...args)
{
    func(args...);
}

types是一个模板参数包,表示零个或多个模板参数;args是一个函数参数包,表示零个或多个模板参数。在可变参数模板方法里,每次递归调用func函数自身,传入的是函数参数表包,他会把这个包拆分为第一个参数和剩余的一包函数参数。直到这个参数包为空,则调用上面这个普通的参数为空的func函数。

在上面的func例子中,func(args...)为什么不会去调用下面的函数:

template <typename... types>
void func(const types &...args)
{
    func(args...);
}

因为这个带有1个可变参数包的模版方法,相比如上面的带有2个参数(1个普通模版参数和1个可变参数包)的方法而言,更加泛化。也即带有2个参数的那个模版方法更加特化,所以当有这两个方法同时存在时,会优先匹配带有2个参数的模版方法。另外如果调用带有1个可变参数的那个模版方法也会造成死循环。

下面举几个例子说明可变参数模板的用法:

Example 1:不定参数类型、数量的print


我们知道,如果要实现一个参数的类型都是一样的可变参数列表,则可以直接使用标准库中的initializer_list类型。比如要实现打印一系列字符串类型的方法,要求该方法打印字符串,以逗号分隔,最后换行:

printx({"hello", "world"});

输出结果为:

hello,world

注意上述方法调用中,用大括号实现了初始化一个可变参数变量。上述调用方法的实现如下:

template <typename T>
void printx(initializer_list<T> inits)
{
    for (const auto &a : inits)
    {
        if (a != *(inits.end() - 1))
        {
            std::cout << a << ",";
        }
        else
        {
            std::cout << a << std::endl;
        }
    }
}

初始化列表类型是直接支持for循环的。

但现在要假设要实现一个打印函数,该函数接受不定数量且不定参数类型作为参数 ,比如如下方法调用。

printx(7.5, "hello", 42, bitset<16>(377),"world")

输出结果为:

7.5,hello,42,0000000101111001,world

这就需要用到不定参数模板了。实现如下:

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

template <typename T, typename... types>
void printx(const T &firstArgs, const types &...args)
{
    std::cout << firstArgs << ",";
    printx(args...);
}

可以看到,非常优雅简洁。printx函数的可变参数模板方法会一直递归调用自己,每次调用都会把参数包分为firstArgs和剩下的一包args...。当最后剩下的一包参数个数只有1个的时候,就调用第一个只有一个参数的模板方法,在该方法里打印数据并输出换行。

Example 2:使用可变参数模板重写printf


C语言中有printf函数,它可以接受不定类型不定数量的参数,但是前面必须要有一个字符串占位符来表示参数的输出格式,比如下面的代码:

int *pi = new int;
printf("%d %s %p %f\n", 15, "this is alice", pi, 3.14159623);

它的输出结果为:

15 this is alice 0x55bb3cceaeb0 3.141596

现在可以使用可变参数模板来模拟实现printf函数,这也是stackoverflow上的一个问题。使用可变参数模板模拟printf的实现如下:

void printf(const char *s)
{
    while (*s)
    {
        if (*s == '%' && *(++s) != '%')
        {
            throw std::runtime_error("invalid format string: missing arguments");
        }
        std::cout << *s++;
    }
}

template <typename T, typename... types>
void printf(const char *s, const T &value, const types &...args)
{
    while (*s)
    {
        if (*s == '%' && *(++s) != '%')
        {
            std::cout << value;
            printf(++s, args...);
            return;
        }
        std::cout << *s++;
    }
    throw std::logic_error("extra arguments provided to printf");
}

这里面,可变参数模板的一包参数就是排除第一个字符串之外的所有参数。第一个字符串值里的%符号是用来匹配后面的参数的个数。每次调用printf,就会把后面的参数分解为第一个参数和后面一包。但取到第一个参数后,就去前面的字符串中去查找当前字符串为%,且接下来的字符串不为%的字符串,如果找到,则打印这个参数。然后将字符串往后移1位,加上前面的判断,相当于往后移了2位,即跳过当前参数对应的%以及后面的那个字符。如果当前的字符串不满足条件,则直接打印该字符串的内容,然后移到下一位。

如果提供的参数超过了占位符的个数,在这种情况下,*s字符串为空,会跳出while循环,会提示参数个数过多。例如:

printf("%d %s %p %f\n", 15, "this is alice", pi, 3.14159623,"hello");

前面的占位符只有4个,但是提供了5个参数,最后一个“hello”是多出来的,则输出结果为:

15 this is alice 0x55e27bfedeb0 3.1416
terminate called after throwing an instance of 'std::logic_error'
  what():  extra arguments provided to printf
Aborted

如果参数包解析到最后一个,则会匹配到只接受一个字符串的重载版本,这个时候判断前面字符串里剩下的占位符里面是否还有类似%这种,如果有,这表示占位符的个数比实际提供的参数个数还要多,这表示字符串的表示错误,直接抛出异常。比如以下调用语句:

printf("%d %s %p %f\n", 15, "this is alice", pi);

有4个占位符,但只提供了三个参数,输出结果如下:

terminate called after throwing an instance of 'std::runtime_error'
  what():  invalid format string: missing arguments
Aborted

直接抛出了预料中的异常。

Example 3:不定参数类型、数量的max


与第一个例子类似,max函数本身支持相同类型,可变数量的参数,这个是通过初始化列表参数类型initializer_list实现的。它的代码如下:

  template<typename _Tp>
    _GLIBCXX14_CONSTEXPR
    inline _Tp
    max(initializer_list<_Tp> __l)
   { return *std::max_element(__l.begin(), __l.end()); }

使用方法为:

int max = std::max<int>({4, 1, 3, 2});

可以看到,使用大括号作为参数初始化了一个初始化列表对象。如何去掉这个多余的大括号呢?那就要用到可变参数模板了,实现如下:

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

template <typename T, typename... Types>
T max(const T &t, const Types &...args)
{
    return std::max<T>(t, max(args...));
}

代码更简单,且一目了然。现在调用方法如下:

int max2 = max(4, 1, 3, 2);

可以看到,现在移除了为了初始化初始化列表而添加的大括号了。

Example 4:格式化Tuple输出


现在,假设需要实现一个函数,它能够打印tuple对象,调用方法如下:

using namespace vt4;
std::cout << make_tuple(7.5, "hello", 42, bitset<16>(377), "world");

输出如下:

[7.5,hello,42,0000000101111001,world]

现在需要做的就是对操作符进行重载,代码是写在名为vt4的命名空间里的。

namespace vt4
{
    template <int IDX, int MAX, typename... args>
    struct print_tuple
    {
        static void print(ostream &os, const tuple<args...> &t)
        {
            os << get<IDX>(t) << (IDX + 1 == MAX ? "" : ",");
            print_tuple<IDX + 1, MAX, args...>::print(os, t);
        }
    };

    template <int MAX, typename... args>
    struct print_tuple<MAX, MAX, args...>
    {
        static void print(ostream &os, const tuple<args...> &t)
        {
        }
    };

    template <typename... args>
    ostream &operator<<(ostream &os, const tuple<args...> &t)
    {
        os << "[";
        print_tuple<0, sizeof...(args), args...>::print(os, t);
        return os << "]";
    }

}

首先看的是最下面的操作符重载方法,这个操作符重载里面第一个参数是ostream,第二个参数是一个tuple,tuple的类型是可以变参数模板。接下来是一个名为print_tuple的模板类,类的模板参数有三个,第一个参数是index,表示当前处理的是第几个元素,第二个参数是max,表示可变参数模板的参数个数,第三个参数就是可变参数模板的参数包。

在print_tuple模板类里面,有一个静态的print方法,该方法首先根据第一个参数idx,从可变参数中通过get<idx>找到第idx个元素,然后打印出来,接着判断当前的元素个数是否等于模板参数个数,如果不等于,则表示不是最后一个元素,这需要接着输出逗号分隔符,如果是最后一个元素,则不需要输出分隔符。接下来print_tuple模板类继续递归调用自己,调用时的第一个参数indx递增,后面的参数不变。注意的是,这里并没有用到前面的通过参数来进行解包的这个策略。这里始终是一个完整的参数包在传递,唯一变得就是处理的代表第n个元素的这个idx。每一次递归都是创建一个新的tuple_print类,并调用它的print函数。

当处理到最后一个元素的下一个元素时,第一个和第二个参数会相等(index从0开始,而参数个数则从1开始计算,但index和个数相等时,实际上是尾后元素和个数相等),这时就会调用到参数特化的那个tuple_print类。这个类里什么都不做,即跳出递归循环,这就是我们想要的结果。

之前的例子都是展示的是使用模板方法来对那一包参数来进行分解,将这一包参数分解为第一个元素和后面的那一包元素,这一个例子这展示了通过模板类的方式,递归创建类,创建的时候传递当前处理的元素的下标,并通过get和sizeof两个方法来分别获取下标对应的元素,以及参数包的个数,从而来处理首尾元素需要特别对待的场景。

Example 5:用可变参数的递归继承来实现tuple


这个例子可以说是拍案叫绝。它通过使用可变参数模板,以及递归基层来实现了tuple,代码如下:

namespace vt5
{
    template <typename... values>
    class tuple;
    template <>
    class tuple<>
    {
    };

    template <typename Head, typename... Tail>
    class tuple<Head, Tail...> : private tuple<Tail...>
    {
        typedef tuple<Tail...> inherited;

    public:
        tuple() {}
        tuple(Head h, Tail... vtail) : m_head(h), inherited(vtail...)
        {
        }
        Head head() { return m_head; }
        inherited &tail() { return *this; }

    protected:
        Head m_head;
    };
}

上面的代码中,带Head和Tail...的可变参数模版的类tuple,私有继承自模版参数为可变参数包的类,每一次继承都是继承自除第一个参数之外的后面的一包参数。在构造函数中,初始化的时候直接用第一个参数为当前类的Head赋值,用后面的一包参数为基类的构造函数赋值,以达到拆分第一个参数和后面一包参数的目的。

该类提供了两个方法,一个方法获取当前的Head,直接返回了私有的m_head对象,另外一个对象返回了尾部对象,即其直接私有继承的基类。这里的做法是将指向当前对象的指针向基类做了转型。

调用方法如下:

vt5::tuple<int, float, std::string> t(41, 6.3, "nico");
std::cout << t.head() << std::endl;
std::cout << t.tail().head() << std::endl;
std::cout << t.tail().tail().head() << std::endl;

输出结果如下:

41
6.3
nico

总结


可变参数模版提供了一种递归的方法,它能够在递归调用中实现将一包参数分为第一个参数和剩余一包参数的目的,在这个过程中,只需要对第一个参数对象进行处理。在递归的过程中,退出机制通常是通过定义一个特化的模版方法或模版类,或者直接定义一个普通的重载函数或同名的普通来实现的,当可变模版参数中的那一包参数的数目减少到1或者0时,就会去调用这些特化的,同名的只接受一个参数或者0个参数的对象或方法,以达到对最后一个元素进行特殊处理或者直接退出递归调用的目的。

C++为可变参数模版的那一包参数提供了get<int>(...)和sizeof...(...)方法来获取这一包参数中的第n个元素和这一包参数的个数,这两个方法为递归调用提供了另外一种思路,即它不依赖通过模版参数将参数包分为第一个参数和剩余的一包参数这种常见模式。我们可以手动的将当前处理的元素下标进行递增,当这个下标达到元素包数量时,即表示处理完毕,这就是Example 4中的做法。

最后这几个例子完美展示了递归调用模版方法、递归调用创建模版类、以及递归的继承模版类这几个场景。

最后需要再次说明的实,这几个例子全部来自侯捷老师的《 C++新标准:C++11&14》这门课程对可变参数模版讲解,尤其是Example 5,这个递归以可变参数模版作为参数类型的模版类,第一次看到简直拍案叫绝,强烈推荐学习。

 

参考: