1. 程式人生 > >《深度探索C++物件模型》(一)物件模型、儲存形式;預設建構函式一定會構造麼?

《深度探索C++物件模型》(一)物件模型、儲存形式;預設建構函式一定會構造麼?

歡迎檢視系列部落格:

--------------------------------------------------------------------------------------------------------------

一)、讀後感

    在我參加工作兩年多的時候,工作不算很忙了,《深入理解C++物件模型》開始進入我的視野;或許是因為我要從Symbian.C++ 轉向iOS Objective-C,並開始思考語言本身的一些東西的緣故。

    其實在一年前,出於對C++的迷惑,我已經買了這本書。當時翻了幾頁竟然沒懂,就擱那兒了!可是現在,它讓我隨身攜帶、流連忘返、是個旅途好伴侶;看到它我精神抖擻,它給了我繼續做程式設計師的信心

    這段時間經常會在晚上11點後,關閉電腦,然後捧著書本兒汲取知識。這種感覺覺很不錯!如果你在北京上班,那麼不要在地鐵上捧著手機看新聞、看微博和QQ,可以看點兒書。

    以上是我感慨,也許你覺得我說的太羅嗦、誇張。我只能再引用李宗盛《鬼迷心竅》中的歌詞:

        “有人問我你究竟是哪裡好, 這麼多年我還忘不了。“

         “春風再美也比不上你的好,沒見過你的人不會明瞭。”

    我看的是左邊“藍綠色”的老版的,右邊是2012版的。我看過新版的目錄,跟老的基本一樣。買新的吧

                           

    閱讀者要求。需要具備C++的基礎知識。這本說就像譯者評論的那樣,不是嬰幼兒奶粉,它是成人專用的低脂高鈣特殊奶粉。假如把C++比喻成一輛汽車,這本書不是教你怎麼開車,而是將汽車大卸八塊,逐個部件剖析。

    這本說的作者也有一些地方是相互矛盾的,很難理解,難道是C++太複雜了麼。

    專業術語介紹:

derived class 派生類
base class 基類
member function 成員函式
nonvirtual function 非虛擬函式



二)、回答幾個小問題

    這本書的作者就是C++第一個編譯器(cfront)的負責人,所以作者主要從編譯器的角度來剖析C++的物件模型。

    第一個、一般來說在學習C++的時候,如果沒有指明一個建構函式,那麼系統會預設建立建構函式。非也,編譯器會決定是否有必要生成一個建構函式和解構函式。也就預設建構函式可能不存在哦!特別是沒有繼承的情況下,編譯器認為建構函式和解構函式是無用的。(參考p231)

    第二個、假設兩個基類BaseABaseB都有virutal函式,BaseC繼承自BaseABaseB,那麼BaseC會有幾個虛擬函式表?答案是:根據編譯器不同而不同,有些是兩個虛擬函式表。有些是一個表,比如sun的編譯器。注意:這種情況屬於多重繼承,BaseC肯定會有兩個虛擬函式表指標

    第三個、區域性變數和全域性變數重名了,在區域性變數的生命週期的大括號之內使用這個變數,那個起作用。當然是區域性變數,但是C++並不是從一開始就是這麼設計的。

    一定要重複閱讀第三章:Data語意學第四章:function語意學,和五章:構造、析構、拷貝語意學,這時平時開發中最常見的。

    侯捷翻譯的很不錯,很多地方比如“虛擬函式”,基類,派生類,直接用virtual function 、base class derived class代替,很符合程式設計師的習慣。

下面開始筆記本分

三)、類屬性(Data語意學p83-p143)

---》一個空的類,大小不是0而是1,因為編譯器會生成一個隱晦的1bytes,用於區分,當該類多個物件時,各個物件都能在記憶體分配唯一地址。(p84)

---》成員變數的記憶體對齊,例如一個類只有char a一個屬性; 但是它的大小是4.雖然char的大小是1。(p85)

---》為了保持跟C的相容性,C++不要求基類屬性跟派生類屬性的排列順序,這個完全有編譯器決定。(p88)

---》區域性變數和全域性變數重名情況,在區域性變數的生命週期的大括號之內,使用該變數,哪個起作用?在1990年 隨著The Annotated C++ Reference Manual修訂,區域性變數開始隱藏全域性同名變數。而之前則是不隱藏。(p89)

---》屬性的記憶體順序和宣告順序是一致的。不同級別(public、protected和private)屬性的排列順序是相對一致的,就是說可能不連續,但是必須符合較晚出現的屬性存在較高的地址。(p92)

---》虛擬函式表指標Vptr,可能存在類的開始,也有可能存在類的末尾。通常都是類的末尾。(p92,p111,p112)

首先介紹vptr存在末端模式。下圖演示單一繼承並含有虛擬函式情況下的資料佈局(自然多型)。Point2d 和Point3d是繼承關係,注意:Vptr放在類的末尾。



初學者不要以為派生類的虛擬函式表指標Vptr(類結構中存的是虛擬函式表指標,並非虛擬函式表)存在派生類的那個部位,它依然是在父類的完整物件結構中

只不過,在派生類構造的時候,會將vptr所指向的virtual table修改。

vptr在前端模式,這麼做喪失了與C的相容性。


    如果是前端存放,還存在一個問題:如果基類沒有虛擬函式,派生類有虛擬函式,那麼單一繼承的自然多型就會被打破。如果要將派生類轉換成基類,必須編譯器的介入。(p112)


    編譯器似乎開始發揮它的作用了。多重繼承下又是虛擬繼承,編譯器必須做出必要的偏移和調整,才能保證正確的呼叫虛擬函式。

---》對一個類物件取地址,那麼並不是第一個屬性的地址,第一個屬性的地址還需要+1,這麼做是為了區分指向第一個屬性和指向所有屬性的指標兩種情況。(p98)

---》一般而言,基類屬性在派生類的開始部分,但是C++任何一條規則,只要碰上虛繼承就沒轍兒了。(p99)

---》C++語言保證“出現在派生類中的基類物件,有其完整性”,這麼做是為了在位拷貝的時候,能夠拷貝正確。(p106)

假如ClassA 和ClassB都有一個char的屬性,假設ClassB 繼承自ClassA,假設,C++為了節省記憶體,將自己的char型別和基類的char型別繫結一起,那麼經過下面表示式後可能出現問題:

ClassA* a = new ClassB;

ClassB b = *a;

下圖描述的是“緊湊型別”,這樣會導致嚴重後果,派生類的屬性可能被“抹掉”,如圖中的char b


不要以為ClassB中的char b和ClassA中的char 會放在一起,由於記憶體對齊的規則,ClassA大小是4B,ClassB大小是8B。這樣即使拷貝就不會出問題。

下圖描述的是父類在子類中有完整的物件結構:


(同樣就像剛才我說的那樣:虛擬繼承將破壞這種父類結構的完整型)

---》單一繼承下,父類通常在派生類前端。所以不管繼承有多深,把一個derived class指定給class,該操作不需要編譯器的介入。多重繼承既不像單一繼承,也不容易模擬出其模型,多重繼承的複雜度在於derived class和其上一個base class 乃至於上上一個base class......之間的“非自然”關係,(p112)

多重繼承的問題主要發生於derived class和其第二或後繼的base class 之間的轉換。

對於一個多重派生物件,將其地址指定給“最左端(也就是第一個)基類的指標”,情況和單一繼承時相同,因為兩者都指向相同的起始地址。需要付出的成本只是地址的指定操作而已,至於第二個或後繼的base class的地址指定操作,則需要進行地址修改:加上或者減去介於中間base class大小。

下圖展示了多繼承的關係。涉及到4個類 Point2d、Point3d、Vertex和Vertex3d(p115)



下面展示了多重繼承的物件模型。



多繼承的情況下,drived clas可能會有兩個或兩個以上虛擬函式表指標

請看下面的表示式:

Vertex3d v3d;

Vertex *pv;

Point2d *p2d;

Point3d *p3d;

那麼這個操作 pv = &v3d  需要轉換內部程式碼pv = (Vertex*)(((char*)&v3d) + sizeof(Point3d));

下面這兩個操作,只需要拷貝地址就行了。

p2d = &v3d;

p3d = &v3d;

---》虛擬多繼承情況(p117)

下圖可以表現Vertex3d 的繼承體系圖。左為多重繼承,右為虛擬多重繼承。


    不論是Vertex還是Point3d都內含一個Point2d。然而在Vertex3d的物件佈局中,我們只需要單一一份Point2d就好。所以引入虛擬繼承。然而編譯器要實現虛擬繼承,實在是困難度頗高。虛擬繼承的原則就是:讓VertexPoint3d各自維護的Point2d 摺疊成一個有Vertex3d維護的單一Point2d,並且還可以儲存base class 和derived class的指標之間的多臺指定操作。

    如果一個class含有virtual base class 那麼,該物件將被分割為兩部分:一個不變區域性和一個共享區域性。不變區域性中的資料,不管後繼如何演化,總是擁有固定的offset,所以這部分資料可以直接存取。至於共享區域性(即virtual base class),這一部分的資料,其位置會因為每次的派生操作而有變化,所以他們只能被間接存取。各家編譯器實現技術之間的差異就是間接存取的方法不同,目前有三種主流策略。

第一個策略:如何存取class的共享區域性呢?cfront編譯器會在每一個derived class中安插一個指向virtual base class的指標,這樣就可以間接存取。這樣的實現模型會有下面兩個主要缺點:

1.每一個物件必須針對其每一個virtual base class 揹負一個額外的指標。

解決方法有:第一個,Microsoft編譯器引入所謂的virtual base class table。每一個class object如果有一個或多個virtual base class,就會由編譯器安插一個指標,指向virtual base class table。至於真正的virtual base class 指標,當然是被放在該表格中。

請看下面的虛擬繼承物件模型,如圖。


紅框內即所謂的“共享區域性”,其位置會因每次派生操作而有所變化。虛擬破壞了base class 的物件完整型,虛擬繼承會在自己類中生成一個虛擬函式表指標。

第二個、在virtual function table 中放置virtual base class的offset(不是地址)。


這個方法的好處是,巧妙的利用了虛擬函式表的結構,使得drived class 能夠節省一個指標的大小。上圖中國藍色曲線是offset

2.由於虛擬繼承串鏈的加長,導致間接存取層次的增加。例如:如果我們有三層虛擬衍化,我就需要三次間接存取(經由三個virtual base class指標)。

這個問題的解決方案有:拷貝所有的virtual base class 的指標到drived class中。這樣就解決了存取時間的問題,雖然會有空間的開銷。

一般而言,virtual base class 最有效的一種執行形式就是:一個抽象的virtual base class 沒有任何的data members。也許正是java和Objective-c不使用多重繼承,卻使用介面類(OC叫協議)的原因。

---》如果對類的屬性取地址(p130)

比如 &Point3d::z得到的值將是z在所有屬性中偏移量。

列印該值的時候必須使用這個方法   :printf("&Point3d::z =%p\n",&Point3d::z);

四)、類方法(function語意學p139-p186)

---》 C++的成員函式有三種:static 、nonstatic和virtual。每一種型別的呼叫方法都不同。(p140)

---》C++的設計原則之一就是nonstatic member function至少必須和一般的nonmember function有相同的效率。而實際上成員函式也是被轉化為nonmember function呼叫,下面是轉化步驟:(p142)

1.改寫函式的簽名(signature,函式名稱+引數數目+引數型別)安插一個this指標到函式引數中來。

例如:float Point3d::magnitude3d()const;

經過改寫後的方法為:float Point3d::magnitude3d(const Point3d* const this)const

***這也就是問什麼:const  可以用來區分過載函式的標示的,包括const引數或const函式,但是返回值不算,因為返回值不會作為函式的簽名。

2.對nonstatic data member 的存取操作改為經由this指標來完成。

3.將member function重新寫成一個外部函式。對函式進行mangling(重新命名)處理,是它在程式中成為獨一無二的語彙。

---》一般而言,member function(data member也是一樣)的名稱前面會被加上class名稱,形成獨一無二的命名。(p144)

---》目前C++編譯器對name mangling的做法還沒有統一,但是遲早會統一。(p145)

---》虛擬函式(p147)

如果函式normalize()是一個虛擬函式,那麼下面的呼叫 ptr->normalize()將被內部轉化為:

(*ptr->vptr[1])(this); vptr是有編譯器產生的指標,指向virtual table。下標為1說明是是第1個虛擬函式。

---》靜態成員函式將被轉化為一般的nonmember函式呼叫。它不能存取nonstatic members,不能宣告為:const、volatile或virtual。

由於靜態成員函式缺乏this指標,因此其差不多等同於nonmember function。它提供了一個意想不到的好處:成為callback函式。

---》虛擬成員函式(p152)

在C++中多型(polymorphism)表示”以一個public base class的指標(或者reference),定址處一個derived class object“的意思。

在C++中virtual functions可以在編譯時期獲知,這一組地址是固定不變的,執行期不可能新增或者替換值。

請看下面一個類Point的定義:

class Point {
public:
          virtual ~Point();
          virtual Point& mult(float)=0;
          float x()const {return _x;}
          float y()const {return 0.0;}
          float z()const {return 0.0;}
protected:
        Point(float x=0.0);
        float _x;
};

Point2d繼承自Point。Point3d繼承自Point2d。那麼記憶體模型如圖,單一繼承情況


在單一繼承體系中,virtual function機制的行為十分良好,不但有效率而且很容易塑造其模型出來,但是在多重繼承和虛擬繼承中,對virtual function的支援就沒有那麼美好了

---》thunk技術(p162)

所謂的thunk是一段assembly碼,用來以適當的offser值調整this指標,跳到virtual function去。Thunk技術允許virtual table slot 繼續內含一個簡單的指標,因此多重繼承不需要任何空間上的額外負擔。slots中的地址可以直接指向virtual function,也可以指向一個相關的thunk。

---》vptr將在建構函式中被設立初始值。(p164)

---》多重繼承下的虛擬函式

    多重繼承下,通常派生類會有多個virtual table ,最左邊基類的稱之為:“主要表格”,第二或更過多基類的表格稱為:“次要表格”(參考上圖),派生類的主要表格和次要表格可以連在一起,比如Sun的編譯器的策略就是這樣的。(p164,p165)

class drived 繼承自 class Base1 class Base2 類結構如下:

class Base1{                                      class Base2{
public:                                            public:
          Base1();                                        Base2();
          virtual ~Base1();                               virtual ~Base2();
          virtual void SpeakClearly();                    virtual void mumble();
          virtual Base1* clone() const;                   virtual Base2* clone()const;
};                                                     };

這兩個類我故意並列在一起,Base1和Base2的區別就是兩個不同的虛擬函式void SpeakClearly()和void mumble();

class Derived: public Base1,public Base2{
public:
            Derived();
            virtual ~Derived();
            virtual Derived* clone()const;
protected:
            float data_derived;
};

那麼這幾個類的virtual table的佈局如下:




多重繼承下:derived類會分別重寫“主要表格”和“次要表格”

---》虛擬繼承下的虛擬函式。


---》當然這本說也不是如此的深入,當一個virtual base class 從另外一個virtual base class派生而來,並且兩者都支援virtual functions和nonstatic data members時,編譯器對於virtual的支援簡直就像進入迷宮一樣。作者只是給了一句話“距離複雜的深淵懸崖不遠了。”(p169)

---》獲取一個nonstatic member function的地址,如果該函式是non virtual,則得到的結果是它在記憶體中的真實地址。然而這個地址是不全的,他也需要被繫結與某個class object的地址上(this指標),才能過通過它呼叫函式。(p174)

---》獲取一個virtual member function的地址,只能獲取一個索引值。(p176)

那麼,如果使用一個函式指標float (Point::*pmf)() = &Point::z;這時pmf是一個索引值。

但是,pmf還可以指向一個nonvirtual member function的真實地址啊?cfront的做法是如果pmf大於127就是真實地址,如果小於127就是索引值。當然這種設計限定了繼承體系中只能有128個virtual function,這並不是我們希望看到的。在多重繼承的引入後又有了別的方法解決這個問題。然而,剛剛說的這個方法就淘汰了。(p178)

---》多重繼承下,指向member functions的指標。指向member function的指標需要先指向一個結構體,該結構體中存放幾個屬性分別表示virtual table的索引和non virtual member function的地址。詳情見(p179)

---》inline函式提供了一個強有力的工具。然後與non-inline函式比起來,他們需要更小心的處理。

五),構造、析構和拷貝語意學(p191-p236)

看第五章跟打遊戲一樣,看著看著不行了,看不懂了,這關沒過去,還得從頭兒再來。

---》每一個derived class destructor 會被編譯器加以擴充套件,以靜態呼叫的方式呼叫其“每一個virtual base class”已經“上一層base class”的destructor。所以virtual function不要宣告為pure(p193)

point的宣告

type struct
{
        float x,y,z;
}Point;

point的使用:

Point global;

