1. 程式人生 > >C++應用程序性能優化(二)——C++對象模型

C++應用程序性能優化(二)——C++對象模型

struct string 棧和堆 銷毀對象 getcount oca 函數參數 運行機制 控制

C++應用程序性能優化(二)——C++對象模型

一、C++對象模型與性能優化

對象模型是面向對象程序設計語言的重要方面,會直接影響面向對象語言編寫程序的運行機制以及對內存的使用機制,因此了解對象模型是進行程序性能優化的基礎。只有深入理解C++對象模型,才能避免程序開發過程中一些不易發現的內存錯誤,從而改善程序性能,提高程序質量。

二、C++程序的內存分布

1、程序內存分布簡介

通常,計算機程序由代碼和數據組成,因此代碼和數據也是影響程序所需內存的主要因素。代碼是程序運行的指令,比如數學運算、比較、跳轉以及函數調用,其大小通常由程序的功能和復雜度決定,正確地使用程序編寫技巧以及編程語言的特性可以優化所生成的代碼的大小;數據是代碼要處理的對象。

程序占用的內存區通常分為五種:全局/靜態數據區、常量數據區、代碼區、棧、堆。
程序的代碼存儲在代碼區中,而程序的數據則根據數據種類的不同存儲在不同的內存區中。C++語言中,數據有不同的分類方法,例如常量和變量,全局數據和局部數據,靜態數據和非靜態數據。此外,程序運行過程中動態產生和釋放的數據也要存放在不同的內存區。
不同內存區存儲的數據如下:
(1)全局/靜態數據區存儲全局變量以及靜態變量(包括全局靜態變量和局部靜態變量)。
(2)常量數據區存儲程序中的常量字符串等。
(3)棧中存儲自動變量或者局部變量,以及傳遞函數參數等,而堆是用戶程序控制的存儲區,存儲動態產生的數據。
不同類型的數據在內存存儲位置的示例如下:

#include <stdio.h>
#include <stdlib.h>

using namespace std;

int g_GolbalVariable = 100;

int main()
{
    int localVariable = 1;
    static int staticLocalVariable = 200;
    const int constLocalVariable = 100;
    char* pLocalString1 = "pLocalString1";
    const char* pLocalString2 = "pLocalString2";
    int* pNew = new int[5]; // 16字節對齊
    char* pMalloc = (char*)malloc(1);

    printf( "GolbalVariable: 0x%x\n", &g_GolbalVariable);
    printf( "Static Variable: 0x%x\n", &staticLocalVariable);
    printf( "LocalString1: 0x%x\n", pLocalString1);
    printf( "const LocalString2: 0x%x\n", pLocalString2);
    printf( "const LocalVariable: 0x%x\n", &constLocalVariable);

    printf( "New: 0x%x\n", pNew);
    printf( "Malloc: 0x%x\n", pMalloc);

    printf( "LocalVariable: 0x%x\n", &localVariable);

    return 0;
}

上述代碼定義了8個變量,一個全局變量GolbalVariable,一個靜態局部變量staticLocalVariable,六個局部變量。在RHEL 7.3系統使用GCC編譯器編譯運行,程序輸出結果如下:

GolbalVariable: 0x60105c
Static Variable: 0x601060
LocalString1: 0x4009a0
const LocalString2: 0x4009ae
const LocalVariable: 0xdbd23ef8
New: 0x182b010
Malloc: 0x182b030
LocalVariable: 0xdbd23efc

全局變量、靜態變量和局部靜態變量存儲在全局/靜態數據區。
字符串常量存儲在常量數據區,pLocalString1指向的字符串"pLocalString1"的長度是13字節,加上結束符’\0’,共計14個字節,存儲在0x4009a0開始的14個字節內存空間;存儲pLocalString2的字符串"pLocalString2"時,從0x4009ae地址開始,因此,沒有進行內存對齊處理。程序中的其它字符串常量,如printf中的格式化串通常也存儲在常量數據區。
通過new、malloc獲得的內存是堆的內存。通過new申請5個int所需的內存,但由於內存邊界需要字節對齊(堆上分配內存時按16字節對齊),因此申請5個int共計20個字節,但占據32字節的內存。通過malloc申請1個字節的內存,申請1個字節時會從32字節後開始分配。
內存對齊雖然會浪費部分內存,但由於CPU在對齊方式下運行較快,因此內存對齊對於程序性能是有益的。C++語言中struct、union、class在編譯時也會對成員變量進行內存對齊處理,開發人員可以使用#progma pack()或者編譯器的編譯選項來控制對struct、union、class的成員變量按多少字節對齊,或者關閉對齊。

2、全局/靜態數據區、常量數據區

全局/靜態存儲區、常量數據區在程序編譯階段已經分配好,在整個程序運行過程中始終存在,用於存儲全局變量、靜態變量,以及字符串常量等。其中字符串常量存儲的區域是不可修改的內存區域,試圖修改字符串常量會導致程序異常退出。

