目录
概述
信号和槽用于对象之间的通信。信号和槽机制是Qt的核心特性,可能也是与其他框架提供的特性最不同的部分。信号和槽是由Qt的元对象系统实现的。
在GUI编程中,当我们更改一个小部件时,我们通常希望通知另一个小部件。更一般地说,我们希望任何类型的对象都能够相互通信。例如,如果用户单击Close按钮,我们可能希望调用窗口的Close()函数。
其他工具包使用回调实现这种通信。回调是一个指向函数的指针,所以如果你想让一个处理函数通知你一些事件,你可以把一个指向另一个函数(回调)的指针传递给处理函数。然后,处理函数在适当的时候调用回调。虽然确实存在使用此方法的成功框架,但回调可能不直观,并且可能在确保回调参数的类型正确性方面存在问题。
在Qt中,我们有一个回调技术的替代方案:我们使用信号和槽。当一个特定的事件发生时,就会发出一个信号。Qt的小部件有许多预定义的信号,但是我们总是可以子类化小部件,向它们添加我们自己的信号。槽是响应特定信号而调用的函数。Qt的小部件有许多预定义的槽,但是通常的做法是子类化小部件并添加您自己的插槽,这样您就可以处理您感兴趣的信号。
信号和槽机制是类型安全的:信号的签名必须与接收槽的签名匹配。(实际上,一个slot的签名可能比它接收到的信号短,因为它可以忽略额外的参数。)由于签名是兼容的,所以当使用基于函数指针的语法时,编译器可以帮助我们检测类型不匹配。基于字符串的SIGNAL和SLOT语法将在运行时检测类型不匹配。信号和槽是松耦合的:发出信号的类既不知道也不关心哪个槽接收到信号。Qt的信号和槽机制确保如果你将一个信号连接到一个槽,槽将在正确的时间用信号的参数调用。信号和槽可以接受任意数量的任何类型的参数。它们是完全类型安全的。
所有从QObject或其子类(例如,QWidget)继承的类都可以包含信号和槽。当对象以其他对象可能感兴趣的方式改变其状态时,就会发出信号。这就是对象所做的所有通信。它不知道也不关心是否有东西在接收它发出的信号。这是真正的信息封装,并确保对象可以用作软件组件。
槽可以用来接收信号,但也是普通的成员函数。就像一个对象不知道是否有任何东西接收到它的信号一样,一个槽也不知道是否有任何信号连接到它上面。这确保了真正独立的组件可以用Qt创建。
您可以将任意数量的信号连接到一个插槽,并且一个信号可以连接到任意数量的插槽。甚至可以将一个信号直接连接到另一个信号。(这将在第一个信号发出时立即发出第二个信号。)
信号和槽一起构成了一个强大的组件编程机制。
信号
当对象的内部状态以某种方式发生变化,对象的客户端或所有者可能会感兴趣时,对象就会发出信号。信号是公共访问函数,可以从任何地方发出,但我们建议只从定义信号及其子类的类发出信号。
当发出信号时,连接到它的槽通常立即执行,就像普通的函数调用一样。当这种情况发生时,信号和槽机制完全独立于任何GUI事件循环。一旦所有槽都返回,emit语句之后的代码就会执行。当使用queued连接时,情况略有不同;在这种情况下,emit关键字后面的代码将立即继续执行,而槽将稍后执行。
如果多个插槽连接到一个信号,则在信号发出时,这些插槽将按照它们连接的顺序依次执行。
信号是由moc自动生成的,不能在.cpp文件中实现。它们永远不能有返回类型(即使用void)。
关于参数的注意事项:我们的经验表明,如果信号和槽不使用特殊类型,它们的可重用性会更好。如果QScrollBar::valueChanged()使用一个特殊类型,比如假设的QScrollBar::Range,那么它只能连接到专门为QScrollBar设计的槽。将不同的输入部件连接在一起是不可能的。
槽
当一个连接到插槽的信号被发出时,就会调用这个插槽。Slots是普通的c++函数,可以正常调用;它们唯一的特殊之处在于信号可以与它们相连。
由于slot是普通的成员函数,因此在直接调用时遵循普通的c++规则。然而,作为插槽,它们可以被任何组件调用,而不管其访问级别如何,都可以通过信号插槽连接调用。这意味着从任意类的实例发出的信号可能导致在不相关类的实例中调用私有槽。
您还可以将插槽定义为虚拟的,我们发现这在实践中非常有用。
与回调相比,信号和槽稍微慢一些,因为它们提供了更大的灵活性,尽管对于实际应用程序来说差异并不大。一般来说,发出连接到某些插槽的信号比直接调用接收器(使用非虚拟函数调用)慢大约10倍。这是定位连接对象、安全地遍历所有连接(即检查后续接收器在发射期间没有被销毁)以及以通用方式编组任何参数所需的开销。虽然10个非虚函数调用听起来很多,但它的开销比任何new或delete操作都要少得多。只要在后台执行需要new或delete的字符串、向量或列表操作,信号和槽开销只占整个函数调用成本的很小一部分。当您在插槽中执行系统调用时也是如此;或者间接调用十多个函数。信号和插槽机制的简单性和灵活性是值得的,您的用户甚至不会注意到这些开销。
请注意,在与基于qt的应用程序一起编译时,定义称为信号或槽的变量的其他库可能会导致编译器警告和错误。要解决这个问题,请#undef有问题的预处理器符号。
一个小例子
一个最小的c++类声明可能是:
class Counter
{
public:
Counter() { m_value = 0; }
int value() const { return m_value; }
void setValue(int value);
private:
int m_value;
};
一个基于qobject的小类可能是这样的:
#include <QObject>
class Counter : public QObject
{
Q_OBJECT
public:
Counter() { m_value = 0; }
int value() const { return m_value; }
public slots:
void setValue(int value);
signals:
void valueChanged(int newValue);
private:
int m_value;
};
基于qobject的版本具有相同的内部状态,并提供访问状态的公共方法,但除此之外,它还支持使用信号和插槽进行组件编程。这个类可以通过发出一个信号valueChanged()来告诉外界它的状态发生了变化,并且它有一个槽,其他对象可以向它发送信号。
所有包含信号或槽的类必须在声明的顶部提到Q_OBJECT。它们还必须(直接或间接)派生自QObject。
插槽由应用程序程序员实现。下面是Counter::setValue()槽的一种可能实现:
void Counter::setValue(int value)
{
if (value != m_value) {
m_value = value;
emit valueChanged(value);
}
}
emit行从对象发出信号valueChanged(),并以新值作为参数。
在下面的代码片段中,我们创建了两个Counter对象,并使用QObject::connect()将第一个对象的valueChanged()信号连接到第二个对象的setValue()插槽:
Counter a, b;
QObject::connect(&a, &Counter::valueChanged,
&b, &Counter::setValue);
a.setValue(12); // a.value() == 12, b.value() == 12
b.setValue(48); // a.value() == 12, b.value() == 48
调用a.setValue(12)使a发出一个valueChanged(12)信号,b将在其setValue()槽中接收该信号,即调用b.setValue(12)。然后b发出相同的valueChanged()信号,但由于没有插槽连接到b的valueChanged()信号,因此该信号被忽略。
注意,setValue()函数只在value != m_value时设置值并发出信号。这可以防止循环连接情况下的无限循环(例如,如果b.valueChanged()连接到a.setValue())。
默认情况下,每建立一个连接,都会发出一个信号;对于重复的连接发出两个信号。您可以通过一个disconnect()调用中断所有这些连接。如果您传递Qt::UniqueConnection类型,则只有在它不是重复的情况下才会进行连接。如果已经有一个重复的信号(相同对象上相同槽的相同信号),连接将失败,connect将返回false。
这个例子说明了对象可以一起工作,而不需要知道彼此的任何信息。要启用这一点,只需要将对象连接在一起,这可以通过一些简单的QObject::connect()函数调用或ic的自动连接特性来实现。
一个真实的例子
下面是一个没有成员函数的简单小部件类的头部示例。目的是展示如何在自己的应用程序中利用信号和槽。
#ifndef LCDNUMBER_H
#define LCDNUMBER_H
#include <QFrame>
class LcdNumber : public QFrame
{
Q_OBJECT
LcdNumber通过QFrame和QWidget继承了QObject,后者拥有大部分信号槽知识。它有点类似于内置的QLCDNumber小部件。
Q_OBJECT宏由预处理器扩展,以声明几个由moc实现的成员函数;如果在“对LcdNumber的虚表的未定义引用”这几行中出现编译器错误,那么您可能忘记运行moc或在link命令中包含moc输出。
public:
LcdNumber(QWidget *parent = nullptr);
signals:
void overflow();
在类构造函数和公共成员之后,声明类信号。当要求LcdNumber类显示一个不可能的值时,它会发出一个信号overflow()。
如果你不关心溢出,或者你知道溢出不会发生,你可以忽略overflow()信号,即不将其连接到任何插槽。
另一方面,如果您想在号码溢出时调用两个不同的错误函数,只需将信号连接到两个不同的插槽。Qt将调用两者(按照它们连接的顺序)。
public slots:
void display(int num);
void display(double num);
void display(const QString &str);
void setHexMode();
void setDecMode();
void setOctMode();
void setBinMode();
void setSmallDecimalPoint(bool point);
};
#endif
槽是一个接收函数,用于获取其他小部件中状态变化的信息。如上面的代码所示,LcdNumber使用它来设置显示的号码。由于display()是类与程序其余部分接口的一部分,因此槽是公共的。
几个示例程序将QScrollBar的valueChanged()信号连接到display()插槽,因此LCD数字连续显示滚动条的值。
注意,display()是重载的;当您将信号连接到插槽时,Qt将选择合适的版本。对于回调,您必须找到五个不同的名称并自己跟踪类型。
带有默认参数的信号和槽
信号和槽的签名可能包含参数,参数可以有默认值。参考:void QObject::destroyed(QObject *obj = nullptr)
void destroyed(QObject* = nullptr);
当一个QObject被删除时,它会发出这个QObject::destroyed()信号。我们希望在任何可能有对已删除的QObject的悬空引用的地方捕获这个信号,这样我们就可以清除它。合适的插槽签名可能是:
void objectDestroyed(QObject* obj = nullptr);
为了将信号连接到插槽,我们使用QObject::connect()。有几种方法连接信号和插槽。第一种是使用函数指针:
connect(sender, &QObject::destroyed, this, &MyObject::objectDestroyed);
对函数指针使用QObject::connect()有几个优点。首先,它允许编译器检查信号的参数是否与插槽的参数兼容。如果需要,参数也可以由编译器隐式转换。
你也可以连接到函数函数或c++ 11 lambda:
connect(sender, &QObject::destroyed, this, [=](){ this->m_objects.remove(sender); });
在这两种情况下,我们在connect()调用中将其作为上下文提供。context对象提供了接收者应该在哪个线程中执行的信息。这一点很重要,因为提供上下文可以确保接收者在上下文线程中执行。
当发送者或上下文被销毁时,lambda将被断开连接。您应该注意,在发出信号时,仿函数内部使用的任何对象仍然是活动的。
将信号连接到插槽的另一种方法是使用QObject::connect()以及signal和slot宏。关于是否在SIGNAL()和SLOT()宏中包含参数(如果参数具有默认值)的规则是,传递给SIGNAL()宏的签名必须不少于传递给SLOT()宏的签名的参数。
所有这些都行得通:
connect(sender, SIGNAL(destroyed(QObject*)), this, SLOT(objectDestroyed(Qbject*)));
connect(sender, SIGNAL(destroyed(QObject*)), this, SLOT(objectDestroyed()));
connect(sender, SIGNAL(destroyed()), this, SLOT(objectDestroyed()));
但这个行不通:
connect(sender, SIGNAL(destroyed()), this, SLOT(objectDestroyed(QObject*)));
因为槽将期待信号不会发送的QObject。此连接将报告运行时错误。
注意,当使用QObject::connect()重载时,编译器不会检查信号和槽参数。
高级信号和插槽使用
对于可能需要信号发送方信息的情况,Qt提供了QObject::sender()函数,它返回一个指向发送信号的对象的指针。
Lambda表达式是将自定义参数传递给slot的方便方式:
connect(action, &QAction::triggered, engine,
[=]() { engine->processAction(action->text()); });
使用Qt与第三方信号和插槽
可以将Qt与第三方信号/插槽机制一起使用。您甚至可以在同一个项目中使用这两种机制。只需将以下行添加到qmake项目(.pro)文件中。
CONFIG += no_keywords
它告诉Qt不要定义moc关键字signals, slots和emit,因为这些名字会被第三方库使用,例如Boost。然后,要继续使用带有no_keywords标志的Qt信号和槽,只需将源代码中Qt moc关键字的所有使用替换为相应的Qt宏Q_SIGNALS(或Q_SIGNAL)、Q_SLOTS(或Q_SLOT)和Q_EMIT。
基于字符串的连接和基于仿函数的连接的区别
从Qt 5.0开始,Qt在c++中提供了两种不同的方式来编写信号槽连接:基于字符串的连接语法(SIGNAL、SLOT)和基于仿函数的连接语法。这两种语法各有利弊。下表总结了它们的区别。
String-based | Functor-based | |
类型检查在… | 运行时 | 编译时 |
可以执行隐式类型转换 | Y | |
可以连接信号到lambda表达式 | Y | |
可以将信号连接到具有比信号更多参数的槽(使用默认参数) | Y | |
可以连接c++函数到QML函数吗 | Y |
类型检查和隐式类型转换
基于字符串的连接在运行时通过比较字符串进行类型检查。这种方法有三个局限性:
- 连接错误只能在程序开始运行后检测到。
- 信号和槽之间不能进行隐式转换。
- 类型和名称空间无法解析。
限制2和3的存在是因为字符串比较器不能访问c++类型信息,所以它依赖于精确的字符串匹配。
相反,基于函函数的连接由编译器检查。编译器在编译时捕获错误,支持兼容类型之间的隐式转换,并识别同一类型的不同名称。
例如,只有基于仿函数的语法才能用于将携带int型的信号连接到接受double型的槽。QSlider保存一个整型值,而QDoubleSpinBox保存一个双精度值。下面的代码片段展示了如何保持它们同步:
auto slider = new QSlider(this);
auto doubleSpinBox = new QDoubleSpinBox(this);
// OK: The compiler can convert an int into a double
connect(slider, &QSlider::valueChanged,
doubleSpinBox, &QDoubleSpinBox::setValue);
// ERROR: The string table doesn't contain conversion information
connect(slider, SIGNAL(valueChanged(int)),
doubleSpinBox, SLOT(setValue(double)));
下面的示例说明了缺少名称解析。QAudioInput::stateChanged()是用一个类型为“QAudio::State”的参数声明的。因此,基于字符串的连接也必须指定“QAudio::State”,即使“State”已经可见。这个问题不适用于基于仿函数的连接,因为参数类型不是连接的一部分。
auto audioInput = new QAudioInput(QAudioFormat(), this);
auto widget = new QWidget(this);
// OK
connect(audioInput, SIGNAL(stateChanged(QAudio::State)),
widget, SLOT(show()));
// ERROR: The strings "State" and "QAudio::State" don't match
using namespace QAudio;
connect(audioInput, SIGNAL(stateChanged(State)),
widget, SLOT(show()));
// ...
连接到Lambda表达式
基于函子的连接语法可以将信号连接到c++ 11 lambda表达式,这是有效的内联槽。此特性不适用于基于字符串的语法。
在下面的例子中,TextSender类发出一个textCompleted()信号,该信号携带一个QString参数。下面是类声明:
class TextSender : public QWidget {
Q_OBJECT
QLineEdit *lineEdit;
QPushButton *button;
signals:
void textCompleted(const QString& text) const;
public:
TextSender(QWidget *parent = nullptr);
};
下面是当用户点击按钮时发出TextSender::textCompleted()的连接:
TextSender::TextSender(QWidget *parent) : QWidget(parent) {
lineEdit = new QLineEdit(this);
button = new QPushButton("Send", this);
connect(button, &QPushButton::clicked, [=] {
emit textCompleted(lineEdit->text());
});
// ...
}
在本例中,lambda函数使连接变得简单,即使QPushButton::clicked()和TextSender::textCompleted()具有不兼容的参数。相反,基于字符串的实现将需要额外的样板代码。
注意:基于仿函数的连接语法接受指向所有函数的指针,包括独立函数和常规成员函数。但是,为了可读性,信号应该只连接到插槽、lambda表达式和其他信号。
将c++对象连接到QML对象
基于字符串的语法可以将c++对象连接到QML对象,但基于仿函数的语法不能。这是因为QML类型是在运行时解析的,所以它们对c++编译器不可用。
在下面的示例中,单击QML对象使c++对象打印一条消息,反之亦然。下面是QML类型(在QmlGui.qml中):
Rectangle {
width: 100; height: 100
signal qmlSignal(string sentMsg)
function qmlSlot(receivedMsg) {
console.log("QML received: " + receivedMsg)
}
MouseArea {
anchors.fill: parent
onClicked: qmlSignal("Hello from QML!")
}
}
下面是c++类:
class CppGui : public QWidget {
Q_OBJECT
QPushButton *button;
signals:
void cppSignal(const QVariant& sentMsg) const;
public slots:
void cppSlot(const QString& receivedMsg) const {
qDebug() << "C++ received:" << receivedMsg;
}
public:
CppGui(QWidget *parent = nullptr) : QWidget(parent) {
button = new QPushButton("Click Me!", this);
connect(button, &QPushButton::clicked, [=] {
emit cppSignal("Hello from C++!");
});
}
};
这是建立信号槽连接的代码
auto cppObj = new CppGui(this);
auto quickWidget = new QQuickWidget(QUrl("QmlGui.qml"), this);
auto qmlObj = quickWidget->rootObject();
// Connect QML signal to C++ slot
connect(qmlObj, SIGNAL(qmlSignal(QString)),
cppObj, SLOT(cppSlot(QString)));
// Connect C++ signal to QML slot
connect(cppObj, SIGNAL(cppSignal(QVariant)),
qmlObj, SLOT(qmlSlot(QVariant)));
注意:QML中的所有JavaScript函数都采用var类型的参数,它映射到c++中的QVariant类型。
当点击QPushButton时,控制台显示“QML收到:“来自c++的Hello !””。同样地,当单击Rectangle时,控制台显示“c++ received: "Hello from QML!"”。
请参阅与c++中的QML对象交互(Overview - QML and C++ Integration | Qt QML 5.15.17)了解让c++对象与QML对象交互的其他方法。
使用槽位中的默认参数连接参数较少的信号
通常,只有当槽具有与信号相同(或更少)的参数数量,并且所有参数类型都兼容时,才能进行连接。
基于字符串的连接语法为该规则提供了一种变通方法:如果插槽具有默认参数,则可以从信号中省略这些参数。当信号发出的参数少于插槽时,Qt使用默认参数值运行插槽。
基于仿函数的连接不支持此特性。
假设有一个叫做DemoWidget的类,它的slot printNumber()有一个默认参数:
public slots:
void printNumber(int number = 42) {
qDebug() << "Lucky number" << number;
}
使用基于字符串的连接,DemoWidget::printNumber()可以连接到QApplication::aboutToQuit(),即使后者没有参数。基于仿函数的连接将产生编译时错误:
DemoWidget::DemoWidget(QWidget *parent) : QWidget(parent) {
// OK: printNumber() will be called with a default value of 42
connect(qApp, SIGNAL(aboutToQuit()),
this, SLOT(printNumber()));
// ERROR: Compiler requires compatible arguments
connect(qApp, &QCoreApplication::aboutToQuit,
this, &DemoWidget::printNumber);
}
要使用基于仿函数的语法解决此限制,请将信号连接到调用槽的lambda函数。请参阅上面的部分,连接到Lambda表达式。
选择的重载信号和插槽
使用基于字符串的语法,可以显式指定参数类型。因此,期望的重载信号或槽的实例是明确的。
相反,对于基于仿函数的语法,必须强制转换重载信号或槽,以告诉编译器使用哪个实例。
例如,QLCDNumber有三个版本的display()插槽:
QLCDNumber::display(int)
QLCDNumber::display(double)
QLCDNumber::display(QString)
要将int版本连接到QSlider::valueChanged(),有两种语法:
auto slider = new QSlider(this);
auto lcd = new QLCDNumber(this);
// String-based syntax
connect(slider, SIGNAL(valueChanged(int)),
lcd, SLOT(display(int)));
// Functor-based syntax, first alternative
connect(slider, &QSlider::valueChanged,
lcd, static_cast<void (QLCDNumber::*)(int)>(&QLCDNumber::display));
// Functor-based syntax, second alternative
void (QLCDNumber::*mySlot)(int) = &QLCDNumber::display;
connect(slider, &QSlider::valueChanged,
lcd, mySlot);
// Functor-based syntax, third alternative
connect(slider, &QSlider::valueChanged,
lcd, QOverload<int>::of(&QLCDNumber::display));
// Functor-based syntax, fourth alternative (requires C++14)
connect(slider, &QSlider::valueChanged,
lcd, qOverload<int>(&QLCDNumber::display));
Signals & Slots | Qt Core 5.15.17
Differences between String-Based and Functor-Based Connections | Qt 5.15