Qt学习(15)——元对象系统
Qt程序里元对象系统无处不在,元对象系统最主要的一个功能就是实现信号和槽,窗体和控件对象之间的沟通一般都使用信号和槽,这是非常核心的东西,在学习了这些基础之后就可以根据Qt的帮助文档自学。本节简要介绍Qt元对象系统,信号和槽机制,基本是从Qt文档翻译过来的,然后通过按钮弹窗示例学习一下信号和槽的简单应用。
1、元对象系统简介
在Qt助手的索引里面输入“The Meta-Object System”,就可以看到元对象系统的英文文档。现在将其主要内容描述如下:
Qt元对象系统实现了对象之间的通信机制——信号和槽,并提供了运行时类型信息和动态属性系统。元对象系统是Qt类库独有的功能,是Qt对标准C++的扩展,并且元对象系统本身也是由纯C++语言写成的,所以学好C++是必须的前提。使用元对象系统的前提需要三件事情:
- 直接或间接地以QObject为基类,这样才能利用元对象系统的功能,Qt的窗体和控件最顶层的基类都是QObject。
- 将Q_OBJECT放在类声明的私有段落,以启用元对象特性,如动态属性、信号和槽等。之前遇到的Q_OBJECT都是在类声明里面的第一行,没有加private字样,因为类声明默认就是私有的。
- 元对象编辑器(Meta-Object Compiler,moc)为每个QObject的子类提供必要的代码以实现元对象特性。
moc工具读取C++源码,找到一个或多个包含Q_OBJECt宏的类声明,然后生成额外的代码文件,如moc_widget.cpp,里面包含实现元对象系统的代码。生成的源文件可以包含在类原有的源文件里,如在widget.cpp里包含:
#include "moc_widget.cpp"
这种包含方式看起来比较别扭,Linux 上的开发工具 KDevelop 自动生成的代码是这么用的。第二种方式是编译链接时揉到一起,Qt Creator 生成的代码就是通过编译链接时,把 moc_widget.o 与其他目标文件链接到一起,这种方式不用改源代码,相对而言比较顺眼。
除了提供信号和槽机制用于对象之间的通信(这是主要任务),元对象系统还提供了更多的特性:
- QObject::metaObject( ) 函数返回当前类对象关联的元对象(meta-object)。
- QMetaObject::className( ) 函数返回当前对象的类名称字符串,而不需要 C++ 编译器原生的运行时类型信息(run-time type information,RTTI)支持。
- QObject::inherits( ) 函数判断当前对象是否从某个基类派生,判断某个基类是否位于从 QObject 到对象当前类的继承树上。
- QObject::tr() 和 QObject::trUtf8( ) 函数负责翻译国际化字符串,因为 Qt5 规定源文件字符编码是UTF-8,所以这两个函数现在功能是一样的。
- QObject::setProperty( ) 和 QObject::property( ) 函数用于动态设置和获取属性,都通过属性名称字符串来操作。
- QMetaObject::newInstance( ) 构建一个当前类的新实例对象。
元对象系统还提供了qobject_cast( )函数,可以对基于QObject的类对象进行转换,qobject_cast( )函数功能类似标准C++的dynamic_cast( )。当然qobject_cast( )的优势在于不需要编译器支持TTTI,而且跨动态链接库之间的转换也是可行的。简单地说,原本是派生类的对象指针,就可以转为基类对象指针来用(转换得到可用值),其他情况都会得到NULL指针。比如:
MyWidget是QWidget的派生类,并且类声明带有Q_OBJECT宏,新建一个对象:
QObject *obj = new MyWidget;
虽然obj是一个QObject *,但是它本质上是一个MyWidget对象指针,可以转成基类指针:
QWidget *widget = qobject_cast<QWidget *>(obj);
但是如果将MyWidget对象指针转成其他无关的类对象指针,就会失败:
QLabel *label = qobject_cast<QWidget *>(obj);
关于元对象系统的介绍就是这些,实现元对象系统的内幕代码在后面给出。
2、信号和槽
下面举例说明什么是信号和槽,比如叫外卖:
- 比如到午饭时间了,某宅男饿了——由不饿到饿,是一个状态的变化,肚子饿了就相当于是一个信号。谁都会饿的,每个人都可以发这类信号。注意信号只是一个空想,没东西吃是填不饱肚子的。饿了怎么办,准备叫外卖。
- 街上餐馆很多,都希望多做点生意,送外卖也是常事——做好饭送外卖就是槽函数。这个送外卖功能,餐馆一般都是有的,但谁来买送给谁,这个暂时定不了。如果餐馆饭做得好,但没人吃那也是不行的。
- 食客饿了(信号),餐馆有送饭服务(槽函数),二者怎么沟通呢?通常我们都是打电话,Qt 把这个过程叫信号和槽的关联(connect)。虽然我们每次叫外卖都要拨一长串号码,但 Qt 关联比我们打电话方便,它只需要将信号关联具体某家餐馆外卖服务一次,以后都是自动拨号的。Qt 对象的信号和槽关联好之后,源头只需要发个信号,叫一声“我饿了”,connect 函数会自动拨号,餐馆立刻就送餐过来。
信号和槽函数在进行关联的时候,二者的参数需要一致,不能我叫西红柿鸡蛋的盖浇饭,餐馆给送兰州拉面,那是不行的。多个对象的信号和槽函数在参数匹配的情况下,它们之间的关联可以是一对一,一对多(某吃货可以同时叫多个餐馆的饭),多对一(多个人可以同时订某家餐馆的饭),所以关联是比较自由的。本节只看简单的一对一关联。
3、按钮弹窗示例
本小节示例效果是这样的:按一下窗体里的按钮,发个信号“我饿了”,然后自动弹出个消息框,显示“叮咚!外卖已送达”。下面我们打开Qt Creator,新建一个 Qt Widgets Application 项目,按步骤填:
①项目名称设置为 hungry,创建路径如 E:QtProjectsch04,点击“下一步”;
②Kit Selection 里面选中 Select all kits,点击“下一步”;
③基类选择 QWidget,然后其他名字就用自动填的,点击“下一步”;
④项目管理界面不修改,直接点“完成”。
然后看到 Qt Creator 编辑模式里的 hungry 项目,我们打开 widget.ui,在 QtCreator 设计模式,拖一个“Push Button”到窗体中间位置,并修改该 pushButton 对象的 text 属性为:我饿了。看到类似如下图所示:
编辑好后按 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(” 字样的时候,编辑器会自动提示源头对象有哪些信号,这很方便:
也可以通过Qt的帮助文档查询QPushButton的资料。
对于第四个槽函数宏,也是类似的提示效果:
FoodIsComing 槽函数就是我们自己写的,也在自动提示列表之内,其他的槽函数可以查 QWidget 类的帮助文档。编写 connect函数的时候,需要注意括号嵌套的层数,因为括号比较多,如果末尾少了右括号,要注意补。
写好 connect 函数代码之后,叫外卖的第三步就完成了。我们点击 Qt Creator 左下角运行按钮,查看运行效果,并点击窗体的按钮测试一下:
点击一下“我饿了”按钮,消息框自动就弹出来。整个流程就是按钮发一个 clicked 信号,connect 将该信号关联了主窗体的FoodIsComing 槽函数,这个槽函数实现弹窗。信号和槽机制往简单了说就是上面三板斧。信号本身是个空想,它自己不干活的,真正干活的是槽函数,槽函数完整功能并提供服务,信号和槽通过 connect 关联,只需要关联一次,以后都会自动工作。
4、按钮弹窗自动关联示例
这里的自动关联是指不需要手动编写connect函数,通过自动命名槽函数的方式来编写代码。自动关联的要求是槽函数根据源头的对象名(指针)和其信号名称来命名,元对象系统可以实现剩下的自动connect功能。这对窗体设计是一种便利,如果我们窗体里拖了10个按钮,手动编写connect函数的话,就需要编写10个connect函数调用,比较麻烦。通过自动关联方式,这些connect函数代码全部可以省略了。我们只需要关注如何实现槽函数的功能即可。下面我们新建一个自动关联的项目,我们重新打开Qt Creator,点击菜单“文件”—>“新建文件或项目”,建立一个Qt Widgets Application,按步骤:
①项目名称填写 autoconn,创建路径 E:QtProjectsch04,点击“下一步”;
②Kit Selection 界面选中 Select all kits,点击“下一步”;
③类信息界面,基类选择 QWidget,其他的用自动命名的,点击“下一步”;
④项目管理界面不修改,点击“完成”。
在编辑模式,项目视图里打开界面文件 widget.ui ,进入图形界面设计模式,类似地拖一个按钮放到窗体中间,这次我们修改按钮的两个属性,将按钮对象的 objectName 设置为 hungryButton,将 text 属性设置为:我饿了。如图所示:
objectName 就是对象名称属性,设置为 hungryButton 之后,实际代码里对应的就是 ui->hungryButton 按钮指针。
编辑好属性之后,我们开始使用自动关联槽函数的方法,右击 hungryButton 按钮,点击右键菜单里的“转到槽 ...”,
会出现根据信号来定义槽函数的界面:
信号列表里面,第一列是信号的名称,第二列是该对象所属的类或基类名称,QPushButton 不仅有自己的信号,还拥有它的基类 QAbstractButton、再上层基类 QWidget、顶层基类 QObject 等定义的信号。(注意信号可以有各种参数,但不能有返回值,也就是说必须返回 void 类型。)选择第一个 clicked() 信号,然后点击 OK 。这样就自动添加了槽函数的声明和它的定义代码底板,QtCreator 会自动跳转到 widget.cpp 的 void Widget::on_hungryButton_clicked() 函数定义处:
Qt Creator 会自动添加槽函数,并且跳转到槽函数定义位置,方便程序员编辑。由于是自动关联的,这个槽函数名称也是自动生成的,这时候不要修改这个函数的名称,也不要改按钮的对象名称,这样才能保证自动关联有效。然后就可以在该槽函数里面添加我们需要的代码,修改之后,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 函数的声明,也是 Qt Creator 自动添加的,不需要修改,这里只是把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
Qt Creator 自动添加的槽函数声明是 private slots,这个私有槽函数也是可以正常运行的,主要是访问权限和继承权限与 public 类型不一样,其他的功能是相同的。
有 Qt Creator 自动生成的槽函数,就有了自动关联,我们实际只变了两行代码,就是开头的 #include <QMessageBox>和槽函数里的一句弹消息框。自动关联的方式大大简化了我们需要做的工作。现在我们只需要点击 Qt Creator 左下角的运行按钮就够了:
这里有一个疑问,没有手动关联的 connect 函数,信号和槽它们怎么工作的呢?
诀窍有两条,第一个是槽函数命名非常非常严格,必须按照如下规则来写:
void on_<object name>_<signal name>(<signal parameters>);
必须以 on_ 打头,接下来是对象名,对应例子的 hungryButton,再接一个下划线,最后是信号名和信号可能的参数。
上面示例的槽函数自动命名就是:
void on_hungryButton_clicked();
按照规则命名是第一步。
第二步是由uic和moc等工具自动完成的,在E:QtProjectsch04uild-autoconn-Desktop_Qt_5_5_1MinGW_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(140, 100, 75, 23)); retranslateUi(Widget); QMetaObject::connectSlotsByName(Widget); } // setupUi
connectSlotsByName 就是完成自动关联的函数,这是元对象系统包含的功能,根据对象名、信号名与 on_<object name>_<signal name>(<signal parameters>) 槽函数进行自动匹配关联,可以给程序员提供便利,省了许多 connect 函数调用的代码。后面会详细讲这些代码,这里只要先掌握自动关联大法就够了。
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 函数参数里的 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);
这里第二个位置是一个函数指针形式,第四个位置也是一个函数指针形式,信号里声明的参数和槽函数声明是一致的,所以关联是匹配的。
最后提醒一下,不管使用旧句式,还是新句式,关联的源头和接收端一定是实际存在的对象,ui->pushButton 这个按钮对象成功创建之后,上面的关联才能正常执行。虽然新句式可以用 &QPushButton::clicked 这个函数指针形式,但注意第一个和第三个参数是实际的对象,这是把源头对象关联到接收端对象,而不是把类关联到类!如果没有定义对象实体,关联函数就没意义。