1. 程式人生 > >淺談智慧指標

淺談智慧指標

一 什麼是智慧指標?

智慧指標是一個類,用於封裝一個普通指標的類,並且在這個類的建構函式中將這個普通指標初始化,並且在解構函式中對這個普通指標進行釋放。而這個智慧指標之所以這樣做,是為了解決我們在以普通指標malloc或new申請空間之後,由於這裡申請的空間需要手動釋放,否則會造成記憶體洩漏,但是雖說大家在使用malloc或new申請空間之後,大多數人會牢記這點,但是這個問題還是防不勝防的,而且除了忘記釋放的問題以外,很有可能會對一塊空間釋放多次,這也是需要提防的,所以C++中就引入了智慧指標。如果將new 返回的地址賦給這些物件,在智慧指標過期時,這些記憶體將自動釋放

當你在建立一個類物件並以一個指向malloc或new出來的空間的普通指標進行初始化的時候,這個類物件就在棧上被開闢出來了,而且大家都知道棧上的空間是會被自動釋放的,也就是說最終這個物件會被銷燬,這樣的話必然會呼叫解構函式,所以理所當然的就會將之前申請的堆空間給釋放掉了。

常用的智慧指標以及它們大致是如何實現的?(當然以下都是模擬實現)

C++標準庫中從boost庫當中引入了auto_ptr,unique_ptr(scoped_ptr),shared_ptr以及weak_ptr,這也是我們常用的幾個智慧指標。而模擬實現這些智慧指標主要面對的問題是如何讓它們在進行拷貝構造或者賦值的時候,防止淺拷貝,讓一塊空間被多個物件中的指標所指向,從而導致最後被析構多次

 

二 Java中的垃圾回收機制

        先談談Java中的垃圾回收機制。Java中的垃圾回收機制的目的在於回收釋放不再被引用的例項物件佔用的記憶體空間。垃圾回收通過確定記憶體單元是否還被物件引用來確定是否收集該記憶體區。垃圾回收首先要判斷該物件是否是時候可以收集。兩種常用的方法有2種,分別是引用計數器和物件引用遍歷。

       引用計數器的原理是設定一整形變數來記錄當前物件是否被引用以及引用的次數。當一個物件被建立時,將該物件對應計數器設定初值為1。當有新引用指向物件時,計數器+1;當其中某些引用超過生命期時,計數器-1.當計數器變為0時就可以當做垃圾收集了。這種方法實現簡單,效率相對較高;但出現交叉引用時難以處理。下面是原文舉例說明“父物件有一個對子物件的引用,子物件反過來引用父物件。這樣,他們的引用計數永遠不可能為0”。

       較早版本的JVM使用引用計數器,現在大多數JVM採用物件引用遍歷。物件引用遍歷從一組物件出發,沿著整個物件關係圖上的每條連結線,遞迴確定當前可達的物件。如果某物件不能從這些根物件中的任何一個到達,則將它作為垃圾收集。等待它的就是刪除,釋放儲存空間了。

1.auto_ptr

auto_ptr是最早被引入標準庫的,也是被重點表明無論何種情況下都不要使用auto_ptr的,其中的原因就是在auto_ptr雖然簡單,但是這裡面存在嚴重的問題。

雖然這裡我們說明了無論何種情況都不要使用auto_ptr,但是我們這裡還是模擬實現一下,也好明白為什麼要禁用auto_ptr。

關於auto_ptr差不多經歷了三個版本,曾經多次想讓auto_ptr變的不那麼坑人,畢竟被引入了標準庫啊,雖然說結果都失敗了。

首先是第一個版本:

class AutoPtr
{
public:
	AutoPtr(T* ptr=NULL)   //預設建構函式,將類成員中_ptr置為空
		:_ptr(ptr)
	{}
 
	AutoPtr(AutoPtr& ap)   //拷貝建構函式
	{
		_ptr=ap._ptr;
		ap._ptr=NULL;
	}
 
	AutoPtr& operator=(AutoPtr& ap)    //賦值運算子過載
	{
		if(this!=&ap)//如果寄宿物件和宿主物件不是同一物件時,同一個物件沒意義
		{
			_ptr=ap._ptr;
			ap._ptr=NULL;
		}
 
		return *this;
	}
 
