1. 程式人生 > 其它 >深度探索C++物件模型

深度探索C++物件模型

一、關於物件

C++在佈局以及存取時間上主要的額外負擔是由virtual引起的

  • virtual function :支援一個有效率的執行期繫結,多型。
  • virtual base class :實現多次出現在繼承體系中的base class,有一個單一而被共享的例項

1.1 C++物件模型

加上封裝後的佈局成本

在C++中,有兩種class data members:static和nonstatic;三種class member functions:static、nonstatic和virtual。

C++物件模型

在此模型中,Nonstatic data members被配置於每一個class object之內

,static data members則被存放在class object之外。static和nonstatic function members也被放在class object之外。

Virtual functions則以兩個步驟支援之:

  1. 每一個 class產生一堆虛擬函式指標,放在表格之中。這個表格被稱為虛擬函式表(vtbl)
  2. 每一個class object安插一個指標,指向相關的virtual table。通常這個指標被稱為虛表指標(vptr)。vptr的設定(setting)和重置(resetting)都由每一個class的constructor、destructor和copy assignment運算子自動完成。

1.2 關鍵詞所帶來的差異

如果一個程式設計師迫切需要一個相當複雜的C++ class的某部分資料,使他擁有C宣告的那種模樣,那麼那一部分最好抽取出來成為一個獨立的struct宣告。C struct在C++中的一個合理用途,是當你要傳遞“一個複雜的class object的全部或部分”到某個C函式去時,struct宣告可以將資料封裝起來,並保證擁有與C相容的空間佈局。

1.3 物件的差異

C++程式設計模型直接支援三種programming paradigms(程式設計正規化)

  1. 程式模型(procedural model)。就像 C一樣,C++當然也支援它。
  2. 基於物件模型(abstract data type model,ADT)。此模型所謂的“抽象”是和一組表示式(public介面)一起提供的,那時其運算定義仍然隱而未明。
  3. 面向物件模型(object-oriented model)。在此模型中有一些彼此相關的型別,通過一個抽象的 base class(用以提供共同介面)被封裝起來。

多型的主要用途是經由一個共同的介面來影響型別的封裝,這個介面通常被定義在一個抽象的base class中。

需要多少記憶體才能夠表現一個class object?一般而言要有:

  • 其 nonstatic data members的總和大小。
  • 加上任何由於 alignment(譯註)的需求而填補(padding)上去的空間(可能存在於 members之間,也可能存在於集合體邊界)。譯註:alignment就是將數值調整到某數的倍數。在32位計算機上,通常alignment為4 bytes(32位),以使bus的“運輸量”達到最高效率。
  • 加上為了支援 virtual而由內部產生的任何額外負擔(overhead)。

指標的型別(The Type of a Pointer)

“指標型別”會教導編譯器如何解釋某個特定地址中的記憶體內容及其大小。轉換(cast)其實是一種編譯器指令,並不改變一個指標所含的真正地址,它隻影響“被指出之記憶體的大小和其內容”的解釋方式。

總而言之,多型是一種威力強大的設計機制,允許你繼承一個抽象的public介面之後,封裝相關的型別。需要付出的代價就是額外的間接性——不論是在“記憶體的獲得”或是在“型別的決斷”上。C++通過class的pointers和references來支援多型,這種程式設計風格就稱為“面向物件”。

C++也支援具體的ADT程式風格,如今被稱為object-based(OB)。非多型的資料型別提供一個public 介面和一個private實現品,包括資料和演算法,但是不支援型別的擴充。OB設計比對等的OO設計速度更快而且空間更緊湊速度快是因為所有的函式呼叫操作都在編譯時期解析完成,物件建構起來時不需要設定 virtual機制;空間緊湊則是因為每一個class object 不需要負擔傳統上為了支援virtual機制而需要的額外負荷。不過,OB設計比較沒有彈性

二、建構函式語意學

2.1 Default Constructor的構造操作

“帶有 Default Constructor”的 Member Class Object

如果一個class沒有任何constructor,但它內含一個member object,而後者有default constructor,那麼這個class的implicit default constructor就是“nontrivial”,編譯器需要為該class 合成出一個default constructor。不過這個合成操作只有在constructor真正需要被呼叫時才會發生。再一次請你注意,被合成的default constructor只滿足編譯器的需要,而不是程式的需要。

“帶有 Default Constructor”的 Base Class

如果一個沒有任何constructors的class派生自一個“帶有default constructor”的base class,那麼這個derived class 的default constructor 會被視為nontrivial,並因此需要被合成出來。它將呼叫上一層 base classes 的 default constructor(根據它們的宣告順序)。對一個後繼派生的class而言,這個合成的constructor和一個“被顯式提供的default constructor”沒有什麼差異。

