智慧指標

alone_qing發表於2024-06-14

1.原因

智慧指標的出現主要是用來解決在實際的開發過程中程式設計師使用裸指標而導致的一系列問題。當然裸指標本身是安全的,只是會由於開發者的不規範使用而導致出現各類問題:

  • 申請的資源在程式執行結束後忘記釋放了。
  • 對申請了的資源做了重複的釋放
  • 由於程式的程式碼邏輯使得程式在中途就直接return退出了,導致沒有執行後面的釋放操作。
  • 程式執行過程中產生了異常,由於異常棧的展開導致資源釋放的程式碼沒有執行。

智慧指標的出現就是為了解決上述的這些問題,利用棧上的物件出作用域自動呼叫解構函式來釋放申請的資源。c++11庫裡面主要提供了兩類智慧指標,帶引用計數的智慧指標不帶引用計數的智慧指標

template<typename T>
class CSmartPtr
{
public:
    // 類構造來管理申請的資源
	CSmartPtr(T *ptr = nullptr) :mptr(ptr) {}
    // 自動呼叫解構函式來釋放資源
	~CSmartPtr() { delete mptr; }
private:
	T *mptr;
};

int main()
{
	CSmartPtr<int> ptr(new int);
	/*程式碼段*/
    // 出了作用域自動析構,及時在中途退出也沒有問題
	return 0;
}

智慧指標就是把裸指標做了一次物件導向的封裝,在建構函式中初始化資源的地址,在解構函式中釋放地址對應的資源。智慧指標利用的就是棧上的物件出作用域自動呼叫析構的特點,不能將智慧指標定義在堆空間中,因為本質上還是成了一個裸指標。

同時智慧指標還需要提供*和->運算子的過載,這樣就可以像使用裸指標一樣使用智慧指標。

template<typename T>
class CSmartPtr
{
public:
	CSmartPtr(T *ptr = nullptr) :mptr(ptr) {}
	~CSmartPtr() { delete mptr; }
    
    // 運算子的過載
    // 非const可以透過返回值修改成員變數,也可以直接修改返回值
	T& operator*() { return *mptr; }
    T* operator->() { return mptr; }
    // const即不可以修改返回值,也不可以透過返回值修改成員變數。
	const T& operator*()const { return *mptr; }
	const T* operator->()const { return mptr; }
private:
	T *mptr;
};
int main()
{
	CSmartPtr<int> ptr(new int);
	*ptr = 20;
	cout << *ptr << endl;
	return 0;
}

但是這樣的指標指標還存在一個問題:

int main()
{
	CSmartPtr<int> ptr1(new int);
	CSmartPtr<int> ptr2(ptr1);
	return 0;
}

當我們用一個智慧指標物件去初始化另一個智慧智慧物件時,由於做的是淺複製,只是對指向的指標做了一次賦值,但是指標指向的記憶體資源缺並沒有做複製,這就導致了兩個智慧指標都指向同一塊資源,這樣在析構的時候就會對同一資源析構兩次,導致程式報錯。

因此智慧指標主要解決一下兩個問題:

  • 智慧指標淺複製的問題。
  • 多個智慧指標指向同一資源的時候,怎麼保證資源只做唯一一次的釋放,而不是多次釋放。

注:被delete後的指標p的值(地址值)並非就是NULL,而是隨機值。也就是被delete後,如果不再加上一句p=NULL,p就成了“野指標”,在記憶體裡亂指一通。同時在delete之前會自動檢查p是否為空(NULL),如果為空(NULL)就不再delete了

2.不帶引用計數的智慧指標

2.1 auto_ptr

auto_ptr的原始碼如下:

template<class _Ty>
	class auto_ptr
	{	// wrap an object pointer to ensure destruction
public:
	typedef _Ty element_type;
    
    // 建構函式初始化資源的地址
	explicit auto_ptr(_Ty * _Ptr = nullptr) noexcept
		: _Myptr(_Ptr)
		{	// construct from object pointer
		}

	/*這裡是auto_ptr的複製建構函式,
	_Right.release()函式中,把_Right的_Myptr
	賦為nullptr,也就是換成當前auto_ptr持有資源地址
	*/

    // 複製建構函式,這裡直接是呼叫的一個內部的release函式
	auto_ptr(auto_ptr& _Right) noexcept
		: _Myptr(_Right.release())
		{	// construct by assuming pointer from _Right auto_ptr
		}
	// 從release函式中可以看出,這是做的就是把原來的指標指向置為空,然後用當前的智慧指標物件進行管理
	_Ty * release() noexcept
		{	// return wrapped pointer and give up ownership
		_Ty * _Tmp = _Myptr;
		_Myptr = nullptr;
		return (_Tmp);
		}
private:
	_Ty * _Myptr;	// the wrapped object pointer
};

