【C++】 淺析深淺拷貝

  C++中深拷貝和淺拷貝的問題是很值得我們注意的知識點,如果編程中不注意,可能會出現疏忽,導致bug。本文就詳細講講C++深淺拷貝的種種。

  我們知道,對於一般對象:

    int a = 1;
    int b = 2;

  這樣的賦值,複製很簡單,但對於類對象來說並不一般,因爲其內部包含各種類型的成員變量,在拷貝過程中就會出現問題

例如:

#include <iostream>
using namespace std;

class String
{
public:

    String(char str = "")
        :_str(new char[strlen(str) + 1]) // +1是爲了避免空字符串導致出錯
    {
        strcpy(_str , str);
    }
    
    // 淺拷貝
    String(const String& s)
        :_str(s._str)
    {}
    
    ~String()
    {
        if(_str)
        {
            delete[] _str;
            _str = NULL;
        }
        cout<<"~String()"<<endl;
    }
    
    void Display()
    {
        cout<<_str<<endl;
    }
    
private:
    char *_str;
};

void Test()
{
    String s1("hello");
    String s2(s1);
    String.Display();
    
}

int main()
{
    Test();
    
    return 0;
}

運行結果:

wKiom1bqRJ7CC1j7AABxvgeu1pI402.png

我們發現,編譯通過了,但是崩潰了 =  =ll ,這就是淺拷貝帶來的問題。


   事實是,在對象拷貝過程中,如果沒有自定義拷貝構造函數,系統會提供一個缺省的拷貝構造函數,缺省的拷貝構造函數對於基本類型的成員變量,按字節複製,對於類類型成員變量,調用其相應類型的拷貝構造函數。原型如下:

String(const String& s)
     {}

但是,編譯器提供的缺省函數並不是十全十美的。


      缺省拷貝構造函數在拷貝過程中是按字節複製的,對於指針型成員變量只複製指針本身,而不復制指針所指向的目標--淺拷貝。


用圖形象化爲:


wKioL1bqSOax7GQiAAAb-81vW_4778.png

  在進行對象複製後,事實上s1、s2裏的成員指針 _str 都指向了一塊內存空間(即內存空間共享了),在s1析構時,delete了成員指針 _str 所指向的內存空間,而s2析構時同樣指向(此時已變成野指針)並且要釋放這片已經被s1析構函數釋放的內存空間,這就讓同樣一片內存空間出現了 “double free” ,從而出錯。而淺拷貝還存在着一個問題,因爲一片空間被兩個不同的子對象共享了,只要其中的一個子對象改變了其中的值,那另一個對象的值也跟着改變。所以這不是我們想要的結果,同事也不是真正意義上的複製。


爲了解決淺拷貝問題,我們引出深拷貝,即自定義拷貝構造函數,如下:

String(const String& s)
        :_str(new char[strlen(s._str) + 1])
    {
        strcpy(_str , s._str);
    }

這樣在運行就沒問題了。


那麼,程序中還有沒有其他地方用到拷貝構造函數呢?

答案:當函數存在對象型的參數(即拷貝構造)或對象型的返回值(賦值時的返回值)時都會用到拷貝構造函數。


而拷貝賦值的情況基本上與拷貝複製是一樣的。只是拷貝賦值是屬於操作符重載問題。例如在主函數若有:String s3; s3 = s2; 這樣系統在執行時會調用系統提供的缺省的拷貝賦值函數,原型如下:


void operator = (const String& s) 
    {}

我們自定義的賦值函數如下:

void operator=(const String& s)
   {
      if(_str != s._str)
      {
        strcpy(_str,s._str);
      }
      return *this;
   }


  但是這只是新手級別的寫法,考慮的問題太少。我們知道對於普通變量來講a=b返回的是左值a的引用,所以它可以作爲左值繼續接收其他值(a=b)=30,這樣來講我們操作符重載後返回的應該是類對象的引用(否則返回值將不能作爲左值來進行運算),如下:

String& operator=(const String& s)
    {
        if(_str != s._str)
        {
            strcpy(_str,s._str);
        }
        return *this;
    }

 

  而上面這種寫法其實也有問題,因爲在執行語句時,_str 已經被構造已經分 配了內存空間,但是如此進行指針賦值,_str 直接轉而指向另一片新new出來的內存空間,而丟棄了原來的內存,這樣便造成了內存泄露。應更改爲:

String& operator=(const String& s)
    {
        if(_str != s._str)  
        {
            delete[] _str;
            _str = new char[strlen(s._str) + 1];
            strcpy(_str,s._str);
        }
        return *this;
    }

