浅议C++回调函数的实现方式

引子-模板方法模式的实现

看一下模板方法模式的实现方式,首先定义一个模板基类,它有一个模板成员函数和一个钩子成员函数:

class TemplateBase {
	int data; // 假设有一个int型的数据成员
public:
	virtual ~TemplateBase() {}
	
	void process() { // 模板函数
		// 其它准备操作
		hook(data);
		// 其它后续操作
	}
	
	virtual void hook(int data) {} // 钩子函数
	// 其它成员函数
};

其中,成员函数void process()是模板函数,负责框架性的通用流程处理,它一般不允许被派生类重写,由基类负责它的实现;成员函数void hook(int)是钩子函数,负责通用流程中的个别具体步骤,是一个虚函数,要根据不同的业务逻辑由派生类进行重写覆盖。
接着,当需要进行具体处理时,可以通过继承TemplateBase的方式来定义一个具体类,并重写hook函数的具体实现。如:

class ConcreteTemplate : public TemplateBase {
public:
	virtual void hook(int data) {
		// 	具体的逻辑
	}
}; 

这种实现方式的不便之处就是需要定义具体的子类,而且子类的编写也是固定程式化的:继承基类,并且重写构造函数,然后使用这个新定义的类,new一个新对象。每需要一个具体的处理就得要按照这种方式定义一个新类,程序编写起来不是太方便。

那么,有没有更为便捷的方式来实现类似的场景?
从模板模式的实现中可以看出,当调用父类实现的process函数时,子类重写的hook函数会被回调,从而实现了通用流程中的具体操作,既然它的特点是回调操作,那完全可以使用回调函数或者说函数式编程方式来实现这种场景。实际上对于模板类来说,只要是函数签名是void(int)的函数,它就可以调用,显然,只要提供符合这种签名类型的函数就可以了。回调函数的特点是函数可以作为参数进行传递,并可以保存起来,在需要的时候在进行调用。尤其是在C++11之后引入了lambda表达式,可以就地声明一个回调函数,在编写程序时非常方便,显然,这种方式带来了很大的便利性。
下面我们来看一下C++中回调函数的几种实现方式及其特点。

函数指针方式

具体的说,就是把钩子函数看作是一个回调函数,保存在模板方法类中,当需要具体的业务处理时, 让模板方法来调用这个回调函数,应用程序可以根据具体业务场景直接定义一个回调函数,而无需再定义一个模板子类了。可以把上面的模板方法实现的场景改成下面的代码实现:

class Template final { // 模板类,可以为final,不用通过继承它来扩展具体hook
	int data;
	void(*hook)(int); // 回调函数,即钩子函数

public:
	Template(void(*callback)(int)) {
		hook = callback;
	}

	void process() { // 模板方法 
		// ...其它准备操作
		hook(data); // 回调
		// ...其它后续操作
	}

	// 其它成员函数
};

当应用程序使用Template 类来处理业务时,可以在构造对象时传入一个函数指针,并保存在对象中的hook成员中,当调用process时,会对hook进行回调,从而实现了具体逻辑步骤。如:

static void foo(int) {
	std::cout << "this is a hook fucntion" << std::endl;
}

Template tm(foo);
//....
tm.process();

看一下这个实现的特点:
1、回调函数在类中是一个指针类型的数据成员,保存在对象中,因此,在对象布局中要占用一个指针大小的空间。
2、调用回调函数时使用的是函数指针,属于动态绑定,它相当于虚函数的调用开销。
2、回调函数的类型是函数指针,这样在外部使用时,只能传入函数指针或者没有捕捉变量的lambda表达式,函数对象无法直接作为实参使用,仍然不够灵活。

std::function方式

为了适配各种不同的回调函数形式,可以使用C++标准库中的std::function类模板来保存回调函数,std::function可以存放各种类型的可调用对象(Callable),如:函数指针、成员函数、函数对象、lambda表达式等,这样就丰富了回调函数的形式。实现形式如下所示:

class A{
	int data;
	std::function<void(int)> hook;

public:
	A(std::function<void(int)> callback) : hook(callback) {
	}
	
	void process() { // 模板方法 
		// 其它准备操作
		hook(data);
		// 其它后续操作
	}
	
	// 其它成员函数
};

特点:
1、可以保存各种形式的可调用对象。
2、在保存可调用对象时,除了std::fucntion对象本身要占用空间外,std::fucntion对象还要在堆中额外分配内存资源。
3、在调用回调函数时,不管是什么类型的可调用对象,调用过程都采用了动态绑定,相当于虚函数的调用开销。
显然,同函数指针方式相比,仅仅是丰富了可调用对象的形式,无论是内存空间还是函数调用开销都不是很好。

类模板参数方式

为了能够支持各种可调用对象作为回调函数,也可以考虑使用模板方式,即把回调函数类型定义为模板参数,只要模板参数类型是符合函数签名的可调用对象就行。如下所示:

template<typename Callback> 
class B{
	int data;
	Callback hook;

public:
	B(int x, Callback callback) : data(x), hook(callback) {
	};

	void process() { // 模板方法 
		// 其它准备操作
		hook(data);
		// 其它后续操作
	}
	
	// 其它成员函数
};

显然这种方式的特点:
1、只要是可调用对象都可以作为回调函数来使用,回调类型形式多样,这点它同std::function方式完全一样。例如,下面的例子就是使用一个lambda表达式作为回调函数。

	B<void(*)(int)> tm(42, [](int data){
		std::cout << "this is a hook fucntion" << std::endl;
	});

2、如果回调函数是函数指针类型,调用回调函数时,使用的是动态绑定,这一点同上面的函数指针和std::fucntion方式一样。
3、如果回调函数是函数对象或者lambda表达式,调用回调函数时,使用的是静态绑定,这一点比函数指针和std::fucntion方式的调用效率要高。
4、由于是模板类,每当使用一个新的回调函数类型时,编译器都要实例化一个新类,会增加代码空间。因此,在实际编写代码时,可以从一个函数中把与模板参数类型无关的代码提取出来,单独封装成一个成员函数,这样就让与模板参数类型相关的代码尽可能地最小化。如下面的代码,把和模板类型无关的代码封装成函数pre_process()和post_process(),这样使用不同的模板参数类型实例化process时,就不会在重复生成pre_process和post_process相关的代码了,void process()的代码实现尽可能地最小化了 。

	void process() { // 模板方法 
		pre_process();
		hook(data);
		post_process();
	}
	
void pre_process() {
// 其它准备操作
}
void post_process() {
// 其它准备操作
}

不过,该方式虽然在一定程度上改善了std::function方式中内存占用和调用时间开销大的缺点,但是在内存占用上仍然没有优化到最佳化。不妨看一个特殊情况,如果Callback类型的函数对象是一个空对象,即它什么数据成员也没有,我们知道在C++中,即使一个类没有任何数据成员,在创建它的对象实例时,也要给它分配一个字节的空间大小。这是没有必要的,如果对内存占用锱铢必较的话,可以针对空对象使用空基类优化-EBO。

EBO模式

所谓空基类优化,就是一个派生类继承一个空基类时,在创建派生类对象时不会给空的基类子对象分配空间。
我们知道,如果一个类要复用另一个类的功能,通常有两种实现方式:一种是继承,另一种是组合。如果使用组合的话,即使是空类,它作为一个类的数据成员的话,仍然要占用一个字节的空间大小。如果复用的是空类的话,可以使用继承的方式,这样基类不会在派生类中只有空间。下面看一下EBO的一个简单实现方式。
我们可以借助于C++标准库中std::tuple做一个简易的实现:
1、std::tuple
在某些C++平台上,可以使用std::tuple来实现一个EBO优化方式。如果回调函数是一个空类函数对象,在std::tuple中就不占用任何空间,借助于它,可以根据回调函数的类型,来产生内存空间最小化的数据成员对象。

template<typename Callback> 
class C {
	std::tuple<int, Callback> tup; // tuple在有的编译器中,使用EBO的优化方式 
	
public:
	C(int x, Callback callback) : tup(x, callback) {
	};

	void process() { // 模板方法 
		int data = std::get<0>(tup);
		// 其它准备操作
		auto callback = std::get<1>(tup);
		callback(data);
		// 其它后续操作
	}
	
	// 其它成员函数
};

我们可以写一个测试程序看一下不同形式的回调函数的内存占用:

static void foo(int) {} // 普通函数

struct callback1 { // 空对象
	void operator()(int) {}
};

struct callback2 { // 非空对象
	int  x = 42;
	void operator()(int) {}
};

void test() {
	C<void(*)(void)> a(42, foo);
	C<callback1> b(42, callback1());
	C<callback2> c(42, callback2());
	
	std::cout << sizeof(a) << std::endl; // 16
	std::cout << sizeof(b) << std::endl; // 4
	std::cout << sizeof(c) << std::endl; // 8
}

在64位GCC环境下编译运行,可以看出对象a、b、c占用的空间大小分别是16、4、8。其中b对象的回调函数对象是一个空类,该回调函数对象并没有占用空间,仅有int类型的数据成员占用了4字节的空间。

该方式的特点是实现简单,借用了std::tuple的EBO优化特性,但是在用到各个数据成员时,都得先要使用std::get()来获取,在编程时稍有不便,而且个别C++平台中的std::tuple并不一定采用了EBO优化方式,比如MSVC平台,在跨平台移植时可能有不一致的表现。

2、type_traits
使用type_traits,根据回调函数的形式合成不同的类结构,如果callback是个空类对象,并且没有使用final修饰,采用继承方式,否则,就使用组合方式 。

