查看原文
其他

理解可变参数模板

CPP开发者 2023-07-27

The following article is from CPP编程客 Author 里缪

1

GP and Templates(泛型编程与模板)

现实中许多问题错综而复杂,解决起来极为不易。
人们发现可以将这些问题拆解成更小的问题来进行解决。往往当把问题拆解到最小模块的时候,便能对问题产生新的理解。
能解决问题的根本在于:问题整体可分解为部分,部分可组成为整体,整体等于部分之和。这是还原论的思想,在编程世界叫作分治策略,实现手法分为迭代和递归。
向下拆分,向上总结,就能把一个大问题化为许多小问题,通过解决一个个小问题,最终就能解决整个大问题。
解决之时,需要描述问题的数据和关系。
在C++中,用面向对象来表示事物的整体结构,用数据结构来表示数据之间的关系,用算法来表示具体的逻辑实现。
数据有类型之分,而不同的问题可能本质上实为同类问题,这便导致每次遇到同类问题,还得提供不同类型版本的解法。
因此数据和方法应该进行分离,对数据进行更高层次的抽象。
面向对象编程的目的是将数据和方法关联起来,而为了分离数据和方法之间的依赖,出现了泛型编程。
泛型编程本质上就是对数据类型的高度抽象,使同一个方法能作用到不同的类型,为同类问题提供了一个通解。
C++泛型编程所提供的支持组件便是模板,而Variadic Templates(可变参数模板/可变模板参数),顾名思义,就是支持任意个数、任意类型的模板。
对于一类问题,若发现变化的永远是输入,而解决方法始终不变,便可以使用可变参数模板定义通解。
这个通解,就是问题的「最小模块」,通过迭代或递归,分多次解决分解后的小问题,最终就能解决整个问题。
然而迭代需要持有一个迭代器并改变它,直到某个条件符合。泛型编程发生于编译期,编译期中的整数计算(如enum)或是typedef(using)的类型定义之后就无法进行改变。所以迭代虽然比递归表示起来更加自然,却无法进行实现。
递归符合一切编译期编程的要求,所以可变参数模板由其实现。
由于递归本身的特性,再结合可变参数模板,使得理解起来极为不易。因为你无法看到实际的结果,对于无法看到的东西,理解起来总是比较困难的。

2

Ellipsis operator and Parameter pack(省略操作符与参数包)

先来看一个简单的可变参数模板示例:

1template <class... Args>
2void func(Args... args)
3{

4    std::cout << sizeof...(args) << std::endl;
5}

"…"叫做省略操作符(ellipsis operator),用以表示任意个参数,带省略号的参数称为参数包(Parameter pack)。

当省略操作符出现在参数名(args)左边时,标示着参数是一个参数包;出现在参数名右边时,用于扩展参数包。因为我们无法直接获取参数包args中的每个参数,所以只能通过展开的方式来获取,那么如何展开参数包就成了难点所在。

现在func函数可以接受任意个参数:

1func<> f1;  // 0个参数
2func<int> f2;  // 1个参数
3func<intfloatstring> f3;  // 3个参数
4func<longvector<int>, stringbool> f4;  // 4个参数
5// ...etc.

若想知道可变参数模板中的参数个数,可以借助sizeof…()操作符(不是sizeof)。一般情况下并不会使用该操作符,只有当你想要对参数包中的某个参数进行特殊处理的情况下才需要使用。

3

Function template and Class template(函数模板和类模板)

函数模板和类模板都支持可变参数模板,可以称它们为可变参数函数模板和可变参数类模板,不过,统称为可变参数模板。

在可变参数模板中,变化得不仅仅是参数的个数,还有参数的类型。

利用参数逐一递减的特性,可以使用每个不同类型参数,这个展开参数的过程,是可变参模板的核心所在。

如前文所述,在泛型编程中的拆解问题的手法依赖于递归,而由于函数模板不支持偏特化,所以它与类模板展开参数包的方式也不尽相同。

3.1

Function template

我们先来看如何使用可变参数函数模板。

程序开发中,免不了需要调试,比如一个游戏程序,在客户端运行时需要收集日志,方便后期跟踪调试。

平时大家都喜欢用printf调试法,直接将调试信息输出到控制台窗口上。可惜的是,若程序为Win32窗体程序,这种方法便没用,因为没有控件台用于输出。而若在MSVC调试器之下运行程序,可以使用OutputDebugString函数,向MSVC的调试主控台打印信息。

为方便打印,使用起来需要向printf那样简单,所以可以用可变参数函数模板来实现:

1#include <Windows.h>
2#include <stdio.h>
3
4template <typename... Args>
5int DebugPrintF(const char* format , Args... args)
6
{
7    const unsigned int MAX_CHARS = 1023;
8    static char buffer[MAX_CHARS + 1];
9
10    int charsWritten = snprintf(buffer, MAX_CHARS, format, args...);
11    buffer[MAX_CHARS] = '\0';
12
13    OutputDebugString(buffer);
14    return charsWritten;
15}
16
17int main()
18
{
19    DebugPrintF("[%s] [%s] %s:%d %s %dh""2020-04-20 19:45:00""INFO""127.0.0.1:"8006"online time:"3);
20
21    return 0;
22}

这种实现的确可以使用,然而你发现还是使用snprintf,相当于仅是换身衣服罢了。

若不想用这种实现,那么来看第二种实现:

1void PrintToSomewhere(const char* value)
2
{
3    OutputDebugString(value);
4}
5
6void PrintToSomewhere(int value)
7
{
8    char buf[20];
9    _itoa_s(value, buf, 10);
10    OutputDebugString(buf);
11}
12
13void PrintToSomewhere(const char value)
14
{
15    char buffer[2];
16    buffer[0] = value;
17    buffer[1] = '\0';
18    OutputDebugString(buffer);
19}
20
21template <typename T>
22void DebugPrintF(T s)
23
{
24    while (*s)
25    {
26        if (*s == '%' && *(++s) != '%')
27            throw std::runtime_error("invalid s string: missing arguments");
28        //std::cout << *s++;
29        PrintToSomewhere(*s++);
30    }
31}
32
33template <typename T, typename... Args>
34void DebugPrintF(const char* s, T value, Args... args)
35
{
36    while (*s)
37    {
38        if (*s == '%' && *(++s) != '%')
39        {
40            // 输出到指定位置
41            PrintToSomewhere(value);
42
43            DebugPrintF(++s, args...);
44            return;
45        }
46        //std::cout << *s++;
47        PrintToSomewhere(*s++);
48    }
49    throw std::logic_error("extra arguments provided to DebugPrintF");
50}

该版本改自网上流传的一个使用可变参数模板实现printf的例子,这里职责分为两个:

  1. 抓出传进来的参数

  2. 进行输出

DebugPrintF负责第一个任务,它只需要将传进来的东西一个一个地抓出来,实际的输出任务交给PrintToSomewhere,这个函数可以自由实现,可以自定义输出流,不论是文件也好,绘图也罢,都取决于具体的需求。

所以这里不关心PrintToSomewhere,主要来看DebugPrintF。

它分成两个版本,一个参数的版本用于终止递归,三个参数的版本用于完成主要任务。

三个参数的版本中,第一个参数为格式化字符串,第二个参数为实际的首个参数,参数包为其余的实际参数。

每次进来,都会检查格式化字符,将其替换为实际字符。输出后,指针后移,循此往复,以递归来不断分解问题,最终,来到一个参数的版本,标示问题解决。

3.2

Class template

因为类模板支持偏特化,所以可变参类模板可以利用该特性来展开参数包。

还是先来看一个简单的例子,这个例子用于获得参数包中参数类型的大小之和,类型可以是任意个:

1template <typename... Args> struct TypeSize;
2
3template <typename Last>
4struct TypeSize<Last>
5{

6    enum { value = sizeof(Last) };
7};
8
9template <typename First, typename... Args>
10struct TypeSize<First, Args...>
11{

12    enum { value = TypeSize<First>::value + TypeSize<Args...>::value  };
13};

之后可以这样使用:

1TypeSize<int>::value;  // value: 4
2TypeSize<intdoublebool>::value;  // value: 13
3TypeSize<std::stringfloat>::value;  // value: 36
4TypeSize<std::string>::value;  // value: 32

这里利用了C++泛型编程的工具之一:编译期整数计算。再加上递归,便能在编译期完成计算类型大小的工作。

因为我们的递归终止条件中有一个参数,所以TypeSize无法接受0个模板参数,实际上,那样也毫无意义。

当然,若你执意如此,也未为不可,递归的终止条件完全由你决定,比如上面的递归终止条件还可以写成这样:

1// 0个参数终止
2template <>
3struct TypeSize<>
4{

5    enum { value = 0 };
6};
7
8// 2个参数终止
9template <typename First, typename Second>
10struct TypeSize<First, Second>
11{

12    enum { value = sizeof(First) + sizeof(Second) };
13};
14
15// ...etc.

