c 物件和記憶體 對虛繼承的討論

2021-04-30 15:34:38 字數 4622 閱讀 4403

我們先回顧一下類和物件的定義,類是定義同一類所有例項變數和方法的藍圖或原型;物件是類的例項化。從記憶體的角度可以對這兩個定義這樣理解,類刻畫了例項的記憶體布局,確定例項中每個資料成員在一塊連續記憶體中的位置、大小以及對記憶體的解讀方式;物件就是系統根據類刻畫的記憶體布局去分配的記憶體。除了例項變數和方法,類也可以定義類變數和類方法,這是我們通常所說的靜態變數和靜態函式,它們不屬於某個具體的物件,而是屬於整個類,所以不會影響物件的記憶體布局和記憶體大小。通過以上的討論我們可以知道:物件本質上就是一塊連續的記憶體,物件的型別(類)就是對這塊記憶體的解讀方式。在c++中我們可以通過四個型別轉換運算子

改變物件的型別,這種轉換改變的是記憶體的解讀方式,不會修改記憶體中的值。修改物件記憶體值的合法途徑是通過成員函式/友元函式修改物件的資料成員。通過成員函式修改物件的值是c++語言保證物件安全的一種機制,但這種機制不是強制的,你可以通過暴力的非法手段避開這個機制(比如你可以取得物件的起始位址,然後根據物件的記憶體布局任意修改記憶體的值),除了極其特殊情況,這種人為非法手段都應當被禁止,因為這種暴力**難於理解、不便移植、極易出錯;另外程式在執行過程中由於**中的某些缺陷也會非法修改物件記憶體的值,這是我們程式中許多疑難bug的根源。所以正確的編寫類,理解物件在記憶體的執行特點,合理的控制物件的建立和銷毀是乙個程式穩定執行的基本保證。

在c++中,物件通常存放在三個記憶體區域:棧、堆、全域性/靜態資料區;相對應的,在這三個區域中的物件就被稱為棧物件、堆物件、全域性/靜態物件。

全域性/靜態資料區:全域性物件和靜態物件存放在該區,在該記憶體區的物件一旦建立後直到程序結束才會釋放。在其生存期內可以被多個執行緒訪問,它可以做為多執行緒通訊的一種方式,所以對於全域性物件和靜態物件要考慮執行緒安全,特別是對於函式中的區域性靜態變數,容易忘記它的執行緒安全性。全域性物件和一些靜態物件有乙個特點:這些物件的初始化操作先於main函式的執行,而且這些物件(可能分布在不同的原始檔中)初始化順序沒有規定,所以在它們的初始化中不要啟動執行緒,同時它們的初始化操作也不應有依賴關係。

堆:堆物件是通過new/malloc在堆中動態分配記憶體,通過delete/free釋放記憶體的物件。我們可對這種物件的建立和銷毀進行精確控制。堆物件在c++中的使用非常廣泛,不同執行緒間、函式間的物件共享可以使用堆物件,大型物件一般也使用堆物件(棧空間是有限的),特別是虛函式多型性一般是由堆物件實現的。使用堆物件也有一些缺點:1.需要程式設計師管理生存週期,忘記釋放會有記憶體洩露,多次釋放可能造成程式崩潰,通過智慧型指標可以避免這個問題;2.堆物件的時間效率和空間效率沒有棧物件高,堆物件一般通過某種搜尋演算法在堆中找到合適大小的記憶體,比較耗時間,另外從堆中分配的記憶體大小會比實際申請的記憶體大幾個位元組,比較耗空間,尤其是對於小型物件這種損耗是非常大的;3.頻繁使用new/delete 堆物件會造成大量的記憶體碎片,記憶體得不到充分的使用。對於2,3兩個問題可以通過記憶體一次分配,多次使用的方法解決,更好的方法是根據業務特點實現特定的記憶體池。

棧:棧物件是自生自滅型物件,程式設計師無需對其生存週期進行管理。一般,臨時物件、函式內的區域性物件都是棧物件。使用棧物件是高效的,因為它不需要進行記憶體搜尋只進行棧頂指標的移動。另外棧物件是執行緒安全的,因為不同的執行緒有自己的棧記憶體。當然,棧的空間是有限的,所以使用中要防止棧溢位的出現,通常大型物件、大型陣列、遞迴函式都要慎用棧物件。

