Qt信号与槽

简介

信号槽是 Qt 框架引以为豪的机制之一。熟练使用和理解信号槽,能够设计出解耦的非常漂亮的程序,有利于增强我们的技术设计能力。

所谓信号槽,实际就是观察者模式。当某个事件发生之后,比如,按钮检测到自己被点击了一下,它就会发出一个信号(signal)。这种发出是没有目的的,类似广播。如果有对象对这个信号感兴趣,它就会使用连接(connect)函数,意思是,用自己的一个函数(成为槽(slot))来处理这个信号。也就是说,当信号发出时,被连接的槽函数会自动被回调。这就类似观察者模式:当发生了感兴趣的事件,某一个操作就会被自动触发。


信号与槽示例

例如一段简单的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// !!! Qt 5
#include <QApplication>
#include <QPushButton>

int main(int argc, char *argv[]) {
QApplication app(argc, argv);

QPushButton button("Quit");
QObject::connect(&button, &QPushButton::clicked, &QApplication::quit);
button.show();

return app.exec();
}

程序中,按钮button在按下去的时候执行QApplicationquit函数。而实现这一切的是QObject::connect。Qt的信号与槽主要是围绕这个函数展开。

在Qt5 中QObject::connect()有五个重载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
QMetaObject::Connection connect(const QObject *, const char *,
const QObject *, const char *,
Qt::ConnectionType);

QMetaObject::Connection connect(const QObject *, const QMetaMethod &,
const QObject *, const QMetaMethod &,
Qt::ConnectionType);

QMetaObject::Connection connect(const QObject *, const char *,
const char *,
Qt::ConnectionType) const;

QMetaObject::Connection connect(const QObject *, PointerToMemberFunction,
const QObject *, PointerToMemberFunction,
Qt::ConnectionType)

QMetaObject::Connection connect(const QObject *, PointerToMemberFunction,
Functor);

五个重载的返回值都是 QMetaObject::Connection,这并不是我们现在要关心的东西。connect()函数最常用的一般形式为:

1
2
connect(sender, signal, 
receiver, slot);

connect 一般会用到四个参数,第一个为 发出信号的对象,第二个为 发出对象发出的信号,第三个为 接收信号的对象,第四个为 接收对象在接收到信号要进行的操作。也就是说:sender发出signal,receiver接收到signal之后执行slot

所以我们可以套用这个形式去分析上面五个重载函数:

  1. senderreceiver 类型都是 const QObjectsignalslot 都是 const char * 这是将 signalslot 都当做字符处理。

  2. senderreceiver 同样是 const QObject ,但 signalslotconst QMetaMethod &。 们可以将每个函数看做是 QMetaMethod 的子类。因此,这种写法可以使用 QMetaMethod 进行类型比对。

  3. sender 同样是 const QObject *signalslot 同样是 const char *,但是却缺少了 receiver。这个函数其实是将 this 指针作为 receiver

  4. senderreceiver 也都存在,都是 const QObject *,但是 signalslot 类型则是PointerToMemberFunction。看这个名字就应该知道,这是指向成员函数的指针。

  5. 前面两个参数没有什么不同,最后一个参数是 Functor 类型。这个类型可以接受 static 函数、全局函数以及 Lambda 表达式。


由此我们可以看出,connect() 函数,senderreceiver 没有什么区别,都是 QObject 指针;主要是 signalslot 形式的区别。具体到我们的示例,我们的 connect() 函数显然是使用的第五个重载,最后一个参数是 QApplicationstatic 函数 quit()。也就是说,当我们的 button 发出了 clicked() 信号时,会调用 QApplicationquit() 函数,使程序退出。

Qt4 的信号槽与Qt5类似。在Qt4 中的 QObject 中,有三个不同的 connect() 重载:

1
2
3
4
5
6
7
8
9
10
11
bool connect(const QObject *, const char *,
const QObject *, const char *,
Qt::ConnectionType);

bool connect(const QObject *, const QMetaMethod &,
const QObject *, const QMetaMethod &,
Qt::ConnectionType);

bool connect(const QObject *, const char *,
const char *,
Qt::ConnectionType) const

将上面代码改为Qt4 应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// !!! Qt 4
#include <QApplication>
#include <QPushButton>

