深入浅出解析Qt creator的ui文件原理及PIMPL设计

在Qt creator中,可以使用Qt Designer(Qt设计师)来快速设计界面,只需拖放就可以设计并快速浏览样式,并且可以生成代码,替代了用代码设计界面的工作。主要是生成了ui文件代替了用代码生成界面。那么这个过程是如何实现的呢?

以下是个简单的例子。新建了一个项目名,类名叫HelloDialog,派生自QDialog。在对话框上添加了一个按钮和一个文本标签。如下所示:
在这里插入图片描述
点击构建按钮会生成ui文件,各个文件内容如下:

1.解析hellodialog.h文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#ifndef HELLODIALOG_H
#define HELLODIALOG_H

#include <QDialog>

namespace Ui {
class HelloDialog;
}

class HelloDialog : public QDialog
{
    Q_OBJECT

public:
    explicit HelloDialog(QWidget *parent = nullptr);
    ~HelloDialog();

private:
    Ui::HelloDialog *ui;
};

#endif // HELLODIALOG_H

在hellodialog.h头文件里定义了一个类HelloDialog,继承自QDialog。这个类中有一个指向Ui::HelloDialog类型的私有变量。Ui::HelloDialog是什么东西呢?看HelloDialog类前边,告诉了它是命名空间Ui中定义的一个类,这叫做前置声明。Ui::HelloDialog这个类是干嘛的呢,就是设计界面上各种部件的类,位于命名空间Ui中,他和我们定义的类名字都叫做HelloDialog,但不是同一个东西

1.1 为什么要用Ui::HelloDialog *ui指针?

用Ui::HelloDialog ui,并且加上include ,即包含其所对应的头文件,是否可以?

不幸的是,这样便在我们编写的类和Ui::HelloDialog类形成了编译依存关系。如果界面有改动(即ui_hellodialog.h)任何改变,那么包含ui_hellodialog.h的文件都得重新编译。任何用到我们定义的HelloDialog类对象的文件也都要重新编译。造成一连串的重新编译。

也许你会很奇怪,为何非要重新编译?==因为C++要在编译时就要确定内存的大小。==如果在运行时才确定内存就会影响效率。如果修改了类的头文件变了(例如增加了成员),那么会导致该类占用的内存变化,那么用到该类对象的文件占用内存大小也要变化,所以编译器只好把这些文件全部编译一遍,从而重新确定所需内存大小。

1.2 为什么要用前置声明?

如果没有前置声明,编译器编译到Ui::HelloDialog *ui这里时,不知道Ui::HelloDialog是什么东西,就会报错。加上前置声明之后,所声明的HelloDialog(namespace Ui中的那个类)叫做不完全类型。就是说看到这个地方,我们只知道HelloDialog他是一个类,但不清楚他包含哪些成员。声明前置类型是为了避免编译器遇到Ui::HelloDialog *ui时报错。

==可以定义指向不完全类型的指针或者引用。==很容易想明白,因为虽然不知道不完全类型是干嘛的、占用多大内存不知道,但是一个指针或引用占用的大小是确定的,所以即使指向的类发生变化,但不会影响到本文件所占用的内存大小。所以编译时只需编译指向的类的文件。本类和用到本类对象的文件都不会重新编译,避免了引用头文件时出现的连锁编译问题。

这种设计模式叫做PIMPL(pointer to implement),即一个私有的成员指针,将指针所指向的类的内部实现数据进行隐藏。作用1、降低编译依赖,提高编译速度。2、接口与实现分离,隐藏实现细节,降低模块耦合。在本文最后会对PIMPL再进行举例说明。

关于PIMPL以及编译依存性,可参考《effective C++》的条款31:“将文件间的编译依存关系降至最低”。

2. 解析hellodialog.cpp文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "hellodialog.h"
#include "ui_hellodialog.h"

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

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

在构造函数中的初始化列表中,HelloDialog是继承自基类QDialog,因此用QDialog(parent)意思是对基类进行初始化,ui(new Ui::HelloDialog)意思是ui指向一个new出来的Ui::HelloDialog对象。

Ui::HelloDialog是一个部件布局对象,他是用来控制界面布局和组件设置的,但它本身并不是,也不包含任何窗体实体(包括在Ui上面布局的控件实体),他只是控制窗体上的部件的行为。类似于通过基类QDialog构造出了一块布,而Ui::HelloDialog控制在这块布上绣什么花花草草,红的还是绿的。

