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

Function語意學(The Semantics of Function)

static member functions不可能作到的兩點:(1)直接存取nonstatic數據,(2)被聲明爲const的。程序員

Member的各類調用方式

Nonstatic Member Functions(非靜態成員函數)

C++的設計準則之一就是:nonstatic member function至少必須和通常的nonmember function有相同的效率。好比,要在下面兩個函數之間做選擇:express

float magnitude3d(const Point3d *_this){ ... }
float Point3d::magnitude3d() const { ... }

選擇member function不該該帶來什麼額外負擔。這是由於編譯器內部已將「member函數實例」轉換爲對等的「nonmember函數實例」安全

下面是magnitude()的一個nonmember定義:ide

float magnitude3d(const Point3d *_this){
    return sqrt( _this->_x * _this->_x +
                 _this->_y * _this->_y +
                 _this->_z * _this->_z );
}

乍見之下彷佛nonmember function比較沒有效率,它間接地經由參數取用座標成員,而member function倒是直接取用座標成員,然而實際上member function被內化爲nonmember的形式。 轉化步驟以下:函數

  1. 改寫函數的signature(譯註:意指函數原型)以安插一個額外的參數到member function中,用以提供一個存取管道,使class object得以將此函數調用。該額外參數被稱爲this指針。
  2. 將每個「對nonstatic data member的存取操做」改成經由this指針來存取。
  3. 將member function從新寫成一個外部函數。將函數名稱通過「mangling」處理,使它在程序中稱爲獨一無二的詞彙。

名稱的特殊處理(Name Mangling)

通常而言,member的名稱前面會加上class的名稱,造成獨一無二的命名。例以下面的聲明:佈局

class Bar{ public: int ival; ... };

其中的ival有可能變成這樣:this

//member通過name-mangling以後的可能結果之一
ival_3Bar

爲何編譯器要這麼作?請考慮以下派生操做:編碼

class Foo : public  Bar{ public: int ival; ... }

記住,Foo對象內部結合了base class和derived class二者:lua

//Foo的內部描述
class Foo{
public:
    int ival_3Bar;
    int ival_3Foo;
    ...
};

無論你要處理哪個ival,經過"name mangling",均可以絕對清楚地指出來。因爲member function能夠被重載化(overload),因此須要更普遍的mangling手法,以提供絕對獨一無二的名稱。設計

把參數和函數名稱編碼在一塊兒,編譯器是在不一樣的編譯模塊之間達成了一種有限形式的類型檢驗。舉例以下,若是一個print函數被這樣定義:

void print(const Point3d& ){ ... }

但意外地被這樣聲明和調用:

//覺得是const Point3d&
void print(const Point3d );

兩個實體若是擁有獨一無二的name mangling,那麼任何不正確的調用操做在連接時期就因沒法決議(resolved)而失敗。有時候咱們能夠樂觀地稱此爲「確保類型安全的連接行爲」(type-safe linkage)。我說「樂觀地」是由於它能夠捕捉函數標記(signature,亦即函數名稱+參數數目 + 參數類型)錯誤;若是「返回類型」聲明錯誤,就沒有辦法檢查出來。

Virtual Member Functions(虛擬成員函數)

若是normalize()是一個virtual member function,那麼如下調用:

ptr->normalize();

將會被內部轉化爲:

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

其中:

  • vptr表示由編譯器產生的指針,指向virtual table。它被安插在每個"聲明有(或繼承自)一個或多個virtual functions"的class object中。事實上其名稱也會被"mangled",由於在一個複雜的class派生體系中,可能存在有多個vptrs
  • 1 是virtual tabel slot的索引值,關聯到normalize()函數
  • 第二個ptr表示this指針

Static Member Functions(靜態成員函數)

若是Point3d::normalize()是一個static member function,如下兩個調用操做:

obj.normalize();
ptr->normalize();

將被轉換爲通常的nonmember函數調用,如:

//obj.normalize()
normalize_7Point3dSFv();
//ptr->normalize()
normalize_7Point3dSFv();

