詳解 C++ 物件模型

2020-08-09 09:39:53

何爲 C++ 物件模型?

C++ 物件模型可以概括爲以下兩個部分

  1. 語言中直接支援物件導向程式設計的部分
  2. 對於各種支援的底層實現機制 機製

語言中直接支援面向程式設計的部分,如建構函式、解構函式、虛擬函式、繼承(單繼承,多繼承,虛繼承)、多型等。重點在底層實現機制 機製。

在 C 語言中,「數據」 和 「處理數據的操作(函數)」是分開來宣告的。也就是說,語言本身並沒有支援 「數據和函數」 之間的關聯性。在 C++ 中,通過抽象數據型別(abstract data type,ADT),在類中定義數據和函數,來實現數據和函數直接的系結。

概括來說,在 C++ 類中有兩種成員數據:static、nonstatic;三種成員函數:static、nonstatic、virtual。
在这里插入图片描述
如下面 下麪的 Base 類的定義:

#include <iostream>
using namespace std;
class Base{
private:
	int iBase;
	static int count;
public:
	Base(int);
	virtual ~Base(void);
	
	int getIBase() const;
	static int instanceCount();
	virtual void print() const;
};

那麼,Base 類在機器中如何構建出各種成員數據和成員函數的呢?


基本 C++ 物件模型

在介紹 C++ 使用的物件模型之前,介紹兩種物件模型:簡單物件模型(a simple object model)、表格驅動物件模型(a table-driven object model)。
在这里插入图片描述

所有的成員佔用相同的空間(跟成員型別無關),物件只是維護了一個包含成員指針的一個表。表中放的是成員的地址,無論是成員變數還是函數,都是這樣處理。物件並沒有直接儲存成員而是儲存了成員的指針


在这里插入图片描述

這個模型在簡單物件的基礎上又新增了一個間接層。將成員分爲函數和數據,並且用兩個表格儲存,然後是物件只儲存了兩個指向表格的指針。這個模型可以保證所有的物件具有相同的大小,比如簡單物件模型中每個物件的大小於其成員的個數有關。其中數據成員表中包含實際數據;函數成員表中包含的實際函數的地址(與數據成員相比,多一次定址)。


在这里插入图片描述

這個模型結合上面兩種模型的特點,並對記憶體存取空間進行了優化。在此模型中,nonstatic 數據成員被放在物件內部;static 數據成員,static 和 nonstatic 成員函數均被放置在物件之外。

對於虛擬函式的支援分兩步完成:

  1. 每一個 class 產生一堆指向虛擬函式的指針,放在一個表格之中。這個表格稱之爲虛擬函式表(virtual table,vtbl)。
  2. 每一個物件被新增了一個指針,指向相關的虛擬函式表 vtbl。通常這個指針被稱爲 vptr。vptr 的設定(setting)和 重置(resetting)都由每一個 class 的建構函式,解構函式和拷貝賦值運算子自動完成。

另外,虛擬函式表地址的前面設定了一個指向 type_info 的指針,RTTI(Run Time Type Identification)執行時型別識別是在編譯器生成的特殊型別資訊,包括物件繼承關係,物件本身的描述,RTTI 是爲多型而生成的資訊,所以只有具有虛擬函式的物件纔會生成。

這個模型的優點在於它的空間和存取時間的效率;但是也有缺點:如果應用程式本身未改變,但當所使用的類的 nonstatic 數據成員新增刪除或修改時,需要重新編譯。


模型驗證測試
爲了驗證上述 C++ 物件模型,我們編寫如下程式碼

#include <iostream>
#include <string>
using namespace std;
//獲取普通成員函數的地址
template<typename dst_type,typename src_type>
dst_type pointer_cast(src_type src)
{
    return *static_cast<dst_type*>(static_cast<void*>(&src));
}
class Base{
private:
	int iBase;
	static int count;
public:
	Base(int i) : iBase(i){}
	virtual ~Base(){}

	int getIBase() const{ return iBase; }
	static int instanceCount(){ count++; return count; }
	virtual void print() const{cout << "printf" << endl; }
};
int Base::count = 0;
void test_base_model()
{
    Base b1(1000);
    cout << "物件b1 的其實記憶體地址:" << &b1 << endl;
    cout << "type_info資訊:" << (int*)*(int*)(&b1) - 1 << endl;

    cout << "虛擬函式表地址:" << (int*)(&b1) << endl;
    cout << "虛擬函式表——第一個函數地址:" << (int *)(*(int*)(&b1)) << endl;
    cout << "虛擬函式表——第二個函數地址:" << (int *)(*(int*)(&b1))  + 1<< endl;

    cout << endl;
    cout << "推測數據成員 iBase 地址:" << (int*)(&b1) + 1 << ",值爲:" << *((int*)(&b1) + 1) << endl;
    cout << "普通函數 getIBase 的地址爲:" << pointer_cast<void *>(&Base::getIBase) << endl;
    cout << "靜態函數instanceCount地址:" << pointer_cast<void *>(&Base::instanceCount) << endl;
}
int main()
{
    test_base_model();
    return 0;
}



