QT核心机制3:信号与槽

写在前面

这篇文章基本是对Qt官方文档某些章节的翻译理解了,翻译这些章节的原因是我认为这些是Qt中最核心的东西,翻译的过程也就是强迫自己认真去读它们的过程,我不会完全一字一句的照搬原文,而是按我自己的理解去翻译其中的重点,毕竟我的目的是理解它们,将它们按自己可以灵活使用的方式组织,而不是机械的把它们从一种语言转变成另一种语言。涉及的官方文档原文内容主要包括以下章节:

  1. The Meta-Object System 元对象系统
  2. The Property System 属性系统
  3. Signals & Slots 信号与槽

在这里约定,对原文的翻译用正常字体,个人的理解使用斜体字体。还有就是请原谅我在文中混用了函数和方法,在这里它们是一个意思。

总共分为三篇文章,本文章为对Signals & Slots 信号与槽的翻译。

所有三篇翻译的链接:

QT核心机制1:元对象系统 – CodingLover

QT核心机制2:属性系统 – CodingLover

QT核心机制3:信号与槽 – CodingLover

信号与槽

信号与槽用于对象之间的通讯。信号与槽机制是Qt的核心特性,也是与其他框架最大的不同之处。Qt的元对象系统使得信号与槽机制得以实现。

引言

在GUI编程中,我们往往希望当一个控件(widget)被用户改变时,另一个控件收到通知。更进一步说,我们希望任何类型的对象之间可以彼此进行通讯。举个例子,当用户点击关闭按钮控件,我们可能希望窗口控件的close()方法被调用。

有些工具以回调函数的方式实现这种通讯。回调是一种指向函数的指针,如果我们希望一个正在运行的函数在发生事件时通知我们,那么就可以向正在运行的函数传入一个指向其他函数的指针(这个函数称为回调函数)。运行中的函数会在恰当的时机调用我们的回调函数。虽然确实存在使用这种方法的成功框架,但回调可能不直观(阅读代码时最怕碰到函数指针,在代码比较复杂时你很难找出这个函数指针在运行时到底指向哪些函数,但是这也确实是最容易实现的通知机制,在开发单片机时,如果不使用RTOS,这是最常用的通知机制,只是使用时需要多加小心),而且在确保回调参数的类型正确性方面可能会遇到问题。

信号与槽

在Qt中,我们使用了信号与槽机制代替了回调机制。信号将会在特定的事件出现时被发出。Qt的控件预定义了很多信号,当然我们也可以继承这些控件以定义自己的子类,然后添加自己的信号。槽是在响应特定信号时会被调用的方法。Qt的控件存在很多预定义的槽,但通常的做法是继承控件以生成自己的子类,然后添加自己的槽,这样我们就可以自行处理感兴趣的信号。image-20220529152344902

上图演示了信号与槽之间是如何连接的,我们可以看到一个信号可以对应多个槽,其实一个槽也可以连接多个信号。

信号与槽机制是类型安全的:信号的参数必须与槽的参数相匹配。(实际上一个槽函数可能会具有比信号更少的参数,这意味着忽略了一些额外的参数。)由于参数需要是兼容的,在我们使用基于函数指针的语法时,编译器就可以在编译阶段帮我们检查参数是否匹配了。而基于字符串的信号与槽语法则会在运行时进行检查。信号和插槽是松散耦合的:发出信号的类既不知道也不关心哪个插槽接收到信号。Qt的信号和插槽机制确保,如果您将一个信号连接到一个插槽,插槽将在正确的时间调用信号的参数。信号和槽可以接受任意数量的任意类型的参数。它们是完全类型安全的。

所有从QObject或它的一个子类(例如,QWidget)继承的类都可以包含信号和槽。当对象以其他对象可能感兴趣的方式改变其状态时,就会发出信号。这就是对象所做的所有通信。它不知道或不关心是否有任何东西正在接收它发出的信号。这是真正的信息封装,并确保对象可以作为软件组件使用。