char* pLocalString1 = "hello world";
pLocalString1[0] = ‘H‘;// 試圖修改不可修改的內存區

全局/靜態數據區除了全局變量,還有靜態變量。C語言中可以定義靜態變量,靜態變量在第一次進入作用域時被初始化,後續再次進入作用域時不必初始化。C++語言中,可以定義靜態變量,也可以定義類的靜態成員變量,類的靜態成員變量用來在類的多個對象間共享數據。類的靜態成員變量存儲在全局/靜態數據區,並且只有一份拷貝,由類的所有對象共享。如果通過全局變量在類的多個對象間共享數據則會破壞類的封裝性。

#include <stdio.h>
#include <stdlib.h>

class A
{
public:
    int value;
    static int nCounter;
    A()
    {
        nCounter++;
    }
    ~A()
    {
        nCounter--;
    }
};
int A::nCounter = 0;

int main()
{
    A a;
    A b;
    printf("number of A: %d\n", A::nCounter);
    printf("non-static class member: 0x%x\n", &a.value);
    printf("non-static class member: 0x%x\n", &b.value);
    printf("static class member: 0x%x\n", &a.nCounter);
    printf("static class member: 0x%x\n", &b.nCounter);

    return 0;
}

上述代碼,類A定義了一個靜態成員變量nCounter用於對類A的對象進行計數,類A也定義了一個成員變量value,在RHEL 7.3系統使用GCC編譯器編譯運行,程序輸出結果如下:

number of A: 2
non-static class member: 0x99a457c0
non-static class member: 0x99a457b0
static class member: 0x601048
static class member: 0x601048

對象a和對象b中的value成員變量的地址不同,而靜態成員變量nCounter的地址相同。類A的每一個對象會有自己的value存儲空間,在棧上分配;類A的所有對象共享一個nCounter的存儲空間,在全局/靜態數據區分配。

3、堆和棧

在C/C++語言中,當開發人員在函數內部定義一個變量,或者向某個函數傳遞參數時,變量和參數存儲在棧中。當退出變量的作用域時,棧上的存儲單元會被自動釋放。當開發人員通過malloc申請一塊內存或使用new創建一個對象時,申請的內存或對象所占的內存在堆上分配。開發人員需要記錄得到的地址,並在不再需要時負責釋放內存空間。

#include <stdio.h>
#include <stdlib.h>

using namespace std;

int g_GolbalVariable = 100;

int main()
{
    int localVariable = 1;
    static int staticLocalVariable = 200;
    const int constLocalVariable = 100;
    char* pLocalString1 = "pLocalString1";
    const char* pLocalString2 = "pLocalString2";
    int* pNew = new int[5]; // 16字節對齊
    char* pMalloc = (char*)malloc(1);

    printf( "GolbalVariable: 0x%x\n", &g_GolbalVariable);
    printf( "Static Variable: 0x%x\n", &staticLocalVariable);
    printf( "LocalString1: 0x%x\n", pLocalString1);
    printf( "const LocalString2: 0x%x\n", pLocalString2);
    printf( "const LocalVariable: 0x%x\n", &constLocalVariable);

    printf( "New: 0x%x\n", pNew);
    printf( "Malloc: 0x%x\n", pMalloc);

    printf( "LocalVariable: 0x%x\n", &localVariable);

    return 0;
}

上述代碼中,通過new在堆上申請5個int的所需的內存空間,將獲得的地址記錄在棧上的變量pNew中;通過malloc在堆上申請1字節的內存空間,將獲得的地址記錄在棧上的變量pMalloc中。

int* pNew = new int[5]; // 16字節對齊
char* pMalloc = (char*)malloc(1);

在main函數結束時,pNew和pMalloc自身是棧上的內存單元,會被自動釋放,但pNew和pMalloc所指向的內存是堆上的,雖然指向堆空間的pNew和pMalloc指針變量已經不存在,但相應的堆空間內存不會被自動釋放,造成內存泄露。通過new申請的堆內存空間需要使用delete進行釋放,使用malloc獲得的堆空間內存需要使用free進行釋放。
既然棧上的內存空間不存內存泄露的問題,而堆上的內存容易引起內存泄露,為什麽要使用堆上的內存呢?因為很多應用程序需要動態管理地管理數據。此外,棧的大小有限制,占用內存較多的對象或數據只能分配在堆空間。
棧和堆的區別如下:
(1)大小
通常,程序使用棧的大小是固定的,由編譯器決定,開發人員可以通過編譯器選項指定棧的大小,但通常棧都不會太大。

#include <stdio.h>
#include <stdlib.h>

int main()
{
    char buf[8 * 1024 * 1024];
    printf("%x\n", buf);

    return 0;
}