c++類有四個基本函式:建構函式、析構函式、拷貝建構函式、賦值運算子過載函式,這四個函式管理著c++物件的建立和銷毀,正確而完整地實現這些函式是c++物件安全執行必要保證。

建立乙個物件有兩個步驟:1.在記憶體中分配sizeof(canytype) 位元組數的記憶體;2.呼叫合適建構函式初始化分配的記憶體。c++物件的大小由三個因素決定:1.各個資料成員的大小;2.由位元組對齊產生的填充空間的大小;3.為支援虛機制編譯器新增的乙個指標,大小是四個位元組,虛機制指標有兩種:1.支援虛函式的虛表指標

,2.支援虛繼承

的虛基類指標。虛繼承時,派生類只儲存乙份被繼承的基類的實體,比如下面例子的菱形繼承關係中,類d中只有乙份類a的實體。另外,類a是乙個空類,但sizeof(a)大小不是0,而是1,這是因為需要用這乙個位元組來唯一標識類a在記憶體中的不同物件。

class a{};

class b : virtual public a {};

class c : virtual public a {};

class d:public b, public c {}

由上面討論知道,類中除了有程式設計人員寫的資料成員,有時還有一些由編譯器為支援虛機制而偷偷給你新增的成員,這些成員我們在**中不會直接用到,但有可能被我們的**非法修改。比如不恰當的建構函式會修改虛機制指標的值,在寫建構函式時我們經常使用如下的**對整個物件進行初始化:

memset(this, 0, sizeof(*this));

這種初始化方式只能在類不涉及虛機制的情況下使用,否則它會修改虛機制指標,使類物件的行為無定義。

銷毀乙個物件也有兩個步驟:1.呼叫析構函式;2.向系統歸還記憶體。析構函式的作用是釋放物件申請的資源。析構函式通常是由系統自動呼叫的,在以下幾種情況下系統會呼叫析構函式:1.棧物件生命週期結束時:包括離開作用域、函式正常return(不考慮nrv優化

)、函式異常throw;2.堆物件進行delete操作時;3.全域性物件在程序結束時。析構函式只在一種情況下需要被顯式的呼叫,那就是用placement new

構建的物件。當類裡包含虛函式的時候我們應該宣告虛析構函式,虛析構函式的作用是:當你delete乙個指向派生類物件的基類指標時保證派生類的析構函式被正確呼叫。有許多資源洩露的問題就是因為沒有正確使用虛析構函式造成的,這種資源洩露有兩種:1.派生類裡直接分配的資源;2.派生類裡的成員物件分配的資源。尤其是第二類,隱蔽性非常高。

構造和析構是一組被成對呼叫的函式,特別是對於棧物件,呼叫是由系統自動完成的,所以我們可以利用這一特性將一些需要成對出現的操作分別封裝在構造和析構函式裡由系統自動完成,這樣可以避免由於程式設計時的遺漏而忘記進行某種操作。比如資源的申請和釋放,多執行緒中的加鎖和解鎖都可以利用棧物件的這一特性進行自動管理。

拷貝建構函式、賦值運算子過載函式是一對孿生兄弟,通常乙個類如果需要顯式寫拷貝建構函式,那麼它也需要顯式寫賦值運算子過載函式。拷貝建構函式的功能是用已存在的物件構造乙個新的物件,賦值運算子過載函式的功能是用已存在的物件替換乙個已存在的物件。看下面幾條語句:

string str1 = 「string test」;  //呼叫帶引數的建構函式

string str2(str1);         //呼叫拷貝建構函式

string str3 = str1;        //呼叫拷貝建構函式

string str4;             //呼叫預設建構函式

str4 = str3;             //呼叫賦值運算子過載函式

拷貝建構函式、賦值運算子過載函式原型如下:

class string

