深刻探索C++對象模型(三)

Data 語義學

一個class的data members,通常而言,能夠表現這個class在程序執行時的某種狀態。Nonstatic data members放置的是「個別的class object」感興趣的數據,static data members則放置的是「整個class」感興趣的數據。ios

C++對象模型儘可能以空間優化和存取速度優化的考慮來表現nonstatic data members,而且保持和C語言struct數據配置的兼容性。它們把數據直接存放在每個class object之中。對於繼承而來的nonstatic data members(無論是virtual仍是nonvirtual base class)也是如此。不過沒有強制定義其間的排列順序。
至於static data members,則被放置在程序的一個global data segment中,不會影響個別class object的大小。在程序之中,無論該class被產生出多少個objects(經由直接產生或間接派生),static data members永遠只存在一份實例(甚至即便該class沒有任何object實例,其static data members也已存在)。可是一個template class的static data members的行爲稍有不一樣。程序員

Data Member的綁定(The Binding of a Data Member)

C++ Standard以「member scope resolution rules」來精煉這個「rewriting rule」,其效果是,若是一個inline函數在class聲明以後當即被定義的話,那麼就仍是對齊評估求值(evaluae)。也就是說,當一我的寫下這樣的代碼:算法

extern int x;

class Point3d{
public:
    //對於函數自己的分析將延遲直至class聲明的右大括號出現纔開始 
    float X() const { return x; }
    //...
private:
    float x;
};
//事實上,分析在這裏運行

時,對於member functions自己的分析,會直到整個class的聲明都出現了纔開始。
所以,在一個inline member function軀體以內的一個data member綁定操做,會在整個class聲明以後才發生。ide

Data Member的佈局(Data Member Layout)

已知下面一組data members:函數

class Point3d{
public:
    //...
private:
    float x;
    static List<Point3d*> *freeList;
    float y;
    static const int chunkSize = 250;
    float z;
};

nonstatic data members在class object中的排列順序和其被聲明的順序同樣,任何中間介入的static data members,如freeList和chunkSize都不會被放進對象佈局之中。在上述例子中,每個Point3d對象是由三個float組成,次序是x,y,z。static data members存放在程序的data segment中,和個別的class objects無關。工具

C++ Standard要求,在同一個access section(也就是private、public、protected等區段)中,members的排列只需符合「較晚出現的members在class object中有較高的地址」這一條件便可。也就是說,各個members並不必定連續排列。什麼東西可能會介於被聲明的members之間呢?members的邊界調整(alignment)可能就須要填補一些bytes。對於C和C++ 而言這的確是真的,對目前的C++編譯器實現狀況而言,這也是真的。佈局

編譯器還可能會合成一些內部使用的data members,以支持整個對象模型,vptr就是這樣的東西,當前全部的編譯器都把它安插在每個「內含virtual function之class」的object內。測試

Data Member的存取

Static Data Members

static data members,按其字面意義,被編譯器提出於class以外,並被視爲一個global變量(但只在class生命範圍內可見)。每個member的存取許可(譯註:private、protected或public),以及與class的關聯,並不會招致任何空間上或執行時間上的額外負擔——不管是在個別的class objects仍是在static data member自己優化

每個static data member只有一個實例,存放在程序的data segment之中。每次程序參閱(取用)static member時,就會被內部轉化爲對該惟一extern實例的直接參考操做。例如:this

Point3d origin, *pt;

//origin.chunkSize = 250
Point3d::chunkSize = 250;

//pt->chunkSize = 250
Point3d::chunkSize = 250;

從指令執行的觀點來看,這是C++語言中「經過一個指針和經過一個對象來存取member,結論完成相同」的惟一一種狀況,這是由於「經由member selection operators(也就是'.'運算符)對一個static data member進行存取操做」只是語法上的一種便宜行事而已,member其實並不在class object之中,所以存取static membeers並不須要經過class object。

若取一個static data member的地址,會獲得一個指向其數據類型的指針,而不是一個指向其class member的指針,由於static member並不內含在一個class object之中。例如:

&Point3d::chunkSize;

會得到類型以下的內存地址:

const int*

若是有兩個classes,每個都聲明瞭一個static member freeList,那麼當它們都被放在程序的data segment時,就會致使名稱衝突。編譯器的解決方法是暗中對每個static data member編碼(對於這種手法有個很美的名稱:name-mangling),以得到一個獨一無的程序識別代碼。有多少個編譯器,就有多少中name-manglint作法。任何name-mangling作法都有兩個要點:

  1. 一種算法,推導出獨一無二的名稱
  2. 萬一編譯系統(或環境工具)必須和使用者交談,那些獨一無二的名稱能夠輕易被推導回原來的名稱

