4.1 元对象系统

Qt 程序里元对象系统无处不在,元对象系统最主要的一个功能就是实现信号和槽,窗体和控件对象之间的沟通一般都使用信号和槽,这是非常核心的东西,在学习了这些基础知识以后, 就可以根据 Qt 帮助文档自学成才。本节先简要介绍 Qt 元对象系统、信号和槽机制,基本是从 Qt 文档翻译过来的,然后通过按钮弹窗示例学习一下信号和槽的简单使用。

4.1.1 元对象系统简介

在 Qt 助手的索引里面输入“The Meta-Object System”,就可以看到元对象系统的英文文档。现在将其主要的内容描述如下:
Qt 元对象系统实现了对象之间通信机制——信号和槽,并提供了运行时类型信息和动态属性系统。元对象系统是 Qt 类库独有的功能,是 Qt 对标准 C++ 的扩展,并且元对象系统本身也是由纯 C++ 语言写成的,所以学好 C++ 是必须的。
使用元对象系统的前提是需要三件事情:
moc 工具读取 C++ 源码,找到一个或多个包含 Q_OBJECT 宏的类声明,然后生成额外的代码文件,如 moc_widget.cpp ,里面包含实现元对象系统的代码。生成的源码文件可以包含在类原有的源文件里,如在 widget.cpp 里包含:
#include "moc_widget.cpp"
这种包含方式看起来比较别扭,Linux 上的开发工具 KDevelop 自动生成的代码是这么用的。
第二种方式是编译链接时揉到一起,QtCreator 生成的代码就是通过编译链接时,把 moc_widget.o 与其他目标文件链接到一起,这种方式不用改源代码,相对而言比较顺眼。

除了提供信号和槽机制用于对象之间的通信(这是主要任务),元对象系统还提供了更多的特性:
元对象系统还提供了 qobject_cast() 函数,可以对基于 QObject 的类对象进行转换,qobject_cast() 函数功能类似标准 C++ 的 dynamic_cast()。当然 qobject_cast() 的优势在于不需要编译器支持 RTTI,而且跨动态链接库之间的转换也是可行的。简单地说,原本是派生类的对象指针,就可以转为基类对象指针来用(转换得到可用值),其他情况都会得到 NULL 指针。比如:
MyWidget 是 QWidget 的派生类,并且类声明带有 Q_OBJECT 宏,新建一个对象:
QObject *obj = new MyWidget;
虽然 obj 是一个 QObject *,但它本质是一个 MyWidget 对象指针,可以转成基类指针:
QWidget *widget = qobject_cast<QWidget *>(obj);

但是如果将 MyWidget 对象指针转成其他无关的类对象指针,就会失败:
 QLabel *label = qobject_cast<QLabel *>(obj);
label的数值就是 NULL。

关于元对象系统介绍就是这些,实现元对象系统的内幕代码在最后一节讲解,本章主要是学会怎么用信号和槽机制。

4.1.2 信号和槽

下面举叫外卖的例子来说明什么是信号和槽,比如:
信号和槽函数在进行关联的时候,二者的参数需要一致,不能我叫西红柿鸡蛋的盖浇饭,餐馆给送兰州拉面,那是不行的。多个对象的信号和槽函数在参数匹配 的情况下,它 们之间的关联可以是一对一,一对多(某吃货可以同时叫多个餐馆的饭),多对一(多个人可以同时订某家餐馆的饭),所以关联是比较自由的。本节只看简单的一对一关 联,4.2 节再看复杂的。

4.1.3 按钮弹窗示例

本小节示例效果是这样的:按一下窗体里的按钮,发个信号“我饿了”,然后自动弹出个消息框,显示“叮咚!外卖已送达”。下面我们打开 QtCreator,新建一个 Qt Widgets Application 项目,按步骤填:
①项目名称设置为 hungry,创建路径如 D:\QtProjects\ch04,点击“下一步”;
②Kit Selection 里面选中 Select all kits,点击“下一步”;
③基类选择 QWidget,然后其他名字就用自动填的,点击“下一步”;
④项目管理界面不修改,直接点“完成”。
然后看到 QtCreator 编辑模式里的 hungry 项目,我们打开 widget.ui,在 QtCreator 设计模式,拖一个“Push Button”到窗体中间位置,并修改该 pushButton 对象的 text 属性为:我饿了。看到类似如下图所示:
ui design
编辑好后按 Ctrl+S 保存,然后关闭 widget.ui 。窗体里的控件对象 pushButton 对象自己带有 clicked 信号,当我们用鼠标点击按钮时,clicked 信号自动触发,所以不需要我们定义新的信号。使用按钮自己的信号就够用了,现在信号就已经有了,我们完成了叫外卖的第 一步。

