第9章 類的建構函式、解構函式與賦值函數
建構函式、解構函式與賦值函數是每個類最基本的函數。它們太普通以致讓人容易麻痹大意,其實這些貌似簡單的函數就象沒有頂蓋的下水道那樣危險。
每個類只有一個解構函式和一個賦值函數,但可以有多個建構函式(包含一個拷貝建構函式,其它的稱為普通建構函式)。對於任意一個類A,如果不想編寫上述函數,C++編譯器將自動為A產生四個預設的函數,如 A(void); // 預設的無參數建構函式 A(const A &a); // 預設的拷貝建構函式 ~A(void); // 預設的解構函式 A & operate =(const A &a); // 預設的賦值函數
這不禁讓人疑惑,既然能自動產生函數,為什麼還要程式員編寫?原因如下:(1)如果使用“預設的無參數建構函式”和“預設的解構函式”,等於放棄了自主“初始化”和“清除”的機會,C++發明人Stroustrup的好心好意白費了。(2)“預設的拷貝建構函式”和“預設的賦值函數”均採用“位拷貝”而非“值拷貝”的方式來實現,倘若類中含有指標變數,這兩個函數註定將出錯。
對於那些沒有吃夠苦頭的C++程式員,如果他說編寫建構函式、解構函式與賦值函數很容易,可以不用動腦筋,表明他的認識還比較膚淺,水平有待於提高。本章以類String的設計與實現為例,深入闡述被很多教科書忽視了的道理。String的結構如下: class String { public: String(const char *str = NULL); // 普通建構函式 String(const String &other); // 拷貝建構函式 ~ String(void); // 解構函式 String & operate =(const String &other); // 賦值函數 private: char *m_data; // 用於儲存字串 }; 9.1 建構函式與解構函式的起源作為比C更先進的語言,C++提供了更好的機制來增強程式的安全性。C++編譯器具有嚴格的型別安全檢查功能,它幾乎能找出程式中所有的文法問題,這的確幫了程式員的大忙。但是程式通過了編譯檢查並不表示錯誤已經不存在了,在“錯誤”的大家庭裡,“語法錯誤”的地位只能算是小弟弟。層級高的錯誤通常隱藏得很深,就象狡猾的罪犯,想逮住他可不容易。根據經驗,不少難以察覺的程式錯誤是由於變數沒有被正確初始化或清除造成的,而初始化和清除工作很容易被人遺忘。Stroustrup在設計C++語言時充分考慮了這個問題並很好地予以解決:把對象的初始化工作放在建構函式中,把清除工作放在解構函式中。當對象被建立時,建構函式被自動執行。當對象消亡時,解構函式被自動執行。這下就不用擔心忘了對象的初始化和清除工作。建構函式與解構函式的名字不能隨便起,必須讓編譯器認得出才可以被自動執行。Stroustrup的命名方法既簡單又合理:讓建構函式、解構函式與類同名,由於解構函式的目的與建構函式的相反,就加首碼‘~’以示區別。除了名字外,建構函式與解構函式的另一個特別之處是沒有傳回值類型,這與傳回值類型為void的函數不同。建構函式與解構函式的使命非常明確,就象出生與死亡,光溜溜地來光溜溜地去。如果它們有傳回值類型,那麼編譯器將不知所措。為了防止節外生枝,乾脆規定沒有傳回值類型。(以上典故參考了文獻[Eekel, p55-p56]) 9.2 建構函式的初始化表 建構函式有個特殊的初始化方式叫“初始設定式表”(簡稱初始化表)。初始化表位於函數參數表之後,卻在函數體 {} 之前。這說明該表裡的初始化工作發生在函數體內的任何代碼被執行之前。
建構函式初始化表的使用規則: * 如果類存在繼承關係,衍生類別必須在其初始化表裡調用基類的建構函式。例如 class A {… A(int x); // A的建構函式 }; class B : public A {… B(int x, int y);// B的建構函式 }; B::B(int x, int y) : A(x) // 在初始化表裡調用A的建構函式 { … } * 類的const常量只能在初始化表裡被初始化,因為它不能在函數體內用賦值的方式來初始化(參見5.4節)。
* 類的資料成員的初始化可以採用初始化表或函數體內賦值兩種方式,這兩種方式的效率不完全相同。非內部資料類型的成員對象應當採用第一種方式初始化,以擷取更高的效率。例如 class A {… A(void); // 無參數建構函式 A(const A &other); // 拷貝建構函式 A & operate =( const A &other); // 賦值函數 };
class B { public: B(const A &a); // B的建構函式 private: A m_a; // 成員對象 };
樣本9-2(a)中,類B的建構函式在其初始化表裡調用了類A的拷貝建構函式,從而將成員對象m_a初始化。樣本9-2 (b)中,類B的建構函式在函數體內用賦值的方式將成員對象m_a初始化。我們看到的只是一條指派陳述式,但實際上B的建構函式幹了兩件事:先暗地裡建立m_a對象(調用了A的無參數建構函式),再調用類A的賦值函數,將參數a賦給m_a。
B::B(const A &a) : m_a(a) { … } 樣本9-2(a) 成員對象在初始化表中被初始
B::B(const A &a) { m_a = a; … } 樣本9-2(b) 成員對象在函數體內被初始化
對於內部資料類型的資料成員而言,兩種初始化方式的效率幾乎沒有區別,但後者的程式版式似乎更清晰些。若類F的聲明如下: class F { public: F(int x, int y); // 建構函式 private: int m_x, m_y; int m_i, m_j; } 樣本9-2(c)中F的建構函式採用了第一種初始化方式,樣本9-2(d)中F的建構函式採用了第二種初始化方式。 F::F(int x, int y) : m_x(x), m_y(y) { m_i = 0; m_j = 0; }
樣本9-2(c) 資料成員在初始化表中被初始化
F::F(int x, int y) { m_x = x; m_y = y; m_i = 0; m_j = 0; } 樣本9-2(d) 資料成員在函數體內被初始化
9.3 構造和析構的次序構造從類層次的最根處開始,在每一層中,首先調用基類的建構函式,然後調用成員對象的建構函式。析構則嚴格按照與構造相反的次序執行,該次序是唯一的,否則編譯器將無法自動執行析構過程。一個有趣的現象是,成員對象初始化的次序完全不受它們在初始化表中次序的影響,只由成員對象在類中聲明的次序決定。這是因為類的聲明是唯一的,而類的建構函式可以有多個,因此會有多個不同次序的初始化表。如果成員對象按照初始化表的次序進行構造,這將導致解構函式無法得到唯一的逆序。[Eckel, p260-261]
9.4 樣本:類String的建構函式與解構函式 // String的普通建構函式 String::String(const char *str) { if(str==NULL) { m_data = new char[1]; *m_data = ‘’; } else { int length = strlen(str); m_data = new char[length+1]; strcpy(m_data, str); } } // String的解構函式 String::~String(void) { delete [] m_data; // 由於m_data是內部資料類型,也可以寫成 delete m_data; } 9.5 不要輕視拷貝建構函式與賦值函數由於並非所有的對象都會使用拷貝建構函式和賦值函數,程式員可能對這兩個函數有些輕視。請先記住以下的警告,在閱讀本文時就會多心:
* 本章開頭講過,如果不主動編寫拷貝建構函式和賦值函數,編譯器將以“位拷貝” 的方式自動產生預設的函數。倘若類中含有指標變數,那麼這兩個預設的函數就隱含了錯誤。以類String的兩個對象a,b為例,假設a.m_data的內容為“hello”,b.m_data的內容為“world”。
現將a賦給b,預設賦值函數的“位拷貝”意味著執行b.m_data = a.m_data。這將造成三個錯誤:一是b.m_data原有的記憶體沒被釋放,造成記憶體泄露;二是b.m_data和a.m_data指向同一塊記憶體,a或b任何一方變動都會影響另一方;三是在對象被析構時,m_data被釋放了兩次。拷貝建構函式和賦值函數非常容易混淆,常導致錯寫、錯用。拷貝建構函式是在對象被建立時調用的,而賦值函數只能被已經存在了的對象調用。以下程式中,第三個語句和第四個語句很相似,你分得清楚哪個調用了拷貝建構函式,哪個調用了賦值函數嗎?
String a(“hello”); String b(“world”); String c = a; // 調用了拷貝建構函式,最好寫成 c(a); c = b; // 調用了賦值函數本例中第三個語句的風格較差,宜改寫成String c(a) 以區別於第四個語句。
9.6 樣本:類String的拷貝建構函式與賦值函數 // 拷貝建構函式 String::String(const String &other) { // 允許操作other的私人成員m_data int length = strlen(other.m_data); m_data = new char[length+1]; strcpy(m_data, other.m_data); } // 賦值函數 String & String::operate =(const String &other) { // (1) 檢查自賦值 if(this == &other) return *this; // (2) 釋放原有的記憶體資源 delete [] m_data; // (3)分配新的記憶體資源,並複製內容 int length = strlen(other.m_data); m_data = new char[length+1]; strcpy(m_data, other.m_data); // (4)返回本對象的引用 return *this; } 類String拷貝建構函式與普通建構函式(參見9.4節)的區別是:在函數入口處無需與NULL進行比較,這是因為“引用”不可能是NULL,而“指標”可以為NULL。類String的賦值函數比建構函式複雜得多,分四步實現:(1)第一步,檢查自賦值。你可能會認為多此一舉,難道有人會愚蠢到寫出 a = a 這樣的自指派陳述式!的確不會。但是間接的自賦值仍有可能出現,例如
// 內容自賦值 b = a; … c = b; … a = c;
// 地址自賦值 b = &a; … a = *b; 也許有人會說:“即使出現自賦值,我也可以不理睬,大不了化點時間讓對象複製自己而已,反正不會出錯!”他真的說錯了。看看第二步的delete,自殺後還能複製自己嗎?所以,如果發現自賦值,應該馬上終止函數。注意不要將檢查自賦值的if語句 if(this == &other) 錯寫成為 if( *this == other) (2)第二步,用delete釋放原有的記憶體資源。如果現在不釋放,以後就沒機會了,將造成記憶體泄露。(3)第三步,分配新的記憶體資源,並複製字串。注意函數strlen返回的是有效字串長度,不包含結束符‘’。函數strcpy則連‘’一起複製。
(4)第四步,返回本對象的引用,目的是為了實現象 a = b = c 這樣的鏈式表達。注意不要將 return *this 錯寫成 return this 。那麼能否寫成return other 呢?效果不是一樣嗎?
不可以!因為我們不知道參數other的生命期。有可能other是個臨時對象,在賦值結束後它馬上消失,那麼return other返回的將是垃圾。
9.7 偷懶的辦法處理拷貝建構函式與賦值函數如果我們實在不想編寫拷貝建構函式和賦值函數,又不允許別人使用編譯器產生的預設函數,怎麼辦?偷懶的辦法是:只需將拷貝建構函式和賦值函式宣告為私人函數,不用編寫代碼。例如: class A { … private: A(const A &a); // 私人的拷貝建構函式 A & operate =(const A &a); // 私人的賦值函數 }; 如果有人試圖編寫如下程式: A b(a); // 調用了私人的拷貝建構函式 b = a; // 調用了私人的賦值函數編譯器將指出錯誤,因為外界不可以操作A的私人函數。 9.8 如何在衍生類別中實作類別的基本函數基類的建構函式、解構函式、賦值函數都不能被衍生類別繼承。如果類之間存在繼承關係,在編寫上述基本函數時應注意以下事項:
* 衍生類別的建構函式應在其初始化表裡調用基類的建構函式。基類與衍生類別的解構函式應該為虛(即加virtual)