Nonstatic Data Members

Nonstatic data members直接存放在每個class object之中。除非經由顯式的(explicit)或隱式的(implicit)class object,不然沒有辦法直接存取它們。只要程序員在一個member function中直接處理一個nonstatic data member,所謂「implicit class object」就會發生。例如:

Point3d
Point3d::translate(const Point3d &pt){
    x += pt.x;
    y += pt.y;
    z += pt.z;
}

表面上所看到的對於x,y,z的直接存取,事實上是經由一個"implicit class object"(由this指針表達)完成,實際上這個函數的參數是:

//member function的內部轉化
Point3d 
Point3d::translate(Point3d *const this, const Point3d &pt){
    this->x += pt.x;
    this->y += pt.y;
    this->z += pt.z;
}

欲對一個nonstatic data member進行存取操做,編譯器須要把class object的起始地址加上data member的偏移位置(offset)。若是:

origin._y = 0.0;

那麼地址&origin._y將等於:

&origin + (&Point3d::_y - 1);

請注意其中的 -1 操做。指向data member的指針,其offset值老是被加上 1,這樣可使編譯系統區分出「一個指向data member的指針,用以指出class的第一個member」和「一個指向data member的指針,沒有指出任何member」兩種狀況。

每個nonstatic data member的偏移位置(offset)在編譯時期便可獲知,甚至若是member屬於一個base class subobject(派生自單一或多重繼承串鏈)也是同樣的。所以,存取一個nonstatic data member,其效率和存取一個C struct member或一個nonderived class的member是同樣的。

「繼承」與Data Member

在C++ 繼承模型中,一個derived class object所表現出來的東西,是其本身的members加上其base class members的總和。至於derived class members和base class members的排列順序,則未在C++ standard中強制指定:理論上編譯器能夠自由安排之。在大部分編譯器上頭,base class members老是先出現,但屬於virtual base class的除外。(通常而言,任何一條通則一旦碰上virtual base class就沒有轍了,這裏亦不例外)

只要繼承不要多態(Inheritance without Polymorphism)

通常而言,具體繼承(concrete inheritance,譯註:相對於虛擬繼承virtual inheritance)並不會增長空間或存取時間上的額外負擔。

class Point2d{
public:
    Point2d(float x = 0.0, float y = 0.0)
        : _x(x), _y(y) {}
        
    float x() { return _x; }
    float y() { return _y; }
    
    void x(float newX) { _x = newX; }
    void y(float newY) { _y = newY; }
    
    void operator+=(const Point2d& rhs){
        _x += rhs.x();
        _y += rhs.y();
    }
    //... more members
protected:
    float _x, _y;
};

//inheritance from concrete class
class Point3d : public Point2d{
public:
    Point3d(float x = 0.0, float y = 0.0, float z = 0.0)
        : Point2d(x, y), _z(z) { };
    
    float z() { return _z; }
    void z(float newZ){ _z = newZ; }
    
    void operator+=(const Point3d& rhs){
        Point2d::operator(rhs);
        _z += rhs.z();
    }
protected:
    float _z;
};

這樣設計的好處就是能夠把管理x和y座標的程序代碼局部化。此外這個設計明顯表現出兩個抽象類之間的緊密關係。當這兩個classes獨立的時候,Point2d object和Point3d object的聲明和使用都不會有所改變,因此這兩個抽象類的使用者不須要知道objects是否爲獨立的classes類型,或是彼此之間有繼承的關係。

把兩個本來不想幹的classes湊出一對「type/subtype」,並帶有繼承關係,會有什麼易犯的錯誤呢?經驗不足的人可能會重複設計一些相同操做的函數。第二個易犯的錯誤是,把一個class分解爲兩層或更多層,有可能會爲了「表現class體系之抽象化」而膨脹所需空間。C++ 語言保證「出如今derived class中的base class subobject有其完整原樣性」。舉例以下:

class Concrete{
public:
    //...
private:
    int val;
    char c1;
    char c2;
    char c3;
};

在一部32位機器中,每個Concrete class object的大小都是8bytes,細分以下:

  1. val佔用4bytes
  2. cl,c2,c3各佔用1bytes
  3. alignment(調整到word邊界)須要1bytes

如今假設,通過某些分析以後,咱們決定了一個更邏輯的表達方式,把Concrete分裂成三層結構:

class Concrete1{
public:
    //..
private:
    int val;
    char bit1;
};

class Concrete2 : public Concrete1{
public:
    //...
private:
    char bit2;
};

