C++中的Primer拷貝控制和資源管理詳解
拷貝控制和資源管理
通常,管理類外資源的類必須定義拷貝控制成員,這種類需要通過析構函數來釋放對象所分配的資源。一旦一個類需要析構函數,那么它兒乎肯定也需要一個拷貝構造函數和一個拷貝賦值運算符。
為了定義這些成員,我們首先必須確定此類型對象的拷貝語義。一般來說,有兩種選擇,可以定義拷貝操作,使類的行為看起來像一個值或者像一個指針。
類的行為像一個值,意味著它應該也有自己的狀態(tài)。當我們拷貝一個類值的對象時,副本和原對象是完全獨立的。改變副本不會對原對象有任何影響,反之亦然。
行為像指針的類則共享狀態(tài)。當我們拷貝一個這種類的對象時,副本和原對象使用相同的底層數據。改變副本也會改變原對象,反之亦然。
在我們使用過的標準庫類中,標準庫容器和string類的行為像一個值。而不出意外的,shared_ptr類提供類似指針的行為,就像我們的StrBlob類一樣,IO類型和unique_ptr不允許拷貝或賦值,因此它們的行為既不像值也不像指針。
為了說明這兩種方式,我們會為練習中的HasPtr類定義拷貝控制成員。首先,我們將令類的行為像一個值;然后重新實現類,使它的行為像一個指針。
我們的HasPtr類有兩個成員,一個int和一個string指針。通常,類直接拷貝內置類型(不包括指針)成員;這些成員本身就是值,因此通常應該讓它們的行為像值一樣。我們如何拷貝指針成員決定了像HasPtr這樣的類是具有類值行為還是類指針行為。
行為像值的類
為了提供類值的行為,對于類管理的資源,每個對象都應該擁有一份自己的拷貝。這意味著對于ps指向的string,每個HasPtr對象都必須有自己的拷貝。為了實現類值行為,HasPtr需要
- 定義一個拷貝構造函數,完成string的拷貝,而不是拷貝指針
- 定義一個析構函數來釋放string
- 定義一個拷貝賦值運算符來釋放對象當前的string,并從右側運算對象拷貝string
類值版本的HasPtr如下所示
class HasPtr{ public: HasPtr(const std::string &s=std::string()): ps(new std::string(s)1),i(0){} //對ps指向的string,每個BasPtr對象都有自己的拷貝 HasPt(const HasPtr&p): ps(new std::string(*p.ps)),i(p.i){} HasPtr & operator=(const HasPtr&); ~HasPtr(){delete ps;} private: std::string*ps; int i; }
我們的類足夠簡單,在類內就已定義了除賦值運算符之外的所有成員函數。第一個構造函數接受一個(可選的)string參數。這個構造函數動態(tài)分配它自己的string副本,并將指向string的指針保存在ps中??截悩嬙旌瘮狄卜峙渌约旱膕tring副本。析構函數對指針成員ps執(zhí)行delete,釋放構造函數中分配的內存。
類值拷貝賦值運算符
賦值運算符通常組合了析構函數和構造函數的操作。類似析構函數,賦值操作會銷毀左側運算對象的資源。類似拷貝構造函數,賦值操作會從右側運算對象拷貝數據。但是,非常重要的一點是,這些操作是以正確的順序執(zhí)行的,即使將一個對象賦予它自身,也保證正確。而且,如果可能,我們編寫的賦值運算符還應該是異常安全的一一當異常發(fā)生時能將左側運算對象置于一個有意義的狀態(tài)。
在本例中,通過先拷貝右側運算對象,我們可以處理自賦值情況,并能保證在異常發(fā)生時代碼也是安全的。在完成拷貝后,我們釋放左側運算對象的資源,并更新指針指向新分配的string:
HasPtr&HasPtr::operator=(const HasPtr&rhs) { auto newp=new string(*rhs.ps);//指貝底層string delete ps;//釋放舊內存 ps = newp;//從右側運算對象指貝數據到本對象 i = rhs; return*this;//返回本對象 }
在這個賦值運算符中,非常清楚,我們首先進行了構造函數的工作:newp的初始化器等價于HasPtr的拷貝構造函數中ps的初始化器。接下來與析構函數一樣,我們delete當前ps指向的string。然后就只剩下拷貝指向新分配的string的指針,以及從rhs拷貝int值到本對象了。
關鍵概念:賦值運算征
當你編寫賦值運算符時:有兩點需要記住:
- 如果將一個對象賦予它自身,賊值運算待必須能正確工作。
- 天多數賦值運算符組各了析構函數和拷貝構造函數的工作。
當你編寫一個賦值運算符時,一個好的模式是先將右側運算對象賦值到一個局部臨時對象中。當拷貝完成后,銷毀左側運算對象的現有成員就是安全的了。一旦左側運算對象的資源被銷毀,就只剩下將數據從臨時對象拷貝到左側運算對象的成員中了。
為了說明防范自賦值操作的重要性,考慮如果賦值運算符如下編寫將會發(fā)生什么
//這樣編寫賦值運算符是錯誤的! HasPtr& HasPtr::operator=(const HasPtr&zhs) { delete ps;//釋放對象指向的string //如果rhs和*this是同一個對象,我們就將從已釋放的內存中拷貝數據! ps=new string(*(rhs.ps)); i=rhs.i; return*this; }
如果zhs和本對象是同一個對象,delete ps會釋放this和rhs指向的string。接下來,當我們在new表達式中試圖拷貝(rhs.ps)時,就會訪問一個指向無效內存的指針,其行為和結果是未定義的。
這樣我們的StrBlobPtr類就仍能使用指向vector的weak_ptr了。你修改后的類將需要一個拷貝構造函數和一個拷貝賦值運算符,但不需要析構函數。解釋拷貝構造出數和拷貝賦值運算符必須要做什么。解釋為什么不需要析構函數。
定義行為像指針的類
對于行為類似指針的類,我們需要為其定義拷貝構造函數和拷貝賦值運算符,來拷貝指針成員本身而不是它指向的string。我們的類仍然需要自己的析構函數來釋放接受string參數的構造函數分配的內存。但是,在本例中,析構函數不能單方面地釋放關聯(lián)的string。只有當最后一個指向string的HasPtr銷毀時,它才可以釋放string。
令一個類展現類似指針的行為的最好方法是使用shared_ptr來管理類中的資源。拷貝(或賦值)一個shared_ptr會拷貝(賦值)shared_ptr所指向的指針。
shared_ptr類自己記錄有多少用戶共享它所指向的對象。當沒有用戶使用對象時,shared_ptr類負責釋放資源。
但是,有時我們希望直接管理資源。在這種情況下,使用引用計數(reference count)就很有用了。為了說明引用計數如何工作,我們將重新定義HasPtr,令其行為像指針一樣,但我們不使用shared_ptr,而是設計自己的引用計數。
引用計數
引用計數的工作方式如下:
- 除了初始化對象外,每個構造函數(拷貝構造函數除外)還要創(chuàng)建一個引用計數,用來記錄有多少對象與正在創(chuàng)建的對象共享狀態(tài)。當我們創(chuàng)建一個對象時,只有一個對象共享狀態(tài),因此將計數器初始化為1。
- 拷貝構造函數不分配新的計數器,而是拷貝給定對象的數據成員,包括計數器。拷貝構造函數遞增共享的計數器,指出給定對象的狀態(tài)又被一個新用戶所共享。“析構函數遞減計數器,指出共享狀態(tài)的用戶少了一個。如果計數器變?yōu)?,則析構函數釋放狀態(tài)。
- 拷貝賦值運算符遞增右側運算對象的計數器,遞減左側運算對象的計數器。如果左側運算對象的計數器變?yōu)?,意味著它的共享狀態(tài)沒有用戶了,拷貝賦值運算符就必須銷毀狀態(tài)。
唯一的難題是確定在哪里存放引用計數。計數器不能直接作為HasPtr對象的成員。
下面的例子說明了原因:
HasPtr p1("HiYa!"); HasPtr p2(p1);//p1和p2指向相同的string HasPtr p3(p1);//p1、p2和p3都指向相同的string
如果引用計數保存在每個對象中,當創(chuàng)建p3時我們應該如何正確更新它呢?可以遞增p1中的計數器并將其拷貝到p3中,但如何更新p2中的計數器呢?
解決此問題的一種方法是將計數器保存在動態(tài)內存中。當創(chuàng)建一個對象時,我們也分配一個新的計數器。當拷貝或賦值對象時,我們拷貝指向計數器的指針。使用這種方法,副本和原對象都會指向相同的計數器。
定義一個使用引用計數的類
通過使用引用計數,我們就可以編寫類指針的HasPtr版本了:
class HasPtr{ public: //構造函數分配新的string和新的計數器,將計數器置為1 HasPtr(const std::string&s=std::string()): ps(newstd::string(s)),i(0),use(new std::size_t(1)){} //指貝構造函數拷貝所有三個數據成員,并遞增計數器 HasPtr(const HasPtr &p): ps(p.ps),i(p.i),use(p.use){++*use;} HasPtr &operator=(const HasPtr&); ~HasPtr(); private: std::string* ps; std::size_t* use;//用來記錄有多少個對象共享*ps的成員 };
在此,我們添加了一個名為use的數據成員,它記錄有多少對象共享相同的string。接受string參數的構造函數分配新的計數器,并將其初始化為1,指出當前有一個用戶使用本對象的string成員。
類指針的拷貝成員“篡改“引用計數
當拷貝或賦值一個HasPtr對象時,我們希望副本和原對象都指向相同的string。即,當拷貝一個HasPtr時,我們將拷貝ps本身,而不是ps指向的string。當我們進行拷貝時,會遞增該string關聯(lián)的計數器。
(我們在類內定義的)拷貝構造函數拷貝給定HasPtr的所有三個數據成員。這個構造函數還遞增use成員,指出ps和p.ps指向的string又有了一個新的用戶。
析構函數不能無條件地delete ps一一可能還有其他對象指向這塊內存。析構函數應該遞減引用計數,指出共享string的對象少了一個。如果計數器變?yōu)?,則析構函數釋放ps和use指向的內存:
HasPtr::~HasPtr() { if(--*use==0){//如果引用計數變?yōu)? delete ps;//釋放string內存 delete use;//釋放計數器內存 } }
拷貝賦值運算符與往常一樣執(zhí)行類似拷貝構造函數和析構函數的工作。即,它必須遞增右側運算對象的引用計數(即,拷貝構造函數的工作),并遞減左側運算對象的引用計數,在必要時釋放使用的內存(即,析構函數的工作)。
而且與往常一樣,賦值運算符必須處理自賦值。我們通過先遞增zhs中的計數然后再遞減左側運算對象中的計數來實現這一點。通過這種方法,當兩個對象相同時,在我們檢查ps(及use)是否應該釋放之前,計數器就已經被遞增過了:
HasPtr& HasPtr::operator=(const HasPtr&rhs) { ++*rhs.use;//通增右側運算對象的引用計數 if(--*use==0){//然后遞減本對象的引用計數 delete ps;//如果沒有其他用戶 delete use;//釋放本對象分配的成員 } ps=rhs.ps;//將數據從rhs拷貝到本對象 i = rhs.i; use=rhs.use; return*this;//返回本對象 }
總結
以上為個人經驗,希望能給大家一個參考,也希望大家多多支持腳本之家。