從上述原始碼中可以看出,auto_ptr的淺複製實現就是把原來的智慧指標置空,用當前的智慧指標來管理,也就是只有最後一個auto_ptr智慧指標持有記憶體空間資源,之前的都被置為nullptr了

注:auto_ptr不能用在容器中,當做容器的複製的時候,會導致原來容器中的元素全部都為nullptr了。

2.2 scoped_ptr

scoped_ptr的原始碼如下:

template<class T> class scoped_ptr // noncopyable
{
private:
    T * px;
    // 這裡直接私有化了複製建構函式和賦值建構函式,因為物件不能訪問類的私有成員函式,也就直接杜絕了智慧指標淺複製的發生。
    scoped_ptr(scoped_ptr const &);
    scoped_ptr & operator=(scoped_ptr const &);
    typedef scoped_ptr<T> this_type;
    // 同時私有化了比較運算子過載函式,使得scoped_ptr也不支援智慧智慧指標的比較操作
    void operator==( scoped_ptr const& ) const;
    void operator!=( scoped_ptr const& ) const;
public:
    typedef T element_type;
    explicit scoped_ptr( T * p = 0 ): px( p ) // never throws
    {
#if defined(BOOST_SP_ENABLE_DEBUG_HOOKS)
        boost::sp_scalar_constructor_hook( px );
#endif
    }
#ifndef BOOST_NO_AUTO_PTR
	/*支援從auto_ptr構造一個scoped_ptr智慧指標物件,
	但是auto_ptr因為呼叫release()函式,導致其內部指
	針為nullptr*/
    explicit scoped_ptr( std::auto_ptr<T> p ) BOOST_NOEXCEPT : px( p.release() )
    {
#if defined(BOOST_SP_ENABLE_DEBUG_HOOKS)
        boost::sp_scalar_constructor_hook( px );
#endif
    }
#endif
	// 解構函式,釋放智慧指標持有的資源
    ~scoped_ptr() // never throws
    {
#if defined(BOOST_SP_ENABLE_DEBUG_HOOKS)
        boost::sp_scalar_destructor_hook( px );
#endif
        boost::checked_delete( px );
    }
};

上述原始碼可以看到scoped_ptr私有化了複製建構函式和賦值建構函式,直接從根本上杜絕了淺複製的發生。即scoped_ptr一旦被初始化之後,其就永遠的管理了那塊記憶體資源,不會被剝奪,直到解構函式釋放該記憶體資源。

2.3 unique_ptr

unique_ptr的原始碼如下:

