论坛首页 编程语言技术论坛

在ANSI C下设计和实现简便通用signal-slot机制——一种平台相关但易于移植的,lambda表达式风格的,经由抵抗编译器而得的方案

浏览 12417 次
精华帖 (15) :: 良好帖 (0) :: 新手帖 (0) :: 隐藏帖 (0)
作者 正文
   发表时间:2009-10-09  
C
注:在几处发表同样的主题,希望通过讨论,接收到大家提出各种建议或意见,抛砖引玉。

    最近在ARM平台下做一些开发,考虑到这个场合下的风气,使用的语言是C而不是一直倾向的C++。因为面向对象等一些设计在C中同样可以达到,基本上对自己的习惯不会有太大的影响。唯一感到不太方便的就是在设计还没有成型或者遇到无法设计的地方时,很难(或者要花很大的代价)通过提升抽象层次,延迟问题的解决以换取一定的灵活性和隔离各个层面之间的关联。这个方面的一个典型案例就是signal-slot机制,在c++中借助signa-slot辅以函数对象,高阶函数,以及lambda表达式几乎可以达到随心所欲书写代码的程度。虽然在接口(设计模式)正是为解决这些问题产生的,但考虑到前面说的设计问题,这个时候往往会感到十分为难。实际上分类是一种很难的事情,在对问题域了解程度有限的情况下,或者面临十分复杂的非线性场景时,一开始所做的抉择大多数不会有好的结果,所以在现实的代码中,充斥怪味的类型的地方比比皆是。而这正是signal-slot或者叫闭包,委托的东西大展手脚的时候,你可以不必要按照书上的固定模式去规划代码,而解决几乎所有的类似需要解耦和灵活性的问题。

    所以接下来的事情就演变成决心在有限的条件(ANSI C)下看看能不能自己实现一个可重用的signal-slot方案。在初步的设想中,这个方案可能会有地方使用到一些平台相关的手筋,但希望尽量不要影响到大局的设计,以方便移植到各种平台下。
   发表时间:2009-10-09  
lz是说closure还是curry?

我怎么觉得signal-slot对应的应该算curry啊。。。
0 请登录后投票
   发表时间:2009-10-09  
GTK 那套lib

就是用macro实现的类似 signal-slot的机制。
0 请登录后投票
   发表时间:2009-10-10  
mikeandmore 写道
lz是说closure还是curry?

我怎么觉得signal-slot对应的应该算curry啊。。。


闭包就是指closure,curry当属于高阶函数吧,应该是两者都可以用于signal-slot中的slot
0 请登录后投票
   发表时间:2009-10-10  
signal-slot(信号-信号槽)机制顾名思义就是一个发送和接收信号的系统。通常描述为signal是一个发布者,slot可以是一个或多个订阅者,这些slots作为回调者连接到signal上,在signal发出(emit)的时候会自动的被调用。一般情况下,一个signal何时会发生是不可预知的,或者至少在建立slots连接对象的时候如此,这非常适合用于在逻辑上独立性很强的代码间建立通讯。比如QT(1)和GTK+(2)这些GUI库都用

signal来发布界面中产生的事件,这样程序中的功能模块和界面流程就可以完全解耦,便于独立的设计和开发。

在Unix/Linux操作系统里也有signal的概念,不过和这里所说的不是一个东西,不过在发生和响应的关系上它们具有一定的相似性。

从回调的角度看,这和C语言中的函数指针表现一致,但仅有函数指针是远远不够的,因为当回调发生的时候,slot代码中运行的一些信息可能不仅是函数参数这么多的东西,或者至少不仅仅是signal所能附带的那些参数。后者往往表现为回调者参数是signal发出参数的超集,所以某些语言中提出闭包(closuer)(3)这样的概念,比如Borland在其C++实现中扩展有__clouser关键字用于其VCL组件库。C++中一个普遍的场景是需要调用类成员函数时,必须提供有类的实例,而这是不能表现为signal参数的。在微软的ATL中采用thunk(4)这种不能跨平台的特定技巧来实现传递类实例以及调用其成员函数,之后的C#,更是把这种东西规范到语言层面上,称为委托(delegate)(5)。

在一些场合下,signal-slot和事件(event)或者消息(message)是类似的概念。比如Java中提出有Event对象,并通过传递监听者(Listener)来响应。C中也有类似的实现,比如libevent库(6)。在我的理解中,通常情况下Event多数被定义为程序外部的输入事件,而signal是程序内部的一种机制,实际用法上,signal-slot是更接近语言层面的解决方案,比较早引入signal-slot概念QT采用的方案就是附加了一个预编译阶段。所以可以在程序输入输出的边界上使用Event,内部映射到signal-slot系统上。

在C++中,signal-slot已经通过模板被完美的实现的,这方面最完善的当属boost.signal(7)库和libsigc++(8),前者自不必言,后者在GTK+的C++封装GTKMM(9)里被首先开发,后来独立成为了一个库。这里有一篇两者的对比文章(10),有兴趣的人可以通过阅读它很快的了解到signal-slot大致是个什么样。