【執行結果】
在这里插入图片描述

根據 C++ 物件模型,範例化物件 b1 的起始記憶體地址,即虛擬函式表地址

  • 虛擬函式表的第一個函數地址是虛解構函式地址;
  • 虛擬函式表的第二個函數地址就是虛擬函式 print() 的地址,通過函數指針可以呼叫,進行驗證;
  • 推測數據成員 iBase 的地址爲虛擬函式表的地址 + 1,(int *)(&b1) + 1;
  • 靜態數據成員和靜態函數所在記憶體地址,與物件數據成員和函數成員位元欄不一樣;

上面介紹了基本的 C++ 物件模型,引入繼承之後,C++ 模型又是怎樣的?

C++物件模型中加入單繼承

不管是單繼承、多繼承、還是虛繼承,如果基於 「簡單物件模型」,每一個基礎類別都可以被派生類中的一個 slot 指出,該 slot 內包含基礎類別物件的地址。這個機制 機製的主要缺點是,因爲間接性而導致空間和存取時間上的額外負擔;優點是派生類物件的大小不會因其基礎類別的改變而受影響。

如果基於 「表格驅動模型」,派生類中有一個 slot 指向基礎類別表,表格中的每一個 slot 含有一個相關的基礎類別地址(這個很像虛擬函式的地址)。這樣每個派生類物件含一個 bptr,它會被初始化,指向其基礎類別表。這種策略的主要缺點是由於間接性而導致的空間和存取時間上的額外負擔;優點則是在每一個派生類物件中對繼承都有一致的表現方式,每一個派生類物件都應該在某個固定位置上放置一個基礎類別表指針,與基礎類別的大小或數量無關。第二個優點是,不需要改變派生類物件本身,就可以放大,縮小或更改基礎類別表

不管上述哪一種機制 機製,「間接性」的級數都將因爲整合的深度而增加。C++ 實際模型是,對於一般繼承是擴充已存在的虛擬函式表;對於虛繼承新增一個虛擬函式表指針。

1. 無重寫的單繼承

無重寫,即派生類中沒有於基類同名的虛擬函式

class Derived : public Base
{
public:
    Derived(int d) : Base(d), iDerived(888){}
    virtual ~Derived(){}
    virtual void derived_print(){}

protected:
    int iDerived;
};

Base、Derived的類圖如下所示:

在这里插入图片描述
Base 的模型跟上面一樣,不受繼承影響。Derived 不是虛繼承,所以是擴充已存在的虛擬函式表,所以結構如下圖所示:
在这里插入图片描述
爲了驗證上述C++物件模型,我們編寫如下測試程式碼。

void test_single_inherit_norewrite()
{
    Derived d(999);
    cout << "物件d'的起始位置爲:" << &d << endl;
    cout << "type_info資訊:" << (int *)*(int *)(&d) - 1 << endl;
    cout << "虛擬函式表的地址:" << (int*)(&d) << endl;
    cout << "第一個虛擬函式地址:" << (int*)*(int*)(&d) << endl;
    cout << "第二個虛擬函式地址:" << (int*)*(int*)(&d) + 1<< endl;
    cout << "第三個虛擬函式地址:" << (int*)*(int*)(&d) + 2<< endl;

   cout << "推測數據成員iBase地址:\t\t" << ((int*)(&d) +1) << "\t通過地址取得的值:" << *((int*)(&d) +1) << endl;

    cout << "推測數據成員iDerived地址:\t" << ((int*)(&d) +2) << "\t通過地址取得的值:" << *((int*)(&d) +2) << endl;
}

【執行結果】
在这里插入图片描述

2. 有重寫的單繼承

派生類中重寫了基礎類別的 print() 函數

class Derived_Overrite : public Base
{
public:
    Derived_Overrite(int);
    virtual ~Derived_Overrite(void);
    virtual void print(void) const;
protected:
    int iDerived;
};

Base、Derived_Overwrite的類圖如下所示:
在这里插入图片描述
重寫print()函數在虛擬函式表中表現如下:
在这里插入图片描述


C++ 物件模型中加入多繼承

