C++類對象內存模型與成員函數調用分析

轉自:http://blog.csdn.net/fairyroad/article/details/6376620


C++類對象內存模型是一個比較抓狂的問題,主要是C++特性太多了,所以必須建立一個清晰的分析層次。一般而言,講到C++對象,都比較容易反應到以下這個圖表:

 

 

這篇文章,就以這個表格作爲分析和行文的策略的縱向指導;橫向上,兼以考慮無繼承、單繼承、多重繼承及虛擬繼承四方面情況,這樣一來,思維層次應該算是比較清晰了。

1、C++類數據成員的內存模型

1.1 無繼承情況

實驗最能說明問題了,首先考慮下面一個簡單的程序1:

 

#include<iostream>

 

class memtest

{

public:

    memtest(int _a, double _b) : a(_a), b(_b) {}

    inline void print_addr(){

        std::cout<<"Address of a and b is:/n/t/t"<<&a<<"/n/t/t" <<&b<<"/n";

    }

    inline void print_sta_mem(){

        std::cout<<"Address of static member c is:/n/t/t"<<&c<<"/n";

    }

   

private:

    int a;

    double b;

    static int c;

};

 

int memtest::c = 8;

 

int main()

{

    memtest m(1,1.0);

    std::cout<<"Address of m is : /n/t/t"<< &m<<"/n";

    m.print_addr();

    m.print_sta_mem();

    return 0;

}

在GCC4.4.5下編譯,運行,結果如下:

 

可以發現以下幾點:

1.       非靜態數據成員a的存儲地址就是從類的實例在內存中的地址中(本例中均爲0xbfadfc64)開始的,之後的double b也緊隨其後,在內存中連續存儲;

2.       對於靜態數據成員c,則出現在了一個很“莫名其妙”的地址0x804a028上,與類的實例的地址看上去那是八竿子打不着;

其實不做這個測試,關於C++數據成員存儲的問題也都是C++ Programmer的常識,對於非靜態數據成員,一般編譯器都是按其在類中聲明的順序存儲,而且數據成員的起始地址就是類得實例在內存中的起始地址,這個在上面的測試中已經很明顯了。對非靜態數據成員的讀寫,我們可以這樣想,其實C++程序完全可以轉換成對應的C程序來編寫,有一些C++編譯器編譯C++程序時就是這樣做的。對非靜態數據成員的讀寫也可以藉助這個等價的C程序來理解。考慮下面代碼段2:

// C++ code

struct foo{

public:

   int get_data() const{ return data; }

   void set_data(int _data){ data = _data;}

private:

   int data;

};

 

foo f();

int d = f.get_data();

如果要你用C你會怎麼實現呢?

// C code

struct foo{

   int data;

};

int get_foo_data(const foo* pFoo){ return pFoo->data;}

void set_foo_data(foo* pFoo, int _data){ pFoo->data = _data;}

 

foo f;

f.data = 8;

foo* pF = &f;

int d = get_foo_data(pF);

在C程序中,我們要實現同樣的功能,必須是要往函數的參數列表中壓入一個指針作爲實參。實際上C++在處理非靜態數據成員的時候也是這樣的,C++必須藉助一個直接的或暗喻的實例指針來讀寫這些數據,這個指針,就是大名鼎鼎的 this指針。有了this指針,當我們要讀寫某個數據時,就可以藉助一個簡單的指針運算,即this指針的地址加上該數據成員的偏移量,就可以實現讀寫了。這個偏移量由C++編譯器爲我們計算出來。

對於靜態數據成員,如果在static_mem.cpp中加入下面一條語句:

std::cout<<”Size of class memtest is :  ”<<sizeof(memtest)<<”/n”;

我們得到的輸出是:12。也就是說,class的大小僅僅是一個int 和一個double所佔用的內存之和。這很簡單,也很明顯,靜態數據成員沒有存儲在類實例的地址空間中,它被C++編譯器弄到外面去了也就是程序的data segment中,因爲靜態數據成員不在類的實例當中,所以也就不需要this指針的幫忙了。