“帶有一個 Virtual Function”的 Class

另有兩種情況,也需要合成出default constructor:

  1. class宣告(或繼承)一個 virtual function。
  2. class派生自一個繼承串鏈,其中有一個或更多的 virtual base classes。

“帶有一個 Virtual Base Class”的 Class

Virtual base class 的實現法在不同的編譯器之間有極大的差異。然而,每一種實現法的共同點在於必須使virtual base class在其每一個derived class object中的位置,能夠於執行期準備妥當。

有4種情況,會造成“編譯器必須為未宣告 constructor 的classes合成一個default constructor”。C++Standard 把那些合成物稱為 implicit nontrivial default constructors。被合成出來的constructor只能滿足編譯器(而非程式)的需要。它之所以能夠完成任務,是藉著“呼叫member object或base class的default constructor”或是“為每一個object初始化其virtual function機制或virtual base class機制”而完成的。至於沒有存在那4種情況而又沒有宣告任何constructor的classes,我們說它們擁有的是implicit trivial default constructors,它們實際上並不會被合成出來。

在合成的 default constructor 中,只有 base class subobjects 和 member class objects會被初始化。所有其他的nonstatic data member(如整數、整數指標、整數陣列等等)都不會被初始化。這些初始化操作對程式而言或許有需要,但對編譯器則非必要。如果程式需要一個“把某指標設為0”的default constructor,那麼提供它的人應該是程式設計師。

C++新手一般有兩個常見的誤解:

  1. 任何class如果沒有定義default constructor,就會被合成出一個來。
  2. 編譯器合成出來的default constructor會顯式設定“class 內每一個 data member的預設值”。

2.2 Copy Constructor的構造操作

Default Memberwise Initialization

當class object 以“相同 class 的另一個 object”作為初值,其內部是以所謂的default memberwise initialization手法完成的,也就是把每一個內建的或派生的data member(例如一個指標或一個數組)的值,從某個object拷貝一份到另一個object身上。不過它並不會拷貝其中的 member class object,而是以遞迴的方式施行 memberwise initialization。

C++Standard上說,如果class沒有宣告一個copy constructor,就會有隱式的宣告(implicitly declared)或隱式的定義(implicitly defined)出現。和以前一樣,C++Standard 把copy constructor區分為trivial和nontrivial兩種。只有nontrivial的例項才會被合成於程式之中。決定一個copy constructor是否為trivial的標準在於class 是否展現出所謂的“bitwise copy semantics”。

Bitwise Copy Semantics(位逐次拷貝)

在這被合成出來的copy constructor中,如整數、指標、陣列等等的non class members也都會被複制,正如我們所期待的一樣。

不要 Bitwise Copy Semantics!

什麼時候一個class不展現出“bitwise copy semantics”呢?有4種情況:

  • 當class內含一個member object而後者的class宣告有一個copy constructor時(不論是被 class設計者顯式地宣告,就像前面的 String那樣;或是被編譯器合成,像 class Word那樣)。
  • 當class繼承自一個base class而後者存在一個copy constructor時(再次強調,不論是被顯式宣告或是被合成而得)。
  • 當class聲明瞭一個或多個virtual functions時。
  • 當class派生自一個繼承串鏈,其中有一個或多個virtual base classes時。

重新設定Virtual Table的指標

回憶編譯期間的兩個程式擴張操作(只要有一個class聲明瞭一個或多個virtual functions就會如此):

  • 增加一個virtual function table(vtbl),內含每一個有作用的virtual function的地址。
  • 一個指向virtual function table的指標(vptr),安插在每一個class object內。

合成出來的ZooAnimal copy constructor 會顯式設定object的vptr指向ZooAnimal class的virtual table,而不是直接從右手邊的class object中將其vptr現值拷貝過來。

處理 Virtual Base Class Subobject

Virtual base class的存在需要特別處理。一個class object 如果以另一個object作為初值,而後者有一個 virtual base classsubobject,那麼也會使“bitwise copy semantics”失效。

每一個編譯器對於虛擬繼承的支援承諾,都代表必須讓“derived class object中的virtual base class subobject位置”在執行期就準備妥當。維護“位置的完整性”是編譯器的責任。“Bitwise copy semantics”可能會破壞這個位置,所以編譯器必須在它自己合成出來的copy constructor中做出仲裁。

