信号与槽

信号(Signals)和槽(Slots)被用于在 Qt 对象之间通信。信号槽机制是 Qt 的核心特性,同时也可能是与其他框架的类似特性区别最大的一部分。信号槽使得 Qt 的元对象系统成为可能。

介绍

在 GUI 编程中,当我们修改某个控件后,我们通常希望另一个控件可以收到通知。更普遍地,我们希望任何类型的对象都可以和另一个对象进行通信。例如,如果用户点击了关闭按钮,我们可能希望该窗口的 close() 函数被调用。

其它开发工具中,使用回调来实现此类通信。回调是指函数指针——当您希望某个处理函数通知您某些事件时,通过传递一个函数指针(即回调)至该处理函数来实现,处理函数会在适合的时机调用这个回调。尽管许多优秀的框架的确在使用此方法,但回调依然是一种非常不直观的手段,而且可能会遭遇回调参数类型正确性校验等方面的问题。

信号槽

在 Qt 中,我们有回调技术的替代品:信号槽。信号会在特定事件发生时被发射;Qt 的控件包含大量预定义的信号,并且我们也可以编写控件的子类来为它们添加自定义的信号。是指会在响应对应信号时被自动调用的函数;Qt 的控件包含大量预定义的槽,并且,编写控件的子类来添加自定义槽也是常见的做法,这样您便可以处理感兴趣的信号。

Signals & Slots

信号槽机制是类型安全的:信号的函数签名必须与接收它的槽函数签名一致(事实上,槽可以具有比它接收的信号更短的签名,因为允许忽略尾部的额外参数)。鉴于函数签名需要兼容,当我们使用函数指针格式的 connect() 时,编译器可以帮助我们识别它们的参数类型中的不匹配。信号槽之间是松耦合关系:发射信号的类无需知晓也无需关心是哪个槽接收了这个信号。Qt 的信号槽机制确保了,如果将一个信号连接至一个槽,则槽会在正确的时机被调用,并传入信号所携带的参数。信号槽可以携带任意类型、任意个数的参数,它们是完全类型安全的。

所有继承自 QObject 或它的任意子类型(如 QWidget)的类都可以包含信号槽。对象在修改了可能会被其它对象感兴趣的状态时,会发射信号,但并不需要知晓或关心是否有人接收了这个信号。这是真正的信息封装,确保了该对象可以被当作软件中的一个组件所使用。

槽可被用于接收信号,但它们同时也是常规的成员函数。正如一个对象并不知道是否有人接收了它的信号,槽也不知道是否有信号连接至它。这确保了 Qt 可以创建互相之间完全独立的组件。

将任意多的信号可以被连接至同一个槽,同一个信号也可以根据需要连接至任意多个槽。甚至,将一个信号直接连接至另一个信号也是可行的(这会另第二个信号在第一个发射的同时被发射)。

两者结合后,信号槽构成了一种强大的组件化编程机制。

信号

当一个对象的内部状态被改变,同时它的使用者或所有者可能会感兴趣时,它会发射信号。信号是类的公共成员函数,可以从任何地方发射,但我们只推荐在定义这个信号的类和它的子类中发射。

当信号发射时,连接至它的槽通常会立刻执行,就如同常规的函数调用一样(即就地回调)。此时,信号槽机制的运行完全独立于(即不依赖)任何 GUI 事件循环,emit语句之后的代码会在所有槽函数返回后再继续执行。此场景与队列连接有细微的差别——在后者中,emit语句之后的代码会立即执行,而槽则会在随后才被调度。

若多个槽被连接至同一个信号,则当信号发射时,这些槽会按照连接的顺序依次执行。

