所屬文章系列:尋找塵封的銀彈:設(shè)計(jì)模式精讀
【一灸姊、從繁雜的代碼中尋找簡化之法】
【動(dòng)機(jī)】
程序員都知道設(shè)計(jì)模式是好東西,一開始都能動(dòng)力十足地去學(xué)習(xí)房匆。但是酌伊,時(shí)間久了才發(fā)現(xiàn):設(shè)計(jì)模式很難學(xué),《設(shè)計(jì)模式》相關(guān)書籍里的細(xì)節(jié)非常復(fù)雜卷员,學(xué)起來很吃力盈匾。即便學(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)用場景就有點(diǎn)模糊宰啦,《設(shè)計(jì)模式》書中舉的“代碼示例”苏遥,把抽象工廠模式和原型模式放在一起講,導(dǎo)致我對這個(gè)模式的理解一度走偏赡模。后來田炭,經(jīng)過不斷的思考和實(shí)踐,終于弄明白了漓柑。
我們以Windows下的“畫圖”應(yīng)用程序?yàn)槔塘颍纯此拇a的痛點(diǎn)到底在哪里?當(dāng)我們使用原型模式之后辆布,再看看它會(huì)給我們帶來什么驚喜瞬矩?
先看一段未使用原型模式的代碼,當(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);
????...
}
這些代碼看起來中規(guī)中矩景用,似乎沒有什么可改進(jìn)的余地。但是惭蹂,當(dāng)有一個(gè)新需求到來的時(shí)候伞插,我們新寫了一些代碼以支持新功能,代碼都運(yùn)行正常盾碗,卻總是感覺哪里不對媚污。
新需求是這樣的:用戶可以在菜單里選擇“拷貝到新圖片”,也就是說廷雅,把用戶正在畫的圖全盤拷貝到一個(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è)新對象,代碼卻寫了那么多朵锣,而且構(gòu)造過程的那些類名如Button谬盐、參數(shù)如p1,都需要了解得很清楚诚些,而且為了取到p1這些參數(shù)飞傀,需要從很多個(gè)類中去取皇型,費(fèi)盡了周折。最為頭疼的是砸烦,有時(shí)取到的參數(shù)并非想要的值弃鸦,這種情況測試起來就比較困難,總會(huì)有一些Bug直接出現(xiàn)在用戶面前幢痘。
有的人就會(huì)想:有沒有一個(gè)好方法唬格,能夠讓這段代碼看起來很簡單,而且不會(huì)出錯(cuò)颜说?再貪心一點(diǎn)购岗,代碼在面對未來的需求變化時(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() {
//對每個(gè)成員變量賦值
}
Graphic *Label::Clone() {
//對每個(gè)成員變量賦值
}
【優(yōu)劣對比】
本質(zhì)上玄妈,使用原型模式的代碼乾吻,就是把GraphicTool::UserClickClone原有代碼中克隆Canvas的實(shí)現(xiàn)代碼,分散到了Canvas類拟蜻、Graphic各個(gè)子類中去了绎签。粗看起來總代碼量沒有多少變化,而實(shí)際上是減少了瞭郑!
減少的那部分代碼辜御,就是靠Canvas::Clone做到的。它把原來需要知道Graphic各個(gè)子類的類名如Button的代碼屈张,用多態(tà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è)子類的類名熬拒,這樣一來爷光,擴(kuò)展性就出來了,一旦再有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ù)”。
展開來講重贺,它就是一種克隆復(fù)雜對象的解決方案:客戶代碼在克隆的時(shí)候骑祟,不需要知道克隆對象的細(xì)節(jié),包括對象所屬的類檬姥、構(gòu)造對象的參數(shù)等曾我。
類圖如下:
【適用場景】
在考慮使用原型模式之前,我們需要問自己一個(gè)問題:為什么要克隆健民,而不是使用已有的對象抒巢,或者直接構(gòu)造一個(gè)?
看下面使用已有對象的例子秉犹,兩個(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è)例子中蛉谜,根本不需要克隆。相反崇堵,如果克隆了對象型诚,反倒錯(cuò)了,這是因?yàn)閮蓚€(gè)函數(shù)之間需要共享對象鸳劳,而克隆對應(yīng)的是不共享狰贯。
那什么時(shí)候必須使用克隆,而達(dá)到不共享的目的呢赏廓?由外部需求的驅(qū)動(dòng)涵紊,例如前文的GraphicTool::UserClickClone,或者多線程的需要幔摸。我以標(biāo)準(zhǔn)化的形式來表達(dá)這種情況摸柄,參見下面的CloneRequirement::DoSomething:
void CloneRequirement::DoSomething() {
????CloneBase *newClone = client2->Clone();
... //把newClone傳遞給某個(gè)對象或某個(gè)線程
}
CloneBase *Client2::Clone() {
????CloneBase *newClone = new Client2(...);
????newClone->share = this->share->Clone();
????...
????return newClone;
}
【思維進(jìn)階(一):原型與值對象的區(qū)別】
有一個(gè)“值對象模式”與原型模式相關(guān)。
原型的本質(zhì)就是由客戶代碼來決定克隆一個(gè)對象既忆,而值對象的本質(zhì)是由模式內(nèi)部代碼決定每次構(gòu)造一個(gè)新對象驱负。
例如“拼接一個(gè)字符串”的表達(dá)式還可以寫成這樣:
????strBuffer.append("<").append(">");
它是用簡潔的形式來代替如下這種繁瑣的表達(dá)式:
????strBuffer.append("<");
????strBuffer.append(">");
這里用到的技術(shù)就是“值對象模式”,這個(gè)模式出現(xiàn)在《領(lǐng)域驅(qū)動(dòng)設(shè)計(jì)》一書中患雇。
在append函數(shù)中跃脊,每調(diào)用一次都會(huì)生成一個(gè)新對象,這樣就避免了共享對象時(shí)苛吱,有時(shí)會(huì)發(fā)生錯(cuò)誤讀寫對象的問題匾乓。還有一個(gè)好處是讓表達(dá)式準(zhǔn)確、簡潔地表達(dá)程序員的意圖又谋,代價(jià)就是C++需要自己考慮內(nèi)存釋放的問題拼缝,而Java則沒有這個(gè)問題。
“值對象”的概念用一段文字不可能講得清楚彰亥,由于它和原型模式有一些相關(guān)咧七,本文只是粗略講解一下。有機(jī)會(huì)我會(huì)單開一篇文章探討它任斋。
【思維進(jìn)階(二):單一職責(zé)】
上一篇《設(shè)計(jì)模式精讀~單元測試的利器 ~ 抽象工廠模式》中講過一些關(guān)于“兩個(gè)維度的變化”以及“單一職責(zé)”的內(nèi)容继阻,這篇的原型模式也和這個(gè)有關(guān),只不過是具體的維度有所不同罷了废酷。
原型模式有兩個(gè)大維度瘟檩,每個(gè)大維度里各有兩個(gè)小維度:
1.克隆維度:誰負(fù)責(zé)克隆,傳遞給誰澈蟆。
2.構(gòu)造細(xì)節(jié)維度:如何確定被克隆的子類是什么墨辛,如何為新對象的每個(gè)成員變量賦值。
“兩個(gè)維度的變化”背后的原則是“單一職責(zé)”趴俘,也就是說睹簇,每個(gè)維度對于一個(gè)職責(zé)×壬粒“單一職責(zé)”的背后是人腦一次面對的概念越少越好太惠,容易理解和記憶,也容易發(fā)現(xiàn)代碼的漏洞疲憋。
如果人腦一次關(guān)注的維度過多凿渊,就自然會(huì)產(chǎn)生懈怠,導(dǎo)致很多代碼漏洞的發(fā)生缚柳。
我們來看多個(gè)維度會(huì)產(chǎn)生怎樣的變化埃脏,例如本節(jié)里剛剛提到的
“誰負(fù)責(zé)克隆”有三種情況,
“傳遞給誰”有四種情況喂击,
“如何確定被克隆的子類是什么”有十種情況剂癌,
“如何為新對象的每個(gè)成員變量賦值”有二十種情況。
那么把這四個(gè)維度都考慮進(jìn)來翰绊,就有3*4*10*20=2400種變化佩谷!人腦見到這種情況,一定會(huì)退避三舍监嗜,那么Bug自然就能找到生長的土壤谐檀。
另外,把維度分開或者單一職責(zé)還有一個(gè)好處裁奇,就是方便單元測試桐猬,因?yàn)槊總€(gè)用例只考慮一個(gè)變化,單獨(dú)測試時(shí)只有3+4+10+20=42個(gè)用例刽肠,反觀多個(gè)維度一起測試就是2400個(gè)用例溃肪!
【三免胃、細(xì)節(jié)、例外】
【擴(kuò)展】
像其他創(chuàng)建型模式一樣惫撰,原型模式可以通過自己實(shí)現(xiàn)的內(nèi)部注冊表機(jī)制羔沙,來實(shí)現(xiàn)子類原型的動(dòng)態(tài)載入。
【限制】
一厨钻、有些類不能修改扼雏,因?yàn)樗鼈兪且惶椎谌筋悗欤蛘呤瞧渌麍F(tuán)隊(duì)的代碼夯膀,例如Graphic子類或者子類引用的一些下游類诗充。那么只能在自己可控的范圍內(nèi)增加Clone函數(shù),在其他地方實(shí)現(xiàn)克隆過程但分散到子類中诱建,就像前文“未使用原型模式的代碼”那樣蝴蜓。
二、如果克隆對象之間涂佃,有循環(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)樵瓉淼拇a也不知道哪些該克隆哪些該共享木缝,而錯(cuò)誤地使用了編譯器默認(rèn)實(shí)現(xiàn)的淺拷貝方式(即共享方式),最常見的例子就是兩個(gè)對象共享一個(gè)字符串對象指針围辙。
作于2018-5-17
附:這次的代碼格式有點(diǎn)亂我碟,等我找到一個(gè)好的代碼格式再更新這篇文章,抱歉了姚建。