C++ 拷貝構造函數與賦值函數

這裏我們用類String 來介紹這兩個函數:

拷貝構造函數是一種特殊構造函數,具有單個形參,該形參(常用const修飾)是對該類類型的引用。當定義一個新對象並用一個同類型的對象對它進行初始化時,將顯式使用拷貝構造函數。爲啥形參必須是對該類型的引用呢?試想一下,假如形參是該類的一個實例,由於是傳值參數,我們把形參複製到實參會調用拷貝構造函數,如果允許拷貝構造函數傳值,就會在拷貝構造函數內調用拷貝構造函數,從而形成無休止的遞歸調用導致棧溢出。

string(const string &s);
//類成員,無返回值

賦值函數,也是賦值操作符重載,因爲賦值必須作爲類成員,那麼它的第一個操作數隱式綁定到 this 指針,也就是 this 綁定到指向左操作數的指針。因此,賦值操作符接受單個形參,且該形參是同一類類型的對象。右操作數一般作爲const 引用傳遞。

string& operator=(const string &s);
//類成員,返回對同一類類型(左操作數)的引用

拷貝構造函數和賦值函數並非每個對象都會使用,另外如果不主動編寫的話,編譯器將以“位拷貝”的方式自動生成缺省的函數。在類的設計當中,“位拷貝”是應當防止的。倘若類中含有指針變量,那麼這兩個缺省的函數就會發生錯誤。這就涉及到深複製和淺複製的問題了。 
拷貝有兩種:深拷貝,淺拷貝 
當出現類的等號賦值時,會調用拷貝函數,在未定義顯示拷貝構造函數的情況下,系統會調用默認的拷貝函數——即淺拷貝,它能夠完成成員的一一複製。當數據成員中沒有指針時,淺拷貝是可行的。 
但當數據成員中有指針時,如果採用簡單的淺拷貝,則兩類中的兩個指針將指向同一個地址,當對象快結束時,會調用兩次析構函數,而導致指針懸掛現象。所以,這時,必須採用深拷貝。 
深拷貝與淺拷貝的區別就在於深拷貝會在堆內存中另外申請空間來儲存數據,從而也就解決了指針懸掛的問題。指向不同的內存空間,但內容是一樣的 
簡而言之,當數據成員中有指針時,必須要用深拷貝。

class A{
    char * c;
}a, b;

//淺複製不會重新分配內存
//將a 賦給 b,缺省賦值函數的“位拷貝”意味着執行
a.c = b.c;
//從這行代碼可以看出
//b.c 原有的內存沒有釋放
//a.c 和 b.c 指向同一塊內存,任何一方的變動都會影響到另一方
//對象析構的時候,c 被釋放了兩次(a.c == b.c 指針一樣)

//深複製需要自己處理裏面的指針
class A{
    char *c;
    A& operator =(const A &b)
    {
        //隱含 this 指針
        if (this == &b)
            return *this;
        delete c;//釋放原有內存資源

        //分配新的內存資源
        int length = strlen(b.c);
        c = new char[length + 1];
        strcpy(c, b.c);

        return *this;
    }
}a, b;
//這個是深複製,它有自定義的複製函數,賦值時,對指針動態分配了內存

這裏再總結一下深複製和淺複製的具體區別:

  1. 當拷貝對象狀態中包含其他對象的引用時,如果需要複製的是引用對象指向的內容,而不是引用內存地址,則是深複製,否則是淺複製。
  2. 淺複製就是成員數據之間的賦值,當值拷貝時,兩個對象就有共同的資源。而深拷貝是先將資源複製一份,是對象擁有不同的資源(內存區域),但資源內容(內存裏面的數據)是相同的。
  3. 與淺複製不同,深複製在處理引用時,如果改變新對象內容將不會影響到原對象內容
  4. 與深複製不同,淺複製資源後釋放資源時可能會產生資源歸屬不清楚的情況(含指針時,釋放一方的資源,其實另一方的資源也隨之釋放了),從而導致程序運行出錯

