拷貝構造函數與賦值運算符重載函數要點


拷貝構造函數

一個小例子

  最近在《劍指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?

  C++ explicit關鍵字 詳解(用於構造函數)

 

  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++拷貝構造函數詳解

  C++ 類訪問控制public/private/protected探討

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM