回调函数就是一个可以被作为参数传递的函数,一个普通函数的定义与使用,通常是先准备好函数,参数待定,输入参数不同,结果不同。

而回调,则准备阶段是相反的,先准备好参数,函数待定,输入的函数不同,同样的参数,运行的结果不同。

在C语言中,回调函数,就是简单的函数指针,而在C++中,则更加的泛化,除了普通函数指针作为参数传递,最为直接的还有虚函数回调,

也可以使用其它的仿函数,C++11提供了std::function和lambda算子,还提供了std::bind方法构造仿函数等等,这使得回调的使用方法变得

多种多样,下面我们就来讲讲各种回调的优势与劣势,并且在他们做一些对比,以及引申说明一些回调更高级用法,例如:Qt的信号槽,自定义

用户事件。

值得注意的一点是,最原始的东西,往往性能最高的,越封装,性能就越低,因此使用C语言中函数指针作为回调,性能一定是最佳的,因此,当

有人问题Qt信号槽与C的函数指针回调有哪些区别,你回答性能更差,这是一句屁话,因为任何包装,都是降低性能,但相比于封装之后的灵活性

和扩展性来说,损失这点性能,也就是一个屁。

std::function

在C++11中,如果懂一些模板编程,自己实现一个std::function并非很难的一件事情,只不过你考虑没有标准库那么多,可能性能会差一些,

适用性和实用性差一些(这也是为啥看不懂标准库实现的原因之一)。

在了解std::function之前,我们需要知道什么是仿函数,仿函数泛指所有支持operator()的所有对象,其中包括:

  1. 函数指针
  2. 重载operator()的类对象
  3. 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点:

  1.  std::bind(func, args…) 后续args的参数个数必须等于func的参数个数。
  2.  占位符,必须从_1开始,且必须连续,也就是使用占位符中,如果有_3, 则必须使用到_2和_1。
  3.  固定的类型必须与绑定函数对应位置的参数的类型一致(至少是可以转换的), 这里举例绑定的是常量,当然可以绑定变量,因为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是啥玩意,可以看一下源码,

是一个很简单的玩应。

*/

总结一下

  1.  lambda算子可以完全取代std::bind,在没有lambda算子的年代(boost库时代), C++函数式编程,完全依赖boost::bind。
  2.  std::bind是纯代码实现,不像lambda算子,它是扩展语法。
  3.  那么除了lambda算子和std::bind,还有更复杂替代方案,就是定义局部类(结构体),重载operator()运算符,代码量很大。
  4.  std::bind在部分使用上,代码更简单,如果熟悉std::bind,代码少,理解肯定更简单。
  5.  熟悉std::bind,至少可以看懂别人这样使用(尤其是使用过boost库的开发者).。

虚函数回调

虚函数回调,也是一种回调方式,只不过它是C++内置的一种回调方式,通过继承、重载,然后动态的索引虚函数表完成回调过程。

虚函数回调与普通函数回调对比:

  1.  不管使用哪种方式,都能达到回调效果。
  2.  普通函数回调,更适合组合的方式,虚函数回调,只能使用继承的方式。
  3.  普通函数回调,性能高于虚函数回调,因为虚函数有一个查找的过程。
  4.  虚函数回调,有一个明显的缺陷,就是一定需要继承,有时候,则会增加类复杂度。
  5.  同样它也有一个优势,对于单个回调,虚函数回调,可以更方便进行非完全性覆盖,就是我们派生重载某一个虚函数时,还可以很方便调用基类的实现当然普通函数回调,也能达到这个目的,但代码看起来会更生硬。

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.   监听者模式,更适合跨模块通信,一般性使用设计模式,其实就为了解耦,让模块边界更清晰,而模块内部,就需要尽可能少使用一些复杂设计模式,除非模块内部,存在扩展性。

使用上面这一套模式,已经感觉没什么问题了,但是当你实际应用,才发现毛病还有很多,应用时会逐渐暴露,且越来越隐晦,有哪些问题呢?