在C中由于语言本身的制约,很难在语言层面提供支持,所以多数是以库的形式存在着,使用上会比较繁琐。这方面最完善的例子就是GTK+开发的C支撑库GLib所带的GSignal,属于GObject(11)的一个部分。考虑到GLib本身所面向的场合,这也是一个非常重的实现。它依赖于其自身的类型的系统,并且用户需要专门为回调的附加数据提供列集(marshal)和散列(unmarshal)方法。尽管这并不是什么困难的事情,而且GTK提供有工具可以自动生成代码,但显然不适合用于资源比较苛刻的场合下。不过GSignal对使用者提供了宏来封装接口,还是非常简洁清晰的,可以达到多数情况下和C++中的没有太大的区别。

(1) http://qt.nokia.com/
(2) http://www.gtk.org/
(3) http://en.wikipedia.org/wiki/Closure_%28computer_science%29
(4) http://en.wikipedia.org/wiki/Thunk
(5) http://msdn.microsoft.com/en-us/magazine/cc301810.aspx
(6) http://www.monkey.org/~provos/libevent/
(7) http://www.boost.org/
(8) http://libsigc.sourceforge.net/
(9) http://www.gtkmm.org/
(10) http://www.3sinc.com/opensource/boost.bind-vs-sigc2.html
(11) http://library.gnome.org/devel/gobject/
1 请登录后投票
   发表时间:2009-10-11   最后修改:2009-10-11
关于signal-slot具体的细节在后面会逐步展开,先让我们来看一下程序开发中的场景。

一个程序或者代码可以看作是大大小小的许多数据集合,以及处理这些数据集合的方法的组合。数据集合的存在是因为内聚的需要,我们把一个大问题逐步细分的结果,这样才可能完成复杂的工作。通过一个恰当大小的集合我们可以把琐碎问题的复杂性限制在足够可以对付的范围内。到底什么样是恰当的没有一个固定的标准,从解决问题的角度看,这应该是由问题域内在的关联性决定的。大家都知道好的代码组织的标准是“强内聚,松耦合”(这实际上是通行的设计标准),抛开设计的划分好坏不谈从实现的角度看,这里的难点是后者。

代码中处理这些数据的方法,有些是集合内部的,有些是需要跨越两个甚至更多集合的。前者相当于面向对象中类的私有方法,后者相当于公开成员(被调用或访问)。私有方法是内聚的,而公开成员则是用来跟外界耦合的。细节上这种耦合一般是通过参数传递来完成的,相当于集合之间的通讯。C语言中虽然不能完全一一对应,但设计的时候可以同样如此考虑。

然后一个大的集合可以套上一个或多个小的集合,相当于又做了一次内聚的工作,每次内聚之后,外在的复杂性会比原先降低一些,所以是值得的。大小集合的包含可以通过继承、组合实现,也有可能属于描述中的层次的概念。比如当大集合跟小集合通讯时,需要向它传递外界数据(这是前面工作的一种可能的副作用)的时候,相当于某种形式的代理。仔细分析,按照通常的方法,随着层次的深度越来越大,可能产生的通讯量也会急剧的增加,因为通讯中所有可能出现的参数都要传递到,尽管最后用到的可能只是其中很小的子集。在代码中这一点可能并没有直接反应出来,因为这种复杂性被其他方式代替了。

我们通过一个图来描绘一下(图一):

图中的矩形相当于数据集合,而圆形(椭圆形)相当于方法(方法所处理的数据)。可以直观的看到它们之间的关系。
这个图更加符合C语言的场景,从纯粹面向对象的来描绘,可以由另外一张图来表示(图二):

这里明确所有的通讯都是通过接口完成的。方法和调用都属于某个接口组合,所以看不到了。除此之外跟上图不同的地方是多出了一些三角形,这是为了耦合不同形状或数量的接口而附加的,相当于添加一个层次,还有就是弥合同时访问两个以上对象接口,相当于在多个集合间通讯,也需要添加一个层次。这就是那句著名的话:任何软件设计问题都可以通过添加一个抽象层加以解决。

Eric Raymond在评价几种语言时(1),在谈到C++的地方,指出这是面向对象对象的缺点:使用面向对象方法导致组件之间出现很厚的粘合层,并且带来了严重的可维护性问题。他认为应该让粘合层尽可能的薄。

其实用C开发这个问题也同样存在,只是C语言中我们有绝招——我已经记不清楚什么时候听说过的这个名字了——那就是“全局变量大法”,相当于把数据从小的集合中移到图中最大的那个矩形里,相信大伙都在“牛人”的代码中见过,至于有什么问题就不必说了。

(1) http://www.catb.org/~esr/writings/taoup/html/ch14s04.html#c_language
  • 大小: 3.1 KB
  • 大小: 3 KB
0 请登录后投票
   发表时间:2009-10-12  