RHEL 7.3系統中默認的棧大小為8MB,在RHEL 7.3系統使用GCC編譯器編譯運行,程序會運行時出錯,原因是棧溢出。
堆的大小通常只受限於系統有效的虛擬內存的大小,因此可以用來分配創建一些占用內存較大的對象或數據。
(2)效率
棧上的內存是系統自動分配的,壓棧和出棧都有相應的指令進行操作,因此效率較高,並且分配的內存空間是連續的,不會產生內存碎片;堆上的內存是由開發人員來動態分配和回收的。當開發人員通過new或malloc申請堆上的內存空間時,系統需要按照一定的算法在堆空間中尋找合適大小的空閑堆,並修改相應的維護堆空閑空間的鏈表,然後返回地址給程序。因此,效率幣棧要低,此外還容易產生內存碎片。
如果程序在堆上申請5個100字節大小的內存塊,然後釋放其中不連續的兩個內存塊,此時當需要在堆上申請一個150字節大小的內存塊時,則無法充分利用剛剛釋放的兩個小內存塊。由此可見,連續創建和刪除占用內存較小的對象或數據時,很容易在堆上造成內存碎片,使得內存的使用效率降低。

4、C++對象創建方式

從C++對象模型角度看,對象就是內存中的一塊區域。根據C++標準,一個對象可以通過定義變量創建,或者通過new操作符創建,或者通過實現來創建。如果一個對象通過定義在某個函數內的變量或者需要的臨時變量來創建,是棧上的一個對象;如果一個對象是定義在全局範圍內的變量,則對象存儲在全局/靜態數據區;如果一個對象通過new操作符創建,存儲在堆空間。
對面向對象的C++程序設計,程序運行過程中的大部分數據應該封裝在對象中,而程序的行為也由對象的行為決定。因此,深入理解C++對象的內部結構,從而正確地設計和使用對象,對於設計開發高性能的C++程序很重要。

三、C++對象的生命周期

1、C++對象生命周期簡介

對象的生命周期是指對象從創建到銷毀的過程,創建對象時要占用一定的內存空間,而對象要銷毀後要釋放對應的內存空間,因此整個程序占用的內存空間也會隨著對象的創建和銷毀而動態的發生變化。深入理解對象的生命周期會幫助分析程序對內存的消耗情況,從而找到改進方法。
對象的創建有三種方式,不同方式所創建對象的生命周期各有不同,創建對象的三種方式如下:
(1)通過定義變量創建對象
(2)通過new操作符創建對象
(3)通過實現創建對象

2、通過定義變量創建對象

通過定義變量創建對象時,變量的作用域決定了對象的生命周期。當進入變量的作用域時,對象被創建;退出變量的作用域時,對象被銷毀。全局變量的作用域時整個程序,被聲明為全局對象的變量在程序調用main函數前被創建,當程序退出main函數後,全局對象才會被銷毀。靜態變量作用域不是整個程序,但靜態變量存儲在全局/靜態數據區,在程序開始時已經分配好,因此聲明為靜態變量的對象在第一次進入作用域時會被創建,直到程序退出時被銷毀。

#include <stdio.h>
#include <stdlib.h>

class A
{
public:
    A()
    {
        printf("A Created\n");
    }
    ~A()
    {
        printf("A Destroyed\n");
    }
};

class B
{
public:
    B()
    {
        printf("B Created\n");
    }
    ~B()
    {
        printf("B Destroyed\n");
    }
};

A globalA;

void test()
{
    printf("test()------------------------->\n");
    A localA;
    static B localB;
    printf("test()<-------------------------\n");
}

int main()
{
    printf("main()------------------------->\n");
    test();
    test();
    static B localB;
    printf("main()<-------------------------\n");
    return 0;
}

上述代碼中定義了一個A的全局對象globalA,一個A的局部對象localA,一個B的靜態局部對象localB,localA和localB的作用域為test函數。
在RHEL 7.3系統使用GCC編譯器編譯運行結果如下:

A Created
main()------------------------->
test()------------------------->
A Created
B Created
test()<-------------------------
A Destroyed
test()------------------------->
A Created
test()<-------------------------
A Destroyed
B Created
main()<-------------------------
B Destroyed
B Destroyed
A Destroyed

根據程序運行結果,全局對象globalA在main函數開始前被創建,在main函數退出後被銷毀;靜態對象localB在第一次進入作用域時被創建,在main函數退出後被銷毀,如果程序從來沒有進入到其作用域,則靜態對象不會被創建;局部對象在進入作用域時被創建,在退出作用域時被銷毀。

3、通過new操作符創建對象

通過new創建的對象會一直存在,直到被delete銷毀。即使指向對象的指針被銷毀,但還沒有調用delete,對象仍然會一直存在,占據這堆空間,直到程序退出,因此會造成內存泄露。

#include <stdio.h>
#include <stdlib.h>

class A
{
public:
    A()
    {
        printf("A Created\n");
    }
    ~A()
    {
        printf("A Destroyed\n");
    }
};

A* createA()
{
    return new A();
}

void deleteA(A* p)
{
    delete p;
    p = NULL;
}

int main()
{
    A* pA = createA();
    pA = createA();

    deleteA(pA);
    return 0;
}