槽可以用来接收信号,但它们也是普通的成员函数。就像对象不知道谁会接收到它的信号一样,插槽也不知道自己会被连接到哪个信号。这确保了可以用Qt创建真正独立的组件。、

我们可以将任意数量的信号连接到单个插槽,并且一个信号可以连接到任意数量的插槽。甚至可以将一个信号直接连接到另一个信号。(这将在第一个信号发出时立即发出第二个信号。)

信号和插槽一起构成了一个强大的组件编程机制。

信号(Signals)

当对象的内部状态以某种方式发生变化时,对象的客户或所有者可能会感兴趣,该对象就会发出信号。信号是公共访问函数(public的),可以从任何地方发出,但我们建议只从定义信号及其子类的类中发出信号(只建议在定义了信号的类中发出信号,或者从继承了此类的子类中发出信号)

当发出一个信号时,连接到它的插槽通常会立即执行,就像普通的函数调用一样。当发生这种情况时,信号和插槽机制完全独立于任何GUI事件循环。一旦所有插槽都返回,就会执行emit语句后面的代码。当使用排队连接(queued connections,可以简单理解为使用一个队列缓需要执行的槽函数,这样就可以延迟执行了,有点像linux中使用异步方法进行文件IO)时,情况略有不同;在这种情况下,emit关键字后面的代码将立即继续,稍后将执行插槽。(一般如果直接连接,槽函数会立刻在接收者的线程中执行,而使用排队连接,槽函数会在接收信号的对象线程开始执行时执行。)

如果多个插槽连接到一个信号,当信号发出时,这些插槽将按照它们连接的顺序依次执行。信号是由moc(元对象编译器)自动生成的,不能在.cpp文件中实现。它们永远不能有返回类型(即使用void)。

关于参数的提示:经验表明,如果信号和插槽不使用特殊类型,它们更易于重用。如果QScrollBar::valueChanged()使用一种特殊类型,例如假设的QScrollBar::Range,它只能连接到专门为QScrollBar设计的插槽。将不同的输入部件连接在一起是不可能的。这意味着在设计信号与槽时,使用更通用的参数类型(比如常见的基本类型)可以增加它们的可复用性。

槽(Slots)

槽将会在连接到的信号发出时被调用。槽其实就是正常的C++方法,因此也可以被正常调用,唯一的不同就是它们可以连接到信号。

由于slot是普通的成员函数,当直接调用时,它们遵循普通的c++规则。但是,作为插槽,它们可以通过信号插槽连接被任何组件调用,而不管其访问级别如何。这意味着从任意类的实例发出的信号可能导致在不相关类的实例中调用私有槽。就算一个槽声明为private,当它连接到信号,发出信号的类也可以正常调用这个槽,这有点像将一个类的private的成员变量导出为属性以使得其他类的实例可以访问它。

我们还可以将槽定义为虚函数,这在实践中非常有用。

与回调相比,信号和槽机制提供了更多灵活性,因此在效率上会稍微慢一点,但是实际应用中基本难以感知到。总的来说,以发送信号的方式来调用连接的槽,会比直接调用槽函数慢十倍左右,这是在槽函数不是虚函数的情况下。多出的开销用于定位连接到对象,安全遍历所有连接(例如检查接收信号的对象在发送信号时是否被销毁),以及以通用方式整理任何参数。虽然10个非虚函数调用听起来很多,但它比任何new或delete操作的开销要小得多。例如,一旦您执行一个字符串、向量或列表(String,vector,list)操作,而这些操作在幕后需要new或delete,信号和插槽开销只占整个函数调用成本的很小一部分。每当您在一个插槽中进行系统调用时,情况也是如此;或者间接调用10个以上的函数。信号和插槽机制的简单性和灵活性值得这样的开销,用户甚至不会注意到这一点。

