C++的杂七杂八:如何实现一个简单的bind

/ 0评 / 1

Published by orzz.org(). (https://orzz.org/cxx-bind/)

这篇文的草稿我是在2014年5月11号开始打的,可是拖拖拉拉直到现在才真正动笔写,自己对自己也是醉了。。
之所以写bind而不是什么其他的东西,是因为bind在各种C++的utility里面可以说是最能体现出“利用语言本身来拓展语言功能”这一特征的了。

在C++98/03的时代,人们为了让C++具有把函数和参数打包成闭包(closure)这一能力而发明了bind,从而使C++不仅可以存储算法执行的结果,还可以打包算法和算法的传入参数,存储算法执行的动作,直到调用这个闭包的时候才释放出参数并参与算法计算。有了这个东西之后,再配合可以存储任何可调用体的function对象,不少面向对象里麻烦的调用关系可以被简化到不像话的地步(具体可参看此文:以boost::function和boost:bind取代虚函数)。

在C++98/03下实现一个好用的bind是痛苦的。看看Boost.Bind,还有我自己,为了实现一个完善的bind折腾遍了C++的奇巧淫技……这是很划不来的,平时学习或兴趣玩一玩还可以,真在项目工程里这样做了,如何维护会是一个很头痛的事情。

在C++11里事情变得很不一样了。首先stl里就已经给我们提供了好用的std::bind,然后再就是语言本身的进化,让“写一个bind”之类的事情变得无比简单。于是我去年曾花了点时间,用C++11写了下面这个200多行的“simple bind”,用来让自己被C++98/03腌入味的思维习惯向C++11转换一下:

这坨代码在阅读的时候,需要从后往前看。首先,我们来看下最后的这个函数:

这里有几个小地方需要说明下。
第一,fr是一个仿函数。bind想要实现的功能,目的就是要返回一个可以被施以括号操作符()进行类似函数调用的东西,因此fr是一个仿函数是很自然的事情。
第二,fr是一个类模板,参数是F&&和P&&...。这样做的目的是,fr需要保存bind参数中的f和args,自然需要在编译期获得它们的类型。bind的函数模板参数可以在编译的时候自动帮我们推导出类型,因此把类型传递给fr就可以了。为何是F&&和P&&,而不是F和P呢?这是因为F&&、P&&是通过引用折叠特征推导出来的携带了参数左右值属性的引用类型,这样处理可以让我们在写fr的时候可以直接利用这些引用类型来作为构造时的参数,省下不少类型转换的麻烦。

那么接下来,该看一下类模板fr了。首先,fr需要是一个仿函数,它需要有一个可以接受任意参数的operator();
然后,很自然的,它需要有可以可以接收f和args的构造函数,然后它就成了这个样子:

那么这里用来储存f和args的分别是call_和args_,它们俩很显然是fr的成员变量。
它们俩的类型分别应该是std::decay<FuncT>::type和std::tuple<typename std::decay<ParsT>::type...>。另外,fr还必须有一个拷贝构造函数和一个移动构造函数,把这些加上,fr就变成了这个样子:

蛋疼的是,这样虽然符合标准,但在VS2013下却是编译不过的,原因见此:Move Constructor - invalid type for defaulted constructor VS 2013,因此我们不得不给默认的移动构造函数加上一个空实现:

做好了这些以后,可以说bind的框架已经搭起来了,接下来需要实现最核心的部分,也就是fr的operator()。在写operator()之前,我们必须解决下面几个问题:

  • 1. 返回值(return_type)应该如何萃取?
  • 2. 已打包好的args_如何一个个的放入call_中进行调用?
  • 3. 在真正被调用时,如何将绑定时指定的占位符替换成真正的实参?
  • 首先,我们来想办法从callable_type中把返回值类型return_type萃取出来:

    如上,我们构建了一个traits模板,当它遇到普通函数指针或类成员函数指针时会直接返回函数的返回值类型;当它遇到普通指针时,会取出指针内容的类型再次放入traits里;当遇到的是普通类型,则尝试取出类型的operator()成员函数指针,并把此指针类型放入traits。
    于是我们上面的匹配规则覆盖了普通函数指针、类成员函数指针、普通指针和仿函数。

    好了,返回值类型的问题解决了,接下来,打包好的args如何解包呢?
    首先,打包好的args实际上是一个std::tuple。那么从args中获取第N个元素的方法则是std::get<N>(args_)。在使用bind的时候,我们会为待绑定的function指定它的参数,因此fr的typename... ParsT变参即保存了参数个数。
    于是解包的过程就变成了如何在编译期通过ParsT得到一个“0, 1, 2...”的编译期常数数列。
    我们可以通过这个模板元gen来完成保存了编译期常数数列seq的计算:

    有了上面的gen以后,若ParsT包含了3个参数,gen<ParsT...>::seq_type的类型即为seq<0, 1, 2>。
    让我们先不管占位符_1、_2……之类的处理,只考虑bind时指定的所有实参,我们可以构造这样的一个辅助函数来完成call_的调用:

    上面的写法里,把ParsT...重新打包为一个std::tuple类型,然后使用变参模板的自动展开配合std::tuple_element和std::get把ParsT和args_一个个解出来;接着使用static_cast把使用std::get得到的实参还原为绑定时的类型,放入call_中。
    当然,直接通过call_(...)这样调用肯定是错误的,因为call_有可能是一个普通指针,或一个类成员函数指针,必须再引入一个辅助函数invoke来做一次转换。

    下面我们来针对各种情况实现invoke函数。首先,很自然的,普通函数指针和仿函数指针都可以使用这个invoke:

    但是由于当f不是右值的时候,F是一个引用类型,所以我们需要在判断is_pointer之前先remove_reference:

    然后,当f是一个类成员函数时,有两种可能:1. 第一个参数是一个指针;2. 第一个参数是一个对象。我们先来考虑第一个参数是指针的情况:

    同普通函数一样,参数和函数类型都先去掉了引用再进行判断。
    当第一个参数是一个对象的时候,情况变得稍稍有些复杂。因为这个参数有可能是一个std::reference_wrapper。我们先把这种情况排除掉:

    相比起第一参数为指针的情况,第一参数为对象(引用)其实就是把->*操作符换成了.*操作符。
    那么当考虑了std::reference_wrapper以后,很显然,我们需要把wrapper中真正的类型拨出来,否则无法直接使用.*操作符:

    最后,我们来考虑仿函数,非常简单,把指针和成员函数指针的情况过滤掉,剩下的就是专门处理仿函数的case了:

    invoke做好了,我们前面的do_call就可以这样写了:

    接下来,我们来考虑占位符(placeholder)。
    占位符本身只是一个标记,一种特殊的类型对象。我们可以使用一个空的类模板,并对其进行实例化来完成_1 - _20的定义:

    上面的写法直接用了const来定义占位符,按照C++标准,这些占位符默认是static的。
    当然了,比较好的做法应该是使用extern const,不过这样就需要一个cpp来对这些占位符提供定义了。好在编译器是聪明的,对于static的全局变量,虽然原则上不同的独立编译单元会重新实例化,但如果它们全是一样的,而且又没有在运行时被修改,它们一般会被优化为同一份内存。

    占位符要完成“占位”的功能,需要我们从两方面把它筛选出来,并替换为外部实际调用闭包时向operator()传入的参数。
    首先,当然是实参本身的替换了。也就是在调用invoke的时候,向invoke传递的参数在传递之时就应当完成替换;
    然后,是参数类型的替换。我们在向invoke传递参数的同时,还使用std::tuple_element从params_t中提取出了对应的类型,并对std::get的结果进行了static_cast之后才能正确的invoke。因此params_t必须在std::tuple_element之前,把其中的placeholder换成对应的实参类型。

    对于占位符对象的替换,我们可以使用一个select函数来完成。通过C++的函数重载,其实根本不需要祭出“SIFNAE”之类的大招就可以很轻松的把placeholder剃出来:

    这里的tp,是调用operator()时传递的所有实参的tuple,而第二个参数则是args_通过std::get解包后得到的参数。偿若args_解包得到的是placeholder,那么select就会使用tp并解包出对应位置的参数传回去;否则就仍然使用args_解包得到的参数。

    从params_t中把placeholder的类型置换掉稍微有些麻烦,我们需要用一点模板元的思想去处理这个问题。考虑有这么一个模板merge,它第一个模板参数是调用operator()时传递的实参的类型tuple,后面则是变参列表,传递的是bind时的所有参数类型,那么我们可以先定义出merge的基本样子:

    merge<std::tuple<ParsT...>>的模板特化是为了处理bind无参数的情况,这个时候只有用户在调用operator()时传递的参数列表。由于bind时没有指定任何一个参数,operator()的参数将全部被忽略。
    那么,后面的置换过程和select其实是差不多的,只是select使用的是重载,返回的是一个运行时的对象;而merge的武器则是特化,返回的是一个类型:

    这里匹配占位符用的是const placeholder<N>&,原因是特化必须精准的给出需要匹配的类型,而不像重载,会自动选择最合适的进行适配。
    有了invoke、select和merge以后,我们就可以把operator()写完整了:

    以上,一个简单的bind构建完毕。上面的代码没有考虑类成员的绑定(只考虑了类成员函数),如果读者感兴趣,可自行增加此功能。


    友情链接:qicosmos - std::bind技术内幕

    Published by orzz.org(). (https://orzz.org/cxx-bind/)

    发表回复

    您的电子邮箱地址不会被公开。 必填项已用*标注

    此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据