物件導向程式設計(C++篇2)——構造

charlee44發表於2022-03-07

1. 引述

在C++中,學習類的第一課往往就是建構函式。根據建構函式的定義,建構函式式是用於初始化類物件的資料成員的。無論何時,只要類被建立,就會執行建構函式:

class ImageEx
{
public:
    ImageEx()
    {
        cout << "Execute the constructor!" << endl;
    }
};

int main()
{
    ImageEx imageEx;    
    return 0;
}

那麼問題來了,為什麼要有建構函式?

2. 詳述

2.1. 資料型別初始化

正如上一篇文章《物件導向程式設計(C++篇1)——引言》中提到的那樣:類是抽象的自定義資料型別。對於C++的內建資料型別,我們可以採用如下方式進行初始化:

double price = 109.99;

這種初始化行為很像賦值操作,但是初始化與賦值是兩種概念:初始化的含義是建立變數的時候賦予其一個初始值,而賦值的含義則是把物件的當前值擦除,以一個新的值來代替。實際上,我們同樣可以使用類似建構函式一樣的方式初始化內建資料型別:

double price(109.99);

那麼,我們在定義變數的時候不進行初始化會怎麼樣呢?答案是會進行預設初始化(其實不太準確,在某些情況下,會不被初始化,進而產生未定義的行為,是非常危險的):

double price;
price = 109.99;

在C++中,一個合理的原則是:變數型別定義時初始化。這個原則不僅可以避免未初始化可能產生的未定義行為,還節省了效能:避免定義(預設初始化)後再進行賦值操作。

2.2. 類初始化

可能你會認為,先定義(預設初始化)之後再進行賦值,對效能影響不大。這句話對於C#、Java、JavaScript這樣的語言來說是成立的,它們的應用場景很多時候可以不用關心這個(效能場景則不一定)。而對於C++這樣的面向底層的語言來說,追求的是"零成本抽象(zero overhead abstraction)"的設計原則,只是簡單的資料結構影響當然不太,但是對於一個非常複雜的資料型別,則可能存在不可忽視的效能開銷。

可以為一個類的資料成員提供一個類內初始值:

class ImageEx
{
    int imgWidth = 0;
    int imgHeight = 0;
    int bandCount = 0;
};

類的資料成員如果不進行初始化,那麼就會如前所述,進行預設初始化:

class ImageEx
{
public:

    void Print()
    {
        cout << imgWidth << '\t' << imgHeight << '\t' << bandCount << endl;
        for (int i = 0; i < 10; i++)
        {
            printf("%d\t", data[i]);
        }
    }

private:
    int imgWidth;
    int imgHeight;
    int bandCount;

    unsigned char data[10];
};

int main()
{
    ImageEx imageEx;
    imageEx.Print();

    return 0;
}

執行結果:
figure1

預設初始化的未定義行為當然不是我們想要的,於是我們給他加一個初始化函式:

class ImageEx
{
public:

    void Init()
    {
        imgWidth = 200;
        imgHeight = 100;
        bandCount = 3;
        memset(data, 0, 10 * sizeof(unsigned char));
    }

    void Print()
    {
        cout << imgWidth << '\t' << imgHeight << '\t' << bandCount << endl;
        for (int i = 0; i < 10; i++)
        {
            printf("%d\t", data[i]);
        }
        cout << endl;
    }

private:
    int imgWidth;
    int imgHeight;
    int bandCount;

    unsigned char data[10];
};

int main()
{
    ImageEx imageEx;
    imageEx.Print();
    imageEx.Init();
    imageEx.Print();

    return 0;
}

執行結果:
figure2

從上例可以發現,如果我們自己給類的資料成員進行初始化函式,其實類的資料成員早就進行了一次預設初始化操作,這個初始化函式其實是一次額外的賦值。以這個類物件中的陣列資料成員data為例,假使這個陣列的容量很大,其額外的一次賦值操作對於底層來說,是不可忽略的效能開銷。

那麼使用建構函式的原因就很容易理解了,建構函式就是實現當類定義時初始化資料成員的,這樣可以避免額外的初始化效能開銷:

class ImageEx
{
public:
    ImageEx()
    {
        cout << "Default initialization!" << endl;
        Print();
        cout << "Execute the constructor!" << endl;
        Init();
    }


    void Print()
    {
        cout << imgWidth << '\t' << imgHeight << '\t' << bandCount << endl;
        for (int i = 0; i < 10; i++)
        {
            printf("%d\t", data[i]);
        }
        cout << endl;
    }

private:
    void Init()
    {
        imgWidth = 200;
        imgHeight = 100;
        bandCount = 3;
        memset(data, 0, 10 * sizeof(unsigned char));
    }

    int imgWidth;
    int imgHeight;
    int bandCount;

    unsigned char data[10];
};

int main()
{
    ImageEx imageEx;
    imageEx.Print();

    return 0;
}

進一步探究,建構函式本質是個函式,函式是由語句組成,已經定義的資料型別只能賦值初始化,而無法再進行構造。也就是說,在呼叫建構函式之前,資料成員還是已經預設初始化了:

figure3

因此,初始化最好的實現是使用建構函式的初始值列表:

class ImageEx
{
public:
    ImageEx() :
        imgWidth(200),
        imgHeight(100),
        bandCount(3),
        data{ 0, 1, 2 }
    {
        cout << "Execute the constructor!" << endl;
    }


    void Print()
    {
        cout << imgWidth << '\t' << imgHeight << '\t' << bandCount << endl;
        for (int i = 0; i < 10; i++)
        {
            printf("%d\t", data[i]);
        }
        cout << endl;
    }

private:
    int imgWidth;
    int imgHeight;
    int bandCount;

    unsigned char data[10];
};

int main()
{
    ImageEx imageEx;
    imageEx.Print();

    return 0;
}

執行結果:
figure4

通過這種實現,類中所有的資料成員都在定義時初始化,從而使類物件也實現了定義時初始化;避免了先定義後賦值的效能開銷,體現了C++"零成本抽象(zero overhead abstraction)"的設計哲學。

上一篇
目錄
下一篇

相關文章