1 類、對象和記憶體1.1 通過記憶體看對象
我們先回顧一下類和對象的定義,類是定義同一類所有執行個體變數和方法的藍圖或原型;對象是類的執行個體化。從記憶體的角度可以對這兩個定義這樣理解,類刻畫了執行個體的記憶體布局,確定執行個體中每個資料成員在一塊連續記憶體中的位置、大小以及對記憶體的解讀方式;對象就是系統根據類刻畫的記憶體布局去分配的記憶體。除了執行個體變數和方法,類也可以定義類變數和類方法,這是我們通常所說的靜態變數和靜態函數,它們不屬於某個具體的對象,而是屬於整個類,所以不會影響對象的記憶體布局和記憶體大小。通過以上的討論我們可以知道:對象本質上就是一塊連續的記憶體,對象的類型(類)就是對這塊記憶體的解讀方式。在c++中我們可以通過四個類型轉換運算子改變對象的類型,這種轉換改變的是記憶體的解讀方式,不會修改記憶體中的值。修改對象記憶體值的合法途徑是通過成員函數/友元函數修改對象的資料成員。通過成員函數修改對象的值是c++語言保證對象安全的一種機制,但這種機制不是強制的,你可以通過暴力的非法手段避開這個機制(比如你可以取得對象的起始地址,然後根據對象的記憶體布局任意修改記憶體的值),除了極其特殊情況,這種人為非法手段都應當被禁止,因為這種暴力代碼難於理解、不便移植、極易出錯;另外程式在運行過程中由於代碼中的某些缺陷也會非法修改對象記憶體的值,這是我們程式中許多疑難bug的根源。所以正確的編寫類,理解對象在記憶體的運行特點,合理的控制對象的建立和銷毀是一個程式穩定啟動並執行基本保證。
1.2 不同記憶體地區的對象
在C++中,對象通常存放在三個記憶體地區:棧、堆、全域/待用資料區;相對應的,在這三個地區中的對象就被稱為棧對象、堆對象、全域/靜態對象。
全域/待用資料區:全域對象和靜態對象存放在該區,在該記憶體區的對象一旦建立後直到進程結束才會釋放。在其生存期內可以被多個線程訪問,它可以做為多線程通訊的一種方式,所以對於全域對象和靜態對象要考慮安全執行緒,特別是對於函數中的局部靜態變數,容易忘記它的執行緒安全性。全域對象和一些靜態對象有一個特點:這些對象的初始化操作先於main函數的執行,而且這些對象(可能分布在不同的源檔案中)初始化順序沒有規定,所以在它們的初始化中不要啟動線程,同時它們的初始化操作也不應有依賴關係。
堆:堆對象是通過new/malloc在堆中動態分配記憶體,通過delete/free釋放記憶體的對象。我們可對這種對象的建立和銷毀進行精確控制。堆對象在c++中的使用非常廣泛,不同線程間、函數間的對象共用可以使用堆對象,大型物件一般也使用堆對象(棧空間是有限的),特別是虛函數多態性一般是由堆對象實現的。使用堆對象也有一些缺點:1.需要程式員管理生存周期,忘記釋放會有記憶體泄露,多次釋放可能造成程式崩潰,通過智能指標可以避免這個問題;2.堆對象的時間效率和空間效率沒有棧對象高,堆對象一般通過某種搜尋演算法在堆中找到合適大小的記憶體,比較耗時間,另外從堆中分配的記憶體大小會比實際申請的記憶體大幾個位元組,比較耗空間,尤其是對於小型對象這種損耗是非常大的;3.頻繁使用new/delete 堆對象會造成大量的記憶體片段,記憶體得不到充分的使用。對於2,3兩個問題可以通過記憶體一次分配,多次使用的方法解決,更好的方法是根據業務特點實現特定的記憶體池。
棧:棧對象是自生自滅型對象,程式員無需對其生存周期進行管理。一般,臨時對象、函數內的局部對象都是棧對象。使用棧對象是高效的,因為它不需要進行記憶體搜尋只進行棧頂指標的移動。另外棧對象是安全執行緒的,因為不同的線程有自己的棧記憶體。當然,棧的空間是有限的,所以使用中要防止棧溢出的出現,通常大型物件、大型數組、遞迴函式都要慎用棧對象。
2 C++對象的建立和銷毀
C++類有四個基本函數:建構函式、解構函式、拷貝建構函式、賦值運算子多載函數,這四個函數管理著C++對象的建立和銷毀,正確而完整地實現這些函數是C++對象安全運行必要保證。
2.1 構造/析構
建立一個對象有兩個步驟: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.衍生類別裡的成員對象分配的資源。尤其是第二類,隱蔽性非常高。
構造和析構是一組被成對調用的函數,特別是對於棧對象,調用是由系統自動完成的,所以我們可以利用這一特性將一些需要成對出現的操作分別封裝在構造和解構函式裡由系統自動完成,這樣可以避免由於編程時的遺漏而忘記進行某種操作。比如資源的申請和釋放,多線程中的加鎖和解鎖都可以利用棧對象的這一特性進行自動管理。
2.2 拷貝/賦值
拷貝建構函式、賦值運算子多載函數是一對孿生兄弟,通常一個類如果需要顯式寫拷貝建構函式,那麼它也需要顯式寫賦值運算子多載函數。拷貝建構函式的功能是用已存在的物件建構一個新的對象,賦值運算子多載函數的功能是用已存在的對象替換一個已存在的對象。看下面幾條語句:
string str1 = “string test”; //調用帶參數的建構函式
string str2(str1); //調用拷貝建構函式
string str3 = str1; //調用拷貝建構函式
string str4; //調用預設建構函式
str4 = str3; //調用賦值運算子多載函數
拷貝建構函式、賦值運算子多載函數原型如下:
class string
{
private:
char* m_pStr;
int m_nSize;
public:
string (); //預設建構函式
string (const char* pStr); //帶參數的建構函式
~ string ();
string (const string & cOther); //拷貝建構函式
string & operator=(const string & cOther); //賦值運算子多載函數
}
這兩個函數的參數類型都是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
{
private:
char* m_pStr;
int m_nSize;
public:
string (); //預設構造
string (const char* pStr); //帶參數的建構函式
~ string ();
string (const string & cOther) //實現拷貝建構函式
{
Copy(cOther);
}
string & operator=(const string & cOther) //實現賦值運算子多載函數
{
if (this != & cOther)
{
Clear();
Copy(cOther);
}
return *this;
}
private:
void Copy(const string & cOther)
{
m_pStr = new char [cOthre. m_nSize+1];
strcpy(m_pStr, cOther. m_pStr);
m_nSize = cOther.m_nSize;
}
void Clear()
{
if (m_pStr != NULL)
{
delete[] m_pStr;
m_pStr = NULL;
}
m_nSize = 0;
}
}
string類中這兩個函數的實現模式可以在其他類中直接套用,只需要改動Copy和Clear()函數即可。
3 總結
作為c++程式員每天都要和類、對象以及記憶體打交道,寫一個類實現某項功能不難,但要實現一個健壯的、可重用的、易擴充的類就不是很容易了。很多時候我們寫一個類時用的還是c的思維,對類的四個基本函數考慮的不夠周到仔細,對類對象在不同記憶體地區運行特點理解不夠,容易產生一些低級的bug,而且對後續的代碼維護擴充也帶來難度。本文中對這些內容做了基本的介紹,希望對大家有些協助。