回调函数就是一个可以被作为参数传递的函数,一个普通函数的定义与使用,通常是先准备好函数,参数待定,输入参数不同,结果不同。
而回调,则准备阶段是相反的,先准备好参数,函数待定,输入的函数不同,同样的参数,运行的结果不同。
在C语言中,回调函数,就是简单的函数指针,而在C++中,则更加的泛化,除了普通函数指针作为参数传递,最为直接的还有虚函数回调,
也可以使用其它的仿函数,C++11提供了std::function和lambda算子,还提供了std::bind方法构造仿函数等等,这使得回调的使用方法变得
多种多样,下面我们就来讲讲各种回调的优势与劣势,并且在他们做一些对比,以及引申说明一些回调更高级用法,例如:Qt的信号槽,自定义
用户事件。
值得注意的一点是,最原始的东西,往往性能最高的,越封装,性能就越低,因此使用C语言中函数指针作为回调,性能一定是最佳的,因此,当
有人问题Qt信号槽与C的函数指针回调有哪些区别,你回答性能更差,这是一句屁话,因为任何包装,都是降低性能,但相比于封装之后的灵活性
和扩展性来说,损失这点性能,也就是一个屁。
std::function
在C++11中,如果懂一些模板编程,自己实现一个std::function并非很难的一件事情,只不过你考虑没有标准库那么多,可能性能会差一些,
适用性和实用性差一些(这也是为啥看不懂标准库实现的原因之一)。
在了解std::function之前,我们需要知道什么是仿函数,仿函数泛指所有支持operator()的所有对象,其中包括:
- 函数指针
- 重载operator()的类对象
- lambda算子,
std::function属于第二类,只不过它是一个模板类,他可以根据其他任意的仿函数构造一个std::function的仿函数。使用std::function的好处在于,
只要函数返回值和参数一致,可以让他们的类型统一起来变成一个类型,方便我们作为参数传递。
std::function的存储模式非常的高效,当用于赋值的仿函数的内存低于某一个界限(64个字节)时,使用的std::function预置的内存,而高于这个内存
时,才会尝试去申请额外的内存,并用预置的内存存储申请的内存的地址(std::function的内存模式与std::any一样)。
std::function的执行过程也非常的高效,就是使用拷贝的仿函数,直接执行其括号运算。
显然std::function的执行效率和内存使用上,虽然略高于函数指针回调(就一丢丢),但是它的通用性更强。
其实有了2类与3类回调,我们可以方便将任意的一段的执行过程,轻易包装为一个std::function,尤其是lambda所带闭包区间。
由于普通函数指针就是std::function一种,即便转换为std::function, 代价也非常的低,因此后续统一将普通函数回调认作std::function
那么除了使用2类和3类回调构造function以外,还有一种方法(严格来说,它属于第2类),那就是使用标准库的std::bind。
std::bind
之所以起名叫做bind,肯定就有绑定的意思,这里是指绑定参数,就是可以选择固定部分参数,构造一个参数更少的仿函数出来。
首先,我们需要理解一下占位符,定义在命名空间:std:: placeholders中,下面的代码,可以认为已经调用如下的代码:
using namespace std:: placeholders;
占位符总共定义了9个,分别是:_1, 2, … _9, 这玩意差不多够用就行,别怀疑哈,这些玩意就是一些可以识别的特殊类对象。
下面讲一下 std::bind的4种用法:
1.固定参数
auto func = [](int a, double b){};
auto c = std::bind(func, _1, 3.0); // c <==> std::function<void(int)>
/* 等价的lambda算子如下
auto c = [func](int a){
func(a, 3.0);
};
*/
c(2); // <==> func(2, 3.0);
c(4); // <==> func(4, 3.0);
auto d = std::bind(func, 5, _1); // d <==> std::function<void(double)>; 这里表示固定第一个整形参数为2
/* 等价的lambda算子如下
auto c = [func](double b){
func(5, b);
};
*/
d(4.0) ; // <==> func(5, 4.0);
从上面的使用可以看出来,我们可以使用bind固定部分参数,构造一个参数更少的仿函数, 但是需要注意一下3点:
- std::bind(func, args…) 后续args的参数个数必须等于func的参数个数。
- 占位符,必须从_1开始,且必须连续,也就是使用占位符中,如果有_3, 则必须使用到_2和_1。
- 固定的类型必须与绑定函数对应位置的参数的类型一致(至少是可以转换的), 这里举例绑定的是常量,当然可以绑定变量,因为std::bind就是一个普通函数。
2. 交换参数
auto func = [](int a, int b, double c){}
auto ff1 = std::bind(func, _3, _2, _1); // ff1 <==> std::function<void(double, int, int)>;
/* 等价的lambda算子如下
auto c = [func](double c, int b, int a){
func(a, b, c);
};
*/
auto ff2 = std::bind(func, _1, _1, _2); // ff2 <==> std::function<void(int, double)>;
/* 等价的lambda算子如下
auto c = [func](int a, double c){
func(a, a, c);
};
*/
ff1(3.0, 2, 1); // <==> func(1, 2, 3.0);
ff2(2, 3.0); // func(2, 2, 3.0);
上面说到占位符,必须是连续的,但是顺序可是随意的,因此,我们实际使用时,可以用来交换参数,已到达一些适配的目的。
占位符可以重复,表示新的仿函数其中一个参数,最终调用会分别传给其中的两个参数。
3. 绑定类成员函数
struct A{
int a = 3;
int func(int c) { return c + a;}
};
A a;
int d = 4;
auto d = std::bind(&A::func, _1, d); //<==> std::function<void(A*)>
/* 等价的lambda算子如下
auto c = [d](A* a){
a->func(d);
};
*/
d(&a); // <==> a.func(d);
我在其它的文章,有讲到成员函数的本质,就是隐藏了第一个参数(this)。
所以使用std::bind,完全可以将其当成普通函数绑定一样进行使用,当然std::bind内部实现,肯定不一样哈。
4. 绑定引用
上面3种使用方式,在与lambda对比时,你会发现,绑定要么是常量,要么就绑定拷贝普通变量,那它能否拷贝引用了?
例如:
void myadd(int& a){
a += 1;
}
int d = 4;
auto fff = std::bind(&myadd, d); // 这样行吗?
fff(); // 此时d仍然是4.
/*
原因很简单,std::bind, 后面的参数,就是普通拷贝,fff内部调用myadd, 传入的是拷贝的数据的引用, 等价的lambda算子如下:
auto c = [d]() mutable{
myadd(d);
};
那么为了达到引用的效果呢,可以如下使用:
*/
auto fff1 = std::bind(&myadd, std::ref(d));
fff1(); //此时d = 5
/*
等价lambda算子:
auto refd = std::ref(d);
auto c1 = [refd](){
myadd(refd); //refd有强转运算符重载,可以转换d&
};
那么下面的lambda算子完全等价:
auto c2 = [&d](){
myadd(d);
};
到了这,你会发现std::bind只能进行拷贝参数,同时你会好奇std::ref是啥玩意,可以看一下源码,
是一个很简单的玩应。
*/
总结一下
- lambda算子可以完全取代std::bind,在没有lambda算子的年代(boost库时代), C++函数式编程,完全依赖boost::bind。
- std::bind是纯代码实现,不像lambda算子,它是扩展语法。
- 那么除了lambda算子和std::bind,还有更复杂替代方案,就是定义局部类(结构体),重载operator()运算符,代码量很大。
- std::bind在部分使用上,代码更简单,如果熟悉std::bind,代码少,理解肯定更简单。
- 熟悉std::bind,至少可以看懂别人这样使用(尤其是使用过boost库的开发者).。
虚函数回调
虚函数回调,也是一种回调方式,只不过它是C++内置的一种回调方式,通过继承、重载,然后动态的索引虚函数表完成回调过程。
虚函数回调与普通函数回调对比:
- 不管使用哪种方式,都能达到回调效果。
- 普通函数回调,更适合组合的方式,虚函数回调,只能使用继承的方式。
- 普通函数回调,性能高于虚函数回调,因为虚函数有一个查找的过程。
- 虚函数回调,有一个明显的缺陷,就是一定需要继承,有时候,则会增加类复杂度。
- 同样它也有一个优势,对于单个回调,虚函数回调,可以更方便进行非完全性覆盖,就是我们派生重载某一个虚函数时,还可以很方便调用基类的实现当然普通函数回调,也能达到这个目的,但代码看起来会更生硬。
6. 虚函数回调和普通回调,形态上的差异,使得虚函数回调,更适合去几何多个回调。举一个例子:
Filmora中定义了大量的按钮类,其中有一个类就是FFToolButton, 这是一个按钮类的实现,其实大部分按钮,除了绘制形态和触发行为不一样以外,其它的实现,都是一样的。
由于触发行为,Qt有提供信号,那就只剩下绘制形态的差别,那么为了方便扩展,按钮的绘制,肯定就需要使用回调。
其实,可以思考一下,这个回调,该采取那种回调方式呢?
显然,更适合用普通函数回调,因为按钮的品类的那么多,我们不可能,每一个品类,都是去重载,实现一个单独的按钮类,而更适合
使用组合的方式,拼接一个不同的绘制方法。
那么实际应用时,我们应该如何去考量,该使用普通回调,还是使用虚函数回调呢?
其实,不管是哪种回调,某一个类型的东西,具体很大相似性,但是又一定的差异性,而该使用哪种回调方案,更多的考量的是差异性大不大。
例如:Qt中的QAbstractItemModel, 该类中定义了大量的虚函数,显然不同的模型,差异性很大,已经大到,我们需要单独的使用一个源文件,去填充差异性。
而单独的按钮类,仅仅只是绘制形态不一样,单独有一个源文件去重载该类,反而变得不合适。
当然,不管使用哪一种回调方案,肯定都能达到目的,因此遇到具体问题的时候,可以去想一下,那种方案代码的扩展性、可读性更好。
监听者模式
Filmora中大量的使用到监听者模式,监听者模式,一定需要用到回调,不管是虚函数回调,还是仿函数回调,其实原理都一样哈。
我们下面以Filmora常见的监听者模式的形态为例:
class XXObserver{
public:
void xxxSomeCallback1() = 0;
void xxxSomeCallback2(int a ) = 0;
};
class XXService{
private:
std::list<XXObserver*> m_observers;
public:
void attach(XXObserver* observer){
m_observers.push_back(observer);
}
void detach(XXObserver* observer){
m_observsers.remove(observer);
}
void someFun1(){
/* 到了某一个时机时,需要通知所有监听者*/
for(XXObserver* observer : m_observsers){
observer->xxSomeCallback1();
}
}
void someFunc2(){
/* 到了另外一个时机时*/
int b = 11; //获取某一个结果状态
for(XXXObserver* observer : m_observers)){
observer->xxSOmeCallback2(b);
}
}
};
Class MyWindow : public XXObserver{
public:
MyWindow(){
XXService::instance().attach(this);
}
~MyWindow(){
XXService::instance().detach(this);
}
void xxSomeCallback1(){
// 显然,Service在处理到时机1时,会执行当前回调
}
void xxSomeCallback2(){
// service在处理到时机2时, 同样会执行到当前回调
}
};
上诉是监听者回调,最最最简单的实现方式,也是Filmora中最多的写法,当然改成普通函数回调,代码差不了太多。
监听者模式,具备那些特征呢?
- 服务一般不关心监听结果,因此通常发送的是一些时机,同时回调,一般不需要返回值,因为是多个结果,且具备有扩展性,监听者的插入具备不确定性,所以获取结果,是无意义的,因此,那些使用监听者获取结果变种实现,已经不是监听者模式了。
- 监听者模式,更适合跨模块通信,一般性使用设计模式,其实就为了解耦,让模块边界更清晰,而模块内部,就需要尽可能少使用一些复杂设计模式,除非模块内部,存在扩展性。
使用上面这一套模式,已经感觉没什么问题了,但是当你实际应用,才发现毛病还有很多,应用时会逐渐暴露,且越来越隐晦,有哪些问题呢?
问题1:上诉的监听者模式,严格的要求监听者服务的生命周期高于监听者回调的声明,否则监听者在析构时,会直接崩溃。
问题2:上诉的这个框架,是不是多线程的,最经典的多线程监听模式,是指主线程attach,detach,而回调执行在其它的线程。
问题3:上诉的框架,显然不支持回调递归内嵌,在回调函数中,如果再次调用service.detach或者attach,则会出现遍历容器的过程中,修改容器,导致遍历的迭代器失效,继而崩溃。
问题4: 监听者对象,对于一个服务监听,通常只需要attach一份实例,如果压入多份,则会导致回调被执行两次。
上诉的4个问题如何解决呢?
问题1比较简单,我们只需要服务断开时,主动依然链接监听者,主动断开链接,又或者监听者可以判断服务是否还存在,如果存在,则主动断开,否则,啥也不做。
问题2,先保留,有一个方式可以解决此类问题,就是上锁,但是锁回调,不是明智的选择。
问题3,嵌套的问题,我们只需要遍历监听者,并执行回调时,提前拷贝一份容器,一般性情况,这样执行回调的容器不会被破坏,但这会有两个新的问题:
问题3.1: 回调嵌套这种行为,只是低概率行为,大多数使用监听者模式,是不会有这种情况,拷贝容器会有性能问题,在Qt编程中,尽量使用Qt的容器,Qt的容器采取的修改时拷贝, 使用Qt的容器,配和Qt特有的Foreach语法,就能完美规避这个问题。
问题3.2: 预先拷贝的问题,还会导致,先执行的监听者回调,释放了后执行监听者回调,这回导致预先拷贝的容器中,还没来得及执行回调的监听者变成野指针, 这个问题,暂保留。
问题4: 这个问题,就相对比较简单,只需要在detach时,查询是否已经attach,没有attach,则attach。
自定义用户事件
自定义用户事件,也是监听者模式的包装,具体的使用细节,参考:
FFCustomEvent源码解析篇
相比于上诉的监听者模式的实现,自定义用户事件,具备以下优势:
- 发送者(服务)和接受者(监听者),完全解耦,之所以完全解耦,是因为有更上层的自定义用户事件管理器做中介。
- 主动链接,自动断开,防止忘写断开导致的崩溃, 这借用的Qt的自动回收机制。
- 使用更简单,使用组合以及事件驱动的方式。
- 通用性更强,不需要再定义监听者回调基类,而是有更通用更简单的方式定义事件。
- 支持跨线程同性,但是目标所在线程,必须有消息循环。
- 自定义用户事件完美解决上诉的4个问题。
缺陷有以下两点:
- 接受者必须继承自QObject, 因为跨线程和自动断开这两个功能需要, 当然可以考虑扩展, 当接受者如果没有继承自QObject, 就需要主动断开,且不能跨线程。
- 只能单事件attach, 不像虚函数回调实现的监听者模式那样,单个Observer一次attach,则可以有多个回调被连接。
备注1: 自定义用户事件,虽然封装之后,使用非常简单,但更适合跨模块,模块内部,尽量采取别的方案,例如信号槽的方式,如果代价着实太大,也可以用这玩意。
备注2: 自定义用户事件,属于监听者模式,不关心结果,不要想着,发送者获取接受者的结果,压根没有拷贝这种设计。
备注3: 任何业务需求的实现,不能为了拆而拆,这里的拆就是解耦的意思,我们只能用更抽象的思维,或者无关业务需求的拆法去拆,这样业务需求表达地方尽可能紧凑,否则
无脑拆解业务,只能让单点需求的扩散面太宽,反而调用复杂度、理解复杂度变得更高,因此模块内部使用解耦,需要有考量性。
Qt的信号槽
Qt的信号槽,仍然是监听者模式的包装,它包装的复杂度更高,具体使用细节,这里就需要过多介绍。
Qt的信号的发送者和接受者,属于松耦合的关系(就是绑定时,需要同时获取信号对象和槽对象)。
不过的Qt信号的参数,是支持引用的,但是由于是监听者模式,其实很少会这么用。
使用Qt信号槽的优势有哪些呢?
- 发送者与接受者是松耦合的关系,链接的方式非常的独特。
- 主动链接,自动断开,主动断开,仍然需要判断发送者的生命周期,由于使用的Qt,判断也比较简单。
- 支持跨线程,目标所在线程,必须有消息循环。
- 相比于第一类监听者模式包装,同样也完美的处理了4类问题。
使用Qt的信号槽有哪些需要注意的地方呢?
其实总结一句话,就是Qt的信号槽,它属于监听者回调,不要觉得它使用简单,就无脑的用,明明可以直调的地方,就要考量一下,是否直接调用更简单呢?
总结
回调函数,其实是一种延时设置的函数,具有滞后性,不仅是空间上,也是时间上的,它是保持代码扩展性不可避免的手段,同样它充满了足够的未知性。
所以如果安全、高效、简单的调用回调,显得尤为重要。
其中监听者模式,就是回调函数,最常见的用法,但也是最容易出问题的一种用法,总而言之,用回调时,需要考量一下方法。
- 跨模块且具有强扩展性的回调,一定要充分考虑其安全性,使用简单性,尤其是跨线程时,对于监听者回调的用法,不适合使用第一类包装。
- 使用Qt编程,尽可能使用信号槽,但也不能够无脑的去使用信号槽,见过很多的信号槽,信号链接槽,槽又发信号,这样反反复复的嵌套了很多层, 因为很多调用明明是可以直调的,非要设计成信号槽的方式,代码多余,调试性更差,完全没有必要。
- 自定义用户事件,就是封装用来跨模块通信的,可以完全取代第一类封装,尤其是跨线程时, 但仅限跨模块,模块内且没有跨线程时,仍然可以考虑使用第一类包装。
评论(0)