在引入static member functions以前,C++ 語言要求全部的member functions都必須經由該class的object來調用。而實際上,只有當一個或多個nonstatic data members在member function中被直接存取時,才須要class object。class object提供了this指針給這種形式的函數調用使用。這個this指針把「在member function中存取的nonstatic class members」綁定於「object內對應的members」之上。若是沒有任何一個members被直接存取,事實上就不須要this指針,所以也就沒有必要經過一個class object來調用一個member function。不過,C++ 語言到當前爲止並不能識別這種狀況。

static member functions的主要特性是它沒有this指針。如下的次要特性通通根源於其主要特性:

  • 它不可以直接存取其class中的nonstatic members
  • 它不可以被聲明爲const、volatile或virtual
  • 它不須要經由class object才被調用,雖然大部分時候它是這樣被調用的。

若是取一個static member function的地址,得到的將是其在內存中的位置,也就是其地址。因爲static member function沒有this指針,因此其地址的類型並非一個「指向class member of function的指針」,而是一個「nonmember函數指針」。

Virtual Member Functions(虛擬成員函數)

virtual function的通常實現模型:每個class有一個virtual table,內含該class之中有做用的virtual function的地址,而後每一個object有一個vptr,指向virtual table的所在。

爲了支持virtual function機制,必須首先可以對於多態對象有某種形式的「執行期類型判斷法(runtime type resolution)」。也就是說,如下的調用操做將須要ptr在執行期的某些相關信息,

ptr->z();

如此一來纔可以找到並調用z()的適當實體。

在C++中,多態(polymorphism)表示「以一個public base class的指針(或reference),尋址出一個derived class object」的意思。例以下面的聲明:

Point *ptr;

咱們能夠指定ptr以尋址出一個Point2d對象:

ptr = new Point2d;

或是一個Point3d對象

ptr = new Point3d;

ptr的多態機能主要扮演一個輸送機制(transport mechanism)的角色,經由它,咱們能夠在程序的任何地方採用一組public derived類型,這種多態形式被稱爲消極的(passive),能夠在編譯期完成——virtual base class的狀況除外。

當被指出的對象真正被使用時,多態也就變成積極的(active)了。以下:

//積極多態的常見例子
ptr->z();

在runtime type identification(RTTI)性質於1993年被引入C++ 語言以前,C++ 對「積極多態」的惟一支持,就是對virtual function call的決議(resolution)操做。有了RTTI,就可以在執行期查詢一個多態的指針或多態的reference了

//積極多態的第二個例子
if(Point3d *p3d = dynamic_cast<Point3d*>(ptr))
    return p3d->_z;

在實現上,能夠在每個多態對象的class object身上增長兩個members:

  1. 一個字符串或數字,表示class的類型
  2. 一個指針,指向表格,表格中帶有程序的virtual function的執行期地址

表格中的virtual functions地址如何被構建起來?在C++ 中,virtual function(可經由其class object被調用)能夠在編譯時期獲知。此外,這一組地址是固定不變的。執行期不可能新增或替換之。因爲程序執行時,表格的大小和內容都不會改變,因此其建構和存取皆能夠由編譯器徹底掌控,不須要執行期的任何介入。

一個class只會有一個virtual table,每個table內含其對應的class object中全部active virtual functions函數實例的地址。這些active virtual functions包括:

  • 這一class所定義的函數實例。它會改寫(overriding)一個可能存在的base class virtual function函數實例。
  • 繼承自base class的函數實例。這是在derived class決定不改寫virtual function時纔會出現的狀況
  • 一個pure_virtual_called()函數實例,它既能夠扮演pure virtual function的空間保衛者角色,也能夠當作執行期異常處理函數(有時候會用到)

每個virtual function都被指派一個固定的索引值,這個索引在整個繼承體系中保持與特定的virtual function的關係。以下的Point class體系中:

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

vitual destructo被賦值slot 1。而mult()被賦值slot 2。mult()並無函數定義(由於它是一個pure virtual function),因此pure_virtual_calssed()的函數地址會被放在slot 2中,若是該函數意外地被調用,一般的操做是結束掉這個程序。y()被賦值slot 3而z()被賦值slot 4。下圖爲Point的內存佈局和virtual table。

單一繼承下的Virtual Functions

