拷貝構造函數
一個小例子
最近在《劍指Offer》上看到了一道題(程序如下),要求我們分析編譯運行的結果,並提供3個選項: A. 編譯錯誤; B. 編譯成功,運行時程序崩潰;C. 編譯運行正常,輸出10。
1 #include <iostream> 2 using namespace std; 3 4 class A 5 { 6 private: 7 int value; 8 9 public: 10 A(int n) { value = n; } 11 A(A other) { value = other.value; } 12 void Print() { cout << value << endl; } 13 }; 14 15 int main() 16 { 17 A a = 10; 18 A b = a; 19 b.Print(); 20 return 0; 21 }
這個程序是通不過編譯的,GCC和VS均通不過。根據《劍指Offer》上的解釋,上述程序中的拷貝構造函數A(A other)傳入的參數是A的一個實例,所以由於是傳值參數,我們把形參復制到實參會調用拷貝構造函數。因此如果允許拷貝構造函數傳值,就會在拷貝構造函數內調用復制構造函數,就會形成無休止的遞歸調用從而導致棧溢出。為了說明這個解釋,我們先看下稍微修改過的程序:
1 #include <iostream> 2 using namespace std; 3 4 class A 5 { 6 private: 7 int value; 8 9 public: 10 A(int n) 11 { 12 value = n; 13 cout << "constructor with argument" << endl; 14 } 15 16 A(const A& other) 17 { 18 value = other.value; 19 cout << "copy constructor" << endl; 20 } 21 22 A& operator=(const A& other) 23 { 24 cout << "assignment operator" << endl; 25 value = other.value; 26 return *this; 27 } 28 29 void func(A other) 30 { 31 } 32 }; 33 34 int main() 35 { 36 A a = 10; // constructor with argument 37 A b = 5; // constructor with argument 38 b = a; // assignment operator 39 A c = a; // copy constructor 40 b.func(a); // copy constructor 41 42 return 0; 43 }
程序的運行結果如下:
對於上述程序的第39行,構造c,實質上是c.A(a)。假如拷貝構造函數參數不是引用類型的話,那么將使得c.A(a)變成a傳值給c.A(A other),即A other = a,而other沒有被初始化,所以other = a將繼續調用拷貝構造函數(這也是為什么調用func函數會調用拷貝構造函數)。接下來是構造other,也就是other.A(a),即A other = a,又會觸發拷貝構造函數,這樣永遠地遞歸下去。
關於上述程序還有一些值得說明的:
1)當某對象沒被初始化,這時運用賦值運算符調用的是拷貝構造函數;否則調用的是賦值運算符重載函數;
2)上述程序的“A a = 10”實際存在隱式類型轉換(從int到A),這是編譯器默認幫我們處理的。這條語句相當於:
A tmp(10); A a(tmp); // or a = tmp
如果我們不想編譯器默認幫我們做這種轉換,我們可以在構造函數前加上關鍵字explicit:
explicit A(int n) {...}
這樣,類似這樣的賦值“A a = 10”將通不過編譯。
另外,當我們定義如下的函數:如果構造函數前沒有加explicit,則我們這樣子調用test(10)是可以的;但如果加了explicit就會提示無法從‘int’轉換為‘A’。
void test(A other) { }
關於explicit可參考:
What does the explicit keyword in C++ mean?
3)關於public、protected、private幾個關鍵字的重新理解。如果我們直接在main函數用a.value是不行的(因為權限是private),但在拷貝構造函數和重載賦值運算符函數中確是可以的,而且,當我們不將傳入參數設定為const的話,我們在函數中還可以修改傳入參數的值,這不是自相矛盾了嗎?具體解釋可以參考博文C++ 類訪問控制public/private/protected探討的說法:
“
類是將數據成員和進行於其上的一系列操作(成員函數)封裝在一起。注意:成員函數可以操作數據成員(可以稱類中的數據成員為泛數據成員)!
對象是類的實例化,怎樣理解實例化?其實每一個實例對象都只是對其中的數據成員初始化,內存映像中每個對象僅僅保留屬於自己的那份數據成員副本。而成員函數對於整個類而言卻是共享的,即一個類只保留一份成員函數。
那么每個對象怎樣和這些可以認為是“分離”的成員函數發生聯系,即成員函數如何操作對象的數據成員?記住this指針,無論對象通過(.)操作或者(->)操作調用成員函數。編譯時刻,編譯器都會將這種調用轉換成我們常見的全局函數的形式,並且多出一個參數(一般這個參數放在第一個,有點像python中類中函數聲明中的self參數),然后將this指針傳入這個參數。於是就完成了對象與成員函數的綁定(或聯系)。
實例化后就得到同一個類的多個不同的對象,既然成員函數共享的,那么成員函數就可以操作對象的數據成員。
問題是現在有多個對象,成員函數需要知道操作的是哪個對象的數據成員?比如有對象obj1和obj2,都屬於A類,A類有public成員函數foo()。如果obj1調用該函數,編譯時會給foo函數傳入this指針,obj1.foo中操作obj1自身的成員就不用任何修飾,直接訪問,因為其中的數據成員自動根據this指針找到。
如果obj1調用該函數,同樣可以訪問同類的其他對象的數據成員!那么你需要做的是讓foo函數知道是同類對象中哪個對象的數據成員,一個解決辦法是傳入同類其他對象的指針或引用,那么就可以操作同類其他對象的數據成員。
foo(A& obj)
這樣定義,然后調用:
obj1.foo(obj2)
就可以在obj1訪問obj2的數據成員,而無論這些數據成員是private還是protected
"
處理靜態成員變量
關於靜態成員變量詳細的可參考之前博文C/C++中關鍵字static的用法及作用。靜態成員變量主要是為了同個類的不同實例之間數據的共享,所以其處理方法也跟其他成員變量稍微不一樣。
先看下程序:
1 class Rect 2 { 3 public: 4 Rect() // 構造函數,計數器加1 5 { 6 count++; 7 } 8 ~Rect() // 析構函數,計數器減1 9 { 10 count--; 11 } 12 static int getCount() // 返回計數器的值 13 { 14 return count; 15 } 16 private: 17 int width; 18 int height; 19 static int count; // 一靜態成員做為計數器 20 }; 21 22 int Rect::count = 0; // 初始化計數器 23 24 int main() 25 { 26 Rect rect1; 27 cout << "The count of Rect: " << Rect::getCount() << endl; 28 29 Rect rect2(rect1); // 使用rect1復制rect2,此時應該有兩個對象 30 cout << "The count of Rect: " << Rect::getCount() << endl; 31 32 return 0; 33 }
程序的輸出結果都是count = 1,這明顯跟我們的期望值(應該是2)不一樣。具體原因在於我們沒有定制拷貝構造函數,而是由編譯器幫我們自動生成一個默認拷貝函數:
1 Rect::Rect(const Rect& orig) 2 { 3 width = orig.width; 4 height = orig.height; 5 }
顯然這里並沒有處理靜態成員變量count,所以我們需要定制拷貝構造函數:
1 Rect(const Rect& orig) // 拷貝構造函數 2 { 3 width = orig.width; 4 height = orig.height; 5 count++; // 計數器加1 6 }
深拷貝與淺拷貝
深拷貝主要解決的問題是指針成員變量淺拷貝的問題。這方面的博文很多,可以參考博文C++拷貝構造函數詳解。這篇博文有提到了幾點值得注意的:
1. 防止默認拷貝(也能夠禁止復制)
有一個小技巧可以防止按值傳遞——聲明一個私有拷貝構造函數。甚至不必去定義這個拷貝構造函數,這樣因為拷貝構造函數是私有的,如果用戶試圖按值傳遞或函數返回該類對象,將得到一個編譯錯誤,從而可以避免按值傳遞或返回對象。如下程序:
1 #include <iostream> 2 using namespace std; 3 4 class CExample 5 { 6 private: 7 int value; 8 9 public: 10 //構造函數 11 CExample(int val) 12 { 13 value = val; 14 cout << "creat: " << value << endl; 15 } 16 17 private: 18 //拷貝構造,只是聲明 19 CExample(const CExample& C); 20 21 public: 22 ~CExample() 23 { 24 cout << "delete: " << value << endl; 25 } 26 27 void Show() 28 { 29 cout << value << endl; 30 } 31 }; 32 33 //全局函數 34 void g_Fun(CExample C) 35 { 36 cout << "test" << endl; 37 } 38 39 int main() 40 { 41 CExample test(1); 42 // g_Fun(test); // 按值傳遞將出錯 43 44 return 0; 45 }
而根據《C++ Primer》第四版13.1.3節,要禁止類的復制, 類必須顯示聲明其復制構造函數為private。
2. 小問題1:以下哪個函數是拷貝構造函數,為什么?
1 X::X(const X&); 2 X::X(X); 3 X::X(X&, int a=1); 4 X::X(X&, int a=1, int b=2);
解答:對於一個類X,如果一個構造函數的第一個參數是下列之一:
a)X&
b)const X&
c) volatile X&
d)const volatile X&
且沒有其他參數或其他參數都有默認值,那么這個函數是拷貝構造函數.
1 X::X(const X&); //是拷貝構造函數 2 X::X(X&, int=1); //是拷貝構造函數 3 X::X(X&, int a=1, int b=2); //當然也是拷貝構造函數
3. 小問題2:一個類中可以有多個拷貝構造函數嗎?
解答:類中可以存在超過一個拷貝構造函數。
1 class X { 2 public: 3 X(const X&); // const 的拷貝構造 4 X(X&); // 非const的拷貝構造 5 };
賦值運算符重載
關於運算符重載可參考之前博文C++運算符重載。
在本文的第2個程序中我們就已經對賦值運算符進行了重載:
1 A& operator=(const A& other) 2 { 3 cout << "assignment operator" << endl; 4 value = other.value; 5 6 return *this; 7 }
為了便於說明,我們用一個新的例子(我們極容易寫出這樣的代碼):
1 class CMyString 2 { 3 public: 4 CMyString(char *ptr = nullptr); 5 CMyString(const CMyString &str); 6 ~CMyString(); 7 CMyString& operator=(const CMyString& str); 8 9 private: 10 char *pData; 11 }; 12 13 CMyString& CMyString::operator=(const CMyString& str) 14 { 15 pData = str.pData; 16 return *this; 17 }
這個賦值運算符重載函數存在的問題如下:
1)淺拷貝;
2)沒有(檢查)釋放實例自身已有的內存。如果我們忘記在分配新內存之前釋放自身已有的空間,程序將出現內存泄漏;
3)沒有判斷傳入的參數和當前的實例(*this)是不是同一個實例。如果是同一個,則不進行復制操作,直接返回。如果事先不判斷就進行賦值,那么在釋放實例自身的內存的時候就會導致嚴重問題:當*this和傳入的參數是同一個實例時,那么一旦釋放了自身的內存,傳入的參數的內存也同時被釋放了,因此在也找不到需要賦值的內容了。
修改之后的賦值運算符重載函數如下:
1 CMyString& CMyString::operator=(const CMyString& str) 2 { 3 if (this == &str) 4 return *this; 5 6 delete []pData; 7 pData = nullptr; 8 9 pData = new char[strlen(str.pData) + 1]; 10 strcpy(pData, str.pData); 11 12 return *this; 13 }
上述代碼現在的問題在於4)異常安全性,即new可能會拋出異常,而我們卻沒有處理!所以我們可以將程序繼續修改:
1 CMyString& CMyString::operator=(const CMyString& str) 2 { 3 if (this == &str) 4 return *this; 5 6 char *tmp = new(nothrow) char[strlen(str.pData) + 1]; 7 if (tmp == nullptr) 8 return *this; 9 10 strcpy(tmp, str.pData); 11 12 delete []pData; 13 pData = tmp; 14 tmp = nullptr; 15 16 return *this; 17 }
除了前邊提到的4個點,賦值運算符重載還有兩點需要注意:
5)是否把返回值的類型聲明為該類型的引用,並在函數結束前返回實例自身的引用(即*this)。只有返回一個引用,才可以允許連續賦值。否則如果函數的返回值是void,應用該賦值將不能做連續賦值。假設有3個CMyString對象:str1、str2和str3,在程序中語句str1=str2=str3將不能通過編譯。
6)是否把傳入的參數的類型聲明為常量引用。如果傳入的參數不是引用而是實例,那么從形參到實參會調用一次拷貝構造函數。把參數聲明為引用可以避免這樣的無謂消耗,從而提高代碼效率。同時,我們在賦值運算符函數內不會修改傳入的實例的狀態,因此應該為傳入的引用參數加上const關鍵字。
參考資料
《劍指Offer》
C++ 類訪問控制public/private/protected探討