
所屬文章系列:尋找塵封的銀彈:設(shè)計(jì)模式精讀
【一、從繁雜的代碼中尋找簡(jiǎn)化之法】
【動(dòng)機(jī)】
程序員都知道設(shè)計(jì)模式是好東西,一開始都能動(dòng)力十足地去學(xué)習(xí)。但是,時(shí)間久了才發(fā)現(xiàn):設(shè)計(jì)模式很難學(xué),《設(shè)計(jì)模式》相關(guān)書籍里的細(xì)節(jié)非常復(fù)雜,學(xué)起來(lái)很吃力。即便學(xué)會(huì)了,用的地方也不多,因?yàn)轫?xiàng)目的時(shí)間壓力很大。即便有機(jī)會(huì)用,也會(huì)發(fā)現(xiàn)不知道何時(shí)該用哪種設(shè)計(jì)模式,這才是關(guān)鍵!因?yàn)檫@個(gè)問題一旦解決好了,項(xiàng)目壓力再大,代碼也會(huì)寫得很快很漂亮,而且Bug少。
本文要討論的“原型模式”,它的應(yīng)用場(chǎng)景就有點(diǎn)模糊,《設(shè)計(jì)模式》書中舉的“代碼示例”,把抽象工廠模式和原型模式放在一起講,導(dǎo)致我對(duì)這個(gè)模式的理解一度走偏。后來(lái),經(jīng)過不斷的思考和實(shí)踐,終于弄明白了。
我們以Windows下的“畫圖”應(yīng)用程序?yàn)槔纯此拇a的痛點(diǎn)到底在哪里?當(dāng)我們使用原型模式之后,再看看它會(huì)給我們帶來(lái)什么驚喜?
先看一段未使用原型模式的代碼,當(dāng)然這些代碼并不是Windows的源碼:
當(dāng)用戶把一個(gè)圖形元素拖拽到畫布上時(shí),會(huì)調(diào)用如下函數(shù):
void GraphicTool::UserCreateGraphicItem(int userSelected) {
????Graphic *newItem = NULL;
????switch (userSelected)
????case BUTTON: {
newItem = new Button(p1, p2, ...); // p1等參數(shù)需要從其他類中獲取
????????break;
????}
????case LABEL: {
????????newItem = new Label(p1, p2, ...);
????????break;
????}
????...
????default: {
????????return;
????}
????Canvas::InsertGraphicItem(newItem);
}
void Canvas::InsertGraphicItem(Graphic *newItem) {
????ItemList.Add(newItem);
????...
}
這些代碼看起來(lái)中規(guī)中矩,似乎沒有什么可改進(jìn)的余地。但是,當(dāng)有一個(gè)新需求到來(lái)的時(shí)候,我們新寫了一些代碼以支持新功能,代碼都運(yùn)行正常,卻總是感覺哪里不對(duì)。
新需求是這樣的:用戶可以在菜單里選擇“拷貝到新圖片”,也就是說(shuō),把用戶正在畫的圖全盤拷貝到一個(gè)新圖片編輯窗口里,代碼可以這樣寫:
void GraphicTool::UserClickClone() {
????Canvas *newCanvas = new Canvas(...);
????newCanvas->Brush = currentWindow->Canvas->Brush;
????newCanvas->Font = currentWindow->Canvas->Font;
????...
????for (Graphic *item = newCanvas->ItemList.first(); item < newCanvas->ItemList.end(); ++item) {
????????switch (item->Type())
????????case BUTTON: {
//需要寫一些獲取p1, p2等參數(shù)的代碼
????????????newItem = new Button(p1, p2, ...);
????????????break;
????????}
????????case LABEL: {
????????????newItem = new Label(p1, p2, ...);
????????????break;
????????}
????????...
????????default: {
????????????return;
????????}
????????item = newItem;
????}
currentWindow = new Window(); //切換到新窗口
????currentWindow->Canvas = newCanvas;
}
代碼算是可以工作了,但是總感覺不舒服,大概是因?yàn)镚raphicTool::UserClickClone的前邊只是為了克隆一個(gè)新對(duì)象,代碼卻寫了那么多,而且構(gòu)造過程的那些類名如Button、參數(shù)如p1,都需要了解得很清楚,而且為了取到p1這些參數(shù),需要從很多個(gè)類中去取,費(fèi)盡了周折。最為頭疼的是,有時(shí)取到的參數(shù)并非想要的值,這種情況測(cè)試起來(lái)就比較困難,總會(huì)有一些Bug直接出現(xiàn)在用戶面前。
有的人就會(huì)想:有沒有一個(gè)好方法,能夠讓這段代碼看起來(lái)很簡(jiǎn)單,而且不會(huì)出錯(cuò)?再貪心一點(diǎn),代碼在面對(duì)未來(lái)的需求變化時(shí),能保持較小的改動(dòng)?
【典型代碼】
這里就是原型模式一展身手的地方了!
加入原型模式后,代碼成為了如下的樣子:
void GraphicTool::UserClickClone() {
Canvas *newCanvas = currentWindow->Canvas->Clone(); //原型模式
????currentWindow = new Window();
????currentWindow->Canvas = newCanvas;
}
不過需要Canvas支持Clone:
Canvas *Canvas::Clone() {
????Canvas *newCanvas = new Canvas(...);
????for (Graphic *item = ItemList.first(); item < ItemList.end(); ++item) {
????????newCanvas->ItemList.add(item->Clone());
????}
????...
????return newCanvas;
}
還需要Graphic的所有子類支持Clone:
Graphic *Button::Clone() {
//對(duì)每個(gè)成員變量賦值
}
Graphic *Label::Clone() {
//對(duì)每個(gè)成員變量賦值
}
【優(yōu)劣對(duì)比】
本質(zhì)上,使用原型模式的代碼,就是把GraphicTool::UserClickClone原有代碼中克隆Canvas的實(shí)現(xiàn)代碼,分散到了Canvas類、Graphic各個(gè)子類中去了。粗看起來(lái)總代碼量沒有多少變化,而實(shí)際上是減少了!
減少的那部分代碼,就是靠Canvas::Clone做到的。它把原來(lái)需要知道Graphic各個(gè)子類的類名如Button的代碼,用多態(tài)的方式給消除掉了。并且原來(lái)調(diào)用這些子類構(gòu)造函數(shù)時(shí)獲取參數(shù)的過程,也被轉(zhuǎn)移到各個(gè)子類,而且直接從子類的成員變量中獲取即可,不需求到其他類中尋找,非常方便而且不易出錯(cuò)。
使用原型模式的回報(bào)還遠(yuǎn)不止此:
1.代碼量小,見前文。
2.在獲取構(gòu)造Graphic各個(gè)子類時(shí),減少了獲取構(gòu)造參數(shù)的繁雜過程,也避免了第一次構(gòu)造和克隆的代碼重復(fù)。
3.不需要知道Graphic各個(gè)子類的類名,這樣一來(lái),擴(kuò)展性就出來(lái)了,一旦再有Graphic的新子類加入,GraphicTool::UserClickClone和Canvas::Clone兩個(gè)函數(shù)都不需要修改任何一行代碼,只需要實(shí)現(xiàn)子類的Clone函數(shù)即可。
4.GraphicTool::UserClickClone和Canvas::Clone兩個(gè)函數(shù)的思路非常清晰,代碼一看就懂。
【二、模式核心】
【定義】
原型模式(Prototype):用一句話概括就是“省略構(gòu)造細(xì)節(jié)的克隆技術(shù)”。
展開來(lái)講,它就是一種克隆復(fù)雜對(duì)象的解決方案:客戶代碼在克隆的時(shí)候,不需要知道克隆對(duì)象的細(xì)節(jié),包括對(duì)象所屬的類、構(gòu)造對(duì)象的參數(shù)等。
類圖如下:

【適用場(chǎng)景】
在考慮使用原型模式之前,我們需要問自己一個(gè)問題:為什么要克隆,而不是使用已有的對(duì)象,或者直接構(gòu)造一個(gè)?
看下面使用已有對(duì)象的例子,兩個(gè)函數(shù)之間以共享的方式傳遞參數(shù):
void Client1::DoSomething() {
????Share *share = new Share();
????share->Value = 1;
????Client2 *client2 = new Client2();
????client2->DoSomething(share);
????if (share->Value) ...
}
void Client2::DoSomething(Share *share) {
????share->Value = share->Value == 0 ? 1 : share->Value;
}
在這個(gè)例子中,根本不需要克隆。相反,如果克隆了對(duì)象,反倒錯(cuò)了,這是因?yàn)閮蓚€(gè)函數(shù)之間需要共享對(duì)象,而克隆對(duì)應(yīng)的是不共享。
那什么時(shí)候必須使用克隆,而達(dá)到不共享的目的呢?由外部需求的驅(qū)動(dòng),例如前文的GraphicTool::UserClickClone,或者多線程的需要。我以標(biāo)準(zhǔn)化的形式來(lái)表達(dá)這種情況,參見下面的CloneRequirement::DoSomething:
void CloneRequirement::DoSomething() {
????CloneBase *newClone = client2->Clone();
... //把newClone傳遞給某個(gè)對(duì)象或某個(gè)線程
}
CloneBase *Client2::Clone() {
????CloneBase *newClone = new Client2(...);
????newClone->share = this->share->Clone();
????...
????return newClone;
}
【思維進(jìn)階(一):原型與值對(duì)象的區(qū)別】
有一個(gè)“值對(duì)象模式”與原型模式相關(guān)。
原型的本質(zhì)就是由客戶代碼來(lái)決定克隆一個(gè)對(duì)象,而值對(duì)象的本質(zhì)是由模式內(nèi)部代碼決定每次構(gòu)造一個(gè)新對(duì)象。
例如“拼接一個(gè)字符串”的表達(dá)式還可以寫成這樣:
????strBuffer.append("<").append(">");
它是用簡(jiǎn)潔的形式來(lái)代替如下這種繁瑣的表達(dá)式:
????strBuffer.append("<");
????strBuffer.append(">");
這里用到的技術(shù)就是“值對(duì)象模式”,這個(gè)模式出現(xiàn)在《領(lǐng)域驅(qū)動(dòng)設(shè)計(jì)》一書中。
在append函數(shù)中,每調(diào)用一次都會(huì)生成一個(gè)新對(duì)象,這樣就避免了共享對(duì)象時(shí),有時(shí)會(huì)發(fā)生錯(cuò)誤讀寫對(duì)象的問題。還有一個(gè)好處是讓表達(dá)式準(zhǔn)確、簡(jiǎn)潔地表達(dá)程序員的意圖,代價(jià)就是C++需要自己考慮內(nèi)存釋放的問題,而Java則沒有這個(gè)問題。
“值對(duì)象”的概念用一段文字不可能講得清楚,由于它和原型模式有一些相關(guān),本文只是粗略講解一下。有機(jī)會(huì)我會(huì)單開一篇文章探討它。
【思維進(jìn)階(二):?jiǎn)我宦氊?zé)】
上一篇《設(shè)計(jì)模式精讀~單元測(cè)試的利器 ~ 抽象工廠模式》中講過一些關(guān)于“兩個(gè)維度的變化”以及“單一職責(zé)”的內(nèi)容,這篇的原型模式也和這個(gè)有關(guān),只不過是具體的維度有所不同罷了。
原型模式有兩個(gè)大維度,每個(gè)大維度里各有兩個(gè)小維度:
1.克隆維度:誰(shuí)負(fù)責(zé)克隆,傳遞給誰(shuí)。
2.構(gòu)造細(xì)節(jié)維度:如何確定被克隆的子類是什么,如何為新對(duì)象的每個(gè)成員變量賦值。
“兩個(gè)維度的變化”背后的原則是“單一職責(zé)”,也就是說(shuō),每個(gè)維度對(duì)于一個(gè)職責(zé)?!皢我宦氊?zé)”的背后是人腦一次面對(duì)的概念越少越好,容易理解和記憶,也容易發(fā)現(xiàn)代碼的漏洞。
如果人腦一次關(guān)注的維度過多,就自然會(huì)產(chǎn)生懈怠,導(dǎo)致很多代碼漏洞的發(fā)生。
我們來(lái)看多個(gè)維度會(huì)產(chǎn)生怎樣的變化,例如本節(jié)里剛剛提到的
“誰(shuí)負(fù)責(zé)克隆”有三種情況,
“傳遞給誰(shuí)”有四種情況,
“如何確定被克隆的子類是什么”有十種情況,
“如何為新對(duì)象的每個(gè)成員變量賦值”有二十種情況。
那么把這四個(gè)維度都考慮進(jìn)來(lái),就有3*4*10*20=2400種變化!人腦見到這種情況,一定會(huì)退避三舍,那么Bug自然就能找到生長(zhǎng)的土壤。
另外,把維度分開或者單一職責(zé)還有一個(gè)好處,就是方便單元測(cè)試,因?yàn)槊總€(gè)用例只考慮一個(gè)變化,單獨(dú)測(cè)試時(shí)只有3+4+10+20=42個(gè)用例,反觀多個(gè)維度一起測(cè)試就是2400個(gè)用例!
【三、細(xì)節(jié)、例外】
【擴(kuò)展】
像其他創(chuàng)建型模式一樣,原型模式可以通過自己實(shí)現(xiàn)的內(nèi)部注冊(cè)表機(jī)制,來(lái)實(shí)現(xiàn)子類原型的動(dòng)態(tài)載入。
【限制】
一、有些類不能修改,因?yàn)樗鼈兪且惶椎谌筋悗?kù),或者是其他團(tuán)隊(duì)的代碼,例如Graphic子類或者子類引用的一些下游類。那么只能在自己可控的范圍內(nèi)增加Clone函數(shù),在其他地方實(shí)現(xiàn)克隆過程但分散到子類中,就像前文“未使用原型模式的代碼”那樣。
二、如果克隆對(duì)象之間,有循環(huán)引用的關(guān)系,就很難實(shí)現(xiàn)克隆了。雖然循環(huán)引用不好,但實(shí)際的代碼總會(huì)有一些這種情況存在,而想改變循環(huán)引用的現(xiàn)狀又很困難,只好退而求其次了。
【注意事項(xiàng)】
Prototype的子類都必須實(shí)現(xiàn)Clone,這有時(shí)會(huì)很困難。例如,當(dāng)子類已經(jīng)存在時(shí),克隆會(huì)迫使你立刻做出決定:是不是所有的指針都需要克隆一份兒?也就是深拷貝還是淺拷貝的問題。
有時(shí)做這種決定是很困難的。不過在做這個(gè)決定的時(shí)候,有時(shí)會(huì)發(fā)現(xiàn)一些隱藏得很深的Bug。因?yàn)樵瓉?lái)的代碼也不知道哪些該克隆哪些該共享,而錯(cuò)誤地使用了編譯器默認(rèn)實(shí)現(xiàn)的淺拷貝方式(即共享方式),最常見的例子就是兩個(gè)對(duì)象共享一個(gè)字符串對(duì)象指針。
作于2018-5-17
附:這次的代碼格式有點(diǎn)亂,等我找到一個(gè)好的代碼格式再更新這篇文章,抱歉了。