template<class _Ty,
	class _Dx>	// = default_delete<_Ty>
	class unique_ptr
		: public _Unique_ptr_base<_Ty, _Dx>
	{	// non-copyable pointer to an object
public:
	typedef _Unique_ptr_base<_Ty, _Dx> _Mybase;
	typedef typename _Mybase::pointer pointer;
	typedef _Ty element_type;
	typedef _Dx deleter_type;

    // 提供了帶右值引用的複製建構函式
    // noexcept關鍵字的含義是表明該函式不會發生異常,這樣有助於編譯器對其進行一些特殊的最佳化處理加速函式的執行
	unique_ptr(unique_ptr&& _Right) noexcept
		: _Mybase(_Right.release(),
			_STD forward<_Dx>(_Right.get_deleter()))
		{	// construct by moving _Right
		}
	
	// 提供了帶右值引用的operator=賦值過載函式
	unique_ptr& operator=(unique_ptr&& _Right) noexcept
		{	// assign by moving _Right
		if (this != _STD addressof(_Right))
			{	// different, do the move
			reset(_Right.release());
			this->get_deleter() = _STD forward<_Dx>(_Right.get_deleter());
			}
		return (*this);
		}
	
    // 交換兩個unique_ptr智慧指標物件的底層指標和刪除器
	void swap(unique_ptr& _Right) noexcept
		{	// swap elements
		_Swap_adl(this->_Myptr(), _Right._Myptr());
		_Swap_adl(this->get_deleter(), _Right.get_deleter());
		}

	// 透過自定義刪除器釋放資源
	~unique_ptr() noexcept
		{	// destroy the object
		if (get() != pointer())
			{
			this->get_deleter()(get());
			}
		}
	
	/*unique_ptr提供->運算子的過載函式*/
	_NODISCARD pointer operator->() const noexcept
		{	// return pointer to class object
		return (this->_Myptr());
		}

	// 返回智慧指標物件底層管理的指標
	_NODISCARD pointer get() const noexcept
		{	// return pointer to object
		return (this->_Myptr());
		}

	/*提供bool型別的過載,使unique_ptr物件可以
	直接使用在邏輯語句當中,比如if,for,while等*/
	explicit operator bool() const noexcept
		{	// test for non-null pointer
		return (get() != pointer());
		}
    
    /*功能和auto_ptr的release函式功能相同,最終也是隻有一個unique_ptr指標指向資源*/
	pointer release() noexcept
		{	// yield ownership of pointer
		pointer _Ans = get();
		this->_Myptr() = pointer();
		return (_Ans);
		}

	/*把unique_ptr原來的舊資源釋放,重置新的資源_Ptr*/
	void reset(pointer _Ptr = pointer()) noexcept
		{	// establish new pointer
		pointer _Old = get();
		this->_Myptr() = _Ptr;
		if (_Old != pointer())
			{
			this->get_deleter()(_Old);
			}
		}
	/*
	刪除了unique_ptr的複製構造和operator=賦值函式,
	因此不能做unique_ptr智慧指標物件的複製構造和
	賦值,防止淺複製的發生
	*/
	unique_ptr(const unique_ptr&) = delete;
	unique_ptr& operator=(const unique_ptr&) = delete;
	};

從原始碼可以看出,unique_ptr直接刪除了複製建構函式和賦值建構函式,這樣就可以避免淺複製的產生。同時提供了帶右值引用的複製建構函式和賦值建構函式,即unique_ptr可以透過右值引用進行複製和賦值,或者是一些產生臨時物件的地方。

// 程式碼示例1
unique_ptr<int> ptr(new int);
unique_ptr<int> ptr2 = std::move(ptr); // 使用了右值引用的複製構造
ptr2 = std::move(ptr); // 使用了右值引用的operator=賦值過載函式

// 程式碼示例2
// 返回一個臨時物件
unique_ptr<int> test_uniqueptr()
{
	unique_ptr<int> ptr1(new int);
	return ptr1;
}
int main()
{
    // 呼叫右值引用的複製建構函式來接收一個臨時物件。
	unique_ptr<int> ptr = test_uniqueptr();
	return 0;
}

同樣,unique_ptr也是隻能有一個該智慧指標引用資源,同時unique_ptr還提供了reset、swap等資源交換函式。

3.帶引用計數的智慧指標

當多個智慧指標指向同一記憶體資源的時候,每一個智慧指標都會給該資源的引用計數加1,當一個智慧指標物件析構的時候,同樣會使智慧指標的引用計數減1,當引用計數減到0的時候就可以釋放該塊記憶體資源了。這裡要對智慧指標引用計數進行++和--操作的時候,為了保證一個執行緒的安全,在shared_ptr和weak_ptr底層的引用計數採用的是CAS原子操作,這樣使得引用計數是互斥訪問的,保證了一個執行緒安全的問題。

shared_ptr的引用計數是存在於堆上的,new出來一塊專門存放智慧指標引用計數的資源空間,同時使用_Rep來指向new的這篇空間。一般而言shared_ptr我們稱之為強智慧指標,而weak_ptr我們稱之為弱智慧指標。針對於強弱智慧指標使用的兩個特殊場景。

3.1 智慧指標的交叉引用問題

如下示例程式碼:

#include <iostream>
#include <memory>
using namespace std;

class B; // 前置宣告類B
class A
{
public:
	A() { cout << "A()" << endl; }
	~A() { cout << "~A()" << endl; }
	shared_ptr<B> _ptrb; // 指向B物件的智慧指標

    // 修改方式
    weak_ptr<B> _ptrb; // 指向B物件的弱智慧指標。引用物件時,用弱智慧指標
};
class B
{
public:
	B() { cout << "B()" << endl; }
	~B() { cout << "~B()" << endl; }
	shared_ptr<A> _ptra; // 指向A物件的智慧指標
    
