在ANSI C下设计和实现简便通用的signal-slot机制

时间:2023-01-14 00:12:05

http://bbs.chinaunix.net/thread-1592226-2-1.html

注:在几处发表同样的主题,希望通过讨论,接收到大家提出各种建议或意见,抛砖引玉。


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

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

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

  1. signal-slot(信号-信号槽)机制顾名思义就是一个发送和接收信号的系统。通常描述为signal是一个发布者,slot可以是一个或多个订阅者,这些slots作为回调者连接到signal上,在signal发出(emit)的时候会自动的被调用。一般情况下,一个signal何时会发生是不可预知的,或者至少在建立slots连接对象的时候如此,这非常适合用于在逻辑上独立性很强的代码间建立通讯。比如QT(1)和GTK+(2)这些GUI库都用signal来发布界面中产生的事件,这样程序中的功能模块和界面流程就可以完全解耦,便于独立的设计和开发。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

(1) http://www.catb.org/~esr/writing ... s04.html#c_language

图一.GIF(3.1 KB, 下载次数: 35)

在ANSI C下设计和实现简便通用的signal-slot机制


图二.GIF(2.96 KB, 下载次数: 36)

在ANSI C下设计和实现简便通用的signal-slot机制

在前面的叙述中多处提及到层,究竟什么是层,在软件设计中这是一个很抽象的概念,这里我试着解释一二。局部上看,当产生一次调用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

该是转入到正题的时候了。前面啰啰嗦嗦扯了一大通,可能不懂的还是看不懂,有点兴趣的人却早就不耐烦了。十分抱歉,最近事情都撞到一块了,有些安排不过来。不过慢一些也好,可以一边叙述一边思考,尽量考虑的周全一些,也欢迎大家一起来出主意。

首先需要给想达到的目标定下个框框。因为有前述很多的别人早已实现好的案例作为参考,所以对于一个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/WG...2009/n2927.pdf

(6)http://spirit.sourceforge.net/dl_doc...tml/index.html

模仿的对象有了,可以展开工作了。这个时候也许有人会有疑问,明明是要用标准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连接时附着的,所以我决定先花些时间解决它,后面会更安心些。另外这个问题比较容易:P

有两种方法可以实现这个过程,一个是在结构中添加新的字段,每个参数表现为一个新的字段;还有一种就是通过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.javaeye.com/topic/452701
(2) http://www.state-machine.com/devzone/cplus_3.0_manual.pdf

大家肯定都能想到这要通过宏来实现,虽然书上万遍提醒宏不能滥用——不知道这个规则哪里来的(1),简直是舍本逐末——但是我了解下来,很多人是根本不用宏,也不会用宏。再不让用,等于就废了令代码更规范的一个用力工具。没关系,我也曾经对宏产生出惧怕的心理,特别是在经历过噩梦一般的MFC后。

我们先把宏要展开的东西确定了之后再来想办法。前面已经有了Signal类型的例子,我们需要展开的只是可变的那个部分——就是像这样:

int var1;
float var2;
...

直到结构定义结束的大括号前的全部附加数据字段。因为分号是合法的分隔符,所以完全可以把这部分字段写在一行里,那就是:int var1; float var2; ...。不难看出,如果让用户来填充这部分内容,我们只需要把预定义的Signal类型跟用户定义部分连接在一起就可以了,如下:

#define SIGNAL(a) struct { Signal; a }

这里假设Signal是内部使用的数据类型。因为我们希望用户直接可以写”SIGNAL(int a) signal_a;“这样直接来声明一个signal,代码会少很多,所以采用匿名结构类型。

这太容易了,但离我们要的整洁程度还有一点点距离,能不能类似于boost用的占位符来缺省表示这些变量名,这样用户就只需要填写类型就可以了,天长日久又能少写不少代码——这是懒人们的想法。就是SIGNAL(int, float) 能展开为

struct { Signal; int _1; int _2 }

宏展开后肯定是一行,所以就直接写成一行了。

这得用上boost.preprocess——噗通,有人会不会大跌眼镜。这可能有两个因素,一是boost是个C++库,二是了解一二的人可能会对它使用的技巧感到头晕目眩。我是属于后者,前者实际上不用担心,因为在宏这方面,相对于C目前的C++没有任何的改进,甚至还没有引入C99的变参宏。

我和大多数又懒又笨的人有着类似的想法,讨厌“玩弄”技巧。所以在整个后面过程中不会有什么花样,都是一些极为基本的想法和知识。

但是每每看到牛人们把技巧玩弄成一种系统的“知识”时,还是不得不心悦诚服。boost.proprocess就具有这样的特点。我们知道宏是不允许嵌套的,所以没人去想到像写程序一样去写宏,比如循环、递归这样的结构。通过boost.preprocess可以定义这样的宏,比如做加法运算,就是类似:

SUM(1, 2),得到的结果是3
SUM(1, 2, 3, 4),得到的结果是10

注意这是宏变换出来的结果,是不是简直匪夷所思——一个文本替换的工具可以进行运算。

boost.proprecess是boost里所有库中被依赖最重的部分之一,通过宏,可以把一些库的最小接口归纳出来,极大的方便了使用,比如前面举例的BOOST_FOREACH宏。

有关实现机理这里有一篇网上的文章(2)说的比较简洁明了,最后的关键在于循环展开,类似于穷举法。还可以看一眼boost.binary(3)——类似手段的一个应用。

其实无论它自己的实现中包含怎样的技巧,使用它还是很容易的,这符合我前面的关于boost的观点。可以参考boost核心成员所作的入门指导(4)(作为《C++ Template Metaprogramming》这本书的附录,也可以看出其重要性)。关于boost.proprecess就介绍到这里,让我们用实现代码直接举例吧。

我们定义一个被循环调用的宏,实际过程中如果不知道要做什么,这一步可以放在后头做。

#define __SIGNAL_FILEDS(z, n, seq) \
  BOOST_PP_SEQ_ELEM(n, seq) \
  BOOST_PP_CAT(_, BOOST_PP_INC(n)); \

注:前面加上两个下划线,是因为我们希望它只局限于内部使用同时避免跟外界重名,这被称为“丑化”(ugly)。在库中如果所写的代码可能暴露到外部,但希望用户不要访问,一般会这么做,提醒到用户这不是一个接口。

然后,定义循环体来调用它就可以了。

#define SIGNAL(n, tuple) \
  struct { \
    BOOST_PP_REPEAT(n, __SIGNAL_FILEDS, BOOST_PP_TUPLE_TO_SEQ(n, tuple)) \
  } \

首先解释一下这里用到的数据结构。最基本是SEQUENCE(简写为SEQ),相当于一个可以反复展开的宏参数顺序列表(a)(b)(c)...,这些括号中的文本构成了一个序列,可以供预定义好的一些宏来访问,比如BOOST_PP_SEQ_ELEM(n, seq),用于访问(展开)这样列表中的第n个参数。

然后类似于for或者while等流程控制语句,BOOST_PP_REEAT可以对一个SEQUENCE进行循环遍历,第一个参数是循环次数,第二个参数是调用的宏,第三个参数就是一个SEQUENCE。

还有数据结构TUPLE或者ARRAY,它们是直接被逗号分隔的宏参数,类似于(a, b, c...)或者(3, (a, b, c)),这里的3表示TUPLE的大小。受制于宏的机制,在预处理中无法直接得出(a, b, c)这样一个列表中参数的数目,所以要额外提供这么一个参数。

可以在这些类似集合的数据结构上做一些同样的操作,它们之间也可以相互转换。例如上面用的BOOST_PP_TUPLE_TO_SEQ宏用于把一个TUPLE转换成SEQ,以被循环所接受。

调用的宏包含3个参数,第一个是下次循环到达的次数,用于优化目的,一般可以不用理会。然后是本次循环次数n,和SEQUENCE集合。

剩下的BOOST_PP_INC用于对一个数字进行加1运算,然后BOOST_PP_CAT可以把两段文本连接为一整个,功能上和宏的##运算符一致,但跟##不展开任何参数不同,它允许参数本身也是一个宏。组合它们就逐一产生出所要的_1, _2等所要的变量名来。

所以如果写

SIGNAL(2,(int, float))

就生成

struct { int _1; float _2; }

比起直接写是不是要简洁一些,这里稍微有些难堪的是”2"这个参数。前面已经说明过为什么要有它了,如果使用SEQUENCE的话可以省略掉,但必须类似这样来写:

SIGNAL(int)(float)

考虑到一般人使用参数的习惯,我偏向保留使用TUPLE的写法。可以通过定义一系列的宏来简略掉参数,就是:

#define SIGNAL0() SIGNAL(0, ())
#define SIGNAL1(...) SIGNAL(1, (__VA_ARGS__))
#define SIGNAL2(...) SIGNAL(2, (__VA_ARGS__))
...

然后就可以用SIGNAL2(int, float)来代替SIGNAL(2, (int, float))。

不要责备这里使用了C99的变参宏扩展,如果你的编译器不支持,完全可以把参数写全,我只是稍微偷了些懒。我倒是想,要是变参宏能够提供一个额外的参数数目来,再结合boost.proprecess那该多漂亮啦。

好了,你可以把这些宏录入到测试代码中,一般编译器提供有-E以及-P的参数——gcc当然支持,可以展开宏,试试是不是有些意思。

聪明人对比这后面的写法跟前面的写法,会发现没啥大的差别——后者只是少些几个变量名而已,还绕了不小的圈子。这里这么写也是为了后面其他地方也可能要用到有关,如果觉得不好,完全可以按自己的喜好来。不久还可以看到,前面写法的相比也有一个好处——用户可以完全按自己定义的名字去访问参数字段。

(1) 有一个说法是:Bjarne Stroustrup在担任AT&T大规模程序设计部门负责人的期间发现,将近50%的问题是由宏引起的。
(2) http://www.cppblog.com/kevinlynx/archive/2008/08/20/59451.html
(3) http://www.boost.org/doc/libs/1_38_0/boost/utility/binary.hpp
(4) http://www.boostpro.com/tmpbook/preprocessor.html

有了signal,下面该是slot了。且慢,经过前面的分析,我们知道slot相似的部分应该不是什么问题,那些不清楚的地方还不是十分明朗,我们先把周边琐碎的问题处理掉,最后再集中解决它。

沿着signal的部分往下,已经知道了signal的定义,我们如何发出它。我们知道大概样子应该是SIGNAL_EMIT(实际参数列表)。这里的实际参数列表对应着前面我们定义的占位符字段,也就是_1,_2,等等。这很容易,就是一些赋值语句,最后应该有一个调用slot的地方,我们假设它是一个函数,大约是signal_emit(Signal *)这种形式,因为参数已经包括在Signal类型里了。所以SIGNAL_EMIT宏展开的形式是——我们以SIGNAL2(int, float)这个类型的signal举例,也就是如果SIGNAL_EMIT(signal, 10, 0.5)展开会得到:

signal._1 = 10;
signal._2 = 0.5;
signal_emit(&signal);

考虑到实际使用中我们不一定能访问了实例,而对于实例指针却不存在问题,所以实际的代码应该是以指针为参数,也就是SIGNAL_EMIT(signal_ptr, 10, 0.5),这里使用signal_ptr明确含义,同时也避免一些可能的重名:

(signal_ptr)->_1 = 10;
(signal_ptr)->_2 = 0.5;
signal_emit(signal_ptr);

对指针的形式不了解的情况下把它用括号括起来是个好习惯,比如如果使用者这样调用宏:SIGNAL_EMIT(&signal, 10, 0.5),没有括号的话展开的代码就错了。

好了,有了展开式,写宏就是体力活了,参考之前的循环,我们不然得出:

#define __SIGNAL_PARAMS(z, n, signal_params) \
  BOOST_PP_CAT((BOOST_PP_TUPLE_ELEM(2, 0, signal_params))->_, BOOST_PP_INC(n)) \
  = \
  BOOST_PP_SEQ_ELEM(n, BOOST_PP_TUPLE_ELEM(2, 1, signal_params));

#define SIGNAL_EMIT(n, signal_ptr, params) \
  BOOST_PP_REPEAT(n, __SIGNAL_PARAMS, (signal_ptr, BOOST_PP_TUPLE_TO_SEQ(n, params))) \
  __signal_emit((Signal *)(signal_ptr));

最后一行的调用形式还不确定,我们先大概写一下。

然后类似的,为了使用上的方便,我们定义一系列不同数目参数的宏:

#define SIGNAL0_EMIT(signal_ptr) SIGNAL_EMIT(0, signal_ptr, ())
#define SIGNAL1_EMIT(signal_ptr, ...) SIGNAL_EMIT(1, signal_ptr, (__VA_ARGS__))
#define SIGNAL2_EMIT(signal_ptr, ...) SIGNAL_EMIT(2, signal_ptr, (__VA_ARGS__))
#define SIGNAL3_EMIT(signal_ptr, ...) SIGNAL_EMIT(3, signal_ptr, (__VA_ARGS__))
#define SIGNAL4_EMIT(signal_ptr, ...) SIGNAL_EMIT(4, signal_ptr, (__VA_ARGS__))
#define SIGNAL5_EMIT(signal_ptr, ...) SIGNAL_EMIT(5, signal_ptr, (__VA_ARGS__))
#define SIGNAL6_EMIT(signal_ptr, ...) SIGNAL_EMIT(6, signal_ptr, (__VA_ARGS__))
#define SIGNAL7_EMIT(signal_ptr, ...) SIGNAL_EMIT(7, signal_ptr, (__VA_ARGS__))
#define SIGNAL8_EMIT(signal_ptr, ...) SIGNAL_EMIT(8, signal_ptr, (__VA_ARGS__))
#define SIGNAL9_EMIT(signal_ptr, ...) SIGNAL_EMIT(9, signal_ptr, (__VA_ARGS__))

这里的“...”依然是C99的变参宏形式,如果觉得不妥,可以把1-9个参数都写全。是不是感到有所不爽?我也一样。继续说上一二,这里有两个问题,一个是下面的定义跟上面有重复之嫌,比如对于C89而言既然要写全所有的参数,那么宏完全可以直接在下面定义就可以了,不需要上面的循环过程。还有一个是,如果还有更多的参数怎么办?理论上参数可以无限递增下去。

对于前者,的确没啥好的理由去解释,毕竟要受制于宏。但牛人们都喜欢这么用,看BOOST里的代码,同时都会有两种形式。如果碰上对宏支持有缺陷的编译器时,就一个个手写宏,否则就采用循环。也许他们和我一样骨子里希望有个什么可以偷懒的法子吧。

早期写程序,看到厂家提供的案例,一个简单的问题都定义出无数的函数和过程来,一大堆参数调用来调用去,自己写往往就是一串语句下来直接解决掉。随着项目做的多了,渐渐就明白那样的好处了——如果过程中一旦出现什么变化,别人的代码会改动非常小,或者几乎没有改动,只是参数上的调整而已。而自己的代码就得彻底推到重来,这就是因为自己就没有对代码的行为归纳总结。说到底,是自己考虑问题的时候,没有考虑可能出现的变化。设计模式也是如此,灵活性来自于哪里?就来自于你对问题可能发生的变化都考虑到了,体现到代码上就是各个调用接口,写代码的工作相当于教会计算机帮我们做事情。

那么对于参数数目上的意见呢?以前看到人家项目的代码里,经常出些类似的一堆不同参数的LOG定义,非常不以为然。慢慢的也可以理解了,毕竟实际我们遇到的大多数问题是普通的问题,那种极端的问题再特别对付也不迟。就算是编译器内部也是存在限制的,只是限制远远超出我们潜在的需求,所以不必为此吹毛求疵。

废话一堆,言归正传。对于这里使用的宏名称我不是十分有把握,如果大家有什么更好的建议可以提出来,接下来对于具体一个SIGNAL2(int, float) signal的调用就是:

SIGNAL2_EMIT(10, 0.5);

至于后面具体怎么做的,要不可以不用关心,要不就跟着后面慢慢了解吧。

signal_emit的实现必然离不开slot,那那我们看看signal_connect呢,这就该是定义slot的地方了,更是离不开,所以不可避免的,我们要进入整个过程中最关键的部分:如何生成一个slot?

让我们先大概比划一下,模仿boost.signal所做的,假设我们有个signal——这个就是具体的名字吧,我们需要:

signal.connect(bind(...));

bind部分生成了一个函数对象作为slot,在C里面我们实现不了,但如果能达到我们的目标的话,格式应该是类似的:

SIGNAL_CONNECT(signal, bind(...));

对一个对象,我们可以试图用结构来代替,前面我们也说过slot携带参数的问题,所以应该跟Signal类型是类似的,这个类型的实例可以通过几个方式——一个是在signal_connect里生成,一个是bind返回,还有就是预先定义好——来完成。

最后一个灵活性最大,我们实验过程中就先采用它吧。

那么bind的形式可能是bind(slot, ...)这个样子,无论是宏还是函数,这里必须生成一些必要的数据提交给signal,以最终在signal_emit的时候可以调用某个过程。因为即便是宏,也只能产生本地的代码,这些代码是无法提交到signal_emit过程里的。而signal_emit本身是不能参与到连接过程中的,那样就达不到解耦的目的。

从前面boost.signal的例子我们可以了解到,这样一个过程生成的至少应该是对一个函数指针的绑定,那么我们如何在signal_emit中调用这样一个函数呢?它的参数应该来自于signal和slot的组合,这肯定是变化的。

那么假设signal_emit可以调用一个变参函数,C标准中有<stdarg.h>,允许我们使用一些宏来获取这些变化的参数,最终我们再转换到对函数的调用,这依然逃脱不了怎么产生调用的过程,因为不同函数,参数数目和大小是不同的,必须有明确的参数列表,编译器才能够产生出正确的调用代码来。

让我们进一步分析,我们知道大部分调用是通过栈来传递参数的,如果相同类型或大小的参数,在调用栈中出现的位置也应该是一致的。因此变参函数中可以不考虑参数的类型,可以按照其大小来获取参数,可以预定义一系列尺寸的类型来适配它。但是最终的函数调用是不能这么做的,因为调用参数的数目和大小是一个排列组合问题,很快就膨胀到无法罗列的地步。在C标准中没有规定变参函数和普通函数参数间的匹配问题,所以也无法用变参函数来代替普通函数。在gcc中如果强行这么做编译器会给出一个警告,并在执行到掉用时直接挂起程序。

再分析下去,就要考虑到不同编译器不同平台上的调用约定,或者叫二进制应用接口(ABI)(1),这肯定是做不到兼容的,而且对于大多数程序员而言这也超出了他们需要了解的范畴,这样的代码移植起来是十分困难的。比如x86平台上参数大多数是通过栈传递的,而对于我手头的ARM这样的RISC平台,可以利用寄存器多的优势,把前三个参数通过寄存器传递。

也许是我的能力还没有到达掌握它的地步,总之,这条路是走不下去了。

让我们回到连接过程再考虑考虑,如果不考虑函数绑定的问题,我们需要的大概是这样形式的调用:

SIGNAL_CONNECT(signal, slot, ...);

省略号代表信号发出时我们需要执行的语句,那么如果我们直接用实际的程序代码来代替会怎么样呢?这样,前面讨论如何调用函数根本不是个问题,直接写调用代码就完事了,既然可以写调用代码,那写其他代码也不是问题,对编译器来看肯定都是一样的。这很自然的让我想到了语句块,也就是假设我们有被大括号括起来的这么一些语句表达式:{ ... },我们需要的是在signal发生的时候,程序能够进入到这个语句块中进行执行,并在末尾出返回到signal发生的地方——一般就是signal_emit函数体里。

继续来看signal_connect过程,也就是上面SIGNAL_CONNECT(...)宏展开后的形式是:

signal_connect(signal, slot);
{
...
}
下面就是signal发出时才执行的代码,在正常程序流程时,connect完毕,会顺序执行它们,这一般肯定是我们不想要的结果,但这不是问题,最简单的方法我们可以用一个goto语句跳过它,就是:

signal_connect(signal, slot);
goto END_OF_SLOT;
BEGIN_OF_SLOT:
{
...
}
END_OF_SLOT:

接下来一个问题是,我该如何返回呢,就是在signal发出,执行到花括号结束后能够返回到发出的函数体里。

我们有没有在运行时让程序可以跳来跳去的办法呢?相对于前面说的,这样我们的大脑就不用跳来跳去了。

(1) http://en.wikipedia.org/wiki/Application_binary_interface

上面的想法是如此的直接,以至于在我的脑海里它似乎已经存在了很多年,恍惚记忆中是非常久远的事情了;又或者反复考量过它很多回,但从来没有被实现过。

说起前面那种跨越语句块的跳转,对C比较熟悉,或者在linux/unix下做过开发的人大多都会想到setjmp/longjmp函数。相对于goto语句,这种跳转被称作长跳转。如果你还不知道它有什么用,不妨去看看王胜祥写的系列文章(1)。

C前辈高手们留话说:longjmp和setjmp玩得不熟,就不要自称为C语言高手。(2)当然这是仁者见仁智者见智的事,实际上如果不是linux/unix下signal中断(系统信号)的需要,很多人恐怕都不会遇上它们。而且这对和系统底层有着密切联系的函数竟然出现在C标准中也多少让我感到有些诧异。莫非那些制定标准的大牛们认定了语言里需要这样一种机制?不管那么多,能帮助我完成后面的工作那就要谢谢它。

我们先不细究它们,简单讲一下setjmp/longjmp的标准用法。setjmp函数用于把程序当前状态保存在一个类型为jmpbuf的缓冲区里,然后在程序的任何地方我们都可以通过以它为参数调用longjmp函数来跳转到之前调用setjmp函数的地方,并且以另外一个整数值参数作为那个setjmp函数的返回值——因为规定第一次调用setjmp函数的返回值为0,所以可以区分出程序是顺序执行下来的还是从其他地方跳转回来的。

初次了解到这个旮旯的话,肯定立刻恍然大悟了:前面我们要的功能实现起来十分简单啊。那我就把代码罗列出来吧。

首先,我们需要在signal和slot结构里增加上述jmpbuf的字段以供前后两次跳转使用。

