淺析C++中的深淺拷貝
淺拷貝:又稱值拷貝,將源物件 的值拷貝到目標拷貝物件中去,本質上來說源物件和目標拷貝物件共用一份實體,只是所引用的變數名不同,地址其實還是相同的。舉個簡單的例子:你的小名叫西西,大名叫沫沫,當別人叫你西西或者沫沫的時候你都會答應,這兩個名字雖然不相同,但是都指的是你。
假設有一個String類,String s1;String s2(s1);在進行拷貝構造的時候將物件s1裡的值全部拷貝到物件s2裡。
我現在來簡單的實現一下這個類:
#include<iostream> #include<cstring> #pragma warning(disable:4996) using namespace std; class STRING{ public: STRING(char* s="") :_str(new char[strlen(s)+1]) { strcpy(_str,s); } STRING(const STRING& s) { _str=s._str;//兩個指標指向了同一塊記憶體區域 } STRING& operator=(const STRING& s) { if(this!=&s) { this->_str=s._str; } return *this; } ~STRING() { if(_str) { delete[] _str; _str=NULL; } } void show() { cout<<_str<<endl; } private: char* _str; }; void test() { STRING s1("hello linux!"); STRING s2(s1); s2.show(); } int main() { test(); system("pause"); return 0; }
其實這個程式是存在問題的,什麼問題呢?我們想一下,建立s2的時候程式必然會去呼叫拷貝建構函式,這時候拷貝構造僅僅只是完成了值拷貝,導致兩個指標指向了同一塊記憶體區域。隨著程式的執行結束,又去呼叫解構函式,先是s2去呼叫解構函式,釋放了它所指向的記憶體區域,接著s1又去調解構函式,這時候解構函式企圖釋放一塊已經被釋放的記憶體區域,程式將會崩潰。s1和s2的關係是這樣的:
為了驗證s1和s2確實指向了同一塊記憶體區域,我進行了除錯,如圖所示:
所以程式會崩潰是應該的。那這個問題應該怎麼去解決呢?這就引出了深拷貝。
深拷貝,拷貝時先開闢出和源物件大小一樣的空間,然後將源物件裡的內容拷貝到目標拷貝物件中去,這樣兩個指標就指向了不同的記憶體位置,並且裡面的內容還是一樣的,這樣不但達到了我們想要的目的,還不會出現問題,兩個指標先後去呼叫解構函式,分別釋放自己所指向的位置。即為每次增加一個指標,便申請一塊新的記憶體,並讓這個指標指向新的記憶體,深拷貝情況下,不會出現重複釋放同一塊記憶體的錯誤。
深拷貝實際上是這樣的:
下面為深拷貝的拷貝建構函式和賦值運算子的過載傳統實現:
STRING(const STRING& s) { _str=new char[strlen(s._str)+1]; strcpy(_str,s._str); } STRING& operator=(const STRING& s)//賦值運算子的過載 { if(this!=&s)//不允許自己給自己賦值 { delete[] _str; this->_str=new char[strlen(s._str)+1]; strcpy(this->_str,s._str); } return *this; }
這裡的拷貝建構函式我們很容易理解,先開闢出和源物件一樣大的記憶體區域,然後將需要拷貝的資料複製給目標拷貝物件。
那這裡的賦值運算子的過載是怎樣做的呢?
這種方法解決了我們的指標懸掛問題,通過不斷的開空間讓不同的指標指向不同的記憶體,以防止同一塊記憶體被釋放兩次的問題 ,還有一種深拷貝的現代寫法:
STRING(const STRING& s)
:_str(NULL)//初始化為NULL,否則釋放空指標會出錯
{
STRING tmp(s._str);//呼叫了建構函式,完成了空間的開闢以及值的拷貝
swap(this->_str,tmp._str);//交換tmp和目標拷貝物件所指向的內容
}
STRING& operator=(const STRING& s)
{
if(this!=&s)//不讓自己給自己賦值
{
STRING tmp(s._str);//呼叫建構函式完成空間的開闢以及賦值工作
swap(this->_str,tmp._str);//交換tmp和目標拷貝物件所指向的內容
}
return *this;
}
先來分析下拷貝構造是怎麼實現的:
拷貝構造呼叫完成之後,會接著去呼叫解構函式來銷燬區域性物件tmp.按照這種思路,不難可以想到s2的值一定和拷貝構造裡的tmp的值一樣,指向同一塊記憶體區域。通過除錯來證明一下:
在拷貝建構函式裡的tmp:
呼叫完拷貝構造後的s2:(此時tmp被析構)
賦值運算子的過載實現過程與上述拷貝構造完全相同。
關於賦值運算子的過載還可以這樣來寫:
STRING& operator=(STRING s)
{
swap(_str,s._str);
return *this;
}
當實現如下呼叫時:
void test()
{
char* str="hello linux!";
STRING s1(str);
STRING s2;
s2=s1;//先呼叫拷貝構造,再呼叫了賦值運算子的過載
s1.show();
s2.show();
}
先建立s2物件(呼叫建構函式),s1=s2;(呼叫拷貝構造,再呼叫賦值運算子的過載)。為什麼又去呼叫了拷貝構造呢?為什麼???很簡單,因為在這個賦值運算子的過載的引數裡建立了一個臨時物件,為什麼說它是一個臨時物件,因為出了賦值運算子過載的作用域它就被析構掉了。這種方法的過程就是通過傳參建立一個臨時物件,通過呼叫拷貝構造來完成空間的開闢並且拷貝源物件裡的資料。然後再交換目標物件和臨時物件的值,賦值運算子過載函式被呼叫完之後,臨時物件被析構。總結一下:這種現代方式本質上來說就是我自己不想開空間,讓臨時物件去開空間並完成拷貝,最後只要讓我指向臨時物件所指向的內容,再讓臨時物件指向我原先的所在的區域。最後臨時物件被析構掉。