	T* operator->()    //過載->
	{
		return _ptr;
	}
 
	T& operator*()  //過載*
	{
		return *_ptr;
	}
 
	~AutoPtr()     //解構函式
	{
		if(_ptr)
		{
			delete _ptr;
			_ptr=NULL;
		}
	}
 
private:
	T* _ptr;
};
int main()
{
	AutoPtr<string> p1 (new string ("auto"));
	AutoPtr<string> p2;
	p2 = p1;
	/*p2接管了p1物件所有權之後,P1置為空,將不能再用p1操作這塊空間*/
}

分析第一個版本,這裡在解決拷貝建構函式以及賦值運算子過載的時候,通過進行資源轉移,也就是在進行拷貝構造或賦值之後,將用於賦值或拷貝的物件中的指標置空。

 

這裡主要存在兩個大問題:首先,由於我們要進行拷貝構造或是賦值的時候需要對傳入物件的指標進行置空修改,所以這裡形參就不能為const,也就是說我們無法拷貝構造const物件,或用const物件進行賦值;其次,就是在進行了拷貝構造或賦值之後,再就不能對原物件進行操作了(其中的指標已經為空了),也就是說我們無法讓多個智慧指標指標物件管理一塊空間

第二個版本:

template<class T>
class AutoPtr
{
public:
	AutoPtr(T* ptr=NULL)
		:_ptr(ptr)
		,owner(true)
	{}
 
	AutoPtr(const AutoPtr& ap)
	{
		_ptr=ap._ptr;
		owner=true;
		ap.owner=false;
	}
 
	AutoPtr& operator=(const AutoPtr& ap)
	{
		if(this!=&ap)
		{
			_ptr=ap._ptr;
			ap.owner=false;
		}
 
		return *this;
	}
 
	T* operator->()
	{
		return _ptr;
	}
 
	T& operator*()
	{
		return *_ptr;
	}
 
	~AutoPtr()
	{
		if(_ptr&&owner)
		{
			delete _ptr;
			_ptr=NULL;
		}
	}
 
private:
	T* _ptr;
	mutable bool owner; //用mutable修飾的成員變數不受const成員方法的限制。
};
int main()
{
	AutoPtr<string> p1 (new string ("auto"));
	AutoPtr<string> p2;
	p2 = p1;
	
}

第二個版本是為了解決第一個版本所面對的問題,其一,通過對這個物件再封裝一個bool型別的變數,讓其表示當前物件的管理許可權,當具有管理許可權的時候為true,並且在這個物件進行析構時,對對應的空間進行釋放,反之則無法釋放這塊空間;其二以mutable來定義這個bool變數,使得能夠在拷貝建構函式與賦值運算子過載的形參為const的引用的時候,能在函式內對這個bool變數進行修改(即轉移管理許可權)。

 

這樣一來,表面上貌似是解決了之前所面臨的兩個問題,但是實際上呢?看下面這個測試用例:


void funtest()
{
	int* p=new int(0);
	AutoPtr<int> ap(p);
	if(true)
	{
		AutoPtr<int> ap1(ap);//ap1出了這個區域性範圍就操作空間進行釋放
	}
 
	cout<<*ap<<endl;//執行了那塊釋放的空間
 
}

這裡存在一個很嚴重的問題,在區域性範圍內進行拷貝構造或是賦值的話,出了這個作用域,空間就會被釋放,那麼在這個區域性作用域之外,再對物件進行操作的話,很容易造成崩潰,就算不崩潰,那原空間已經被釋放了,再對其進行訪問也是毫無意義。

 

所以,這樣看來這個版本還不如上面的第一個版本,最起碼不會造成這麼隱晦的崩潰。

第三個版本:

template<class T>//輔助類指標
class AutoPtrRef
{
	explicit AutoPtrRef(T* ptr)//防止進行隱式轉換
		:_ptr(ptr)
	{}
 
	T* _ptr;
};
 
template<class T>
class AutoPtr
{
public:
	AutoPtr(T* ptr=NULL)
		:_ptr(ptr)
	{}
 