信号会通过 moc 自动生成定义,不能在.cpp中手动编写定义。信号不具备返回类型(即void)。(译者注:事实上,非队列连接的信号可以具有返回类型,返回类型与槽返回类型相同,也可用 QVariant 接收任意类型返回,但此机制不保证会被后续版本继续支持。Qt 在 qobjectdefs_impl.h 中,通过 operator, 和 ApplyReturnValue 实现此机制,有兴趣的读者可自行查阅。

关于函数参数:我们的经验是,若信号槽参数不使用特殊类型,则可以具备更广的泛用性。如果 QScrollBar::valueChanged() 中使用了特殊类型,假设命名为QScrollBar::Range,则它只能被连接至专门为 QScrollBar 设计的槽函数上,而将不同的输入控件互相连接则是不可能的。

当连接至它的信号被发射时,槽会被调用。槽是常规的 C++ 函数,可以被正常调用;它的特殊性质只有被连接至信号时才会体现。

由于槽是常规的成员函数(译者注:也可是静态成员函数或常规的全局函数),当被直接调用时,它遵循标准的 C++ 规范。然而,作为槽,它们可以被任何组件通过信号槽机制调用,而无视它的作用域。这意味着从任意类的实例发射的信号,可以触发另一个非友元类实例中的私有槽。

您也可以将槽定义为虚函数,这在实践中被证明非常有用。

与回调相比,信号槽会稍微慢一些,因为它们提供了更多的灵活性,不过在实际应用中这些性能差异通常无关紧要。一般来说,发射一个连接至槽的信号,大约比直接调用接收者的函数慢上十倍(译者注:此处指函数调用的额外开销,并非函数执行总耗时),此处不考虑虚函数调用开销。这些开销被用于定位接收对象、线程安全地遍历所有连接(即检查接收者是否在发射过程中被销毁),并将所有参数转换为规范化的形式。虽然十倍非虚函数的调用开销听起来很高,但它其实比任何newdelete开销少得多。例如,当执行一个字符串、向量或链表操作,而它们在内部依赖newdelete时,信号槽开销在整个函数调用中只占据了一个非常小的比例。这同样适用于在槽中执行系统调用,或在其中间接调用了超过十个函数的场景。信号槽机制的简洁性和灵活性是值得付出这些开销的,并且您的用户甚至都无法感知到。

注意,若其它库定义了名为signalsslots的类型(译者注:包括类型名称、函数签名、宏定义等任何代码标识),将它们与 Qt 程序共同编译时会导致警告甚至错误。为修复此问题,使用#undef取消这些预编译符号的定义。(译者注:不推荐此方法,建议使用下文的在 Qt 中使用第三方的信号槽机制

一个小范例

若有一个最小化的 C++ 类定义,如下所示:

  1. class Counter
  2. {
  3. public:
  4. Counter() { m_value = 0; }
  5. int value() const { return m_value; }
  6. void setValue(int value);
  7. private:
  8. int m_value;
  9. };

则它对应的最小化的基于 QObject 的类型则为:

  1. #include <QObject>
  2. class Counter : public QObject
  3. {
  4. Q_OBJECT
  5. public:
  6. Counter() { m_value = 0; }
  7. int value() const { return m_value; }
  8. public slots:
  9. void setValue(int value);
  10. signals:
  11. void valueChanged(int newValue);
  12. private:
  13. int m_value;
  14. };

基于 QObject 的版本具备一些内部状态,并且提供了公共方法来获取这些状态,同时还具备使用信号槽机制的组件化编程支持。这个类可以通过发射valueChanged()信号来通知外界它的状态发生了改变,同时也拥有一个槽,让其它对象可以向其发送信号。

所有包含信号槽的类都必须在类的起始处声明 Q_OBJECT ,并且必须继承自(直接或间接) QObject 。(译者注:迄今所有版本中,QObject 均不支持虚继承和菱形继承,即每个类只能拥有唯一一个非虚继承的 QObject 基类

槽需要程序开发者提供定义,此处为Counter::setValue()槽的一个可能的定义:

  1. void Counter::setValue(int value)
  2. {
  3. if (value != m_value) {
  4. m_value = value;
  5. emit valueChanged(value);
  6. }
  7. }

emit一行从该对象发射valueChanged()信号,并携带新的值作为参数。

在下方代码片段中,我们创建了两个Counter对象,使用 QObject::connect() 将第一个对象的valueChanged()信号连接至第二个对象的setValue()槽。

  1. Counter a, b;
  2. QObject::connect(&a, &Counter::valueChanged,
  3. &b, &Counter::setValue);
  4. a.setValue(12); // a.value() == 12, b.value() == 12
  5. b.setValue(48); // a.value() == 12, b.value() == 48

调用a.setValue(12)会发射信号valueChanged(12),该信号被bsetValue()槽中接收,即调用b.setValue(12)。然后b会发射相同的valueChanged()信号,但由于没有槽被连接至bvalueChanged()信号,这个信号会被忽略。

注意,setValue()函数当且仅当value != m_value时才会修改数值并发射信号。这样避免了环形连接(如b.valueChanged()又被连接回a.setValue()时)时的无限循环。

默认下,每有一个连接,信号会被发射一次;若创建了两个连接,则信号会被发射两次。您可以使用一次 disconnect() 来断开所有连接。如果在连接时传递了 Qt::UniqueConnection 类型,则连接只会被创建一次而非多次。如果已经有存在的重复连接(即对象的相同的信号,被连接至相同对象的相同的槽),则新连接会失败并返回false

本范例说明了,对象之间无需了解对彼此的任何信息,便可共同协作。为实现此目的,对象们只需要通过一些简单的 connect() 调用连接至彼此,或通过 uic自动连接特性完成。

真实范例

下文为一个简单的控件类的头文件范例,不包含成员函数。此代码目为演示如何在您自己的应用中利用信号槽。

  1. #ifndef LCDNUMBER_H
  2. #define LCDNUMBER_H
  3. #include <QFrame>
  4. class LcdNumber : public QFrame
  5. {
  6. Q_OBJECT

LcdNumber通过 QFrameQWidget,间接继承自 QObject,后者提供了绝大部分信号槽特性。这和内置的 QLCDNumber 控件有部分相似之处。

会被编译器展开为一些成员函数的声明,以供moc实现;如果您遇到形如“无法解析的外部符号:LcdNumber”(undefined reference to vtable for LcdNumber)的编译错误,那么您可能忘记执行moc,或忘记将moc输出包含到链接指令中。(译者注:对于 qmake 工程,将包含 Q_OBJECT 的头文件添加到 .pro 文件的 HEADERS 变量中,即可自动执行 moc 并链接其输出

  1. public:
  2. LcdNumber(QWidget *parent = nullptr);
  3. signals:
  4. void overflow();

在类构造函数和公共成员之后,我们声明该类的信号。当被要求显示不可能的数值时,LcdNumber类会发射信号overflow()

如果不关心越界,或者知道不会发生越界,那么可以忽略overflow()信号,即不将它连接至任何槽。

另一方面,如果您想在数值溢出时调用两个不同的错误处理函数,只需简单地将信号连接至两个不同的槽。Qt 会确保它们都被调用(按照连接的顺序)。

  1. public slots:
  2. void display(int num);
  3. void display(double num);
  4. void display(const QString &str);
  5. void setHexMode();
  6. void setDecMode();
  7. void setOctMode();
  8. void setBinMode();
  9. void setSmallDecimalPoint(bool point);
  10. };
  11. #endif

槽是指接收信号的函数,用于获取其它控件状态改变的信息。如上述代码所示,LcdNumber使用槽来设置被显示的数字。由于display()同时也是该类在程序中的接口之一,这个槽是公共(public)的。

范例程序将 QScrollBarvalueChanged() 信号连接至此处的display()槽,于是 LCD 数字会持续显示为滚动条的数值。

注意,display()函数被重载;当连接信号至这个槽时,Qt 会选取适合的版本。在回调时,您需要自行查询多个不同的名称并跟踪它们的类型。

信号槽与默认参数

信号槽可以包含默认参数,即具有默认值的参数。如 QObject::destroyed():

  1. void destroyed(QObject* = nullptr);

当一个 QObject 对象被删除时,它会发射 QObject::destroyed() 信号。我们可以捕获此信号,此时我们可能仍持有被删除的 QObject 对象的悬空引用,则可以将其清理掉。一个对应的槽函数签名可能为:

  1. void objectDestroyed(QObject* obj = nullptr);

为了将该信号连接至此槽,我们使用 QObject::connect()。有多种方式可以连接信号槽,首先是使用函数指针:

  1. connect(sender, &QObject::destroyed, this, &MyObject::objectDestroyed);

使用函数指针来执行 QObject::connect() 有诸多优点。首先,这允许编译器检查信号的参数是否与槽的参数相匹配;同时,如果有必要,编译器可以对参数进行隐式转换(译者注:如将信号的 int 参数转换为槽的 double 参数)。

您也可以使用仿函数(functor)或 C++11 的匿名函数(lambda):

  1. connect(sender, &QObject::destroyed, this, [=](){ this->m_objects.remove(sender); });

在这两种场景中,我们使用this作为connect()调用中的上下文对象。上下文对象指明了接收函数应该在哪个线程被执行。这非常重要,因为它提供了上下文,以确保接收者可以在上下文所处的线程中被执行。

仿函数/匿名函数可以在发送者或上下文对象被销毁时断开连接。您需要注意确保仿函数/匿名函数中用到的所有对象,在信号发射时保持可用。

另一种连接信号槽的方法,是通过SIGNALSLOT宏使用 QObject::connect()。若参数列表中包含默认值,则SIGNAL()SLOT()中是否包含该参数的规则是,传递给SIGNAL()宏的参数列表必须不少于传递给SLOT()宏的参数列表。

以下方式都可以生效:

  1. connect(sender, SIGNAL(destroyed(QObject*)), this, SLOT(objectDestroyed(Qbject*)));
  2. connect(sender, SIGNAL(destroyed(QObject*)), this, SLOT(objectDestroyed()));
  3. connect(sender, SIGNAL(destroyed()), this, SLOT(objectDestroyed()));

但下面这个无法生效:

  1. connect(sender, SIGNAL(destroyed()), this, SLOT(objectDestroyed(QObject*)));

——因为槽希望获得一个 QObject 对象,但信号并不会发送它。这个连接会在运行时汇报一条错误信息。

注意,使用这个 QObject::connect() 重载时,信号和槽的参数并不会被编译器进行检查。(译者注:即使用 SIGNAL() / SLOT() 的连接方式

信号槽的进阶应用

对于想要获取信号发送方信息的的场景,Qt 提供了 QObject::sender() 函数,该函数返回发送信号的对象的指针。

匿名表达式(lambda)可以用更简单的方法来将自定义参数传递至槽(译者注:使用捕获列表):

  1. connect(action, &QAction::triggered, engine,
  2. [=]() { engine->processAction(action->text()); });

在 Qt 中使用第三方的信号槽机制

Qt 中可以使用第三方的信号槽机制。您可以在项目中同时使用这两种机制,只需要将下述代码添加至您的 qmake 工程文件(.pro)中:

  1. CONFIG += no_keywords

它告诉 Qt 不要定义 moc 关键字signalsslotsemit,因为这些名称会被三方库所使用,如 Boost。在no_keyword标识下继续使用 Qt 信号槽时,只需简单地将代码中的 Qt moc 关键字替换为对应的 Qt 宏 Q_SIGNALS(或 Q_SIGNAL)、Q_SLOTS(或 Q_SLOT),以及 Q_EMIT

另请参阅: QLCDNumberQObject::connect()、Digital Clock ExampleTetrix ExampleMeta-Object_System,以及 Qt 属性系统