Point foobar()
{
        Point local;
        Point *heap = new Point;
        *heap = local;
        delete heap;
        return local;
}

觀念上Point的建構函式和解構函式會被編譯器建立,事實上並非如此:Point被編譯器看做是Plain Ol' Data。

---》無繼承情況下的物件構造(p196)

---》不論是private、public存取層,或是member function的宣告,都不會佔用物件的空間。(p199)

---》constructor可能內帶大量隱藏程式碼,因為編譯器會擴充每一個constructor,大致有下面幾種情況:(p206)

        1.初始化“初始化列表中的資料”

        2.如果data member沒有出現在初始化列表中,將呼叫data member的constructor。

        3.如果有vptr進行初始化。

        4.上一層的base class constructor必須唄呼叫,以base class的宣告順序為準。

        5.所有virtual base class constructor必須被呼叫。

---》虛擬繼承下的建構函式。(p210)

如下圖的繼承關係。


如果Vertex3d構造的時候,必然呼叫Point3d的建構函式,同時呼叫Vertex的建構函式,然而這兩個類都要必須呼叫Point2d的建構函式,這是不合理的。取而代之的是應該在Vertex3d的建構函式中直接對Point2d初始化。這樣就需要Vertex3d再條用Point3d或者Vertex的建構函式的時候傳遞一個bool引數__most_derived,即“是否是最後一層繼承關係”,然後Point3d或者Vertex的建構函式根據這個bool變數決定是否構造Point2d。

總結為一句話:virtual base class constructor,只有當一個完整的class object被定義出來時,它才會被呼叫。如果object只是某個完整的object的suboject

,他就不會被呼叫。

---》vptr的初始化(p213)

在base class constructor呼叫操作之後,但是在程式設計師提供的程式碼或是“member initialization list中所列的members初始化操作”之前編譯器對vptr進行初始化。這個過程就像想象的那樣:一個PVertex物件會先成為一個point2d物件。一個point3d物件、一個vertex物件和一個vertex3d物件,最後才成為一個PVertex物件。

---》一個建構函式的真實步驟可能如下:(216)

        1.在derived class constructor 中,“所有virtual base classes”及“上一層base class”的constructor會被呼叫。

        2.上述完成後,物件vptr(可能多個vptrs)被初始化,指向相關的virtual table(可能多個表)

        3如果有member initialization list 的話,將在constructor體內擴充套件開來。這必須在vptr被設定之後才進行,以免有一個virtual member function被呼叫。

        4.最後,執行程式設計師所提供的程式碼。

---》如果不準將一個class object指定給另外一個class object,那麼只要將copy assignment operator宣告為private即可。(p219)

---》解構函式(p231)

如果class 沒有定義destructor,那麼只有在class內帶的member object(或是class自己的base class)擁有destructor的情況下,編譯器才會自動合成出一個來。否者destructor被視為不需要,也就不需要合成(當然更不需要呼叫)

---》解構函式的實際操作可能如下:

        1.destructor的函式本身首先被執行

        2.如果class擁有member class objects,而後者擁有destructor,那麼它們會以其宣告順尋的反序被呼叫。

        3.如果object內帶一個vptr,則現在被重新設定,指向適當的base class的virtual table

        4.如果有任何直接的nonvirtual base lasses 擁有destructor,它們會以其宣告的反序被呼叫。

        5.如果有任何 virtual base class擁有destructor,而當前討論的這個class是最末端(most-derived)的class,那麼它們會以其原來的構造順尋的相反順尋被呼叫。

以上是第三章、第四章和第五章的主要內容。

 - - - - - - - - -未完待續---------- 剩餘章節會新寫一個blog- - - - - - - - - - - 

六、C++大記事:

    1993年引入RTTI。

    1991年引入templates 模板(在cfront 3.0中引入的)

    1990 隨著The Annotated C++ Reference Manual修訂,區域性變數開始隱藏全域性同名變數。

    1989年,釋出了Release 2.0。引入了多重繼承、抽象類、常數成員函式,以及成員保護。

    1987年 引入靜態成員函式。

    20世紀80年代中期引入虛擬函式。

從某種角度上來說,C++的強大要歸功與C++的編譯器的強大。這時我才知道為什麼用很厚一本書來介紹visual studio,可能也是Symbian不用標準C++的原因。

如有問題,歡迎大家斧正!