深複製和淺複製還有個區別就是執行的時候,淺複製是直接複製內存地址的,而深複製需要重新開闢同樣大小的內存區域,然後複製整個資源。

好,有了前面的鋪墊,下面開始講講拷貝構造函數和賦值函數,其實前面第一部分也已經介紹了許多

這裏以string 類爲例來進行說明

class String
{
public:
    String(const char *str = NULL);
    String(const String &rhs);
    String& operator=(const String &rhs);
    ~String(void){
        delete[] m_data;
    }

private:
    char *m_data;
};

//構造函數
String::String(const char* str)
{
    if (NULL == str)
    {
        m_data = new char[1];
        *m_data = '\0';
    }
    else
    {
        m_data = new char[strlen(str) + 1];
        strcpy(m_data, str);
    }
}

//拷貝構造函數,無需檢驗參數的有效性
String::String(const String &rhs)
{
    m_data = new char[strlen(rhs.m_data) + 1];
    strcpy(m_data, rhs.m_data);
}

//賦值函數
String& String::operator=(const String &rhs)
{
    if (this == &rhs)
        return *this;

    delete[] m_data; m_data = NULL;
    m_data = new char[strlen(rhs.m_data) + 1];
    strcpy(m_data, rhs.m_data);

    return *this;
}

類String 拷貝構造函數與普通構造函數的區別是:在函數入口處無需與 NULL 進行比較,這是因爲“引用”不可能是NULL,而“指針”可以爲NULL。(這是引用與指針的一個重要區別)。然後需要注意的就是深複製了。 
相比而言,對於類String 的賦值函數則要複雜的多:

1、首先需要執行檢查自賦值

這是防止自複製以及間接複製,如 b = a; c = b; a = c;之類,如果不進行自檢的話,那麼後面的 delete 將會進行自殺操作,後面隨之的拷貝操作也會出錯,所以這是關鍵的一步。還需要注意的是,自檢是檢查地址,而不是內容,內存地址是唯一的。必須是 if(this == &rhs)

2、釋放原有的內存資源

必須要用 delete 釋放掉原有的內存資源,如果此時不釋放,該變量指向的內存地址將不再是原有內存地址,也就無法進行內存釋放,造成內存泄露。

3、分配新的內存資源,並複製資源

這樣變量指向的內存地址變了,但是裏面的資源是一樣的

4、返回本對象的引用

這樣的目的是爲了實現像 a = b = c; 這樣的鏈式表達,注意返回的是 *this 。

但仔細一想,上面的程序沒有考慮到異常安全性,我們在分配內存之前用delete 釋放了原有實例的內存,如果後面new 出現內存不足拋出異常,那麼之前delete 的 m_data 將是一個空指針,這樣很容易引起程序崩潰,所以我們可以調換下順序,即先 new 一個實例內存,成功後再用 delete 釋放原有內存空間,最後用 m_data 賦值爲new後的指針。

接下來說說拷貝構造函數和賦值函數之間的區別。

拷貝構造函數和賦值函數非常容易混淆,常導致錯寫、錯用。拷貝構造函數是在對象被創建是調用的,而賦值函數只能在已經存在了的對象調用。看下面代碼:

    String a("hello");
    String b("world");

    String c = a;//這裏c對象被創建調用的是拷貝構造函數
                 //一般是寫成 c(a);這裏是與後面比較
    c = b;//前面c對象已經創建,所以這裏是賦值函數

上面說明出現“=”的地方未必調用的都是賦值函數(算術符重載函數),也有可能拷貝構造函數,那麼什麼時候是調用拷貝構造函數,什麼時候是調用賦值函數你?判斷的標準其實很簡單:如果臨時變量是第一次出現,那麼調用的只能是拷貝構造函數,反之如果變量已經存在,那麼調用的就是賦值函數。 
參考資料:《Effective C++》、《高質量C++&C編程指南》

 

轉自: https://blog.csdn.net/zhoucheng05_13/article/details/80937775

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章