1.2 單繼承與多重繼承的情況

由於我們還沒有討論類函數成員的情況,尤其,虛函數,在這一部分我們不考慮繼承中的多態問題,也就是說,這裏的父類沒有虛函數——雖然這在實際中幾乎就是禁手。如此,我們的討論簡潔很多了。

在C++繼承模型中,一個子類的內存模型可以看成就是父類的各數據成員與自己新添加的數據成員的總和。請看下面的程序段3。

class father

{

public:

   // constructors destructor

   // access functions

   // operations

protected:

   int age;

   char sex;

   std::string phone_number;

};

 

class child : public father

{

public:

   // ...

protected:

   std::string twitter_url; // 兒子時髦,有推號

};

這裏sizeof(father)和sizeof(child)分別是12和16(GCC 4.4.5)。先看sizeof(father)吧,int佔4 bytes,char佔1byte,std::string再佔4 bytes,系統再將char圓整到4的倍數個字節,所以一共就是12 bytes了,對於child類,由於它僅僅引入了一個std::string,所以在12的基礎上加上std::string的4字節就是16字節了。

在單繼承不考慮多態的情況下,數據成員的佈局是很簡單的。用一個圖來說明,如下。

 

多重繼承一般都被公認爲C++複雜性的證據之一,但是就數據成員而言,其實也很簡單,多重繼承的複雜性主要是指針類型轉換與環形繼承鏈的問題,這些內容都將在第二部分講述。

假設有下面三個類,如下面的程序段4所示,繼承結構關係如圖:

class A{

public:

   // ...

protected:

   int a;

   double b;

};

 

class B{

public:

   // ...

protected:

   char c;

};

 

class C : public A, public B

public:

   // ...

protected:

   float f;

};

 

那麼,對應的內存佈局就是圖4所示。

 

1.3 虛繼承

多重繼承的一個語意上的副作用就是它必須支持某種形式的共享子對象繼承,所謂共享,其實就是環形繼承鏈問題。最經典的例子就是標準庫本身的iostream繼承族。

class ios{...};

class istream : public ios {...};

class ostream : public ios {...};

class iostream : public istream, public ostream {...};

無論是istream還是ostream都含有一個ios類型的子對象。然而在iostream的對象佈局中,我們只需要一個這樣的ios子對象就可以了,由此,新語法虛擬繼承就引入了。

虛擬繼承中,關於對象的數據成員內存佈局問題有多種策略,在Inside the C++ Object Model中提出了三種流行的策略,而且Lippman寫此書的時候距今天已經很遙遠了,現代編譯器到底如何實現我也講不太清楚,等哪天去翻翻GCC的實現手冊再論,今天先前一筆債在這。

2、C++類函數成員的內存模型

2.1 關於C++指針類型

要理解好C++類的函數成員的內存模型,尤其是虛函數的實現機制,一定要對指針的概念非常清晰,指針是絕對的利器,無論是編寫代碼還是研究內部各種機制的實現機理,這是由計算機體系結構決定的。先給一段代碼,標記爲代碼段5:

class foo{

  //...

};

int a(1);

double b(2.0);

foo f = foo();

 

int* pa = &a;

double* pb = &b;

foo* pf = &f;

我們知道,int指針的內容是一個 表徵int數據結構 的地址,foo指針的內容就是一個 表徵foo數據結構 的地址。那麼,系統是如何分別對待這些看上去就是0101的地址的呢?同樣是一個 1000110100...10100,我怎麼知道這個地址就一個int 數據結構的地址呢?它NN的拼什麼就不是一個 foo 數據結構的地址呢?我只有知道了它是int,我才知道應該取出從1000110100...10100開始的4個byte,對不對?

所以我就想——強調一下,我也只是在猜想——一定是指針的數據類型(比如int*,還是foo*?)裏面保存了相關的信息,這些信息告訴系統,我要的是一個int,你給我取連續的4個byte出來;我要的是一個foo結構,你給我取XX個連續的byte出來…

