上篇:架构篇

引入

所谓事件机制,简而言之,就是用户将自己的一个或多个回调函数挂钩到某个事件上,一旦事件被触发,所有挂钩的函数都被调用。

 

毫无疑问,事件机制是个十分有用且常用的机制,不然C#也不会将它在语言层面实现了。

 

但是C++语言并无此种机制。

 

幸运的是boost库的开发者们替我们做好了这件事(事实上,他们做的还要更多些)。他们的类称作signal,即信号的意思,当信号发出的时候,所有注册过的函数都将受到调用。这与事件本质上完全一样。

 

简单情况下,你只需要这样写:

 

double square(double d){return pi*r*r;} //面积

double circle(double d){return 2*pi*r;} //周长

//double(double)是一个函数类型,意即:接受一个double型参数,返回double

signal<double(double)[1]> sig;

sig.connect(&square); //sig注册square

sig.connect(&circle);//注册circle

//触发该信号,sig会自动调用square(3.14)circle(3.14),并返回最后一个函数,circle()的返回值

double c=sig(3.14);  //assert(c==circle(3.14))

 

signal能够维护一系列的回调函数,并且,signal还允许用户指定函数的调用顺序,signal还允许用户定制其返回策略,默认情况下返回(与它挂钩的)最后一个函数的返回值,当然你可以指定你自己的返回策略”(比如:返回其中的最大值),其中手法,甚为精巧。另外,如果注册的是函数对象(仿函数)而非普通函数,则signal还提供了跟踪能力,即该函数对象一旦析构,则连接自动断开,其实现更是精妙无比。

 

俗语云:熟读唐诗三百首,不会吟诗也会吟。写程序更是如此。如果仔细体会,会发现signal的实现里面隐藏了许许多多有价值的思想和模式。何况boost库是个集泛型技术之大成的库,其源代码本身就是一笔财富,对于深入学习C++泛型技术是极好的教材。所以本文不讲应用,只讲实现,你可以边读边参照boost库的源代码[2]。另外,本文尽量少罗列代码,多分析架构和思想,并且列出的代码为了简洁起见,往往稍作简化[3],略去了一些细节,但是都注明其源文件,自行参照。

 

在继续往下读之前,建议大家先看看boost库的官方文档,了解signal的各种使用情况,这样,在经历下面繁复的分析过程时心中才会始终有一个清晰的脉络。事实上,我在阅读代码之前也是从各种例子入手的。

 

架构

Signal的内部架构,如果给出它的总体轮廓,非常清晰明了。见下图:

 

图一

               

 

显然,signal在内部需要一个管理设施来管理用户所注册的函数(这就是图中的slot manager),从根本上来说,boost::signal中的这个slot“管理器就是multimap(如果你不熟悉multimap,可以参考一些STL方面的书籍(如《C++ STL》《泛型编程与STL》)或干脆查询MSDN。这里我只简单的说一下——multimap将键(key)映射(map)到键值(键和键值的类型可以是任意),就像字典将字母映射到页码一样。)它负责保存所谓的slot每一个slot其实本质上是一个boost::function[4]函数对象该函数对象封装了用户注册给signal回调的函数(或仿函数)。当然,slot是经过某种规则排序的。这正是signal能够控制函数调用顺序的原因。

 

当你触发signal时,其内部迭代遍历管理器”——multimap,找出其中保存的所有函数或函数对象并逐一调用它们。

 

听起来很简单,是不是?但是我其实略去了若干细节,譬如,如何让用户控制某个特定的连接?如何控制函数的调用顺序?如何实现可定制的返回策略?等等。

 

看来设计一个“industry-strength”signal并非一件易事。事实上,非常不易。然而,虽然我们做不到,却可以看看大师们的手笔。

 

我们从signal的最底层布局开始,signal的底层布局十分简单,由一个基类signal_base_impl来实现。下面就是该基类的代码:

 

摘自boost/signals/detail/signal_base.hpp

class signal_base_impl {

public:

typedef function2<bool, any, any> compare_type;

private:

typedef std::multimap<any, connection_slot_pair, compare_type> slot_container_type; //multimap作为slot管理器的类型

 

    //遍历slot容器的迭代器类型

typedef slot_container_type::iterator slot_iterator;

     //slot容器内部元素的类型,事实上,那其实就是std::pair<any,connection_slot_pair>