问题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源码解析篇

相比于上诉的监听者模式的实现,自定义用户事件,具备以下优势:

  1.  发送者(服务)和接受者(监听者),完全解耦,之所以完全解耦,是因为有更上层的自定义用户事件管理器做中介。
  2.  主动链接,自动断开,防止忘写断开导致的崩溃, 这借用的Qt的自动回收机制。
  3.  使用更简单,使用组合以及事件驱动的方式。
  4.  通用性更强,不需要再定义监听者回调基类,而是有更通用更简单的方式定义事件。
  5.  支持跨线程同性,但是目标所在线程,必须有消息循环。
  6.  自定义用户事件完美解决上诉的4个问题。

缺陷有以下两点:

  1.  接受者必须继承自QObject, 因为跨线程和自动断开这两个功能需要, 当然可以考虑扩展, 当接受者如果没有继承自QObject, 就需要主动断开,且不能跨线程。
  2.  只能单事件attach, 不像虚函数回调实现的监听者模式那样,单个Observer一次attach,则可以有多个回调被连接。

备注1: 自定义用户事件,虽然封装之后,使用非常简单,但更适合跨模块,模块内部,尽量采取别的方案,例如信号槽的方式,如果代价着实太大,也可以用这玩意。

备注2: 自定义用户事件,属于监听者模式,不关心结果,不要想着,发送者获取接受者的结果,压根没有拷贝这种设计。

备注3: 任何业务需求的实现,不能为了拆而拆,这里的拆就是解耦的意思,我们只能用更抽象的思维,或者无关业务需求的拆法去拆,这样业务需求表达地方尽可能紧凑,否则

无脑拆解业务,只能让单点需求的扩散面太宽,反而调用复杂度、理解复杂度变得更高,因此模块内部使用解耦,需要有考量性。

Qt的信号槽

Qt的信号槽,仍然是监听者模式的包装,它包装的复杂度更高,具体使用细节,这里就需要过多介绍。

Qt的信号的发送者和接受者,属于松耦合的关系(就是绑定时,需要同时获取信号对象和槽对象)。

不过的Qt信号的参数,是支持引用的,但是由于是监听者模式,其实很少会这么用。

使用Qt信号槽的优势有哪些呢?

  1.  发送者与接受者是松耦合的关系,链接的方式非常的独特。
  2.  主动链接,自动断开,主动断开,仍然需要判断发送者的生命周期,由于使用的Qt,判断也比较简单。
  3.  支持跨线程,目标所在线程,必须有消息循环。
  4.  相比于第一类监听者模式包装,同样也完美的处理了4类问题。

使用Qt的信号槽有哪些需要注意的地方呢?

其实总结一句话,就是Qt的信号槽,它属于监听者回调,不要觉得它使用简单,就无脑的用,明明可以直调的地方,就要考量一下,是否直接调用更简单呢?

总结

回调函数,其实是一种延时设置的函数,具有滞后性,不仅是空间上,也是时间上的,它是保持代码扩展性不可避免的手段,同样它充满了足够的未知性。

所以如果安全、高效、简单的调用回调,显得尤为重要。

其中监听者模式,就是回调函数,最常见的用法,但也是最容易出问题的一种用法,总而言之,用回调时,需要考量一下方法。

  1.  跨模块且具有强扩展性的回调,一定要充分考虑其安全性,使用简单性,尤其是跨线程时,对于监听者回调的用法,不适合使用第一类包装。
  2.  使用Qt编程,尽可能使用信号槽,但也不能够无脑的去使用信号槽,见过很多的信号槽,信号链接槽,槽又发信号,这样反反复复的嵌套了很多层,  因为很多调用明明是可以直调的,非要设计成信号槽的方式,代码多余,调试性更差,完全没有必要。
  3.  自定义用户事件,就是封装用来跨模块通信的,可以完全取代第一类封装,尤其是跨线程时, 但仅限跨模块,模块内且没有跨线程时,仍然可以考虑使用第一类包装。
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。