在前面的叙述中多处提及到层,究竟什么是层,在软件设计中这是一个很抽象的概念,这里我试着解释一二。局部上看,当产生一次调用A->B,那么B就比A低上一层;于全局而言,实现功能供别人调用的代码一般层次较低,而负责调度和组织程序运行的代码属于较高层次——“指挥的都是领导,干活的都是小兵”。一个程序的运行路径是错综复杂的,实际上构成的是一个跳跃编织的立体网格。

从数据划分的角度分析,当一个对象属于另外一个对象的时候,意味着相应的整体上大概要低一层,当然这不是绝对的。

一般来说,层次越低,灵活性就越低,因为它所拥有的资源(数据)就相应受到了局限。如果需要提高灵活性,一个手段是通过和外界的通讯获取更多的信息,也就产生了前面说的通讯量问题。

和灵活性相对的是复杂性,为了降低复杂性,我们不得不分层,后遗症就是局部灵活性的丧失。“全局变量大法”意味的最大的灵活性可能,因为所有的数据都可以在任何时候访问。最极端的情况下,只需要一层就可以完成所有的功能(所有的方法都不带有参数),相对的复杂性也就最大。

另外一个可能存在问题的方面就是耦合,因为高层可以直接或者间接访问到低层,一旦低层需要作出修改的话,很可能扩散出去——牵一发而动全身。

整体上,层次属于设计对付的事情,不是我们这里所要解决(也无法解决)的问题。不过通过一些手段可以提升局部的层次,比如pimpl惯用法(1)——作为设计模式的一种前身,相当于把具体实现的层次提升至传递pimpl指针的那一刻。回调也是如此,其层次属于建立回调连接的那一刻,而不再是调用后的那一层,通常情况下,前者都远远要高于后者。从耦合的角度看,也大大缩小了耦合的范围——跳过之下的很多层。还有,比如pimpl用法,可以通过在某一层上散布指针,相当于扩展了层的面积,而进一步缩小的层的平均厚度,减少了需要的通讯量。

让我们举一些具体的例子,看看如何用接口对付类似的问题——软件开发经典著作《设计模式》(2)开篇要旨的一句话:针对接口编程,而不是针对实现编程。

比如,有一个类是用于发布时间的,称为EventDispatch,发布类型是Event,然后需要接收时间的类是我们需要实现的。最原始的做法就是,在EventDispatch类中有一个类EventListener的实例event_listener,然后直接调用其方法receive(Event *),也就是event_listener.receive(p_event)。这里假设p_event是实际产生的一个Event实例的指针,我们采用C++的语法以区分指针和实例本身。

代码是:

class Event {
  ...
};

class EventListener {
  ...
public:
  void receive(Event *) { ... }
};

class EventDispatch {
  ...
  class EventListener event_listener;
  ...
public:
  some_func() { ... ; event_listener.receive(p_event); ... }
  ...
};

这样的话,EventDispatch类就和EventListener类紧密的耦合在一起了,EventDispatch类还要负责EventListener类实例的生老病死,显然灵活性上要大打折扣,也很难做到模块化式独立设计和开发。

如果用原始的pimpl方法,我们可以把一个EventListener类指针类型作为类EventDispatch的成员。通过构造函数或者其他某种初始化方法对其赋值,相当于EventListener类可以在外部生成,开发EventDispatch的人只需要关心调用那一刻的事就可以了。

代码是:

class EventDispatch {
  ...
  class EventListener *event_listener;
  ...
public:
  EventDispatch(..., class EventListener *_event_listener, ...) : event_listener(_event_listener) { ... }
  ...
  some_func() { ...; event_listener->receive(p_event); ... }
  ...
}

这一下灵活性有了很大的提升,耦合的问题也基本上解决了。但还是有些小问题,就是对EventListener类型的依赖还存在着,而且没有一个明确的约束,一旦EventListener作出重大的修改,也许就不适用了。

接口则把这种约束提升到语义的层次上,也使得对EventListener的依赖达到可能的最小程度,便于设计和规划。

代码是:

struct EventListener {
  virtual void receive(Event *) = 0;  // 以抽象类来定义接口
};

class EventListenerA : public EventListener { // 可以接受事件的类必须继承接口
  ...
public:
  ...
  void receive(Event *) { ... };
  ...
};

class EventListenerB : public EventListener { // 另一个也可以接受事件的类
  ...
};

上述两个类在实际使用中可以相互替换,达到灵活的开发和部署。

让我们进一步考虑,如果事件需要多播(multicast)怎么办?就是上述EventListenerA,EventListernerB等等更多的类各有若干实例需要接收到事件。

这个时候的办法有两个,一个是最直接可以想到了,修改EventDispatch类,让它可以接收多个成员。显然这个时候对修改的封闭性没有做到,模块的封装被破坏了,或者至少可能会觉得不爽。这没有关系,我们还有绝招没使——可以通过增加一个层,专门负责管理这若干个实例,然后本身也继承自EventListener接口,把它交给EventDispatch好了。

代码是:

struct EventListenerCollection : public EventListener {
  std::vector<EventListener *> listener_collection;
public:
  void receive(Event *event) {
    BOOST_FOREACH(EventListener *listener, listener_collection) { // 顺便了解一下BOOST_FOREACH的用法
      listener->receive(event);
    }
  }
  void add(Event *event) { // 往其中添加需要接受事件的对象
    listener_collection.push_back(event);
  }
  ...
};

这个时候就能尝到接口带来的甜头了吧。

这是一个很简单的例子,实际中遇到的情况往往要复杂许多。

随着层次的提高,接口也会变的逐渐庞大起来;还有在对问题域了解不够充分的情况下,接口也是很难定制或者需要不断修改的。一旦接口发生的修改,也就意味着前面的努力可能要付之东流了。在开发压力特别大的场合下,很多时候就干脆放弃了这个原则,那个原则,直接拿代码开刀。这里补一块那里补一块,还来的快些,不过也就随之留下了许多隐患。最令人丧气的是,再也体会不到那种成事后的喜悦感了。

与之相对的,是过度设计的问题,看起来代码布局非常不错,只是数据被打的很散,需要让他们工作起来我们不得不做很多粘合的过程,类似上面增加层的工作,琐碎的工作越做越做不完。这是一个度或平衡的问题,往往在实践者和理论者之间产生不大不小的冲突。

某些“新手”即使在使用接口的情况下,也会把实例化的过程放到构造函数中,而且会非常满意的觉得内聚的很不错:你看琐碎的东西我都封装掉了,外部只管使用它就可以工作了,看起来多么简洁。

这显然是没有理解接口的含义。在Java出现开始大幅度推广纯面向对象的应用的时候,IOC(3)的概念得到了广泛的宣传。本质上它仍然是接口的范畴。不过如雨后春笋般冒出来的各种IOC容器,利用Java乃至C#等新语言的特性,把接口的实例化提升到了程序的外部,通过专门的配置过程来完成,也算是彻底的把这种灵活性发挥到极致了吧。

现在让我们看看signal-slot是如何对待上述同样问题的。

代码是:

class EventDispatch {
  ...
public:
  Signal(...Event *...) signal_event;
  ...
  some_func() { ...; signal_event(p_event); ... }
  ...
};

假设Signal是某种已经定义好的signal类型,简便起见,这里直接把其实例公有,外界可以直接访问到。这里没有关于EventListener的定义,不是说放到其他地方定义,而是根本不需要。下面是某种形式对signal的使用(或者叫响应),注意这只是形式之一,具体要看语言能提供什么样的表达能力。

假设有一个对象some_object,具有receive方法,可以接收同样的Event *类型参数,注意这里receive方法不是必须这么叫,可以是其他任何名字,也就是说它不是要求的某种形式的接口。

那么我们可以在程序中任何能访问到某个EventDispatch实例,假设叫event_dispatch,以及some_object的地方,这么建立两者之间的联系。

代码示意为:
event_dispatch.signal_event.connect(some_object.receive, 参数1 ....);

因为并不特指用哪个库或者哪种方法,所以这里没有规范的代码,就解释一下大概的意识。这里的参数1,就是指实际上signal发送出来的event,而some_object.receive,指以参数以及后面省略的更多信息去调用some_object的receive方法,当然实际上目前也不是这么写。

这里真正调用的是signal_event的connect方法,因为signal是一种通用或者预定义好的类型,所以connect方法是不会变化的。你可以在任何地方调用0到任意多次数的connect,而EventDispacth对象对此是丝毫不觉的。Signal本身会帮助管理和维护所有可能需要完成的事情。这里在EventDispatch中的some_func过程里一旦调用signal_event的重载()函数方法时,Signal会自动帮助完成调用在此前所有连接到其上的函数。

connect函数中的内容称为slot,也就是实际上调用产生时执行的内容,虽然此例中实际上最后就是调用some_object.receive,但slot维护的内容不止于此。如果使用高阶函数或者lambda表达式作为slot,那么就更无法对应到事先定义的方法上了。

可以看出,这里EventDispatch跟事件的接收者已经完全解耦了。而且如之前所述,回调过程的层次可以提升到事件发生之前,大大提高了代码的灵活性。无论是前述的任何情况,一个EventDispatch都足可以应付。

这里关键是signal_event,它可以看作是EventDispatch暴露出来的一个接口。这跟跟前面恰好反过来,所以也可以看作是反向接口。不过我并不赞同用接口来称呼它。

IOC模式,也被人们称为叫“好莱坞准则”(4),就是把接口的实现和部署从代码中分离出来。跟接口仍然无法阻止“新人”犯错不同的是,signal-slot则是彻底的杜绝了这种情况——你无法在类中任何建立连接对象,包括构造函数(虽然我常常在构造函数里这么做,但那是事件编程的范畴,不属于这次讨论的主题)——因为你对被连接到的对象毫无所知。好莱坞物色对象,估计至少要对对方的长相,高矮胖瘦调查清楚,这跟接口倒是别无二致。而signal-slot倒更类似中国的一个说法“姜太公钓鱼——愿者上钩”——尽管未必每次都有那么好的运气,钓上文王这条大鱼。