上述代碼中,createA函數使用new操作符創建了一個A對象,並將返回地址記錄在pA指針變量中;然後再次使用createA函數創建了一個A對象,將返回地址記錄在pA指針變量中,此時pA指針將指向第二次創建的A對象,第一次創建的A對象已經沒有指針指向。使用deleteA銷毀對象時,銷毀的是第二次創建的A對象,第一次創建的A對象會一直存在,直到程序退出,並且即使在程序退出時,第一次創建的A對象的析構函數仍然不會被調用,最終造成內存泄露。

4、通過實現創建對象

通過實現創建對象通常是指一些隱藏的中間臨時變量的創建和銷毀。中間臨時變量的生命周期很短,不易被開發人員察覺,通常是造成性能下降的瓶頸,特別是占用內存多、創建速度慢的對象。
中間臨時對象通常是通過拷貝構造函數創建的。

#include <stdio.h>
#include <stdlib.h>

class A
{
public:
    A()
    {
        printf("A Created\n");
    }
    A(const A& other)
    {
        printf("A Created with copy\n");
    }
    ~A()
    {
        printf("A Destroyed\n");
    }
};

A getA(A a)
{
    printf("before\n");
    A b;
    return b;
}

int main()
{
    A a;
    a = getA(a);
    return 0;
}

在RHEL 7.3系統使用GCC編譯器編譯運行結果如下:

A Created
A Created with copy
before
A Created
A Destroyed
A Destroyed
A Destroyed

getA函數的參數和返回值都是通過值傳遞的,在調用getA是需要把實參復制一份,壓入getA函數的棧中(對於某些C++編譯器,getA函數的返回值也要拷貝一份放在棧中,在getA函數調用結束時,參數出棧就會返回給調用者)。因此,在調用getA函數時,需要構造一個a的副本,調用一次拷貝構造函數,創建了一個臨時變量。
中間臨時對象的創建和銷毀是隱式的,因此如果中間臨時對象的創建和銷毀在循環內或是對象構造需要分配很多資源,會造成資源在短時間內被頻繁的分配和釋放,甚至可能造成內存泄露。
上述代碼getA函數的問題可以通過傳遞引用的方式解決,即getA(A& a),不用構造參數的臨時對象。
實際的C++工程實踐中,會有大量其它類型的隱式臨時對象存在,如重載+和重載++等操作符,對對象進行算術運算時也會有臨時對象,操作符重載本質上也是函數,因此要盡量避免臨時對象的出現。
當一個派生類實例化一個對象時,會先構造一個父類對象,同樣,在銷毀一個派生類對象時也會銷毀其父類對象。派生類對象的父類對象是隱含的對象,其生命周期和派生類對象綁定在一起。如果構造父類對象的開銷很大,則所有子類的構造都會開銷很大。

#include <stdio.h>
#include <stdlib.h>

class A
{
public:
    A()
    {
        printf("A Created\n");
    }
    ~A()
    {
        printf("A Destroyed\n");
    }
};

class B : public A
{
public:
    B(): A()
    {
        printf("B Created\n");
    }
    ~B()
    {
        printf("B Destroyed\n");
    }
};

int main()
{
    B b;
    return 0;
}

在RHEL 7.3系統使用GCC編譯器編譯運行結果如下:

A Created
B Created
B Destroyed
A Destroyed

根據運行結果,創建派生類對象時會先創建隱含的父類對象,銷毀派生類對象時會在調用派生類析構函數後調用父類的析構函數。

四、C++對象的內存布局

1、C++對象內部結構簡介

C++對象的內部結構及實現和C++編譯器緊密相關,不同的編譯器可能會有不同的實現方式。

2、C++簡單對象

在一個C++對象中包含成員數據和成員函數,成員數據分為靜態成員數據和非靜態成員數據;成員函數分為靜態成員函數、非靜態成員函數和虛函數。

#include <stdio.h>
#include <stdlib.h>

class SimpleObject
{
public:
    static int nCounter;
    double value;
    char flag;
    SimpleObject()
    {
        printf("SimpleObject Created\n");
    }
    virtual ~SimpleObject()
    {
        printf("SimpleObject Destroyed\n");
    }

    double getValue()
    {
        return value;
    }
    static int getCount()
    {
        return nCounter;
    }
    virtual void test()
    {
        printf("virtual void test()\n");
    }
};

int main()
{
    SimpleObject object;
    printf("Obejct start address: 0x%X\n", &object);
    printf("Value address: 0x%X\n", &object.value);
    printf("flag address: 0x%X\n", &object.flag);
    printf("Object size: %d\n", sizeof(object));

    return 0;
}

在RHEL 7.3系統使用GCC編譯器編譯運行結果如下:

SimpleObject Created
Obejct start address: 0x5728F3F0
Value address: 0x5728F3F8
flag address: 0x5728F400
Object size: 24
SimpleObject Destroyed