int main(int argc, char *argv[]) {
QApplication app(argc, argv);

QPushButton button("Quit");
QObject::connect(&button, SIGNAL(clicked()),
&app, SLOT(quit()));
button.show();

return app.exec();
}

SIGNALSLOT 是两个宏,将这两个函数转换为字符串。注意,即使quit()是QApplication的 static 函数,也必须传入一个对象指针。这也是 Qt 4 的信号槽语法的局限之处。另外,注意到connect()函数的 signal 和 slot 都是接受字符串,因此,不能将全局函数或者 Lambda 表达式传入connect()。一旦出现连接不成功的情况,Qt 4 是没有编译错误的(因为一切都是字符串,编译期是不检查字符串是否匹配),而是在运行时给出错误。这无疑会增加程序的不稳定性。


定义自己的信号与槽

经典的观察者模式在讲解举例的时候通常会举报纸和订阅者的例子。有一个报纸类Newspaper,有一个订阅者类Subscriber。Subscriber可以订阅Newspaper。这样,当Newspaper有了新的内容的时候,Subscriber可以立即得到通知。在这个例子中,观察者是Subscriber,被观察者是Newspaper。在经典的实现代码中,观察者会将自身注册到被观察者的一个容器中(比如subscriber.registerTo(newspaper))。被观察者发生了任何变化的时候,会主动遍历这个容器,依次通知各个观察者(newspaper.notifyAllSubscribers())。

实现代码如下:

newpaper.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//!!! Qt5
#include <QObject>

////////// newspaper.h
class Newspaper : public QObject {
Q_OBJECT
public:
Newspaper(const QString & name) : m_name(name) {}

void send() {
emit newPaper(m_name);
}

signals:
void newPaper(const QString &name);

private:
QString m_name;
}

reader.h

1
2
3
4
5
6
7
8
9
10
11
12
13
//!!! Qt5
#include <QObject>
#include <QDebug>

class Reader : public QObject {
Q_OBJECT
public:
Reader() {}

void receiveNewspaper(const QString & name) {
qDebug() << "Receives Newspaper: " << name;
}
};

main.cc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <QCoreApplication>

#include "newspaper.h"
#include "reader.h"

int main(int argc, char *argv[]) {
QCoreApplication app(argc, argv);

Newspaper newspaper("Newspaper A");
Reader reader;
QObject::connect(&newspaper, &Newspaper::newPaper,
&reader, &Reader::receiveNewspaper);
newspaper.send();

return app.exec();
}

当我们运行上面的程序时,会看到终端输出 Receives Newspaper: Newspaper A 这样的字样。

首先看 Newspaper 这个类。这个类继承了QObject类。只有继承了 QObject 类的类,才具有信号槽的能力。所以,为了使用信号槽,必须继承 QObject

至于后面的 Q_OBJECT 宏:

凡是 QObject 类(不管是直接子类还是间接子类),都应该在第一行代码写上 Q_OBJECT。不管是不是使用信号槽,都应该添加这个宏。这个宏的展开将为我们的类提供信号槽机制、国际化机制以及 Qt 提供的不基于 C++ RTTI 的反射能力。因此,如果你觉得你的类不需要使用信号槽,就不添加这个宏,就是错误的。其它很多操作都会依赖于这个宏。注意,这个宏将由 moc(我们会在后面章节中介绍 moc。这里你可以将其理解为一种预处理器,是比 C++ 预处理器更早执行的预处理器。) 做特殊处理,不仅仅是宏展开这么简单。moc 会读取标记了 Q_OBJECT头文件,生成以 moc_ 为前缀的文件,比如 newspaper.h 将生成 moc_newspaper.cpp。你可以到构建目录查看这个文件,看看到底增加了什么内容。注意,由于 moc 只处理头文件中的标记了 Q_OBJECT 的类声明,不会处理 cpp 文件中的类似声明。因此,如果我们的 NewspaperReader 类位于 main.cpp 中,是无法得到 moc 的处理的。解决方法是,我们手动调用 moc 工具处理 main.cpp,并且将 main.cpp 中的 #include "newspaper.h" 改为 #include "moc_newspaper.h" 就可以了。不过,这是相当繁琐的步骤,为了避免这样修改,我们还是将其放在头文件中。许多初学者会遇到莫名其妙的错误,一加上 Q_OBJECT 就出错,很大一部分是因为没有注意到这个宏应该放在头文件中。