我们还可以使用C++11的integral_constant来消除enum:

1template<typename First, typename... Args> struct TypeSize;
2
3template <typename First, typename... Args>
4struct TypeSize<First, Args...> 
5        :
 std::integer_constant<int, TypeSize<First>::value + TypeSize<Args...>::value>
6{};
7
8template<typename Last>
9struct TypeSize<Last> : std::integer_constant<intsizeof(Last)>
10{};
11
12TypeSize<intdoubleshort>::value; // output: 14

interal_constant可以包覆任意类型的一个静态常量,它的定义如下:

1template<class T, T v>struct integral_constant {
2    static constexpr T value = v;
3    using value_type = T;
4    using type = integral_constant; // using injected-class-name
5    constexpr operator value_type() const noexcept return value; }
6    constexpr value_type operator()() const noexcept return value; } //since c++14
7};

通过其中的编译期静态常量value,便能以递归的方式得到每个类型的大小,使用方式和上面的方式完全一样。

4

Recursive inheritance(递归继承)

boost中有一个工具——tuple(元组),可以存放任意类型的多个数据,C++11标准中也加入了tuple。
这个东西就是用可变参数模板实现的(可变参数模板是C++11才支持的,boost中的tuple并非采用可变参数模板实现,boost的tuple默认支持最多十个模板参数,可以满足绝大多数应用。)
本节不是要讨论tuple的用法,也不是要研究tuple的源码,而是要利用可变参数模板实现出一个自己的Tuple。虽不算完善,却也包含了主要的技术。
我们先来看看tuple的用法:

1typedef tuple<intdoublestring> my_tuple;
2my_tuple t(1, 2.7, "blah blah...");
3t.get<0>();  // 1
4t.get<1>();  // 2.7
5t.get<2>();  // blah blah...

可以看到,它可以接受任意类型、任意个数的参数,并可以访问指定位置的参数内容。

因此,下面根据需求来进行实现:

1template <typename... Types> class Tuple;
2template<> class Tuple<> {}
3
4template <typename Head, typename... Tail>
5class Tuple<Head, Tail...> : private Tuple<Tail...>
6{
7    using TailType = Tuple<Tail...>;
8public:
9    Tuple() {}
10    Tuple(Head v, Tail... vtails) : head_(v), TailType(vtails...) {}
11
12protected:
13    Head head_;
14
15public:    
16    Head head() return head_; }
17    TailType& tail() return *this; }
18};

在这短短的几行代码中,包含的诸多技巧,我们从上到下依次来看。
  1. Tuple支持可变参数的模板,因此可以接受任意个数、任意类型的参数。
  2. 通过特化,提供了空类型的Tuple<>,该类型可用于终止递归。
  3. 主版本的Tuple,将传递进来的参数分为第一个和其余个(1, N)。这样每次传递进来的参数会依次减少,达到遍历所有参数的效果。
  4. 主版本的Tuple通过私有继承,递归式地继承自后一个Tuple,也就是参数逐次递减的下一个Tuple,我们将其称为尾部Tuple。
  5. 私有继承表示has-a关系,继承其实现;公有继承表示is-a关系,继承其接口。表示父子关系或访问接口时使用公有继承,则处应该是has-a关系,所以使用私有继承。
  6. 因为要访问传入的数据,所以必须添加一个成员变量来进行保存,这个变量为head——每次传入Tuple中的第一个参数类型所定义的数据。
  7. 利用head()来访问传入的数据,利用tail()来访问其余的数据。
我们需要对每个传入的参数都定义数据,但却不能对每个类型都定义一个偏特化版本来满足需求。
针对这种情况,有一个令人拍案叫绝的技巧,就是利用继承和递归来依次展开参数,这样就会层层往上继承,最终继承到Tuple<>,这个技巧就称为「递归继承」。
举个例子,假如我们使用了Tuplet(1, 2.0, "blah blah..."),那么展开后就是这样的:,>
------------------------------
Tuple<>                      |
------------------------------

------------------------------
Tuple<string>                |
string head_("blah blah...");|
------------------------------

------------------------------
Tuple<floatstring>         |
float head_(2.0);            |
------------------------------

------------------------------
Tuple<intfloatstring>    |
int head_(1);                |
------------------------------
别看我们才写了几行代码,实际使用时通过「递归继承」可以自动产生成百上千行的代码。

5

索引式访问Tuple

可以通过head和tail来访问tuple保存的数据:

