模式羅漢拳: Immutable模式與string類的實現
透明
梗概
禁止改變對象的狀態,從而增加共享對象的堅固性、減少對象訪問的錯誤,同時還避免了在多線程共享時進行同步的需要。
實現方法:在對象構造完成以后就完全禁止改變任何狀態信息。如果需要改變狀態,則生成一個狀態與原對象不同的新對象。
場景
假設你正在為一家游戲公司開發一個和外太空、宇宙飛船有關的游戲,當然你有必要用某種方式來表示一艘宇宙飛船(不管它是屬于地球人的還是屬于外星人的)所處的位置。很自然的,你決定編寫一個Position類。從一個Position對象應該可以查詢到當前位置的x坐標和y坐標(我們的游戲比較簡單,二維地圖,呵呵),還應該可以根據輸入的偏移量得到新的位置。很正確的設計,不是嗎?(見例1)
例1:Position的設計
class Position{
private:
int x, y; //簡單點,用整型數來表示坐標
public:
Position(int x, int y){ //ctor需要兩個參數。
this->x = x;
this->y = y;
}
int getX( ){ return x; }
int getY( ){ return y; }
void Offset(int offX, int offY){ //根據偏移量得到新的位置
x+=offX;
y+=offY;
}
}
但是,如果我們的Position需要在多線程環境下使用,它能保證線程安全嗎?答案是很明顯的No!如果兩條線程同時調用同一個Position對象的Offset函數,你就無法保證得到的結果是什么了。所以,為了保證線程安全,也許你還會想給Offset函數加上同步機制——麻煩了!
換個角度想想怎么樣?假如我們根本不讓Offset函數修改Position的內容?假如我們讓Offset函數生成一個新的Position對象?如果是這樣,Position對象就已經是線程安全的了——它沒有任何“寫”操作,而沒有寫操作的類是不需要同步的。于是我們這樣做了,并且很輕松的得到了一個線程安全的Position類。(見例2)
例2:線程安全的Position類(這里只展示Offset函數)
Position Position::Offset(int offX, int offY){ //根據偏移量得到新的位置
return Position(x+offX, y+offY);
}
約束
- 你有一個天性被動的類。這個類的實例不需要改變自己的狀態。同時這個類的實例還被其他多個對象共享。
- 正確協調被共享的對象的狀態改變非常困難。當一個對象的狀態發生改變時,所有使用它的對象都應該得到通知。這造成了對象之間的緊耦合。
- 在多線程共享時,還需要使用同步機制來保證線程安全性。
解決方案
為了避免狀態改變帶來的諸多麻煩,不允許對實例的狀態做任何修改。具體的做法就是:不在類的公開接口中出現任何可以修改對象狀態的方法,只出現狀態讀取方法。如果client需要不同的狀態,就生成一個新的對象。(見圖1)

圖1 Immutable模式的類圖
效果
- 不再需要協調狀態修改的代碼,也不再需要協調任何同步代碼。
- 生成了更多的對象。增加了對象生成和銷毀的開銷。
實現
Immutable模式的實現主要有以下兩個要點:
1.除了構造函數之外,不應該有其它任何函數(至少是任何public函數)修改任何成員變量。
2.任何使成員變量獲得新值的函數都應該將新的值保存在新的對象中,而保持原來的對象不被修改。
在“效果”中我已經講到:Immutable模式會大大提高對象生成和銷毀的頻率。因此,在C++中實現Immutable模式時,還必須特別注意對象的生存周期。你可以嘗試用智能指針[Meyers96, Item28]來幫助你處理對象的銷毀問題,但是無論如何你都必須仔細檢查以確保沒有內存泄漏——如果每艘飛船的每次移動都會造成內存泄漏,你的游戲該是多么糟糕!
此外,Immutable模式還有一種變體:Read Only Object模式。它的做法是:當一個類的對象對于某些client可寫、某些client不可寫時,讓這個類實現一個ReadOnly接口。然后讓可寫的client直接訪問對象,而讓不可寫的client通過ReadOnly接口訪問該對象,從而實現了不同的讀寫權限控制。(如圖2所示)