控制窗体上的部件行为样式是通过ui->setupUi(this)这一行实现,ui对象所属的类中定义了setupUi函数,用于控制部件行为样式,将this对象,也就是我们定义的类的实例对象作为参数传入到setupUi参数中。意思就是说在我的这个窗体上生成部件并设置样式。

3. 解析hellodialog.ui文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#define UI_HELLODIALOG_H

#include <QtCore/QVariant>
#include <QtWidgets/QApplication>
#include <QtWidgets/QDialog>
#include <QtWidgets/QLabel>
#include <QtWidgets/QPushButton>

QT_BEGIN_NAMESPACE

class Ui_HelloDialog
{
public:
    QPushButton *pushButton;
    QLabel *label;

    void setupUi(QDialog *HelloDialog)
    {
        if (HelloDialog->objectName().isEmpty())
            HelloDialog->setObjectName(QString::fromUtf8("HelloDialog"));
        HelloDialog->resize(400, 300);
        pushButton = new QPushButton(HelloDialog);
        pushButton->setObjectName(QString::fromUtf8("pushButton"));
        pushButton->setGeometry(QRect(100, 100, 93, 28));
        label = new QLabel(HelloDialog);
        label->setObjectName(QString::fromUtf8("label"));
        label->setGeometry(QRect(240, 110, 101, 16));

        retranslateUi(HelloDialog);

        QMetaObject::connectSlotsByName(HelloDialog);
    } // setupUi

    void retranslateUi(QDialog *HelloDialog)
    {
        HelloDialog->setWindowTitle(QApplication::translate("HelloDialog", "HelloDialog", nullptr));
        pushButton->setText(QApplication::translate("HelloDialog", "PushButton", nullptr));
        label->setText(QApplication::translate("HelloDialog", "hello world!", nullptr));
    } // retranslateUi

};

namespace Ui {
    class HelloDialog: public Ui_HelloDialog {};
} // namespace Ui

QT_END_NAMESPACE

#endif // UI_HELLODIALOG_H```

在ui文件中,定义了一个Ui_HelloDialog类,这个类就是控制窗体上部件的行为样式。首先界面有两个指向QPushButton和QLabel的指针,表明窗体上有这两个部件。

void setupUi(QDialog *HelloDialog)函数里边,写了这两个部件具体的样式、行为。注意参数是QDialog 类型的指针,表明这些部件的父对象。当我们在自己定义的类构造函数里边使用ui->setupUi(this)时,是把定义的HelloDailog类的实例对象作为参数传进去,所以setupUi中的部件就创建到了我们定义的窗体HelloDailog实例上边,并设定了显示样式。

1
2
3
4
5
6
7
8
9
10
11
#include "hellodialog.h"
#include <QApplication>

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    HelloDialog w;
    w.show();

    return a.exec();
}

main.cpp文件中HelloDialog w创建了w对话框对象,相当于把&w作为参数传入到了setupUi中(QDiailog指针可以指向派生的w对象)。从而在w这个窗体上生成部件并按照设定样式进行显示。

在ui文件最后,定义了Ui命名空间,这个命名空间里边有一个类class HelloDialog,公有派生自Ui_HelloDialog,就是说Ui::HelloDialog保留继承过来的公有属性,具有Ui_HelloDialog的行为和特点。这样绕一圈是为了避免用户定义类名与Qt自动生成的ui文件中的类名冲突,因此放在了Ui命名空间中。Ui::HelloDialog就相当于Ui_HelloDialog。

经过上述一番解释,是否对Qt creator的ui文件原理了然于胸了呢。总结Qt通过PIMPL的设计如下:

1、分离实现细节。这样的话,所有关于窗体的元素、配置、布局,便从窗体中抽离出来,任何窗体对象想使用这样的Ui,用一个指向Ui类对象的指针,然后用该指针setupUi一下,把这个窗体对象传进去就好了。
2、减少重新编译。当修改ui文件中部件的设计时,只需编译该文件即可。不会导致由于很多文件编译依存关系导致的重新编译。

4. PIMPL原理

原文链接:https://blog.csdn.net/armman/article/details/1737719

4.1 城门失火殃及池鱼

pImpl惯用手法的运用方式大家都很清楚,其主要作用是解开类的使用接口和实现的耦合。如果不使用pImpl惯用手法,代码会像这样:

1
2
3
4
5
6
7
8
9
//c.hpp
 #include<x.hpp>
class C
{
public:
    void f1();
private:
    X x; //与X的强耦合
};