在设计上看,signal-slot是从事件的产生出发起,能够直接映射到现实世界中,所以非常容易定义。而且它具有极小的粒度,增删修改都很方便,所以即使是作为接口,也要比狭义上接口的定义来的容易的多。小粒度的缺点是数量上会增多,所以它也并不是完全代替接口,实际中两者各有擅场,可以斟酌采用。也可以在遇到设计难题先用signal-slot代替,等稳定后看看是否可以归纳出接口。

(1) http://www.gotw.ca/gotw/028.htm
(2) http://zh.wikipedia.org/wiki/%E8%AE%BE%E8%AE%A1%E8%8C%83%E4%BE%8B
(3) http://martinfowler.com/articles/injection.html
(4) http://en.wikipedia.org/wiki/Hollywood_Principle

0 请登录后投票
   发表时间:2009-10-12  
让我想到了 actionscript 3
的冒泡和 EventDispatcher 自定义的 Event
0 请登录后投票
   发表时间:2009-10-14   最后修改:2009-10-14
该是转入到正题的时候了。前面啰啰嗦嗦扯了一大通,可能不懂的还是看不懂,有点兴趣的人却早就不耐烦了。十分抱歉,最近事情都撞到一块了,有些安排不过来。不过慢一些也好,可以一边叙述一边思考,尽量考虑的周全一些,也欢迎大家一起来出主意。

首先需要给想达到的目标定下个框框。因为有前述很多的别人早已实现好的案例作为参考,所以对于一个signal-slot大致的模样很容易清楚的,只需要模仿就可以了——这是中国特色的办法。

让我们看看使用boost.signal的一个完整的例子,这个例子基本上摘抄自前面的链接中,我把它组合到一个C/C++例子程序里,这样有条件的可以自己试着运行一下。

运行这个例子需要以下环境:boost.signal库及相关头文件,GNU C++编译器(我一般使用它来做例子测试,你也可以使用微软的VC++6.0及以后版本,但不保证测试过)。因为boost是以源代码方式发布的,需要自己编译,这个比较耗时。网络上有一些别人编译好的版本可以拿来用,你可以针对自己的环境选择。我实际使用的环境是cygwin(1)及随之发布的库及工具,国内用户可以从cygwin.cn网站安装。

这个例子十分简单,全部放到一个c++代码文件里,编译运行就可以,具体步骤就不说了。

在代码头部需要包含<boost/signals.hpp>头文件,以及<iostream>,<string>等用于测试目的,然后加上申明"using namespace std;",以符合可能的习惯。

首先申明Signal,这个申明可以作为局部或者全局变量:

boost::signal<int(float, string)> sig;

模板中的内容是一个函数类型,直接指明了signal发出时,调用接口是int(float, std::string)这样一个函数。

演示简便起见,在程序入口main函数里,我们直接发出它:

int main()
{
  // ..., 之前这里我们需要预先建立signal-slot连接,后面添加
  sig(3.1415926, "pi"); // 发出(emit)信号
  return 0;
}

我们定义一个同样类型的自由函数供调用,方便起见,你可以把它直接放到main函数的上面:

int Func(float val, string str)
{
   // 显示被调用到,我们打印调用信息
   std::cout << "Func: " << "the value of " << str << " is " << val << endl;
   return 0;
}

好了,可以建立signal-slot连接:
int main()
{
  sig.connect(&Func);  // 连接到Func函数
  sig(3.1415926, "pi");
  return 0;
}

可以编译实际运行一下看看效果,链接要求预先编译好的boost.signal,一般名字是libboost_signals-gcc-mt这样。

上面这个例子实际上就相当于:
int main()
{
  Func(3.1415926, "pi");
  return 0;
}

该没人问这样的问题吧——那干嘛还兜那么个圈子?

上面例子仅仅是相当于原始函数指针的效果,让我们继续看看调用对象成员函数怎么办。

先定义类,同样摆到main函数的上面:
class Foo {
public:
  int Func(float val, string str) {
    std::cout << "Foo->Func: " << "the value of " << str << " is " << val << endl;
    return 0;
  }
};

然后添加类的实例及对实例方法的调用,这个时候需要要代码头部添加头文件包含#include <boost/bind.hpp>

int main()
{
  Foo obj; // 类Foo的实例

  sig.connect(&Func);  // 连接到Func函数
  sig.connect(boost::bind(&Foo::Func, obj, _1, _2)); // 连接到obj的Func方法
  sig(3.1415926, "pi");
  return 0;
}

这里连接就复杂了一些,slot部分不再是一个简单的函数指针,而是包括成员函数指针,实例以及参数部分的一个绑定。这是生成了一个新的函数供signal调用,把成员函数和具体实例组合在一起,所以称为函数组合或绑定;也因为相当于实际调用函数中参数的增多,被称为高阶函数,这也是来自于函数式编程领域里的标准称呼。_1,_2这样的东西被称为占位符,为了给生成的函数附加参数,实际上是可以携带某种类型数据的对象。编译运行看看效果是不是和想象一样。

