天天看點

從零開始學C++之對象語義與值語義、資源管理(RAII、資源所有權)、模拟實作auto_ptr<class>、實作Ptr_vector

一、對象語義與值語義

1、值語義是指對象的拷貝與原對象無關。拷貝之後就與原對象脫離關系,彼此獨立互不影響(深拷貝)。比如說int,C++中的内置類型都是值語義,前面學過的三個标準庫類型string,vector,map也是值語義

2、對象語義指的是面向對象意義下的對象

對象拷貝是禁止的(Noncopyable)

OR

一個對象被系統标準的複制方式複制後,與被複制的對象之間依然共享底層資源,對任何一個的改變都将改變另一個(淺拷貝)

3、值語義對象生命期容易控制

4、對象語義對象生命期不容易控制(通過智能指針來解決,見本文下半部分)。智能指針實際上是将對象語義轉化為值語義,利用局部對象(智能指針)的确定性析構,包括auto_ptr, shared_ptr, weak_ptr,  scoped_ptr。

5、值語義與對象語義是分析模型決定的,語言的文法技巧用來比對模型。

6、值語義對象通常以類對象的方式來使用,對象語義對象通常以指針或引用方式來使用

7、一般将隻使用到值語義對象的程式設計稱為基于對象程式設計,如果使用到了對象意義對象,可以看作是面向對象程式設計。

8、基于對象與面向對象的差別

很多人沒有區分“面向對象”和“基于對象”兩個不同的概念。面向對象的三大特點(封裝,繼承,多态)缺一不可。通常“基于對象”是使用對象,但是無法利用現有的對象模闆産生新的對象類型,繼而産生新的對象,也就是說“基于對象”沒有繼承的特點。而“多态”表示為父類類型的子類對象執行個體,沒有了繼承的概念也就無從談論“多态”。現在的很多流行技術都是基于對象的,它們使用一些封裝好的對象,調用對象的方法,設定對象的屬性。但是它們無法讓程式員派生新對象類型。他們隻能使用現有對象的方法和屬性。是以當你判斷一個新的技術是否是面向對象的時候,通常可以使用後兩個特性來加以判斷。“面向對象”和“基于對象”都實作了“封裝”的概念,但是面向對象實作了“繼承和多态”,而“基于對象”沒有實作這些。

假設現在有這樣一個繼承體系:

從零開始學C++之對象語義與值語義、資源管理(RAII、資源所有權)、模拟實作auto_ptr<class>、實作Ptr_vector

其中Node,BinaryNode 都是抽象類,AddNode 有兩個Node* 成員,Node應該實作為對象語義:

(一):禁止拷貝。

比如

AddNode ad1(left, right);

AddNode ad2(ad1);

假設允許拷貝且沒有自己實作拷貝構造函數(預設為淺拷貝),則會有兩個指針同時指向一個Node對象,容易發生析構兩次的運作時錯誤。

下面看如何禁止拷貝的兩種方法:

方法一:将Node 的拷貝構造函數和指派運算符聲明為私有,并不提供實作

此時如下的最後一行就會編譯出錯了:

即要拷貝構造一個AddNode 對象,最遠也得從調用Node類的拷貝構造函數開始(預設拷貝構造函數會調用基類的拷貝構造函數,如果是自己實作的而且沒有顯式調用,将不會調用基類的拷貝構造函數),因為私有,故不能通路。

需要注意的是,因為聲明了Node類的拷貝構造函數,故必須實作一個構造函數,否則沒有預設構造函數可用。

方法二:Node類繼承自一個不能拷貝的類,如果有很多類似Node類的其他類,此方法比較合适

注意NonCopyable 類的構造函數聲明為protected,則不能直接構造對象,如NonCopyable nc; // error 

但在構造派生類,如最底層的AddNode類時,可以被間接調用。

同樣地,NonCopyable類的拷貝構造函數和指派運算符為私有,故如 AddNode ad2(ad1); 編譯出錯。

二、資源管理

(一)、資源所有權

1、局部對象

資源的生存期為嵌入實體的生存期。

(1)、一個代碼塊擁有在其作用域内定義的所有自動對象(局部對象)。釋放這些資源的任務是完全自動的(調用析構函數)。

如 void fun()

{

Test t; //局部對象

}

(2)、所有權的另一種形式是嵌入。一個對象擁有所有嵌入其中的對象。釋放這些資源的任務也是自動完成(外部對象的析構函數調用内部對象的析構函數)。如

class A

private:

B b; //先析構A,再析構b 

};

2、動态對象(new 配置設定記憶體)

(1)、對于動态配置設定對象就不是這樣了,它總是通過指針通路。在它們的生存期内,指針可以指向一個資源序列,若幹指針可以指向相同的資源。動态配置設定資源的釋放不是自動完成的,需要手動釋放,如delete 指針。

(2)、如果對象從一個指針傳遞到另一個指針,所有權關系就不容易跟蹤。容易出現空懸指針、記憶體洩漏、重複删除等錯誤。

(二)、RAII 與 auto_ptr

一個對象可以擁有資源。在對象的構造函數中執行資源的擷取(指針的初始化),在析構函數中釋放(delete 指針)。這種技法把它稱之為RAII(Resource Acquisition Is Initialization:資源擷取即初始化),如前所述的資源指的是記憶體,實際上還可以擴充為檔案句柄,套接字,互斥量,信号量等資源。

