C++引用計數(reference counting)技術簡介(1)
1.引用計數的作用
C++引用計數是C++為彌補沒有垃圾回收機制而提出的記憶體管理的一個方法和技巧,它允許多個擁有共同值的物件共享同一個物件實體。
C++的引用計數作為記憶體管理的方法和技術手段主要有一下兩個作用。
(1)簡化了堆物件(Heap Objects)的管理。 一個物件從堆中被分配出來之後,需要明確知道是誰擁有了這個物件,因為只有擁有這個物件的所有者才能夠銷燬它。但在實際使用過程中, 這個物件可能被傳遞給另一個物件(例如通過傳遞指標引數),一旦這個過程複雜,我們很難確定誰最後擁有了這個物件。 使用引用計數就可以拋開這個問題,我們不需要再去關心誰擁有了這個物件,因為我們把管理權交給了物件自己。當這個物件不再被引用時,它自己負責銷燬自己。
(2)解決了同一個物件存在多份拷貝的問題。引用計數可以讓等值物件共享一份資料實體。這樣不僅節省記憶體,也使程式速度加快,因為不在需要構造和析構同值物件的多餘副本。
2.等值物件具有多份拷貝的情況
一個未使用引用計數計數實現的String類虛擬碼示例如下:
class String
{
public:
String(const char* value="");
String& operator=(const String& rhs)
{
if(this==&rhs) //防止自我賦值
return *this;
delete[] data; //刪除舊資料
data=new char[strlen(rhs.data)=1]
strcpy(data,rhs.data);
return *this;
}
...
private:
char* data;
};
String a,b,c,d,e;
a=b=c=d=e="Hello";
很顯然物件a~e都有相同的值”hello”,這就是等值物件存在多份拷貝。
3.以引用計數實現String
3.1含有引用計數的字串資料實體
引用計數實現String需要額外的變數來描述資料實體被引用的次數,即描述字串值被多少個String物件所共享。這裡重新設計一個結構體StringValue來描述字串和引用計數。StringValue設計如下:
Struct StringValue
{
int refCount;
char* data;
};
3.2含有引用計數的字串資料實體的String
新的String類的大致定義可描述如下:
class String
{
private:
Struct StringValue
{
int refCount;
char* data;
StringValue(const char* initValue);
~StringValue();
};
StringValue* value;
public:
String(const char* initValue="");//constructor
String(const String& rhs);//copy constructor
String& operator=(const String& rhs); //assignment operator
~String(); //destructor
};
關於StringValue的建構函式和解構函式可定義如下:
String::StringValue::StringValue(const char* initValue):refCount(1)
{
data=new char[strlen(initValue)+1];
strcpy(data,initValue);
}
String::StringValue::~StringValue()
{
delete[] data;
}
String的成員函式可定義如下:
String的建構函式:
String::String(const char* initValue):value(new StringValue(initValue)){}
在這種建構函式的作用下String s1("lvlv");
和String s2=("lvlv")
,分開構造相同初值的字串在記憶體中存在相同的拷貝,並沒有達到資料共享的效果。其資料結構為:
事實上可以令String追蹤到現有的StringValue物件,並僅僅在字串獨一無二的情況下才產生新的StringValue物件,上圖所顯示的重複記憶體空間便可消除。這樣細緻的考慮和實現需要增加額外的程式碼,可有讀者自行實現和練習。
String拷貝建構函式:
當String物件被複制時,產生新的String物件共享同一個StringValue物件,其程式碼實現可為:
String::String(const String& rhs):value(rhs.value)
{
++valus->refCount;
}
如果以圖示表示,下面的程式碼:
String s1("lvlv");
String s2=s1;
會產生如下的資料結構:
這樣就會比傳統的non-reference-counted String類效率高,因為它不需要分配記憶體給字串的第二個副本使用,也不要再使用後歸還記憶體,更不需要將字串值複製到記憶體中。這裡只需要將指標複製一份,並將引用計數加1。
String解構函式:
String的解構函式在絕大部分呼叫中只需要將引用次數減1,只有當引用次數為1時,才回去真正銷燬StringValue物件:
String::~String()
{
if(--value->refCount==0) delete value;
}
String的賦值操作符(assignment):
當用戶寫下s2=s1;
時,這是String物件的相互賦值,s1和s2指向同一個StringValue物件,該物件的引用次數應該在賦值過程中加1。此外,賦值動作之前s2所指向的StringValue物件的引用次數應該減1,因為s2不再擁有該值。如果s2是原本StringValue物件的最後一個引用者,StringValue物件將被s2銷燬。String的賦值操作符實現如下:
String& String::operator=(const String& rhs)
{
if (this->value == rhs.value) //自賦值
return *this;
//賦值時左運算元引用計數減1,當變為0時,沒有指標指向該記憶體,銷燬
if (--value->refCount == 0)
delete value;
//不必開闢新記憶體空間,只要讓指標指向同一塊記憶體,並把該記憶體塊的引用計數加1
value = rhs.value;
++value->refCount;
return *this;
}
3.3String的寫時複製(Copy-on-Write)
字串應該支援以下標讀取或者修改某個字元,需要過載方括號操作符。String應該有
const char& operator[](size_t index) const;//過載[]運算子,針對const Strings
char& operator[](size_t index);//過載[]運算子,針對non-const Strings
對於const版本,因為是隻讀動作,字串內容不受影響:
const char& String::operator[](size_t index) const
{
return value->data[index];
}
對於non-const版本,該函式可能用來讀取,也可能用來寫一個字元,C++編譯器無法告訴我們operator[]被呼叫時是用於寫還是取,所以我們必須假設所有的non-const operator[]的呼叫都用於寫。此時,我能就要確保沒有其他任何共享的同一個StringValue的String物件因寫動作而改變。也就是說,在任何時候,我們返回一個字元引用指向String的StringValue物件內的一個字元時,我們必須確保該StringValue物件的引用次數為1,沒有其他的String物件引用它。
//過載[]運算子,針對non-const Strings
char& String::operator[](size_t index)
{
if (value->refCount>1)
{
--value->refCount;
value = new StringValue(value->data);
}
if (index<strlen(value->data))
return value->data[index];
}
和其他物件共享一份資料實體,直到必須對自己擁有的那份實值進行寫操作,這種在電腦科學領域中存在了很長曆史。特別是在作業系統領域,各程序(processes)之間往往允許共享某些記憶體分頁(memory pages),直到它們打算修改屬於自己的那一分頁。這項技術非常普及,就是著名的寫時複製(copy-on-write)。
注意:實現了String的寫時複製,但存在一個問題,比如:
String s1="Hello";
char* p=&s1[1];
String s2=s1;
這樣就會出現如下資料結構:
這表示下面的語句會導致其他的String物件也被修改。這個不問題不限於指標,如果有人以引用的方式將String的non-const operator[]返回值儲存起來,也會發生同樣的問題。
解決這種問題主要有三種方法。
(1)忽略之。
允許這種操作,即使出錯也不錯處理。這種方法很不幸被那些實現reference-counted字串的類庫所採用。考察如下程式,
#include <iostream>
#include <string>
using namespace std;
std::string a="lvlv";
int main()
{
char* p=&a[1];
*p='a';
std:: string b=a;
std::cout<<"b:"<<b<<endl;
return 0;
}
上面程式碼在VS2017中編譯執行輸出”lalv”。
(2)警告
有些編譯器知道會有這種問題,並給出警告。雖然無力解決,卻會說明不要那麼做,如果違背,後果不可預期。
(3)避免
徹底解決這種問題,採取零容忍態度。但是會降低物件之間共享的資料實體的個數。基本解決辦法是:為每一個StringValue物件加上一個flag標誌,用以指示是否可被共享。一開始,我們先樹立此標誌為true,表示物件可被共享,但只要non-const operator[]作用於物件值時就將標誌清除。一旦標誌被設為false,那麼資料實體可能永遠不會再被共享了。
下面是StringValue的修改版,包含一個可共享標誌flag。
Struct StringValue
{
int refCount;
char* data;
bool shareable;
StringValue(const char* initValue);
~StringValue();
};
String::StringValue::StringValue(const char* initValue):refCount(1),shareable(true)
{
data=new char[strlen(initValue)+1];
strcpy(data,initValue);
}
String::StringValue::~StringValue()
{
delete[] data;
}
相比之前的StringValue的建構函式和解構函式,並沒有什麼大的修改。當然String member functions也要做相應的修改。以copy constructor為例,修改如下:
String::String(const String& rhs)
{
if(rhs.value->shareable)
{
value=rhs.value;
++valus->refCount;
}
}
其他的String的成員函式都應該以類似的方法檢查shareable。對於Non-const operator[]是唯一將shareable設為false者,其實現程式碼可為:
char& String::operator[](size_t index)
{
if (value->refCount>1)
{
--value->refCount;
value = new StringValue(value->data);
}
value->shareable=false;//新增此行
if (index<strlen(value->data))
return value->data[index];
}
4.小結
以上描述了引用計數的作用和使用引用計數來實現自定義的字串類String。使用引用計數來實現自定義類時,需要考慮很多細節問題,尤其是寫時複製是提升效率的有效手段。
要幾本掌握引用計數這項技術,需要我們明白引用計數是什麼,其作用還有如何在自定義類中實現引用計數,如果這些都掌握了,那麼引用計數也算是基本掌握了。
參考文獻
[1]記憶體管理之引用計數
[2]More Effective C++.Scott Meyers著,侯捷譯.P183-213.
[3]more effective c++讀書筆記