設(shè)計(jì)模式精讀 ~ 省略構(gòu)造細(xì)節(jié)的克隆技術(shù) ~ 原型

所屬文章系列:尋找塵封的銀彈:設(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è)好的代碼格式再更新這篇文章,抱歉了。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容