【Modern cpp】不定参数模板与std::tuple、std::bind实现原理

不定参数函数

    学过C语言的人应该都用过printf这个库函数,它的声明如下:

extern int printf(const char *format,...);

    它的第一个参数是一个格式化的字符串,后面可以接任意个数任意类型的参数(取决于格式化字符串中的格式化字符个数)。如果自定义一个接受不定参数的函数,改如何实现呢?标准库有帮助实现这类功能的帮助函数。

int sum(int count, ...)
{
    va_list vl;
    int sum = 0;
    va_start(vl, count);
    for (int i = 0; i < count; ++i)
    {
        sum += va_arg(vl, int);
    }
    va_end(vl);
    return sum;
}

    上面函数功能很简单,第一个输入是后面不定参数的个数,然后计算它们的和。借助va_xx系列函数即可完成。通过va系列函数操作不定参数比较麻烦,而且调用时候也容易出错(调用时需要在va_arg函数中指定类型,而不能通过推导得出类型)。这些弊端在c++11的不定参数模板里面有了新的解决方案。

不定参数模板

    在上一个c++标准即c++98标准中模板参数被要求有确定的个数,而新的c++11标准修改了这一限制,允许代码编写者引入不定参数的模板。这一变化引起了很多标准库的实现,如tuple和bind等,都充分利用了不定参数模板的语言特性,摆脱了之前“丑陋“的实现方式。看一下代码

template <typename... T> class Multityp{};

    上面代码即一个不定参数模板类的声明。这里T被称为模板参数包(template parameter pack)。它是一种新的模板参数类型,有了这样的参数类型就可以允许模板参数接受任意多个不同类型的不同参数。

Multitype<int, char, double> multi();
Multitype<int> multi2();
Multitype<int, char> multi3();

    使用的代码很直观,不需要额外解释。编译器会根据不同场景的调用(比如上面第一句),将多个参数打包成一个“单一”的模板参数T(上面的定义的模板参数包T),这样T就是一个包含int,char和double的参数包亦即类型集合。
    跟普通模板一样,参数包也可以是非类型的,即数值类型。

template <int... NT> class MultitypeInt{};
MultitypeInt<1,2,3> obj; //-->template <int,int,int> class MultitypeInt{};

    在编译器看来,一个模板参数包在推导出真正类型前,它仍然是一个参数(一个打包了n个类型的集合)。如果代码想应用它们时(即希望将它们展开时),这个过程成为解包(unpack)。

template <typename ... T> class Multitype
{
public:
    Multitype(T... params){}
};

    上面代码中的构造函数参数就是将模板参数中的参数包进行解包,解包成为一组对应的多类型形参。

不定参数模板实现的printf

    用不定参数模板实现这样功能的函数,实际上基本都要用到递归,接下来的代码实现跟惯常的函数递归方式很类似,比较容易理解,代码如下:

class print
{
public:
    template<typename FIRST, typename ...PACK>
    static
    void cppprintf(FIRST first, PACK... params)
    {
        std::cout << first;
        cppprintf(params...);
    }

    //重载函数版本,递归结束条件
    template<typename T>
    static
    void cppprintf(T end)
    {
        std::cout << end << std::endl;
    }
};
print::cppprintf("sdf", 123, 'c', 456, 123.123);

    上述代码即一个不定参数的打印函数实现,在笔者的编译器中,如果不把这两个函数声明在一个class内,编译不通过,这个问题我也没有找到明确的原因。不过不影响这里的演示,通过递归的调用(也需要编译器做递归的查找对应调用函数)实现了一个printf的功能。
    那么这种递归推导如何用在类模板中呢?实际上在标准库的tuple这样的实现中,就大量用到了这种推导,接下来我们来介绍一下。

std::tuple的实现原理

    在讲述实现原理前,我们先看一下tuple可以用来做什么。

// tuple example
#include <iostream>    
#include <tuple>       