簡單地說,指針類型中包含了一個類似於 sizeof 的信息,或者其他的輔助信息——至少我們先這麼來理解,至於系統到底怎麼實現的,那是《編譯原理》上艱深的理論和GCC浩繁的代碼裏黑客們的神蹟了。這個sizeof的信息就告訴了系統你應該拿幾個(連續)地址上的字節返回給我。例如,int* pInt的值爲0xbfadfc64,那麼系統根據int*這個指針的類型,就知道應該把從0xbfadfc64到0xbfadfc68的這一段內存上的數據取出來返回。

回到C++的話題上,假設下面的代碼段6,其實就是前面代碼段3,爲了閱讀的方便copy過來一下。

class father

{

public:

   // constructors destructor

   // access functions

   // operations

protected:

   int age;

   char sex;

   std::string phone_number;

};

 

class child : public father

{

public:

   // ...

protected:

   std::string twitter_url; // 兒子時髦,有推號

};

現在我進行下面的調用:

child c();

father* pF = &c;

child* pC = &c;

std::string tu;

 

tu = pF->twitter_url;// 這個調用是非法的,原因我們後面說,暫且將這一行標記爲(*)

tu = pC->twitter_url;

if(child* pC1 = dynamic_cast<child*>(pF))

    tu = pC1->twitter_url;

對於(*)行,其實原因就是我們前面所說的,指針類型中包含了一個類似於sizeof 的信息,或者其他的輔助信息,對比圖5,我們可以這樣子想,一個father類型object嵌套在了一個child類型的object裏面,因爲指針類型有一個sizeof的信息,這個信息決定了一個pF類型的指針只能取到12個連續字節的內容,(*)試圖訪問到這個12個字節之外的內容,當然也就要報錯了。

我得說明一句,這樣子想只是一種理解上的自由(而且我認爲這樣理解,從結論和效果上講是靠譜的),到底是不是這樣子,我還並沒有調查清楚。

 

這裏,我們先調查了一下指針訪問類的數據成員,還沒有涉及到函數成員,但其實這纔是本部分的核心內容。OK,馬不停蹄趁熱打鐵,接下來我們就說這個故事。

 

2.2 靜態函數成員

與靜態數據成員一樣,靜態函數成員從實現的角度上講,最大的特點就是編譯器在處理靜態函數成員的時候不會講一個this指針壓入其參數列表,回顧代碼段2,一般的成員函數都會壓入一個this到參數列表的。這個實現的不同,決定了靜態函數成員們許多不一樣的特性。

如果取一個靜態函數成員的地址,獲得的就是其在內存中的地址,由於它們沒有this指針,所以其地址類型並不是一個指向類成員函數的特別的指針。

也由於沒有了this指針這一本質特點,靜態函數成員有了以下的語法特點:

l  它不能直接讀寫class內的非靜態成員,無論是數據成員還是函數成員;

l  它不能聲明爲const或是virtual;

l  它不是由類的實例來調用的,而是類作用域界定符;

這裏,我想起了《大學》上一段話:物有本末,事有終始,知所先後,則近道矣”,這話太TMD妙了,凡事入乎其內,外面的什麼東西都是浮雲,就像《越獄》裏的Micheal看到一面牆就想得到裏面的鋼筋螺絲,這時候這面牆已經不是一面牆了。如果只是生硬地去記憶上面那些東西,那是何其痛苦的事情,也幾乎不可能,但是一旦“入乎其內”了,這些東西就真的很簡單了。

靜態函數成員的特點賦予了它一些有趣的應用場合,比如它可以成爲一個回調函數,MFC大量應用了這一點;它也可以成功地應用線程函數身上。

2.3 非靜態函數成員

還是可以回到代碼段3,其實這個代碼段已經給出了非靜態成員函數的實現機制。

