一文看懂Qt creator的ui文件設計及PIMPL原理

在Qt creator中,可以使用Qt Designer(Qt設計師)來快速設計界面,只需拖放就可以設計並快速瀏覽樣式,並且可以生成代碼,替代了用代碼設計界面的工作。主要是生成了ui文件代替了用代碼生成界面。那麼這個過程是如何實現的呢?

以下是個簡單的例子。新建了一個項目名,類名叫HelloDialog,派生自QDialog。在對話框上添加了一個按鈕和一個文本標籤。如下所示:
在這裏插入圖片描述
點擊構建按鈕會生成ui文件,各個文件內容如下:

1.解析hellodialog.h文件

#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.h>,即包含其所對應的頭文件,是否可以?

不幸的是,這樣便在我們編寫的類和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文件

#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文件

#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實例上邊,並設定了顯示樣式。

#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慣用手法,代碼會像這樣:

//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慣用手法的代碼像這樣:

//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修飾符則可以將變量的定義置於另一個編譯單元中。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章