int main ()
{
  std::tuple<int,char> foo (10,'x');
  auto bar = std::make_tuple ("test", 3.1, 14, 'y');

  std::get<2>(bar) = 100;                                    // access element

  std::get<0>(foo) = std::get<2>(bar);
  std::get<1>(foo) = 'z';

  return 0;
}

    上面代码应比较清楚的展示了tuple的能力,它是一个“元组”,包含了一些不同类型的对象,并且可以通过模板参数去访问某一个元组中的对象。除了类型不同外它和vector粗略也比较一下,也比较相像。一个是不定长度的不同类型的集合,另一个是不定长度的想同类型的集合。
    从tuple的功能来看,有两个重要的点,一个是任意多的类型,一个是任意多的参数。这个正好符合不定参数模板参数的特性。它的实现可以看一下简化的版本:

template<typename ...T>
class mytuple;

//偏特化版本
template<typename HEAD, typename ...TLIST>
class mytuple<HEAD, TLIST...> : public mytuple<TLIST...>
{
public:
    mytuple(HEAD head, TLIST... args) : mytuple<TLIST...>(args...), value(head)
    {
    }

    HEAD value;
};

//结束条件,特化版本
template<>
class mytuple<>
{
};

    利用不定参数模板,定义三个class,分别是版本类定义mytuple,和它的两个偏特化、特化版本。其中偏特化版本递归的继承了自身,特化版本是递归继承的结束条件。这样一个不定模板参数的类定义就会形成一个n层深的集成关系,每一层会有对应类型的一个成员变量value。那么这样整个数据的模型就形成了。举一个例子,mytuple<int, char, float>的对象内存模型是什么样子呢?如下图

tuple.png

    上图显示了mytuple<int, char, float>类型的一个对象的内存模型,比较清晰就不多做解释了,tuple正是利用这种递归继承形成的对象一层层存储不同类型数据的。
    那么如何像标准库函数一样把它里面的元素get出来呢?看一下下面的代码

template<int N, typename ...T>
struct mytupleat;

//类模板偏特化
template<int N, typename T, typename ...TLIST>
struct mytupleat<N, mytuple<T, TLIST...> >
{
    static_assert(sizeof...(TLIST) >= N, "wrong index");
    typedef typename mytupleat<N - 1, mytuple<TLIST...> >::value_type value_type;
    typedef typename mytupleat<N - 1, mytuple<TLIST...> >::tuple_type tuple_type;
};

//类模板偏特化
template<typename T, typename ...TLIST>
struct mytupleat<0, mytuple<T, TLIST...> >
{
    typedef T value_type;
    typedef mytuple<T, TLIST...> tuple_type;
};
///////////////////////////////////////////////////////////////////////////
template<int N, typename HEAD, typename ...TLIST>
typename mytupleat<N, mytuple<HEAD, TLIST...> >::value_type
mygettuple(mytuple<HEAD, TLIST...> tuple)
{
    typedef typename mytupleat<N, mytuple<HEAD, TLIST...> >::value_type VALUE;
    typedef typename mytupleat<N, mytuple<HEAD, TLIST...> >::tuple_type TUPLE;
    VALUE ret = ((TUPLE) tuple).value;
    return ret;
}
///////////////////////////////////////////////////////////////////////////
class TestMyTuple
{
public:
    static void execute()
    {
        mytuple<int, double, const char *> test(12, 13.12, "123");

        auto ntest = mygettuple<0>(test);
        auto dtest = mygettuple<1>(test);
        auto csztest = mygettuple<2>(test);

        mytuple<int> test2(22);
        auto ntest2 = mygettuple<0>(test2);
    }
};

    mytupeat类是用来通过类模板和其偏特化版本去递归定义某一个N(即类型index)对应的对象类型和mytuple的类型(是哪一层的基类)。结束条件是N = 0的情况,这时候两个typedef的类型最终找到了最后定义的类型。举例说明,N = 0的情况,直接应用偏特化版本struct mytupleat<0, mytuple<T, TLIST...> >,两个类型都找到了确切的定义;N > 0的情况,假如N = 2,当递归的找typedef的过程,N - 1 = 0时遇到边界,定义的T问value_type,此时的T正好是index = 2时候的类型,同理tuple_type亦然。
     mygettuple这个模板函数用来最终完成取出某一个index位置上的某一个类型的value。从调用反推来看一下,拿测试代码的mygettuple<1>(test)这一句举例,首先根据test对象的类型推导出mygettuple输入参数的类型是mytuple<int, double, const char * >亦即mytuple<int, T...>,继而返回值类型为mytupleat<1, mytuple<int, double, const char* > >::value_type,函数内部的两个typedef类型分别为mytupleat<1, mytuple<int, double, const char* > >::value_type和mytupleat<1, mytuple<int, double, const char* > >::tuple_type。上述这些类型推导还需要各自递归推导,最终value_type-->mytupleat<0, mytuple<double, const char* > >::value_type