上述的用法有点复杂,但想法很直接,所以先举例,下面让我们看看如何调用函数对象。函数对象是一个特定的概念,该对象包含一个重载了()操作符的方法,这样调用的时候就不需要使用函数名,而可以直接以类或对象名代替,看上去就跟自由函数一样。举例如下:

struct Bar {
  int operator()(float val, string str) {
    std::cout << "Bar: " << "the value of " << str << " is " << val << endl;
  }
};

同样把类定义放到main上面,我们增加main实体如下:
int main()
{
  Foo obj; // 类Foo的实例

  sig.connect(&Func);  // 连接到Func函数
  sig.connect(boost::bind(&Foo::Func, obj, _1, _2)); // 连接到obj的Func方法
  sig.connect(Bar());  // 连接到某个Bar函数对象
  sig(3.1415926, "pi");
  return 0;
}

这次显得非常简洁,似乎比调用成员方法容易的多,但实际上是一回事。注意这里的Bar()本身不是调用,而只是通过缺省构造函数生成一个临时对象,然后当signal发出的时候会调用其重载的()的操作符。它等价于:

sig.connect(boost::bind(&Bar::operator(), Bar(), _1, _2)); // 这个复杂的写法是不是容易理解一些

还有这里暗含一个对象生存期的问题。无论是使用obj实例,还是临时对象,boost::bind的实现是保持一份它们的拷贝,直到实际调用产生的那一刻。临时对象因为都是一样的不用考虑,如果是实例,那么在连接建立后的对实例的修改将不会影响到slot中持有的那个对象。如果你需要的不是这样而是想要引用原有的实例对象,那么可以采用指针传递该实例,也就是:

sig.connect(boost::bind(&Foo::Func, &obj, _1, _2)); // 连接到指向obj实例指针/引用的Func方法

不要小看它们之间的差异,实际使用过程中,我们更多需要的可能是引用,而不是无数长相一致的初始对象。这种需求是如此广泛,以至于boost中有一个非常小但用处很大的库boost::ref,可以用来产生对象引用或者引用对象这个东西。它的实现如此简单但思想经典,所以我就摘抄如下:

template<class T>
class reference_wrapper
{
public:
    typedef T type;
    explicit reference_wrapper(T& t): t_(&t) {}
    operator T& () const { return *t_; }
    // ...
private:
    T* t_;
};

template<class T>
inline reference_wrapper<T> const ref(T &t)
{
    return reference_wrapper<T>(t);
}

然后,上面用取地址符的地方就可以换成:

sig.connect(boost::bind(&Foo::Func, boost::ref(obj), _1, _2)); // 这是不是更有c++的味道

上述的这些东西基本上等价于closuer,thunk之类的东西,这算不上什么,让我们把问题变稍微复杂些。如果有人写了个类似上述Func功能的函数Func1,但不小心参数弄反了(这是经常的事):

int Func1(string str, float val)
{
   std::cout << "Func1: " << "the value of " << str << " is " << val << endl;
   return 0;
}

程序可能有其他地方也用到这个函数,要改起来可能会有些混乱,没关系,我们可以这么做:

sig.connect(boost::bind(&Func1, _2, _1));

类似的,通过boost::bind我们可以在signal参数的基础上任意的增减调用参数,达到匹配最终调用的目的。

进一步,boost::bind可以通过使用函数对象作为参数,把函数和函数堆叠起来,这在函数式语言中称作currying(2),和Haskell语言一样这是为了纪念著名的逻辑学家Haskell Curry(3)。

比如,当我们获得pi值的时候,需要计算的是半径为2的圆的面积,我们分别有上述的输出函数,和一个计算面积的函数:

float Area(float r, float pi)
{
  return r * r * pi;
}

然后在main代码里增加:

sig.connect(boost::bind(&Func, boost::bind(Area, 2, _1), _2));

这里看出来boost::bind返回的就是一个函数对象。

无论是上述何种做法,都局限于函数的粒度上。为此我们不得不做些预先的设计工作,或者在遇到变化的时候,随时准备添加一层类似于上述计算面积的过程。这本身当然不是什么问题,问题在于当我们说到层的时候,往往是指它们是需要独立设计和思考的。我们有限的思维能力,让我们局限于一个不算太长的代码范围内,这对像我这样天资愚笨的人而言已经是十分吃力了。而一旦出现了上述情况的话,我们就不得不打断当前的工作,跳转到其他一个代码空间里,做出某种变更,然后再返回到刚才被打断的地方。这分散了我们的注意力,破坏了已经存在于大脑中一个连续的逻辑思维过程,降低了效率和提高了出错概率。

这个时候我们需要的是一个可以不用跳来跳去就可以完成工作的办法,这就是lambda表达式所能带来的好处。