如果是非final空类,采用继承方式:

template<typename Callback, bool = std::is_empty<Callback>::value && !std::is_final<Callback>::value> 
class D : private Callback{
	int data;
	
public:
	D(int x, Callback) : data(x), Callback() {
	};

	void process() {
		this->operator()(data); // 肯定使用静态绑定来调用,因为operator()肯定不是virtual函数 
	}
};

如果非空类或者函数指针,采用组合方式 :

template<typename Callback> 
class D<Callback, false> { // 如果 Callback不是空函数对象,或者是被final修饰的,就使用委托的方式来合成一个类 
	int data;
	Callback hook;
	
public:
	D(int x, Callback callback) : data(x), hook(callback) {};

	void process() {
		hook(data); // 如果是函数指针,使用动态绑定,否则是静态绑定。
	}
};

使用上面的测试程序进行验证,当回调函数对象是一个空类时,可以看到该回调函数在D类中并没有占用空间。

特点:
1、内存占用空间尽可能地优化了,如果是回调函数是空对象,它不会占用额外地空间,比上面的模板类方式的内存占用要少。
2、如果回调函数是函数指针类型,使用动态绑定,如果是函数对象或者lambda表达式,使用的是静态绑定,调用开销同上面的模板类方式一样。

当然,如果回调函数被final修饰了,不管是不是空对象,这种情况也无法使用EBO优化,因为final修饰的类无法作为基类被继承,只能使用组合方式。回调函数对象是函数指针或者不是空类,也只能使用组合方式,这几种情况占用空间是不可避免地。那么,有没有回调函数不占用空间的实现方式呢?有,我们来看下面的实现方式。

policy模式

最后介绍一种基于策略类(policy)的方式。此场景下回调函数只能是函数对象的形式存在,它需要定义函数对象类,这个类也称为策略policy,也就是定义一个专门的类来完成与一个特定模板参数类型的对象的操作。

template<template <typename> class Callback>
class E {
	int data;
public:
	E(int x) : data(x) {};
	void process() { // 用到时再生成回调的对象,不用提前生成并保存起来,以免占用空间
		//... 
		Callback<void>()(data);
		//...
	}
};

不管回调函数是不是空函数对象,该方式不会为回调函数额外分配空间,因为压根就不保存它,只是在使用时才在栈中临时创建一个函数对象。看一下它的测试,大小都是4,回调函数没有占用任何空间:因为没有保存,当然不会占用空间了。

// 封装函数为一个函数对象
template<typename T> // 必须使用模板方式来定义class,这是一个policy类 
class func_ptr_wrap {
public:
	void operator() (int x) {
		foo(x); // 函数形式,使用class封装成函数对象,调用时是静态绑定 
	}
};

// 封装lambda表达式为一个函数对象
template<typename T>
class lambda_wrap {
public:
	void operator()(int x) {
		auto lambda = [](int x) { // lambda形式 
			puts("called function ptr by lambda deleter");
		};
		lambda(x);
	}
};

// 定义一个函数对象
template<typename T>// 必须使用模板方式来定义class
struct callback0 
{
	void operator()(int x) {
		puts("callback with function object");
	}
};

void test6() {
	E<func_ptr_wrap> a(42);
	a.process();
	E<lambda_wrap> b(42);
	b.process();
	E<callback0> c(42);
	c.process();
	
	std::cout << sizeof(a) << std::endl;
	std::cout << sizeof(b) << std::endl;
	std::cout << sizeof(c) << std::endl;
}

特点:
1、不保存回调函数对象,因此也就不占用对象的内存空间。
2、回调函数不能使用函数指针和lambda表达式,只能使用函数对象,也就是必须定义函数对象类。
3、回调函数对象类还必须是模板类,必须要为它们指定模板参数。
4、延迟创建函数对象。回调函数必须是显式的函数对象,也就是必须编写代码来定义一个函数对象类,虽然带来了不便,但是它有一个好处就是,这个函数对象直到使用时才会被创建,不像前面的其它几种形式,它们都是在创建模板对象时,同时创建了回调函数对象。比如lambda表达式,它在就地声明时就已经创建了相应的函数对象,显然,如果这个回调函数由于某种原因最终没有被调用时,就不会使用这个对象,它也就白创建了,也就是说回调函数对象不但被延迟调用,而且还被延迟创建了。

该模式虽好,但是此场景下回调函数只能以函数对象的形式存在,也就是必须要为回调函数定义一个class。因为要求必须提供函数对象类,函数指针和lambda表达式(没有显式的类型)也就无法直接使用,只能先把它们封装成函数对象,显然由于无法直接就地定义lambda表达式,使用起来确有不便之处。不过,如果遇到了严格要求回调函数不占用调用方的内存空间,或者需要延迟创建函数对象的应用场景时,不妨考虑此方式。

  • 5
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值