這兩個函式的引數型別都是const string &,我們知道對於c++物件通常以常引用作函式的引數,這樣可以提高引數的傳遞效率,以物件作為函式引數時會呼叫拷貝建構函式生成乙個臨時物件供函式使用,效率較低。拷貝建構函式是乙個很特殊的函式,對於其他函式用物件作為函式引數頂多是效率的損失,但對拷貝建構函式用物件作為函式引數就會形成無限遞迴呼叫,所以拷貝構造必須以常引用作為引數。

拷貝建構函式在c++編譯器中有預設的實現,實現的方式是按位對記憶體進行拷貝(memcpy(this, & cother, sizeof(string)),如果預設的實現滿足我們的要求那就不需要顯式的去實現這個函式,否則就必須實現,判斷是否滿足位拷貝語義的依據是類的成員資料中是否需要動態分配其他資源,比如上面的string類,成員m_pstr需要從堆中分配記憶體來存放具體的字串,這塊堆記憶體是位拷貝語義無法正確管理的,所以在string物件進行拷貝/賦值時程式設計人員需要負責管理這塊記憶體。通常在三種情況下會呼叫拷貝建構函式,1. 乙個物件以值傳遞的方式傳入函式;2. 乙個物件以值傳遞的方式從函式返回;3. 乙個物件需要通過另外乙個物件進行初始化。如果你確保對類物件的使用不會出現以上三種情況,那就說明你根本不需要拷貝建構函式,直接將拷貝建構函式私有化是最安全的選擇。從以上的討論我們知道,對於拷貝建構函式有三種處理策略(對於賦值運算子過載函式同樣適用):1.什麼都不寫,按預設的處理;2.顯式寫拷貝構造;3.將拷貝構造私有化。在寫乙個類前,我們必須分析類自身的實現方式以及對類物件的使用方式,明確選擇一種策略,如果你放棄選擇你就為將來可能出現的bug埋下乙個伏筆。

上面討論了拷貝/賦值函式的選擇策略,下面看看它們具體的實現方式。拷貝建構函式的功能由乙個物件構造乙個新的物件,只要乙個copy操作就可以完成。賦值運算子過載函式的功能是由乙個物件替換乙個已存在的物件,完成這個功能需要三個操作:自賦值檢查、clear原有物件、copy新物件。如string類的實現:

class string

string & operator=(const string & cother)  //實現賦值運算子過載函式

return *this; }

private:

void copy(const string & cother)

void clear()

m_nsize = 0;

} }

string類中這兩個函式的實現模式可以在其他類中直接套用,只需要改動copy和clear()函式即可。

作為c++程式設計師每天都要和類、物件以及記憶體打交道,寫乙個類實現某項功能不難,但要實現乙個健壯的、可重用的、易擴充套件的類就不是很容易了。很多時候我們寫乙個類時用的還是c的思維,對類的四個基本函式考慮的不夠周到仔細,對類物件在不同記憶體區域執行特點理解不夠,容易產生一些低階的bug,而且對後續的**維護擴充套件也帶來難度。本文中對這些內容做了基本的介紹,希望對大家有些幫助。

c 虛繼承物件的記憶體布局

好了,我們從最基礎的的討論起。當c 支援virtual base class 時,就會多了一些額外負擔,當class 中內含乙個或多個virtual base class subobject時,將分成兩個部分,乙個不變區域性和乙個共享區域性。最初的方案是為每乙個虛基類安插乙個指標指向這個虛基類,其缺...

C 虛繼承中的物件記憶體布局

鑽石型虛擬繼承 虛繼承是為了解決多繼承中的資料冗餘而出現的。列印虛函式表 void printfmove int vbptr 列印偏移量 cout void test int main 程式執行結果 物件在記憶體中的布局 所以,有以下結論 在虛繼承時,類中會自動加乙個指標 vbptr 該變數指向乙個...

C 虛繼承和虛繼承

虛繼承是在多繼承中為了解決衝突而技術。學術一點來說,是指乙個指定的基類,在繼承體系結構中,將其成員資料例項共享給也從這個基類直接或間接派生的其他類。虛繼承非常有用,可以避免多繼承的歧義和多重拷貝。考慮有如下繼承結構。b和c繼承a,d多繼承b c,我們看以下 class a class b publi...