    // 修改方式
    weak_ptr<A> _ptra; // 指向A物件的弱智慧指標。引用物件時,用弱智慧指標
};
int main()
{
	shared_ptr<A> ptra(new A());// ptra指向A物件,A的引用計數為1
	shared_ptr<B> ptrb(new B());// ptrb指向B物件,B的引用計數為1
	ptra->_ptrb = ptrb;// A物件的成員變數_ptrb也指向B物件,B的引用計數為2
	ptrb->_ptra = ptra;// B物件的成員變數_ptra也指向A物件,A的引用計數為2

	cout << ptra.use_count() << endl; // 列印A的引用計數結果:2
	cout << ptrb.use_count() << endl; // 列印B的引用計數結果:2

	/*
	出main函式作用域,ptra和ptrb兩個區域性物件析構,分別給A物件和
	B物件的引用計數從2減到1,達不到釋放A和B的條件(釋放的條件是
	A和B的引用計數為0),因此造成兩個new出來的A和B物件無法釋放,
	導致記憶體洩露,這個問題就是“強智慧指標的交叉引用(迴圈引用)問題”
	*/
	return 0;
}

對於上述強智慧指標的交叉引用問題,可以看出由於引用計數無法減到1導致雙方管理的資源都無法得到釋放,這樣的解決方式就是:定義物件的時候,使用強智慧指標shared_ptr而在其他地方引用物件的時候使用弱智慧指標weak_ptr。

這裡針對於weak_ptr和shared_ptr的區別主要在於:

  • weak_ptr不會改變資源的引用計數,它是作為一個觀察者的角色用來判斷管理的資源是否存在。
  • weak_ptr持有的引用計數並不是資源的引用計數,而是對同一資源的觀察者的引用計數
  • weak_ptr不提供常用的一些指標的操作,也無法訪問觀察的資源,如果想操作weak_ptr只能透過lock方法把它提升為強智慧指標。

3.2 多執行緒訪問共享變數的問題:

思想如下一種場景:執行緒A和執行緒B訪問一個共享的物件,此時執行緒A正在析構這個物件,而執行緒B在呼叫該物件的成員方法,如果此時執行緒A析構完了,而執行緒B去呼叫成員方法就會發生錯誤。
示例程式碼如下:

#include <iostream>
#include <thread>
using namespace std;

class Test
{
public:
	// 構造Test物件,_ptr指向一塊int堆記憶體,初始值是20
	Test() :_ptr(new int(20)) 
	{
		cout << "Test()" << endl;
	}
	// 析構Test物件,釋放_ptr指向的堆記憶體
	~Test()
	{
		delete _ptr;
		_ptr = nullptr;
		cout << "~Test()" << endl;
	}
	// 線上程B中執行show成員函式
	void show()
	{
		cout << *_ptr << endl;
	}
private:
    // volatile關鍵字的作用就是防止編譯器對其做最佳化,使其每次讀取該值都需要從記憶體中重新進行讀取。
	int *volatile _ptr;
};
void threadProc(Test *p)
{
	// 睡眠兩秒,此時main主執行緒已經把Test物件給delete析構掉了
	std::this_thread::sleep_for(std::chrono::seconds(2));
	/* 
	此時當前執行緒訪問了main執行緒已經析構的共享物件,結果未知,隱含bug。
	此時透過p指標想訪問Test物件,需要判斷Test物件是否存活,如果Test物件
	存活,呼叫show方法沒有問題;如果Test物件已經析構,呼叫show有問題!
	*/
    // 此時呼叫的時候,物件p已經被析構掉了,這樣呼叫就會發生錯誤
	p->show();
}
int main()
{
	// 在堆上定義共享物件
	Test *p = new Test();
	// 使用C++11的執行緒類,開啟一個新執行緒,並傳入共享物件的地址p
	std::thread t1(threadProc, p);
	// 在main執行緒中析構Test共享物件
	delete p;
	// 等待子執行緒執行結束
	t1.join();
	return 0;
}

上述程式碼,以為主執行緒已經delete了物件p,而子執行緒是毫不知情的,此時子執行緒還有物件p去呼叫成員函式就會出現錯誤。這裡的解決方法就是透過強弱智慧指標來解決:

#include <iostream>
#include <thread>
#include <memory>
using namespace std;

class Test
{
public:
	// 構造Test物件,_ptr指向一塊int堆記憶體,初始值是20
	Test() :_ptr(new int(20)) 
	{
		cout << "Test()" << endl;
	}
	// 析構Test物件,釋放_ptr指向的堆記憶體
	~Test()
	{
		delete _ptr;
		_ptr = nullptr;
		cout << "~Test()" << endl;
	}
	// 該show會在另外一個執行緒中被執行
	void show()
	{
		cout << *_ptr << endl;
	}
private:
	int *volatile _ptr;
};
void threadProc(weak_ptr<Test> pw) // 透過弱智慧指標觀察強智慧指標
{
	// 睡眠兩秒
	std::this_thread::sleep_for(std::chrono::seconds(2));
	/* 
	如果想訪問物件的方法,先透過pw的lock方法進行提升操作,把weak_ptr提升
	為shared_ptr強智慧指標,提升過程中,是透過檢測它所觀察的強智慧指標儲存
	的Test物件的引用計數,來判定Test物件是否存活,ps如果為nullptr,說明Test物件
	已經析構,不能再訪問;如果ps!=nullptr,則可以正常訪問Test物件的方法。
	*/
    // 弱智慧指標提升為強智慧指標,透過返回值來判斷管理的資源是否已經被釋放了。
	shared_ptr<Test> ps = pw.lock();
	if (ps != nullptr)
	{
		ps->show();
	}
}
int main()
{
	// 在堆上定義共享物件
	shared_ptr<Test> p(new Test);
	// 使用C++11的執行緒,開啟一個新執行緒,並傳入共享物件的弱智慧指標
	std::thread t1(threadProc, weak_ptr<Test>(p));
	// 在main執行緒中析構Test共享物件
	// 等待子執行緒執行結束
	t1.join();
    // 如果這裡改為t1.detach那麼成員函式show就不會被呼叫。
	return 0;
}

注1:使用shared_ptr來管理記憶體資源的時候,管理的空間資源和引用計數資源是分開開闢的,這樣會導致問題,如果引用計數的空間資源開闢出錯,那麼就是進一步導致了管理的記憶體資源無法釋放。而使用make_shared來建立智慧指標的時候,空間是一起開闢的,要麼都成功要麼都失敗,但是也會帶來額外的問題,第一就是無法自定義刪除器,要等到weaks引用計數為0才釋放資源。

注2:如果shared_ptr管理的資源不是new分配的記憶體,才考慮自定義刪除器,這也是為什麼make_shared不支援自定義刪除器的原因,因為make_shared就是透過new分配記憶體資源。

4.自定義刪除器

一般而言我們使用智慧指標管理的都是堆記憶體空間(也就是new開闢的空間),當智慧指標出作用域時就是自動呼叫解構函式來釋放這塊堆記憶體空間(也就是呼叫delete)。但是對於一些特殊的情況,就不能呼叫delete了,比如開啟一個資料夾,這時候就需要自己來自定刪除器來關閉開啟的資料夾。

class FileDeleter
{
public:
	// 刪除器負責刪除資源的函式
    // 過載()運算子
	void operator()(FILE *pf)
	{
		fclose(pf);
	}
};
int main()
{
    // 由於用智慧指標管理檔案資源,因此傳入自定義的刪除器型別FileDeleter
	unique_ptr<FILE, FileDeleter> filePtr(fopen("data.txt", "w"));
	return 0;
}

這裡也可以使用lambda表示式來進行處理

int main()
{
	// 自定義智慧指標刪除器,關閉檔案資源
	unique_ptr<FILE, function<void(FILE*)>> 
		filePtr(fopen("data.txt", "w"), [](FILE *pf)->void{fclose(pf);});
	// 自定義智慧指標刪除器,釋放陣列資源
	unique_ptr<int, function<void(int*)>>
		arrayPtr(new int[100], [](int *ptr)->void {delete[]ptr; });
	return 0;
}
  • function<void(FILE*)>:指定了自定義刪除器的型別
  • 自定義刪除器是一個lambda表示式 [](FILE pf)->void { fclose(pf); },它接受一個FILE引數,並在unique_ptr超出作用域時呼叫fclose。

參考文件:
C++11智慧指標
volatile關鍵字
noexcept關鍵字
使用delete刪除指標

相關文章