1.       改寫非靜態成員函數的函數原型,壓入一個額外的this指針到成員函數的參數列表中,目的就是提供一個訪問類的實例的非靜態數據/函數成員的渠道;

2.       將每一個對非靜態數據/函數成員的讀寫操作改爲經由this指針來讀寫;

3.       最驚訝的一步是,將成員函數改寫爲一個外部函數——Gotcha!這就是爲什麼sizeof(Class)的時候不會將非虛函數地址指針計算進去的原因,因爲(非靜態)成員函數都被搬到類的外面去了,並藉助Name Mangling算法將函數名轉化爲一個全局唯一的名字。

對於第3點,有一個明顯的好處就是,對類成員函數的調用就和一般的函數調用幾乎沒任何開銷上的差異,幾乎從C++投胎開始,效率就成爲了C++的極致追求之一。

2.4 虛擬成員函數

是本文中最複雜也最有趣的話題了。虛擬函數也是和繼承這個話題相伴相生,所以本節將納入對單繼承、多重繼承和虛擬繼承,一起描述他們之間的關係,這樣,對C++對虛擬函數的調用,以及由此所變現出來的多態的理解,應該是非常清晰了。

2.4.1 單繼承下的虛擬成員函數

對於虛擬函數,我們首先引入兩個數據結構,爲什麼引入一會就知道了。

1.         Virtual table. 大名鼎鼎的vtbl,如果一個類有虛擬函數,編譯器首先一堆指向virtual function的指針,這些指針,就存放在了這個vtbl之中。

2.         vptr. 編譯器會爲每個或自己有,或其父類/祖爺類等有虛擬函數的類的實例壓入一個指針,指向相關聯的virtual table,這個指針就是vptr。

先不管爲什麼要這麼做,先看看這麼一些數據結構引入之後,編譯器怎麼來處理虛擬函數調用的問題。考慮代碼段7:

class base{

public:

   virtual int sayhello(){

      std::cout<<"Hello, I'm a BASE lass instance!/n";

   }

};

 

class derived : public base{

public:

   virtual int sayhello(){

      std::cout<<"Hello, I'm a DERIVED class instance!/n";

   }

};

 

base b;

derived d;

 

base* pB = &d;

pB->sayhello();

 

pB = &b;

pB->sayhello();

對於這句:pB->sayhello();

虛擬函數的關鍵——從效用角度講就是多態的關鍵——就是爲sayhello()找到適當的執行體。爲此我們必須好好理解多態。

/ *******************************************************插敘:關於多態

我的理解是:同樣的操作,得到不同的結果。我們或許接觸得多的是override,因爲一開始比較正式和系統的講多態這個概念的時候是虛函數的 override 引發的,但是不盡然,按照“同樣的操作,得到不同的結果”的觀點,override和overload都是實現多態的手段。(當然還有其它的手段)。

l  override意思是重寫,僅僅發生在繼承這個過程,在基類中定義了某個函數,且這個函數是virtual的——這是必要條件——再從基類繼承出一個新的派生類,就可以在派生類重新定義這個函數”。override的條件比較苛刻,繼承+虛函數。

l  overload就是重載了,允許多個函數具有相同的名字,這裏的函數既可以是類的成員函數——如構造函數就可以重載多個版本,也可以是全局的函數。更明顯的例子是運算符重載,complex類中複數的相加等運算就是+的重載,可以把運算符看成函數,從而被overload。

由上面的討論可以看出,override和overload最大的相同點是:多個函數具有相同的名字。最大的不同點是:override是在程序運行的時候才決定調用哪個函數,overload是在代碼被編譯的時候決定調用哪個函數——靜態聯編。

那麼,多態到底有什麼用呢?

