深入探索C++物件模型(四)
https://www.cnblogs.com/lengender-12/p/6959222.html
Function語意學(The Semantics of Function)
static member functions不可能做到的兩點:(1)直接存取nonstatic資料,(2)被宣告為const的。
Member的各種呼叫方式
Nonstatic Member Functions(非靜態成員函式)
C++的設計準則之一就是:nonstatic member function至少必須和一般的nonmember function有相同的效率。比如,要在下面兩個函式之間作選擇:
float magnitude3d(const Point3d *_this){ … }
float Point3d::magnitude3d() const { … }
選擇member function不應該帶來什麼額外負擔。這是因為編譯器內部已將“member函式例項”轉換為對等的“nonmember函式例項”
下面是magnitude()的一個nonmember定義:
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的形式。 轉化步驟如下:
改寫函式的signature(譯註:意指函式原型)以安插一個額外的引數到member function中,用以提供一個存取管道,使class object得以將此函式呼叫。該額外引數被稱為this指標。
將每一個“對nonstatic data member的存取操作”改為經由this指標來存取。
將member function重新寫成一個外部函式。將函式名稱經過“mangling”處理,使它在程式中稱為獨一無二的詞彙。
名稱的特殊處理(Name Mangling)
一般而言,member的名稱前面會加上class的名稱,形成獨一無二的命名。例如下面的宣告:
class Bar{ public: int ival; … };
其中的ival有可能變成這樣:
//member經過name-mangling之後的可能結果之一
ival_3Bar
為什麼編譯器要這麼做?請考慮如下派生操作:
class Foo : public Bar{ public: int ival; … }
記住,Foo物件內部結合了base class和derived class兩者:
//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:
一個字串或數字,表示class的型別
一個指標,指向表格,表格中帶有程式的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;
};
一共有三種可能性:
它可以繼承base class所宣告的virtual function的函式實體。正確地說,是該函式實體得地址會被拷貝到derived class的virtual table的相對應的slot之中
它可以實現自己的函式實體,表示它自己的函式實體地址必須放在對應的slot中
它可以加入一個新的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:
pbase1不需要調整this指標(因為Base1是最左端base class之故,它已經指向Derived 物件的起始處),其virtual table slot需放置真正的destructor地址。
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的支援。
第一種情況是,通過一個"指向第二個base class"的指標,呼叫derived class virtual function。例如
Base2 *ptr = new Derived;
//呼叫Derived::~Derived
//ptr必須向後調整sizeof(Base1)個bytes
delete ptr;
從上面那個圖可以看到這個呼叫操作的重點:ptr指向Derived物件中的Base2 subobject;為了能夠正確執行,ptr必須調整指向Derived物件的起始處。
第二種情況是第一種情況的變化,通過一個“指向derived class”的指標,呼叫第二個base class中一個繼承而來的virtual function。在此情況下,derived class指標必須再次調整,以指向第二個base subobject。例如:
Derived *pder = new Derived;
//呼叫Base2::mumble()
//pder必須被向前調整sizeof(Base1)個bytes
pder->mumble();
第三種情況發生於一個語言擴充性質之下:允許一個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:?;
Point *ptr = new Point3d;
pmf,一個指向member function的指標,被設值為Point:?() (一個virtual function)的地址。ptr則被指定以一個Point3d物件,如果我們經由ptr呼叫z():
ptr->z();
則被呼叫的是Point3d:?()。同樣,我們可以從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:?();
得到的結果是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->vptrpmf.index);
這種方法所受的批評是:每一個呼叫操作都得付出上述成本,檢查其是否為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就供應了三種風味:
一個單一繼承例項(其中帶有vcall thunk地址或是函式地址)
一個多重繼承例項(其中帶有faddr和delta兩個members)
一個虛擬繼承例項(其中帶有四個members)
Inline Functions
一般而言,處理一個inline函式,有兩個階段:
分析函式定義,以決定函式的“intrinsic inlin ability”(本質的inline能力)。“intrinsic”(本質的,固有的)一詞在這裡意指“與編譯器相關”
如果函式因其複雜度,或因其建構問題,被判斷不可成為inline,它會被轉為一個static函式,並在“被編譯模組”內產生對應的函式語義。
真正的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)