上述代碼,靜態成員數據nCounter存儲在全局/靜態數據區,由類的所有對象共享,並不作為對象占據的內存的一部分,因此sizeof返回的SimpleObject大小並不包括nCounter所占據的內存大小。非靜態成員數據value和flag存儲在對象占用的內存中,不論時全局/靜態數據區,還是堆上、棧上。Value是double類型,占據8個字節(64位),flag是char類型,占據1個字節,但由於內存對齊,也會占用8字節。
SimpleObject類對象的數據成員占用了16個字節,剩下的8字節是與虛函數相關的。如果將兩個虛函數的virtual關鍵字去掉,則sizeof(SimpleObject)將得到16。
虛函數用於實現C++語言的動態綁定特性,為了實現動態綁定特性,C++編譯器遇到含有虛函數的類時,會分配一個指針指向一個函數地址表,即虛函數表(virtual table),虛函數表指針占據了8個字節,並且占據的是類實例內存布局開始的8個字節。
C++簡單對象占用的內存空間如下:
(1)非靜態成員數據是影響對象占用內存大小的主要因素,隨著對象數目的增加,非靜態成員數據占用的內存空間會相應增加。
(2)所有的對象共享一份靜態成員數據,因此靜態成員數據占用的內存空間大小不會隨著對象數目的增加而增加。
(3)靜態成員函數和非靜態成員函數不會影響對象內存的大小,雖然其實現會占用相應的內存空間,同樣不會隨著對象數目的增加而增加。
(4)如果類中包含虛函數,類對象會包含一個指向虛函數表的指針,虛函數的地址會放在虛函數表中。
在虛函數表中,不一定完全是指向虛函數實現的指針。當指定編譯器打開RTTI開關時,虛函數表中的第一個指針指向的是一個typeinfo的結構,每個類只產生一個typeinfo結構的實例,當程序調用typeid()來獲取類的信息時,實際是通過虛函數表中的第一個指針獲取typeinfo結構體實例。

3、單繼承

C++語言中,繼承分為單繼承和多繼承。

#include <stdio.h>
#include <stdlib.h>

class SimpleObject
{
public:
    static int nCounter;
    double value;
    char flag;
    SimpleObject()
    {
        printf("SimpleObject Created\n");
    }
    virtual ~SimpleObject()
    {
        printf("SimpleObject Destroyed\n");
    }

    double getValue()
    {
        return value;
    }
    static int getCount()
    {
        return nCounter;
    }
    virtual void test()
    {
        printf("virtual void SimpleObject::test()\n");
    }
};
int SimpleObject::nCounter = 0;

class DerivedObject : public SimpleObject
{
public:
    double subValue;
    DerivedObject()
    {
        printf("DerivedObject Created\n");
    }
    virtual ~DerivedObject()
    {
        printf("DerivedObject Destroyed\n");
    }
    virtual void test()
    {
        printf("virtual void DerivedObject::test()\n");
    }

};

int main()
{
    DerivedObject object;
    printf("Obejct start address: 0x%X\n", &object);
    printf("Value address: 0x%X\n", &object.value);
    printf("flag address: 0x%X\n", &object.flag);
    printf("subValue address: 0x%X\n", &object.subValue);
    printf("SimpleObject size: %d\n"
           "DerivedObject size: %d\n",
           sizeof(SimpleObject),
           sizeof(DerivedObject));

    return 0;
}

在RHEL 7.3系統使用GCC編譯器編譯運行結果如下:

SimpleObject Created
DerivedObject Created
Obejct start address: 0x96EA42D0
Value address: 0x96EA42D8
flag address: 0x96EA42E0
subValue address: 0x96EA42E8
SimpleObject size: 24
DerivedObject size: 32
DerivedObject Destroyed
SimpleObject Destroyed

根據上述輸出結果,構造一個派生類實例時首先需要構造一個基類的實例,基類實例在派生類實例銷毀後被銷毀。
SimpleObject類大小是24個字節,DerivedObject類的大小是32個字節,DerivedObject增加了一個double類型的成員數據subValue,需要占用8個字節。由於DerivedObject類也需要一個虛函數表,因此DerivedObject派生類與SimpleObject基類使用同一個虛函數表,DerivedObject派生類在構造時不會再創建一個新的虛函數表,而是在SimpleObject基類的虛函數表中增加或修改,DerivedObject實例的虛函數表中會存儲DerivedObject相應的虛函數實現,如果DerivedObject沒有提供某個虛函數實現,則存儲基類SimpleObject的虛函數實現。

4、多繼承

C++語言提供多繼承的支持,多繼承中派生類可以有一個以上的基類。多繼承是C++語言中頗受爭議的一項特性,多繼承在提供強大功能的同時也帶來了容易造成錯誤的諸多不便。因此,後續很多面向對象程序設計語言取消了多繼承支持,而是提供了更清晰的接口概念。
C++語言中仍然通過繼承實現接口,在面向接口的編程模型,如COM,都采用多繼承實現。如果需要開發一個文字處理軟件,要求有些文檔即可以打印有可以存儲,有些文檔只可以打印或存儲。考慮到程序的可擴展性,比較好的設計是將打印和存儲分別定義為兩個接口,在接口中定義相應的方法。當一個類實現了打印和存儲接口時,其對象即可以打印也可以存儲。如果只實現了打印或存儲,則只具備相應的功能。

