简介
在图形用户界面编程中,当我们更改一个部件时,往往希望通知另一个部件。一般来说,我们希望任何类型的对象都能相互通信。例如,如果用户点击Close 按钮,我们可能希望窗口的close() 函数被调用。
其他工具包使用回调来实现这种通信。回调是指向函数的指针,因此,如果您想让处理函数通知您某个事件,您可以将指向另一个函数(回调)的指针传递给处理函数。处理函数会在适当的时候调用回调函数。虽然使用这种方法的成功框架确实存在,但回调可能并不直观,而且在确保回调参数的类型正确性方面可能存在问题。
信号和插槽
在 Qt 中,我们有回调技术的替代方法:我们使用信号和插槽。当一个特定事件发生时,就会发出一个信号。Qt 的 Widget 有许多预定义的信号,但我们也可以通过子类化 Widget 来添加自己的信号。槽是响应特定信号而调用的函数。Qt 的 widget 有许多预定义的槽,但通常的做法是对 widget 进行子类化并添加自己的槽,这样就可以处理自己感兴趣的信号了。
信号和槽机制是类型安全的:信号的签名必须与接收槽的签名一致。(事实上,槽的签名可能比它接收的信号短,因为它可以忽略额外的参数)。由于签名是兼容的,编译器可以帮助我们在使用基于函数指针的语法时检测到类型不匹配。基于字符串的 SIGNAL 和 SLOT 语法可以在运行时检测到类型不匹配。信号和槽是松散耦合的:发出信号的类既不知道也不关心哪些槽接收信号。Qt 的信号和插槽机制确保,如果将信号连接到插槽,插槽将在正确的时间被调用信号的参数。信号和槽可以接受任意数量、任意类型的参数。它们是完全类型安全的。
所有继承自QObject 或其子类(如QWidget )的类都可以包含信号和槽。当对象以其他对象可能感兴趣的方式改变其状态时,就会发出信号。这就是对象进行通信的全部内容。它不知道也不关心是否有任何东西接收到了它发出的信号。这是真正的信息封装,可确保对象作为软件组件使用。
槽可用于接收信号,但它们也是正常的成员函数。正如对象不知道是否有任何东西接收它的信号一样,槽也不知道是否有任何信号连接到它。这就确保了可以用 Qt 创建真正独立的组件。
您可以将任意多的信号连接到一个插槽,一个信号也可以连接到任意多的插槽。甚至可以将一个信号直接连接到另一个信号。(每当第一个信号发出时,第二个信号就会立即发出)。
信号和插槽共同构成了强大的组件编程机制。
信号
当对象的内部状态发生某种变化,而对象的客户端或所有者可能对此感兴趣时,对象就会发出信号。信号是公共访问函数,可以从任何地方发出,但我们建议只从定义信号的类及其子类发出。
信号发出后,与之相连的槽通常会立即执行,就像普通函数调用一样。在这种情况下,信号和槽机制完全独立于任何图形用户界面事件循环。一旦所有插槽都返回,就会执行emit 语句后面的代码。使用queued connections 时,情况略有不同;在这种情况下,emit 关键字后面的代码将立即继续执行,而插槽将在稍后执行。
如果多个插槽与一个信号相连,则在信号发出时,这些插槽将按照连接的顺序一个接一个地执行。
信号由moc自动生成,不得在.cpp 文件中执行。
关于参数的说明:我们的经验表明,如果信号和槽不使用特殊类型,它们的可重用性会更高。如果QScrollBar::valueChanged() 使用了特殊类型,如假设的 QScrollBar::Range,那么它只能连接到专门为QScrollBar 设计的槽。将不同的输入部件连接在一起是不可能的。
插槽
当与槽相连的信号发出时,槽就会被调用。槽是普通的 C++ 函数,可以正常调用;它们唯一的特点是可以连接信号。
由于槽是普通的成员函数,它们在直接调用时遵循普通的 C++ 规则。不过,作为插槽,任何组件都可以通过信号-插槽连接调用它们,无论其访问级别如何。这就意味着,任意类的实例发出的信号都可以导致一个无关类的实例调用一个私有槽。
您还可以将槽定义为虚拟槽,我们发现这在实践中非常有用。
与回调相比,信号和槽会稍微慢一些,因为它们提供了更大的灵活性,不过对于实际应用来说,两者之间的差异并不明显。一般来说,发出一个与某些插槽相连的信号要比直接调用非虚拟函数调用的接收器慢十倍左右。这是定位连接对象、安全地遍历所有连接(即检查后续接收器是否在信号发射过程中被销毁)以及以通用方式调用任何参数所需的开销。虽然十次非虚拟函数调用听起来似乎很多,但这比任何new 或delete 操作的开销都要小得多。只要执行的字符串、向量或列表操作在后台需要new 或delete ,信号和插槽开销就只占整个函数调用成本的很小一部分。在槽中进行系统调用或间接调用十多个函数时,情况也是如此。信号和插槽机制的简洁性和灵活性非常值得,用户甚至不会注意到这些开销。
请注意,定义名为signals 或slots 变量的其他库在与基于 Qt XML 的应用程序一起编译时,可能会引起编译器警告和错误。要解决这个问题,#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
class Counter : public QObject
{
Q_OBJECT
// Note. The Q_OBJECT macro starts a private section.
// To declare public members, use the 'public:' access modifier.
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() 信号没有连接到任何槽,因此该信号会被忽略。
请注意,只有当value != m_value 时,setValue() 函数才会设置值并发出信号。这样可以防止在循环连接的情况下出现无限循环(例如,如果b.valueChanged() 连接到a.setValue() )。
默认情况下,您建立的每一个连接都会发出一个信号;重复连接会发出两个信号。您只需调用一次disconnect() 就能中断所有这些连接。如果通过Qt::UniqueConnection type ,则只有在不重复的情况下才会建立连接。如果已经存在重复连接(相同对象上相同插槽的相同信号),连接将失败,connect 将返回false 。
本例说明,对象之间无需了解任何信息即可协同工作。要做到这一点,只需将对象连接在一起,这可以通过一些简单的QObject::connect() 函数调用或uic 的自动连接功能来实现。
真实示例
下面是一个不含成员函数的简单 widget 类的标题示例。目的是展示如何在自己的应用程序中使用信号和槽。
#ifndef LCDNUMBER_H
#define LCDNUMBER_H
#include
class LcdNumber : public QFrame
{
Q_OBJECT
LcdNumber 它通过QFrame 和QWidget 继承了QObject ,后者拥有大部分信号槽知识。它与内置的QLCDNumber widget 有些相似。
预处理器对Q_OBJECT 宏进行了扩展,以声明几个由moc 实现的成员函数;如果编译时出现 "undefined reference to vtable forLcdNumber"之类的错误,可能是忘记运行 moc或在链接命令中包含 moc 输出。
public:
LcdNumber(QWidget *parent = nullptr);
signals:
void overflow();
在类构造函数和public 成员之后,我们声明类signals 。当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 XML 会选择相应的版本。如果使用回调,您必须找到五个不同的名称,并自己跟踪类型。
连接重载信号和槽
当信号或槽被重载时(有多个不同参数的版本),需要使用函数指针语法明确指定要连接的版本。您可以使用qOverload() 或static_cast 来区分:
// Connect to the int overload of QComboBox::currentIndexChanged(int)
connect(comboBox, qOverload
this, &MyClass::handleIndexChanged);
// Or select QLCDNumber::display(int) when connecting from QSlider::valueChanged(int)
connect(slider, &QSlider::valueChanged,
lcd, qOverload
// Using static_cast (more verbose):
connect(comboBox, static_cast
this, &MyClass::handleIndexChanged);
// Or using a lambda to call the correct overload:
connect(slider, &QSlider::valueChanged,
this, [lcd](int value) { lcd->display(value); });
自动连接管理
Qt 会自动管理QObject 派生类型之间连接的生命周期。当发送方或接收方对象被销毁时,连接会自动移除,从而防止调用已删除的对象。这既适用于函数指针语法,也适用于基于字符串的 SIGNAL/SLOT 语法。
对于 lambda 连接,请提供一个上下文对象(通常为this ),以确保 lambda 在上下文被销毁时断开连接:
connect(button, &QPushButton::clicked, this, [this]{ handleClick(); });
虽然 Qt XML 可防止向完全销毁的对象发送信号,但在派生类的析构函数结束后但到达~QObject 之前的对象销毁过程中,信号仍有可能被发送。这一限制特别适用于使用函数指针语法建立的连接。当基类的析构函数发出已经被销毁的派生类所连接的信号时,就会出现这种情况。如果这可能会给您的类带来问题,请考虑在析构函数中显式地断开此类信号。
带有默认参数的信号和插槽
信号和槽的签名可能包含参数,而参数可以有默认值。请考虑QObject::destroyed():
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 lambdas:
connect(sender, &QObject::destroyed, this, [=](){ this->m_objects.remove(sender); });
在这两种情况下,我们都会在调用 connect() 时提供this 作为上下文。上下文对象提供了接收器应在哪个线程中执行的信息。这一点非常重要,因为提供上下文可以确保接收器在上下文线程中执行。
当发送者或上下文被销毁时,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 XML 提供了QObject::sender() 函数,该函数返回指向发送信号的对象的指针。
Lambda 表达式是向槽传递自定义参数的便捷方法:
connect(action, &QAction::triggered, engine,
[=]() { engine->processAction(action->text()); });
与第三方信号和槽一起使用 Qt
可以将 Qt 与第三方信号/插槽机制结合使用。您甚至可以在同一个项目中同时使用这两种机制。为此,请在 CMake 项目文件中写入以下内容:
target_compile_definitions(my_app PRIVATE QT_NO_KEYWORDS)
在 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 的库中的信号和插槽
基于 Qt 的库的公共 API 应使用关键字Q_SIGNALS 和Q_SLOTS ,而不是signals 和slots 。否则很难在定义了QT_NO_KEYWORDS 的项目中使用这样的库。
为执行这一限制,库创建者可在构建库时设置预处理器定义QT_NO_SIGNALS_SLOTS_KEYWORDS 。
该定义排除了信号和槽,但不影响在库实现中使用其他 Qt 特有的关键字。