回到代码编辑模式,打开头文件 widget.h ,向其中添加槽函数声明,下面把该头文件内容完整贴出来:
#ifndef WIDGET_H
#define WIDGET_H

#include <QWidget>

namespace Ui {
class Widget;
}

class Widget : public QWidget
{
    Q_OBJECT

public:
    explicit Widget(QWidget *parent = 0);
    ~Widget();

//添加这一段代码
public slots:       //槽函数声明标志
    void FoodIsComing();    //槽函数

private:
    Ui::Widget *ui;
};

#endif // WIDGET_H
Qt 的关键字 slots 就是槽函数声明段落的标志,槽函数声明段落可以是 private、protected 或者 public 类型的,这些访问权限和继承权限与普通成员函数是一样的,上面示范的是公有槽函数。除了声明段落标志不一样,槽函数与普通成员函数其他情况都是一样的,也可以作为普通成员 函数来调用,当然也必须有函数定义的实体代码,头文件里仅仅是声明。我们打开 widget.cpp 文件,添加头文件包含 和 槽函数定义代码:
#include "widget.h"
#include "ui_widget.h"
#include <QMessageBox>

Widget::Widget(QWidget *parent) :
    QWidget(parent),
    ui(new Ui::Widget)
{
    ui->setupUi(this);

}

Widget::~Widget()
{
    delete ui;
}

//槽函数定义代码,与普通成员函数类似
void Widget::FoodIsComing()
{
    QMessageBox::information(this, tr("送餐"), tr("叮咚!外卖已送达"));
}
在槽函数定义代码部分,FoodIsComing 函数与普通成员函数没区别,只有在头文件才能看到它是不是在槽函数声明段落。新包含的头文件 <QMessageBox> 是声明了用于弹消息框的类。QMessageBox 类可以按照常规定义对象方式使用,如:
QMessageBox msgBox;
msgBox.setWindowTitle("Take out")
msgBox.setText("Food is coming.");
msgBox.exec();
setWindowTitle 函数是设置消息框标题,setText 是设置要显示的消息。exec 函数是指模态显示,消息框会弹出并显示在最上层,如果不关闭该消息框,就不会回到正常界面。
常规方式需要上面四句代码,而这类消息框内容格式相对单调,是可以做成预定义的消息框供程序员直接调用的。QMessageBox 类提供了静态公有函数,里面预定义好了现成的消息框,只需把参数传给它,就会自动弹窗。我们 FoodIsComing 函数里使用的就是静态函数 QMessageBox::information ,它的声明如下:
StandardButton QMessageBox::​information(QWidget * parent, const QString & title, const QString & text, StandardButtons buttons = Ok, StandardButton defaultButton = NoButton)
StandardButton 是 Qt 预定义的按钮类型枚举,比如 QMessageBox::Ok、QMessageBox::Cancel 等等,可以为消息框添加这些按钮,并且返回按钮枚举值。我们这里只用了头三个参数:父窗口指针、消息框标题、消息框内容。后面的 buttons 参数可以为消息框添加额外按钮,defaultButton 是指默认按钮,我们暂时用不着,先不管这些。返回值就是按钮类型的枚举值,可以获知用户是点击哪个按钮使消息框关闭的。

FoodIsComing 槽函数的声明和定义都按照上面写好之后,我们就完成了叫外卖的第二步。

下面第三步是将 pushButton 的信号 clicked (即“我饿了”)与 主窗口的槽函数 FoodIsComing 关联起来,实现自动拨号叫外卖。Qt 通过 QObject::​connect 函数完成信号和槽函数的关联,因为主窗口最顶层的基类是 QObject,所以我们下面代码不需要加 QObject:: 前缀。我们向 widget.cpp 文件构造函数里新增一句关联函数代码,完整的 widget.cpp 代码如下:
#include "widget.h"
#include "ui_widget.h"
#include <QMessageBox>

Widget::Widget(QWidget *parent) :
    QWidget(parent),
    ui(new Ui::Widget)
{
    ui->setupUi(this);

    //添加关联代码,必须放在 setupUi 函数之后
    connect(ui->pushButton, SIGNAL(clicked()), this, SLOT(FoodIsComing()));
}

Widget::~Widget()
{
    delete ui;
}