我們已經看過4種情況,在那些情況下class不再保持“bitwise copy semantics”,而且 default copy constructor 如果未被宣告的話,會被視為nontrivial。在這4種情況下,如果缺乏一個已宣告的copy constructor,編譯器為了正確處理“以一個class object 作為另一個class object 的初值”,必須合成出一個copy constructor。

2.3 程式轉化語意學

轉化

  • 顯式的初始化操作(Explicit Initialization)
  • 引數的初始化(Argument Initialization)
  • 返回值的初始化(Return Value Initialization)

優化方法:

  • 在使用者層面做優化(Optimization at the User Level)
  • 在編譯器層面做優化(Optimization at the Compiler Level)。Named Return Value(NRV)優化

copy constructor的應用,迫使編譯器多多少少對你的程式程式碼做部分轉化。尤其是當一個函式以傳值(by value)的方式傳回一個class object,而該class有一個copy constructor(不論是顯式定義出來的,或是合成的)時。這將導致深奧的程式轉化——不論在函式的定義上還是在使用上。此外,編譯器也將copy constructor的呼叫操作優化,以一個額外的第一引數(數值被直接存放於其中)取代 NRV。程式設計師如果瞭解那些轉換,以及copy constructor 優化後的可能狀態,就比較能夠控制其程式的執行效率。

2.4 成員們的初始化隊伍(Member Initialization List)

當你寫下一個constructor時,就有機會設定class members的初值。要不是經由member initialization list,就是在constructor函式本體之內。

在下列情況下,為了讓你的程式能夠被順利編譯,你必須使用member initialization list:

  1. 當初始化一個reference member時;
  2. 當初始化一個const member時;
  3. 當呼叫一個base class的constructor,而它擁有一組引數時;
  4. 當呼叫一個member class的constructor,而它擁有一組引數時。

編譯器會一一操作initialization list,以適當順序在constructor之內安插初始化操作,並且在任何explicit user code之前。list中的專案順序是由class中的members宣告順序決定的,不是由initialization list中的排列順序決定的。

簡略地說,編譯器會對initialization list 一一處理並可能重新排序,以反映出members的宣告順序。它會安插一些程式碼到constructor體內,並置於任何explicit user code之前。

三、Data語意學

Nonstatic data members放置的是“個別的class object”感興趣的資料,static data members則放置的是“整個class”感興趣的資料

對於nonstatic data members,直接存放在每一個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也已存在)。

3.1 Data Member的繫結

因此在一個inline member function軀體之內的一個data member繫結操作,會在整個class宣告完成之後才發生。然而,這對於member function的argument list並不為真。Argument list中的名稱還是會在它們第一次遭遇時被適當地決議(resolved)完成。

3.2 Data Member的佈局

Nonstatic data members在class object中的排列順序將和其被宣告的順序一樣,任何中間介入的static data members都不會被放進物件佈局之中。

編譯器還可能會合成一些內部使用的data members,以支援整個物件模型。vptr就是這樣的東西,目前所有的編譯器都把它安插在每一個“內含virtual function之class”的 object 內。一些編譯器把vptr放在一個class object的最前端。

3.3 Data Member的存取

Static Data Members

每一個static data member只有一個例項,存放在程式的data segment之中。每次存取static member時,就會被內部轉化為對該唯一extern例項的直接存取操作

Nonstatic Data Members

Nonstatic data members直接存放在每一個class object 之中。經由顯式的(explicit)或隱式的(implicit)class object存取它們。欲對一個nonstatic data member進行存取操作,編譯器需要把class object的起始地址加上data member的偏移位置(offset)。

3.4 “繼承”與Data Member

在C++繼承模型中,一個derived class object所表現出來的東西,是其自己的members加上其base class members的總和。至於derived class members和base class members的排列順序,則並未在C++Standard中強制指定;理論上編譯器可以自由安排之。在大部分編譯器上頭,base class members總是先出現,但屬於virtual base class的除外

a. 沒有繼承沒有多型:

b. 只有繼承沒有多型:

c. 單繼承加多型:

d. 多重繼承