我们定义基本的Signal和Slot结构类型(定义前需要包含头文件#include <setjmp.h>):

struct __Signal {
  jmp_buf environment;
  struct __Slot *slot;
};

struct __Slot {
  jmp_buf environment;
  struct __Signal *signal;
};

除了供跳转用的jmpbuf字段,在signal和slot的结构中还各自包含对方的指针,一个方便signal发出时调用,一个用于记录signal-slot对的连接。

connect过程简单如下:

void __signal_connect(struct __Signal *signal, struct __Slot *slot)
{
signal->slot = slot;
slot->signal = signal;
}

然后根据前面说的,就可以写出一个大致功能的signal_emit函数:

void __signal_emit(struct __Signal *signal)
{
  if (setjmp(signal->environment) == 0) {  // 这里设置返回点
    /* 跳转到slot代码处,取1为入口返回值 */
    longjmp(signal->slot->environment, 1);
  }
}

disconnect过程暂不关心,略过。

为了方便追踪,我们在代码中先不用宏,但尽量模拟宏展开后的样子,所以测试程序如下,方便起见,前后代码就都放在一个c文件里:

// 声明一个带有两个参数,分别为int,float型的signal
struct {
struct __Signal signal;
int _1;
float _2;
} signal;

int main()
{
struct __Slot slot;  // slot暂不携带其他信息

/* 这里开始是SIGNAL_CONNECT宏展开的代码 */
__signal_connect((struct __Signal *)&signal, &slot);
if (setjmp(slot.environment) != 0) {  // 如果是跳转而来的话
   /* 这里就是slot代码了,添加打印信息,表示成功 */
   printf("int=%d, float=%f\n", signal._1, signal._2);  // 看看我们的占位符是如何使用的在ANSI C下设计和实现简便通用的signal-slot机制
}
/* 这里SIGNAL_CONNECT结束 */

/* 以下是顺序执行的代码,我们模拟SIGNAL_EMIT宏展开的代码调用前面的signal-slot */
signal._1 = 5;
signal._2 = 10.0;
__signal_emit((struct __Signal *)&signal);  // 调用处只需要signal的信息

/* 大功告成 */
return 0;
}

代码是如此简单,我稍微解释一下之前相关的部分——就是占位符。因为我们的占位符实际上就是结构的成员,所以代码使用起来就是访问结构成员的写法,是不是失去了一些神秘感?我之前说到过signal参数命名的问题。在这种情况下,似乎用有意义的名字反而更好些,比如int代表是数量,而float代表是价格,是不是分别用amount和price来的更清楚些?当然也可以把这个信息体现在signal的命名上,但那样名字又会太长;如果交由用户命名,signal_emit部分的赋值代码用户也必须负责(也可能会少一些赋值语句)。总的来说,我认为它们没有太多优劣之分,所以后面还会坚持使用_1,_2。。。这样的形式,就当是坚持一个梦想吧。

好了,现在可以编译运行一下——“怎么?”。比我聪明的人肯定立即就察觉到这样的程序肯定过不了关;如果你和我一样笨,那也没关系,运行一下就知道了。如果你对setjmp和longjmp是怎么回事并不清楚,那么跟着也就慢慢知道的。

上述程序在不同的编译器和优化参数下会有不同的结果,我们唯一能确认到的地方就是的确能执行到slot里的代码——打印出信息来,但后面就不能按设想的正常工作了。这究竟是怎么回事?我们必须了解到setjmp/longjmp的工作机理了。

由计算机结构原理可知反应计算机当前执行状态的信息莫过于就是一堆寄存器了,计算机通过这些寄存器的状态变换来逐一执行机器指令。所以对于内存不发生变化的情况下,一个寄存器集合就相当于当前上下文环境的一个快照(这也是为什么我们把jmpbuf字段命名为environment,或者也可以叫context)。如果进一步理解程序的执行,我们可以看出来,这里头最关键的部分是对堆栈部分的保存和恢复,因为函数的调用和返回必须通过堆栈来实现。所以setjmp和longjmp函数就是完成了这样一些工作来做到跳转到之前的某个工作现场的。

那么如果来回跳转会发生什么情况呢,相当于我们需要跳转到过去,做一些事然后再跳转回来;或者对于setjmp/longjmp这么一对匹配的调用来说,我们需要在一对和一对之间形成交叉,这种事情常常是有些令人不放心的。

这里我们就分析最关键的栈的变化情况,如下示意的,一个栈的空间变化是后进先出,这可以用于在函数调用之间保存局部变量,调用参数和返回地址,当一个调用产生时,我们可以说生成一个新的栈帧来对应它。

(图三)

对于signal-slot而言,slot所在的函数其栈帧在前,而调用signal的函数栈帧在后,对于上面的程序而言就分别是main和__signal_emit函数的栈帧,因为当signal调用产生时,需要跳转到之前的main函数中,也就是必须恢复到之前进入到main函数的栈帧,才能恢复当时的执行现场。同时栈顶指针(如果有的话)重新指回到当初main栈帧的顶部,以便于下一个调用的来临。不难得出,此时在从main到signal发出这一段的函数调用栈帧已经被释放,而且当有新调用产生的时候必然覆盖掉曾经出现过栈涨部分。也就是说我们肯定无法再返回到signal调用函数体里,即便是可以恢复到之前栈帧位置上也是如此。这就相当于你可以回到过去,但不能做任何事情一样来改变因果关系一样。


(图四)
如图四,使用两对setjmp/longjmp函数是无法工作的。

(1)http://www.csai.cn/incSearch/sea ... =%CD%F5%CA%A4%CF%E9
(2)http://www.limodev.cn/blog/archives/340


图三.GIF(2.91 KB, 下载次数: 28)

在ANSI C下设计和实现简便通用的signal-slot机制


图四.GIF(3.45 KB, 下载次数: 28)

在ANSI C下设计和实现简便通用的signal-slot机制

那是不是可以保留一对setjmp/longjmp,另外一个跳转再考虑其他的办法。这应该是可以的,那么保留哪一对呢?稍微分析一下就可以得出答案:我们需要保留是从slot中返回的那一对。因为前面的跳转会破坏堆栈,这不是我们想要的结果,我们希望的是程序还沿着现有的层次继续执行回去,只是调用那一刻的代码转移到slot中来执行。

那么跳转到slot中该如何实现?我们需要的是相当于goto这样的功能,只是不局限于一个语句块的内部,这个时候就需要一些平台相关的代码来解决它了。

其实想到这样一种跳转方式来实现signal-slot,就是因为我这次面对的是一个特定平台(ARM)特定编译器(ADS)下的开发。关于ARM我虽然了解不多,但也知道它有个pc寄存器,可以用于获得当前程序指令的地址——也就是说,应当可以获得前面示例中的BEGIN_OF_SLOT前后的地址,这样我可以通过嵌入汇编的方式,实现整个signal-slot连接和跳转过程。

然而在实现过程中,我发现除了这个地方之外其他的部分都可以不需要与平台相关联,所以决心把它做成通用的一个实现。在我所知的一些平台上,获取程序执行的地址都是有办法的——几乎所有编译器都支持某种形式的嵌入汇编,如果我们能把这里需要的代码量压缩到只有几句的话,那么移植起来就不会是太累人的事;而且,有些编译器甚至不需要通过汇编来获取这个地址,比如gcc。

说到gcc,请允我先补充一下有关内容。以前javaeye的TrustNo1在谈论到c这种非函数式语言中实现lambda表达式的问题时,指出他需要的是可以在函数体内部任意地方临时定义一个函数的功能。gcc对c的扩展中是支持这种形式的,称为嵌套函数。(1)

有了嵌套函数,上面讨论的问题就都不存在了,我们可以传递其指针让signal可以调用它并返回。简单示例一下代码如下(只列出不同部分):

struct __Slot {
void *func_addr;
struct __Signal *signal;
};

void __signal_emit(struct __Signal *signal)
{
((void(*)())(signal->slot->func_addr))();
}

int main()
{
struct {
   struct __Signal signal;
   int _1;
   float _2;
} signal;

struct __Slot slot;

/* 这里开始是SIGNAL_CONNECT宏展开的代码 */
void func() {
   printf("int=%d, float=%f\n", signal._1, signal._2);
}
slot.func_addr = &func;
__signal_connect((struct __Signal *)&signal, &slot);
/* 这里SIGNAL_CONNECT结束 */

signal._1 = 5;
signal._2 = 10.0;
__signal_emit((struct __Signal *)&signal);

/* 大功告成 */
return 0;
}

的确非常好,可惜这个扩展不被其他编译器接受,连替代的办法也没有。

所以还是回到我们上面讨论的办法来吧。为了不过早嵌入到丑陋繁琐的汇编代码细节里,我们先借用一下gcc的另外一个扩展——它支持一种取得标签地址的运算符"&&"(2)。至少如果可行的话,其他平台上我们有替代的办法。

于是我们把代码改成下面的样子(仍然只列出跟上例不同的地方):

int main()
{
struct {
   struct __Signal signal;
   int _1;
   float _2;
} signal;

struct __Slot slot;

/* 这里开始是SIGNAL_CONNECT宏展开的代码 */
slot.func_addr = &BEGIN_OF_SLOT;
__signal_connect((struct __Signal *)&signal, &slot);
goto END_OF_SLOT;
BEGIN_OF_SLOT:
{
   printf("int=%d, float=%f\n", signal._1, signal._2);
}
longjmp(((struct _Signal *)&signal)->environment, 1); // 不好意思,上次的代码少了这一句
END_OF_SLOT:
/* 这里SIGNAL_CONNECT结束 */

signal._1 = 5;
signal._2 = 10.0;
__signal_emit((struct __Signal *)&signal);

/* 大功告成 */
return 0;
}

编译运行看看?哈,一点也不奇怪,程序立即就当掉了。可以跟踪执行一下,你会发现跳转进入slot后,根本不存在对printf函数的调用代码,这是因为编译器已经把它优化掉了。对于上述这种硬goto的情况,包括if(false),while(false)等类似的代码,大多数编译器都会毫不留情把代码砍掉,所以一些用于产生debug信息的代码利用这个特性来在不同编译条件下切换以不影响到最终代码的质量。

那我们应该怎么办?这个不难,我们只需给goto语句附加一个编译器无法优化的条件就可以了。这个办法很多,最通用的一种是增加一个volatile变量,显式的阻止可能存在的优化。我这里采用是另外一个办法,因为它汇编出来的代码要少些:

int main()
{
struct {
   struct __Signal signal;
   int _1;
   float _2;
} signal;

struct __Slot slot;

/* 这里开始是SIGNAL_CONNECT宏展开的代码 */
slot.func_addr = &BEGIN_OF_SLOT;
__signal_connect((struct __Signal *)&signal, &slot);
if (slot.func_addr + 1) goto END_OF_SLOT;
BEGIN_OF_SLOT:
{
   printf("int=%d, float=%f\n", signal._1, signal._2);
}
longjmp(((struct __Signal *)&signal)->environment, 1); // 不好意思,上次的代码少了这一句
END_OF_SLOT:
/* 这里SIGNAL_CONNECT结束 */

signal._1 = 5;
signal._2 = 10.0;
__signal_emit((struct __Signal *)&signal);

/* 大功告成 */
return 0;
}

这里对func_addr这个指针附加一个递增运算,如果不递增是不行的,因为编译器仍然会识别出func_addr不为0。但递增后它就不确认了。但实际上func_addr不可能在递增后归0,所以仍然是可行的。

编译运行!啊,可喜可贺?可以看到打印语句已经被执行到了,只是打印出来的东西还是不对,怎么回事?

稍加分析就不难理解,根据之前的栈帧分析,我们可以知道,这个时候进入到slot的栈帧已经不是之前进入main函数的栈帧了,所以所有的对局部变量的访问都不可能恢复到过去的地址上去,而编译器却对此毫无所知,也就是说不通过对main的调用,进入到main中任何地址执行,其状态都是不确定的。

那怎么办?有一种办法是把signal,slot转移到全局或者静态变量上,这种变量的地址是在编译时就已经确定并且不会改变的,那么代码中所有访问它们的地方在任何时候执行的效果都是一样的。我们来试试看:

int main()
{

static struct {
   struct __Signal signal;
   int _1;
   float _2;
} signal;

struct __Slot slot;  // 我们没有用到slot,所以暂时可以不变


/* 这里开始是SIGNAL_CONNECT宏展开的代码 */
slot.func_addr = &BEGIN_OF_SLOT;
__signal_connect((struct __Signal *)&signal, &slot);
if (slot.func_addr + 1) goto END_OF_SLOT;
BEGIN_OF_SLOT:
{
   printf("int=%d, float=%f\n", signal._1, signal._2);
}
longjmp(((struct __Signal *)&signal)->environment, 1); // 不好意思,上次的代码少了这一句
END_OF_SLOT:
/* 这里SIGNAL_CONNECT结束 */

signal._1 = 5;
signal._2 = 10.0;
__signal_emit((struct __Signal *)&signal);

/* 大功告成 */
return 0;
}

不好意思,因为全局signal的命名和系统头文件产生冲突,我这里选择了静态变量的办法。

编译执行!终于可以松了一口气——结果证明这个办法是可行的。

全局(静态)变量虽然帮助我们达到了目标,但这个方法显然离目标还差了很远,只是勉强凑活在一些没办法时候用用。根据我的实际使用经验看,的确很多时候signal成为全局或者准全局的可能性还是蛮大的,但这里的slot就不一定了。我们为了灵活优化的目的,没有时刻在堆上分配slot——这样使用起来,能够局部声明的话,还是有些优势的。

如果我们可以使用栈上的变量,而不是重新产生新的的话,对slot内部的语句块也有大大的便利,这样它会更像是lambda表达式,而不仅限于一两句简单的调用/转移语句。

(1) http://gcc.gnu.org/onlinedocs/gc ... ml#Nested-Functions
(2) http://gcc.gnu.org/onlinedocs/gc ... ml#Labels-as-Values

若问起如何想到这样时,我只能说是模仿——从前面的内容大家应该都可以了解到。所以要感谢谢牛人们带来的创意设计,借用一句话:只有想不到的,没有做不到的。不过看到这里百般折腾的摆弄,牛人们也肯定会不以为然:有这个闲工夫早就搞一门新语言了。呵呵,插科打诨一下,别以为意。

但是,还有许多问题没有解决掉,前面还不知道会有有怎样的艰险?让我们继续启航吧——小舟颤颤巍巍的驶向了大海深处。

可能很多人只是通过文章来了解信息,并没有真正去试过,所以我说过的东西并没有经过充分的验证,至少明显其中有不少手误。这些就罢了,对于拿到test.c文件的人,如果你的测试通过了,那得恭喜一下了。不过,我想你肯定没有打开编译优化开关,现在我们继续做一些测试,给gcc命令加上-On参数,n可以从1一直到6,会有怎样的结果?

一旦打开了优化开关,就像打开了潘多拉的魔盒,随着优化深度的不同,各种奇怪事物纷纷出现:可能程序没有任何反应了,也可能输出了结果但挂起了。总之程序异常退出了,结果非常糟糕。怎么办?别急着关上它,让我们一探魔盒里的究竟,看看是不是如传说所言那样最终输出美好的结果。

不过程序出现异常倒不是什么意外的事情,实际上,最初很快就实现跳转并输出正确结果反而有些出乎我的意料。现在为了更好的追踪定位错误,我们还是先回到宏展开的代码吧:

int main()
{
  static struct {
    struct __Signal signal;
    int _1;
    float _2;
  } signal;

  static struct __Slot slot;

  /* 这里开始是SIGNAL_CONNECT宏展开的代码 */
  __signal_connect((struct __Signal *)&signal, &slot);

  /* 设定跳转地址 */
  (&slot)->func_addr = &&LABEL_BEGIN;
  LABEL_BEGIN:

  /* signal跳转进入的slot部分 */
  if (slot.signaling) {      
    {
      /* 真正的slot代码 */
      printf("int=%d, float=%f\n", signal._1, signal._2);
    }
    longjmp(((struct __Signal *)&signal)->environment, 1);
  }
  /* 这里SIGNAL_CONNECT结束 */

  signal._1 = 5;
  signal._2 = 10.0;
  __signal_emit((struct __Signal *)&signal);

  /* 大功告成 */
  return 0;
}

再补充一段signal emit的代码:

void __signal_emit(struct __Signal *signal)
{
  struct __Slot *slot = signal->slot;
  while (slot) {
    slot->signaling = 1;
    if (setjmp(signal->environment) == 0) {
        ((void(*)())(slot->func_addr))();  // 上次的代码这里有误
    }
    /* 跳转返回后的地方 */
    slot->signaling = 0;
    slot = slot->next;
  }
}

这里需要关注三个地方分别是:设定跳转地址,signal跳转进入的slot部分,以及跳转返回后的地方。

设定跳转地址这个地方的问题有些“无厘头”,如果是借助汇编实现的,反而一般没有问题。当其他问题都解决掉以后,我手头编译器(gcc 4.x)结果显示,在-O2优化设定下,程序将没有任何反应,而其他优化级别无论高低,皆能执行到正常的slot部分——打印出结果。跟踪调试代码,发现func_addr被设定到main函数开始前的某个地址处,所以陷入了死循环。据我的分析,应该是LABEL_BEGIN并没有被程序流程真正使用到,所以优化器认为这个值是多少并不重要,解决的办法就是增加一个真正使用到这个标签的语句,如下:

slot.func_addr = &&LABEL_BEGIN;
{
   void * volatile temp = slot.func_addr;
   if (temp) goto LABEL_BEGIN;
   slot->func_addr = temp;
}
LABEL_BEGIN:

这里附加了一个volatile变量来阻止对goto的优化,而且在goto后面增加了一句实际无用的赋值语句,原因是你不能在紧挨标签前一句跳转到标签,那样优化器把那句话忽略,又回到原先的情景。

下一步是signal跳转进入slot的部分,当优化级别高到一定程度,程序同样也会没有任何结果的非法结束。反汇编跟踪显示,这一段没有设想中应有的代码。这是因为编译器识别出slot的signaling字段的值恒为0(我们在__signal_connect初始化它),所以认为这段代码不会被执行到而忽略。为什么编译器会识别到一个函数体中的执行结果(后面会指出,通过调用一个无用函数是阻止编译器优化的办法之一)?这是因为测试程序中我们把函数和调用者放在同一个.c编译单元中,优化级别提高以后,会直接内联到调用者代码里,所以优化器得出这样的结果。至于后面跳转中的赋值,那是非正常的程序流程,编译器自然不会把它们关联起来。

实际使用中,因为__signal_connect可以作为库的一部分摆放到独立的.c文件,独立的编译,也就自然避免了某些类似的问题。这里我们先采取一个临时的措施来阻止这个优化,把slot变成volatile的:

volatile static struct __Slot slot;

解释完这两个边角问题后,下面进入了我们主要需要对付的地方——跳转。

关心细节的人肯定会注意到我们使用函数调用方法来实现跳转的。通过把执行地址强制转换为一个函数,并调用它,最终程序流程会执行到我们想要跳转的地方。函数调用肯定不是跳转这么简单的一件事情,不过方便起见我们使用了它——反正后面的longjmp会回到跳转之前,无论跳转里面做了什么事情都无关紧要。当然,这也可以通过汇编方式的实现,在x86平台下可以通过一条长跳转指令,跟微软的thunk差不多的办法;但是在ARM ADS/RVDS平台上,内嵌汇编是不允许这种跳转的,因此函数调用是既方便也更通用的办法。

但是实际上我们进入的slot并不是一个函数,干活的指令虽然还是那些,但是没有了进入到一个函数后必须的一些初始化工作,同时正常流程下编译器估计会运行到的状态都已经不存在了,也就是编译器可能做出一些无法预计的优化,这原本应该是有效的,但现在变成错误了。这种情况下,代码还能工作实在是件值得庆幸的事情。

好吧,程序的确能打印出些什么,为什么之后就挂掉了呢?这关键还在之前讨论过的调用栈上。对于一个通常的函数调用而言,为了在调用后正常回到原始程序,它必须对之前的堆栈加以保护,还可能包括一些寄存器,编译器根据不同平台上的使用惯例,来假设函数调用前后的环境——我们之前说过它大概对应着一个寄存器集合。

保护堆栈的方法就是每个函数必须形成和使用自己的栈帧,我们以容易接触到的x86平台为例,对于x86上c函数调用约定一般大家都知道cdecl,pascal等之类,后者被微软用于windows回调函数。cdecl是标准c使用的,我们就以它为例,它要求调用前,参数自右向左入栈,调用完成后,调用者负责恢复这一部分堆栈,这样方便不定参数的传递。最后调用发生时,指令call会将其下一条指令地址压入栈,这样函数返回后的ret指令会弹出并沿之继续执行。一般对栈的使用上会有两个指针,一个栈顶指针(栈指针),和一个栈基指针(或者叫栈帧指针),x86上分别是esp和ebp寄存器。任何对栈的操作esp寄存器自动改变,保证始终指向栈顶。ebp是由函数本身管理的,每次进入函数时,函数会保护之前的ebp(压栈),并把当前栈顶指针位置作为新的ebp值,这样使用的局部变量统统可以交由一个ebp带一个偏移量来实现,对于通常使用的的满递减栈(full descending)而言,这个偏移量是一个负值。这里不使用esp的原因是因为它在函数体内如果仍然是可能/需要变化的。最终函数退出的时候,再恢复之前的ebp值——它是上一个函数所使用的,这样所有的栈帧都可以通过ebp进行回溯。(1)

我们用一个图来直观的表现栈帧的细节,图五:

x86上函数进入和退出的标准惯例代码,进入时执行的指令是:

push ebp       ;保存之前的栈基指针
mov ebp, esp   ;令ebp指向当前栈顶位置,
sub esp, x     ;x是函数使用的auto(局部)变量的字节大小,这样栈顶指针就知道该函数栈帧的末尾处。

退出时,恢复到它们进入函数前状态:

mov esp, ebp
pop ebp
ret

对于不同的平台,使用栈的方法大体是差不多的,比如ARM下,习惯上r13用作为栈顶指针sp,r11用作栈帧指针,当然具体指令中这不是绝对的。

这里参考的地方还有很多(2)(3)(4)(5)——恕我不能一一列举,都是些本地高手的链接,我深知这是抬高声望的捷径 在ANSI C下设计和实现简便通用的signal-slot机制

很显然,对于我们的slot调用过程而言,尽管如上述退出我们可以不关心,但是它并没有进入的那一部分。这样,slot中使用的栈帧仍然是调用前那个函数的,虽然如果通过调用,栈指针已经被更新过,但那本来就不会影响到之前的栈帧。这里出现两个情况,一个是对局部变量的访问实际上落在之前栈帧向后的空间,甚至可能越出了到达栈顶空闲部分(看变量的偏移值),当然目前我们没有使用局部变量可以放过这一个点。二是如果slot中产生函数调用时,其压入栈的参数位于栈顶之前(出于优化,编译器会预先计算好包括压栈参数在内所需的栈帧尺寸,一次性把栈指针扩展到后头,然后通过mov指令而不是push指令加快压栈进程),同样覆盖了之前函数的栈帧空间。总之,没有了当前slot的栈帧,对栈的操作都是危险的。如图六:

这怎么办呢?当然了解到平台ABI细节,可以通过内嵌汇编实现。但这不方便。更加简洁的办法是,给slot附加一个函数入口,也就是跳转时进入一个空函数,该函数负责分配栈帧,保存设定栈帧指针的工作,然后跳转过来就没问题了。后面的工作编译器可以自动帮助完成,我们只需要负责分配栈帧空间就可以了。这可以通过申明一个字节型数组来实现,具体要多大栈帧,就指定多大的数组,也许不一定确切知道这个大小的值,但只要保证大到一定程度没问题就可以了;可以在所有局部使用变量包括函数参数的字节大小上再给一个足够的富余量就可以了;甚至你可以大概估算一个大的多的值。

我们定义__signal_slot_invoke函数如下:

void __signal_slot_invoke(struct __Slot *slot)
{
  volatile unsigned char temp[1024];    // 1024是一个太大到足够的值
  ((void(*)())(slot->func_addr))();
  temp[0] = temp[0];      // 阻止尾调用优化及数组忽略
}

然后重写__signal_emit函数:

void __signal_emit(struct __Signal *signal)
{
  struct __Slot *slot = signal->slot;
  void(* volatile func)(struct __Slot *) = &__signal_slot_invoke;
  while (slot) {
    slot->signaling = 1;
    if (setjmp(signal->environment) == 0) {
      func(slot);
    }
    slot->signaling = 0;  // 未知用处,可略
    slot = slot->next;
  }
}

我们这里定义了一个volatile 的func函数指针,再通过它来调用实际的__signal_slot_invoke函数,这是因为要避免因为跟__signal_emit代码在同一个文件里出现的内联函数优化,然后数组伪栈肯定也是volatile的。还有一个小的细节是我们在调用后加了一条temp[0] = temp[0]语句,它是无法被优化的,但也是永远不会执行到的。这是因为如果前一句函数调用如果是函数体内最后一句语句的话,仍然会产生一个尾调用优化——编译器会在调用前回复到之前函数的堆栈上。这很好理解,因为再也不会访问局部变量了。

程序到这里,应该就可以运行了。因为我们的代码很简单,没有其他影响到的地方,那就把余下的细节留待需要的时候再讲吧。这里先总结一下对抗编译器优化的办法:

1、最直观通用的办法就是使用volatile关键字,通过给变量附加volatile关键字,程序对每一个使用到它的表达式都不会进行优化,不会预先计算它的值,不会把它的值保存在寄存器里,而是严格按照执行的次序,在需要的时候从内存中重新读取。
2、是通过一个不可见的函数调用,把需要刷新的变量作为可以改变的参数(地址)传递过去,也许实际上这个函数没做什么特别的动作,但编译器无法进行任何优化假设。
3、其他可能的办法。(有待梳理)

(1) http://en.wikipedia.org/wiki/X86_calling_conventions
(2) http://blog.csdn.net/normalnoteb ... 6/06/25/833458.aspx
(3) http://blog.solrex.cn/articles/call-stack-and-satck-frame.html
(4) http://liyiwen.javaeye.com/blog/345525
(5) http://blog.chinaunix.net/u/13392/showart_70276.html

图五.GIF(4.42 KB, 下载次数: 17)

在ANSI C下设计和实现简便通用的signal-slot机制


图六.GIF(5.1 KB, 下载次数: 18)

在ANSI C下设计和实现简便通用的signal-slot机制

抵抗编译器的优化贯穿着始终,它成了实现过程中我遭遇的最繁琐的任务。因为没有一个标准的指导和熟练的反应,常常是写着写着突然发现不能工作了,再回过头来花很长时间查找。由此也可见编译器工作者们的辛劳,谢谢他们——尽管让我吃了不少苦头。

趟过荆棘密布,危机四伏的沼泽地后,接下来面对的就是此行的主要任务之一:绑定本地变量。自搭起signal-slot的框架以来,我们还没有引用过一次局部变量,这个问题肯定一直缠绕在大家心头。

根据前面对堆栈的分析,我们可以知道,slot引用的栈帧已经不是它所在母体函数的栈帧,也就是说编译器肯定无法帮助实现引用到之前局部变量的值。当然你仍然可以使用相同的变量名,但它们的值在初始化之前都是不确定的,就像刚进入到母体函数一样。这肯定是不行的,要知道signal-slot机制,还有闭包,委托等等的带来的一个直观的体验就是,你可以把本不相干的变量融合在一起形成一个“临时对象”,其中不属于参数部分的变量又称为*变量(1)。

如何绑定这些*变量是除调用/跳转外另一个主要问题,前面我们知道全局变量这种特殊类型*变量是不存在问题的,常量也不存在问题,它们可以直接在slot中引用,剩下要解决的就是对局部变量的引用问题。

编译器不能帮助引用到之前变量值的原因是,进入slot的栈帧指针已经出现变化,我们现在让它指向了一片临时数组产生的伪栈中。根据局部变量的引用方法——通过栈帧指针加上偏移量——变量相对偏移量没有变,所以变量的引用都落在当前伪栈中。如果能帮栈帧指针恢复到过去那当然是最好的,但这样还有个隐患,你不能在slot中调用其他函数,因为这样会覆盖母体函数之后新生出来的栈——跟我们最早的问题一样。那么还能怎么办?想必很多人都想得到,这可以通过一次“栈复制”来解决。如果我们知道前后的栈帧指针,把之前栈帧的内容复制到之后的栈帧中,保持偏移量不变就可以重新从新栈中获得过去的变量值。因为我们要求数组伪栈必须大于函数栈,所以复制和引用都是不会出现问题的。(图七)

可以通过汇编访问保存在寄存器里的栈帧指针,我们需要更加通用的C解决方案。这也不是什么难事,通过在栈上设定一个“锚”变量,然后通过前后地址值相减,我们同样可以获得两个栈帧之间的偏移量,它也等于所有变量的偏移量。

当前的地址可以直接求值获得,但之前的地址呢?没有之前的地址,就变成“刻舟求剑”了。没关系,它可以放在slot里传递。但slot如果也是局部变量呢?嗯,这个矛盾不能在自身内部解决,必须再引进一个外部变量。这有两个手段:一是通过一个全局变量,在调用前后对其赋值;这个方法有些缺陷,不能并发使用,或者在访问前后必须通过锁互斥之类的手段。锁互斥一般需要依赖于线程库之类的,移植起来比较麻烦;另外一个方法是在跳转中设定一个参数来获取,对函数调用方式的跳转,可以直接通过函数参数传递它,也就是:

void __signal_slot_invoke(struct __Slot *slot)
{
  volatile unsigned char temp[SLOT_STACK_FRAME_SIZE];    // SLOT_STACK_FRAME_SIZE是某个预定义的值
  ((void(*)())(slot->func_addr))(slot);
  temp[0] = temp[0];
}

当然,这要求我们在进入slot的部分添加一些代码来获得它,这些代码依赖于平台调用约定。不过它移植起来起来要方便一些,而且可以和之前获取地址的部分合并在一起——跳转后刚好落在它后面。比如,在x86 gcc平台上,重新定义宏:

#define __SIGNAL_SLOT_ENTRY(slot_ptr)                                        \
  (slot_ptr)->func_addr = && BOOST_PP_CAT(LABEL_BEGIN_, __LINE__);        \
  if ((size_t)((slot_ptr)->func_addr) + 1) goto BOOST_PP_CAT(LABEL_END_, __LINE__); \
  BOOST_PP_CAT(LABEL_BEGIN_, __LINE__):                        \
  {                                                                        \
    size_t __esp;                                                        \
    __asm__ __volatile__("movl %%esp, %0":"=m"(__esp)::"ax","bx","cx","dx","si","di","memory"); \
    (slot_ptr) = *(SLOT **)(__esp + sizeof(void *));                        \
  }                                                                        \
  BOOST_PP_CAT(LABEL_END_, __LINE__):

花括号里面的就是获取函数参数的汇编代码,这是AT&T格式的,没接触过的看起来可能有些吃力,我稍微解释一下。

__asm__和__volatile__都是gcc的扩展关键字,前者不用说了,后者是阻止对其中的汇编代码进行优化,虽然我们这里没什么可以优化的,但为了确保不出问题,以及可能潜在的修订我们就始终加上它吧。

嵌入的汇编代码部分被冒号“:”分隔为四个部分,第一个部分是指令部分,第二个是输出参数部分,第三个是输入参数部分,输入输出参数对应着指令中%引用的除寄存器外的变量。最后一部分告诉编译器有哪些值可能被修改了,提示编译器在汇编前后做出相应的处理。

这里的__esp变量我们用来保存汇编中获取的栈指针——x86平台上就是esp寄存器的值。movl指令就是把第一个参数的值放到第二个参数里,跟MASM格式的源和目的恰好反过来。参数部分就一个输出变量__esp,它前面的"=m"限定符表示它是一个内存变量;最后面我们指示从ax-di所有的通用寄存器都出现了变化,内存也出现了变化;其实汇编代码对大部分都没做改变,这么用是为了阻止后面的代码优化,下面涉及的时候再解释。

根据cdecl调用约定,最左面的参数位于栈顶,因为调用本身还有一个压入返回地址的操作,所以进入调用代码后,参数位于栈顶地址加上返回地址大小的位置上,加法是因为对递减栈而言其栈顶方向是低地址。32位平台上,地址大小是4个字节,但我们这里没有指定,而是通过对一个void *类型取sizeof得到其大小;虽然实际上是一样的,但考虑到潜在的移植性,我们使用更加通用的办法。然后对地址做加法运算,我们是转换到一个size_t类型上而不是通常的int,这同样是为了移植性。对于某些平台而言,void *(或者任意的指针类型)和int大小是不一定相等的,c99为此专门定义有intptr_t/uintptr_t类型在其标准库头文件stdint.h中(2),有一些讨论(3)指出了c89平台上应该如何处理。我们这里使用size_t这个大家熟知的类型,因为一个对象最大应该可以充满整个寻址空间,所以它的大小应该等于指针类型的大小。

这样,在跳转后,无论如何声明的slot,我们仍然可以确保slot指针指在同一个对象上,因为它是通过层层函数调用传递而来的。类似的,对于ARM ADS平台,重新定义同一个宏:

__inline static void __signal_slot_dummy() { }

#define __SIGNAL_SLOT_ENTRY(slot_ptr)                                        \
  {                                                                        \
    volatile size_t __anti_optimize = (size_t)(slot_ptr);                \
    __asm {                                                                \
      MOV (slot_ptr)->func_addr, pc;                                        \
      ADD (slot_ptr)->func_addr, __SIGNAL_SLOT_FUNC_OFFSET;                \
      MOV r0, __anti_optimize;                                                \
      NOP; NOP; NOP; NOP; NOP; NOP; NOP; NOP;                                \
      MOV slot_ptr, r0;                                                        \
      BL __signal_slot_dummy, {}, {}, {r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11}; \
    }                                                                        \
  }

因为跳转代码落在汇编代码中,所以我们是在一整个汇编中来串接跳转前后的变化。这里必须要有预防一些优化的手段,对于寄存器变化部分,没有等价于gcc那种完备的嵌入汇编功能;不过好在对于调用未知函数,可以指定这件事情,所以我们添加了一个空的内联函数调用;后面的三个花括号分别用于指定输入寄存器,输出寄存器,及变化了的寄存器。同样,我们把所有的通用寄存器都指定为变化了的,究竟为什么这么做看下面。

我想其他大多数平台都可以通过类似的定义来实现,移植起来不是什么困难的事。

有了slot指针,我们可以做到想传递什么就传递什么。我们在结构中增加栈帧指针(实际上是“锚”变量的地址):

顺便把一个前面的边角问题处理好:有关栈帧大小的定义。它不是事先可以知道的,当然可以尽量大,但大到多少合适,这也是没有边际的。一个比较合理的解决方案是交给用户定义。因为数组大小必须是常量(C99有变长数组),所以只能交给宏(SLOT_STACK_FRAME_SIZE)来完成。我们最好是在用户定义slot的那个c文件的头部定义这个量,方便用户定位和修改(有办法可以断言出用户定义的不够用)。这种情况下,我们必须在头文件里定义__signal_slot_invoke函数,那么我们必须把它定义为static的,甚至是inline的(消除告警),然后为了让使用用户slot文件中的这个本地函数,我们可以让它的地址保存到slot结构中,代码如下:

struct __Slot {
  unsigned char signaling;
  void *func_addr;
  void *stack_addr;
  void (*signal_slot_invoke)(struct __Slot *);
  struct __Signal *signal;
  struct __Slot *prev;
  struct __Slot *next;
};

重写__signal_emit函数:

void __signal_emit(struct __Signal *signal)
{
  struct __Slot *slot = signal->slot;
  while (slot) {
    slot->signaling = 1;
    if (setjmp(signal->environment) == 0) {
      slot->signal_slot_invoke(slot);
    }
    slot->signaling = 0;  // 未知用处,可略
    slot = slot->next;
  }
}

然后是测试程序代码,清楚起见,我们先不用之前定义的宏(但是模拟它):

int main()
{
  struct {
    struct __Signal signal;
    int _1;
    float _2;
  } signal;

  struct __Slot slot;

  /* 这里开始是SIGNAL_CONNECT宏展开的代码 */
  {
    SLOT *__slot_ptr = (SLOT *)&slot;
    volatile size_t __slot_stack_offset;

    __signal_connect((struct __Signal *)(&signal), __slot_ptr);

    /* 设定invoke调用 */
     __slot_ptr->signal_slot_invoke = &__signal_slot_invoke;

    /* 设定跳转地址 */
    __slot_ptr->func_addr = &&LABEL_BEGIN;

    /* 设定栈帧地址 */
    __slot_ptr->stack_addr = (void *)&__slot_stack_offset;
  
    if ((size_t)(__slot_ptr->func_addr) + 1) goto LABEL_END: // 正常流程跳过下面获取地址参数一段
    LABEL_BEGIN:
    {
      size_t __esp;
      __asm__ __volatile__("movl %%esp, %0":"=m"(__esp)::"ax","bx","cx","dx","si","di","memory");
      __slot_ptr = *(SLOT **)(__esp + sizeof(void *));
    }
    LABEL_END:

    /* signal跳转进入的slot部分 */
    if (__slot_ptr->signaling) {
      __slot_stack_offset = (size_t)(&__slot_stack_offset);
      __slot_stack_offset = (size_t)(__slot_ptr->stack_addr) - __slot_stack_offset;
      {
        /* 用户slot代码 */
        memcpy((void *)&signal, (void *)((size_t)(&signal) + __slot_stack_offset), sizeof(signal));
        printf("int=%d, float=%f\n", signal._1, signal._2);
      }
      longjmp(((struct __Signal *)__slot_ptr->signal)->environment, 1);
    }
  }
  /* 这里SIGNAL_CONNECT结束 */

  signal._1 = 5;
  signal._2 = 10.0;
  __signal_emit((struct __Signal *)&signal);

  /* 大功告成 */
  return 0;
}

整个SIGNAL_CONNECT宏展开成为一个单独的语句块,以方便我们使用一些内置变量,这是C89强制要求的。实际上,即使C99和C++允许在语句块内部的任何地方定义局部变量,把局部语句和其使用的资源组合成块——就是使用{}括在一起,也是一个良好的编程习惯。它至少令你的代码看起来更加整洁,前面代码也常常可以看到这种用法。

除了内部必须使用的__slot_ptr这个slot指针变量外,还有一个用于计算栈帧偏移兼求取栈帧地址的变量——任何一个局部变量都可以充当“锚变量”。它必须是volatile以在后面的计算中避免编译器会优化成预先求值。这两句话联合起来完成计算偏移量的过程:

      __slot_stack_offset = (size_t)(&__slot_stack_offset);
      __slot_stack_offset = (size_t)(__slot_ptr->stack_addr) - __slot_stack_offset;

和通常不一样,我们必须一步步求值,以强迫程序必须每次从内存中读取值并按次序计算它。如果把它们写在一个表达式里,编译器优化后,就可能因为预测优化导致问题。这个过程也可以用一个函数调用来实现。

然后用户定义的代码中对要使用的变量可以通过一个memcpy调用来实现“栈复制”的过程。

        memcpy((void *)&signal, (void *)((size_t)(&signal) + __slot_stack_offset), sizeof(signal));

这里有一个隐藏的要对抗编译器优化的表达式,就是取地址(&)操作符。比如上面我们已经有过一次获取栈帧地址的操作:

    __slot_ptr->stack_addr = (void *)&__slot_stack_offset;

在进入slot后,同样要再获取一次,在代码不复杂,寄存器够用的情况下,编译器会把之前的求地址得到的结果放在寄存器里,然后再第二次赋值时重复使用——这跟赋值对象的volatile属性没关系;这样的话,那“锚”就没有跟着船一起前进,得到的偏移值恒为0。如果在RISC这种寄存器较多的机器上,类似的优化问题会更加突出。比如对于ARM,甚至出现多个栈帧指针寄存器的情况。所以在之前的汇编代码里,我们都指定所有的寄存器必须作废。

这个地方有没有什么可以替代的方法,回答是不一定。对于取地址操作而言,一种可能就是如果之前没有进行过同样的操作,那应该不用担心后面的取地址运算会在之前进行。对于我们的stack_addr,可以类似goto语句这么绕过去:

if ((size_t)(__slot_ptr->func_addr) + 1) __slot_ptr->stack_addr = (void *)&__slot_stack_offset;

因为编译器不知道前面的求值是否进行过,所以就不会做优化了。

但是对于用户行为就无法估算了,即便我们的确有办法确保slot中使用的变量都没有事先被求过地址,但也没法保证其他优化不被进行,所以这里安全的方法只有依赖于编译器了。如果没有类似作废寄存器的方法,有些编译器可以在局部打开或关闭优化,请参考自己的编译器文档。

根据代码我们可以重新得到我们的宏:

#define __SLOT_STACK_OFFSET __slot_stack_offset
#define __SLOT_PTR __slot_ptr

#define SIGNAL_CONNECT(signal_ptr, slot_ptr, statement, finalization)        \
  {                                                                        \
    SLOT *__SLOT_PTR = (SLOT *)slot_ptr;                                \
    volatile size_t __SLOT_STACK_OFFSET;                                \
    __SLOT_PTR->stack_addr = (size_t)&__SLOT_STACK_OFFSET;                \
    __signal_connect((struct __Signal *)(signal_ptr), __SLOT_PTR);        \
    __SIGNAL_SLOT_ENTRY(__SLOT_PTR);                                        \
    if (__SLOT_PTR->signaling > 0) {                                        \
      __SLOT_STACK_OFFSET = (size_t)&__SLOT_STACK_OFFSET;                \
      __SLOT_STACK_OFFSET = __SLOT_PTR->stack_addr - __SLOT_STACK_OFFSET; \
      {                                                                        \
        __SLOT_BLOCK statement;                                                \
      }                                                                        \
      longjmp(__SLOT_PTR->signal->environment, 1);                        \
    }                                                                   \
  }

对于用户要引用的局部变量,也就是memcpy语句,可以通过一个宏来实现:

#define SLOT_REUSE_LOCAL_VAR(a) \
  memcpy((void *)&a, (void *)((size_t)&a + __SLOT_STACK_OFFSET), sizeof(a));

用户slot代码如下:
(
  SLOT_REUSE_LOCAL_VAR(signal);
  printf("int=%d, float=%f\n", signal._1, signal._2);
)

如果你觉得内存拷贝代价有些高,你可以直接用指针运算来引用变量,但使用上必须自己指定类型:

#define SLOT_LOCAL_VAR(a, type) \
  (*(type *)((size_t)&a + __SLOT_STACK_OFFSET))

也就是用户slot代码写成这样:

(
  printf("int=%d, float=%f\n", SLOT_LOCAL_VAR(signal._1, int), SLOT_LOCAL_VAR(signal._2, float));
)

最后,如果在并发程序里,可能你在宿主函数也要知会slot过程对局部变量的改变,就是把变量复制会宿主栈帧里:

#define SLOT_UPDATE_LOCAL_VAR(a) \
  memcpy((void *)((size_t)&a + __SLOT_STACK_OFFSET), (void *)&a, sizeof(a));


到这一步,我们的实现已经非常接近于最终的情景了,栈帧断言部分大家可以自己先补充。我总是觉得宏名取的不是很合适,但还没有想到更好的办法,等最后全部完成后,大家可以帮助挑一挑。

(1) http://en.wikipedia.org/wiki/Free_variables_and_bound_variables
(2) http://en.wikipedia.org/wiki/Stdint.h
(3) http://*.com/questions/502811/sizeof-int-sizeof-void

图七.GIF(4.75 KB, 下载次数: 16)

在ANSI C下设计和实现简便通用的signal-slot机制

完成了绑定局部变量的任务之后对成功就满怀信心了,让我们再接再厉,一口气把剩下的任务完成,要攻克的堡垒只剩下最后一座了:绑定易失变量的值,以及堆空间的分配和释放,让我们在夕阳落山之前解决它,然后可以美美的睡上一觉。

先说说上次栈帧断言的事,通过宏定义的栈帧大小很难预先计算——用户可能随时修改代码;尽管一个较大的值比较安全,但一方面这不是绝对的,另一方面在一些场合(比如嵌入式)我们要使用紧凑的栈来节约空间,这就要避免栈溢出的情况。尽管一般来说因为栈分配是编译期的事所以无法做到动态进行(c99有动态数组,一些平台上有alloc栈分配函数),但如果能通过断言在运行时给出错误提示那也不错了。

为了得出栈帧大小,我们必须有栈帧起始地址,栈帧结束地址或者附近位置的地址。比如,如果局部变量按顺序排列的话,起始地址可以看作是第一个变量的地址,而结束地址是最后一个变量的地址(附加上函数调用入栈参数的最大长度)。第一个无法预先得到,第二个理论上也存在问题,我们预先假设的顺序也不一定成立——编译器不会保证它。但对进入到slot的栈帧,我们是可以得到起始地址的,就是我们定义的数组伪栈,我们可以把它的地址放到slot里:

  slot->stack_frame_head = (void *)&temp[SLOT_STACK_FRAME_SIZE-1]; // 注意栈递减情况下,起始地址在最后一个字节上

那么栈尾呢?我们要的是应该的栈尾位置,跟定义的伪栈大小无关。这里的解决办法是如果我们知道原先栈的栈尾位置,可以通过和局部变量映射的同样办法映射到偏移后的地址上。那只要得到原先的栈尾位置就可以了:考虑到一旦产生函数调用后,新函数里的变量就肯定位于之前栈的上面了,也就是说可以得到大于并挨着原先栈尾的位置。我们可以在signal connect函数里,对局部变量求地址:

  slot->stack_frame_tail = (void *)&p;

然后在CONNECT宏中求得__SLOT_STACK_OFFSET之后的位置添加:

      assert(SLOT_STACK_FRAME_SIZE > (size_t)__SLOT_PTR->stack_frame_head \
             - ((size_t)__SLOT_PTR->stack_frame_tail - __SLOT_STACK_OFFSET)); \

好了,赶紧回到我们的最终任务上,前面我们绑定的局部变量有一个要求,就是必须按照例子中的那样,main中调用signal的时候,自己还没有退出,但实际使用中可无法保证这一点,一个是,我们可能在一个子函数中完成连接,在调用还没产生前已经返回了,还有一个是,如果并发的情况,你不知道那个函数还有没有结束,如果结束了,和前一种情况一样,它们使用的栈空间已经被释放,你从那个位置复制过来的值就“失效”了。

这个时候我们必须要有可以在堆上分配并获取这些值的办法。方案就是把这些值附加到slot上,然后slot自然也是不能消失的,在调用产生的时候,可以恢复它们。slot附加参数的办法自然和signal是类似的,两种方法,一是和现有signal方案一样,一种是我们讨论过但没有采纳的动态分配方案。这里我们选择了和signal的不同的方案,因为slot不像signal需要预先设计,我觉得这种方案要灵活些。

我们定义参数的类型:

struct __SlotArg {
  size_t addr;
  size_t size;
  void *value;
};

它其中包含三个部分,一个是栈帧上的偏移,一个是大小,还有一个指向它在堆上分配的值的空间。为什么这样设计,请继续往下看。

我们定义完整的slot类型,让它包括一个参数部分:

struct __Slot {
  unsigned int argc;
  struct __SlotArg *argv;
  int signaling;
  void *func_addr;
  void *stack_addr;
  void *stack_frame_head;
  void *stack_frame_tail;
  void (*signal_slot_invoke)(struct __Slot *);
  struct __Signal *signal;
  struct __Slot *next;
  struct __Slot *prev;
};

其中的argc表示参数的数量,argv自然指向一个类似于参数数组(或其他集合)的东西。为了提高效率我们采用的是数组,这样可以在一次完成对所有变量所占空间的分配。那么携带参数的方法就是一个变参函数:

void __slot_fetch_args(struct __Slot *slot, unsigned int count, ...)
{
  unsigned int i;
  size_t size;
  char *p;
  va_list ap;
  slot->argc = count;
  // 预分配足够的a参数描述数组空间
  size = sizeof(struct __SlotArg) * count;
  p = (char *)size;
  slot->argv = (struct __SlotArg *)malloc(size);

  // 通过函数参数完成对参数描述数组的赋值
  va_start(ap, count);
  for (i = 0; i < count; i++) {
    slot->argv .addr = (size_t)(va_arg(ap, void *));
    slot->argv.size = va_arg(ap, size_t);
    size += slot->argv.size;
  }
  va_end(ap);
  // 在预分配的空间上一次分配完所有变量值存储的空间
  slot->argv = (struct __SlotArg *)realloc(slot->argv, size);
  p += (size_t)slot->argv;
  for (i = 0; i < count; i++) {
    slot->argv.value = p;
    // 保存值
    memcpy(slot->argv.value, (void *)(slot->argv.addr), slot->argv.size);
    p += slot->argv.size;
  }
}

函数的第一个参数不用说了,第二个参数是所携带参数的个数,也就是slot->argc,接下来的参数传递,是按照大小,和地址两两逐个进行的,这是因为不同的参数肯定有不同的类型,我们不需要处理类型(也无法处理),只需要处理内容(值)就可以了。从注释我们可以看到,上诉参数类型只是用于描述参数的,真正的参数附着在参数描述数组的后面,我们把它们的在堆上的地址保存在参数描述数组里。

然后进入到slot后,我们可以通过一个简单的过程,一次性复制所有参数到当前栈帧上供接下来引用,因为参数描述的addr是前局部变量的地址,因此很容易转换到当前帧上:

void __slot_commit_args(struct __Slot *slot, size_t slot_stack_offset)
{
  unsigned int i;
  for (i = 0; i < slot->argc; i++) {
    memcpy((void *)((size_t)slot->argv.addr - slot_stack_offset), slot->argv.value, slot->argv.size);
  }
}

为了使用上的方便,我们分别用SLOT_FETCH_LOCAL_VAR和SLOT_COMMIT_LOCAL_VAR宏来代替它们:

#define __SLOT_PARAMS(z, n, seq) \
  , &BOOST_PP_SEQ_ELEM(n, seq), sizeof(BOOST_PP_SEQ_ELEM(n, seq))

#define __SLOT_VA_ARGS(n, tuple) \
  n BOOST_PP_REPEAT(n, __SLOT_PARAMS, BOOST_PP_TUPLE_TO_SEQ(n, tuple))

#define SLOT_FETCH_LOCAL_VAR(slot_ptr, n, a)                \
  __slot_fetch_args(slot_ptr, __SLOT_VA_ARGS(n, a));

// #define SLOT_FETCH_LOCAL_VAR0(slot_ptr) SLOT_FETCH_LOCAL_VAR(slot_ptr, 0, ())
#define SLOT_FETCH_LOCAL_VAR1(slot_ptr, ...) SLOT_FETCH_LOCAL_VAR(slot_ptr, 1, (__VA_ARGS__))
#define SLOT_FETCH_LOCAL_VAR2(slot_ptr, ...) SLOT_FETCH_LOCAL_VAR(slot_ptr, 2, (__VA_ARGS__))
#define SLOT_FETCH_LOCAL_VAR3(slot_ptr, ...) SLOT_FETCH_LOCAL_VAR(slot_ptr, 3, (__VA_ARGS__))
#define SLOT_FETCH_LOCAL_VAR4(slot_ptr, ...) SLOT_FETCH_LOCAL_VAR(slot_ptr, 4, (__VA_ARGS__))
#define SLOT_FETCH_LOCAL_VAR5(slot_ptr, ...) SLOT_FETCH_LOCAL_VAR(slot_ptr, 5, (__VA_ARGS__))
#define SLOT_FETCH_LOCAL_VAR6(slot_ptr, ...) SLOT_FETCH_LOCAL_VAR(slot_ptr, 6, (__VA_ARGS__))
#define SLOT_FETCH_LOCAL_VAR7(slot_ptr, ...) SLOT_FETCH_LOCAL_VAR(slot_ptr, 7, (__VA_ARGS__))
#define SLOT_FETCH_LOCAL_VAR8(slot_ptr, ...) SLOT_FETCH_LOCAL_VAR(slot_ptr, 8, (__VA_ARGS__))
#define SLOT_FETCH_LOCAL_VAR9(slot_ptr, ...) SLOT_FETCH_LOCAL_VAR(slot_ptr, 9, (__VA_ARGS__))

#define SLOT_COMMIT_LOCAL_VAR()                                        \
  __slot_commit_args(__SLOT_PTR, __SLOT_STACK_OFFSET);

同样如果你不想用栈复制,可以直接引用堆上的变量,同样需要自己指定类型:

#define SLOT_ARG(n, type)                        \
  (*((type *)__SLOT_PTR->argv[n].value))

最后我们的slot如果需要释放,也需要释放变量分配的空间:

#define SLOT_FREE_ARGS(slot_ptr)         \
  if ((slot_ptr)->argc > 0) {                 \
    free((slot_ptr)->argv);                 \
    (slot_ptr)->argc = 0;                 \
  }

SLOT的初始化需要增加argc字段:

#define SLOT_INIT(slot_ptr)                        \
  (slot_ptr)->argc = 0;                                \
  (slot_ptr)->signal = 0;

差不多完成了。且慢,我们还没处理完连接部分,比如signal,slot单方面释放问题,还有如果是堆上分配的空间,到底如何被释放,比如我们的堆上的slot连接完了之后,signal如果disconnect掉它,它就变成悬浮的对象了(signal并不知道这一点),我们需要有一个最终处理过程:这可以通过signal指定不同signaling标记再调用一次slot完成,重写signal_disconnect函数,增加这一调用:

void __signal_disconnect(struct __Signal *signal, struct __Slot *slot)
{
  if (slot->prev) {
    slot->prev->next = slot->next;
  } else {
    signal->slot = slot->next;
  }
  if (slot->next) {
    slot->next->prev = slot->prev;
  }
  /* 通知slot连接被释放 */
  slot->signaling = -1;
  if (setjmp(signal->environment) == 0) {
    slot->signal_slot_invoke(slot);
  }
}

重写我们SIGNAL_CONNECT宏,增加一个finalization表达式参数:

#define SIGNAL_CONNECT(signal_ptr, slot_ptr, statement, finalization)        \
  {                                                                        \
    SLOT * __SLOT_PTR = (SLOT *)(slot_ptr);                                \
    struct __Signal * __signal_ptr;                                        \
    volatile size_t __SLOT_STACK_OFFSET;                                \
    __SLOT_PTR->stack_addr = (void *)&__SLOT_STACK_OFFSET;                \
    __SLOT_PTR->signal_slot_invoke = &__signal_slot_invoke;                \
    __signal_connect((struct __Signal *)(signal_ptr), __SLOT_PTR);        \
    __SIGNAL_SLOT_ENTRY(__SLOT_PTR);                                        \
    __signal_ptr = __SLOT_PTR->signal;                                        \
    if (__SLOT_PTR->signaling > 0) {                                        \
      __SLOT_STACK_OFFSET = (size_t)&__SLOT_STACK_OFFSET;                \
      __SLOT_STACK_OFFSET = (size_t)(__SLOT_PTR->stack_addr) - __SLOT_STACK_OFFSET; \
      assert(SLOT_STACK_FRAME_SIZE > (size_t)__SLOT_PTR->stack_frame_head \
             - ((size_t)__SLOT_PTR->stack_frame_tail - __SLOT_STACK_OFFSET)); \
      {                                                                        \
        __SLOT_BLOCK statement;                                                \
      }                                                                        \
      longjmp(__signal_ptr->environment, 1);                                \
    } else if (__SLOT_PTR->signaling < 0) {                                \
      __SLOT_PTR->signal = 0;                                                \
      __SLOT_STACK_OFFSET = (size_t)&__SLOT_STACK_OFFSET;                \
      __SLOT_STACK_OFFSET = (size_t)(__SLOT_PTR->stack_addr) - __SLOT_STACK_OFFSET; \
      assert(SLOT_STACK_FRAME_SIZE > (size_t)__SLOT_PTR->stack_frame_head \
             - ((size_t)__SLOT_PTR->stack_frame_tail - __SLOT_STACK_OFFSET)); \
      {                                                                        \
        __SLOT_BLOCK finalization;                                        \
      }                                                                        \
      longjmp(__signal_ptr->environment, 1);                                \
    }                                                                        \
  }

然后是单独释放signal或者slot的宏:

#define SIGNAL_FREE(signal_ptr) \
  while (signal->slot) { \
    __signal_disconnect(signal, signal->slot); \
  } \

#define SLOT_FREE(slot_ptr)                                                \
  if ((slot_ptr)->signal) __signal_disconnect((slot_ptr)->signal, slot_ptr); \
  SLOT_FREE_ARGS(slot_ptr)

让我们来测试一下:

void connect(struct __Signal *signal)
{
  int i = 5;
  float j = 10.0;

  SLOT *slot = (SLOT *)malloc(sizeof(SLOT));

  /* 携带局部变量i,j当前值 */
  SLOT_FETCH_LOCAL_VAR2(slot, i, j);

  SIGNAL_CONNECT(signal, slot
                 , (
                    /* 提交携带的值 */
                    SLOT_COMMIT_LOCAL_VAR();
                    printf("int=%d, float=%f\n", i, j);
                  )
                 , (
                    /* 释放slot参数及自身空间 */
                    SLOT_FREE(slot);
                    free(slot);
                    )
                  );

}

int main()
{

  SIGNAL2(int, float) signal;

  SLOT slot;


  SLOT_INIT(&slot);
  SIGNAL_INIT(&signal);

  /* 子函数中的连接 */  
  connect((struct __Signal *)&signal);

  /* 宿主内部连接 */
  SIGNAL_CONNECT(&signal, &slot
                 , (
                    SLOT_COMMIT_LOCAL_VAR();
                    SLOT_REUSE_LOCAL_VAR(signal);
                    printf("int=%d, float=%f\n", signal._1, signal._2);
                  )
                  , ()
                  );

  SIGNAL2_EMIT(&signal, 5, 10);
  
  SIGNAL_DISCONNECT(&signal, &slot);

  return 0;
}

自此所有的工作基本就告成了,当然始终还存在些微小的修饰,以及高阶功能的增强,如果有人感兴趣了,留待一起讨论改善。

从这里的实现还可以知道,结合signal/slot,我们可以在c中完成类似lambda表达式这种函数式编程功能,你可以定义一系列参数的FUNCTOR宏,来模拟函数对象,然后作为参数进行传递。这些工作就交给大家自己完成了。

附件是目前为止的代码,欢迎大家下载测试并完善。
在ANSI C下设计和实现简便通用的signal-slot机制

sigslot.zip

补充一个图,并修正上次测试例子里的错误遗漏:

void connect(struct __Signal *signal)
{
int i = 5;
float j = 10.0;

SLOT *slot = (SLOT *)malloc(sizeof(SLOT));

/* 携带局部变量i,j当前值 */
SLOT_FETCH_LOCAL_VAR2(slot, slot, i, j);

SIGNAL_CONNECT(signal, slot
, (
/* 提交携带的值 */
SLOT_COMMIT_LOCAL_VAR();
printf("int=%d, float=%f\n", i, j);
)
, (
/* 提交携带的值 */
SLOT_COMMIT_LOCAL_VAR();
/* 释放slot参数及自身空间 */
SLOT_FREE(slot);
free(slot);
)
);

}

图八.GIF(4.68 KB, 下载次数: 19)

在ANSI C下设计和实现简便通用的signal-slot机制

除了一般性的事件驱动式编程外,可以引用到这里signal/slot机制的地方就是函数式编程的需求,函数对象是一种在许多语言里被反复实现的东西,不过因为signal/slot本身就是一种特殊的函数调用机制,所以我并没有立即扩充这方面的实现,如果有人需要可以自己实现或者一起讨论它。

我们这里用另外一个实际需求作为用例来展示lambda表达式的运用:异步/并发,也就是一般意义上的线程。在Java在面向对象语言中引入了各种完整的运行机制后,线程作为一个语言特性也得到了大家的关注,这里有一篇文章(1)比较完整的描述了在一个面向对象语言下应该如何规划线程的问题,最终作者提出了一个新的asychronous关键字,他希望可以直接修饰一个方法为异步而免去了调用者的烦恼。尽管在我看来这是不太合适的,但也反应了我们需要更方便的办法来规划这件事情。

作者的的想法还特别借鉴了任务的概念。在一些实时系统中,比如我在用的UCOS-II,没有线程这个东西,并发运行的单元是任务,它们一般是在系统启动时就初始化好的;因为要保证系统的实时性,任务是严格按照优先级的设定来调度的。不过习惯了一般系统里的线程后,我总是会把一些可能需要等待耗时的代码作为异步方式运行;虽然在一般的子任务里的仍然可以继续创建任务,但它是非常繁琐的事情,例如需要分配和指定堆栈,指定优先级(不能冲突),通过长长的参数列表调用函数,以及同样的变量/值传递的问题。这种暂时性的创建过程大部分都是重复的,所以我第一个想法就是如何归整一下,能够让程序自动来完成。

有了signal-slot机制,这种想法的实现就更加方便了,我可以指定一个lambda表达式作为异步运行的单元,就是像这样,在代码里需要异步执行一些东西的时候写:

ASYNC_RUN(...);// 省略号里是异步执行的代码

实现它不是十分困难的事情,UCOS-II的代码我已经有了。现在类似的,我们用gcc环境里通常有的phtread库来演示一下这个过程。相对而言,这会更简单一些——没有堆栈和优先级的分配问题。

我们大概知道需要调用的lambda表达式在结构里由一对signal/slot成员来表示,然后ASYNC_RUN的宏展开后相当于发出signal,让它自动调用slot的代码,也就是上述的省略号部分;当然这是在线程被创建之后,也就是说我们还有一个通用的函数,作为入口点用于调用pthread_create函数来创建线程,然后在其中调用signal就可以了。

数据结构我们定义如下:

struct __AsyncTask {
  pthread_t pid;
  pthread_mutex_t mutex;
  pthread_cond_t running;
  SIGNAL0() signal;
  SLOT slot;
};

其中running成员表示线程是否运行,后面你会看到它的用途:

代码部分我们可以交给两个函数进行,一个__async_create和一个__async_run来完成。

void __async_create(struct __AsyncTask *async_task)
{
  pthread_mutex_init(&async_task->mutex, NULL);
  pthread_cond_init(&async_task->running, NULL);

  pthread_mutex_lock(&async_task->mutex);

  // 将 async_task作为参数传递给执行线程
  pthread_create(&async_task->pid, NULL, (void *(*)(void *))&__async_run, (void *)async_task);

  // 等待线程运行并复制完栈
  pthread_cond_wait(&async_task->running, &async_task->mutex);

  pthread_mutex_unlock(&async_task->mutex);
}

void * __async_run(struct __AsyncTask *async_task)
{
  SIGNAL0_EMIT(&async_task->signal);
  SIGNAL_DISCONNECT(&async_task->signal, &async_task->slot);
  pthread_cond_destroy(&async_task->cond);
  return 0;
}

然后就是定义ASYNC_RUN宏:

#define ASYNC_RUN(a) \
  { \
    struct __AsyncTask __async_task; \
    SIGNAL_INIT(&__async_task.signal); \
    SLOT_INIT(&__async_task.slot); \
    SIGNAL_CONNECT(&__async_task.signal \
                   , &__async_task.slot \
                   , ( \
                       SLOT_REPLICATE_LOCAL_STACK(); \
                       pthread_mutex_lock(&__async_task.mutex); \
                       pthread_cond_signal(&__async_task.running); \
                       pthread_mutex_unlock(&__async_task.mutex); \
                       { \
                         a; \
                       } \
                     ) \
                   , () \
                  ); \
    __async_create(&__async_task); \
  }

这其中需要注意的地方就是并发时,在线程还没有开始运行可能生成线程的函数已经结束了,但这样我们就无法引用到局部*变量了。为了达成这个目的,当然可以使用SLOT_FETCH_LOCAL_VAR()宏,但我们这里因为确切知道signal发出的时候——几乎同时进行,所以可以用一个锁互斥的操作,让生成函数等待线程运行并复制完本地栈之后再继续运行,复制栈的工作交给SLOT_REPLICATE_LOCAL_STACK宏,前面我们已经描述过得到栈帧大小的办法,所以我们可以省略掉对SLOT_REUSE_LOCAL_VAR宏的使用(注意:但不包括函数的参数部分,如果要使用参数部分的值,另定义局部变量赋值并使用它),这样显得更加傻瓜化一些。SLOT_REPLICATE_LOCAL_STACK宏如下:

#define SLOT_REPLICATE_LOCAL_STACK() \
SLOT_LOCAL_VAR(__SLOT_PTR, struct __Slot *) = __SLOT_PTR; \
SLOT_LOCAL_VAR(__SLOT_STACK_OFFSET, size_t) = __SLOT_STACK_OFFSET; \
SLOT_LOCAL_VAR(__signal_ptr, struct __Signal *)= __signal_ptr; \
memcpy((void *)((size_t)__SLOT_PTR->stack_frame_tail - __SLOT_STACK_OFFSET), __SLOT_PTR->stack_frame_tail \
        , (size_t)__SLOT_PTR->stack_frame_head + __SLOT_STACK_OFFSET - (size_t)__SLOT_PTR->stack_frame_tail);
         
增加一些辅助设施,得到线程句柄的宏,等等:
#define ASYNC_PTHREAD_ID() __async_task.pid
#define ASYNC_LOCAL_VAR SLOT_LOCAL_VAR
...

最后是测试程序:

int main()
{
  int i = 5;
  float j = 10.0;
  volatile pthread_t pid;
  ASYNC_RUN(
            ASYNC_LOCAL_VAR(pid) = ASYNC_PTHREAD_ID();
            printf("int=%d, float=%f\n", i, j);
            );

  while (pid == 0);
  pthread_join(pid, NULL);
  return 0;
}


并发是编程中要处理的一大问题,我们这里只涉及一个非常小的局部,如果可能的话,将来我们将单独涉猎它。

到此为止,整个介绍过程应该可以圆满结束了,欢迎大家多提宝贵意见,帮助我继续改进它。

“咦,这位愁眉苦脸的同学还有什么问题吗?”

“嗯。。。啊。。。”

“哦,你的意思是说,虽然大部分代码是C写就的,但还是存在一小片段的代码无法用C实现,而你的平台上的这种特定方案难以实现是吗?”

这的确是可能的,需要在某些平台上使用汇编可能是不太切实际的要求——“不过没有关系,我这里还有一个用C实现的方案”。

”?。。。“

“是的,虽然它仍然不能保证百分之百的兼容,但相信绝大多数情况下是可行的。最重要的是,它是由纯ANSI C实现的”。

“不过今天的时间不多了,我们留待下次再讲吧”,说完某人就转身离去,后头砸来一片臭鸡蛋。

(1) http://www.ibm.com/developerworks/cn/java/j-king/


这里有一个简单易用的signal-slot C++实现库,供参考http://sigslot.sourceforge.net/


先把最近更新的代码发上来,对gcc平台做了详尽的测试,基本上是可以用在项目中了。对未知编译器(纯ANSI C)的支持因为还没有来得及详细测试,尚未包含。

主要更新有:
增加了对c99变长数组和alloca()的支持,在有两者其一的支持下,用户不需要指定栈的大小(大多数平台上都有alloca函数);
缺省做自动栈复制或值提交功能,用户一般不再需要使用SLOT_REUSE_LOCAL_VAR(...),SLOT_COMMIT_LOCAL_VAR()等宏来显式复制变量或值;
用户可以通过一些宏来配置上述选项,见config.h;
其他一些修正,代码优化,更多安全性检查,通用性增强等方便使用。

示例代码:

#include <stdio.h>
#include "sigslot.h"

void connect(struct __Signal *signal)
{
int i = 5;
float j = 10.0;

SLOT *slot = (SLOT *)malloc(sizeof(SLOT));
SLOT_FETCH_LOCAL_VAR3(slot, slot, i, j);
SIGNAL_CONNECT(signal, slot
                , (
                   printf("int=%d, float=%f\n", i, j);
                   )
                , (
                   SLOT_FREE(slot);
                   free(slot);
                   )
                );
}

int main(int argc, char *argv[])
{
SIGNAL2(int, float) signal;
SLOT slot;

SLOT_INIT(&slot);
SIGNAL_INIT(&signal);

connect((struct __Signal *)&signal);

SIGNAL_CONNECT(&signal, &slot
                , (
                   printf("int=%d, float=%f\n", signal._1, signal._2);
                   )
                , ()
                );

SIGNAL2_EMIT(&signal, 5, 10);
SIGNAL2_EMIT(&signal, 50, 100);

SIGNAL_DISCONNECT(&signal, &slot);
SIGNAL_FREE(&signal);

return 0;
}

对于SLOT_REUSE_LOCAL_VAR,SLOT_LOCAL_VAR,SLOT_UPDATE_LOCAL_VAR等宏的命名上我觉得有些歧义,如果用HOST_VAR代替LOCAL_VAR会不会好些,望各位指点一二,先就此谢过!

在ANSI C下设计和实现简便通用的signal-slot机制

sigslot.zip

在文章结束前,发布最后一个测试候选版,增加了未知编译器/纯c代码的支持和实现。

改进包括:
增强对反向栈帧的支持。就是只使用一个栈指针或者栈基指针位于栈顶,变量位于底部的情形。这个时候一般无法预测栈帧的大小(除非变量严格有序,并且按栈帧方向增长)。但一个好的副作用是及时没有合法的c99/alloca支持也可以动态分配栈帧(递归调用),所以可以运行时在每个宿主函数内部指定栈帧大小。一个宏SLOT_STACK_FRAME_REVERSE指定这个情况。(ARM以及MSVC Release版本符合这个情况)

兼容性的改善,目前明确测试过的平台有ARM ADS,GCC 3.x/4.x,MSVC 2008;相信通过配置宏的组合可以支持到更多的平台。纯c的实现也同时在三个平台上都可以工作,定义宏SLOT_USE_LONGJMP强制使用C回调。

其他改进,一般情况自动栈复制都可以工作,增加了注释和说明。

作为最后一个测试候选版,目前slot仍然有用户指定,所以接口还保持和过去一样。但下一个版本将交由signal统一管理,从堆上自动分配和释放,用户将一般不显式跟slot打交道,这样一致性更好些,使用负担也轻了;同时增加一个initialization表达式作为connect参数,也就是:

[slot = ]SIGNAL_CONNECT(signal,
                                (initialization ...),
                                (signal processing...),
                                (finalization...)
                               );

也欢迎大家提出意见和建议。

附件是完整版本的库和测试文件,包括boost/proprocessor头文件,编译时指定相应(当前)目录为头文件搜索路径,例如gcc -I. ...
在ANSI C下设计和实现简便通用的signal-slot机制

sigslot.zip

上次代码中有一处bug隐藏在assert断言中,这个版本不再继续,我就提交一个patch先。

代码刚刚发布出去就忙不迭的打补丁,这是一个程序员心中永远的痛!

diff --git a/sigslot/sigslot.h b/sigslot/sigslot.h
index 335046b..4ca72f6 100755
--- a/sigslot/sigslot.h
+++ b/sigslot/sigslot.h
@@ -136,8 +136,8 @@ struct __Slot {
     __SLOT_PTR->signaling = 1;                                                \
   } else if (__SLOT_PTR->stack_frame_head == 0) {                        \
     __SLOT_PTR->invoke = &__slot_init_invoke_stub;                        \
-    if (setjmp(__slot_ptr->signal->environment) == 0) {                        \
-      __sigslot_invoke(__slot_ptr);                                        \
+    if (setjmp(__SLOT_PTR->signal->environment) == 0) {                        \
+      __sigslot_invoke(__SLOT_PTR);                                        \
     }                                                                        \
   } else {                                                                \
     volatile size_t anti_optimize = (size_t)&__SLOT_STACK_OFFSET;        \
@@ -182,8 +182,8 @@ struct __Slot {
#define __SIGNAL_INIT_SLOT()                                                \
   if (__SLOT_PTR->stack_frame_head == 0) {                                \
     __SLOT_PTR->invoke = &__slot_init_invoke_stub;                        \
-    if (setjmp(__slot_ptr->signal->environment) == 0) {                        \
-      __sigslot_invoke(__slot_ptr);                                        \
+    if (setjmp(__SLOT_PTR->signal->environment) == 0) {                        \
+      __sigslot_invoke(__SLOT_PTR);                                        \
     }                                                                        \
   } else {                                                                \
     volatile size_t anti_optimize = (size_t)&__SLOT_STACK_OFFSET;        \
@@ -398,7 +398,7 @@ struct __Slot {
     unsigned int i;                                                        \
     for (i = 0; i < __SLOT_PTR->argc; i++) {                                \
       assert((size_t)__SLOT_PTR->argv.addr <= (size_t)__SLOT_PTR->stack_frame_head - __SLOT_PTR->argv.size \
-             && (size_t)&__SLOT_PTR->argv.addr >= (size_t)__SLOT_PTR->stack_frame_tail); \
+             && (size_t)__SLOT_PTR->argv.addr >= (size_t)__SLOT_PTR->stack_frame_tail); \
     }                                                                        \
   }                                                                        \
     __slot_commit_args(__SLOT_PTR, __SLOT_STACK_OFFSET)


上次留了个包袱,很长时间过去了,想必大家都不耐烦了。前面得到的反馈不多,我就当作都在等最后的结果。不说废话,先直接贴出代码(前面已经给出了完整的代码,这里是示意的,虽然不一定工作,但更加直接明了一些):

首先给slot类型增加一个jmp_buf字段和一个名字叫jmpcode的整型数组:

struct __Slot {
  unsigned int argc;
  struct __SlotArg *argv;
  int signaling;
  void *func_addr;
  void *stack_addr;
  void *stack_frame_head;
  void *stack_frame_tail;
  void (*signal_slot_invoke)(struct __Slot *);
  struct __Signal *signal;
  struct __Slot *next;
  struct __Slot *prev;
  jmp_buf environment;
  int jmpcode[sizeof(jmp_buf) / sizeof(void *)];
};

然后,主体代码不用动,只需要改变调用和slot头部的代码,分别是__SIGNAL_SLOT_INVOKE和__SIGNAL_SLOT_ENTRY,他们是对应的。之前当我们在不同平台上移植时,也只要处理好调用和入口就可以了。

  1. #define __SIGNAL_SLOT_ENTRY(slot_ptr) \
  2.   { \
  3.     jmp_buf environment; \
  4.     setjmp(environment); \
  5.     int ret = setjmp(slot_ptr->environment; \
  6.     if (ret == 0) { \
  7.       unsigned int i, j = 0; \
  8.       for (i = 0; i < sizeof(jmp_buf) / sizeof(void *); i++) { \
  9.         if (((void **)&environment)[i] != ((void **)&slot_ptr->environment)[i]) { \
  10.           slot->jmpcode[j++] = i; \
  11.         } \
  12.       } \
  13.       slot_ptr->jmpcode[j] = -1; \
  14.     } else { \
  15.       slot_ptr = (struct __Slot *)ret; \
  16.     } \
  17.   }
  18.   
  19. # define __SIGNAL_SLOT_INVOKE(slot_ptr) \
  20.   { \
  21.     jmp_buf environment; \
  22.     if (setjmp(environment) == 0) { \
  23.       unsigned int i; \
  24.       for (i = 0; slot_ptr->jmpcode[i] >= 0; i++) { \
  25.         ((void **)&environment)[slot_ptr->jmpcode[i]] = ((void **)&slot_ptr->environment)[slot_ptr->jmpcode[i]]; \
  26.       } \
  27.       longjmp(environment); \
  28.     } \
  29.   }
复制代码


对整个过程稍有了解的人看了代码一定就立刻明白了,我们抛开实现的细节看看如果一般性面对必须要用C来解决这个问题的话会怎么考虑。

这里剩下没有解决的问题就是跳转地址的问题,c本身没有提供直接的方法得到它,但我们也还可以有一些间接的办法,比如内存扫描。通过一些特征化的代码,我们可以从函数处扫描或许(至少概率上)能够得到大致范围的地址;但这个方法仍然有局限,一是代码的位置不一定是顺序排列的,二是我们无法区分多个 slot的情形——代码生成完全依赖于编译器,无法安置其他信息。

这个时候一旦回到最初始的setjmp/longjmp,我们立即就可以想到它们处理的数据中肯定包含有类似于跳转地址这样的信息。于是自然就会得到上述的代码。

在__SIGNAL_SLOT_ENTRY初始化的部分,我们可以通过连续两次调用setjmp得到两个稍有不同的jmp_buf信息,其中必然有我们最想要的跳转地址,而且它们肯定是不同的;剩下的大部分应该是差不多的,因为我们没有改变过多的状态(也许还有一些不重要的寄存器的值发生了变化)。通过对比它们间的不同部分,应该可以得到包含有地址的信息。我们扔掉前一个jmp_buf,只需要留下后者,用于跳转返回就可以了。

当然这里有个如何比较的问题,因为jmp_buf类型是透明的,我们并不确切知道它是如何定义的,也就是其中值的含义。幸好大多数(可能从早期继承过来的代码)平台都是将其作为一个值缓冲区处理的;因此我们可以按类似整型数组这样看待它(在我看到的几个平台上它的确都被定义为整型数组);更一般的,地址类型应该对应着void *,所以就以它作为最小的比较单位成功的可能性最大。

继续考虑跳转,通过前面对栈的分析,我们知道,最开始跳转失败的原因是栈冲突导致的,也就是jmp_buf里面还有一个关键的信息是我们需要的栈指针和栈帧指针。那我们现在已经有了可以工作的伪栈,把它们和跳转地址组合在一起就是我们真正想要的那个jmp_buf。对于jmp_buf我们一向用 environment或者context来命名,表示这是计算机的执行环境,也就是寄存器集合的一个快照。

所以我们在slot类型里附加上一个jmpcode用于指示其携带的jmp_buf里究竟有哪写部分可能是跳转地址信息,然后再跳转的时候,再获得当时的运行环境,也就是jmp_buf,把其中的跳转地址修改为之前我们获得的那个就可以了。我们一般性的把jmpcode定义为最大包含有 jmp_buf中所有void *类型值的集合,也就是长度为sizeof(jmp_buf)/sizeof(void*)的数组,好在其中存储jmp_buf中可能是跳转地址的位置索引;当然实际长度应该是肯定小于才对。

这里还有一个附带的奖励,当跳转回来后,携带有一个返回值,刚好可以用来表示我们希望传递的slot指针(setjmp返回值或者longjmp的携带的参数是int类型,在大多数32位平台上可以直接转换)。

另外某些平台上,包括gcc/msvc for x86都明确有在setjmp返回后不保证寄存器可以复用,就是隐含这个时候大部分的通用寄存器可能作废了,因此我们无需担心jmp_buf里除栈指针和跳转地址之外其他部分的不同。实际测试中gcc下setjmp的确具有相当于前面我们破坏寄存器的汇编指示的作用。(当然这一点不是通用的,在最终的代码给出的是更加准确的解决方案,修改栈指针而不是跳转地址。)

还有一些可以继续考究的地方,一是前头说的,还有就是可以进一步优化,因为jmp_buf索引位置是不会发生变化的,比较的事情只需要做一次就可以了。然后,如果有一些平台上,缺省的扫描方法不行,或者jmp_buf不是一个平凡的void *数组,你可以自己定制它——直接给出索引值或者查看头文件里的定义。等等,这里就不一一细述了。

这个办法不是一开始就想到的,只是后来希望做到最大的兼容性才在苦思冥想中得出,也因此把它放在最后来讲述。虽然在醒悟的那一刻还是有些兴奋,但再细细回味,觉得想到它应该是一件还算正常的事才对。这里面最关键是不要忽略那些隐含的信息,这是大多数时候思维上的弱点。

好了,编译、运行。。。

“啊!出错了!???” 。。。

原来是一高兴代码写错了(我犯了好几次把后面取索引值的地方直接写成了下标的错误!)

修改,再编译、运行。。。通过,bingo!

[全文完]

最后,给出最新版本,也作为第一个正式的版本。这个版本的接口已经修改成上次说的那种形式,查看test.c可以很容易就明白了。

BTW 后记:

经过不断的锤炼,现在对它能够工作已经十分具有信心。从一开始为写出能够工作的代码费心,转化到目前不论怎么写都不会出错,这其实是一个对问题域的了解过程——解决问题实际上都是这么一个相似的过程,所不同的是我们面对着不同的问题而已。

[ 本帖最后由 TiGEr.zZ 于 2009-11-22 21:10 编辑 ]
在ANSI C下设计和实现简便通用的signal-slot机制

sigslot.zip

作一些改进:

对assert附加了错误信息输出,有助于使用;
更好的Arm C编译器支持,包括RVDS和ADS,在RVDS下如果要编译成thumb代码模式的话,请在SIGNAL_CONNECT局部使用ARM模式,或者定义SLOT_USE_LONGJMP为1。

下载地址:
http://code.google.com/p/c-sigslot/downloads/list