class Concrete3 : public Concrete2{
public:
    //...
private:
    char bit3;
};

從設計的觀點來看,這個結構可能更合理。可是從效率的觀點來看,咱們可能會受困於一個事實:如今Concrete3 object的大小是16bytes,比原先的設計多了一倍。

怎麼回事?還記得「base class subobject在derived class中的原樣性」嗎?

Concrete1內含兩個members: val和bit1, 加起來是5bytes。而一個Concrete1 object實際用掉8bytes,包括填補用的3bytes,以使object可以符合一個機器的word邊界。Concrete2加了惟一一個nonstatic data member bit2,數據類型爲char,輕率的程序員會認爲它會和Concrete1捆綁在一塊兒,佔用本來用來填補的1bytes。然而Concrete2的bit2實際上倒是被放在填補的3bytes以後,因而大小變成12bytes,而不是8bytes。其中有6bytes浪費在填補空間上。相同的道理是Concrete3 object的大小是16bytes,其中9bytes用於填補空間。

聲明以下:

Concrete2 *pc2;
Concrete1 *pc1_1, *pc1_2;

其中pc1_1pc1_2二者均可以指向前述三種class objects。下面這個指定操做:

*pc1_2 = *pc1_1;

應該執行一個默認「memberwise」複製操做(複製一個個的members),對象是被指的object的Concrete1那一部分。若是pc1_1實際指向一個Concrete2 object或Concrete3 object,則上述操做應該將複製內容指定給其Concrete1 subobject。

然而,若是C++ 語言把derived class members(也就是Concrete2::bit2 或Concrete3::bit3)和Concrete subobject捆綁在一塊兒,去除填補空間,上面那些語意就沒法保留了,以下:

pc1_1 = pc2;    //令pc1_1指向Concrete2對象

//derived class subobject被覆蓋掉,因而bit2 member現有一個並不是預期的數值
*pc1_2 = *pc1_1;

加上多態(Adding Polymorphism)

class Point2d{
public:
    Point2d(float x = 0.0, float y = 0.0)
        : _x(x), _y(y) { };
        
    //x和y的存取函數與前一版相同
    //因爲對不一樣維度的點,這些函數的操做固定不變,因此沒必要設計爲virtual
    
    //加上z的保留空間(當前什麼也不作)
    virtual float z() { return 0.0; }
    virtual void z(float){ };
    
    //設定如下的運算符爲virtual
    virtual void operator+=(const Point2d& rhs){
        _x += rhs.x();
        _y += rhs.y();
    }
protected:  
    float _x, _y;
};

這樣的設計,給Point2d class帶來空間和存儲時間的額外負擔:

  • 導入一個和Point2d有關的virtual table,用來存放它所聲明的每個virtual functions的地址。這個table的元素數目通常而言是被聲明的virtual functions的數目,再加上一個或兩個slots(用以支持runtime type identification)
  • 每個class object中導入一個vptr,提供執行期的連接,使每個object可以找到相應的virtual table.
  • 增強constructor,使它可以爲vptr設定初值,讓它指向class所對應的virtual table.
  • 增強destructor,使它可以抹消「指向class之相關virtual table」的vptr

多重繼承(Multiple Inheritance)

單一繼承提供了一種「天然多態(natural polymorphism)」形式,是關於classed體系中的base type和derived type之間的轉換。

多重繼承既不像單一繼承,也不容易模塑出其模型。多重繼承的複雜度在於derived class和其上一個base class乃至於上上一個base class....之間的「非天然」關係。

考慮下面這個多重繼承所得到的class Vertex3d

class Point2d{
public:
    //...擁有virtual接口,因此,Point2d對象中會有vptr
protected:
    float _x, _y;
};

class Point3d : public Point2d{
public:
    //...
protected:
    float _z;
};

class Vertex{
public:
    //... 擁有virtual接口,因此Vertex對象之中會有vptr
protected:
    Vertex *next;
};

class Vertex3d : public Point3d, public Vertex{
public:
    //...
protected: 
    float mumble;
};

多重繼承的問題主要發生於derived class objects和其第二或後繼的base class object之間的轉換。不管是直接轉換以下:

extern void mumble(const Vertex&);
Vertex3d v;
...
//將一個Vertex3d轉換爲一個Vertex,這是「不天然的」
mumble(v);

或是經由其所支持的virtual function機制作轉換。

對一個多重派生對象,將其地址指定給「最左端(也就是第一個)base class的指針」,狀況將和單一繼承時相同,由於兩者都指向相同的起始地址。需付出的成本只有地址的指定操做而已。至於第二個或後繼的base class的地址指定操做,則須要將地址修改過:加上(或減去,若是downcast的話)介於中間的base class subobjects大小。例如:

Vertex3d v3d;
Vertex *pv;
Point2d *p2d;
Point3d *p3d;

通過下面這個指定操做:

pv = &v3d;

須要這樣的內部轉化:

pv = (Vertex*)(((char*)&v3d) + sizeof(Point3d));

而下面的指定操做:

p2d = &v3d;
p3d = &v3d;

都只須要簡單地拷貝其地址就好了。若是有兩個指針以下:

Vertex3d *pv3d;
Vertex *pv;

那麼下面的指定操做:

pv = pv3d;

不可以只是簡單地被轉換爲:

pv = (Vertex*)((char*)pv3d) + sizeof(Point3d);

由於若是pv3d爲0,pv將得到sizeof(Point3d)的值,這是錯誤的。因此,對於指針,內部轉換操做須要一個條件測試:

pv = pv3d ? (Vertex*)((char*)pv3d) + sizeof(Point3d) : 0  ;

至於reference,則不須要針對可能的0值作防衛,由於reference不可能參考到「無物」

若是要存取第二個(或後繼)base class中的一個data member會是怎樣的狀況?須要付出額外的成本嗎? 不,members的位置在編譯期就固定了,所以,存取members只是一個簡單的offset運算,就像單一繼承同樣簡單——無論是經由一個指針,一個reference或是一個object來存取。

虛擬繼承(Virtual Inheritance)

多重繼承的一個語意上的反作用就是,它必須支持某種形式的「shared subobject繼承」。一個典型的例子就是最先的iostream library:

不管是istream或ostream都內含一個ios subobject,然而在iostream的對象佈局中,咱們只須要一份ios subobject就好。語言層面的解決辦法是導入所謂的虛擬繼承。

通常的實現方法以下所述:Class若是內含一個或多個virtual base class subobjects,像istream那樣,將被分割爲兩部分:一個不變局部和一個共享局部。不變局部中的數據,無論後繼如何衍化,老是擁有固定的offset(從object的開頭算起),因此這一部分數據能夠被直接存取。至於共享局部,所表現的就是virtual base class subobject。這一部分的數據,其位置會由於每次的派生操做而有變化,因此它們只能夠被間接存取。各家編譯器實現技術之間的差別就在於間接存取的方法不一樣。

如下說明三種主流策略,下面是Vertex3d虛擬繼承的層次結構:

通常的佈局策略是先安排好derived class的不變部分,而後再創建其共享部分。

如何可以存取class的共享部分呢?

cfont編譯器會在每個derived class object中安插一些指針,每一個指針指向一個virtual base class。要存取繼承得來的virtual base class members,可使用相關指針間接完成。舉例以下:

void Point3d::operator+=(const Point3d &rhs){
    _x += rhs._x;
    _y += rhs._y;
    _z += rhs._z;
}

在cfront策略下,這個運算會被內部轉換爲:

_vbcPoint2d->_x += rhs._vbcPoint2d->_x;  // vbc意指virtual base class
_vbcPoint2d->_y += rhs._vbcPoint2d->_y;
_z += rhs._z;

而一個derived class和一個base class的實例之間的轉換,如

Point2d *p2d = pv3d;

在cfront實現模型下,會變成:

Point2d *p2d = pv3d ? pv3d->_vbcPoint2d : 0;

這樣的實現模型有兩個主要的缺點:

  1. 每個對象必須針對每個virtual base class揹負一個額外的指針。然而理想上咱們但願class object有固定的負擔,不由於其virtual base classes的數目而有所變化。
  2. 因爲虛擬繼承串鏈的加長,致使間接存取層次增長。好比,有三層虛擬衍化,就須要三次間接存取(經由三個virtual base class指針),然而理想上咱們卻但願有固定的存取時間,不由於虛擬衍化的深度而改變。

MetaWare和其餘編譯器使用cfront的原始模型來解決第二個問題,它們經由拷貝操做去的全部的nested virtual base class指針,放到derived class object中,這就解決了"固定存儲時間"的問題。雖然付出了一些空間上的代價。下圖說明了這種「以指針指向base class」的實現模型。

對於第一個問題,通常有兩個解決辦法。Microsoft編譯器引入所謂的virtual base class table。每個class object若是有一個或多個virtual base classes,就會由編譯器安插一個指針,指向virtual base class table。至於真正的virtual base class指針,固然是被放在表格中。

第二個解決辦法是在virtual function table中放置virtual base class的offset。下圖顯示了base class offset實現模型。