什么是lambda表达式?数学意义上的定义就不在这里讨论了(我水平也不够)。程序设计语言中的lambda表达式也就是指由一系列运算符结合而成的表达式,它自身也可以同时成为一个新的表达式的部分。在命令式语言里,你可以把一个语句块看成就是一个lambda表达式,虽然它不是严格意义上的一个表达式。和函数式语言中不同的是,在C语言里,你不能把语句块直接拿来作为函数使用,C++也同样如此。但是在Object C里有block的概念(4),可以把语句块当作可以调用的对象,在一些动态语言里也有类似的东西。我们用lambda表示式重写上面的例子:

sig.connect([](float pi, string str) { Func(2 * 2 * pi, str); });

connect的参数就是一个lambda表达式,它和函数很像,只是没有名字并且和函数体语句块一起被定义。lambda表达式可以组合成小到一个语句的任意粒度,如果我们的调用对象粒度不大,并且不需要重用的话,这种表述方式无疑大大提高了编码效率。

上述的例子是按照已经被接纳成为C++ 0x一部分的lambda表达式标准提议(5)写就,相信不久将来就会出现大众的代码里。

关于lambda表达式,boost里面也早已有实现,而且有两个,一个是boost::lambda,还有一个是作为boost::spirit一部分的boost::phoenix(6)。两者大同小异,前者比较早,已经没有什么变化,后者更新一些,支持更加全面一些。我们看看boost::lambda如何来写上面的例子:

sig.connect(boost::lambda::bind(&Func, boost::lambda::_1 * 2 * 2, boost::lambda::_2));

去掉boost::lambda这个长长的前导命名空间,是不是就很漂亮了。总体上boost::lambda在表达一些简单的运算是足可以胜任的,但在表示复杂的代码流程和引用复杂的成员对象时有些繁琐,因此使用上会受到一定的制约。

注:后面的部分例子只是为了展示一些高阶能力,细节上没有面面俱到,感兴趣的可以自己去尝试。

到目前为止我们演示了signal-slot主要部分:连接(connect)和发出(emit),使用中还有对连接的管理,比如至少可以断开;以及对返回值的使用。这个例子中signal的函数原型具有int类型的返回值,可以在发出signal的地方获得,也就是把signal当作函数使用。在C++中这很正常,因为它实际上就是一个函数对象。不过这些都不关键,暂时就不一一讨论了。

(1)http://cygwin.com
(2)http://en.wikipedia.org/wiki/Currying
(3)http://en.wikipedia.org/wiki/Haskell_Curry
(4)http://en.wikipedia.org/wiki/Blocks_%28C_language_extension%29
(5)http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2009/n2927.pdf
(6)http://spirit.sourceforge.net/dl_docs/phoenix-2/libs/spirit/phoenix/doc/html/index.html
0 请登录后投票
   发表时间:2009-10-16  
模仿的对象有了,可以展开工作了。这个时候也许有人会有疑问,明明是要用标准C来实现,怎么举了一堆C++的例子;用C++也罢了,干嘛又扯BOOST这种诡异的东西?这是为了更好的抽象。一般来说C++的抽象能力要胜过C一筹,所以同样的问题用C++来分析,可能更加容易抓住本质。如果到这里你没有疑问了,可以跳过下面两段的内容。

若是继续问起什么是抽象,让我解释起来恐怕就吃力了——抽象本身就是一个很抽象的概念。书上对这个问题的解释也是十分复杂的,我这里从不同角度谈一下。简单说来,我们对事物的认识来自于整个大脑的反射。比如经过在社会上生活一段时间后,大部分人都可以迅速直接的理解日常生活中常用的词汇,无论它的含义是多么深奥;但如果见到一个完全陌生的孤立单词,无论它多么浅显可能也无从去理解它;如果这个单词的含义比较复杂,这个时候需要一个人详细解释它的来龙去脉可能才能明白;等到你反复见过或者使用过几次之后,再使用它的时候就可能轻而易举,和常用的词汇没有区别。这就是经过了一个抽象过程,把具有复杂内涵的事物直接反映到大脑里。这种映射过程本身会有千差万别,但只要不影响到对应关系就可以。幼儿的学习过程开始只是单纯的模仿,有些人即使到了成年后可能也并没有去了解过那些习以为常的认识究竟来自于哪里,连抽象的过程也免了。

抽象的一个主要作用就是为了大脑可以更加迅速对事物作出反应。通过抽象把无关紧要的地方封闭掉,抓住关键特征,降低外在复杂性,更好了帮助我们认知事物。人类语言就是一个抽象集合的一个代表,通过增加单词或者赋予单词更多的涵义我们可以通过各种简洁巧妙的方式表达出想要表达的意思。相比起来,虽然程序设计中的跟计算机打交道的语言能力还很弱小,但我们也可以通过类型,函数以及分层等手段达到类似的效果。