Google一下,曰:多態是一種不同的對象以單獨的方式作用於相同消息的能力。注意這幾個相同與不同,這個概念是從自然語言中引進的——這個意識對於理解OOP是很好的——我的一種學習體會就是,儘量在自然界中尋找神似的感覺,嘿,OOP還是很好理解嘛!舉個例子,動詞“關閉”應用到不同的事務上其意思是不同的。關門,關閉銀行賬號或關閉一個程序的窗口都是不同的行爲,其實際的意義取決於該動作所作用的對象。這個比方應該對理解多態有幫助,總之還是那句話:同樣的操作,不同的對象執行,就會出現不同的結果。

大多數面嚮對象語言的多態特性都僅以虛擬函數的形式來實現,但C++除了一般的虛擬函數形式之外,還多了兩種“類似於靜態的”(因爲我覺得沒有虛函數那樣足夠靈活,不過,也夠強大的了)多態機制:

1、操作符重載(函數重載的一種):例如,對整型和string對象應用 += 操作符時,每個對象都是以單獨的方式各自進行解釋。顯然,潛在的 += 實現在每種類型中是不同的。但是從直觀上看,我們能夠預期結果是什麼。

2、模板:例如,當接受到相同的消息時,整型vector對象和串vector對象對消息反映是不同的,我們以關閉行爲爲例:

vector<int> vi; 

vector<string> names;

string name("C++有點BT呀");

vi.push_back(5);

names.push_back(name);

靜態的多態機制不會導致和虛擬函數相關的運行時開銷。此外,操作符重載和模板兩者是通用算法最基本的東西,在STL中體現得尤爲突出。

關於多態的優點,說不清,可能主要是編程實踐不夠,很多書上是這樣說的(比如C++ primer)依賴動態聯編,達到統一的接口,不同的實現的功能。從代碼執行角度來看,動態聯編產生對象的靜態類型和動態類型的區別,用戶通過對象動態類型來匹配相應的實現,使得同樣的代碼有了不同的表現。多態使得類的接口和實現分離,降低了程序的耦合性和編譯的依賴性,提高了軟件的模塊化,從而誕生了各種各樣所謂的模式。概括起來多態所帶來的優點——靈活。

*******************************************************************/

羅裏吧嗦一大堆,讓我們再次回到代碼段7。爲了實現所謂的根據對象的實際情況作出相應動作的所謂“多態”,必須首先能夠對於多態對象有某種形式的運行期對象識別辦法。也就是說,我們需要在運行期獲得pB->sayhello();中關於pB的某些相關信息,pB他老人家到底指向了啥子捏?前面我(猜想)着說過,指針類型中可能加入了某些類似於sizeof的信息,好吧,計算這個猜想是對的,也不能保證多態就一定可以實現——單純這樣一個信息太老土了,不夠。萬一子類沒有引入新的數據成員怎麼辦呢?那好吧,我就直接引入一個對象類型的編碼,比如我用某些bit位表示表示類,但是這樣對空間要求增加了,而且,這樣也不優雅,不簡潔。

根據《Inside the C++ Object Model》,這些額外(類型)信息是有的——我前面的猜測部分是正確的——但是不是和指針放在一起。我們一步步來,首先,額外信息到底是什麼?知道了這個,我們可以精確的評估開銷。其次,我們到底把這些信息放哪裏呢?放對了地方,纔有可能爭取時間與空間的優勢。

對於第一個問題,我們需要知道:

A.       pB所指對象的真實類型,到底是base還是derived?

B.       sayhello()函數體在內存中間的位置。

對於第二個問題,C++的辦法是,在每一個需要多態(有virtual函數)的類對象身上壓入兩個成員:

a)       一個字符串或數組,表示class的類型,即type-info;

b)      一個指針,指向某個表格,表格中保存了類和類的繼承鏈中virtual函數的運行期地址。

這兩點,分別對應於前面A, B兩項需求。而對於b)中提到的兩份數據,就是本小節一開始提到的vptr和vtbl了。

vtbl中的virtual函數地址從何得知呢?在C++中,virtual函數可以在編譯期間就得到,此外,這一組地址是固定不變的,運行期不可能增加或更改。由於程序在執行中vtbl的大小和內容不會改變,所以vtbl構造可以完全由編譯器掌控,不需要運行期的任何介入。

然而,爲運行期準備好這些地址雷鋒還只做了一半。還有一個問題就是找到這些地址。這個,就是vptr的用途了。首先,vptr將指向編譯器分配好的vtbl表格,然後被壓入類的實例中,這樣,我們藉助這個vptr找到了vtbl,又因爲vtbl表格中一個個表項就是這些virtual的地址,所以萬里長征終於到頭了。剩下的,就是運行期在vtbl中找到特點的表項,取出virtual函數的地址即可。

一個類只有一個vtbl,每個vtbl內含其對應的類對象中所有虛函數實體的地址,這些虛函數包括:

1.         該類所定義的函數實體。它會override一個可能存在的基類中的虛函數。

2.         繼承自基類的函數實體,這些是在子類決定不改寫虛擬函數時纔會出現的情況。

3.         一個純虛函數實體。

每一個虛擬函數都被指派一個固定的索引值,這個索引在整個集成體系中保持與特定的虛函數的關聯。考慮一個實例代碼段8:

class Parent {

public:

    Parent():nParent(888) {}

    virtual void sayhello() { cout << "Perent()::sayhello()" << endl; }

    virtual void walk() { cout << "Parent::walk()" << endl; }

    virtual void sleep() { cout << "Parent::sleep()" << endl; }

protected:

   int nParent;

};

 

class Child : public Parent {

public:

    Child():nChild(88) {}

    virtual void sayhello() { cout << "Child::sayhello()" << endl; }

    virtual void  walk_child() { cout << "Child::walk_child()" << endl; }

    virtual void  sleep_child() { cout << "Child::sleep_child()" << endl; }

protected:

   int nChild;

};

 

class GrandChild : public Child{

public:

    GrandChild():nGrandchild(8) {}

    virtual void  sayhello() { cout << "GrandChild::sayhello()" << endl; }

    virtual void walk_child() { cout << "GrandChild::walk_child()" << endl; }

    virtual void sleep_grandchild() { cout << "GrandChild::sleep_grandchild()" << endl; }

protected:

   int nGrandchild;

};

現在,我們使用一個int** pVtbl 來作爲遍歷對象內存佈局的指針,這樣可以方便地像使用數組一樣來遍歷所有的成員包括其虛函數表:

typedef void(*Fun)(void);

GrandChild gc;

int** pVtbl = (int**)&gc;

cout << "[0] GrandChild::_vptr->" << endl;

for(int i=0; (Fun) pVtbl[0][i]!=NULL; i++){

    pFun = (Fun) pVtbl[0][i];

    cout << "    ["<<i<<"] ";

    pFun();

}

cout << "[1] Parent.nParent = " << (int)pVtbl[1] << endl;

cout << "[2] Child.nChild = " << (int) pVtbl[2] << endl;

cout << "[3] GrandChild.nGrandchild = " << (int) pVtbl[3] << endl;

運行結果如下:

 

 

我們發現,當一個子類繼承父類時:

1.         它可以繼承父類中所聲明的virtual函數的函數實體,準確地說,是該函數實體的地址會被拷貝到子類的虛擬函數表中;

2.         它可以使用自己的函數體,如Child::sayhello()和GrandChild::walk_child()。

3.         它可以加入新的虛函數。

由前面的討論,我們已經可以畫出這三個類的內存佈局圖了,如下頁圖6所示。由這個圖,如果我們計算sizeof(GrandChild),對於結果應該就不會差異了:3個int變量,再加上一個指針。

下一步,就是編譯期間如何對pB->sayhello()設定對虛函數的調用呢?

1.         首先,我們並不知道pB所指對象的真正類型,但是我知道經由pB可以存取到該對象的虛擬函數表。

2.         雖然我並不知道哪個sayhello()應該被調用,但是我知道每一個sayhello()函數的地址都放在vbtl的某個表項中,比如上述代碼中的第1表項。

由以上的這些信息,編譯器已經可以將pB->sayhello()轉化爲:

              (*pB->vptr[1]) (pB);

在這個轉化中,vptr表示編譯器所壓入的指針,指向vtbl,1表示sayhello()在vtbl中的索引號。。唯一一個需要在運行期才能知道的信息是:該索引所對錶項到底是哪一個sayhello()的函數實體,這個可以藉助type-info的信息獲得,因爲pB也被壓入了參數列表中。

 

(未完待續...)

 

 

 

2.4.2 多重繼承下的虛擬函數

多重繼承下的虛擬函數主要有一下幾個麻煩:

1.         幾個父類都聲明瞭相同原型的virtual函數;

2.         有不止一個父類將其析構函數聲明爲虛擬;

3.         一般的虛擬函數問題;

先給出代碼段9。

class Parent1{

public:

   Parent1() : data_parent1(0.0){}

   virtual ~Parent1(){cout<<"Parent1::~Parent1()"<<endl;}

   virtual void speakClearly(){cout<<"Parent1::speakClearly()"<<endl;}

   virtual Parent1* clone() const{cout<<"Parent1::clone()"<<endl; return null;}

protected:

   int data_parent1;

};

 

class Parent2{

public:

   Parent2() : data_parent2(1.0){}

   virtual ~Parent2(){cout<<"Parent2::~Parent2()"<<endl;}

   virtual void mumble(){cout<<"Parent2::mumble()"<<endl;}

   virtual Parent2* clone() const{cout<<"Parent2::clone()"<<endl; return null;}

protected:

   int data_parent2;

};

 

class Child : public Parent1, public Parent2

{

public:

   Child() : data_child(2.0){}

   virtual ~Child(){cout<<"Child::~Child()"<<endl;}

   virtual Child* clone() const{cout<<"Child::clone()"<<endl; return null;}

protected:

   int data_child;

};

就內存佈局而言,有了前面的基礎了,猜得出來大概是個什麼樣子了。好吧,我們就先猜一把,然後再寫段代碼驗證驗證。對於數據成員,多重繼承使用的就是各自分配一段空間“疊放”在一起,如之前的圖4所示。對於虛擬函數,其實就是多了個vptr嘛,也放進去不久結了嗎?

嗯,所以我們可以猜想了,見圖8。

 

接下來就是調試驗證了,調試代碼段10如下:

typedef void(*Fun)(void);

 

int main()

{

    Child c;

    Fun pFun;

 

    int** pVtbl = (int**)&c;

 

    cout << "[0] Parent1::_vptr->" << endl;

    pFun = (Fun)pVtbl[0][0];

    cout << "     [0] ";

    pFun();

 

    pFun = (Fun)pVtbl[0][1];

    cout << "     [1] ";

    pFun();

 

    cout << "    Parent1.data_parent1 = " << (int)pVtbl[1] << endl;

 

    int s = sizeof(Parent1)/4;

    cout << "[" << s << "] Parent2::_vptr->"<<endl;

    pFun = (Fun)pVtbl[s][0];

    cout << "     [0] "; pFun();

 

    pFun = (Fun)pVtbl[s][1];

    cout << "     [1] "; pFun();

 

    s++;

    cout << "    Parent2.data_parent2 = " << (int)pVtbl[s] << endl;

 

    s++;

    cout << "[3] Child.data_child = " << (int)pVtbl[s] << endl;

}

需要的是,這段代碼的運行要將虛析構函數註釋掉,理由應該很好理解吧,對象都被析構掉了,指針也就成爲懸掛指針了,SIGSEGV就會觸發。Code::Blocks(GCC 4.5.2)下運行結果如下:

 

 

再次說明一下,因爲我們註釋掉了虛析構函數那一行,所以上面的輸出中沒有Child::~Child()之類的信息。所以,我們的猜想圖8是正確的。

根據《Inside The C++ Object Model》一書,關於多重繼承主要有三種情況要仔細考慮,對着圖8,這三種情況其實都是浮雲。