	AutoPtr(AutoPtr& ap)
	{
		_ptr=ap._ptr;
		ap._ptr=NULL;
	}
 
	AutoPtr(AutoPtrRef<T>& ap)
	{
		_ptr=ap._ptr;
		ap._ptr=NULL;
	}
 
	AutoPtr& operator=(AutoPtr& ap)
	{
		if(this!=&ap)
		{
			_ptr=ap._ptr;
			ap._ptr=NULL;
		}
 
		return *this;
	}
 
	AutoPtr& operator=( AutoPtrRef<T>& ap)
	{
		if(this!=&ap)
		{
			_ptr=ap._ptr;
			ap._ptr=NULL;
		}
 
		return *this;
	}
 
	operator AutoPtr<T>()const //如果傳過來的是const物件,則返回當前物件
	{
		return *this;
	}
 
	operator AutoPtrRef<T>()const
	{
		AutoPtrRef<T> tmp(_ptr);
		_ptr=NULL;
		return tmp;
	}
 
	T* operator->()
	{
		return _ptr;
	}
 
	T& operator*()
	{
		return *_ptr;
	}
 
	~AutoPtr()
	{
		if(_ptr)
		{
			delete _ptr;
			_ptr=NULL;
		}
	}
 
private:
	T* _ptr;
};
int main()
{
	AutoPtr<string> p1 (new string ("auto"));
	AutoPtr<string> p2;
	p2 = p1;
	const AutoPtr<string> p1 (new string ("auto"));
	const AutoPtr<string> p2;
	p2 = p1;//突破const限制
}

第三個版本是在第一個版本的基礎上進行改進,不可避免的是原物件的指標還是被置空了,無法解決讓多個物件管理同一塊空間的問題;但是這裡它解決了對const物件進行拷貝構造或賦值,通過利用型別轉換運算子。
綜上,auto_ptr的三個版本各有各的缺陷,而且都是挺關鍵的缺陷,所以慎用auto_ptr,或者乾脆就別用了。

 

2.scoped_ptr(unique_ptr)

上面討論了auto_ptr的各種缺陷,歸結到底都是由於拷貝建構函式以及賦值運算子過載造成的,這裡scoped_ptr(unique_ptr)乾脆就禁止拷貝構造和賦值了,也就是通過防拷貝來解決:

template<class T>
class ScopedPtr
{
public:
	ScopedPtr(T* ptr=NULL)
		:_ptr(ptr)
	{}
 
	T* operator->()
	{
		return _ptr;
	}
 
	T& operator*()
	{
		return *_ptr;
	}
 
 
	~ScopedPtr()
	{
		if(_ptr)
		{
			delete _ptr;
			_ptr=NULL;
		}
	}
 
private:
	ScopedPtr(ScopedPtr& sp);
 
	ScopedPtr& operator=(ScopedPtr& sp);//將拷貝構造與賦值運算子過載兩個函式給成私有的,但都給出定義;禁止拷貝和賦值
 
 
private:
	T* _ptr;
}
int main()
{
	AutoPtr<string> p1 (new string ("auto"));
	AutoPtr<string> p2;
	p2 = p1;
	// p2(p1)編譯器將要報錯
}

這裡要說明一點scoped_ptr(unique_ptr)是針對單個型別空間,對於多個型別空間的管理,boost庫當中是用scoped_array來管理的(無非就是在釋放空間是將delete變成delete[ ]),但是在我們標準庫當中卻並沒有引入scoped_array,原因在於我們C++標準庫當中管理一段連續空間用什麼?std::vector容器,更何況scoped_array當中還沒有std::vector所具有的功能強大。

3.shared_ptr與weak_ptr

scoped_ptr所說可以解決部分智慧指標所要解決的問題,但是對於讓多個物件管理同一塊空間的問題,scoped_ptr還是無能為力。這裡就引入了shared_ptr:

template<class T>
class Dele
{
public:
	void operator()(T* ptr)//過載()運算子
	{
		delete ptr;
		ptr=NULL;
	}
};
 