//槽函数定义代码,与普通成员函数类似
void Widget::FoodIsComing()
{
    QMessageBox::information(this, tr("送餐"), tr("叮咚!外卖已送达"));
}
connect 函数第一个参数是发信号的源头对象指针,按钮对象的指针就是 ui->pushButton,ui 是为窗体构建界面的辅助类对象指针,我们在窗体设计界面拖的控件对象都存在这个 ui 指向的对象里。ui->pushButton 就指向我们之前拖的按钮对象。因为通过设计模式拖的控件全部是以指针类型访问的,所以以后说到窗体里的控件,一般都是说它的指针名字。
第二个参数用 SIGNAL 宏包裹,里面是按钮对象的信号 clicked() ,信号的声明和成员函数类似,但必须放在 signals 声明段落。上面没看到 signals 声明段落是因为 QPushButton 类的对象自带这个信号,不需要我们来定义。
第三个参数是接收对象的指针,也就是服务提供方,是槽函数所在对象的指针,我们上面用的 this 指针就是主窗体自己。
第四个参数是接收对象里的槽函数,并用 SLOT 宏封装起来。

connect 函数意义是非常清晰的,将源头和源头的信号,关联到接收端和接收端的槽函数。注意源头和接收端必须是存在的实体对象指针,不能是野指针。connect 函数必须放在 ui->setupUi 之后,否则控件指针是未定义的野指针,那种关联必然失败,会导致程序崩溃。