Newspaper 类的 publicprivate 代码块都比较简单,只不过它新加了一个 signalssignals 块所列出的,就是该类的信号。信号就是一个个的函数名,返回值是 void(因为无法获得信号的返回值,所以也就无需返回任何值),参数是该类需要让外界知道的数据。信号作为函数名,不需要在 cpp 函数中添加任何实现。

Newspaper 类的 send() 函数比较简单,只有一个语句 emit newPaper(m_name);emit 是 Qt 对 C++ 的扩展,是一个关键字(其实也是一个宏)。emit 的含义是发出,也就是发出 newPaper() 信号。感兴趣的接收者会关注这个信号,可能还需要知道是哪份报纸发出的信号?所以,我们将实际的报纸名字 m_name 当做参数传给这个信号。当接收者连接这个信号时,就可以通过槽函数获得实际值。这样就完成了数据从发出者到接收者的一个转移。

Reader 类更简单。因为这个类需要接受信号,所以我们将其继承了 QObject,并且添加了 Q_OBJECT宏。后面则是默认构造函数和一个普通的成员函数。Qt5 中,任何 成员函数static 函数全局函数Lambda 表达式 都可以作为槽函数。与信号函数不同,槽函数必须自己完成实现代码。槽函数就是普通的成员函数,因此作为成员函数,也会受到 publicprivate 等访问控制符的影响。(我们没有说信号也会受此影响,事实上,如果信号是 private 的,这个信号就不能在类的外面连接,也就没有任何意义。)

main() 函数中,我们首先创建了 NewspaperReader 两个对象,然后使用 QObject::connect() 函数。这个函数我们上一节已经详细介绍过,这里应该能够看出这个连接的含义。然后我们调用 Newspapersend() 函数。这个函数只有一个语句:发出信号。由于我们的连接,当这个信号发出时,自动调用 reader 的槽函数,打印出语句。

这样我们的示例程序讲解完毕。我们基于 Qt 的信号槽机制,不需要观察者的容器,不需要注册对象,就实现了观察者模式。

下面给出 Qt4 对应代码

nwespaper.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <QObject>
class Newspaper : public QObject {
Q_OBJECT
public:
Newspaper(const QString & name) : m_name(name) {}

void send() const {
emit newPaper(m_name);
}

signals:
void newPaper(const QString &name) const;

private:
QString m_name;
};

reader.h

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <QObject>
#include <QDebug>

class Reader : public QObject {
Q_OBJECT
public:
Reader() {}

public slots:
void receiveNewspaper(const QString & name) const {
qDebug() << "Receives Newspaper: " << name;
}
};

main.cc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <QCoreApplication>

#include "newspaper.h"
#include "reader.h"

int main(int argc, char *argv[]) {
QCoreApplication app(argc, argv);

Newspaper newspaper("Newspaper A");
Reader reader;
QObject::connect(&newspaper, SIGNAL(newPaper(QString)),
&reader, SLOT(receiveNewspaper(QString)));
newspaper.send();

return app.exec();
}

Newspaper 类没有什么区别。

Reader 类,receiveNewspaper() 函数放在了 public slots 块中。在 Qt4 中,槽函数必须放在由 slots 修饰的代码块中,并且要使用访问控制符进行访问控制。其原则同其它函数一样:默认是 private 的,如果要在外部访问,就应该是 public slots;如果只需要在子类访问,就应该是 protected slots

main() 函数中,QObject::connect() 函数,第二、第四个参数需要使用 SIGNALSLOT 这两个宏转换成字符串。注意 SIGNALSLOT 的宏参数并不是取函数指针,而是除去返回值的函数声明,并且 const 这种参数修饰符是忽略不计的。

下面说明另外一点,我们提到了“槽函数是普通的成员函数,作为成员函数,会受到 publicprivateprotected 的影响”,publicprivate 这些修饰符是供编译器在编译期检查的,因此其影响在于编译期。对于 Qt4 的信号槽连接语法,其连接是在运行时完成的,因此即便是 private 的槽函数也是可以作为槽进行连接的。但是,如果你使用了 Qt5 的新语法,新语法提供了编译期检查(取函数指针),因此取 private 函数的指针是不能通过编译的。

作者: 豆子

------ end ------