1. 程式人生 > >C++ 拷貝建構函式與賦值函式的區別(很嚴謹和全面)

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程式設計指南》