同時,也考慮到了自己給自己賦值的情況。


  可是這樣寫就完善了嗎?是否要再仔細思索一下,還存在問題嗎?!其實我可以告訴你,這樣的寫法也頂多算個初級工程師的寫法。前面說過,爲了保證內存不泄露,我們前面 delete[]  _str,然後我們在把new出來的空間給了_str,但是這樣的問題是,你有考慮過萬一 new 失敗了呢?!內存分配失敗,m_psz沒有指向新的內存空間,但是它卻已經把舊的空間給扔掉了,所以顯然這樣的寫法依舊存在着問題。一般高級工程師的寫法會是這樣的:

String& operator=(const String& s)
    {
        if(_str != s._str)
        {
            char *tmp = new char[strlen(s._str) + 1];
            strcpy(tmp , s._str);
            delete[] _str;
            _str = tmp;
        }
        return *this;
    }

這樣寫就比較全面了。


但是!!!還有元老級別的大師寫的更加簡便的拷貝構造和賦值函數,我們一睹爲快:

<元老級拷貝構造函數>

    String(const String& s)
        :_str(NULL)
    {
        String tmp = s._str;
        swap(_str , tmp._str);
    }

<元老級賦值函數>

// 1.
   String& operator=(const String& s)
   {
     if(_str != s._str)
     {
       String tmp = s._str;
       swap(_str , tmp._str);
     }
     return *this;
   }
// 2.
    String& operator=(String& s)//在此會拷貝構造一個臨時的對象s
   {
     if(_str != s._str)
     {
       swap(_str ,s._str);// 交換this->_str和臨時生成的對象數據成員s._str,離開作用域會自動析構釋放
     }
     return *this;
   }

看出端倪了麼?


  事實上,這是藉助了以上自定義的拷貝構造函數。定義了局部對象 tmp,在拷貝構造中已經爲 tmp 的成員指針分配了一塊內存,所以只需要將 tmp._str 與this->_str交換指針即可,簡化了程序的設計,因爲 tmp 是局部對象,離開作用域會調用析構函數釋放交換給 tmp._str 的內存,避免了內存泄露。

這是非常值得我們學習和借鑑的。


這是本人對C++深淺拷貝的理解,若有紕漏,歡迎留言指正 ^_^


附註整體代碼:

#include <iostream>
using namespace std;

class String
{
public:

    String(char *str = "")
        :_str(new char[strlen(str) + 1])
    {
        strcpy(_str , str);
    }
    
    // 淺拷貝
    String(const String& s)
        :_str(s._str)
    {}


    //賦值運算符重載
    //有問題,會造成內存泄露。。。
    String& operator=(const String& s)
    {
        if(_str != s._str)
        {
            strcpy(_str,s._str);
        }
        return *this;
    }

    // 深拷貝  <傳統寫法>
    String(const String& s)
        :_str(new char[strlen(s._str) + 1])
    {
        strcpy(_str , s._str);
    }
    
    //賦值運算符重載
    //一.  這種寫法有問題,萬一new失敗了。。
    String& operator=(const String& s)
    {
        if(_str != s._str)
        {
            delete[] _str;
            _str = new char[strlen(s._str) + 1];
            strcpy(_str,s._str);
        }
        return *this;
    }

    //二.  對上面的方法改進,先new後delete,如果new失敗也不會影響到_str原來的內容
    String& operator=(const String& s)
    {
        if(_str != s._str)
        {
            char *tmp = new char[strlen(s._str) + 1];
            strcpy(tmp , s._str);
            delete[] _str;
            _str = tmp;
        }
    }

    // 深拷貝  <現代寫法>
    String(const String& s)
        :_str(NULL)
    {
        String tmp = s._str;
        swap(_str , tmp._str);
    }
    
    //賦值運算符的現代寫法一:
    String& operator=(const String& s)
    {
        if(_str != s._str)
        {
            String tmp = s._str;
            swap(_str , tmp._str);
        }
        return *this;
    }
    //賦值運算符的現代寫法二:
    String& operator=(String& s) //在此會拷貝構造一個臨時的對象s
    {
        if(_str != s._str)
        {
            swap(_str ,s._str);//交換this->_str和臨時生成的對象數據成員s._str,離開作用域會自動析構釋放
        }
        return *this;
    }

    ~String()
    {
        if(_str)
        {
            delete[] _str;
            _str = NULL;
        }
        cout<<"~String()"<<endl;
    }

    void Display()
    {
        cout<<_str<<endl;
    }

private:
    char *_str;
};

void Test()
{
    String s1;
    String s2("hello");
    String s3(s2);
    String s4 = s3;

    s1.Display();
    s2.Display();
    s3.Display();
    s4.Display();

}

int main()
{
    Test();

    return 0;
}


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