/SigSlot

Just Like QT

Primary LanguageC++

SigSlot

如果你想使用它,只需要包含SigSlot.hpp头文件即可(要求版本c++17以及以上),如果你对如何实现感兴趣,可以浏览Source文件夹,里面的模块很详细

Introduction

现在我们来设想一下现实生活中的场景,你是一个买报纸的人,订阅了报社的一些报纸,当这些报纸被出版的时候会送到你家,仔细想想这个过程我们把它抽象为编程里面的一些概念

首先会有一些事件Event,然后你作为一个对象订阅了某些事件,当事件触发的时候,会通知你,不仅如此事件可能还会携带一些信息(也就是一些参数),你可以处理这些信息,也可以不管它们(比如我只关心报纸有没有出版,并不关心报纸里面的内容)

如何在编程里面实现这个模型呢,其实有一种著名的设计模式叫做观察者模式,可以实现类似的效果,但是仍然有不少的缺陷,如果你对此感兴趣可以去搜一些资料,这里不过多阐述与主题无关的内容,基于SigSlot使得你能更加轻松的构建上面的事件机制,接下来看看我们该怎么使用吧

Examples

来看我们实现的一些例子吧:

class Button :public SigSlot::Object
{
private:
   int data = 0;
public:
   SigSlot::Event<> clicked;
};
void Print() { std::cout << "鼠标被点击了"<<  std::endl; };

int main()
{
   Button* button = new Button();
   button->clicked.connect(Print);
   button->clicked.emit();
   delete button;
   return 0;
}

下面来介绍一下上面那段代码的含义,首先我们定义了定义了一个类叫Button,然后继承了SIgSlot中的Object类型,如果你想使用这套事件机制,就要继承这个Object类型,然后我们定义了一个Event类型的变量clicked,这个Event类型是个模板类,参数类型是你想传递的参数,如果你不想传递参数,就像上面那样把<>里面什么都不填就行了,button->clicked.emit()这一行就是触发事件啦,运行这个程序你就发现打印出一行文字了

怎么样?是不是听起来很不错呢,接下来我们深入探讨一些细节

More Details

事实上我们有更复杂的需求,比如我们需要传递参数,比如上面那个位置,希望传递鼠标被点击的时候的坐标,如何实现?

class Button :public SigSlot::Object
{
private:
   int data = 0;
public:
   SigSlot::Event<int,int> clicked;
};
void Print(int x,int y) { std::cout << "鼠标被点击了,坐标是"<< x << y << std::endl; };

int main()
{
   Button* button = new Button();
   button->clicked.connect(Print);
   button->clicked.emit(100,200);
   delete button;
   return 0;
}

不难发现,Event多了两个模板参数就是,

int,int(SigSlot::Event<int,int> clicked),

相应的button->clicked.emit(100,200),emit的时候也会多两个参数,无论你想传递什么类型的参数只需要在Event对应的模板参数里面写上就行了,请记住emit传入的参数一定要和Event的参数完全一致哦,运行上面的程序你就会发现,会把100,200也打印出来

到这里其实还有更多的细节,比如作为订阅者我只关心鼠标的x坐标,而不关心y的坐标,或者我只关心事件有没有发生,不关心所传递的参数是什么,这里我利用模板元编程实现了参数裁剪的功能,也就是说订阅者函数的参数类型不用和事件的参数类型完全一致,比如上面上面的Print函数:void Print(int x,int y),就和事件参数类型完全一致,其实写成void Print(int x),或者void Print()都是可以订阅事件的,你可能会问,只有一个int接收的是两个int里面的哪一个呢,事实上遵循如下的规则,那就是函数参数类型是事件类型的子序列

比如int,int,float是int,int,char,int,float,char的子序列并且对应的序号是0,1,4所以函数接受的是事件位置对饮序号的参数,回到这个问题显然接受的是第一个int啦, 至于多余的参数会自动裁剪掉,如果出现了事件中没有的参数,就会抛出一条静态编译错误,比如下面这两个例子, int,int,char 不匹配 int,char,char int,float 不匹配 int,long char

是不是非常灵活呢,事实上也允许你使用自定义的结构体或者类,而不需要提前注册它们,比如下面这个例子

struct Mouse
{ 
    int x;
    int y;
};
class Button :public SigSlot::Object
{
private:
	int data = 0;
public:
	SigSlot::Event<Mouse> clicked;
};
void Print(Mouse mouse) { std::cout << "鼠标被点击了,坐标是"<< mouse.x << mouse.y << std::endl; };

int main()
{
	Button* button = new Button();
	button->clicked.connect(Print);
	button->clicked.emit({100,200});
	delete button;
	return 0;
}

这里传递参数的方式都是常引用,不用担心多余的拷贝哦

More Subscribers

