上篇:架构篇
引入
所谓“事件”机制,简而言之,就是用户将自己的一个或多个回调函数挂钩到某个“事件”上,一旦“事件”被触发,所有挂钩的函数都被调用。
毫无疑问,事件机制是个十分有用且常用的机制,不然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)> 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库的源代码。另外,本文尽量少罗列代码,多分析架构和思想,并且列出的代码为了简洁起见,往往稍作简化,略去了一些细节,但是都注明其源文件,自行参照。
在继续往下读之前,建议大家先看看boost库的官方文档,了解signal的各种使用情况,这样,在经历下面繁复的分析过程时心中才会始终有一个清晰的脉络。事实上,我在阅读代码之前也是从各种例子入手的。
架构
Signal的内部架构,如果给出它的总体轮廓,非常清晰明了。见下图:
图一
显然,signal在内部需要一个管理设施来管理用户所注册的函数(这就是图中的slot manager),从根本上来说,boost::signal中的这个slot“管理器”就是multimap(如果你不熟悉multimap,可以参考一些STL方面的书籍(如《C++ STL》《泛型编程与STL》)或干脆查询MSDN。这里我只简单的说一下——multimap将键(key)映射(map)到键值(键和键值的类型可以是任意),就像字典将字母映射到页码一样。)它负责保存所谓的slot,每一个slot其实本质上是一个boost::function函数对象,该函数对象封装了用户注册给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,这是个泛型的指针,可以指向任何对象,为什么不是整型或其它类型,后面会为你解释。
以上是主要部分,你可能会觉得奇怪,为什么保存在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成员表现的正是该function与signal的连接。为什么要多出这么一个成员呢?原因是这样的: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之间的“连接”这种概念。signal将slot安插妥当后会返回一个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类型的智能指针,从而对应于同一个连接的所有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对象来操纵它。具体原因上文有详述。
basic_connection* con = new basic_connection();
connection slot_connection;
slot_connection.reset(con);
std::auto_ptr<slot_iterator> saved_iter(new slot_iterator());
//用户注册的函数在此才算真正在signal内部安家落户——即将它插入到slot管理器(multimap)中去
slot_iterator pos =
slots_.insert(stored_slot_type(name,
connection_slot_pair(slot_connection,slot)
));
//保存在slot管理器内部的connection对象应该设为“控制性”的。具体原因上文有详述。
pos->second.first.set_controlling();
*saved_iter = pos;
//下面设置表现本连接的basic_connection对象的各项数据,以便管理该连接。
con->signal = this; //指向连接到的signal
con->signal_data = saved_iter.release();//一个iterator,指出回调函数在signal中的slot管理器中的位置
con->signal_disconnect = &signal_base_impl::slot_disconnected; //如果想断开连接,则应该调用此函数,并将前面两项数据作为参数传递过去,则回调函数将被从slot管理器中移除。
...
return slot_connection;//返回该连接
}
这个函数结束后,连接也就创建完了,看一看最后一行代码,正是返回该连接。
从上面的代码可以看出,basic_connection对象有三个成员:signal,signal_data,signal_disconnect,这三个成员起到了控制该连接的作用。源代码上的注释已经提到,成员signal指向连接到的是哪个signal。而signal_data其实是个iterator,指明了该slot在slot管理器中的位置。最后,成员signal_disconnect则是个void(*)(void*,void*)型的函数指针,指向一个static成员函数——signal_base_impl::slot_disconnected。以basic_connection中的signal和signal_data两个成员作为参数来调用这个函数就能够断开该连接。即:
(*signal_disconnect)(local_con->signal, local_con->signal_data);
然而,具体如何断开连接还得看slot_disconnected函数的代码(注意将它和上面的connect_slot函数的代码作一个比较,它们是几乎相反的过程)
摘自libs/signals/src/signal_base.cpp
void signal_base_impl::slot_disconnected(void* obj, void* data)
{
signal_base_impl* self = reinterpret_cast<signal_base_impl*>(obj);//指明连接到的是哪个signal
//指出slot在slot管理器中的位置
std::auto_ptr<slot_iterator> slot(
reinterpret_cast<slot_iterator*>(data));
... //省略部分代码。
self->slots_.erase(*slot);//将相应的slot从slot管理器中移除
}
值得注意的是,basic_connection中的两个成员:signal和signal_data的类型都是void*,具体原因在高级篇里会作解释。而slot_disconnected函数的代码不出所料:先将两个参数的类型转换为合适的类型,还其本来面目:一个是signal_base_impl*,另一个是指向迭代器的指针:slot_iterator*,然后调用slots_上的erase函数将相应的slot移除,就算完成了这次disconnect。这简直就是connect_slot()的逆过程。
这里,你可能会有疑问:这样就算断开了连接?那么用户如果不慎通过某个指向该basic_connection的connection再次试图断开连接又当如何呢?更可能的情况是,用户想要再次查询该连接是否断开。如此说来,basic_connection中是否应该有一个标志,表示该连接是否已断开?完全不必,其第三个成员signal_disconnect是个函数指针,当断开连接后,将它置为0,不就是个天然的标志么?事实上,connection类的成员函数connected()就是这样查询连接状态的:
摘自boost/signals/connection.hpp
bool connected() const
{
return con.get() && con->signal_disconnect;
}
再次提醒一下,con是个shared_ptr,指向basic_connection对象。并且,尤其要注意的是,连接断开后,表示该连接的basic_connection对象并不析构,也不能析构,因为它还要充当连接状态的标志,以供仍可能在用户手中的connection对象来查询。当指向它的所有connection对象都析构时,根据shared_ptr的规则,它自然会析构掉。
好了,回到主线,连接和断开连接的大致过程都已经分析完了。其中我略去了很多技术细节,尽量使过程简洁,这些技术细节大多与仿函数有关——假若用户注册的是个仿函数,就有得折腾了,其中曲折甚多,我会在高级篇里详细分析。
排序
跟踪完了连接过程,下面是真正的调用过程,即触发了signal,各个注册的函数均获得一次调用,这个过程逻辑上颇为简单:从slot管理器中将它们一一取出并调用一次不就得了?但是,正如前面所说的,调用可是要考虑顺序的,各个函数可能有着不同的优先级,这又该如何管理呢?问题的关键就在于multimap的排序,一旦将函数按照用户提供的优先级排序了,则调用时只需依次取出调用就行了。那么,排序准则是什么呢?如你所知,一个signal对象sig允许注册这样一些函数:
sig.connect(&f0); //f0没有优先级
sig.connect(1,&f1);//f1的优先级为1
sig.connect(2,&f2); //f2的优先级为2
sig.connect(&f3); //f3没有优先级
这时候,这四个函数的顺序是f1,f2,f0,f3。准则这样的,如果用户为某个函数提供了一个优先级,如1,2等,则按优先级排序,如果没有提供,则相应函数追加在当前函数队列的尾部。这样的排序准则如何实现呢,很简单,只需要将一个仿函数提供给multimap来比较它的键,multimap自己会排序妥当,这个仿函数如下:
摘自boost/signals/detail/signal_base.hpp
template<typename Compare, typename Key>
class any_bridge_compare {
...
//slot管理器的键类型为any,所以该仿函数的两个参数类型都是any
bool operator()(const any& k1, const any& k2) const
{
//如果k1没有提供键(如f0,它的键any是空的)则它处于任何键之后
if (k1.empty())
return false;
//如果k2没有提供键,则任何键都排在它之前
if (k2.empty())
return true;
//如果两个键都存在,则将键类型转换为合适的类型再作比较
return comp(*any_cast<Key>(&k1), *any_cast<Key>(&k2));
}
private:
Compare comp;
};
这个仿函数就是提供给slot管理器来将回调函数排序的仿函数。它的比较准则为:首先看k1是否为空,如果是,则在任何键之后。再看k2是否为空,如果是,则任何键都在它之前。否则,如果两者都非空,则再另作比较。并且,从代码中看出,这最后一次比较又转交给了Compare这个仿函数,并事先将键转型为Key类型(既然非空,就可以转型了)。Key和Compare这两个模板参数都可由用户定制,如果用户不提供,则为默认值:Key=int,Compare=std::less<int>。
现在你大概已经明白为什么slot管理器要以any作为其键(key)类型了,正是为了实现“如果用户不指定优先级,则优先级最低”的语义。试想,如果用户指定什么类型,slot管理器的键就是什么类型——如int,那么哪个值才能表示“最低优先级”这个概念呢?正如int里面没有值可以表现“负无穷大”的概念一样,这是不可能的。但是,如果用一个指针来指向这个值,那么当指针空着的时候,我们就可以说“这是个特殊的值”,本例中,这个特殊值就代表“优先级最低”,而当指针非空时,我们再来作真正的比较。况且,any是个特殊的指针,你可以以类型安全的方式(通过一个any_cast<>)从中取出你先前保存的任何值(如果类型不符,则会抛出异常)。
回顾上面的例子,对于f0,f3没有提供相应的键,从而构造了一个空的any()对象,根据前面所讲的比较准则,其“优先级最低”,并且,由于f3较晚注册,所以在最末端(想想前面描述的比较准则)。
当然,用户也可以定制
Key=std::string,
Compare=std::greater<std::string>。
总之一切按你的需求。
回调
下面要分析的就是回调了。回调函数已经连接到signal,而触发signal的方式很简单,由于signal本身就是一个函数对象,所以可以这样:
signal<int(int,double)> sig;
sig.connect(&f1);
sig.connect(&f2);
int ret=sig(0,3.14); //正如调用普通函数一样
前面提到过,signal允许用户定制其返回策略(即,返回最大值,或最小值等),默认情况下,signal返回所有回调函数的返回值中的最后一个值,这通过一个模板参数来实现,在signal的模板参数中有一个名为Combiner,是一个仿函数,默认为:
typename Combiner = last_value<R>
last_value是个仿函数,它有两个参数,均为迭代器,它从头至尾遍历这两个迭代器所表示的区间,并返回最后一个值,算法定义如下:
摘自boost/last_value.hpp
T operator()(InputIterator first, InputIterator last) const
{
T value = *first++;
while (first != last)
value = *first++;
return value;
}
我本以为signal会以一个简洁的for_each遍历slot管理器,辅以一个仿函数来调用各个回调函数,并将它们的返回值缓存为一个序列,而first和last正指向该序列的头尾。然后在该序列上应用该last_value算法(返回策略),从而返回恰当的值。这岂非很自然?
但是很明显,将各个回调函数的返回值缓存为一个序列需要消耗额外的空间和时间,况且我在signal的operator()操作符的源代码里只发现一行!就是将last_value应用于一个区间。在此之前找不到任何代码是遍历slot管理器并一一调用回调函数的。但回调函数的确被一一调用了,只不过方式很巧妙,也很隐藏,并且更简洁。继续往下看。
从某种程度上说,参数first指向slot管理器(multimap)的区间头,而last指向其尾部。但是,既然该仿函数名为last_value,那么直接返回*(--last)岂不更省事?为何非要在区间上每前进一步都要对迭代器解引用呢(这很关键,后面会解释)?况且,函数调用又在何处呢?slot管理器内保存的只不过是一个个函数,遍历它,取出函数又有何用?问题的关键在于,first并非单纯的只是slot管理器的迭代器,而是一个iterator_adapter,也就是说,它将slot管理器(multimap)的迭代器封装了一下,从而对它解引用的背后其实调用了函数。有点迷惑?接着往下看:
iterator_facade(iterator_adapter)
iterator_facade(iterator_adapter)在boost库里面是一个独立的组件,其功能是创建一个具有iterator外观(语义)的类型,而该iterator的具体行为却又完全可以由用户自己定制。具体用法请参考boost库的官方文档。这里我们只简单描述其用途。
上面提到,传递给last_value<>仿函数的两个迭代器是经过封装的,如何封装呢?这两个迭代器的类型为slot_call_iterator,这正是个iterator_adapter,其代码如下:
摘自boost/signals/detail/slot_call_iterator.hpp
template<typename Function, typename Iterator>
class slot_call_iterator //参数first的类型其实是这个
:public iterator_facade<...>
{
...
dereference() const
{
return f(*iter); //调用iter所指向的函数
}
};
iterator_facade是个模板类,其中定义了迭代器该有的一切行为如:operator ++,operator --,operator *等,但是具体实施该行为的却是其派生类(这里为slot_call_iterator),因为iterator_facade会将具体动作转交给其派生类来执行,比如,operator*()在iterator_facade中就是这样定义的:
reference operator*() const
{
//转而调用派生类的dereference()函数
return this->derived().dereference();
}
而派生类的dereference()函数在前面已经列出了,其中只有一行代码:return f(*iter),iter自然是指向slot管理器内部的迭代器了,*iter返回的值当然是connection_slot_pair,下面只需要取出这个pair中的second成员,然后再调用一下就行了。但是为什么这里的代码却是f(*iter),f是个什么东东?在往下跟踪会发现,事实上,f保存了触发signal时提供的各个参数(在上面的例子中,是0和3.14)而f其实是个仿函数,f(*iter)其实调用了它重载的operator(),后者才算完成了对slot的真正调用,代码如下:
摘自boost/signals/signal_template.hpp:
R operator()(const Pair& slot) const
{
F* target = const_cast<F*>(any_cast<F>(&slot.second.second));
return (*target)(args->a1,args->a2);//真正的调用在这里!!!
}
这两行代码应该很好理解:首先取出保存在slot管理器(multimap)中的function(通过一个any_cast<>),然后调用它,并将返回值返回。
值得说明的是,args是f的成员,它是个结构体,封装了调用参数,对于本例,它有两个成员a1,a2,分别保存的是signal的两个参数(0和3.14)。而类型F对于本例则为boost::function<int(int,double)>,这正是slot管理器内所保存的slot类型,前面已经提到,这个slot由connection_pair里面的second(一个any类型的泛型指针)来持有,所以这里出现了any_cast<>,以还其本来面目。
所以说,slot_call_iterator这个迭代器的确是在遍历slot管理器,但是对它解引用其实就是在调用当前指向的函数,并返回其返回值。了解到这一点,再回顾一下last_value的代码,就不难理解为什么其算法代码中要步步解引用了——原来是在调用函数!
简而言之,signal的这种调用方式是“一边迭代一边调用一边应用返回策略”,三管齐下。
“这太复杂了”你抱怨说:“能不能先遍历slot管理器,依次调用其内部的回调函数,然后再应用返回策略呢?”。答案是当然能,只不过如果那样,就必须先将回调函数的返回值缓存为一个序列,这样才能在其上应用返回策略。哪有三管齐下来得精妙?
现在,你可以为signal定制返回策略了,具体的例子参考libs/signals/test/signal_test.cpp。
后记
本文我们只分析了signal的大致架构。虽然内容甚多,但其实只描述了signal的小部分。其中略去了很多技术性的细节,例如slot管理器内保存的函数对象为什么要用any来持有。而不直接为function<...>,还有slot管理器里的调用深度管理——即如果某个回调函数要断开自身与signal的连接该如何处理。还有,对slot_call_iterator解引用时其实将函数调用的返回值缓存了起来(文中列出的代码为简单起见,直接返回了该返回值),如何缓存,为什么要缓存?还有,为什么basic_connection中的signal和signal_data成员的类型都是void*?还有signal中所用到的种种泛型技术等等。
当然,细节并非仅仅是细节,很多精妙的东西就隐藏在细节中。另外,我们没有分析slot类的用处——不仅仅作为中间层。最后,一个最大的遗留问题是:如果注册的是函数对象,如何跟踪其析构,这是个繁杂而精妙的过程,需要篇幅甚多。
下篇:高级篇
概述
在本文的上篇中,我们已经分析了signal的总体架构。至于本篇,我们则主要集中于将函数对象(即仿函数)连接到signal的来龙去脉。signal库的作者在这个方面下了很多功夫,甚至可以说,并不比构建整个signal架构的功夫下得少。
之所以为架构,其中必然隐藏着一些或重要或精妙的思想。
学过STL的人都知道,函数对象(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都是“控制性”的,一旦析构则自动断开连接。所以,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(f,tag_type(f)));
//创建一个connection,表示f与该slot的连接,这是为了实现“delayed-connect”
create_connection();
}
bound_objects是slot类的成员,其类型为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的“this”参数绑定为s1,从而生成一个“void()”型的仿函数,每次调用该仿函数就相当于调用s1.test(),然而,这个仿函数本身并非可跟踪的,不过,很显然,这里的s1对象一旦析构,则该仿函数就失去了意义,从而应该让连接断开。所以,我们应该使S1类成为可跟踪的(见struct S1的代码)。
然而,这又能说明什么呢?仍然只有一个trackable子对象!但是,答案已经很明显了:既然boost::bind可以绑定一个参数,难道不能绑定两个参数?对于一个延迟调用的函数对象,一旦其某个按引用语义传递的参数析构了,该函数对象也就相应失效了。所以,对于这种函数对象,其按引用传递的参数都应该是可跟踪的。在上例中,s1就是一个按引用传递的参数,所以是可跟踪的。所以,如果有多个这种参数绑定到一个仿函数,就会有多个trackable对象,其中任意一个对象的析构都会导致仿函数失效以及连接的断开。
例如,假设C1,C2类都是trackable的。并且函数test的类型为void(C1,C2)。那么boost::bind(&test,boost::ref(c1),boost::ref(c2))就会返回一个void()型的函数对象,其中c1,c2作为test的参数绑定到了该函数对象。这时候,如果c1或c2析构,这个函数对象也就失效了。如果先前该函数对象曾连接到某个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_connection的signal和signal_data成员的类型为void*而不是signal_base_impl*和slot_iterator*了——是的,因为函数对象不但连接到signal,还“连接”到slot。将这两个成员类型设置为void*可以复用该类以使其充当“监视器”的角色。signal库的作者真可谓惜墨如金。
回到正题,我们接着考察如何将封装了函数对象的slot连接到signal。这里,我建议你先回顾本文的上篇,因为这与将普通函数连接到signal有很大一部分相同之处,只不过多做了一些额外的工作。
同样,可想而知的是,这个连接过程仍然是先将slot插入到signal中的slot管理器中去,并将signal的地址,插入后指向该slot的迭代器的地址,以及负责断开连接的函数地址分别保存到表示本次连接的basic_connection对象的三个成员中去。这时,故事几乎已经结束了一半——用户已经可以通过该对象来控制相应连接了。但是,注意,只是“用户”!对于函数对象来说,不但用户能够控制连接,函数对象也必须能够“控制”连接,因为它析构时必须能够断开连接,所以,我们还需要将该连接对象的副本保存到函数对象的各个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因为是控制连接的枢纽,所以其三个成员表现的是被连接的slot在signal中的位置。而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函数接受obj和data为参数,将connection副本erase掉
binding.disconnect = &signal_disconnected;
}
分析完了这段代码,bound_object类的三个成员的含义不言自明。注意,其最后一个成员是个函数指针,指向trackable::signal_disconnected函数,这个函数负责将一个connection副本从某个trackable对象中删除,其参数有二,正是bound_object的前两个成员obj和data,它们合起来指明了一个connection副本的位置。
当这些副本在各个trackable子对象中都安置妥当后,连接就算完成了。我们再来看看连接具体是如何断开的,对于函数对象,断开它与某个signal的连接的过程大致如下:首先,与普通函数一样,将函数对象从signal的slot管理器中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_signals和basic_connection的成员bound_objects而展开。这两个一个负责保存connection的副本以作跟踪之用,另一个则负责在断开连接时清除connection的各个副本。
分析还属其次,重要的是我们能够从中汲取到一些纳为己用的东西。关于trackable思想,不但可以用在signal中,在其它需要跟踪对象析构语义的场合也大可用上。这种架构之最妙之处就在于用户只要作一个简单的派生,就获得了完整的对象跟踪能力,一切的一切都在背后严密的完成。
蛇足&再谈调用
还记得在本文的上篇分析的“调用”部分吗?库的作者藉由一个所谓的“slot_call_iterator”来完成遍历slot管理器和调用slot的双重任务。slot_call_iterator和slot管理器本身的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的源代码和本文列出的相应部分比较后或会发现一些不同之处,那些就是我故意省略掉的细节所在了。对于细节有兴趣的不妨自己分析分析。