對應于智能指針auto_ptr,可以了解為一個auto_ptr對象擁有資源的裸指針,并負責資源的釋放。

下面先來看auto_ptr 的定義:

// TEMPLATE CLASS auto_ptr

template<class _Ty>

class auto_ptr

....

_Ty *_Myptr; // the wrapped object pointer

實際上auto_ptr 是以模闆方式實作的,内部成員變量隻有一個,就是具體類的指針,即将這個裸指針包裝起來。auto_ptr 的實作裡面還封裝了很多關于裸指針的操作,這樣就能像使用裸指針一樣使用智能指針,如->和* 操作;負責裸指針的初始化,以及管理裸指針指向的記憶體釋放。

這樣說還是比較難了解,可以自己實作一個模拟 auto_ptr<Node> 類的NodePtr 類,從中體會智能指針是如何管理資源的:

Node.h:

Node.cpp:

main.cpp:

從零開始學C++之對象語義與值語義、資源管理(RAII、資源所有權)、模拟實作auto_ptr<class>、實作Ptr_vector

從輸出可以看出,通過NodePtr 智能指針對象包裝了裸指針,NodePtr類通過重載-> 和 * 運算符實作如同裸指針一樣的操作,如

np->Calc();  程式中通過智能指針對象的一次拷貝構造和指派操作之後,現在共有3個局部智能指針對象,但np 和 np2 的成員ptr_ 已經被設定為0;第二次new 的Node對象已經被釋放,現在np3.ptr_ 指向第一次new 的Node對象,程式結束,np3局部對象析構,delete ptr_,析構Node對象。

從程式實作可以看出,Node 類是可以拷貝,而且是預設淺拷貝,故是對象語義對象,現在使用智能指針來管理了它的生存期,不容易發生記憶體洩漏問題。(程式中編譯時使用了這裡的記憶體洩漏跟蹤器,現在new 沒有比對delete 但沒有輸出資訊,說明沒有發生記憶體洩漏)。

是以簡單來說,智能指針的本質思想就是:用棧上對象(智能指針對象)來管理堆上對象的生存期。

在本文最前面的程式中,雖然實作了禁止拷貝,但如上所述,對象語義對象的生存期仍然是不容易控制的,下面将通過智能指針auto_ptr<Node>  來解決這個問題,通過類比上面NodePtr 類的實作可以比較容易地了解auto_ptr<Node>的作用:

需要注意的是,在BinaryNode 中現在裸指針的所有權已經歸智能指針所有,由智能指針來管理Node 對象的生存期,故在析構函數中不再需要delete 指針; 的操作。

對auto_ptr 做一點小結:

1、auto_ptr不能作為STL容器的元素

2、STL容器要求存放在容器中的元素是值語義,要求元素能夠被拷貝。

3、auto_ptr的拷貝構造或者指派操作會改變右操作數,因為右操作數的所有權要發生轉移。

實際上auto_ptr 是值語義(将對象語義轉換為值語義),auto_ptr 之是以不能作為STL容器的元素,關鍵在于第3點,即

auto_ptr的拷貝構造或者指派操作會改變右操作數,如下的代碼:

在編譯到push_back 的時候就出錯了,檢視push_back 的聲明:

void push_back(const _Ty& _Val);

即參數是const 引用,在函數内部拷貝時不能對右操作數進行更改,與第3點沖突,是以編譯出錯。

其實可以這樣來使用:

也就是先釋放所有權成為裸指針,再插入容器,在這裡再提一點,就是vector 隻負責裸指針本身的記憶體的釋放,并不負責指針指向記憶體的釋放,假設一

個MultipleNode 類有成員vector<Node*> vec_; 那麼在類的析構函數中需要周遊容器,逐個delete 指針; 才不會造成記憶體洩漏。

更謹慎地說,如上面的用法還是存在記憶體洩漏的 可能性。考慮這樣一種情形:

vec.push_back(node.release()); 當node.release() 調用完畢,進而調用push_back 時,由這裡知道,push_back 會先調用operater

 new 配置設定指針本身的記憶體,如果此時記憶體耗盡,operator new 失敗,push_back 抛出異常,此時裸指針既沒有被智能指針接管,也

沒有插入vector(不能在類的析構函數中周遊vector 進行delete 操作),那麼就會造成記憶體洩漏。

為了解決這個潛在的風險,可以實作一個Ptr_vector 模闆類,負責指針指向記憶體的釋放:

Ptr_vector.h:

Ptr_vector 繼承自vector 類,重新實作push_back 函數,插入裸指針時,先用局部智能指針對象接管裸指針所有權,如果

std::vector<T *>::push_back(val);  成功(operator new 成功),那麼局部智能指針對象釋放裸指針的所有權;如果

std::vector<T *>::push_back(val);  失敗(operator new 失敗),抛出異常,棧展開的時候要析構局部對象,此時局部智能指針對象的析構函數内會

delete 裸指針。

此外,在Ptr_vector 類中還重載了push_back,能夠直接将智能指針作為參數傳遞,在内部插入裸指針成功後,釋放所有權。

當Ptr_vector 對象銷毀時調用析構函數,析構函數調用clear(),周遊vector<T*>,delete 裸指針。

此時,我們就可以如下地使用Ptr_vector:

這樣就確定一定不會發生記憶體洩漏,即使push_back 失敗也不會。

參考:

C++ primer 第四版

Effective C++ 3rd

C++程式設計規範