從單繼承可以知道,派生類只是擴充了基礎類別的虛擬函式表。如果是多繼承的話,又是如何擴充的?

  1. 每個基礎類別都有自己的虛表
  2. 子類的成員函數被放到了第一個基礎類別的表中
  3. 記憶體佈局中,其父類別佈局依次按宣告順序排列
  4. 每個基礎類別的虛表中的 print() 函數都被 overwrite 成了子類的 print()。這樣做是爲了解決不同的基礎類別型別的指針指向同一個子類範例,而能夠呼叫到實際的函數。

在这里插入图片描述
上面3個類,Derived_Mutlip_Inherit繼承自Base、Base_1兩個類,Derived_Mutlip_Inherit的結構如下所示:
在这里插入图片描述


C++ 物件模型中加入虛繼承

虛繼承是爲了解決重複繼承中多個間接父類別的問題的,所以不能使用上面簡單的擴充併爲虛基礎類別提供一個虛擬函式指針(這樣會導致重複繼承的基礎類別會有多個虛擬函式表)形式。

虛繼承的派生類的記憶體結構,和普通繼承完全不同。虛繼承的子類,有單獨的虛擬函式表,另外也單獨儲存一份父類別的虛擬函式表,兩部分之間用一個四個位元組的 0x00000000 來作爲分界。派生類的記憶體中,首先是自己的虛擬函式表,然後是派生類的數據成員,然後是 0x0,之後就是基礎類別的虛擬函式表,之後就是基礎類別的數據成員。

如果派生類沒有自己的虛擬函式,那麼派生類也會有有一個指向虛擬函式表的 vptr,而後是派生類的變數,然後再是基礎類別。虛繼承中,即時派生類和基礎類別都沒有虛擬函式,派生類也會有 vptr,其指向的記憶體地址所儲存的是 0x00。

因此,在虛繼承中,派生類和基礎類別(虛基礎類別)的數據,是完全間隔的,先存放派生類自己的虛擬函式表和數據,中間以 0x 分界,最後儲存基礎類別的虛擬函式和數據。如果派生類過載了父類別的虛擬函式,那麼則將派生類記憶體中基礎類別虛擬函式表的響應函數替換。


1. 簡單虛繼承(無重複繼承情況)

簡單虛繼承的2個類 Base、Derived_Virtual_Inherit1 的關係如下所示:
在这里插入图片描述
Derived_Virtual_Inherit1的物件模型如下圖:
【標註】:Xxxx => -4 是分隔符的意思
在这里插入图片描述

2. 菱形繼承(含重複繼承、多繼承情況)

菱形繼承關係如下圖:

在这里插入图片描述
至此,C++物件模型介紹的差不多了,清楚了C++物件模型之後,很多疑問就能迎刃而解了。下面 下麪結合模型介紹一些典型問題。


如何存取成員?

前面介紹了 C++ 物件模型,下面 下麪介紹 C++ 物件模型對存取成員的影響。其實清楚了 C++ 物件模型,就清楚了成員存取機制 機製,給出一個大致的介紹。

物件大小問題
在这里插入图片描述
其中3個類中的函數都是虛擬函式

  • Derived 繼承 Base
  • Derived_Virtual 虛繼承 Base
class Base{
private:
public:
	Base(){}
	virtual ~Base(){}
	virtual void print(){}
	virtual void print_virtual(){}
};
class Derived : public Base
{
public:
    Derived(){}
    virtual ~Derived(){}
    virtual void print(){}
    virtual void print_virtual(){}

};
class Derived_Virtual : virtual public Base
{
    public:
        Derived_Virtual() {}
        virtual ~Derived_Virtual() {}

        virtual void print_derived_virtual(){}
    protected:

    private:
};

測試物件大小:

void test_size()
{
    Base b;
    Derived d;
    Derived_Virtual dv;
    cout << "sizeof(b) = " << sizeof(b) << endl;
    cout << "sizeof(d) = " << sizeof(d) << endl;
    cout << "sizeof(dv) = " << sizeof(dv) << endl;
    	 
}

【執行結果】
在这里插入图片描述

  • Base 中包含虛擬函式指針,所以 size 爲 4位元組
  • Derived 單繼承 Base,只是擴充了基礎類別的虛擬函式表,不會新增虛擬函式表指針,所以 size 也爲 4
  • Derived_Virtual 虛繼承 Base,根據前面的模型可知,派生類有自己的虛擬函式表及指針,並且有分隔符(0x00000000)4位元組,然後是虛基礎類別的虛擬函式表指針,所以 size = 4+4+4 = 12

那麼一個空類(只有建構函式和解構函式(解構函式不是虛擬函式))的大小是否爲0呢?
【舉個栗子】