到目前为止你肯定还有不满足的地方吧,比如为什么我只能把全局函数当订阅者呢,这样也太无趣了,事实上我们支持很多种类型的订阅方式,包括全局函数,静态成员函数,lambda表达式,类成员函数,仿函数绝对能满足你的需求,更加具体的方法你可以在Test/AllKindsofFunction里面看见,这里只介绍类成员函数的订阅,事实上也是使用最广泛的

class Button :public Object
{
private:
	int data = 0;
public:
	Event<int> clicked;
	void setData(int num) 
	{
		data = num;
	}
	void printData() { std::cout << "成员参数是:" << data << std::endl; };
};


int main()
{
	Button* button = new Button();
	Button* button1 = new Button();
	button->clicked.connect(button1, &Button::setData);
	button->clicked.emit(100);
	button1->printData();
	delete button;
	delete button1;
	return 0;
}

运行一下程序,你会发现button1里面的data已经被修改了,对于类成员函数的订阅只要按照上面那个格式就好了,使用起来就是这么简单,只要将接收者的地址,和成员函数指针,传入就行了,参数传递的规则和上面的全局函数的规则完全一致

Disconnect

同时我们也支持断开连接的操作

int main()
{
	Button* button = new Button();
	Button* button1 = new Button();
	button->clicked.connect(button1, &Button::setData);
	button->clicked.emit(100);
	button1->printData();
	button->clicked.disconnect(button1, &Button::setData);
	button->clicked.emit(120);
	button1->printData();
	delete button;
	delete button1;
	return 0;
}

你会发现第二次打印出的值仍然是100,而不是后来设定的120,是不是用起来很自由呢,由于传入编译器为每个lambda对象都分配了一个类型,所以的话,在lambda函数被订阅的时候,会返回一个整形的序号,根据这个序号就可以断开连接了

Safety

虽然,我们可以灵活的手动disconnect来断开连接,可是万一我忘记断开连接,并且接收者对象析构,这时程序会不会出问题呢?就像下面这种情况

int main()
{
	Button* button = new Button();
	Button* button1 = new Button();
	button->clicked.connect(button1, &Button::setData);
	delete button1;
	button->clicked.emit(100);
	button1->printData();
	delete button;
	return 0;
}

还记得你继承的Object吗,事实上正得益于它,在对象销毁的时候会自动断开,所有与之相关的的connect,从而减少使用者的心智负担,这样用起来是不是更加轻松了呢

Asynchronous Programming

更进一步,我们想要做一些更疯狂的事情,编写异步的代码,事实上之前的事件触发和函数执行都是在同一个线程里面进行的,如果有一个函数执行事件过长就会阻塞主线程,为了应对这些情况,我们需要引入多线程来解决这个问题,将一些耗时比较长的操作放到子线程中执行

#include "SigSlot.hpp"
#include <iostream>
//测试多线程的效果
std::mutex Mutex;
using namespace SigSlot;
class Button : public Object
{
private:
    int id;
    int data;
public:
    Button(int num) : id(num)
    {}
    Event<int, char, int, int, int> clicked;
    void setData(char a, int b, int c) const
    {
        std::lock_guard locker(Mutex);
        std::cout << "函数被触发,参数是" << a << " " << b << " " << c << "对象id是" << id << std::endl;
        std::cout << "当前执行的线程是" << std::this_thread::get_id() << std::endl << std::endl << std::endl;
    }
};
int main()
{
    Button *button = new Button(10);
    Button *button1 = new Button(11);
    Button *button2 = new Button(12);
    std::thread t(SigSlot::EventLoop::exec);
    t.detach();
    button1->moveToThread(t);
    button->clicked.connect(button1, &Button::setData);
    button->clicked.connect(button2, &Button::setData);
    button->clicked.emit(1, 'a', 3, 4, 5);
    std::this_thread::sleep_for(std::chrono::seconds(5));
    std::cout << std::this_thread::get_id() << std::endl;
    delete button;
}

兼容了标准c++的写法的多线程机制,只需要将它移动到你的目标线程去就能在信号触发的时候自动在移动到的线程自动执行啦,这里介绍一些关键的代码

std::thread t(SigSlot::EventLoop::exec);

这一行代码开启了一个子线程,并且运行了SigSlot命名空间里面的一个函数,其实就是开启了事件循环,如果想要在子线程执行信号槽的任务就必须要在子线程开启事件循环,

 button1->moveToThread(t);

这一行代码,把button1移动到子线程去了,这样之后button1订阅事件的函数触发就在子线程了,如果不移动默认创建对象所属于的线程为当前线程,与之对比的是button2并没有移动,如果运行上面这段代码会发现button2在主线程执行,而button1执行的线程是子线程,这样其实就完成了异步编程的要求

注意:请保证在任务执行完毕之前,对象一直存活,用到这里就是button1一直存活,如果在执行任务的途中把button1,delete了会导致未定义的行为,另外数据竞争问题也得读者自己用过加锁等方式来进行解决

结束

文章到这里就结束了,如果有任何问题欢迎联系我 邮箱:2251384@tongji.edu.cn