圖2 Read Only Object模式
Immutable模式與string類的實現策略
如果你也讀過[Meyers96],我想你一定對那個應用在String類上的COW(Copy-On-Write)策略[Meyers96, Item29]印象深刻。COW策略是“lazy evaluation”的發展形式。如果對String類的寫操作數量很少,那么COW策略將大大提高整個String類的效率,并大大降低空間開銷。
可是你知道嗎?在STL中的string類并沒有采用COW策略,從例3就可以看出這一點。為什么?為什么這么好的策略沒有得到采用?相信你從[Meyers96]中便可發現:實際在String類上實現COW策略是如此復雜。更何況我們還必須考慮線程安全的問題。我完全有理由認為:正是因為考慮到這些復雜的情況,STL的實現者們才最終決定用一個比較低效但是安全的實現方案。
例3:STL中的string::operator=和string::operator[]
//下面代碼出自SGI STL 2000年6月8日版本
//為了幫助讀者理解,我做了些微改動,并在關鍵位置加上注釋
//如果使用COW策略,operator=應該不做內容復制,而是進行引用計數
string& string::operator=(const string& s) {
if (&s != this)
assign(s.begin(),s.end()); // 這里的operator=只是簡單的內容復制而已
return *this;
}
//如果使用COW策略,const的operator[]和非const的operator[]應該不同
//但是這里兩個operator[]完全相同
const char & string::operator[](int n) const
{ return *(_M_start + n); } //_M_start是字符數組的起始位置
char & string::operator[](int n)
{ return *(_M_start + n); }
看到這些,我不能不開始猜想:為什么STL的設計者們一定要保留這些給他們造成麻煩的“修改函數”(即可以修改string內容的函數)?我想,這是因為他們希望讓string的行為方式盡量接近于C語言的char *型字符串。不然,我真的想不出其他任何保留operator[]的理由。
那么,如果不必非要讓string類的行為方式接近char *型字符串,如果string類的讀操作應用頻率遠遠大于寫操作(在實際應用中這是很常見的),你會考慮如何實現一個string類?啊,也許你已經想到了:Immutable模式。你可以很舒服的使用[Meyers96]教你的引用計數方法來節約存儲空間,你不必再擔心寫操作的同步問題或別的什么,因為已經沒有寫操作。任何改變字符串內容的操作都將得到一個新的string對象。而對象生存期管理和存儲空間管理這兩個大問題也因為Immutable模式的引入而大大簡化,你完全可以參照[Meyers96]第183頁到第189頁的內容自己來解決它們。
代碼示例
我用了一天的時間,做了一個簡單的ImmutableString實現。其中實現細節用了Proxy類[Meyers96],并參考了COM的引用計數規則[Pan99]。在這個例子中,讀者可以感覺到:Immutable模式大大簡化了共享空間的字符串類型的實現,并為其中的一些方法(比如subString)的實現提供了非常大的便利。本來我想把代碼放在文章里面,但是時間和空間受限,最后決定放棄。
在該代碼中,我做了一個簡單的效率測試:反復進行字符串對象的賦值(operator=)操作。結果表明:ImmutableString的效率比std::string高出了一倍左右。假如你的業務就是不斷的讀取數據庫、不斷的賦值、不斷的輸出,而不對字符串進行修改,那么ImmutableString的效率提升是非常可觀的。
該示例代碼在VC .NET下編譯通過。
相關模式
經常會使用Abstract Factory模式[GOF95]來創建新的對象。
大量的對象經常通過Flyweight模式[GOF95]被共享。
參考書目
[Meyers96] Scott Meyers, More Effective C++, Addison-Wesley, 1996.
[GOF95] Erich Gamma etc., Design Patterns: Elements of Reusable Object-Oriented Software, Addison-Wesley, 1995. 中譯本:《設計模式:可復用面向對象軟件的基礎》,李英軍等譯,機械工業出版社,2000 年9月。
[PAN99] 潘愛民,《COM原理與應用》,清華大學出版社,1999年11月。