對一個多重派生物件,將其地址指定給第一個base class的指標,情況將和單一繼承時相同,因為二者都指向相同的起始地址。至於第二個或後繼的 base class 的地址指定操作,則需要將地址修改過:加上(介於中間的base class subobject(s)大小。

e. 虛擬繼承

四、Function語意學

C++支援三種類型的member functions:static、nonstatic和virtual。

4.1 Member 的各種呼叫方式

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

C++的設計準則之一就是:nonstatic member function至少必須和一般的nonmember function有相同的效率。

名稱的特殊處理(Name Mangling)一般而言,member的名稱前面會被加上class名稱,形成獨一無二的命名。

Virtual Member Functions(虛擬成員函式)

( * ptr->vptr[1])( ptr )
  • vptr表示由編譯器產生的指標,指向virtual table。它被安插在每一個“宣告有(或繼承自)一個或多個 virtual functions”的class object中。事實上其名稱也會被“mangled”,因為在一個複雜的class派生體系中,可能存在多個vptrs。
  • 1是virtual table slot的索引值,關聯到 normalize()函式。
  • 第二個ptr表示this指標。

Static Member Functions(靜態成員函式)

如果取一個static member function的地址,獲得的將是其在記憶體中的位置,也就是其地址。由於static member function沒有this指標,所以其地址的型別並不是一個“指向class member function的指標”,而是一個“nonmember函式指標”。

4.2 Virtual Member Functions(虛擬成員函式)

virtual function的一般實現模型:每一個class有一個virtual table,內含該class之中有作用的virtual function的地址,然後每個object有一個vptr,指向virtual table的所在。在C++中,多型(polymorphism)表示“以一個public base class 的指標(或reference),定址出一個derived class object”的意思。

一個class只會有一個virtual table。每一個table內含其對應之class object中所有active virtual functions函式例項的地址。這些active virtual functions包括:

  • 這一class所定義的函式例項;
  • 繼承自base class的函式例項;
  • 一個pure_virtual_called()函式例項,它既可以扮演pure virtual function的空間保衛者角色,也可以當做執行期異常處理函式(有時候會用到)。每一個virtual function都被指派一個固定的索引值,這個索引在整個繼承體系中保持與特定的virtual function的關係

現在,如果我有這樣的式子:ptr->z()

我如何有足夠的知識在編譯時期設定virtual function的呼叫呢?

  • 一般而言,在每次呼叫z()時,我並不知道ptr所指物件的真正型別。然而我知道,經由 ptr可以存取到該物件的virtual table。
  • 雖然我不知道哪一個z()函式例項會被呼叫,但我知道每一個z()函式地址都被放在slot 4中。這些資訊使得編譯器可以將該呼叫轉化為:(*ptr->vptr[4])(ptr)

多重繼承下的Virtual Functions

在多重繼承中支援virtual functions,其複雜度圍繞在第二個及後繼的base classes身上,以及“必須在執行期調整this指標”這一點。

虛擬繼承下的Virtual Functions

五、構造、析構、拷貝語意學

一般而言,class的data member應該被初始化,並且只在constructor中或是在class的其他member functions中指定初值。其他任何操作都將破壞封裝性質,使class的維護和修改更加困難。

5.1 “無繼承”情況下的物件構造

純虛擬函式的存在(Presence of a Pure Virtual Function)

可以定義和呼叫(invoke)一個pure virtual function;不過它只能被靜態地呼叫(invoked statically),不能經由虛擬機制呼叫

5.2 繼承體系下的物件構造

Constructor可能內含大量的隱藏碼,因為編譯器會擴充每一個constructor,擴充程度視class T的繼承體系而定。一般而言編譯器所做的擴充操作大約如下:

  1. 記錄在member initialization list中的data members初始化操作會被放進constructor的函式本體,並以members的宣告順序為順序。
  2. 如果有一個member並沒有出現在member initialization list之中,但它有一個default constructor,那麼該default constructor必須被呼叫。
  3. 在那之前,如果class object有virtual table pointer(s),它(們)必須被設定初值,指向適當的virtual table(s)。
  4. 在那之前,所有上一層的base class constructors必須被呼叫,以base class的宣告順序為順序(與 member initialization list中的順序沒關聯)。如果base class被列於member initialization list 中,那麼任何顯式指定的引數都應該傳遞過去。如果base class沒有被列於member initialization list中,而它有default constructor(或default memberwise copy constructor),那麼就呼叫之。如果base class是多重繼承下的第二或後繼的base class,那麼this指標必須有所調整。
  5. 在那之前,所有virtual base class constructors必須被呼叫,從左到右,從最深到最淺。如果class被列於member initialization list中,那麼如果有任何顯式指定的引數,都應該傳遞過去。若沒有列於list之中,而class有一個default constructor,亦應該呼叫之。此外,class中的每一個virtual base class subobject的偏移位置(offset)必須在執行期可被存取。如果class object是最底層(most-derived)的class,其constructors可能被呼叫;某些用以支援這一行為的機制必須被放進來。

虛擬繼承(Virtual Inheritance)

在此狀態中,“virtual base class constructors的被呼叫”有著明確的定義:只有當一個完整的class object 被定義出來時,它才會被呼叫;如果object只是某個完整object的subobject,它就不會被呼叫。

vptr初始化語意學(The Semantics of the vptr Initialization)

根本的解決之道是,在執行一個constructor時,必須限制一組virtual functions候選名單。所以為了控制一個class中有所作用的函式,編譯系統只要簡單地控制住vptr的初始化和設定操作即可。在base class constructors呼叫操作之後,但是在程式設計師供應的程式碼或是“member initialization list中所列的 members初始化操作”之前。

constructor的執行演算法通常如下:

  1. 在derived class constructor中,“所有virtual base classes”及“上一層base class”的 constructors會被呼叫。
  2. 上述完成之後,物件的vptr(s)被初始化,指向相關的virtual table(s)。
  3. 如果有member initialization list的話,將在constructor體內擴充套件開來。這必須在vptr被設定之後才做,以免有一個virtual member function被呼叫。
  4. 最後,執行程式設計師所提供的程式碼。

5.3 物件複製語意學(Object Copy Semantics)

我建議儘可能不要允許一個virtual base class的拷貝操作。我甚至提供一個比較奇怪的建議:不要在任何virtual base class中宣告資料。

5.4 物件的效能(Object Efficiency)

5.5 析構語意學(Semantics of Destruction)

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

  1. 一個由程式設計師定義的destructor被擴充套件的方式類似constructors被擴充套件的方式,但順序相反:如果object內含一個vptr,那麼首先重設(reset)相關的virtual table。
  2. destructor的函式本體現在被執行,也就是說vptr會在程式設計師的程式碼執行前被重設(reset)。
  3. 如果class擁有member class objects,而後者擁有destructors,那麼它們會以其宣告順序的相反順序被呼叫。
  4. 如果有任何直接的(上一層)nonvirtual base classes擁有destructor,它們會以其宣告順序的相反順序被呼叫。
  5. 如果有任何virtual base classes擁有destructor,而目前討論的這個class是最尾端(most-derived)的class,那麼它們會以其原來的構造順序的相反順序被呼叫。

六、執行期語意學(Runtime Semantics)

C++的一件困難事情:不太容易從程式原始碼看出表示式的複雜度。

一般而言我們會把object儘可能放置在使用它的那個程式區段附近,這麼做可以節省非必要的物件產生操作和摧毀操作。

全域性物件(Global Objects)

由於這樣的限制,下面這些munch策略就浮現出來了:

  1. 為每一個需要靜態初始化的檔案產生一個_sti()函式,內含必要的constructor呼叫操作或inline expansions。
  2. 在每一個需要靜態的記憶體釋放操作(static deallocation)的檔案中,產生一個__std()函式(譯註:我想std就是static deallocation的縮寫),內含必要的destructor呼叫操作,或是其 inline expansions。
  3. 提供一組runtime library“munch”函式:一個_main()函式(用以呼叫可執行檔案中的所有__sti()函式),以及一個exit()函式(以類似方式呼叫所有的__std()函式)。

區域性靜態物件(Local Static Objects)

首先,我匯入一個臨時性物件以保護mat_identity的初始化操作。第一次處理identity()時,這個臨時物件被評估為false,於是constructor會被呼叫,然後臨時物件被改為true。這樣就解決了構造的問題。而在相反的那一端,destructor也需要有條件地施行於mat_identity身上,但只有在mat_identity已經被構造起來才算數。要判斷mat_identity是否被構造起來,很簡單,如果那個臨時物件為true,就表示構造好了。

6.2 new和delete運算子

運算子new的使用,看起來似乎是個單一運算。但事實上它是由兩個步驟完成的:

  1. 通過適當的new運算子函式例項,配置所需的記憶體
  2. 將配置得來的物件設立初值

尋找陣列維度,對於delete運算子的效率帶來極大的衝擊,所以才導致這樣的妥協:只有在中括號出現時,編譯器才尋找陣列的維度,否則它便假設只有單獨一個objects要被刪除。如果程式設計師沒有提供必須的中括號,那麼就只有第一個元素會被析構。其他的元素仍然存在——雖然其相關的記憶體已經被要求歸還了。

6.3 臨時性物件(Temporary Objects)

臨時性物件在完整表示式尚未評估完全之前,不得被摧毀。也就是說某些形式的條件測試現在必須被安插進來,以決定是否要摧毀和第二算式有關的臨時物件。

參考連結https://zhuanlan.zhihu.com/p/369495063