當一個class派生自Point時,會發生什麼事情?

class Point2d : public  Point{
public:
    Point2d(float x = 0.0, float y = 0.0)
        : Point(x), _y(y) { }
    ~Point2d();
    
    //改寫base class virtual functions
    Point2d &mult(float);
    float y() const { return _y ;}
    //... 其餘操做
protected:
    float _y;
};

一共有三種可能性:

  1. 它能夠繼承base class所聲明的virtual function的函數實體。正確地說,是該函數實體得地址會被拷貝到derived class的virtual table的相對應的slot之中
  2. 它能夠實現本身的函數實體,表示它本身的函數實體地址必須放在對應的slot中
  3. 它能夠加入一個新的virtual function。這時候virtual table的尺寸會增大一個slot,而新的函數實體地址被放進該slot中

相似的狀況以下,Point3d派生自Point2d:

class Point3d : public Point2d{
public:
    Point3d(float x = 0.0, float y = 0.0, float z = 0.0)
        : Point2d(x, y), _z(z){ }
    ~Point3d();
    
    //改寫base class virtual functions
    Point3d &mult(float);
    float z() const { return _z; }
    //...
protected:
    float _z;
};

其virtual table中的slot 1 放置Point3d destructor,slot 2放置Point3d::mult()。slot 3放置繼承自Point2d的y()函數地址,slot 4放置本身的z() 函數地址。Point2d,Point3d的對象佈局和virtual table以下:

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

多重繼承下的Virtual Functions

在多重繼承中支持virtual functions,其複雜讀圍繞在第二個及後繼的base classes身上,以及「必須在執行期調整this指針」這一點。如下面的class體系爲例:

class Base1{
public:
    Base1();
    virtual Base1();
    virtual void speakClearly();
    virtual Base1 *clone() const;
protected:
    float data_Base1;
};

class Base2{
public:
    Base2();
    virtual ~Base2();
    virtual void mumble();
    virtual Base2 *clone() const;
protected:
    float data_Base2;
};

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

Derived 支持virtual functions的困難度,通通落在Base2 subobject身上。有三個問題須要解決,以此而言分別是(1)virtual destructor,(2)被繼承下來的Base2::mumble(),(3)一組clone()函數實體。

首先,把一個從heap中配置而得的Derived對象的地址,指定給一個Base2指針:

Base2 *pbase2 = new Derived;

新的Derived對象的地址必須調整,以指向其Base2 subobject。編譯時期會產生如下的碼:

//轉移以支持第二個base class
Derived *tmp = new Derived;
Base2 *pbase2 = tmp ? tmp + sizeof(Base1) : 0;

若是沒有這樣的調整,指針的任何「非多態運用」都將失敗:

//即便pbase2被指定一個Derived對象,這也應該沒有問題
pbase2->data_Base2;

當程序員要刪除pbase2所指的對象時:

//必須首先調用正確的virtual destructor函數實體
//而後施行delete運算符
//pbase2可能須要調整,以指出完整對象的起始點
delete pbase2;

指針必須被再一次調整,以求再一次指向Derived對象的起始處(推測它還指向Derived對象)。然而上述的offset加法卻不可以在編譯時期直接設定,由於pbase2所指的真正對象只有在執行期才能肯定。

通常規則是,經由指向「第二或後繼之base class」的指針(或reference)來調用derived class virtual function。如:

Base2 *pbase2 = new Derived;
...
delete pbase2; //invoke derived class's destructor(virtual)

該調用操做所連帶的「必要的this指針調整」操做,必須在執行期完成。也就是說,offset的大小,以及把offset加到this指針上頭的那一小段程序代碼,必須經由編譯器在某個地方插入。

比較有效率的解決辦法是利用所謂的thunk。所謂thunk是以小段assembly代碼,用來(1)以適當的offset值調整this指針,(2)調到virtual function去。例如,經由一個Base2指針用Derived destructor,其相關的thunk可能看起來是這個樣子的:

pbase2_dtor_thunk:
    this += sizeof(base1);
    Derived::~Derived(this);