    typedef slot_container_type::value_type stored_slot_type;

 

     //这就是slot管理器,唯一的数据成员——一个multimap,负责保存所有的slot

    mutable slot_container_type slots_;

...

};

 

可以看出slot管理器的类型是个multimap,其键(key)类型却是any[5],这是个泛型的指针,可以指向任何对象,为什么不是整型或其它类型,后面会为你解释。

以上是主要部分,你可能会觉得奇怪,为什么保存在slot管理器内部的元素类型是个怪异的connection_slot_pair而不是boost::function,前面不是说过,slot本质上就是boost::function对象么?要寻求答案,最好的办法就是看看这个类型定义的代码,源代码会交代一切。下面就是connection_slot_pair的定义:

 

摘自boost/signals/connection.hpp

struct connection_slot_pair {

//connection类用来表现连接这个概念,用户通过connection对象来控制相应的连接,例如,调用成员函数disconnect()则断开该连接

connection first;

//any是个泛型指针类,可以指向任何类型的对象

    any second;

//封装用户注册的函数的boost::function对象实际上就由这个泛型指针来持有

...

};

 

原来,slot管理器内部的确保存着boost::function对象,只不过由connection_slot_pair里的second成员——一个泛型指针any——来持有。并且,还多出了一个额外的connection对象——很显然,它们是有关联的——connection成员表现的正是该functionsignal的连接。为什么要多出这么一个成员呢?原因是这样的:connection一般掌握在用户手中,代码象这样:

 

connection con=sig.connect(&f); // 通过con来控制这个连接

 

signal如果在该连接还没有被用户断开(即用户还没有调用con.disconnect())前就析构了,自然要将其中保存的所有slot一一摧毁,这时候,如果slot管理器内部没有保存connection的副本,则slot管理器就无法对每个slot一一断开其相应的连接,从而控制在用户手中的connection对象就仿佛一个成了一个野指针,这是件很危险的事情。从另一个方面说,既然slot管理器内部保存了connection的副本,则只要让这些connection对象析构的时候能自动断开连接就行了,这样,即使用户后来还试图断开手里的con连接,也能够得知该连接已经断开了,不会出现危险。有关connection的详细分析见下文。

 

根据目前的分析,signal的架构可以这样表示:

 

图二

    

 

boost::signals::connection

connection类是为了表现signal与具体的slot之间的连接这种概念。signalslot安插妥当后会返回一个connection对象,用户可以持有这个对象并以此操纵与它对应的连接。而每个slot自己也和与它对应的connection呆在一起(见上图),这样slot管理器就能够经由connection_slot_pair中的first元素来管理连接,也就是说,当signal析构时,需要断开与它连接的所有slot,这时就利用connection_slot_pair中的first成员来断开连接。而从实际上来说,slot管理器在析构时却又不用作任何额外的工作,只需按部就班的析构它的所有成员(slot)就行了,因为connection对象在析构时会考虑自动断开连接(当其内部的is_controlling标志为true时)。

 

要注意的是,对于同一个连接可能同时存在多个connection对象来表现(和控制)它,但始终有一个connection对象是和slot呆在一起的,以保证在signal析构时能够断开相应的连接,其它连接则掌握在用户手中,并且允许拷贝。很显然,一旦实际的连接被某个connection断开,则对应于该连接的其它connection对象应该全部失效,但是库的设计者并不知道用户什么时候会拷贝connection对象和持有多少个connection对象,那么用户经过其中一个connection对象断开连接时,其它connection对象又是如何知道它们对应的连接是否已经断开呢?原因是这样的:对于某个特定连接,真正表现该连接的只有唯一的一个basic_connection对象。而connection对象其实只是个外包类,其中有一个成员是个shared_ptr[6]类型的智能指针,从而对应于同一个连接的所有connection对象其实都通过这个智能指针指向同一个basic_connection对象,后者唯一表现了这个连接。经过再次精化后的架构图如下:

 

图三

 

这样,当用户通过其中任意一个connection对象断开连接(或signal通过与slot保存在一块的connection对象断开连接)时,connection对象只需转交具体表现该连接的唯一的basic_connection对象,由它来真正断开连接即可。这里,需要注意的是,断开连接并非意味着唯一表示该连接的basic_connection对象的析构。前面已经讲过,connection类里有一个shared_ptr智能指针指向basic_connection对象,所以,当指向basic_connection的所有connection都析构掉后,智能指针自然会将basic_connection析构。其实更重要的原因是,从逻辑上,basic_connection还充当了信息中介——由于控制同一连接的所有connection对象都共享它,从而都可以查看它的状态来得知连接是否已经断开,如果将它delete掉了,则其它connection就无从得知连接的状态了。所以这种设计是有良苦用心的。正因此,一旦某个连接被断开,则对应于它的所有connection对象都可得知该连接已经断开了。

 