我所知道的牛人之一Trustno1认为好的语言是它的代码熵值能够逼近于问题域的的熵值,这里有他翻译的论文(1),不妨了解一下。类似的,一个好的设计可以把问题域的关键特征暴露出来,从而让使用者可以直接的理解和操纵。举例而言,比如汽车,对操作者而言就是需要速度和方向的控制,表现为油门和方向盘;但在某些时候,受制于内部的机制,我们不得不给问题附加上本不属于它的内容,即使这样的设计也要足够简洁明了的反应问题本身。还是汽车为例,档位就是这么一个东西。虽然C++语言在大多数情况下和C不存在本质差异,但借助于模板,在类型表达方面还是具备一些C不具有的能力。BOOST库正是这种能力的一个美妙演示,虽然在内部,受制于语言的不完善,存在一些古怪的技巧;但在外部接口上,它都是经过精心设计和良久考验的,在大多数情况下能够帮助你卸除思维上的负担。

这一点上,boost.signal做得已经非常完美了。所以后面的工作只是尽量去模拟它,另外某些地方可能还需要增加一些档位。

因为在C中没有类的概念,所以第一件事情就是分解我们要处理的对象。在C中这完全不是问题,可以通过结构和方法来代替类。网络上已经有了非常全面关于如何在C语言中实现面向对象设计的文章,这里我推荐一个具体而完整的实现(2)。

这样signal类就可以由一个signal结构代替,然后其成员方法可以由一些以signal结构为参数的自由函数代替,比如signal_connect(signal *, ...),signal_emit(signal *, ...)以及signal_disconnect(....)。最终,一般必不可少的,我们需要一些宏定义来让使用的一些固定范式的代码能够自动生成,从而暴露出最简化的使用接口。也就是最终我们面对的可能是SIGNAL_CONNECT(...),SIGNAL_EMIT(...),SIGNAL_DISCONNECT(...)等这样一些宏;为了一致性,有的时候即使是宏对应的就是一个函数的情况下,我们也会定义它,这样可以让使用者不用分神去关注一些跟事情本身无关的方面。

通过前面的介绍,大家应该能够了解到跟传统回调不同的关键在于我们如何传递更多的参数。在C++中这是通过泛型实现的,在C中我们没有对应的手段,但这其实并不困难,因为我们可以把参数附着在结构中一起传递,最坏的情况也不过于我们可能会丢失它们的类型。也就是signal中不仅有一些库内部要使用的信息,还要暴露出部分接口供用户往其中附加数据,以便于signal发出的时候,同过它转移这些数据给真正的调用过程。

虽然传递参数不仅仅是定义它而已,但是这个问题我们肯定要多次遇到,因为我们知道回调发生还有部分数据在在建立slot连接时附着的,所以我决定先花些时间解决它,后面会更安心些。

有两种方法可以实现这个过程,一个是在结构中添加新的字段,每个参数表现为一个新的字段;还有一种就是通过signal自身管理这些数据,它可以提供调用的接口,以便用户往其中提交参数,并自动的分配内存来维护这些参数。前一个种方法会破坏类型的一致性,但后一种方法会失去类型的安全性,并且往往需要更多的代码。

关于类型的一致性,其实并不难解决,看过C中实现面向对象的继承的方法后相信都知道如何解决。我们可以把数据附着在库中所需要的signal结构的后面来保证整个结构跟它的兼容性,这是因为C中规定结构中第一个数据成员的地址等于结构的地址。也就是说,类似下面一些结构都可以强制转换到一个signal类型上,为了突出类型,我们用首字母大些Signal表达signal类型。

Signal { ... };
Signal1 { Signal sig; int var1 };
Signal2 { Signal sig; float var1; int var2 };
...
类似可以一直扩展下去。
在库中,比如调用signal_connect(Signal *, ...)函数的地方,可以在用户提交的最终Signal1, Signal2等等类型上附加强制类型转换,也就是定义宏

#define SIGNAL_CONNECT(signal, ...)  signal_connect((Signal *) signal, ...)

以上省略号都是表示未知内容,而不是语法所许可的,后面如果没有特别指明的地方都是如此。

我觉得这个方法可行,就先采用它看看,如果后面不合适了我们再修改它。接下来就是如何具体声明Signal及其继承类型结构了,在前面我们已经看到BOOST中非常完美的表达方法:signal(函数原型),这在C中是无法做到的。

一种最直接的方法,就是上面直接写出的那样,但这样会跟使用者添加一些负担,它必须在每个使用的地方定义类型,并且这个结构中包含他原本不关心的东西。另外,这个工作大部分都是重复的。

上面提及的C面向对象实现中有实现一般class定义的例子,但仍然解决不了重复的问题。既然Signal只是一个特定的概念,我们只需要面向这个概念的接口;假设我们的回调对象是一个类似void(int, float)的函数,对于boost.signal,我们写为signal<void(int, float)>。这里的关键是参数部分。虽然boost可以支持带返回值的回调,但这个问题很容易以多附加一个参数的方式代替。所以,问题就退化为我们是不是有什么办法,可以让用户直接写SIGNAL(int, float)来定义这么一个signal呢?

(1) http://www.iteye.com/topic/452701
(2) http://www.state-machine.com/devzone/cplus_3.0_manual.pdf
0 请登录后投票
论坛首页 编程语言技术版

跳转论坛:
Global site tag (gtag.js) - Google Analytics