Thunk技術容許virtual table slot繼續內含一個簡單的指針,所以多重繼承不須要任何空間上的額外負擔。Slots中的地址能夠直接指向virtual function,也能夠指向一個相關的thunk(若是須要調整this指針的話)。因而,對於那些不須要調整this指針的virtual function而言,也就不需承載效率上的額外負擔。

調整this指針的第二個額外負擔就是,因爲兩個不一樣的可能:(1)經由derived class(或第一個base class)調用,(2)經由第二個(或其後繼)base class調用,同一函數在virtual table中可能須要多筆對應的slots。如:

Base1 *pbase1 = new Derived;
Base2 *pbase2 = new Derived;

delete pbase1;
delete pabse2;

雖然兩個delete操做致使相同的Derived destructor,但它們須要兩個不一樣的virtual table slots:

  1. pbase1不須要調整this指針(由於Base1是最左端base class之故,它已經指向Derived 對象的起始處),其virtual table slot需放置真正的destructor地址。
  2. pbase2須要調整this指針,其virtual table slot須要相關的thunk地址

在多重繼承之下,一個derived class內含n-1個額外的virtual tables,n表示其上一層base classes的個數(所以,單一繼承將不會有額外的virtual tables)。

針對每個virtual tables,Derived對象中有對應的vptr。下圖說明了這點,vptrs將在constructor(s)中被設定初值(經由編譯器所產生的碼)

用以支持「一個class擁有多個virtual tables」的傳統方法是,將每個tables之外部對象的形式產生出來,並給予獨一無二的名稱。例如,Derived所關聯的兩個tables可能有這樣的名稱:

vtbl_Derived;   //主要表格
vtbl_Base2_Derived;   //次要表格

因而當你將一個Derived對象地址指定給一個Base1指針或Derived指針時,被處理的virtual table是主要表格vtbl_Derived。而當你將一個Derived對象地址指定給一個Base2指針時,被處理的virtual table是次要表格vtbl_Base2_Derived。

因爲執行期連接器(runtime linkers)的降臨(能夠支持動態共享函數庫),符號名稱的連接變得很是緩慢。爲了調節執行期連接器的效率,Sun編譯器將多個virtual tables連鎖爲一個;指向次要表格的指針,可由主要表格名稱加上一個offset得到。在這樣的策略下,每個class只有一個具名的virtual table。

有如下三種狀況,第二或後繼的base class會影響對virtual functions的支持。

  1. 第一種狀況是,經過一個"指向第二個base class"的指針,調用derived class virtual function。例如
Base2 *ptr = new Derived;

//調用Derived::~Derived
//ptr必須向後調整sizeof(Base1)個bytes
delete ptr;

從上面那個圖能夠看到這個調用操做的重點:ptr指向Derived對象中的Base2 subobject;爲了可以正確執行,ptr必須調整指向Derived對象的起始處。

  1. 第二種狀況是第一種狀況的變化,經過一個「指向derived class」的指針,調用第二個base class中一個繼承而來的virtual function。在此狀況下,derived class指針必須再次調整,以指向第二個base subobject。例如:
Derived *pder = new Derived;

//調用Base2::mumble()
//pder必須被向前調整sizeof(Base1)個bytes
pder->mumble();
  1. 第三種狀況發生於一個語言擴充性質之下:容許一個virtual function的返回值類型有所變化,多是base type,也多是publicly derived type。這一點能夠經過Derived::clone()函數實體來講明。clone()的Derived版本傳回一個Derived class指針,默默地改寫了它的兩個base class的函數實體。當咱們經過「指向第二個base class」的指針來調用clone()時,this指針的offset問題因而誕生:
Base2 *pb1 = new Derived;

//調用Derived * Derived::clone()
//返回值必須被調整,指向Base2 subobject
Base2 *pb2 = pb1->clone();

當進行pb1->clone()時,pb1會被調整指向Derived對象的起始地址,因而clone()的Derived版會被調用;它會傳回一個指針,指向一個新的Derived對象,該對象的地址在被指定給pb2以前,必須先通過調整,以指向Base2 subobject。

虛擬繼承下的Virtual Functions

考慮下面的virtual base class派生體系,從Point2d派生出Point3d:

class Point2d{
public:
    Point2d(float = 0.0, float = 0.0);
    virtual ~Point2d();
    
    virtual void mumble();
    virtual float z();
protected:
    float _x, _y;
};

class Point3d : public virtual Point2d{
public:
    Point3d(float = 0.0, float = 0.0, float = 0.0);
    ~Point3d();
    
    float z();
protected:
    float _z;
};

其內存佈局以下圖:

當一個virtual base class從另外一個virtual base class派生而來,而且二者都支持virtual functions和nonstatic data members時,編譯器對於virtual base class的支持簡直就像進了迷宮同樣。建議不要在一個virtual base class中聲明nonstatic data members。

指向Member Function指針(Pointer-to-Member Functions)

取一個nonstatic data member的地址,獲得的結果是該member在class佈局中bytes位置(再加1)。能夠想象,它是一個不完整的值,它須要被綁定於某個class object的地址上,纔可以被存取

取一個nonstatic data member的地址,若是該函數是nonvirtual,獲得的結果是它在內存中真正的地址。然而這個值也是不徹底的。它也須要被綁定於某個class object的地址上,纔可以經過它調用該函數。全部的nonstatic member functions都須要對象的地址(以參數this指出)

一個指向member fucntion的指針,其聲明語法以下:

double           //return type
( Point::*       //class the function is member
   pmf)          //name of pointer to member
  ();            //argument list

而後咱們能夠這樣定義並初始化該指針:

double (Point::*coord)() = &Point::x;

也能夠這樣指定其值:

coord = &Point::y;

想要調用它,能夠這樣作:

(origin.*coord)();
或
(ptr->*corrd)();

這些操做會被編譯器轉化爲:

(coord)(&origin);
和
(coord)(ptr);

指向member function的指針的聲明語法,以及指向「member selection運算符」的指針,其做用是做爲this指針的空間保留着。這也就是爲何static member functions(沒有this指針)的類型是「函數指針」,而不是「指向member function的指針」之故。

使用一個「member function指針」,若是並不用於virtual function、多重繼承、virtual base class等狀況的話,並不會比使用一個「nonmember function指針」的成本高

支持「指向Virtual Member Function」的指針

注意下面的程序片斷:

float (Point::*pmf)() = &Point::z;
Point *ptr = new Point3d;

pmf,一個指向member function的指針,被設值爲Point::z() (一個virtual function)的地址。ptr則被指定以一個Point3d對象,若是咱們經由ptr調用z():

ptr->z();

則被調用的是Point3d::z()。一樣,咱們能夠從pmf間接調用z()

(ptr->*pmf)();

也就是說,虛擬機制仍然可以在使用"指向memeber function之指針"的狀況下運行。

對一個nonstatic member function取其地址,將得到該函數在內存中的地址。然而面對一個virtual function,其地址在編譯時期是未知的,所能知道的僅是virtual function在其相關之virtual table之中的索引值。也就是說,對一個virtual member function取其地址,所能得到的只是一個索引值。

例如:假設咱們有如下的Point聲明:

class Point{
public:
    virtual ~Point();
    float x();
    float y();
    virtual float z();
};

而後取其destructor的地址:

&Point::~Point

獲得的結果是 1, 取x()或y()的地址:

&Point::x();
&Point::y();

獲得的則是函數在內存中的地址,由於它們不是virtual。取z()的地址:

&Point::z();

獲得的結果是2。經過pmf來調用z(),會被內部轉化爲一個編譯時期的式子,通常形式以下:

(*ptr->vptr[(int)pmf])(ptr);

對一個「指向member function的指針」評估求值,會由於改制有兩種意義而複雜化;其調用操做也將有別於常規的調用操做。集編譯器必須定義函數指針使它可以(1)含有兩種數值,(2)更重要的是其數值能夠被區別表明內存地址仍是virtual table中的索引值。

在多重繼承下,指向Member Functions的指針

爲了讓指向member functions的指針也可以支持多重繼承和虛擬繼承,Stroustrup設計了下面一個結構體:

//通常結構,用以支持在多重繼承之下指向member functions的指針
struct _mptr{
    int delta;
    int index;
    union{
        protofunc faddr;
        int v_offset;
    };
};

它們要表現什麼呢?index和faddr分別(不一樣時)帶有virtual table索引和nonvirtual member function地址(爲了方便,當index不指向virtual table時,會被設爲-1)。在該模型下,像這樣的調用操做:

(ptr->*pmf)();

會變成:

(pmf.index < 0) 
   ?    //non-virtual invocation
   ( *pmf.faddr )( ptr)
   :   //virtual invocation
   (* ptr->vptr[pmf.index](ptr));

這種方法所受的批評是:每個調用操做都得付出上述成本,檢查其是否爲virtual或nonvirtual。Microsoft把這項檢查拿掉,導入一個它所謂的vcall thunk。在此策略下,faddr被指定的要不就是真正的member function(若是函數是nonvirtual的話),要不就是vcall thunk的地址。因而virtual或nonvirtual函數的調用操做透明化,vcall thunk會選出並調用相關virtual table中適當的slot。

delta字段表示this指針的offset值,而v_offset字段放的是一個virtual(或多重繼承中的第二或後繼的)base class的vptr位置。若是vptr被編譯器放在class對象的起頭處。這個字段就沒有必要了,代價則是C對象兼容性下降。這些字段只在多重繼承或虛擬繼承的狀況下才有其必要性,有許多編譯器在自身內部根據不一樣的classes特性提供多種指向member functions的指針形式,例如Microsoft就供應了三種風味:

  1. 一個單一繼承實例(其中帶有vcall thunk地址或是函數地址)
  2. 一個多重繼承實例(其中帶有faddr和delta兩個members)
  3. 一個虛擬繼承實例(其中帶有四個members)

Inline Functions

通常而言,處理一個inline函數,有兩個階段:

  1. 分析函數定義,以決定函數的「intrinsic inlin ability」(本質的inline能力)。「intrinsic」(本質的,固有的)一詞在這裏意指「與編譯器相關」

若是函數因其複雜度,或因其建構問題,被判斷不可成爲inline,它會被轉爲一個static函數,並在「被編譯模塊」內產生對應的函數語義。

  1. 真正的inline函數擴展操做是在調用的那一點上。這會帶來參數的求值操做(evaluation)以及臨時性對象的管理。

一樣在擴展點上,編譯器將決定這個調用是否「不可爲inline」。

形式參數(Formal Arguments)

通常而言,面對「會帶來反作用的實際參數」,一般都須要引入臨時性對象。換句話說,若是實際參數時一個常量表達式(constant expression),咱們能夠在替換以前先完成其求值操做(evaluations);後繼的inline替換,就能夠把常量直接「綁」上去。若是既不是常量表達式,也不是帶有反作用的表達式,那麼就直接替換之。
舉例以下,假設咱們有如下的簡單inline函數:

inline int min(int i, int j){
    return i < j ? i : j;
}

下面是三個調用操做:

inline int bar(){
    int minval;
    int val1 = 1024;
    int val2 = 2048;
    
    /*(1)*/ minval = min(val1, val2);
    /*(2)*/ minval = min(1024, 2048);
    /*(3)*/ minval = min(foo(), bar() + 1);
    return minval;
}

標識爲(1)的那一行會被擴展爲:

minval = val1 < val2 ? val1 : val2;

標識爲(2)的那一行直接擁抱常量:

minval = 1024;

標識爲(3)的那一行則引起參數的反作用,它須要導入一個臨時對象,以免重複求值(multiple evaluations)

int t1;
int t2;
minval = (t1 = foo()), (t2 = bar() + 1),
        t1 < t2 ? t1 : t2;

局部變量(Local Variables)

通常而言,inline函數中的每個局部變量都必須被放在函數調用的一個封閉區段中,擁有一個獨一無二的名稱。若是inline函數以單一表達式(expression)擴展屢次,則每次擴展都須要本身的一組局部變量。若是inline函數以分離的多個式子(discrete statements)被擴展屢次,那麼只需一組局部變量,就能夠重複使用(譯註:由於它們被放在一個封閉區段中,有本身的scope)

相關文章
相關標籤/搜索