对于connection,还有一个特别的规则:connection对象分为两种,一种是控制性的,另一种是非控制性的。掌握在用户手中的connection对象为非控制性的,也就是说析构时不会导致连接的断开——这符合逻辑,因为用户手中的connection对象通常只是暂时的复制品,很快就会因为结束生命期而被析构掉,况且,signal::connect()返回的connection对象也是临时对象,用户可以选择丢弃该返回值(即不用手动管理该连接),此时该返回值会立即析构,这当然不应该导致连接的断开,所以这种connection对象是非控制性的。而保存在slot管理器内部,与相应的slot呆在一起的connection对象则是控制性的,一旦析构,则会断开连接——这是因为它的析构通常是由signal对象的析构导致的,所谓树倒猢狲散signal都不存在了,当然要断开所有与它相关的连接了。

 

了解了这种架构,我们再来跟踪一下具体的连接过程。

 

连接

signal注册一个函数(或仿函数)甚为简单,只需调用signal::connect()并将该函数(或仿函数)作为参数传递即可。不过,要注意的是,注册普通函数时需提供函数的地址才行(即“&f”),而注册函数对象时只需将对象本身作为参数。下面,我们从signal::connect()开始来跟踪signal的连接过程。

 

前提:下面跟踪的全过程都假设用户注册的是普通函数,这样有助于先理清脉络,至于注册仿函数(即函数对象)时情况如何,将在高级篇中分析。

 

源代码能够说明一切,下面就是signal::connect()的代码:

 

     template<...>

     connection signal<...>::connect(const slot_type& in_slot)

     {...}

 

这里,我们先不管connect()函数内部是如何运作的,而是集中于它的唯一一个参数,其类型却是const slot_type&,这个类型其实对用户提供的函数(或仿函数)进行一重封装——封装为一个“slot”。至于为什么要多出这么一个中间层,原因只是想提供给用户一个额外的自由度,具体细节容后再述。

 

slot_type其实只是一个位于signal类内部的typedef,其真实类型为slot类。

 

很显然,这里,slot_type的构造函数将被调用(参数是用户提供的函数或仿函数)以创建一个临时对象,并将它绑定到这个const引用。下面就是它的构造函数:

 

     template<typename F>

    slot(const F& f) : slot_function(f)

    {

      ... //这里,我们先略过该构造函数里面的代码(后面再回顾)

}

 

可以看出,用户给出的函数(或仿函数)被封装在slot_function成员中,slot_function的类型其实是boost::function<...>,这是个泛型的函数指针,封装任何签名兼容的函数及仿函数。将来保存在slot管理器内部的就是它。

 

下面,slot临时对象构造完毕,仍然回到signal::connect()来:

 

摘自boost/signals/signal_template.hpp

connection signal<...>::connect(const slot_type& in_slot)

{

     ...

         return impl->connect_slot(in_slot.get_slot_function(),

                              any(),

                              in_slot.get_bound_objects());

}

 

这里,signal将一切又交托给了其基类的connect_slot()函数,并提供给它三个参数,注意,第一个参数in_slot.get_slot_function()返回的其实正是刚才所说的slot类的成员slot_function,也正是将要保存在slot管理器内部的boost::function对象。而第二个参数表示该用户注册函数的优先级,

 

signal::connect()其实有两个重载版本,第一个只有一个参数,就是用户提供的函数,第二个却有两个参数,其第一个参数为优先级,默认是一个整数。这里,我们考察的是只有一个参数的版本,意味着用户不关心该函数的优先级,所以默认构造一个空的any()对象(回忆一下,slot管理器的键(key)类型为any)。至于第三个参数仅在用户注册函数对象时有用,我们暂时略过,在高级篇里再详细叙述。现在,继续追踪至connect_slot()的定义:

 

摘自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以表现本连接——注意,一个连接只对应于一个basic_connection对象,但可以有多个connection对象来操纵它。具体原因上文有详述。