1. 程式人生 > >淺析C++中的深淺拷貝

淺析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;(呼叫拷貝構造,再呼叫賦值運算子的過載)。為什麼又去呼叫了拷貝構造呢?為什麼???很簡單,因為在這個賦值運算子的過載的引數裡建立了一個臨時物件,為什麼說它是一個臨時物件,因為出了賦值運算子過載的作用域它就被析構掉了。這種方法的過程就是通過傳參建立一個臨時物件,通過呼叫拷貝構造來完成空間的開闢並且拷貝源物件裡的資料。然後再交換目標物件和臨時物件的值,賦值運算子過載函式被呼叫完之後,臨時物件被析構。
總結一下:這種現代方式本質上來說就是我自己不想開空間,讓臨時物件去開空間並完成拷貝,最後只要讓我指向臨時物件所指向的內容,再讓臨時物件指向我原先的所在的區域。最後臨時物件被析構掉。