编写 connect 函数代码的时候,对于第二个参数,我们敲好 “SIGNAL(” 字样的时候,编辑器会自动提示源头对象有哪些信号,这很方便:
SIGNAL
也可以通过 Qt 帮助文档查询 QPushButton 的资料。
对于第四个槽函数宏,也是类似的提示效果:
SLOT
FoodIsComing 槽函数就是我们自己写的,也在自动提示列表之内,其他的槽函数可以查 QWidget 类的帮助文档。编写 connect 函数的时候,需要注意括号嵌套的层数,因为括号比较多,如果末尾少了右括号,要注意补。

写好 connect 函数代码之后,叫外卖的第三步就完成了。我们点击 QtCreator 左下角运行按钮,查看运行效果,并点击窗体的按钮测试一下:
click
点击一下“我饿了”按钮,消息框自动就弹出来。整个流程就是按钮发一个 clicked 信号,connect 将该信号关联了主窗体的FoodIsComing 槽函数,这个槽函数实现弹窗。信号和槽机制往简单了说就是上面三板斧。信号本身是个空想,它自己不干活的,真正干活的是槽函数,槽函数完整功能并提供服务,信号和 槽通过 connect 关联,只需要关联一次,以后都会自动工作。

4.1.4 按钮弹窗自动关联示例

这里的自动关联是指不需要手动编写 connect 函数,通过自动命名槽函数的方式来编写代码。自动关联的要求是槽函数根据源头的对象名(指针)和其信号名称来命名,元对象系统可以实现剩下的自动 connect 功能。这对窗体设计是一种便利,如果我们窗体里拖了 10 个按钮,手动编写 connect 函数的话,就需要编 10 个 connect 函数调用,比较麻烦的。通过自动关联方式,这些 connect 函数代码全可以省了。我们只需要关注如何实现槽函数的功能即可。下面我们新建一个自动关联的项目,我们重新打开 QtCreator,点击菜单“文件”--> “新建文件或项目”,建立一个 Qt Widgets Application,按步骤:
①项目名称填写 autoconn,创建路径 D:\QtProjects\ch04,点击“下一步”;
②Kit Selection 界面选中 Select all kits,点击“下一步”;
③类信息界面,基类选择 QWidget,其他的用自动命名的,点击“下一步”;
④项目管理界面不修改,点击“完成”。
在编辑模式,项目视图里打开界面文件 widget.ui ,进入图形界面设计模式,类似地拖一个按钮放到窗体中间,这次我们修改按钮的两个属性,将按钮对象的 objectName 设置为 hungryButton,将 text 属性设置为:我饿了。如图所示:
hungryButton
objectName 就是对象名称属性,设置为 hungryButton 之后,实际代码里对应的就是 ui->hungryButton 按钮指针。

编辑好属性之后,我们开始使用自动关联槽函数的方法,右击 hungryButton 按钮,点击右键菜单里的“转到槽 ...”,
to slots
会出现根据信号来定义槽函数的界面:
slots list
信号列表里面,第一列是信号的名称,第二列是该对象所属的类或基类名称,QPushButton 不仅有自己的信号,还拥有它的基类 QAbstractButton、再上层基类 QWidget、顶层基类 QObject 等定义的信号。(注意信号可以有各种参数,但不能有返回值,也就是说必 须返回 void 类型。)
选择第一个 clicked() 信号,然后点击 OK 。

这样就自动添加了槽函数的声明和它的定义代码底板,QtCreator 会自动跳转到 widget.cpp 的 void Widget::on_hungryButton_clicked() 函数定义处:
autoslot
QtCreator 会自动添加槽函数,并且跳转到槽函数定义位置,方便程序员编辑。由于是自动关联的,这个槽函数名称也是自动生成的,这时候不要修改这个函数的名 称,也不要改按钮的对象名称,这样才能保证自动关联有效。
然后就可以在该槽函数里面添加我们需要的代码,修改之后,widget.cpp 完整内容如下:
#include "widget.h"
#include "ui_widget.h"
#include <QMessageBox>

Widget::Widget(QWidget *parent) :
    QWidget(parent),
    ui(new Ui::Widget)
{
    ui->setupUi(this);
}

Widget::~Widget()
{
    delete ui;
}

void Widget::on_hungryButton_clicked()
{
    QMessageBox::information(this, tr("送餐"), tr("叮咚!外卖已送达"));
}
Widget 构造函数里没有 connect 函数调用,因为不需要了,就是这么简单。

widget.h 里面有 on_hungryButton_clicked 函数的声明,也是 QtCreator 自动添加的,不需要修改,这里只是把 widget.h 代码贴出来给大家看看:
#ifndef WIDGET_H
#define WIDGET_H

#include <QWidget>

namespace Ui {
class Widget;
}

class Widget : public QWidget
{
    Q_OBJECT

public:
    explicit Widget(QWidget *parent = 0);
    ~Widget();

private slots:
    void on_hungryButton_clicked();
    
private:
    Ui::Widget *ui;
};

#endif // WIDGET_H
QtCreator 自动添加的槽函数声明是 private slots,这个私有槽函数也是可以正常运行的,主要是访问权限和继承权限与 public 类型不一样,其他的功能是相同的。

有 QtCreator 自动生成的槽函数,有了自动关联,我们实际只遍了两行代码,就是开头的 #include <QMessageBox>和槽函数里的一句弹消息框。自动关联的方式大大简化了我们需要做的工作。现在我们只需要点击 QtCreator 左下角的运行按钮就够了:
autoconn

这里有一个疑问,没有手动关联的 connect 函数,信号和槽它们怎么工作的呢?
诀窍有两条,第一个是槽函数命名非常严格,必须按照如下规则来写:
void on_<object name>_<signal name>(<signal parameters>);
必须以 on_  打头,接下来是对象名,对应例子的 hungryButton,再接一个下划线,最后是信号名和信号可能的参数。
上面示例的槽函数自动命名就是:
void on_hungryButton_clicked();
按照规则命名是第一步。

第二步是由 uic 和 moc 等工具自动完成的,在 D:\QtProjects\ch04\build-autoconn-Desktop_Qt_5_4_0_MinGW_32bit-Debug 文件夹里可以找到 ui_widget.h,最关键的就是 setupUi 函数末尾一句:
    void setupUi(QWidget *Widget)
    {
        if (Widget->objectName().isEmpty())
            Widget->setObjectName(QStringLiteral("Widget"));
        Widget->resize(400, 300);
        hungryButton = new QPushButton(Widget);
        hungryButton->setObjectName(QStringLiteral("hungryButton"));
        hungryButton->setGeometry(QRect(160, 100, 75, 23));

        retranslateUi(Widget);

        QMetaObject::connectSlotsByName(Widget);
    } // setupUi
connectSlotsByName 就是完成自动关联的函数,这是元对象系统包含的功能,根据对象名、信号名与 on_<object name>_<signal name>(<signal parameters>) 槽函数进行自动匹配关联,可以给程序员提供便利,省了许多 connect 函数调用的代码。后面 4.5 节还会再详细讲这些代码,本节学会用自动关联大法就够了。

4.1.5 关联函数的语法格式

虽然 Qt 有比较好用的自动关联大法,但自动关联不是万能的,尤其是涉及到多个窗体的时候,比如 A 窗体私有按钮控件与 B 窗体私有消息框函数,这个因为权限限制,不是想自动关联就可以自动关联的。自动关联一般用于一个窗体之内的控件关联,其他很多情况都是需要手动编写 connect 函数的,所以学习 connect 函数的语法句式是必须的。

上面展示的 connect 关联是传统语法句式,如本节第一个例子:
connect(ui->pushButton, SIGNAL(clicked()), this, SLOT(FoodIsComing()));
头两个参数是源头对象和信号,后两个参数接收对象和槽函数,这个其实是 Qt4 和之前版本一直存在的句式,在 Qt5 中也是好使的,这种句式可读性很好,信号和槽的标识也很清晰。这种关联方式其实是旧式语法,它的函数声明为:
QMetaObject::Connection QObject::​connect(const QObject * sender, const char * signal, const QObject * receiver, const char * method, Qt::ConnectionType type = Qt::AutoConnection)
connect 函数返回类型是 QMetaObject::Connection ,返回值就是代表信号和槽连接关系的对象,可以用于运行时判断关联是否正确,或者用于解除关联。每次调用 connect 函数都会生成新的连接对象,注意不要对同样的信号和槽重复调用 connect 函数,那样会生成多个不同的连接对象,导致一次信号会触发多次同样的槽函数。
注意到 connect 函数参数里的 signal 和 method(槽函数)都是 char * 字符串类型,所以旧式语法的 connect 函数是根据信号和槽函数的字符串名称来关联的,不具备编译时类型检查,大家都是字符串,参数类型在编译时都不知道。关联出错只有在运行时才会体现。
最后的关联类型参数一般我们都使用默认值 Qt::AutoConnection,这在多线程编程的时候才会有讲究。对于单线程的,关联一般用直连类型(Qt::DirectConnection),信号一触发, 对应槽函数立即就被调用执行;对于多线程程序,跨线程的关联一般用入队关联(Qt::QueuedConnection),信号触发后,跨线程的槽函数被加入事件 处理队列里面执行,避免干扰接收线程里的执行流程。Qt::AutoConnection 会自动根据源头对象和接收对象所属的线程来处理,默认都用这种类型的关联,对于多线程程序这种关联也是安全的。

Qt5 为了能够尽早检查关联类型和参数的匹配情况,引入了新的函数指针关联语法,这样在程序编译时就能发现关联正确与否。新式语法格式如下:
QMetaObject::Connection QObject::​connect(const QObject * sender, PointerToMemberFunction signal, const QObject * receiver, PointerToMemberFunction method, Qt::ConnectionType type = Qt::AutoConnection)
与旧语法句式区别就在于 signal 和 method (槽函数),新句式用的是 PointerToMemberFunction ,这个类型名称是不存在的,只是在文档里面显示,方便程序员看的,实际使用的是模板函数。具体模板函数定义比较复杂,比较关键的就是两个函数参数类型需要一致,或者信号声 明时的参数更多更广。因为信号本身是不干活的,它多点参数无所谓,但必须保证槽函数运行时需要的参数是一定有的。

新的语法句式应用到第一个例子就是如下面这样:
connect(ui->pushButton, &QPushButton::clicked, this, &Widget::FoodIsComing);
这里第二个位置是一个函数指针形式,第四个位置也是一个函数指针形式,信号里声明的参数和槽函数声明是一致的,所以关联是匹配的。
使用 connect 函数时,如果接收端是 this 指针代表的对象,那么语法可以简化,省掉 this 指针的参数:
connect(ui->pushButton, SIGNAL(clicked()),  SLOT(FoodIsComing()));   //旧式语法
新式语法也可以省掉 this 指针参数:
connect(ui->pushButton, &QPushButton::clicked,  &Widget::FoodIsComing);  //新式语法

最后提醒一下,不管使用旧句式,还是新句式,关联的源头和接收端一定是实际存在的对象,ui->pushButton 这个按钮对象成功创建之后,上面的关联才能正常执行。虽然新句式可以用  &QPushButton::clicked 这个函数指针形式,但注意这是把源头对象关联到接收端对象,而不是把类关联到类!如果没有定义对象实体,关联函数就没意义。

tip 练习
① 将第一个示例中的 connect 函数修改为:
connect(ui->pushButton, SIGNAL(clicked456()), this, SLOT(FoodIsComing()));
编译运行例子看看效果,运行时注意看输出面板里打印的信息。
② 将第一个示例中的旧式关联替换成新式关联:
connect(ui->pushButton, &QPushButton::clicked, this, &Widget::FoodIsComing);
编译运行例子查看效果。
③ 将新式语法句子里的 clicked 修改成 clicked456,看看还能否编译成功。


prev
contents
next