template<class T>
class Free
{
public:
	void operator()(T* ptr)
	{
		free(ptr);
		ptr=NULL;
	}
};
 
 
template<class T>
class File
{
public:
	void operator()(T* ptr)
	{
		fclose(ptr);
	}
}
template<class T,class Des=Dele<T>>
class SharedPtr
{
public:
	SharedPtr(T* ptr=NULL)
		:_ptr(ptr)
		,count(0)
	{
		if(_ptr)
		{
			count=new int(1);  //開始引用的時候計數置為1
		}
	}
 
	SharedPtr(const SharedPtr& sp)
	{
		_ptr=sp._ptr;
		count=sp.count;
		(*count)++;
	}
 
	SharedPtr& operator=(const SharedPtr& sp)
	{
		if(this!=&sp)
		{
			if(_ptr&&!(--*count))//保護指標為空
			{
				Destory();
			}
			_ptr=sp._ptr;
			count=sp.count;
			(*count)++;
		}
		return *this;
	}
 
	~SharedPtr()
	{
		if(_ptr&&!(--(*count)))
		{
			Destory();
		}
	}
	
private:
	void Destory()
	{
		Des()(_ptr);
		delete count;
	}
 
private:
	T* _ptr;
	int* count;
};
int main()
{
	AutoPtr<string> p1 (new string ("auto"));
	AutoPtr<string> p2;
	p2 = p1;
	
}

這裡對於shared_ptr是利用了之前string類用過的引用計數,除了封裝一個原生指標外還封裝了一個int*的指標,當然標準庫當中肯定沒這麼簡單,上面只是簡單的進行了模擬大致的方法,僅對於上述的模擬而言,shared_ptr增加了一個引用計數空間用於儲存當前管理這塊原生指標指向的空間的物件有多少,而根據這個引用計數來決定在析構某一物件的時候要不要釋放空間;而除此之外這裡還解決了一個問題,就是對於不同的指標而言,最後進行的處理是不同的,例如檔案指標需要進行關閉檔案,malloc出來的空間需要的是進行free而不是delete等,上述模擬根據這一問題對於不同的指標設計了對應的刪除器來進行解決。

但是這裡還有一個嚴重的問題,就是關於迴圈引用的問題。

 

對於什麼是迴圈引用?我們用下面這個測試用例來解釋:

#include <iostream>
using namespace std;
 
#include <memory>
#include "SharedPtr.h"
 
struct Node
{
	Node(int va)
		:value(va)
		
	{
		cout<<"Node()"<<endl;
	}
 
 
	~Node()
	{
		cout<<"~Node()"<<endl;
	}
	shared_ptr<Node> _pre;
	shared_ptr<Node> _next;
	int value;
};
 
void funtest()
{
	shared_ptr<Node> sp1(new Node(1));
	shared_ptr<Node> sp2(new Node(2));
 
	sp1->_next=sp2;
	sp2->_pre=sp1;
}
int main()
{
	funtest();
	return 0;
}

這裡模擬了雙向連結串列中的兩個節點的情況,對於節點中的_pre和_next都用shared_ptr來進行管理,而節點本身也由shared_ptr進行管理,這樣的話當funtest()執行結束後,我們會發現:

 



很明顯,並沒有呼叫~Node(),也就是說節點在這裡並沒有被釋放,這裡原因在於有兩個shared_ptr的物件管理同一個節點,而其中的一個物件就是另一個節點當中的_pre或是_next,當你對sp2進行釋放的時候,由於引用計數為2,所以這裡並不能把對應節點給釋放掉,而是要等待對方也就是sp1的_next釋放時才能將sp2的節點釋放掉,反過來對於sp1與sp2同理,也要等待sp2中的_pre釋放,所以這裡兩者都未能釋放。

因此,在這裡標準庫就引用了weak_ptr,將上面的_pre和_next的型別換成weak_ptr,由於weak_ptr並不會增加引用計數use的值,所以這裡就能夠打破shared_ptr所造成的迴圈引用問題。但是這裡要注意一點,就是weak_ptr並不能單獨用來管理空間。

 

警告:使用new 分配記憶體時,使用auto_ptr和shared_ptr,使用new或者new【】 分配時,使用unique_ptr;

std::unique_ptr<double[]>pda (new double(5));