使用 C++11 编写类似 QT 的信号槽——上篇
了解 QT 的应该知道,QT 有一个信号槽 Singla-Slot 这样的东西。信号槽是 QT 的核心机制,用来替代函数指针,将不相关的对象绑定在一起,实现对象间的通信。
考虑为 Simple2D 添加一个类似的信号槽,实现对象间的通信。当然,功能比较简单,不过对于 Simple2D 就足够了。最终的使用看起来像是这样的:
class A { public: void FuncA(int v1, float v2, std::string str) { log("A: --%d--%f--%s--", v1, v2, str.c_str()); } }; class B { public: void FuncB(int v1, float v2, std::string str) { log("B: --%d--%f--%s--", v1, v2, str.c_str()); } };
A objA; B objB; Signal<void(int, float, std::string)> signal; Slot slot1 = signal.connect(&objA, &A::FuncA); Slot slot2 = signal.connect(&objB, &B::FuncB); signal(10, 20, "Signal-Slot test");
类 A 和 类 B 分别有一个函数(返回类型、参数个数及参数类型一样),然后将 A 对象 objA 的 FuncA 函数和 B 对象 objB 的 FuncB 函数绑定到信号对象 signal 中,通过信号 signal 的调用,实现对 FuncA 和 FuncB 函数的调用。输出窗口的输出内容为:
Signal-Slot 能够实现对象间的解耦,接下来按照上面的代码,用 C++11 的特性编写信号槽。
信号槽 Signal-Slot
要实现上面的功能似乎并不困难,核心内容就是对回调函数的使用。
将需要绑定的对象函数保存到 std::function 中,再把 std::function 保存到信号 Signal 对象中,使用数组保存 std::function 能够实现一个 Signal 对应多个 Slot,最后重载 Signal 的操作符 ()。接下来将围绕上面的步骤实现 Signal-Slot。
std::function
std::function(引入头文件 <functional>) 是 C++11 的内容,通过 std::function 对 C++ 中各种可调用实体(普通函数、类成员函数、Lambda表达式、函数指针、以及其它函数对象等)的封装,形成一个新的可调用的 std::function 对象。
如果要将成员函数绑定到 std::function 对象中,可以通过以下的代码实现:
class A { public: void FuncA(int v1, float v2, std::string str) { log("A: --%d--%f--%s--", v1, v2, str.c_str()); } };
std::function<void(int, float, std::string)> Functional; A objA; Functional = std::bind(&A::FuncA, objA, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3); Functional(20, 55, "functional test");
输出结果:
通过 std::bind 函数类成员函数绑定到 std::function 中,但对于参数要使用占位符 std::placeholders::_x,由于 FuncA 函数有 3 个参数,所以要使用 3 个占位符。
要实现 Signal-Slot,就要把任意的类成员函数绑定到 std::function 中。对于上面的情况,由于 FuncA 函数有 3 个参数,所以要使用 3 个占位符。对于那些不确定参数个数的类成员函数,如何把它们统一的绑定到 std::function 中呢?或许可以把参数个数为 1 - 10 的常用情况都列举出来,但这样并不是一个号方法。
类成员函数的函数指针
在解决将不确定参数个数的类成员函数绑定到 std::function 前,先看一看不用 std::function 实现的类成员函数的回调函数。
class A { public: void FuncA(int v1, float v2, std::string str) { log("A: --%d--%f--%s--", v1, v2, str.c_str()); } };
typedef void(A::*Functionl)(int, float, std::string); A objA; Functionl functional = &A::FuncA; A* objAPtr = &objA; (objAPtr->*functional)(20, 55, "functional test");
输出结果:
实现的方法和普通函数的函数指针类似,只不过定义函数指针的时候要使用类名 + ::,使用的时候也需要使用对象的指针(这意味着你要多保存一个对象指针)。在不使用 std::function 的情况下,实现类成员函数的回调函数要复杂的多。但有一个好处,就是绑定时和函数参数的个数无关。
结合上面两种方式的类成员函数的回调,就可以解决那个问题了——将不确定参数个数的类成员函数绑定到 std::function。
bind_member 类成员绑定函数
你应该要注意到,无论是 std:: function<void(int, float, std::string)> 的方式,还是 typedef void(Class::*Functional)(int, float, std::string) 的方式,都必须确定函数的返回类型和参数的类型(一旦 std::function 的函数格式确定了,就不能绑定其他格式的函数)。
下面要编写一个函数 bind_member,功能是将类成员函数(任意返回类型,任意参数类型,任意参数个数)绑定到 std::function 中。它看上去是这样的:
std::function<void(int, float, std::string)> Functional; A objA; Functional = bind_member(&objA, &A::FuncA); Functional(20, 55, "functional test");
输出结果:
上面使用 bind_member 函数的代码中,你可以看出两种方式实现类成员函数回调的影子。那么如何实现 bind_member 呢?由于存在函数返回类型,所以要用到函数模板;由于函数的参数个数和参数类型不同,所以要用到可变参模板;如果你不了解可变参模板,可以看下面关于可变参模板的简单介绍。
可变参模板
变参模板是 C++11 的新特性,其基本语法为:
template<class... Args>
和普通模板不同,添加了三个点...,表示 Args 是模板参数包(template type parameter pack),是一连串任意的参数打成的一个包。下面举一个例子(定义一个函数,接受任意参数并输出)说明如何使用可变参模板:
template<class... Args> void Log(Args... args) { printf(""); }
调用函数 Log 时,传入 1、2、3、4 四个参数:
Log(1, 2, 3, 4);
虽然定义了一个可变参模板的函数 Log,但内部如何实现才能输出 1, 2, 3, 4 呢?也就是如何获取参数包中的参数,如果能分别获取参数包中的参数就能使用函数 printf 输出了。
这个是参数包的展开问题,可以使用递归函数的方法展开参数包。因此,需要两个重载函数实现参数包的展开:
template<class T, class... Args> void Log(T header, Args... args) { printf("--%d--\n", header); Log(args...); } void Log(int value) { printf("--%d--\n", value); }
第二个函数可以理解,但是第一个函数是什么意思?这个先不理它,看下面的函数调用:
Log(1); // 1 Log(1, 2); // 2 Log(1, 2, 3); // 3 Log(1, 2, 3, 4); // 4
1、当传入的参数只有 1 时,毫无疑问会调用第二个函数,将 1 输出。
2、当传入的参数为 1 和 2 时,可以猜测它会调用第一个函数:1 给 header 变量,然后输出。剩下的 2 给 args,由于 args 只有一个参数 2,所以接下来的 Log(args...) 会调用第二个函数输出 2。参数包的展开结束。
3、当传入的参数为 1, 2, 3 时,显然它会调用第一个函数:1 给 header 变量,然后输出。剩下的 2, 3 给 args,那么接下来的 Log(args...) 调用的是哪一个函数呢?(第一感觉是 args... 表示着一个变量,应该调用第二个函数才对,因为第二个函数接收一个参数,但这样就不能展开接下来的 2 和 3 了)如果能理解这一步,就能理解如何展开参数包了。答案是 args... 会被拆成两部分,第一个参数 2 为一部分,剩下的 3 作为另一部分。既然分成了两部分,它会调用第一个函数处理(第一次接触变参模板的人,很容易把第一个函数 Log 理解成结束两个参数的函数,但并不是)。 接下来的展开和步骤 2 的只有参数 1 和 2 时一样,所以递归展开参数包结束。
4、当传入的参数为 1, 2, 3, 4 时,这次用图片来说明:
bind_member 实现
结合以上的内容,你可以实现 bind_member 函数:
template<class Return, class Type, class... Args> std::function<Return(Args...)> bind_member(Type* instance, Return(Type::*method)(Args...)) { /* 匿名函数 */ return[=] (Args&&... args) -> Return { /* 完美转发:能过将参数按原来的类型转发到另一个函数中 */ /* 通过完美转发将参数传递给被调用的函数 */ return (instance->*method)(std::forward<Args>(args)...); }; }
代码中只是利用了可变参模板的参数包,解决了函数参数类型和参数个数不确定的问题。然后将函数指针的调用封装在一个匿名函数中,再绑定到 std::function 中。其中使用了 C++11 的完美转发,上面也做了简单的介绍。
避免文章过长,分成两部分来实现 Signal-Slot,重点部分下篇文章再说。