#include <iostream>
#include <string>

using namespace std;

class BaseA
{
public:
    BaseA(int a)
    {
        m_a = a;
    }
    virtual void funcA()
    {
        cout << "BaseA::funcA()" <<endl;
    }
private:
    int m_a;
};

class BaseB
{
public:
    BaseB(int b)
    {
        m_b = b;
    }
    virtual void funcB()
    {
        cout << "BaseB::funcB()" <<endl;
    }
private:
    int m_b;
};

class Derived : public BaseA, public BaseB
{
public:
    Derived(int a, int b, int c):BaseA(a),BaseB(b)
    {
        m_c = c;
    }
private:
    int m_c;
};

struct Test
{
    void* vptrA;
    int a;
    void* vptrB;
    int b;
    int c;
};

int main(int argc, char *argv[])
{
    cout << sizeof(Derived) << endl;
    Derived d(1,2,3);
    Test* pTest = (Test*)&d;
    cout << pTest->a <<endl;//1
    cout << pTest->b <<endl;//2
    cout << pTest->c <<endl;//3
    cout << pTest->vptrA <<endl;//
    cout << pTest->vptrB <<endl;//

    return 0;
}

上述代碼中,Derived類繼承自BaseA和BaseB類,funcA和funcB為虛函數。
技術分享圖片
Derived派生類對象的內存模型如下:
技術分享圖片
創建派生類時,首先需要創建基類的對象。由於多繼承一個派生類中有多個基類,因此,創建基類的對象時要遵循一定的順序,其順序由派生類聲明時決定,如果將Derived類的聲明修改為:
class Derived : public BaseB, public BaseA
基類對象BaseB會被首先創建,BaseA對象其次被創建。基類對象銷毀的順序與創建的順序相反。
多繼承會引入很多復雜問題,菱形繼承時很典型的一種。菱形繼承示例代碼如下:

#include <iostream>
#include <string>

using namespace std;

class People
{
public:
    People(string name, int age)
    {
        m_name = name;
        m_age = age;
    }
    void print()
    {
        cout << "name: " << m_name
             << " age: " << m_age <<endl;
    }
private:
    string m_name;
    int m_age;
};

class Teacher : public People
{
    string m_research;
public:
    Teacher(string name, int age, string research):People(name + "_1", age + 1)
    {
        m_research = research;
    }
};

class Student : public People
{
    string m_major;
public:
    Student(string name, int age,string major):People(name + "_2", age + 2)
    {
        m_major = major;
    }
};

class Doctor : public Teacher, public Student
{
    string m_subject;
public:
    Doctor(string name, int age,string research, string major, string subject):
        Teacher(name, age,research),Student(name, age, major)
    {
        m_subject = subject;
    }
};

struct Test
{
    string name1;
    int age1;
    string research;
    string name2;
    int age2;
    string major;
    string subject;
};

int main(int argc, char *argv[])
{
    Doctor doc("Bauer", 30, "Computer", "Computer Engneering", "HPC");
    cout << "Doctor size: " << sizeof(doc) << endl;
    Test* pTest = (Test*)&doc;
    cout << pTest->name1 << endl;
    cout << pTest->age1 << endl;
    cout << pTest->research << endl;
    cout << pTest->name2 << endl;
    cout << pTest->age2 << endl;
    cout << pTest->major << endl;
    cout << pTest->subject << endl;

    return 0;
}
// output:
// Doctor size: 28
// Bauer_1
// 31
// Computer
// Bauer_2
// 32
// Computer Engneering
// HPC

上述代碼中,底層子類對象的內存局部如下:
技術分享圖片
底層子類對象中,分別繼承了中間層父類從頂層父類繼承而來的成員變量,因此內存模型中含有兩份底層父類的成員變量。
如果頂層父類含有虛函數,中間層父類會分別繼承頂層父類的虛函數表指針,因此,底層子類對象內存布局如下:
技術分享圖片

#include <iostream>
#include <string>

using namespace std;

class People
{
public:
    People(string name, int age)
    {
        m_name = name;
        m_age = age;
    }
    virtual void print()
    {
        cout << "name: " << m_name
             << " age: " << m_age <<endl;
    }
private:
    string m_name;
    int m_age;
};

class Teacher : public People
{
    string m_research;
public:
    Teacher(string name, int age, string research):People(name + "_1", age + 1)
    {
        m_research = research;
    }
};

class Student : public People
{
    string m_major;
public:
    Student(string name, int age,string major):People(name + "_2", age + 2)
    {
        m_major = major;
    }
};