tuple_type->mytupleat<0, mytuple<double, const char* > >::tuple_type。
类型推导出来后,很容易拿到相应对象中的value了。
    以上就是tuple的简单实现原理,标准库的实现更为复杂和全面,这里笔者只是用最简化的代码来展现如何运用不定参数模板以及应用它的技巧来实现一些炫酷的功能。

std::bind实现原理

    首先看一下bind用来做什么的。在c++98标准中标准库里的bind功能是通过bind1st和bind2nd两个模板函数实现的。它们的作用不赘述了,c++11标准库直接废弃了它们,用不定参数模板实现了bind来代替它们,现在的bind函数能力更强,更易用。那么bind用来做什么的?简单说它可以将函数(函数指针、functor、lambda)和函数所需的参数(任意个数参数)绑定为一个对象,我们在后续用到的时候可以直接调用这个对象的operator()函数即可实现对这个函数的调用。这个功能在某些场景极为方便,举个例子,后面我们将要介绍std::thread,并且要实现一个基于它的线程池,那么这个线程池需要动态传入每次线程执行的函数和函数参数,利用bind我们可以每次传入不同类型的函数,每一次函数调用的参数和类型都可以完全不同,极大的方便调用者使用,充分展现c++11标准带来的编程便利。回到bind,我们看一段展示bind功能的代码

    static void execute()
    {
        auto f = std::bind([](int a, char b, const char *c)
                           {
                               std::cout << "bindtest:" << a << b << c << std::endl;
                           }, 1, 'a', "abc");
        f();
    }

    上面代码直接调用std::bind返回一个绑定对象。传给bind的第一参数是函数(lambda),后面三个参数就是前面函数需要的三个参数,通过bind将它们绑定在一起。后面需要的时候调用f()即可完成调用。bind还有很多其他特性,比如用placeholder去改变调用参数位置等,这里不介绍这些更灵活的特性了,我们来看一下要实现bind的基本功能需要做些什么。首先我们需要定义一个类,这个类就是bind的返回值,我们将bind传入的参数都保存在这个类中,由这个类完成函数的存储和函数所需参数的存储,需要调用的时候,通过这个类的operator()函数来动态调用之前传入的函数。原理很简单,需要解决的问题是1、如何将不定参数存储起来2、调用的时候如何按参数顺序将之前存储的参数展开传给函数。直接看代码

//通过递归方式实现的一个简易的makeindex,
template<std::size_t ...>
struct IndexTuple{};

template<std::size_t N, std::size_t... Indexes>
struct MakeIndexes : MakeIndexes<N - 1, N - 1, Indexes...>{};

//递归继承结束条件, 同时也是对上面模板的一个偏特化
template<std::size_t... indexes>
struct MakeIndexes<0, indexes...>
{
    typedef IndexTuple<indexes...> type;
};

//--------------------------------------------------------------

template <typename Fn, typename ...Params>
class _MybindImpl
{
public:
    typedef typename MakeIndexes<sizeof...(Params)>::type __my_indices;