在新近的Sun編譯器中,virtual functon table可經由正值或負值來索引,若是是正值,很顯然就是索引到virtual function;若爲負值,則是索引到virtual base class offsets。在這樣的策略下,Point3d的operator+=運算符必須被轉換爲如下形式:

(this + _vptr_Point3d[-1])->_x += (&rhs + rhs._vptr_Point3d[-1])->_x;
(this + _vptr_Point3d[-1])->_y += (&rhs + rhs._vptr_Point3d[-1])->_y;
_z += rhs._z;

上述的每一種方法都是一種實現模型,而不是一種標準,每一種模型都是用來解決「存取shared subobject內的數據(其位置會由於每次派生操做而有變化)」所引起的問題。因爲對virtual base class的支持帶來額外的負擔以及高度的複雜性,每一種模型多少有點不一樣,並且還會隨着時間而進化。

通常而言,virtual base class最有效的一種運用形式就是:一個抽象的virtual base class,沒有任何data members

指向Data members的指針(Pointer to Data Members)

考慮下面的Point3d聲明,其中有一個virtual function, 一個static data member,以及三個座標值:

class Point3d{
public:
    virtual ~Point3d();
    //...
protected:
    static Point3d origin;
    float x, y, z;
};

取某個座標成員的地址,表明什麼意思? 以下:

&Point3d::z;

上述操做將獲得z座標的class object中的偏移量(offset),最低限度其值將是x和y的大小總和,覺得C++ 語言要求同一個access level中的members的排列次序應該和其聲明次序相同。

然而vptr的位置就沒有限制,實際上vptr不是放在對象的頭部,就是放在對象的尾部。每個float是4bytes,因此咱們應該指望剛纔得到的值要不是8,就是12(在32位機器上一個vptr是4bytes)

然而,這樣的指望還少1bytes。

若是vptr放在對象的尾端,則三個座標值在對象佈局中的offset分別是0,4,8。若是vptr放在對象的起頭,則三個座標值在對象佈局中的offset分別是4,8,12。然而你若去取data members的地址,傳回的值老是多1, 也就是1,5,9或9,5,13等等。

如何區分一個「沒有指向任何data member」的指針,和一個指向「第一個data member」的指針?考慮這樣的例子:

float Point3d::*p1 = 0;
float Point3d::*p2 = &Point3d::x;
//Point3d::*的意思是:「指向Point3d data member」的指針類型

//如何區分
if(p1 == p2){
    cout << "p1 & p2 contain the same value --" ;
    cout << " they must address the same member!" << endl;
}

爲了區分p1和p2, 每個真正的member offset值都被加上1。所以,不論編譯器或使用者都必須記住,在真正使用該值以指出一個member以前,請先減掉1

認識「指向data members的指針」以後,咱們發現,要解釋:

&Point3d::z;  
&origin.z;

之間的差別,就很是明確了。鑑於「取一個nonstatic data member的地址,將會獲得它在class中的offset」,取一個「綁定於真正class object身上的data member」的地址,將會獲得該member在內存中的真正地址。把

&origin.z

所得結果減z的偏移量(相對於origin的起始地址),並加1,就會獲得origin的起始地址。上一行的返回值類型應該是float*,而不是float Point3d::*

因爲上述操做所參考的是一個特定實例,因此取一個static data member的地址,意義也相同。

在多重繼承中,若要將第二個(或後繼)base class的指針,和一個「與derived class object綁定」的member結合起來,那麼將會由於「須要加入offset值」而變得至關複雜。例如:

struct Base1{ int val1; }
struct Base2{ int val2; }
struct Derived : Base1, Base2{ ... }

void fun1(int Derived::*dmp, Derived *pd){
    //指望第一個參數獲得一個「指向derived class之member」的指針
    //若是傳進來的倒是一個"指向base class之member"的指針,會怎樣
    pd->*dmp;
}

void fun2(Derived *pd){
    //bmp將成爲1
    int Base2::*bmp = &Base2::val2;
    
    //bmp = 1
    //可是在Derived中,val2 = 5
    fun1(bmp, pd);
}

當bmp被做爲fun1()的第一個參數時,它的值就必須因介入的Base1 class的大小而調整,不然fun1()中這樣的操做:

pd->*dmp;

將存取Base1::val1,而非程序員因此爲的Base2::val2。要解決這個問題,必須

//經由編譯器內部轉換
fun1(bmp + sizeof(Base1), pd);

然而,通常而言,咱們不能保證bmp不是0,所以必須特別留意之:

//內部轉換
//防範bmp == 0
fun1(bmp ? bmp + sizeof(Base1) : 0, pd);
相關文章
相關標籤/搜索