class Doctor : public Teacher, public Student
{
    string m_subject;
public:
    Doctor(string name, int age,string research, string major, string subject):
        Teacher(name, age,research),Student(name, age, major)
    {
        m_subject = subject;
    }
    virtual void print()
    {

    }
};

struct Test
{
    void* vptr1;
    string name1;
    int age1;
    string research;
    void* vptr2;
    string name2;
    int age2;
    string major;
    string subject;
};

int main(int argc, char *argv[])
{
    Doctor doc("Bauer", 30, "Computer", "Computer Engneering", "HPC");
    cout << "Doctor size: " << sizeof(doc) << endl;
    Test* pTest = (Test*)&doc;
    cout << pTest->vptr1 << endl;
    cout << pTest->name1 << endl;
    cout << pTest->age1 << endl;
    cout << pTest->research << endl;
    cout << pTest->vptr2 << endl;
    cout << pTest->name2 << endl;
    cout << pTest->age2 << endl;
    cout << pTest->major << endl;
    cout << pTest->subject << endl;

    return 0;
}

// output:
// Doctor size: 28
// 0x405370
// Bauer_1
// 31
// Computer
// 0x40537c
// Bauer_2
// 32
// Computer Engneering
// HPC

虛繼承是解決C++多重繼承問題的一種手段,虛繼承的底層實現原理與C++編譯器相關,一般通過虛基類指針和虛基類表實現,每個虛繼承的子類都有一個虛基類指針(占用一個指針的存儲空間,4(8)字節)和虛基類表(不占用類對象的存儲空間)(虛基類依舊會在子類裏面存在拷貝,只是僅僅最多存在一份);當虛繼承的子類被當做父類繼承時,虛基類指針也會被繼承。
在虛繼承情況下,底層子類對象的布局不同於普通繼承,需要多出一個指向中間層父類對象的虛基類表指針vbptr。
vbptr是虛基類表指針(virtual base table pointer),vbptr指針指向一個虛基類表(virtual table),虛基類表存儲了虛基類相對直接繼承類的偏移地址;通過偏移地址可以找到虛基類成員,虛繼承不用像普通多繼承維持著公共基類(虛基類)的兩份同樣的拷貝,節省了存儲空間。

#include <iostream>
#include <string>

using namespace std;

class People
{
public:
    People(string name, int age)
    {
        m_name = name;
        m_age = age;
    }
    void print()
    {
        cout << "this: " << this <<endl;
    }
private:
    string m_name;
    int m_age;
};

class Teacher : virtual public People
{
    string m_research;
public:
    Teacher(string name, int age, string research):People(name + "_1", age + 1)
    {
        m_research = research;
    }
    void print()
    {
        cout << "this: " << this <<endl;
    }
};

class Student : virtual public People
{
    string m_major;
public:
    Student(string name, int age,string major):People(name + "_2", age + 2)
    {
        m_major = major;
    }
    void print()
    {
        cout << "this: " << this <<endl;
    }
};

class Doctor : public Teacher, public Student
{
    string m_subject;
public:
    Doctor(string name, int age,string research, string major, string subject):
        People(name, age),Teacher(name, age,research),Student(name, age, major)
    {
        m_subject = subject;
    }
};

struct Test
{
    void* vbptr_left;
    string research;
    void* vbptr_right;
    string major;
    string subject;
    string name;
    int age;
};

int main(int argc, char *argv[])
{
    Doctor doc("Bauer", 30, "Computer", "Computer Engneering", "HPC");
    cout << "Doctor size: " << sizeof(doc) << endl;
    Test* pTest = (Test*)&doc;
    cout << pTest->vbptr_left << endl;
    cout << *(int*)pTest->vbptr_left << endl;
    cout << pTest->research << endl;
    cout << pTest->vbptr_right << endl;
    cout << *(int*)pTest->vbptr_right << endl;
    cout << pTest->major << endl;
    cout << pTest->subject << endl;
    cout << pTest->name << endl;
    cout << pTest->age << endl;

    return 0;
}

// output:
// Doctor size: 28
// 0x40539c
// 12
// Computer
// 0x4053a8
// 0
// Computer Engneering
// HPC
// Bauer
// 30

上述代碼沒有虛函數,在G++編譯器打印結果如上,底層子類對象的內存布局如下:
技術分享圖片

#include <iostream>
#include <string>

using namespace std;

class People
{
public:
    People(string name, int age)
    {
        m_name = name;
        m_age = age;
    }
    virtual void print()
    {
        cout << "this: " << this <<endl;
    }
private:
    string m_name;
    int m_age;
};

class Teacher : virtual public People
{
    string m_research;
public:
    Teacher(string name, int age, string research):People(name + "_1", age + 1)
    {
        m_research = research;
    }
    void print()
    {
        cout << "this: " << this <<endl;
    }
    virtual void func1()
    {}
};

class Student : virtual public People
{
    string m_major;
public:
    Student(string name, int age,string major):People(name + "_2", age + 2)
    {
        m_major = major;
    }
    void print()
    {
        cout << "this: " << this <<endl;
    }
    virtual void func2()
    {}
};

