深度探索C++對象模型
- 什么是C++對象模型:
- 語言中直接支持面向對象程序設計的部分.
- 對於各個支持的底層實現機制.
- 抽象性與實際性之間找出平衡點, 需要知識, 經驗以及許多思考.
導讀
- 這本書是C++第一套編譯器cfront的設計者所寫.
- 了解C++對象模型, 有助於在語言本身以及面向對象觀念兩方面層次提升.
- explicit(明確出現於C++程序代碼).
- implicit(隱藏於程序代碼背后).
關於對象
- 每個非內聯(non-inline)成員函數只會誕生一個函數實例. 而內聯函數會在每個使用者身上產生一個函數實例.
- C++在布局以及存取時間上的額外負擔主要由虛(virtual)引起的:
- 虛函數機制(virtual function)用於支持一個有效率的運行期綁定(runtime binding).
- 虛基類(virtual base class) 用以實現多次出現在繼承體系中的基類, 有一個單一而被共享的實例.
- 額外負擔, 派生類轉換.
- 在C++中, 有兩種類數據成員: 靜態(static) 和非靜態(non-static).
- 三種類成員函數: 靜態(static), 非靜態(non-static)和虛函數(virtual).
- 每個數據成員或成員函數都有自己的一個slot(元素, 位置, 槽) --- 針對vtbl(虛表)而言.
- 成員函數表(member function table)成為了支持虛函數(virtual function)的一個有效方案.
C++對象模型
- 非靜態數據成員被置於每一個類對象中, 靜態數據成員被存放在個別的類對象之外.
- 靜態和非靜態函數也被存放在個別的類對象之外.
- 虛函數利用虛表(vbtl)和虛表指針(vptr)設置.
- 每個類產生一堆指向虛函數的指針, 並放到表格之中.
- 每個類對象被安插一個指針, 指向相關的virtual table(虛表).
- vptr的設定與重置都由每一個類的構造, 析構和copy賦值運算符自動完成.
- 每個類的type_info(類型信息)的對象也由虛表(virtual table)指出, 通常放在表格的第一個槽(slot).
- 在虛擬繼承的情況下, 基類不管在繼承串鏈中被派生多少次, 永遠只會存在一個實例.
- class不僅是一個關鍵字, 它還會引入它所支持的封裝和繼承的哲學.
- 某種意義上, 在C++中struct和class這兩個關鍵字是可以互換的.
- 基類和派生類的數據成員的布局沒有誰先誰后的強制規定, 但使用初始化列表時, 必須保持成員變量順序的一致性.
- 組合而非繼承, 才是把C和C++結合在一起的唯一可行方法.
對象的差異性
- 三種程序設計范式:
- 程序模型.
- 抽象數據類型(基於對象).
- 面向對象模型.
- 應該還有一個模板編程(范式模型).
- 只有通過指針或引用的間接處理基類對象, 才支持面向對象程序設計所需的多態性質.
- C++中, 多態只存在與public 類體系中, nonpublic的派生行為和void*的指針的多態性, 必須由程序員來顯式管理.
- 隱式轉換操作: 把一個派生類的指針轉換為一個指向public基類類型的指針.
- 由虛函數(virtual function)機制:
ps->rotate();
. - 由dynamic_cast和typeid運算符轉換:
dynamic_cast<base_class *> (derived_class *);
.
- 多態的主要用途是經由一個共同的接口來影響類型的封裝, 這個接口一般定義在一個抽象的基類中.
- 一個指針, 不管它指向那種數據類型, 其本身所需內存大小是固定的, 與計算機的位數一致.
- 指針類型會教導編譯器如何解釋某個特定地址中的內存內容及其大小.
- void*指針能夠持有一個地址, 但不能通過 它來操作所指對象, 因為不知道其覆蓋怎樣的地址空間.
- 派生類不會新添加虛表指針(vptr, 繼續使用基類的指針), 只是覆蓋的地址會有所不同.
- 類型信息的封裝並不是維護於指針之中, 而是維護於鏈接(link)之中, 此鏈接存在於對象的虛表指針(vptr), 和vptr所指的虛表(virtual table)之間.
- 編譯器必須確保每個對象有一個或一個以上的vptr, 這些vptr的內容不會被基類對象初始化或改變.
- 一個指針或引用之所以支持多態, 是因為它們並不引發內存中任何與內存相關的內存委托操作, 會改變的只有他們所指內存的"大小和內容解釋方式"而已.
- 將派生類直接用於初始化基類對象時, 派生類對象會被切割以塞入較小的基類類型內存中.
- C++通過指針(pointer)和引用(reference)來支持多態.
構造函數語義
-
默認構造函數的構造操作:
- 會插入一些構造函數的代碼.
-
編譯器為未聲明任何構造的類, 編譯器會為他們合成一個默認的構造函數.
-
被合成出來的構造函數只滿足編譯器的需要.
- 合成的默認構造函數中, 只有基類派生對象成員類對象會被初始化.
- 所有其他非靜態數據成員(如整數, 整數指針, 整數數組等)都不會被初始化.
-
copy 構造函數的構造操作:
- 默認成員初始化列表, 類似於深拷貝(bitwise copy).
- 默認構造函數和默認copy構造函數在必要時才由編譯器產生出來.
-
一個類對象可以通過兩種方式復制得到, 一種是被初始化(copy constructor), 另一種是被指定(copy assignment operator).
-
位逐次拷貝(bitwise copy semantics(語義)):
- 會拷貝每一個位(bit).
-
什么時候不要位逐次拷貝:
- 當類內含一個成員對象, 該成員對象中聲明了一個copy 構造函數.
- 類繼承的基類中存在一個構造函數.
- 類聲明了一個或多個虛函數.
- 當類派生自一個繼承串連, 其中有一個或多個虛基類時.
-
當編譯器導入一個虛表指針(vptr)到一個類對象中時, 該類就不展現逐次語義(bitwise semantics)了.
程序轉換語義(Program Transformation Semantics)
- 顯示的初始化操作(Explicit Initialization):
- 程序轉換有兩個階段:
- 重寫一個定義, 其中的初始化操作會被剝離.
- 類的copy 構造調用操作會被安插進去.
- 程序轉換有兩個階段:
- 編譯器可能做NRV(Named Return Value)優化操作.
- 以一個類對象作為另一個類對象的初值的情形, C++允許編譯器有大量的自由發揮空間, 以提升程序效率.
- 必須使用成員初始化列表(member initialization list):
- 當初始化一個成員引用(reference member)時.
- 當初始化一個常量成員(const member)時.
- 當調用一個基類的構造函數, 而該基類擁有一組參數時.
- 當調用一個成員類的構造函數, 其擁有一組參數時.
- 編譯器會一一操作初始化列表(initialization list), 以適當順序在構造函數之內安插初始化操作, 在顯式之前.
- 初始化列表中的順序是由類的成員聲明順序決定的, 不是由初始化列表中的排列順序決定的.
- 順序混亂會造成意想不到的危險.
- 初始化列表中的項目被放在顯示聲明代碼(explicit user code)之前.
Data語義
- 一個空類會被編譯器安插一個char, 使這個類的兩個對象得以在內存中配置獨一無二的地址.
- 空虛基類(Empty virtual base class)已經稱為C++面向對象的一個特有術語.
- 提供了一個虛擬接口, 沒有任何數據, 空虛基類被認為是派生對象開頭的一部分, 不花費任何派生類的額外空間.
- 虛基類自讀愛香只會在派生類中存在一份實例, 不管它在class繼承體系中出現了多少次.
- 非靜態成員數據放置的是個別類對象感興趣的數據, 靜態成員數據放置的是整個類感興趣的數據.
- 靜態成員變量被放到全局數據段中, 不會影響個別類對象的大小. 不管生成多少個對象, 靜態數據成員永遠只存在一份實例.
- 編譯器自動加上額外的數據成員, 用以支持某些語言特性.
- 因為內存對齊(alignment), 邊界調整的需要. --- 類對象可能比想象的大.
數據成員的布局
- 成員變量的排列順序因編譯器而異, 編譯器可以隨意選一個放在第一個.
- 在C++中, 在同一access section(private, protected, public等區段)中, 成員的排列只需符合較晚出現的成員變量在類對象中有較高的地址.
- 靜態成員並不需要通過類對象進行訪問.
- 一個靜態數據成員的地址是一個指向其數據類型的指針, 並不是一個指向類成員的指針.
- 對一個非靜態數據成員進行存取操作, 編譯器需要把類對象的起始地址加上數據成員的偏移位置(offset).
- 每個非靜態數據成員的偏移位置(offset)在編譯時期即可知曉.
- 具體繼承(concrete inheritance)並不會增加空間和存取時間上的額外負擔.
- 在每一個類對象(class object)中帶入一個vptr, 提供執行期的鏈接, 使每一個object(對象)能夠找到對應的虛表(虛virtual table).
- 在派生類和基類中, 可能重新設定vptr的值.
在析構函數中, 可能抹消掉指向類相關虛表(virtual table)的vptr.
- 在派生類和基類中, 可能重新設定vptr的值.
- vptr放在類對象的前端(起始處), 會喪失對C語言的兼容性.
- 多重繼承的問題主要發生於派生類對象和其第二或后繼的基類對象之間的轉換.
- 取一個非靜態數據成員的地址, 將得到它在類中的偏移量(offset); 取一個綁定於真正類對象身上的數據成員的地址, 將會得到該成員在內存中的真正地址.
函數(Function)語義
-
靜態成員函數:
- 不能直接存取非靜態成員數據.
- 也不能被聲明為const函數.
-
一般而言, 成員的名稱前面會被加上類名稱, 以形成獨一無二的命名.
-
靜態成員函數沒有this指針.
-
在C++中, 多態(polymorphsim)表示以一個public base class(公有基類)的指針(或引用), 尋址一個派生類對象.
- 多態機能主要扮演一個傳送機制的角色, 可以在程序任何地方采用一組public derived類型.
- 有了RTTI(runtime tyoe identification)就能夠在執行期查詢一個多態的指針或多態的引用.
-
虛擬繼承是C++中多重繼承中特有的概念, 虛擬繼承的一些總結.
-
內聯(inline)函數中的局部變量, 再加上有副作用的參數, 可能會導致大量臨時性的對象產生.
構造, 析構, 拷貝語義
- 繼承體系中每一個類對象的析構函數都會被調用.
- 構造函數可能內含大量的隱藏diamante, 因為編譯器會擴充每一個constructor, 擴充成都視class 的繼承體系而定.
- 記錄在成員初始化列表中的數據成員初始化操作會被放進構造函數本體, 並以成員變量聲明順序為順序.
- 如果有一個成員變量沒有出現在初始化列表中, 它有一個默認的構造函數, 那么該默認的構造函數必須被調用.
- 類對象的虛表指針(virtual table pointer)必須被設置初值, 指向適當的虛表(virtual table).
- 基類的構造函數必須被調用, 以基類的聲明順序為順序.
- 虛基類構造函數必須被調用, 從左到右, 從最深到最淺.
- 如果類沒有定義析構函數, 只有在類內的成員對象(基類)擁有析構函數時, 編譯器才會自動合成一個出來.
執行期語意
- C++所有的全局對象都被繁殖在程序的數據段(data segment)中.
- 運算符new一般由兩個步驟完成:
- 通過適當的new運算符函數實例, 配置所需的內存.
- 將配置來的對象設立初值.
- 臨時對象的摧毀應該是對完整表達式(full-expression)求值過程中的最后一個步驟.
- 完整表達式(full-expression)是表達式最外圍的那個.
- 編譯器不能消除class類型的局部臨時變量, 因為C++back-ends的限制.
- 可以通過一些優化工具把臨時對象放進寄存器.
站在對象模型的尖端
- 模板template, 異常處理exception handing(EH), 運行時類型識別(runtime type identification, RTTI).
- 每一個可執行文件中只需要一份模板的實例, 每個編譯單位都會擁有一份實例.
- 只有在成員函數被使用的時候, C++標准才要求他們被實例化.
- 空間和時間效率的考慮.
- 尚未實現的機能.
- 所有與類型相關的檢驗, 如果牽涉到template參數, 都必須延遲到真正的實例化操作(instantiation)發生, 才得為之.
- Template中的名稱決議法:
- 定義模板(template)的程序端和實例化模板(template)的程序的區別.
- 定義模板(template)專注於一般的模板類.
- 實例化模板(template)專注於特定的實例.
- 如果一個虛函數被實例化, 其實例化點緊跟在其類的實例化點之后.
- dynamic_cast運算符可以再執行期決定真正的類型.
- typeud運算符傳回一個const reference, 類型為type_info.
- 雖然RTTI只適用於多態類(polymorphic classes), 事實上type_info對象也適用於內建類型, 以及非多態的使用者自定義類型.
- 動態共享函數庫, 共享內存.