C++類中指標成員的管理(值型類、智慧指標)
在使用C++類的時候免不了會遇到類中需要指標成員的時候,但類成員裡面一出現指標就
很容易一不小心碰上各種各樣的麻煩,尤其需要注意的是類物件的初始化和賦值,下面
總結了一些常見解決辦法。
先來看看這樣一個類:
#include <iostream> #ifndef DEMO_H_ #define DEMO_H_ using std::cout; using std::endl; class Demo { private: int val; int *ptr; public: Demo(int v = 0, int *p = nullptr): val(v), ptr(p) { cout << "object with value " << val << " constructed" << endl; } ~Demo() { cout << "object with value " << val << " destroyed" << endl; delete ptr; } void set_val(int v) { val = v; } void print() { cout << val << ", " << ptr << endl; } }; #endif
一個很簡單的類,包含一個string物件和一個指向string物件的指標,然後定義了一個默
認建構函式和解構函式,set_val可用於改變val值,print用於輸出物件內容。下面來用一用這個類:
#include <iostream> #include "demo.h" using std::cout; using std::endl; int main() { int *p = new int(10) { Demo a(10, p); a.print(); Demo b = a; b.set_val(20); b.print(); Demo c; c = a; c.set_val(30); c.print(); } cout << "Done!" << endl; return 0; }
Clang++編譯,Mac上執行時輸出瞭如下結果:
object with value 10 constructed //物件a的建立 10, 0x7fff554a49b8 //物件a的內容 20, 0x7fff554a49b8 //物件b的內容 object with value 0 constructed //物件c的建立 30, 0x7fff554a49b8 //物件c的內容 object with value 30 destroyed //呼叫物件c的解構函式 a.out(15751,0x7fffc8d623c0) malloc: *** error for object 0x7fff554a49b8: pointer being freed was not allocated *** set a breakpoint in malloc_error_break to debug [1] 15751 abort ./a.out
對照main函式可以看見大括號裡面的程式碼都看起來執行得很正常,出大括號時區域性物件
a,b,c要各自呼叫解構函式,因為自動儲存物件被刪除的順序與建立順序相反,所以可以看見最先呼叫的是c的解構函式,但是接下去卻出問題了,沒有物件a,b解構函式的呼叫,括號外的“Done!”也沒有輸出。
問題出在哪了呢?
我們仔細看看輸出結果可以發現,三個物件的ptr成員的值都是相同的,也就是說,在初始化b和對c賦值的時候,直接將原來物件的指標值直接複製給了新物件,三個指標都指向了同一個物件,大括號結束的時候呼叫解構函式,第一次對c呼叫後就已經delete了ptr,之後再次呼叫b,a的解構函式delete的還是同一個指標,於是就出現了問題。
事實上,由於我們沒有定義Demo類的複製建構函式,所以在初始化的時候編譯器會為我們合成預設複製建構函式,這個預設建構函式直接將各個成員的值簡單複製給新物件的成員,所以a,b物件的指標成員都指向了同一個物件。同樣的,由於我們沒有為這個類過載賦值運算子=,在遇到物件間的賦值操作時,編譯器自動為我們過載了賦值運算子=,將=右邊物件中的成員的值簡單地複製到左邊,導致b,c物件的指標成員值相同。這樣的複製叫淺複製,對於這個問題,有幾種解決方法:
1.避免使用物件對新物件進行初始化或對其他物件賦值
也就是說,在本例中我們直接建立物件b,c:
int *p1 = new int(10);
int *p2 = new int(10);
Demo b(20, p1);
Demo c(30, p2);
嚴格來講,這只是在避免出現問題,而不是解決問題的方法,但對於一些很小程式來說還是可以一用的。有時儘管我們極力避免使用這兩種操作,但還是有可能一不注意使用了這樣的操作,導致最終程式執行時出現嚴重的後果,而且很難意識到問題所在。為了避免這種疏漏,我們可以宣告類的偽私有函式,禁止使用這兩種操作,使用了這樣的操作將通不過編譯。
偽私有函式放在類的private部分,一來避免了編譯器自動生成複製建構函式和賦值運算子的過載函式,二來因為函式是私有的,在用到函式時就會編譯錯誤,提醒我們及時改正。
private:
int val;
int *ptr;
Demo(const Demo&); //複製建構函式
Demo &operator=(const Demo &); //複製運算子過載
2.定義值型類
所謂值型類,就是物件在複製時進行的是深度複製,的到一個新的副本,對於本例來說,就是將類物件成員指標所指向的值複製給新的物件,而不是簡單地複製指標的值。
為了進行深度複製,我們必須定義自己的複製建構函式和賦值運算子過載函式:
public:
//other functions
Demo(const Demo &obj): val(obj.val), ptr(new int(*obj.ptr)) { }
Demo &operator=(const Demo &obj): val(obj.val), ptr(new int(*obj.ptr)) { return *this; }
3.智慧指標
智慧指標的行為與普通指標一樣,只是增加了部分功能,C++11提供了shared_ptr智慧指標模板,可用於解決我們的問題。為使用智慧指標,必須包含標頭檔案memory,然後將ptr的宣告改為:
std::shared_ptr<int> ptr;
最後將解構函式中的delete ptr;刪去即可。利用C++11提供的shared_ptr智慧指標,很容易問題就解決了,那如果沒有shared_ptr呢?這時候就需要我們定義自己的智慧指標了,這也是shared_ptr的實現思路:
為了定義我們自己的智慧指標,需要引入使用計數(use count),又叫引用計數(reference count),就是在每次建立新的類物件時初始化指標並將使用計數置為1,當物件副本被建立時,複製建構函式複製指標值並增加使用計數的值。當對一個物件賦值時,賦值操作符左邊物件的使用計數值減一(使用計數減至0,則刪除物件),右邊的物件使用計數值加一。在呼叫解構函式時,減少使用計數的值,如果計數減至0,則刪除基礎物件。
用人話說的話,就是所有通過原物件來初始化、通過賦值操作得到的原物件的副本物件中的指標成員都和原物件指向同一個物件,通過一個計數變數來計數指向這個物件的指標的個數,每次呼叫解構函式,都只是將計數變數的值減一,只有減到0時才刪除這些物件中指標成員所指向的物件。
如何實現呢?首先,我們需要一個單獨的類用來封裝使用計數和相關的指標:
class Shared_ptr {
friend class Demo;
int *ptr;
size_t use;
Shared_ptr(int *p): ptr(p), use(1) { }
~Shared_ptr() { delete ptr; }
};
對於普通使用者而言,只需要使用我們的Demo類提供的介面,並不需要關心為了輔助實現Demo類而定義的這個Shared_ptr類,所以這個類的全部成員均為private。注意這個類需要放在Demo類的前面,因為修改後的Demo類需要用到此類:
class Demo {
private:
int val;
Shared_ptr *sptr;
public:
Demo(int v = 0, int *p = nullptr): val(v), sptr(new Shared_ptr(p)){}
~Demo() { if(--sptr->use == 0) delete sptr; }
Demo(const Demo &obj): val(obj.val), sptr(obj.sptr) { ++sptr->use; }
Demo &operator=(const Demo &obj)
{
++obj.sptr->use;
if(--sptr->use == 0)
delete sptr;
val = obj.val;
sptr = obj.sptr;
return *this;
}
};
為了突出變化,這裡只列出了Demo類中關鍵的改變處。
我們將原來Demo類中的指標int *ptr移到了Shared_ptr類中,Shared_ptr類還有一個成員size_t use就是使用計數。而Demo類中的指標變成了指向Shared_ptr物件的指標sptr,Demo類的建構函式、解構函式、複製建構函式以及賦值運算子的過載也有相應的改變。
為了說清楚這個智慧指標是如何工作的,我們還是回到前面的main函式:
首先:
Demo a(10, p); //呼叫 Demo(int v = 0, int *p = nullptr): val(v), sptr(new Shared_ptr(p)){}
這一行呼叫Demo類的建構函式建立了一個新物件a,傳入的指標p又被用於構建一個Shared_ptr物件,呼叫Shared_ptr的建構函式,將ptr的值設為p,use的值設為1。Demo類的Shared_ptr指標指向這個new出來的Shared_ptr物件。
之後:
Demo b = a; //呼叫 Demo(const Demo &obj): val(obj.val), sptr(obj.sptr) { ++sptr->use; }
這一行用a來初始化新物件b,呼叫複製建構函式,複製建構函式直接將a中val和sptr的值複製給b,此時a,b中的指標成員指向同一個Shared_ptr物件,故將使用計數的值加一(++sptr->use;)
接下來:
Demo c;
c = a;
/*呼叫
Demo &operator=(const Demo &obj)
{
++obj.sptr->use;
if(--sptr->use == 0)
delete sptr;
val = obj.val;
sptr = obj.sptr;
return *this;
}
*/
這一行建立了新物件c並對c賦值,先後呼叫Demo類的建構函式和賦值運算子過載函式,由於在賦值時,左運算元被右運算元覆蓋,相當於與左運算元物件指向同一個Shared_ptr物件的Demo物件少了一個,故其使用計數要減一,如果使用計數減為0,則刪除sptr指向的Shared_ptr物件,Shared_ptr物件呼叫其解構函式,刪除ptr所指物件;而與右運算元指向同一個Shared_ptr物件的Demo物件多了一個,故其使用計數要加一。至此,a,b,c物件中的指標成員同時指向同一個Shared_ptr物件,使用計數use = 3.
接下來,大括號結束,自由儲存區的物件按照與建立順序相反的順序開始依次呼叫解構函式,首先是c呼叫解構函式,使用計數use減一,變為2,不執行delete sptr操作;接著呼叫b的解構函式,use減為1,任然不執行delete sptr操作;最後,呼叫a的解構函式,use減為0,執行delete sptr操作,經一步呼叫sptr指向的Shared_ptr的解構函式,執行delete ptr操作,至此,a,b,c物件被刪除,它們的指標成員共同指向的Shared_ptr物件也被刪除,Shared_ptr物件中的ptr指向的物件也被刪除。
智慧指標通過封裝普通指標和使用計數,避免了多次delete同一個指標而引發的錯誤,但自己實現起來還是稍複雜。前面兩種方法,第一種只適合在一些小程式裡面用來規避問題,稍複雜一點的程式可能就不適用了;第二種通過定義自己的複製建構函式和賦值運算子過載函式來實現深度複製,使每個物件都有一個指標成員所指值的副本,也避免了多次delete同一個指標的問題。