    Fn _fn;
    std::tuple<Params...> _params;

    _MybindImpl(Fn&& fn, Params&&... params) : _fn(std::forward<Fn>(fn)), _params(std::forward<Params>(params)...)
    {

    }

    void operator()()
    {
        return invoke(__my_indices());
    }

    template <std::size_t ..._Indx>
    void invoke(IndexTuple<_Indx...>)
    {
        _fn(std::get<_Indx>(_params)...);
    }
};

template<typename Fn, typename ...Params>
_MybindImpl<Fn, Params...> MyBind(Fn &&fn, Params &&... params)
{
    return _MybindImpl<Fn, Params...>(std::forward<Fn>(fn), std::forward<Params>(params)...);
}

class BindTest
{
public:
    static void execute()
    {
        auto f = std::bind([](int a, char b, const char *c)
                           {
                               std::cout << "bindtest:" << a << b << c << std::endl;
                           }, 1, 'a', "abc");
        f();

        int teswt = 12;
        auto f2 = MyBind([](int a, char b, const char *c)
                         {
                             std::cout << "Mybindtest:" << a << b << c << std::endl;
                         }, teswt, 'a', "abc");
        f2();
    }
};

    这段代码极简化的展现了bind的实现原理,这里忽略了很多细节,而且也没有处理函数的返回值,如果对其他细节有兴趣可以自行查阅bind的标准库实现。上面提出的问题1很好解决,用tuple完美解决。那么问题2呢?MakeIndexes就是我们自己实现的调用时将tuple参数全部展开的一个工具类,然后通过_MybindImpl的invoke最终在目标函数调用中将所有参数展开。
    MakeIndexes的模板和它的偏特化版本实际就是在利用递归条件的结束,将最终的参数index序列推导出来。举例子,MakeIndexes<3> --> MakeIndexes<3> : MakeIndexes<2, 2> -> MakeIndexes<2, 2> : MakeIndexes<1, 1, 2> -> MakeIndexes<1, 1, 2> : MakeIndexes<0, 0, 1, 2>(结束条件) -> typedef IndexTuple<0, 1, 2> type;
上面的推导过程很清晰了,最终实际就是需要一个IndexTuple的类型而已。
后续通过invoke函数将IndexTuple携带的不定模板参数展开,展开传递给std::get,这样就完成了调用目标函数传入任意参数的问题。我们再看一下invoke这个函数

    template <std::size_t ..._Indx>
    void invoke(IndexTuple<_Indx...>)
    {
        _fn(std::get<_Indx>(_params)...);
    }

    std::get<_Indx>(_params)...这种调用称为参数组展开(Parameter Pack Expansion),这个用法前面没有遇到过,c++11这种方便的语义,使程序有了很强大的复杂语义实现的能力。
    MakeIndexes所要实现的功能,在c++14标准库中有了标准实现--std::integer_sequence,有兴趣的同学可以查阅一下。

最后

    上面简单介绍了c++11的不定参数模板,以及其强悍语法带来的诸多语言层面上的提升,同时也简要介绍了一下tuple和bind的实现原理,接下来的文章中会用到它们去实现更为实用的功能,到那时候应当可以充分体验c++11带来的便捷的编程体验。

欢迎关注

微信关注下方公众号,第一时间获取干货硬货;公众号内回复【pdf】免费获取数百本计算机经典书籍,也欢迎加入技术群,里面有自动驾驶、嵌入式、搜索广告以及推荐等BAT大厂大佬
【Modern cpp】不定参数模板与std::tuple、std::bind实现原理

原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/320150

非原创文章文中已经注明原地址,如有侵权,联系删除

关注公众号【高性能架构探索】,第一时间获取最新文章

转载文章受原作者版权保护。转载请注明原作者出处!

(0)
上一篇 2023年2月27日 上午11:17
下一篇 2023年2月27日 上午11:21

相关推荐