http://blog.csdn.net/ljianhui/article/details/22895505
字符串一種在程序中經常要使用到的數據結構,然而在C中卻沒有字符串這種類型。在C++中,為了方便字符串的使用,在STL中提供了一個string類。該類維護一個char指針,并封裝和提供各種的字符串操作。
一、為什么要實現隱式公享寫時拷貝
試想一下,如果我們要自己實現一個string類,最簡單的方式是什么?就是讓每一個string類的實例維護一個在內存中獨立的字符數組,每個string對象各不相干。這樣一個對象的任何變化都不會影響到其他的對象。這樣做的好處就是處理簡單,不易出錯,但是這樣做的缺點卻是內存的使用量、程序的效率也低。
例如,對于如下的例子:
在上面的代碼中,我們需要做的事情非常簡單,只是想交換一下兩個string對象的值而已。然而,如果我們采用上面所說的實現方式,一共在內存上進行了三次的用new來創建一個新的數組并復制數據,同時還會調用兩次的delete[]。我們花了這么大的力氣才完成了這樣一個簡單的動作。而隱式共享寫時復制的內存管理策略卻可以解決這樣的問題。雖然C++11用&&運算符解決了上述問題,但是在程序中使用大量的string的副本,而不改變其值的情況還是不少的。例如string數組,刪除一個元素后的移動操作。
二、隱式共享寫時復制的實現思想
什么是隱式共享寫時拷貝呢?就是當用一個string對象初始化另一個string對象或把一個string對象賦值給另一個string對象時,它們內部維護的指針其實指向了內存中的同一個字符數組,這就是隱式共享。
使用這種方法,上述的代碼就不需要調用new來創建數組也不需要復制,也不會調用delete[](如果不是很明白也不要緊,看完實現代碼和后自然就明白了)。然后兩個指針指向同一個對象很容易引發錯誤,當其中一個對象執行析構函數釋放掉其內部指針指向的內存時,另一個對象卻對此完全不知情,可能會引用一個不存在的內存,從而讓程序崩潰。所以為了方便資源的管理,我們引用智能指針的思想,為每個內存中的字符數組(用new創建,存在于堆中)添加一個引用計數used,表示有多少個對象引用這個塊內存(即字符數組)。當一個對象析構時,它會把引用計數used減1,當used為0時,表示沒有對象引用這塊內存,從而把這塊內存釋放掉。當然由于used也要在對象中共享,所以它也是一個堆中的數據,每個對象有一個指向它的指針。
而當一個string對象需要改變它的值時,例如
此時,s1和s2指向了堆內存中的同一個字符數組,而當s2的值要改變時,因為如果直接在其指向的內存中修改,則會影響到對象s1,所以為了讓s2的操作不影響到s1,s2會在重新new出一塊內存,然后先把之前所引用的字符數組的數據復制到新的字符數組中,然后再把s3中的字符數據復制到新的字符數組中。這就是寫時拷貝。注意,同時還要把之前指向的內存的引用計數減1(因為它指向了新的堆中的字符數組),并在堆中重新new一個塊內存,用于保存新的引用計數,同時把新的字符數組的引用計數置為1。因為此時只有一個對象(就是改變值的對象)在使用這個內存。
三、代碼實現及設計要點詳解
說了這么多,還是來看看代碼的實現吧,為了與標準C++的string類區別開來,這樣采用第一個字母大寫來表示自定義的字符串類String。
源代碼可以點擊下面的連接下載:
http://download.csdn.net/detail/ljianhui/7143351
其頭文件_stringv2.h如下:
從上面的String的數據成員,我們可以看到String在其內部維護一個指向堆內存的字符數組的char指針_cstr和一個指向堆內存中字符數組的引用計數的size_t指針_used。本類并沒有實現String的所有操作,只是實現了大部分的初始化和String跟寫操作有關的函數。
注意:為了說明的方便,我會使用s._cstr等方式來指明一個成員變量所屬的對象,或使用*s._cstr等方式來引用一個對象的指針成員所指的內存。但這并不是說在類的外面訪問成員變量,只是為了說明的方便和清晰而已。為了方便代碼的閱讀,類的成員變量或私有函數都以下劃線“_”開頭。
下面就來一個函數一個函數地解釋其實現方式。
1)默認構造函數
這里需要注意的地方就是,在默認初始化中,我們并不使用new來申請內存,而是直接把_cstr置為NULL,這樣做是因為我們不知道程序接下來會做什么動作,貿然為其分配內存是不合理的。例如,對于如下操作,則無需分配內存,
根據隱式共享的原則,只需要把s2._cstr的值賦為s1._cstr即可。而為什么沒有為對象分配內存,而*_used的值卻為1呢?這里只要是為了操作的統一,考慮上面的語句s2 = s1,其產生的操作應該是把s2._used所指向的內存數據(引用計數)的值減1,因為s2._cstr不再指向原先的字符數據。s1._used所指向的內存數據的值加1。若*s2._userd的值為0,就釋放s2._userd和s2._cstr所指向的內存。而如果在這里,*s2._userd的初始值為0,0減1就會變成-1,而_userd是一個無符號整數的指針,它的值就會變成2^32-1,從而讓程序運行的結果不符合我們的預想。而*s2._userd的初始值為1則可完美地避免這個問題。
2)復制構造函數
本函數非常易懂,就是把s的成員的值全部復制給*this即可,但是由于多了*this這個對象引用s的字符數組,所以應該把該字符數組的引用計數加1。注意,此時this->_used和s._used指向了同一個對象。
3)帶C字串參數的構造函數
該函數非常簡單,由于是構造函數,而且使用的參數是C風格的字符串,所以默認為其字符串一定不是某個對象所引用的字符數組,所以直接為其分配內存,并復制字符。非常明顯,因為是該對象第一個創建該字符數組的,所以其引用為1.
4)帶C風格字符串的構造函數
其實現與上原理相同,只是參數不同,不再詳述。
5)析構函數
_decUsed()函數可以說是該類內存釋放的管理函數,可以看到,每當一個對象被析構時,其指向的堆中的字符數組的引用計數就會減1,當引用計數為0時,就釋放字符數組和引用計數。
6)賦值操作函數
該賦值操作函數的參數一個本類的對象,該類賦值操作函數第一個要避免的就是自身賦值的問題,在有指針存在的類中是特別要重視這個問題,而在這個String類也不可例外。為什么這樣說呢?因為我們調用賦值操作函數時,必須要減少左值的引用計數,增加右值的引用計數,這個在第1)點已經說過了,而如果是自身賦值的話,在減少其引用計數時,其引用計數可能為0,從而導致字符數組的釋放,從而讓_cstr指針懸空(delete[]掉了,卻在賦值的過程中,重新賦為delete[]前的值,即_cstr的值沒有在賦值過程中改變)。
一般的程序的做法是判斷參數的地址與this是否相等來避免自身賦值,而這里卻可以采用巧妙的策略來避免這個問題,可以看到上面的代碼并沒有if判斷語句。我們首先對*s._used加1,這樣*s._used至少為2,然后再對*(this->_used)減1,這樣即使s與*this是同一個對象,也可以保證*(this->_used)的值至少為1,不會變為0,從而讓字符數組不會被釋放。因為復制是使用隱式共享的,所以直接復制指針,使指針_cstr其指向與s同一個存在中的字符數組并復制其他的數據成員即可。
同時,我們還要記得返回當前對象的引用。
7)重載的賦值操作函數
該賦值操作函數的參數一個C風格的字符串,因而不會發生自身賦值的問題。與String(const char *cstr)函數相似,唯一不同的是使用賦值操作函數時,對象已經存在,所以要調用_decUsed來減少該對象的_cstr原先指向的字符數組的引用計數。然后生成根據cstr創建一個全新的字符數組。并返回當前對象的引用。
8)重載+=操作符,實現字符串連接
+=是一個復雜的操作,也是我實現時琢磨得最久的操作。因為它是寫的操作,根據寫時拷貝的原則,它需要減少其原先字符數組的引用計數,同時創建一個新的字符數組來儲存增加長度后的字符串。并且該對象所引用的字符數組可能只有該對象自己在引用,也就是說其引用計數為1,此時減少其引用計數還可能導致原先字符數組的釋放,從而丟失數據,并在使用指向原先字符數組的指針進行數據復制時發生錯誤。所以引用計數是否為1應該采用不同的策略。
而當引用計數為1時,我們可以認為該對象獨立享有該字符數組,可以對其進行任何操作而不影響其他對象,這時,我們可以把字符串直接追加到已經的字符數組的后面,而這樣做可能因為字符數組的容量不夠而不能進行,這時為字符數組的重新分配合適的空間。
當引用計數不為1時,我們首先調用_decUsed()來減少原字符數組的引用計數,然后調用_renewAndCat來連接并產生新的字符數組,然后重置_cstr的指向,并new一個新的引用計數,初始值置為1.
上面的函數中,_renewAndCat的功能就是分配新的字符數組,同時把原先的字符數據復制到新的字符數組中,再在新的字符數組中追加字符串,返回新的字符數組的首地址。_addString是只有當引用計數為1時才能調用的函數,其字符數組足以容納連接后的字符串,則直接連接,若不能,則調用_renewAndCat重新分配合適的字符數組,并進行復制,最后,把舊的字符數組delete[]掉,再把_cstr賦值為_renewAndCat創建的新字符數組的首地址。
注:本人認為,如果一個字符串對象做了一次+=運算,那么它很可能會很快做第二次,所以在分配內存時,我采用了預分配的策略,每次分配都分配連接完成后的字符串的長度的1.5倍。這樣當下一次執行+=時,字符數組就可能有足夠多的容量來保存連接后的字符串,而不用重新分配和復制。而且我們注意到,當調用+=一次之后,*_used肯定為1,即下次運行+=時,是極有可能直接加到字符數組的后面的。
為了提高程序的運行效率,在進行1.5倍的預分配時,沒有使用浮點數乘法,更沒有使用除法,而是采用了移位運算,如下:
int capacity = len;
capacity += (capacity >> 1)
其對應的數學表達式為:“x = capacity + capacity/2;capacity = x”。因為右移運算相當于除以2,這樣就實現了乘以1.5的運算操作。
9)清空字符串
該函數用于清除字符串對象引用的字符數組的數據,所以我們只需要調用_decUsed函數,減少對象所引用的字符數組的引用計數,并把其他成員變量設置為默認的值即可。即與默認構造函數所設置的值一致。
注:以下函數不是String的成員函數
10)重載+操作符
該函數的實現可以借助上面實現的+=操作符,先用第一個對象rhs復制構造一個臨時對象stemp,然后通過把第二個參數追加到臨時對象stemp上,返回stemp即可簡單輕松地實現+操作符的重載。
11)重載輸出操作符
12)重載輸入操作符
實現輸入操作符的重載的一個困難之處就是我們不知道用戶要輸入的字符串的長度,也就不知道應該分配一個多大的緩沖區來接收輸入的字符。所以在這里,設置一個一定大小的緩沖,采用循環讀取,連續添加到字符串對象中的方法來實現。那么如何知道該循環讀取輸入多少次呢?也就是說,怎么知道已經把所有的輸入字符讀取完畢呢?在這里,我使用了一個標準輸入流istream的get成員函數,該成員函數從輸入流中讀取指定個數的字符或遇到輸入流結束而返回,注意最后它會自動加入一個空字符‘\0’作為結束。這個空字符也作為讀入的字符數量的計數。例如,如果有一個大小為6的char型數組作為buffer,從標準輸入流中讀入6個字符,實際上只會從標準輸入中讀入最多5個字符(因為可能遇到流結束),并把空字符‘\0’加入到buffer的末尾。
所以我們可以把buffer的最后一個字節,設置成我們自己特定的一個字符(只是是非‘\0’即可),如這里的'#',然后讀入buffer大小的字符數。若還沒有讀取完畢,我們設置的這個特殊的字符會被空字符'\0'覆蓋,我們從而知道,還沒讀取完標準輸入的數據。若我們設置的特殊字符沒有被覆蓋,就說明,讀到的數據不足以填滿buffer,也就是說,我們已經沒有數據可讀了,從而可以判斷已經讀取完所有輸入的字符。
注:輸入也是一個寫的操作,并且會把對象之前的內容覆蓋掉,所以在輸入到對象之前,要先調用clear成員函數,把對象清空。
四、測試代碼
運行結果如下:
五、代碼分析
首先定義一個String的對象s1,s1調用默認構造函數,生成一個默認的對象,然后調用賦值操作函數,為s1分配堆內存字符數組。對象s2是以對象s1的樣本復制構造出來的對象,其作用域只在花括號內。我們可以看到s2的改變并沒有影響到s1。其他的調用也一樣,從而可以看到是實現了隱式共享,寫時拷貝。從運行的結果可以看出,一切的運行都是沒有問題的,與標準庫的string的輸出一致。