需要注意,在与基于qt的应用程序一起编译时,其他定义了称为signals或slots的变量的库可能会导致编译器警告和错误。要解决这个问题,#undef产生问题的预处理器符号。

一个简单的例子

一个最基本的C++类声明可能长这样:

class Counter
{
    publicf:
        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); //值改变信号,一把在执行setValue时emit发送出去

private:
    int m_value;
};

基于QObject类的版本与基本版本相比,增加了槽函数与信号,这使得其可以在值改变时发送信号通知其他对象,或者接收其他对象的信号并调用槽函数setValue更新内部值。我们会发现这形成了一个信号链,其他信号触发setValue槽函数,setValue槽函数执行时会发出valueChanged信号,这样其他连接到这个信号的实例的槽汉书又会被执行。

所有包含信号或插槽的类必须在其声明的顶部引用Q_OBJECT。它们还必须(直接或间接)派生自QObject。

槽函数又应用程序的编写人员实现,如下是一个可能的Counter::setValue槽函数实现:

void Counter::setValue(int value)
{
    if (value != m_value) //只在值真的更新时才发出信号,这样可以节省不必要的开销
    {
        m_value = value;
        emit valueChanged(value);
    }
}

上面函数只在值真的更新时才发出信号,这样可以节省不必要的开销同时避免两个实例相互连接时的无限循环调用。这也提醒我们,信号应该在值真的发生改变时发出,而不是只要调用了函数就发出信号。

在接下来的代码片段中,我们创建了两个Counter对象,然后使用connect函数将第一个对象的valueChanged()信号连接到第二个对象的setValue()槽函数。

Counter a, b;
QObect::connect(&a, &Counter::valueChanged, &b, &Counter::setValue);

a.setValue(12); //此时a和b的值都被更新为12,a先更新自己然后发出信号,b收到信号后也更新自己
b.setValue(48); //此时a和b的值都被更新为48,b先更新自己然后发出信号,a收到信号后也更新自己

以a.setValue(12)为例,执行过程为:a的值setValue改变为12->a发出valueChanged信号->信号触发b的setValue将b的值设置为12。

注意,这里信号只有在值发生改变时发出,这样可以防止a和b的信号与槽相互连接的情况下,发生无限死循环。(如果我们在发送信号时不判断值是否真的改变,在上面例子中就会导致如下情况:a实例setValue->a发出valueChanged信号->b收到a的valueChanged信号->b实例setValue->b发出valueChanged信号->a收到b的valueChanged信号->a实例setValue,情况回到了第一步,然后会一直循环下去。)

默认情况下,对于您建立的每一个连接,都会发出一个信号;对于重复的连接,会发出两个信号。您可以通过一个单独的disconnect()调用来中断所有这些连接。如果您在调用connect()函数时传递了Qt::UniqueConnection到type参数,则只有在它不是重复的情况下才会建立连接。如果已经有一个重复的(完全相同的信号到相同对象的完全相同的槽),连接将失败,connect将返回false。

以上例子说明了对象可以协同工作,而不需要知道彼此的任何信息。要实现这一点,只需要将对象连接在一起,这可以通过一些简单的QObject::connect()函数调用或uic(User Interface Compiler,用户接口编译器,暂时没接触过)的自动连接特性来实现。

一个实际场景的例子

以下是一个简单的不含有成员方法的控件类的开头部分。主要是为了展示如何在自己的应用程序中使用信号与槽机制。

#ifndef LCDNUMBER_H
#define LCDNUMBER_H

#include <QFrame>

