扫一扫
分享文章到微信
扫一扫
关注官方公众号
至顶头条
下篇:高级篇
概述
在本文的上篇中,我们已经分析了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_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[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的参数绑定到了该函数对象。这时候,如果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对象的三个成员[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因为是控制连接的枢纽,所以其三个成员表现的是被连接的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的源代码和本文列出的相应部分比较后或会发现一些不同之处,那些就是我故意省略掉的细节所在了。对于细节有兴趣的不妨自己分析分析。
查看本文来源
如果您非常迫切的想了解IT领域最新产品与技术信息,那么订阅至顶网技术邮件将是您的最佳途径之一。
现场直击|2021世界人工智能大会
直击5G创新地带,就在2021MWC上海
5G已至 转型当时——服务提供商如何把握转型的绝佳时机
寻找自己的Flag
华为开发者大会2020(Cloud)- 科技行者