极简信号和槽实现






4.60/5 (5投票s)
2006年8月8日
8分钟阅读

28968

351
一个轻量级、类型安全、基于模板的信号和槽实现。
引言
我和许多读者一样,也见过或使用过其他类似的库。但我的脑海里一直有一个想法,一个疯狂的想法,就是使用别人的代码,嗯,是作弊。我不能在没有负罪感的情况下这样做,除非我知道我能够自己实现他们的想法。我喜欢走最艰难的路,以便尽可能多地学习。所以,当需要一个信号和槽机制时,我必须为了我的心理健康写我自己的库。这就是我最终想到的。
那么,什么是信号和槽?
好问题。我认为有必要先解释一下它们究竟是什么。信号和槽是处理、传递、处理以及最终调用“事件”的机制。槽连接到信号,当信号被触发时,它会将数据发送到引用的槽,从而允许任意处理该数据。需要指出的是,这种槽与信号的引用是在运行时完成的,这提供了极大的灵活性。
轻量级到什么程度?
简而言之:非常轻量。在我看来,一个库要轻量,它不仅需要提供最少量的有用功能,而且使用起来也应该感觉轻量。它需要在语法上体现出“轻量级”。要充分利用这个库,只需要使用两个类和两个函数。
功能方面,它提供了简单轻量的机制(+1个例外...见下文),用于连接、断开和调用处理多达10个参数的槽。
它不做什么
这个库很简单。它不执行以下操作:
- 参数绑定
- 函数重写
- 重入保护
- 以及其他“不常用的”功能
让我们谈谈语法
首先,我们来定义一些用于使用信号和槽系统的功能:四个函数和一个成员函数。
void print_add(int a, int b) { cout << a << " + " << b << " = " << a + b << endl; } void print_sub(int a, int b) { cout << a << " - " << b << " = " << a - b << endl; } void print_mul(int a, int b) { cout << a << " x " << b << " = " << a * b << endl; } void print_div(int a, int b) { cout << a << " / " << b << " = " << a / (double)b << endl; } class test { public: void inclass(int a, int b) { cout << "MEMBER: The circumfrence of a " << a << " by " << b << " box is " << 2*a + 2*b << endl; } };
嗯,语法很简单。借鉴C#委托的思想,连接这些函数并调用它们看起来像这样:
test t; signal<void, int, int> math_signals; math_signals += slot(print_add); math_signals += slot(print_sub); math_signals += slot(print_mul); math_signals += slot(print_div); math_signals += slot(&t, &test::inclass); math_signals(8, 4);
上面的代码向信号添加了五个槽,并使用数据8和4调用它们。这意味着每个相应的函数都将被执行一次,按照它们添加的顺序,并带参数8 4。
删除信号同样简单。扩展上面的代码,假设我们想移除第三个槽,也就是指向 `print_mul` 的那个。
math_signals -= slot(print_mul);
这段代码就可以做到。
可以使用 `+=`、`-=` 和 `()` 的替代方法是分别使用 `connect`、`disconnect` 和 `emit` 函数。
函数 `slot` 和 `safeslot` 用于创建槽,以避免尽可能多的显式模板参数声明。函数能够推断模板参数,从而消除了许多冗余的模板代码,因为每个类和函数都将具有相同的签名。
返回值
如何将多个具有不同返回类型的函数折叠成一个结果?此库将仅返回最后一个被触发的槽的结果。如果每个信号只连接一个槽,这就能正常工作。此外,没有机制可以将一个函数的返回类型传递给下一个。不过,有一个技巧。
引用。或者更具体地说,创建接受数据引用作为参数的信号和槽。考虑以下代码:
void a(int& in) { ++in; } void b(int& in) { in += 6; } void c(int& in) { in *= 2; } //... signal<void, int&> cool_test; cool_test += slot(a); cool_test += slot(b); cool_test += slot(c); int result = 5; cool_test(result); cout << result << endl;
正如您可能预期的那样,屏幕上会打印出数字“24”。使用引用的参数允许数据被返回并随后被后续函数修改。
+1 例外
好吧,我确信您可能已经注意到我到目前为止展示的代码中一个明显危险的缺点。
如果一个槽包含一个成员函数指针,而我们想要调用函数的类的实例指针被删除或超出作用域,那么事情会很快变得很糟糕。如果幸运的话,它仍然可以工作,但您可能会面临一个糟糕的段错误,导致您那本应精彩绝伦(而且毫无预警)的应用程序崩溃。
所以,到此,`trackable` 类闪亮登场。是的,对于那些熟悉 `boost::function` 的人来说,它的功能类似(是的,这是一个赤裸裸的名字抄袭)。
考虑以下示例
using namespace std; class test_trackable : public semaphore::trackable { public: void inclass(int a, int b) { cout << "MEMBER: The circumfrence of a " << a << " by " << b << " box is " << 2*a + 2*b << endl; } }; int main() { signal<void, int, int> math_signals; { test_trackable track; math_signals += safeslot(&track, &test_trackable::inclass); math_signals(8, 4); } cout << "TRACKED MEMBER FUNCTION POINTER NOW OUT OF SCOPE!" << endl; math_signals(8, 4); system("pause"); return 0; };
现在让我们看看 `safeslot`。这个小函数创建一个能够确定成员函数所属的实例何时被删除的槽,从而避免了潜在的灾难。代价是什么?包含成员函数的类现在必须继承自 `semaphore::trackable`,它实现了一个虚析构函数。稍后将详细介绍这个机制是如何工作的。
因此,如果我们运行这段小程序,`test_trackable::inclass` 只会被调用一次——第一次。为了兼容性,`safeslot` 也会创建简单的函数指针槽。任何可以被包装在安全槽中的成员函数也可以被包装在普通槽中(只是没有安全性)。
让我们解剖一下这条小鱼
好吧,如果到目前为止这段内容读起来有点像广告,我先说声抱歉。但我有责任解释如何使用我的库,并且我想清楚地说明它能做什么和不能做什么。所以,从现在开始,我将讨论设计、内部机制、各部分如何组合以及我遇到的问题。
信号
`signal` 类只是一个简单的 `std::list` 槽的包装器。通过22种不同的模板特化来保持类型安全,以支持多达10个参数和 `void` 返回类型。
槽
`slot` 类隐藏在 `internal` 命名空间中。槽只能通过提供的 `slot` 和 `safeslot` 函数来创建,原因已在前面说明。`internal::slot` 类持有一个引用计数的 `internal::invokable` 类的指针,这是槽的工作核心。引用计数使得类副本的开销很小,从而使 `signal` 类的 `std::list` 存储更高效。
invokable,以及那些永不见天日的东西
有几个工作类埋藏在命名空间中,它们不被直接使用,但几乎完成了所有工作。它们是 `internal::invokable` 及其派生类:`internal::simple_function`、`internal::member_function` 和 `internal::smart_member_function`。这些类存储函数和成员函数指针,指向需要被包装在槽中的代码片段。`internal::simple_function` 包装一个简单的函数指针,`internal::member_function` 包装一个成员函数指针。`internal::smart_member_function` 的功能类似于 `internal::member_function`,但增加了能够确定其指向的数据是否已过期的能力。
trackable
如上所述,`trackable` 类可以防止槽在对象销毁后执行成员函数指针。`trackable` 类为了能够告诉 `internal::smart_member_function` 它已被删除,必须将其数据外部化。因此,它创建了一个引用计数观察者类的实例。任何创建的 `internal::smart_member_function` 类都存储对观察者类的引用。一旦 `trackable` 类被销毁,它就会改变观察者类的数据,从而 `internal::smart_member_function` 可以谨慎地避免某些灾难。最后一个持有对观察者类引用的 `internal::smart_member_function` 类将删除它。
设计中的显著复杂性
有一个显著的设计复杂性,我认为值得专门用一个章节来讨论:如何比较和相等槽。此功能对于删除槽非常重要。该库的早期版本未能提供此功能,导致我无法找到一种简单的方法来分离槽。
现在,为了判断两个槽是否相同,我们需要比较它们 `internal::invokable` 的实例。然而,由于这是一个抽象基类,它不能负责此项,因为重要的数据保存在派生类中。此外,`dynamic_cast` 变得复杂,因为 `internal::member_function` 和 `internal::smart_member_function` 比它们的基类多一个任意的模板参数。
为了解决这个问题,我在 `internal::invokable` 中添加了两个虚拟函数和一个枚举:`gettype()`、`compare(internal::invokable* rhs)`,以及
enum type
{
SimpleFunction,
MemberFunction,
SmartMemberFunction,
UserDefined
};
`gettype()` 返回枚举值之一。`compare(...)` 首先通过 `gettype()` 检查类型是否相同(且不是UserDefined),如果是,则执行 `dynamic_cast` 和比较。
尽管第一印象如此,但这可以保证有效,不会出现错误的转换。为了使两个槽可比较,它们必须共享相同的模板参数。因此,它们持有的 `internal::invokable` 也将共享模板参数。因此,调用 `compare` 将导致类型和参数数量正确的 `dynamic_cast`。一个槽也将允许比较不兼容的类型,当然在这种情况下会返回 `false`。
问题
这个库还有一个我尚未解决的问题。如所示,为了移除一个槽,需要将该槽传递给 `disconnect` 函数,如下所示:
signal<void, int, int> test_signals; test t; // ... test_signals += safeslot(&t, &test::inclass); // Add slot // ... test_signals -= safeslot(&t, &test::inclass); // Remove slot
然而,如果将来需要移除一个槽,而该槽所属的类(其槽函数指针调用)未知,则无法移除。我目前还不确定如何解决这个问题,但我想最合适的方法是使用 `NULL` 来表示“通配符”类实例,如下所示:
signal<void, int, int> test_signals; test t; // ... test_signals += safeslot(&t, &test::inclass); // Add slot // ... test_signals -= safeslot(NULL, &test::inclass); // Remove slot
我希望看到的另一个缺失的功能是控制槽触发的顺序。
结论
尽管这个库简单轻量,但它仍然存在一些缺点。最终,它教会了我很多东西,并且完成这个挑战很有趣。我希望有人会发现这段代码有用,并期待收到关于我工作的反馈。