boost源码剖析之:多重回调机制signal(下)

长平狐 发布于 2012/08/28 15:11
阅读 189
收藏 1

boost源码剖析之:多重回调机制signal()

 

刘未鹏

C++的罗浮宫(http://blog.csdn.net/pongba)

 

本文的上篇中,我们大刀阔斧的剖析了signal的架构。不过还有很多精微之处没有提到,特别是一个遗留问题还没有解决:如果用户注册的是函数对象(仿函数),signal又当如何处理呢?

 

下篇:高级篇

概述

在本文的上篇中,我们已经分析了signal的总体架构。至于本篇,我们则主要集中于将函数对象(即仿函数)连接到signal的来龙去脉。signal库的作者在这个方面下了很多功夫,甚至可以说,并不比构建整个signal架构的功夫下得少。

 

之所以为架构,其中必然隐藏着一些或重要或精妙的思想。

 

学过STL的人都知道,函数对象[1](function object)STL中的重要概念和基石之一。它使得一个对象可以像函数一样被调用,而调用形式又是与函数一致的。这种一致性在泛型编程中乃是非常重要的,它意味着泛化,而这正是泛型世界所有一切的基础。而函数对象又由于其携带的信息较之普通函数大为丰富,从而具有更为强大的能力。

 

所以signal简直是不得不支持函数对象。然而函数对象又和普通函数不同:函数对象会析构。问题在于:如果某个函数对象连接到signal,那么,该函数对象析构时,连接是否应该断开呢?这个问题,signal的设计者留给用户来选择:如果用户觉得函数对象一旦析构,相应的连接也应该自动断开,则可以将其函数对象派生自boost::signals::trackable类,意即该对象是可跟踪的。反之则不用作此派生。这种跟踪对象析构的能力是很有用的,在某些情况下,用户需要这种语义:例如,一个负责数据库访问及更新的函数对象,而该对象的生命期受某个管理器的管理,现在,将它连接到某个代表用户界面变化的signal,那么,当该对象的生命期结束时,对应的连接显然应该断开——因为该对象的析构意味着对应的数据库不再需要更新了。

 

signal库支持跟踪函数对象析构的方式很简单,只要将被跟踪的函数对象派生自boost::signals::trackable类即可,不需要任何额外的步骤。解剖这个trackable类所隐藏的秘密正是本文的重点。

 

架构

很显然,trackable类是整个问题的关键。将函数对象派生自该类,就好比为函数对象安上了一个跟踪器。根据C++语言的规则,当某个对象析构时,先析构派生层次最高(most derived)的对象,再逐层往下析构其子对象。这就意味着,函数对象的析构最终将会导致其基类trackable子对象的析构,从而在后者的析构函数中,得到断开连接的机会。那么,哪些连接该断开呢?换句话说,该断开与哪些signal的连接呢?当然是该函数对象连接到的signals。而这些连接则全部保存在一个list里面。下面就是trackable的代码:

 

     class trackable {

         typedef std::list<connection> connection_list;

        typedef connection_list::iterator connection_iterator;

        mutable connection_list connected_signals;

         ...                   

     }

 

connected_signals是个list,其中保存的是该函数对象所连接到的signals。只不过是以connection的形式来表示的。这些connection都是控制性[2]的,一旦析构则自动断开连接。所以,trackable析构时根本不需要任何额外的动作,只要让该list自行析构就行了。

 

了解了这一点,就可以画出可跟踪的函数对象的基本结构,如图四

 

图四

 

现在的问题是,每当该函数对象连接到一个signal,都会将相应connection的一个副本插入到其trackable子对象的connected_signals成员(一个list)中去。然而,这个插入究竟发生在何时何地呢?

 

在本文的上篇中曾经分析过连接的过程。对于函数对象,这个过程仍然是一样。不过,当时略过了一些细节,这些细节正是与函数对象相关的。现在一一道来:

 

如你所知,在将函数(对象)连接到signal时,函数(对象)会先被封装成一个slot对象,slot类的构造函数如下:

 

     slot(const F& f):slot_function(get_invocable_slot(f,tag_type(f)))

     {

       //一个visitor,用于访问f中的每个trackable子对象

       bound_objects_visitor  do_bind(bound_objects);

       //如果f为函数对象,则访问f中的每一个trackable子对象

      visit_each(do_bind,get_inspectable_slot[3](f,tag_type(f)));

       //创建一个connection,表示f与该slot的连接,这是为了实现“delayed-connect”

      create_connection();

}

 

bound_objectsslot类的成员,其类型为vector<const trackable*>。可想而知,经过第二行代码“visit_each(...)”的调用,该vector中保存的将是指向f中的各个trackable子对象的指针。

 

等等!你敏锐的发现了一个问题:前面不是说过,如果用户要让他的函数对象成为可跟踪的,则将该函数对象派生自trackable对象吗?那么,也就是说,如果f是个可跟踪的函数对象,那么其中的trackable子对象当然只有一个(基类对象)!但为什么这里bound_objects的类型却是一个vector呢?单单一个trackable*不就够了么?

 

在分析这个问题之前,我们先来看一段例子代码:

 

     struct S1:boost::signals::trackable

     {//该对象是可跟踪的!但并非一个函数对象

         void test(){cout<<"test/n";}

     };

     ...

     boost::signal<void()> sig;

     { //一个局部作用域

         S1 s1;

         sig.connect(boost::bind(&S1::test,boost::ref(s1)));

         sig(); //输出 “test”

     } //结束该作用域,s1在此析构,断开连接

     sig(); //无输出

 

boost::bind()&S1::test[4]“this”参数绑定为s1,从而生成一个“void()”型的仿函数,每次调用该仿函数就相当于调用s1.test(),然而,这个仿函数本身并非可跟踪的,不过,很显然,这里的s1对象一旦析构,则该仿函数就失去了意义,从而应该让连接断开。所以,我们应该使S1类成为可跟踪的(见struct S1的代码)。

 

然而,这又能说明什么呢?仍然只有一个trackable子对象!但是,答案已经很明显了:既然boost::bind可以绑定一个参数,难道不能绑定两个参数?对于一个延迟调用的函数对象[5],一旦其某个按引用语义传递的参数析构了,该函数对象也就相应失效了。所以,对于这种函数对象,其按引用传递的参数都应该是可跟踪的。在上例中,s1就是一个按引用传递的参数[6],所以是可跟踪的。所以,如果有多个这种参数绑定到一个仿函数,就会有多个trackable对象,其中任意一个对象的析构都会导致仿函数失效以及连接的断开。

 

例如,假设C1,C2类都是trackable的。并且函数test的类型为void(C1,C2)。那么boost::bind(&test,boost::ref(c1),boost::ref(c2))就会返回一个void()型的函数对象,其中c1,c2作为test的参数绑定到了该函数对象。这时候,如果c1c2析构,这个函数对象也就失效了。如果先前该函数对象曾连接到某个signal<void()>型的signal,则连接应该断开。

 

问题在于,如何获得绑定到某个函数对象的所有trackale子对象呢?

 

关键在于visit_each函数——我们回到slot的构造函数(见上文列出的源代码),其第二行代码调用了visit_each函数,该函数负责访问f中的各个trackable子对象,并将它们的地址保存在bound_objects这个vector中。

 

至于visit_each是如何访问f中的各个trackable子对象的,这并非本文的重点,我建议你自行参考源代码。

 

slot类的构造函数最后调用了create_connection函数,这个函数创建一个连接对象,表示函数对象和该slot的连接。咦?为什么和slot连接,函数对象不是和signal连接的吗?没错。但这个看似蛇足的举动其实是为了实现“delayed connect”,例如:

 

     void delayed_connect(Functor* f)

     {

         //构造一个slot,但暂时不连接

         slot_type slot(*f);

         //使用f做一些事情,在这个过程中f可能会被析构掉

         ...

         //如果f已经被析构了,则slot变为inactive态,则下面的连接什么事也不做

         sig.connect(slot);

     }

     ...

     Functor* pf=new Functor();

     delayed_connect(pf);

     ...

 

这里,如果在slot连接到sig之前,f“不幸析构了,则连接不会生效,只是返回一个空连接。

 

为了达到这个目的,slot类的构造函数使用create_connection构造一个连接,这个连接其实没有实际意义,只是用于监视函数对象是否析构。如果函数对象析构了,则该连接会变为断开态。下面是create_connection的源代码:

 

     摘自libs/signals/src/slot.cpp

void slot_base::create_connection()

    {

        basic_connection* con = new basic_connection();

        con->signal = static_cast<void*>(this);

        con->signal_data = 0;

        con->signal_disconnect = &bound_object_destructed;

        watch_bound_objects.reset(con);

          ...

     }

 

这段代码先new了一个连接,并将其三个成员设置妥当。由于该连接纯粹仅作监视该函数对象是否析构之用,并非真的连接slot,所以signal_data成员只需闲置为0,而signal_disconnect所指的函数&bound_object_destructed也只不过是个什么事也不做的空函数。关键是最后一行代码:watch_bound_objects乃是slot类的成员,类型是connection,这行代码使其指向上面新建的con连接对象。注意,在后面省略掉的部分代码中,该连接的副本也被保存到待连接的函数对象的各个trackable子对象中(前面已经提到(参见图四),这系保存在一个list中),这才真正使得监视成为可能!因为这样做了之后,一旦代连接的函数对象析构了,将会导致con连接为断开状态。从而在sig.connect(slot)时可以通过查询slot中的watch_bound_objects副本的连接状态得知该slot是否有效,如果无效,则返回一个空的连接。这里,connection巧妙的充当了一个监视器的作用。

 

说到这里,你应该也就明白了为什么basic_connectionsignalsignal_data成员的类型为void*而不是signal_base_impl*slot_iterator*——是的,因为函数对象不但连接到signal,还连接slot。将这两个成员类型设置为void*可以复用该类以使其充当监视器的角色。signal库的作者真可谓惜墨如金。

 

回到正题,我们接着考察如何将封装了函数对象的slot连接到signal。这里,我建议你先回顾本文的上篇,因为这与将普通函数连接到signal有很大一部分相同之处,只不过多做了一些额外的工作。

 

同样,可想而知的是,这个连接过程仍然是先将slot插入到signal中的slot管理器中去,并将signal的地址,插入后指向该slot的迭代器的地址,以及负责断开连接的函数地址分别保存到表示本次连接的basic_connection对象的三个成员[7]中去。这时,故事几乎已经结束了一半——用户已经可以通过该对象来控制相应连接了。但是,注意,只是用户!对于函数对象来说,不但用户能够控制连接,函数对象也必须能够控制连接,因为它析构时必须能够断开连接,所以,我们还需要将该连接对象的副本保存到函数对象的各个trackable子对象中去:

 

     摘自libs/signals/src/signal_base.cpp

     connection

      signal_base_impl::

        connect_slot(const any& slot,

                     const any& name,

                     const std::vector<const trackable*>& bound_objects)

     {

... //创建basic_connection对象并设置其成员

        

//下面的for循环将该连接的副本保存到各个trackable子对象中

         for(std::vector<const trackable*>::const_iterator i =

              bound_objects.begin();

            i != bound_objects.end();++i)

{

              bound_object binding;

          (*i)->signal_connected(slot_connection, binding);

              con->bound_objects.push_back(binding);

         }

         ...

     }

 

在上面的代码中,for循环遍历绑定到该函数对象的各个trackable子对象,并将该连接的副本slot_connection保存到其中。这样,当某个trackable子对象析构时,就会通过保存在其中的副本来断开该连接,从而达到跟踪的目的。

 

但是,这里还有个问题:这里实际的连接只有一个,但却产生了多个副本,分别操纵在各个trackable子对象手中,如果用户愿意,用户还可以操纵一个或多个副本。但是,一旦该连接断开——不管是由于某个trackable子对象的析构还是用户手动断开——则保存在各个trackable子对象中的该连接的副本都应该被删除掉。不然既占空间又没有任何意义,还会导致这样的情况:只要其中有一个trackable对象还没有析构,表示该连接的basic_connection对象就不会被delete掉。特别是当连接由用户断开时,每个未析构的trackable对象中都会仍留有一个该连接对象的副本,直到trackable对象析构时该副本才会被删除。这就意味着,如果存在一个长命百岁trackable函数对象,并在其生命期中频繁被用户连接到signal并频繁断开连接,那么,每次连接都会遗留一个连接副本在其trackable基类子对象中,这是个巨大的累赘。

 

那么,这个问题到底如何解决呢?basic_connection仍然是问题的核心,既然用户只能通过connection对象来控制连接,而connection对象实际上完全通过basic_connection来操纵连接,那么如何解决这个问题的责任当然落在basic_connection身上——既然它知道哪个函数(对象)连接到哪个signal并在其slot管理器中的位置,那么,为什么不能让它也知道该连接在各个trackable对象中的副本所在何处呢?

 

当然可以。答案就在于basic_connection的第四个成员bound_objects,其定义如下:

 

std::list<bound_object> bound_objects;

 

该成员正是用来记录该连接在各个trackable对象中的副本所在何处的。它的类型是std::list,其中每一个bound_object型的对象都代表某一个连接副本所在之处。有了它,在断开连接时,就可以依次删除各个trackable对象中的副本。

 

那么,这个bound_objects又是何时被填充的呢?当然是在连接时,因为只有在连接时才知道有几个trackable对象,并有机会将副本保存到它们内部。我们回顾上文的connect_slot函数的代码,其中有加底纹的部分刚才没有分析,这正是与此相关的。为了清晰起见,我们将分析以源代码注释的形式写出来:

 

     //bound_object对象保存的是连接副本在trackable对象中的位置

     bound_object binding;

    //调用的是trackable::signal_connected函数,该函数告诉trackable对象它已经连接到了signal,并提供连接的副本(第一个参数),该函数会将该副本插入到trackable的成员connected_signals(见篇首trackable类的代码)中去。并将插入的位置反馈给binding对象(第二个参数,按引用传递),这时候,通过binding就能够将该副本从trackable对象中删除。

(*i)->signal_connected(slot_connection, binding);

//将接受反馈后的binding对象保存到该连接的bound_objects成员中去,以便以后通过它来删除连接的副本

     con->bound_objects.push_back(binding);

 

要想完全搞清楚以上几行代码,我们还得来看看bound_object类的结构以及trackable::signal_connected到底干了些什么?先来看看bound_object的结构:

 

     摘自boost/signals/connection.hpp

     struct bound_object {

        void* obj;

        void* data;

        void (*disconnect)(void*, void*);

     }

 

发现什么特别的没有?是的,它的结构简直就是basic_connection的翻版,只不过成员的名字不同了而已。basic_connection因为是控制连接的枢纽,所以其三个成员表现的是被连接的slotsignal中的位置。而bound_object表现的是connection副本在trackable对象中的位置。在介绍bound_object的三个成员之前,我们先来考察trackable::signal_connected函数,因为这个函数同时也揭示了这三个成员的含义:

 

     摘自libs/signals/src/trackable.cpp

     void trackable::signal_connected(connection c,

bound_object& binding)

    {

      //connection副本插入到trackable对象中的connected_signals中去,connected_signals是个std::list<connection>型的容器,负责跟踪该对象连接到了哪些signal(见篇首的详述)。

      connection_iterator pos =

        connected_signals.insert(connected_signals.end(), c);

      //将该trackable对象中保存的connection副本设置为控制性的,从而该副本析构时才会自动断开连接。

      pos->set_controlling();

       //obj指针指向trackable对象,注意这里将trackable*转型为void*以利于保存。

      binding.obj = const_cast<void*>(reinterpret_cast<const void*>(this));

       //data指向connection副本在connected_signals容器中的位置,注意这里的转型

      binding.data = reinterpret_cast<void*>(new connection_iterator(pos));

       //通过这个函数指针,可以将这个connection副本删除:signal_disconnected函数接受objdata为参数,将connection副本erase

      binding.disconnect = &signal_disconnected;

    }

 

分析完了这段代码,bound_object类的三个成员的含义不言自明。注意,其最后一个成员是个函数指针,指向trackable::signal_disconnected函数,这个函数负责将一个connection副本从某个trackable对象中删除,其参数有二,正是bound_object的前两个成员objdata,它们合起来指明了一个connection副本的位置。

 

当这些副本在各个trackable子对象中都安置妥当后,连接就算完成了。我们再来看看连接具体是如何断开的,对于函数对象,断开它与某个signal的连接的过程大致如下:首先,与普通函数一样,将函数对象从signalslot管理器中erase掉,这个连接就算断开了。其次就是只与函数对象相关的动作了:将保存在绑定到函数对象的各个trackable子对象中的connection副本清除掉。这就算完成了断开signal与函数对象的连接的过程。当然,得看到代码心里才踏实,下面就是:

 

 

     void connection::disconnect()

     {

         if (this->connected()) {

shared_ptr<detail::basic_connection> local_con = con;

         //先将该函数指针保存下来

void (*signal_disconnect)(void*, void*) =

local_con->signal_disconnect;

         //然后再将该函数指针置为0,表示该连接已断开

        local_con->signal_disconnect = 0;

 

        //断开连接,signal_disconnect函数指针指向signal_base_impl::slot_disconnected函数,该函数在本文的上篇已作了详细介绍

        signal_disconnect(local_con->signal, local_con->signal_data);

 

        //清除保存在各个trackable子对象中的connection副本

        typedef std::list<bound_object>::iterator iterator;

        for (iterator i = local_con->bound_objects.begin();

             i != local_con->bound_objects.end(); ++i) {

     //通过bound_object的第三个成员,disconnect函数指针来清除该连接的每个副本

          i->disconnect(i->obj, i->data);

        }

      }

}

 

前面已经说过,bound_object的第三个成员disconnect指向的函数为trackable::signal_disconnected,顾名思义,“signal”已经“disconnected”了,该是清除那些多余的connection副本的时候了,所以,上面的最后一行代码“i->disconnect(...)”就是调用该函数来做最后的清理工作的:

 

     摘自libs/signals/src/trackable.cpp

     void trackable::signal_disconnected(void* obj, void* data)

{

  //将两个参数转型,还其本来面目

      trackable* self = reinterpret_cast<trackable*>(obj);

      connection_iterator* signal =

        reinterpret_cast<connection_iterator*>(data);

      if (!self->dying) {

         //connection副本erase

        self->connected_signals.erase(*signal);

      }

      delete signal;

}

 

这就是故事的全部。这个清理工作一完成,函数对象与signal就再无瓜葛,从此分道扬镳。回过头来再看看signal库对函数对象所做的工作,可以发现,其主要围绕着trackable类的成员connected_signalsbasic_connection的成员bound_objects而展开。这两个一个负责保存connection的副本以作跟踪之用,另一个则负责在断开连接时清除connection的各个副本。

 

分析还属其次,重要的是我们能够从中汲取到一些纳为己用的东西。关于trackable思想,不但可以用在signal中,在其它需要跟踪对象析构语义的场合也大可用上。这种架构之最妙之处就在于用户只要作一个简单的派生,就获得了完整的对象跟踪能力,一切的一切都在背后严密的完成。

 

蛇足&再谈调用

还记得在本文的上篇分析的调用部分吗?库的作者藉由一个所谓的“slot_call_iterator”来完成遍历slot管理器和调用slot的双重任务。slot_call_iteratorslot管理器本身的iterator语义几乎相同,只不过对前者解引用(dereference,即“*iter”)的背后其实调用了其指向的slot函数,并且返回的是slot函数的返回值。这种特殊的语义使得signal可以将slot_call_iterator直接交给用户制定的返回策略(如max_value<>min_value<>等),一石二鸟。但是这里面有一个难以察觉的漏洞:一个设计得不好的算法可能会使迭代器在相同的位置上出现冗余的解引用,例如,一个设计的不好的max_value<>可能会像这样:

 

    T max = *first++;

    for (; first != last; ++first)

      max = (*first > max)? *first : max;

 

这个算法本身的逻辑并没有什么不妥,只不过注意到其中*first出现了两次,这意味着什么?如果按照以前的说法,每一次解引用都意味着一次函数调用的话,那么同一个函数将被调用两次。这可就不合逻辑了。signal必须保证每个注册的函数有且仅有一次执行的机会。

 

解决这个问题的任务落在库的设计者身上,无论如何,一个普通用户写出上面的算法的确是件无可非议的事。一个明显的解决方案是将函数的返回值缓存起来,第二次或第N次在同一位置解引用时只是从缓存中取值并返回。signal库的设计者正是采用的这种方法,只不过,slot_call_iterator将缓存的返回值交给一个shared_ptr来掌管。这是因为,用户可能会拷贝迭代器,以暂时保存区间中的某个位置信息,在拷贝迭代器时,如果缓存中已经有返回值,即函数已经调用过了,则新的迭代器也因该引用那个缓存。并且,当最后一个引用该缓存的迭代器消失时,就是该缓存被释放之时,这正是shared_ptr用武之地。具体的实现代码请你自行参考boost/signals/detail/slot_call_iterator.hpp

 

值得注意的是,slot_call_iterator符合“single pass”(单向遍历)concept。对于这种类型的迭代器只能进行两种操作:递增和比较。这就防止了用户写出不规矩的返回策略——例如,二分查找(它要求一个随机迭代器)。如果用户硬要犯规,就会得到一个编译错误。

 

由此可见,设计一个完备的库不但需要技术,还要无比的细心。

 

结语

相对于C++精致的泛型技术的应用来说,其背后隐藏的思想更为重要。在signal库中,泛型技术的应用其实也不可不谓淋漓尽致,但是语言只是工具,重要的是解决问题的思想。从这篇文章可以看出,作者为了构建一个功能完备,健壮,某些特性可定制的signal架构付出了多少努力。虽然某些地方看似简单,如connection对象,但是都是经过反复揣摩,时间检验后作出的设计抉择。而对于函数对象,更是以一个trackable基类就实现了完备的跟踪能力。以一个函数对象来定制返回策略则是符合policy-based设计的精髓。另外还有一些细致入微的设计细节,本篇并没有一一分析,一是为了让文章更紧凑,二是篇幅——只讲主要脉络文章尚已如此,再加上各个细节则更是了得了,干脆留给你自行理解,你将boost的源代码和本文列出的相应部分比较后或会发现一些不同之处,那些就是我故意省略掉的细节所在了。对于细节有兴趣的不妨自己分析分析。

 

目录(展开boost源码剖析》系列文章)

 



[1] 函数对象即重载了operator()操作符的对象,故而可以以与函数调用一致的语法形式来调用。又称为functor,中文译为仿函数

[2] 控制性是指该connection析构时会顺便将该连接断开。反之则不然。关于控制性非控制性connection的详细讨论见本文的上篇。

[3] get_inspectable_slot()当且仅当f是个reference_wrapper时,返回f.get()——即其中封装的真实的函数(对象)。其它时候,该函数调用等同于f。关于reference_wrapper的详细介绍见boost的官方文档。

[4] &S1::test为指向成员函数的指针。其调用形式为(this_ptr->*mem_fun_ptr)()(this_ref.*mem_fun_ptr)(),而从一般语义上说,其调用形式为mem_fun_ptr(this_ref)mem_fun_ptr(this_ptr)。所以,boost::bind可以将其“第一个”参数绑定为s1对象。

[5] command模式,其中封装的command对象就是一个延迟调用的函数对象,它暂时保存某函数及其调用的各个参数,并在恰当的时候调用该函数。

[6] boost::ref(s1)生成一个boost::reference_wrapper<S1>(s1)对象,其语义与裸引用几乎一样,只不过具有拷贝构造,以及赋值语义,这有点像java里面的对象引用。具体介绍见boost的官方文档。

[7] signal成员指向连接到的signalsignal_data成员指向该函数在signal中保存的位置(一般为迭代器),而signal_disconnect则是个函数指针,负责断开连接,将前两个成员作为参数传给它就可以断开连接。


原文链接:http://blog.csdn.net/pongba/article/details/1561083
加载中
返回顶部
顶部