1void TupleTest()
2
{
3    Tuple<intfloatstd::string> t(102.0"blah blah...");
4    std::cout << "sizeof(t):" << sizeof(t) << std::endl;
5
6    std::cout << t.head() << std::endl;
7    std::cout << t.tail().head() << std::endl;
8    std::cout << t.tail().tail().head() << std::endl;
9}

输出如下:

呵!虽然现在可以访问,但由于这种方式访问会出现tail()的多次调用,若参数非常长,用这种方式访问是不切实际的,所以需要通过别的方式来提供访问接口。
这种问题有多种解决方案,统称为「索引式访问」。只需提供一个索引,就能返回指定位置的数据。
让我们先来观察现在的访问方式是如何实现的:

1TailType& tail() return *this; }

TailType是(1,N)个参数中后N个参数的Tuple类型,当前Tuple继承自TailType。

那么返回当前this指针给上层Tuple,就会发生强制类型转换,当前Tuple转换为TailType,这样当前Tuple的head_成员就会消失,只有上层Tuple的head_。此时通过head()调用得到的是上层Tuple的head_,也就是第二个参数。

如此递归,就能访问所有元素。

可以看到,核心是类型向上转换,那么我们只要获得指定索引的Tuple类型,就能直接把当前Tuple强制转换成需要的类型,从而访问元素。

开始提供索引式访问之前,我们需要修改Tuple类如下:

1template <typename... Types> class Tuple;
2template<> class Tuple<> {};
3
4template <typename Head, typename... Tail>
5class Tuple<Head, Tail...> : public Tuple<Tail...>
6{
7    using ValueType = Head;
8    using ReferenceType = Head&;
9    using ThisType = Tuple<Head, Tail...>;
10    using TailType = Tuple<Tail...>;
11
12public:
13    Tuple() {}
14    Tuple(Head v, Tail... vtails) : head_(v), TailType(vtails...) {}
15
16    ReferenceType head() return head_; }
17    //TailType& tail() { return *this; }
18
19protected:
20    ValueType head_;
21};

除了增加一些类型定义外,最大的一点改变在于将私有继承改成了公有继承。
各位应该都知道,私有继承的子类是无法向基类转型的。而在前面的Tuple实现中,却是私有继承,那么为何它在tail()返回时能向上转型呢?
前面的Tuple实现使用public继承也是完全可以的,之所以没那么写,是我想在这里强调一下私有继承。
一个简单的例子:

1class A {};
2class B : private A 
3{
4public:
5    void test()
6    
{
7        A& a = *this;  // ok! B cast to its private base class A
8    }
9};
10
11B b;
12A& a = b;  // error! cannot cast B to its private base class A

不知你曾经是否迷惑过这二者之间的不同?
假想一下,母亲(A)传给小短腿(B)一份秘制菜谱,并嘱咐他不要泄露给别人。那么小短腿就只能做菜给小伙伴吃,而不能告诉具体的做法。外人不知道做法,而小短腿自己是知道的。所以他自己可以拥有菜谱,外人可以吃他做出来的菜,却无法得到菜谱。
所以第一种情况可以转换,第二种情况无法转换。
而现在我们要提供的索引式访问是在外部,要想也拥有转型的能力,那么就得使用公有继承,所有人共享菜谱。
回顾一下,只要知道指定位置的Tuple类型,就能进行转换,从而访问到数据。
因为是对类型进行操作,所以要使用类模板。
定义TupleAt,获取类型,如下:

1template <std::size_t I, typename... TList> struct TupleAt;
2
3template <std::size_t I, typename T, typename... TList>
4struct TupleAt<I, Tuple<T, TList...>>
5{

6    using ValueType = typename TupleAt<I - 1, Tuple<TList...>>::ValueType;
7    using TupleType = typename TupleAt<I - 1, Tuple<TList...>>::TupleType;
8};
9
10template <typename T, typename... TList>
11struct TupleAt<0, Tuple<T, TList...>>
12{

13    using ValueType = T;
14    using TupleType = Tuple<T, TList...>;
15};
16
17template<>
18struct TupleAt<0, Tuple<>>
19{

20    using ValueType = Tuple<>;
21    using TupleType = Tuple<>;
22};

TupleAt有两个模板参数,一个是size_t类型,用于表示索引;另一个是可变参数模板。
ValutType表示Tuple中的数据类型,TupleType表示Tuple的类型。同样从(1,N)依次递归,由索引进行控制递归次数,当索引递减至0,就能获得欲访问位置的数据和Tuple类型。
而这只是类型,我们要提供接口给用户使用,因为要对具体的数值进行操作,所以应该使用函数模板。
定义访问接口如下:

1template <std::size_t I, typename... TList>
2typename TupleAt<I, Tuple<TList...>>::ValueType&
3TupleGet(Tuple<TList...>& tuple)
4
{
5    using TupleType = Tuple<TList...>;
6    using BaseTupleType = typename TupleAt<I, TupleType>::TupleType;
7
8    return static_cast<BaseTupleType&>(tuple).head();
9}

外部接口只需简单的进行转换,真正的工作由TupleAt进行完成。若Tuple是私有继承,此处的转换会报错。

现在可以这样使用:

1void TupleTest()
2
{
3    Tuple<intfloatstd::string> t(1032.0"blah blah...");
4    std::cout << "sizeof(t):" << sizeof(t) << std::endl;
5
6    // std::cout << t.head() << std::endl;
7    // std::cout << t.tail().head() << std::endl;
8    // std::cout << t.tail().tail().head() << std::endl;
9
10    std::cout << TupleGet<0>(t) << std::endl;
11    std::cout << TupleGet<1>(t) << std::endl;
12    std::cout << TupleGet<2>(t) << std::endl;
13}

6

Recursive composition(递归复合)

最后,小作补充,除了「递归继承」,上述Tuple也可以使用「递归复合」来完成。

「递归复合」不需要继承,直接在当前类中进行递归展开,实现起来如下:

1template <typename Head, typename... Tail>
2class Tuple<Head, Tail...>
3{

4public:
5    using ValueType = Head;
6    using ReferenceType = Head&;
7    using ThisType = Tuple<Head, Tail...>;
8    using TailType = Tuple<Tail...>;
9
10    Tuple() {}
11    Tuple(Head v, Tail... vtail) : head_(v), tail_(vtail...) {}
12
13    ReferenceType head() return head_; }
14    TailType& tail() return tail_; }
15
16protected:
17    TailType tail_;
18    ValueType head_;
19};

注意这里的增加了一个TailType的成员tail_,这是(1,N)中的后N个参数组成的Tuple,在递归继承中是继承这部分的,而递归复合是直接在当前类中原地展开。
标准中用的是递归继承,这里的区别在于:递归继承有继承关系,可以进行向上转换;而递归复合中的每个Tuple类型都不相同,无法通过直接转换类型来进行索引式访问。
也就是说,递归复合中的tail()函数所返回的类型每次都不一样,第一样访问你用auto tail = tuple.tail(),而第二次再调用tail = tail.tail(),就会产生返回类型不同的问题。
这意味着之前靠强制转换而实现的索引式访问,在这里会失效。
我们可以使用typeid来查看具体的类型:

1Tuple<intfloatstd::string> t(1032.0"blah blah...");
2
3std::cout << "sizeof(t):" << sizeof(t) << std::endl;
4
5std::cout << typeid(decltype(tuple)).name() << std::endl;
6std::cout << typeid(decltype(tuple.tail())).name() << std::endl;
7std::cout << typeid(decltype(tuple.tail().tail())).name() << std::endl;

输出如下:

这里推荐使用递归继承,处理和理解起来都要容易些。

7

总结

可变参数模板是一把威力巨大的宝刀,通过它可以实现一些特别有用的组件,但好刀不是轻易就能使的,需要掌握许多技巧,才能真正发挥它的威力。
我们可以在函数模板和类模板上使用可变参模板,二者都依赖于递归实作,而具体手法又有所不同。
函数模板处理的是数值,依赖于递归调用。若要对具体的数值进行处理,则使用函数模板。
类模板处理的是类型,依赖于两种手法:递归继承或递归复合。若要对类型进行操作,则使用类模板。
二者也可以协作使用,通过类模板在内部处理类型,外部接口则为函数模板,递归继承的索引式访问便是如此实现的。
为了实现一些强大的组件,往往要用到一些精妙的技巧,高超的手法。而这也导致仅仅几行代码,就包含了大量的信息,不易理解,得慢慢消化理解。
实际上,可变参数模板在标准库中已经被广泛使用了,例如一些算法或容器,里面包含着更多的技术和细节,以后再来带大家来剖析一些相关的源码。

- EOF -

推荐阅读  点击标题可跳转

1、C++ 可变参数模板

2、谈谈协程的历史与现状

3、16 岁高中生的"卷":用 13000+ 行代码,从头写了一个 C++ 机器学习库


关注『CPP开发者』

看精选C/C++技术文章 

点赞和在看就是最大的支持❤️

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存