1.       通過一個“指向第二個父類,如Parent2”的指針,調用子類的虛擬函數。請看代碼段11:

Parent2* pP2 = new Child;

// 下面的代碼將調用Child::~Child()

// 因此pP2必須被向後調整sizeof(Parent1)個bytes,由編譯器和運行期信息參與完成

delete pP2;

還是回到圖8,注意到一個問題,Parent1和Child指針所指向的位置是一樣的(如果都是取的同一個Child對象的地址),但是Parent2不是,它與Parent1和Child的指針之間存在一個偏移量,看下面的代碼就知道了:

Child c;

Parent1* pP1 = &c;

Parent2* pP2 = &c;

Child* pC = &c;

cout<<pP1<<"/n"<<pC<<"/n"<<pP2<<"/n/n";

 

運行結果如下圖:

 

 

pP1與pC內容一樣,pP2與pP1和pC之間存在8個字節的偏移量(8個字節是由一個4字節int變量和一個4字節指針引起的)。

對於代碼段11,因爲pP2指向Child對象中Parent2子對象處,爲了能夠正確執行,pP2必須調整到Child對象起始處。

1.       通過一個“指向Child類”的指針,調用Parent2中一個繼承而來的虛擬函數。在這種情況下,子類指針必須再次被調整,以指向第二個父類Parent2處。例如:

Child* pC = new Child;

// 調用Parent2::mumble()

// pC必須被向前調整sizeof(Parent1)個bytes,由編譯器和運行期信息參與完成

pC->mumble();

2.       第三種情況發生在一個語言擴充性質之下:允許一個虛擬函數的返回值類型有所變化(注意,返回值類型不是激活C++重載機制的充分條件),可能是父類類型,也可能是子類類型,這一點通過clone()函數來描述,看下面代碼:

Parent2* pP1 = new Child;

// 調用Child* Child::clone()

// 返回值必須被調整,以指向Parent2子對象,由編譯器和運行期信息參與完成

Parent2* pP2 = pP1->clone();

當進行pP1->clone()時,pP1會被調整到指向Child對象的起始地址,於是clone的Child版會被調用,它會傳回一個指針,指向一個新的Child對象,該對象的地址在被指定給pP2之前,必須經過調整,以指向Parent2子對象處。

之前的註釋中都有一句話,“XXX必須被調整,以指向Parent2子對象,由編譯器和運行期信息參與完成”,確實,就是編譯器會去做的事情,我們也不用管,因爲這也是compiler-dependent的。

2.4.3 虛繼承下的虛擬函數

虛擬繼承的出現就是爲了解決重複繼承中多個間接父類的問題的,經典繼承結構圖就是環形繼承鏈:

class PP {……};

class P1 : virtual public PP{……};

class P2: virtual public PP{……};

class C : public P1, public P2{…… };

虛擬繼承是個有點麻煩乃至無聊的東西,實踐中不被推薦,一般來說:

1)      先是P1,然後是P2,接着是C,而PP這個超類都放在最後的位置;

2)      各個類內部的佈局與多重繼承一樣;

3、C++對象模型總結

大道至簡,如果理解了前面的文字,下面四句話應該就差不多了:

l  非靜態數據成員都存放在對象所跨有的地址空間中,靜態數據成員則存放於對象所跨有的地址空間之外;

l  非虛擬成員函數(靜態和非靜態)也存放於對象所跨有的地址空間之外,且編譯器將其改寫爲普通的非成員函數的形式(以求降低調用開銷);

l  對於虛擬成員函數,則藉助vtbl和vptr支持。

l  對於繼承關係,子類對象跨有的地址空間中包含了父類對象的實體,通過嵌入type-info信息進行識別和虛函數調用。

4、參考資料

[1] Inside The C++ Object Model,Lippaman,第1、2、4章。

[2] C++對象內存佈局,陳皓專欄, http://blog.csdn.net/haoel/archive/2008/10/15/3081328.aspx




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