class Doctor : public Teacher, public Student
{
    string m_subject;
public:
    Doctor(string name, int age,string research, string major, string subject):
        People(name, age),Teacher(name, age,research),Student(name, age, major)
    {
        m_subject = subject;
    }
    void print()
    {
        cout << "this: " << this <<endl;
    }
    virtual void func3()
    {}
};

struct Test
{
    void* vbptr_left;
    char* research;
    void* vbptr_right;
    char* major;
    char* subject;
    void* vptr_base;
    char* name;
    long age;
};

int main(int argc, char *argv[])
{
    Doctor doc("Bauer", 30, "Computer", "Computer Engneering", "HPC");
    cout << "Doctor size: " << sizeof(doc) << endl;
    Test* pTest = (Test*)&doc;
    cout << pTest->vbptr_left << endl;
    cout << std::hex << *(int*)pTest->vbptr_left << endl;
    cout << std::dec << *((int*)pTest->vbptr_left+8) << endl;
    cout << std::dec << *((int*)pTest->vbptr_left+16) << endl;
    cout << std::dec << *((int*)pTest->vbptr_left+24) << endl;

    cout << pTest->research << endl;
    cout << pTest->vbptr_right << endl;

    cout << pTest->major << endl;
    cout << pTest->subject << endl;
    cout << pTest->vptr_base << endl;

    cout << pTest->name << endl;
    cout << pTest->age << endl;

    return 0;
}

上述代碼中,使用了虛繼承,因此不同的C++編譯器實現原理不同。
對於GCC編譯器,People對象大小為char* + int + 虛函數表指針,Teacher對象大小為char*+虛基類表指針+A類型的大小,Student對象大小為char*+虛基類表指針+A類型的大小,Doctor對象大小為char* + int +虛函數表指針+char*+虛基類表指針+char*+虛基類表指針+char*。中間層父類共享頂層父類的虛函數表指針,沒有自己的虛函數表指針,虛基類指針不共享,因此都有自己獨立的虛基類表指針。
VC++、GCC和Clang編譯器的實現中,不管是否是虛繼承還是有虛函數,其虛基類指針都不共享,都是單獨的。對於虛函數表指針,VC++編譯器根據是否為虛繼承來判斷是否在繼承關系中共享虛表指針。如果子類是虛繼承擁有虛函數父類,且子類有新加的虛函數時,子類中則會新加一個虛函數表指針;GCC編譯器和Clang編譯器的虛函數表指針在整個繼承關系中共享的。
G++編譯器對於類的內存分布和虛函數表信息命令如下:

g++ -fdump-class-hierarchy main.cpp
cat main.cpp.002t.class

VC++編譯器對於類的內存分布和虛函數表信息命令如下:
cl main.cpp /d1reportSingleClassLayoutX
Clang編譯器對於類的內存分布和虛函數表信息命令如下:
clang -Xclang -fdump-record-layouts

5、構造與析構

C++標準規定,每個類都必須有構造函數,如果開發人員沒有定義,則C++編譯器會提供一個默認的構造函數,默認構造函數不帶任何參數,也不會對成員數據進行初始化。如果類中定義了任何一種形式的構造函數,C++編譯器將不再生成默認構造函數。
除了構造函數,C++標準規定,每個類都必須有拷貝構造函數,如果開發人員沒有定義,則C++編譯器會提供一個默認的拷貝構造函數,默認拷貝構造函數是淺拷貝,即按照對象的內存空間逐個字節進行拷貝,因此默認拷貝構造函數會帶來隱含的內存問題。

#include <stdio.h>
#include <stdlib.h>

class SimpleObject
{
public:
    int n;
    SimpleObject(int n)
    {
        this->n = n;
        buffer = new char[n];
        printf("SimpleObject Created\n");
    }
    virtual ~SimpleObject()
    {
        if(buffer != NULL)
        {
            delete buffer;
            printf("SimpleObject Destroyed\n");
        }
    }
private:
    //SimpleObject(const SimpleObject& another);
private:
    char* buffer;
};

int main()
{
    SimpleObject a(10);
    SimpleObject b = a;
    printf("Object size: %d\n", a.n);

    return 0;
}
在RHEL 7.3系統使用GCC編譯器編譯運行時會異常退出。

SimpleObject在構造時分配了n個字節的緩沖區,在析構時釋放緩沖區。但由於沒有定義拷貝構造函數,C++編譯器會提供一個淺拷貝的默認拷貝構造函數,SimpleObject b = a語句會通過淺拷貝構造一個SimpleObject對象b,對象b的buffer和對象a的buffer指向同一塊內存空間,在對象a和對象b析構時,這塊內存空間被釋放了兩次,造成程序崩潰。如果不想通過賦值或拷貝構造函數構造對象,可以將拷貝構造函數定義為private,此時SimpleObject b = a;會在編譯時報錯。

C++應用程序性能優化(二)——C++對象模型