像上面这样的代码,C与它的实现就是强耦合的,从语义上说,x成员数据是属于C的实现部分,不应该暴露给用户。从语言的本质上来说,在用户的代码中,每一次使用”new C”和”C c1”这样的语句,都会将X的大小硬编码到编译后的二进制代码段中(如果X有虚函数,则还不止这些)——这是因为,对于”new C”这样的语句,其实相当于operator new(sizeof? )后面再跟上C的构造函数,而”C c1”则是在当前栈上腾出sizeof?大小的空间,然后调用C的构造函数。因此,每次X类作了改动,使用c.hpp的源文件都必须重新编译一次,因为X的大小可能改变了。

在一个大型的项目中,这种耦合可能会对build时间产生相当大的影响。

pImpl惯用手法可以将这种耦合消除,使用pImpl惯用手法的代码像这样:

1
2
3
4
5
6
7
8
9
//c.hpp

class X; //用前导声明取代include
class C
{
     ...
private:
    X* pImpl; //声明一个X*的时候,class X不用完全定义
};

在一个既定平台上,任何指针的大小都是相同的。之所以分为X*,Y*这些各种各样的指针,主要是提供一个高层的抽象语义,即该指针到底指向的是那个类的对象,并且,也给编译器一个指示,从而能够正确的对用户进行的操作(如调用X的成员函数)决议并检查。但是,如果从运行期的角度来说,每种指针都只不过是个32位的长整型(如果在64位机器上则是64位,根据当前硬件而定)。

正由于pImpl是个指针,所以这里X的二进制信息(sizeof?等)不会被耦合到C的使用接口上去,也就是说,当用户”new C”或”C c1”的时候,编译器生成的代码中不会掺杂X的任何信息,并且当用户使用C的时候,使用的是C的接口,也与X无关,从而X被这个指针彻底的与用户隔绝开来。只有C知道并能够操作pImpl成员指向的X对象。

4.2 防火墙

“修改X的定义会导致所有使用C的源文件重新编译”这种事就好比“城门失火,殃及池鱼”,其原因是“护城河”离“城门”太近了(耦合)。

pImpl惯用手法又被成为“编译期防火墙”,什么是“防火墙”,指针?不是。C++的编译模式为“分离式编译”,即不同的源文件是分开编译的。也就是说,不同的源文件之间有一道天然的防火墙,一个源文件“失火”并不会影响到另一个源文件。

但是,这里我们考虑的是头文件,如果头文件“失火”又当如何呢?头文件是不能直接编译的,它包含于源文件中,并作为源文件的一部分被一起编译。

这也就是说,如果源文件S.cpp使用了C.hpp,那么class C的(接口部分的)变动将无可避免的导致S.CPP的重新编译。但是作为class C的实现部分的class X却完全不应该导致S.cpp的重新编译。

因此,我们需要把class X隔绝在C.hpp之外。这样,每个使用class C的源文件都与class X隔离开来(与class X不在同一个编译单元)。但是,既然class C使用了class X的对象来作为它的实现部分,就无可避免的要“依赖”于class X。只不过,这个“依赖”应该被描述为:“class C的实现部分依赖于class X”,而不应该是“class C的用户使用接口部分依赖于class X”。

如果我们直接将X的对象写在class C的数据成员里面,则显而易见,使用class C的用户“看到”了不该“看到”的东西——class X——它们之间产生了耦合。然而,如果使用一个指向class X的指针,就可以将X的二进制信息“推”到class C的实现文件中去,在那里,我们#include”x.hpp”,定义所有的成员函数,并依赖于X的实现,这都无所谓,因为C的实现本来就依赖于X,重要的是:此时class X的改动只会导致class C的实现文件重新编译,而用户使用class C的源文件则安然无恙!

指针在这里充当了一座桥。将依赖信息“推”到了另一个编译单元,与用户隔绝开来。而防火墙是C++编译器的固有属性。

4.3 穿越C++编译期防火墙

是什么穿越了C++编译期防火墙?是指针!使用指针的源文件“知道”指针所指的是什么对象,但是不必直接“看到”那个对象——它可能在另一个编译单元,是指针穿越了编译期防火墙,连接到了那个对象。

从某种意义上说,只要是代表地址的符号都能够穿越C++编译期防火墙,而代表结构(constructs)的符号则不能。

例如函数名,它指的是函数代码的始地址,所以,函数能够声明在一个编译单元,但定义在另一个编译单元,编译器会负责将它们连接起来。用户只要得到函数的声明就可以使用它。而类则不同,类名代表的是一个语言结构,使用类,必须知道类的定义,否则无法生成二进制代码。变量的符号实质上也是地址,但是使用变量一般需要变量的定义,而使用extern修饰符则可以将变量的定义置于另一个编译单元中。