class LcdNumber : public QFrame
{
    Q_OBJECT

LcdNumber类通过继承QFrame和QWidget的形式继承了QObject,其使用了信号与槽的大部分内容。它类似于内置的QLCDNumber控件。

Q_OBJECT宏由预处理器扩展,以声明由moc实现的几个成员函数;如果你得到的编译器错误是“undefined reference to vtable for LcdNumber”,你可能忘记运行moc或在link命令中包含moc输出。(在使用Qt的集成开发环境时,我们基本不会碰到这个问题)。

public:
    LcdNumber(QWidget *parent = nullptr);

signals:
    void overflow();

如上,我们在类构造函数和public成员之后声明类的信号。当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()是类与程序其余部分的接口的一部分,所以槽是公共的(pulic)。

有几个示例程序将QScrollBar控件的valueChanged()信号连接到LcdNumber的display()槽,因此LCD显示的数字会随滚动条的变化而变化。

需要注意display()方法是重载的(有好几个实现);在连接信号时,Qt将会选择合适的版本。如果是使用回调,我们就必须自己判断该连接哪个版本的函数到函数指针。

使用默认参数的信号和槽

信号和槽的参数列表可能带有参数,同时参数具有默认值,biru下面的QObject::destroyed():

void destroyed(QObject* = nullptr);

当一个QObject被删除时,它会发出这个QObject::destroyed()信号。我们想要捕获这个信号,这样我们就可以在其销毁时清除所有对它的引用(因为实例已销毁,引用也就无效了,我们需要清理这些引用,否则访问无效的引用会导致问题)。合适的槽函数参数列表可以如下:

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); });

在以上两种调用方法中,我们都借助this来定位当发出信号时应该在哪个接收者的上下文中执行槽函数,换句话说,槽函数将会在this指向的实例的线程中执行。

对于lambda作为槽函数的场合,lambda函数将会在发送实例销毁或指定的执行上下文销毁时断开连接,应该注意,在发出信号时,lambda中使用的任何对象都仍然是活动的。(这句话的意思我自己不是特别明白,The lambda will be disconnected when the sender or context is destroyed. You should take care that any objects used inside the functor are still alive when the signal is emitted.原文是如此)。

另一种将信号连接到插槽的方法是使用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。此连接将报告运行时错误。牢记,在借助SIGNAL和SLOT宏使用这个QObject::connect()重载时,编译器不会检查signal和slot参数。

总结一下,也就是说connect函数有两种使用方法(两个重载):使用函数指针连接信号与槽,或者使用SIGNAL和SLOT宏以字符串形式连接信号与槽;前者可以在编译阶段就检查信号与槽的参数是否兼容,并且在存在槽函数重载时自动确定该使用哪个版本的槽函数,但是如果信号存在重载,使用这种方式是无法确定槽应该对应信号的哪个版本的,这时就只能使用第二种基于SIGNAL和SLOT宏的字符串连接形式;后者需要在运行时才能检查信号与槽是否兼容,但是可以用于信号存在多个重载的场合,这时需要用户自己指定信号与槽的参数列表。

信号和槽的进阶用法

对于需要信号发送方信息的情况,Qt提供了QObject::sender()函数,它返回一个指向发送信号的对象的指针。

lambda表达式是一种便捷的方法,可以用于传递用户自定义参数到一个槽:

// 使用lambda表达式我们可以直接访问到发送者action,这样就可以不使用QObject::sender()函数了
connect(action, &QAction::triggered, engine, [=]() { engine->processAction(action->text()); });

在Qt中使用第三方提供的信号与槽机制也是可以的,我们甚至可以在同一个项目中同时使用这两种机制(Qt自带的信号与槽机制和第三方提供的信号与槽机制)。只需将以下行添加到qmake项目(.pro)文件中。

CONFIG += no_keywords

它告诉Qt不要定义moc关键字signals, slots, and emit,因为这些名称将被第三方库使用,例如Boost。然后,如果要在带有no_keywords标志的情况下使用Qt自己的信号和插槽,只需将源代码中的所有Qt moc关键字替换为相应的Qt宏Q_SIGNALS(或Q_SIGNAL)、Q_SLOTS(或Q_SLOT)和Q_EMIT。

发表评论

您的电子邮箱地址不会被公开。