C++中实现信号和槽机制

Posted on 2024-07-13 23:00  Aderversa  阅读(7)  评论(0编辑  收藏  举报

实现信号和槽机制

今天,我试图在C++中实现信号和槽机制。

假设,我们需要在终端上实现这样一种效果:当我按下某个数字键时,屏幕中打印该接收到该数字的提示消息。

在上面,我们将按下的“数字键”当成了一个信号,打印信息的函数或者是什么其他东西就可以当做槽。

信号一旦发出,槽就被触发(即执行)。

多个槽能绑定到同一个信号上,这样的话,每个槽都会对该信号做出反应。

我们可以在C++中用程序来模拟这种现象。

#include <functional>
#include <iostream>

class Foo {
public:
	void on_input(int i) {
		std::cout << "Foo echo: " << i << std::endl;
	}
};

class Bar {
public:
	void on_input(int i) {
		std::cout << "Bar echo: " << i << std::endl;
	}
};

class Input {
public:
	void main_loop() { // 用来模拟接收数字的事件循环
		int i;
		while (std::cin >> i) {
			foo->on_input(i);
			bar->on_input(i);
		}
	}
	Foo* foo;
	Bar* bar;
};

int main() {
	Input input;
	Foo foo;
	Bar bar;
	input.foo = &foo;
	input.bar = &bar;
	input.main_loop();
	return 0;
}

我们很轻松地就模拟出了信号和槽的运行机制,但是上述程序依然出现问题:

  • Input类中,用原始指针管理的对象谁知道它什么时候是有效的什么时候是无效的?
  • 如果我们要对Input类进行扩展,比如:为Input类添加一个槽函数,那我们就必须新添加一个类的同时,还要修改Input类(将新的类添加到Input的类成员中,然后在main_loop()发生事件时调用。)这样子做未免太过于麻烦了

有什么解决办法呢?具有相同的返回值和传入参数的函数(成员函数、普通函数、Lambda表达式),都可以使用C++ 11提供的function来封装成统一的可调用对象。

我们可以借助function达成这样一种效果:将需要作为槽的函数全部封装成function,然后Input利用容器统一管理这些function,并且由于其返回值和传入参数是相同的,所以可以统一调用。这样就能达到方便对原始指针的使用,同时也可以很方便地扩展槽函数。

修改后的,Input类的代码:

class Input {
public:
	void main_loop() { // 用来模拟接收数字的事件循环
		int i;
		while (std::cin >> i) {
			for (auto& func : callbacks)
				func(i);
		}
	}

	void addCallback(std::function<void(int)>&& callback) {
		callbacks.push_back(callback);
	}

private:
	std::vector<std::function<void(int)>> callbacks;
};

int main() {
	Input input;
	input.addCallback([](int i) {
		std::cout << "Callback1 echo: " << i << std::endl;
		});
	input.addCallback([](int i) {
		std::cout << "Callback2 echo: " << i << std::endl;
		});
	input.main_loop();
	return 0;
}

虽然运行结果看起来相同,但是我们的程序已经从原来的必须停止程序添加代码才能添加新的槽函数的情况,修改成了可以根据程序运行状态动态增加槽函数了。这是一个巨大的进步。(也许在多线程环境下还存在问题,但现在我们专注于讨论信号和槽的实现机制而非线程同步。所以不用在意这些细节先。)

如果要将Foo对象的on_input()方法变成对应的槽,该怎样做呢?

Input input;
Foo foo;
input.addCallback([&](int i) { // 注意Lambda按引用捕获和按值捕获的区别
		foo.on_input(i);
		});

这样,信号触发时先调用Lambda表达式,然后Lambda表达式才调用我们真正想要设置的槽函数,这相当于加了一层间接。只不过这层间接是由编译器为我们提供的。


我们还可以做出进一步的封装,让Input类只需要关注发出信号和添加槽函数即可,而无需关心具体的信号和槽如何相互调用的。

于是就可以编写一个Signal​类:

class Signal {
public:
	void connect(std::function<void(int)> callback) {
		m_callbacks.push_back(std::move(callback));
	}

	void emit(int i) {
		for (auto&& callback : m_callbacks) {
			callback(i);
		}
	}

private:
	std::vector<std::function<void(int)>> m_callbacks;
};

那Input类就可以这样使用它:

class Input {
public:
	void main_loop() { // 用来模拟接收数字的事件循环
		int i;
		while (std::cin >> i) {
			on_input.emit(i); // 原来我们是要自己使用for循环来遍历的,但现在完全不用关心这个
		}
	}

	void connect(std::function<void(int)> callback) {
		on_input.connect(callback);
	}

private:
	Signal on_input;
};

我们这里默认信号的类型是int,但实际编程中,信号的类型由于需求的不同而不同。上面所写的Signal类,可复用性不高。

所以,我们需要使用模板来提高Signal的可复用性。

一开始,我是这样写的:

template<typename T>
class Signal {
public:
	void connect(std::function<void(T)> callback) {
		m_callbacks.push_back(std::move(callback));
	}

	void emit(T i) {
		for (auto&& callback : m_callbacks) {
			callback(i);
		}
	}

private:
	std::vector<std::function<void(T)>> m_callbacks;
};

这样子做似乎是可以的,但是由于模板必须指定参数,所以我们实际情况中,槽函数的参数可能不止一个,也有可能没有。

  • 当没有参数的时候,我们却必须为其指定一个类型参数,如:Signal<void>​但实际上按照我们的直觉,void就应该是没有的。这就给使用带来了不便。
  • 当参数有多个时,该Signal就无法适配了。

所以,必须使用变长模板来解决这个问题:

template<typename ...T>
class Signal {
public:
	void connect(std::function<void(T...)> callback) {
		m_callbacks.push_back(std::move(callback));
	}

	void emit(T... i) {
		for (auto&& callback : m_callbacks) {
			callback(...i);
		}
	}

private:
	std::vector<std::function<void(T)>> m_callbacks;
};

这样的话,我们调用Signal::connect()​的时候,编译器能根据我们已有的调用,生成出对应类型的Signal出来。

但是,我有一个问题,我们是否可以在connect()的时候指定一个int,但调用的时候却不传入任何参数呢?

Signal<int> signal;
signal.connect([](int i){cout << i << endl;});
signal.emit(); // 错误示例
// 如果是多个参数的Signal,就可以这样来
Signal<int, int> signal;
signal.connect([](int i, int j){cout << std::format("({}, {})", i, j) << endl;}); // std::format, 在<format>中,C++20

这样子会怎么样?

编译器会直接给报错提示,因为Signal​在初始化是就已经被编译器根据模板生成了一个Signal<int>​的模板类。

所以实例化了Signal<int>::emit(int)​,但是Signal并没有Signal<int>::emit()​这个函数,所以它会提醒你传入的参数过少。

也就是说,遵循你模板中定义的参数列表才能正确使用Signal。


但实际上,上面的设计仍然存在着问题,比如:

  • callback添加槽函数的时候,如果lambda表达式本身就是槽函数那还好,但如果lambda表达式间接调用了一些类的成员函数,那又会出现问题。因为Lambda表达式依赖了类的成员函数,当该函数的参数列表发生变化时,你Lambda的参数列表也需要发生变化。这意味着:代码量一大,你修改成员函数的参数列表,那程序基本宣告完蛋。

所以,我们需要将lambda表达式,和类成员函数的参数列表解耦。其实C++ 11标准中提供的std::bind()​就可以解决这个问题,但这里,我们可以自己实现一个bind()​来使用:

template<typename Self, typename MemFn>
auto bind(Self* self, MemFn menfn) {
	// self和memfn都可能是临时变量,如果按引用传递那可能会失效
	// 虽然指针指向的区域不是失效,但是临时的指针变量在出了bind()之后就没了
	return [self, memfn](auto... t){
		return (self->*memfn)(t...);
		// 后续为了兼容智能指针,我们会将它改成这样
		// return ((*self).*memfn)(t...);
	};
}

这里解释一下:Self和MemFn会在函数被调用时,自动推导出是什么类型。

而bind里面的lambda表达式的参数列表的auto... t​的类型,在该lambda表达式被调用时,推导出具体的参数列表。

(self->*memfn)()​是成员函数指针的一个使用方法。我觉得可以简单理解为:将成员函数从具体的实现中剥离出来,成为一个成员函数指针。但是由于非静态成员函数必须依靠具体的实例,所以又需要一个具体的实例来调用它。

这个成员函数指针的调用语法,你可以这样理解:先把成员函数指针解引用,获得成员函数。

然后让self指针调用这个成员函数。并且为了避免成员函数和后面的调用括号结合,需要加一个(foo->*memfn) (t...)


Signal<int> signal;
Foo foo;
signal.connect(&foo, &Foo::on_input);

那我们就可以这样使用来添加槽函数啦。

正常来说到这里就已经足够了。但我很不理解为什么不能通过:

signal.connect(foo.on_input);//实际是不行的

这样像脚本语言一样的调用?但,C++就不支持这种方法。

我觉得这里应该是成员函数必须由具体实例调用这条规定所限制的,如果我们把成员函数单独提出来,那没有类对象实例就无法调用它嘛。

那这样的话,就在外面套层lambda表达式来进行解耦:

signal.connect([&](int i){
	foo.on_input(i);
})

你可能会说:这样不也没有完成前面所说的效果吗?而且,这lambda表达式又依赖了调用函数的参数

signal.connect([&](auto... t){
	return foo.on_input(t...);
});

这样的话,参数就解耦合了。语法上还是不够简洁,我们可以利用宏来进行简化:

#define FUN(fun) [=](auto... t){return fun(t...);}
signal.connect(FUN(foo.on_input));
// 再进一步我们可以使用万能引用来转发参数,因为我们的lambda只是一层外壳,它用不上这个参数
#define FUN(fun) [=](auto&&... t){return fun(std::forward<decltype(t)>(t)...);}
// 因为宏替换的本质就是文本替换,所以可能我们的程序本身就有变量名为t的变量,所以为了避免重名,可以使用下划线
#define FUN(__fun) [=](auto&&... __t){return __fun(std::forward<decltype(__t)>(__t)...);}
// 这样基本上不可能重名,应该没有几个人喜欢在日常编程中给变量多加几个下划线。
#define FUN(__fun) [=](auto&&... __t) mutable {return __fun(std::forward<decltype(__t)>(__t)...);}

这样就几乎相当于是:signal.connect(foo.on_input);​的语法结构了,这样看就很舒服。

这里我们使用了万能引用和完美转发,因为如果参数很多或者参数很大,这样做能减少拷贝的消耗。

这里原本是按引用捕获的,但是由于后面做出了修改

当然,如果你真的害怕宏替换导致的变量重名的错误,就使用上面的bind就行了,就已经足够了。


我们继续,bind​中我们传入了一个对象指针和一个它的成员函数指针。

这个对象指针是一个原始指针,那我们就必须考虑这样一种情况:该对象已经被析构了,但是我们仍然使用这个对象。

由于我们无法单纯通过原始指针来判断它指向的内存是否还是合法的(单线程可以通过在析构后马上置为NULL,但多线程情况下,该问题变得很复杂。),必须借助智能指针。

借助智能指针避免原始指针的各种问题。

修改后的bind

template<typename Self, typename MemFn>
auto bind(Self self, MemFn menfn) { // 这里Self就不强制是原始指针了
	// self和memfn都可能是临时变量,如果按引用传递那可能会失效
	// 虽然指针指向的区域不是失效,但是临时的指针变量在出了bind()之后就没了
	return [self, memfn](auto... t){ 
		return ((*self).*memfn)(t...);
	};
}

这样它可以同时兼容原始指针和智能指针。

至于语法为什么不能用->​来调用成员函数,你看看成员函数指针明明是个函数指针,为什么需要解引用才能使用?

使用的时候应该注意:

{
	auto foo = std::make_shared<Foo>();
	signal.connect([&](int i){// 应该按值捕获,发生拷贝,然后使得shared_ptr的引用计数增加
// 按值捕获的参数如果想要修改记得加mutable关键字
		foo.on_input(i);
	);
	signal.connect(FUN(foo.input));
}

如果是以这种方式去调用,那么foo在出了{}之后就会被析构,因为按引用捕获并没有使得foo的引用计数增加,然后该对象就像普通变量那样出作用域之后被析构。智能指针的拷贝不是什么大的对象,所以拷贝一下没什么,开销不大,但是能为我们避免绝大部分的内存问题。

那这样的话,前面的FUN​宏也要进行修改,因为我在那里使用了按引用捕获。

那我就害怕这个宏FUN给我捕获一些我不想要的东西,这是不会的,因为Lambda表达式只会捕获它使用到的东西。而我们这里的宏里面的Lambda表达式只会捕获我们使用到的对象,而这个东西是我们必须捕获的。


如果我们不希望connect的时候,因为shared_ptr延长了对象的存在时间,我们就可以使用weak_ptr,weak_ptr可以跟shared_ptr引用同一个对象,但是weak_ptr增加shared_ptr的引用计数。

并且由于weak_ptr这个智能指针提供了访问内存的间接性,所以它能避免对内存的非法操作。智能指针都可以做到

具体来说就是这样的:

void test(Input& input)
{
	auto bar = std::make_shared<Bar>();
	std::weak_ptr<Bar> weak_ptr = bar; //  shared:1, weak:1
	input.on_input.connect([weak_ptr](int i) {
		// weak_ptr可以得知它对应的shared_ptr的引用计数是多少,如果是0那么升格失败返回nullptr
		// 否则升格成功,shared_ptr引用计数+1
		// 然后出了作用域后,shared_ptr马上被释放,引用计数-1
		std::shared_ptr<Bar> shared_ptr = weak_ptr.lock(); // 使用前升格
		if (shared_ptr != nullptr) {
			shared_ptr->on_input(i);
		}
		else {
			std::cout << "weak_ptr升格失败" << std::endl;
		}
		});
} // bar: 0 这里引用计数归零。

int main() {
	Input input;
	test(input);
	input.main_loop();
	return 0;
}

在调用失败时,我们可以通过让Lambda在调用失败时返回一些信息,提示Signal将对应的回调删除。

这样的话,我们的bind()就要做出修改了,因为它既要接收shared_ptr,又要接收weak_ptr这两个东西的用法完全不同,可以从bind中特化一个重载。

template<typename Self, typename MemFn>
auto bind(std::weak_ptr<Self> self, MemFn memfn) {
	return [self, memfn](auto&&... t) {
		auto ptr = self.lock();
		if (ptr != nullptr) {
			((*ptr).*memfn)(std::forward<decltype(t)>(t)...);
		}
		};
}

当传入weak_ptr时,就会调用这个版本而非我们之前定义那个bind。

如果我们需要在调用失败时删去调用失败的槽,那可以这样操作:

enum class CallbackResult{
	Keep,
	Erase
};

template<typename Self, typename MemFn>
auto bind(Self self, MemFn memfn) {
	// 这里按值捕获是因为什么?
	// self和memfn都能是临时变量,
	return [self, memfn](auto&&... t) {
		((*self).*memfn)(std::forward<decltype(t)>(t)...);
		return CallbackResult::Keep;
		// 如果单纯返回布尔值,由于我们不知道返回的东西是正的语义还是反的语义,有时候就容易搞错
		// 所以用枚举来表示我们返回的东西的含义
		};
}

template<typename Self, typename MemFn>
auto bind(std::weak_ptr<Self> self, MemFn memfn) {
	return [self, memfn](auto&&... t) {
		auto ptr = self.lock();
		if (ptr != nullptr) {
			((*ptr).*memfn)(std::forward<decltype(t)>(t)...);
			return CallbackResult::Keep;
		}
		return CallbackResult::Erase;
		};
}


template<typename ...T>
class Signal {
public:
	void connect(std::function<CallbackResult(T...)> callback) {
		m_callbacks.push_back(std::move(callback));
	}

	template<typename Self, typename MemFn>
	void connect(Self self, MemFn memfn) {
		m_callbacks.push_back(bind(self, memfn));
	}

	void emit(T... i) {
		for (auto it = m_callbacks.begin(); it != m_callbacks.end();) {
			CallbackResult keep = (*it)(i...);
			switch (keep)
			{
			case CallbackResult::Keep:
				++it;
				break;
			case CallbackResult::Erase:
				it = m_callbacks.erase(it);
				break;
			default:
				break;
			}
		}
	}

private:
	std::vector<std::function<CallbackResult(int)>> m_callbacks;
};


假如我们对Foo类进行了修改,Foo变成了这样:

class Foo { public:
	void on_input(int i, int j) {
		std::cout << "Foo echo: " << i << std::endl;
	}
};

但是,信号却是这样的:

Signal<int> signal;
Foo foo;
signal.connect(FUN(foo.on_input));

这样的话有什么问题?那就是我们的emit函数中,只会传入一个int,而另外的int没有传入,就会出现调用的参数不足的情况。

既然我们emit时会传入一个参数,那么我们是否可以提前准备额外的参数,最终emit执行时只需要传入一个参数即可运行。这完全是可行的:

signal.connect([=](int i) mutable { // 因为调用lambda表达式只传一个参数,所以生成的lambda就长这样
	foo.on_input(i, 1); 
});

这样不就行了!我们在Lambda表达式中准备了一个额外的参数1,然后emit只需要传入i即可!

虽然传入参数的时间不一样,但是最终得到的结果符合我们的预期。

那按照这样来说,槽函数只需要前面几个参数用来接收信号的变量即可,而后面的参数我们可以随便定义。

但是,上面那样的写法过于麻烦,我们可以用宏来进行简化:

#define FUN(__fun, ...) [=](auto&&... __t) mutable {return __fun(__std::forward<decltype(__t)>(__t)...\
	__VA_OPT__(,) __VA_ARGS__);}

宏也是支持变长参数如果前面定义了...​这个变长参数,那后面就可以用__VA_ARGS__​来引用这个变长参数,

注意:

__VA_OPT__在C++20中新增,我GCC好像不用特殊处理即支持,然而我所使用的MSVC需要进行一些设置:

需要开启/Zc:preprocessor​,详细的开启方法请参考微软的文档。

__VA_OPT__的功能是,在__VA_ARGS__有值时,将()内的东西显示出来,如果__VA_ARGS__里面没有东西,那么__VA_OPT__就不会把()里面的符号显示出来。

这样的话,它就能兼容不需要添加额外参数的版本。(因为多出来一个,​)

当然,你也可以不使用__VA_OPT__,这个东西就不要扩展了,定义两个不同名字的宏,一个接收额外参数,另一个不接收额外参数即可。

本文章来自于B站UP主双笙子佯谬的视频:【现代C++】函数式编程优雅实现信号槽

讲得很好,一节课1个半小时我听了整天。

Copyright © 2024 Aderversa
Powered by .NET 8.0 on Kubernetes