class Empty{
public:
	Empty(){}
	~Empty(){}
};

【執行結果】
在这里插入图片描述

結果如上,並不是空的,它有一個隱晦的 1位元組,那是被編譯器安插進去的一個 char。這將使得這個class 的兩個函數在類中有獨一無二的地址。如果不給空類分配一定的空間,那麼將無法使用該類的範例。


數據成員如何存取(直接取址)

跟實際物件模型相關聯,根據 物件起始地址 + 偏移量取得

靜態系結和動態系結

程式呼叫函數時,將使用哪個可執行程式碼塊呢?編譯器負責回答這個問題。將原始碼中的函數呼叫解析爲執行特定的函數程式碼塊被稱爲函數名系結(binding, 又稱聯編)。在 C 語言中,這非常簡單,因爲每個函數名都對應一個不同的函數。在 C++ 中,由於函數過載的緣故,這項任務更復雜。編譯器必須檢視函數參數以及函數名才能 纔能確定使用哪個函數。然而編譯器可以在編譯過程中完成這種系結,這稱爲靜態系結(static binding),又稱爲早期系結(early binding)

然而虛擬函式使這項工作變得更加困難。使用哪一個函數不是能在編譯階段確定的,因爲編譯器不知道使用者選擇哪種型別所以,編譯器必須能夠在程式執行時選擇正確的虛擬函式的程式碼,這被稱爲動態系結(dynamic binding),又稱爲晚期系結(late binding)

使用虛擬函式是有代價的,在記憶體和執行速度等方面是有一定成本的,包括:

  • 每個物件都將增大,增大量爲儲存虛擬函式表指針的大小
  • 對於每個類,編譯器都將建立一個虛擬函式地址表
  • 對於每個函數呼叫,都需要執行一項額外的操作,即到虛擬函式表中查詢地址。雖然非虛擬函式比虛擬函式效率稍高,但不具備動態聯編能力。

函數成員如何存取(間接取址)

跟實際模型相關聯,普通函數(static、nonstatic)根據編譯、鏈接的結果直接獲取函數地址;如果是虛擬函式根據物件模型,取出對於虛擬函式地址,然後在虛擬函式表中查詢函數地址。


多型如何實現?

多型的實現
多型(Polymorphisn)在 C++ 中是通過虛擬函式實現的。通過上面的模型【「有重寫的單繼承」】知道,如果類中有虛擬函式,編譯器就會自動生成一個虛擬函式表,物件中包含一個指向虛擬函式表的指針。能夠實現多型的關鍵在於:虛擬函式是允許被派生類重寫的,在虛擬函式表中,派生類函數覆蓋基礎類別函數。除此之外,還必須通過指針或參照呼叫方法才行,將派生類物件賦給基礎類別物件。
在这里插入图片描述
上面2個類,基礎類別 Base、派生類 Derived 都包含下面 下麪2個方法

void print() const;
virtual void print_virtual() const;

這兩個方法的區別就在於一個是普通函數,一個是虛擬函式。
編寫測試程式碼如下:

void test_polmorphisn()
{
	Base b;
	Derived d;
	b = d;
	b.print();
	b.print_virtual();

	Base *p;
	p = &d;
	p->print();
	p->print_virtual();
}

【執行結果】
blog.csdnimg.cn/20200809085631403.png)

根據模型推測只有 p->print_virtual() 才實現了多型,其他三個呼叫都是呼叫基礎類別的方法

  • b.print(); b.print_virtual();不能實現多型是因爲通過基礎類別物件呼叫,而非指針或者參照,所以不能實現多型
  • p->print();不能實現多型是因爲,print() 函數沒有宣告爲虛擬函式(virtual),派生類中也定義了print 函數只是隱藏了基礎類別的 print 函數。

爲什麼解構函式設成虛擬函式是有必要的?

解構函式應當都是虛擬函式,除非明確該類不做基礎類別(不被其他類繼承)。基礎類別的解構函式宣告爲虛擬函式,這樣做是爲了確保釋放派生類物件時,按照正確的順序呼叫解構函式。
從前面介紹的 C++ 物件模型可知,如果解構函式不定義成虛擬函式,那麼派生類就不會重寫基礎類別的解構函式,再有多型行爲的時候,派生類的解構函式不會被呼叫到(有記憶體漏失的風險

【舉個栗子】

void test_vitual_destructor()
{
    Base *p = new Derived();
    delete p;
}

如果基礎類別不是解構函式
在这里插入图片描述
注意,缺少了派生類的解構函式呼叫。把解構函式宣告爲虛擬函式,呼叫就正常了:
在这里插入图片描述