01 面向?qū)ο笤O(shè)計原則知識結(jié)構(gòu):
圖1 知識結(jié)構(gòu) 一碟開胃的小菜小菜今年計算機專業(yè)大四了,學(xué)了不少軟件開發(fā)方面的東西,也學(xué)著編了些小程序,躊躇滿志,一心要找一個好單位。當(dāng)投遞了無數(shù)簡歷后,終于收到了一個單位的面試通知,小菜欣喜若狂。
到了人家單位,前臺服務(wù)人員給了他一份題目,上面寫著:“請用 C++、Java 或 C# 任意一種 面向?qū)ο笳Z言 實現(xiàn)一個計算器控制臺程序,要求輸入兩個數(shù)和運算符號,得到結(jié)果。 ”
小菜一看,這個還不簡單,三下五除二,10分鐘不到,小菜就寫完了,感覺也沒有錯誤。交卷后,單位說一周內(nèi)等通知吧。于是小菜只得耐心等待??墒前雮€月過去了,什么消息也沒有,小菜很納悶,我的代碼實現(xiàn)了呀,為什么不給我機會呢?
小菜第一個版本的程序:
static void Main (string [] args) { Console.WriteLine("請輸入數(shù)字a:" ); string a = Console.ReadLine(); Console.WriteLine("請選擇輸入運算符(+、-、*、/):" ); string b = Console.ReadLine(); Console.WriteLine("請輸入數(shù)字b:" ); string c = Console.ReadLine(); string d = string .Empty; if (b == "+" ) d = Convert.ToString(Convert.ToDouble(a) + Convert.ToDouble(c)); if (b == "-" ) d = Convert.ToString(Convert.ToDouble(a) - Convert.ToDouble(c)); if (b == "*" ) d = Convert.ToString(Convert.ToDouble(a) * Convert.ToDouble(c)); if (b == "/" ) d = Convert.ToString(Convert.ToDouble(a) / Convert.ToDouble(c)); Console.WriteLine("結(jié)果是:" + d); }
小菜找到從事軟件開發(fā)工作七年的表哥大鳥,請教原因,大鳥問了題目和了解了小菜代碼的細(xì)節(jié)以后,哈哈大笑,說道:“小菜呀小菜,你上當(dāng)了,人家單位出題的意思,你完全沒有明白,當(dāng)然不會再聯(lián)系你了?!?/p>
小菜說 :“我的代碼有錯嗎?單位題目不就是要我實現(xiàn)一個計算器代碼嗎?我這樣寫有什么問題?”
大鳥說 :“且先不說出題人的意思,單就你現(xiàn)在的代碼,就有很多不足的地方需要改進(jìn)。”
【1】 這樣命名是非常不規(guī)范的。 【2】 判斷分支,你這樣的寫法,意味著每個條件都要做判斷,等于計算機做了三次無用功。 【3】 如果除數(shù)時,客戶輸入了0怎么辦?如果輸入的是字符符號而不是數(shù)字怎么辦?
以上三點,是初學(xué)者常犯的毛病,更加慘痛的教訓(xùn)參見圖文:一行代碼蒸發(fā)了¥6,447,277,680 人民幣!
小菜第一個版本程序的改進(jìn):
static void Main (string [] args) { try { Console.WriteLine("請輸入數(shù)字a:" ); string strNumberA = Console.ReadLine(); Console.WriteLine("請選擇輸入運算符(+、-、*、/):" ); string strOperator = Console.ReadLine(); Console.WriteLine("請輸入數(shù)字b:" ); string strNumberB = Console.ReadLine(); string strResult = string .Empty; switch (strOperator) { case "+" : strResult = Convert.ToString( Convert.ToDouble(strNumberA) + Convert.ToDouble(strNumberB)); break ; case "-" : strResult = Convert.ToString( Convert.ToDouble(strNumberA) - Convert.ToDouble(strNumberB)); break ; case "*" : strResult = Convert.ToString( Convert.ToDouble(strNumberA) * Convert.ToDouble(strNumberB)); break ; case "/" : if (strNumberB != "0" ) strResult = Convert.ToString( Convert.ToDouble(strNumberA) / Convert.ToDouble(strNumberB)); else strResult = "除數(shù)不能為零。" ; break ; default : throw new Exception("輸入的運算符不合法!" ); } Console.WriteLine("結(jié)果是:" + strResult); } catch (Exception ex) { Console.WriteLine("您的輸入有錯:" + ex.Message); } }
大鳥 :“吼吼,不錯,不錯,改得很快嘛!至少就目前代碼來說,實現(xiàn)計算器是沒有問題了,但這樣寫的代碼是否符合出題人的意思呢?”
小菜 :“我明白了,他說用任意一種面向?qū)ο笳Z言實現(xiàn),那意思是要用面向?qū)ο蟮木幊谭椒ㄈ崿F(xiàn),對嗎?OK ,這個我學(xué)過,只不過當(dāng)時我沒想到而已?!?/p>
大鳥 :“所有的初學(xué)者都會有這樣的問題,就是碰到問題就直覺地用計算機能夠理解的邏輯來描述和表達(dá)待解決的問題及具體的求解過程 。這其實是用計算機的方式去思考,比如計算器這個程序:
Step2:根據(jù)運算符號判斷選擇如何運算; 這本身沒有錯,但這樣的思維卻使得我們的程序只為滿足實現(xiàn)當(dāng)前的需求,程序 不易維護(hù) ,不易擴展 ,不易復(fù)用 ,靈活性差 。從而達(dá)不到高質(zhì)量代碼的要求。”
1. 復(fù)制 VS. 復(fù)用大鳥 :“比如說,我現(xiàn)在要求你再寫一個 Windows 的計算器,你現(xiàn)在的 代碼能不能復(fù)用呢 ?”
小菜 :“那還不簡單,把代碼復(fù)制過去不就行了嗎?改動又不大,不算麻煩?!?/p>
大鳥 :“小菜看來還是小菜呀,有人說初級程序員的工作就是 Ctrl+C 和 Ctrl+V ,這其實是非常不好的編碼習(xí)慣,因為當(dāng)你的代碼中重復(fù)的代碼多到一定程度,維護(hù)的時候,可能就是一場災(zāi)難。越大的系統(tǒng),這種方式帶來的問題越嚴(yán)重,編程有一原則,就是盡可能的想辦法去避免重復(fù)。想想看,你寫的這段代碼,有哪些是和控制臺無關(guān)的,而只是和計算器有關(guān)的?”
小菜 :“你的意思是分一個類出來?哦,對的,讓計算和顯示分開。”
大鳥 :“準(zhǔn)確的說,就是讓 業(yè)務(wù)邏輯 與 界面邏輯 分開,讓它們之間的耦合度下降。只有分離開,才可以達(dá)到 易維護(hù) 和 可擴展 ?!?/p>
小菜第二個版本的程序:
public class Operation { public static double GetResult (double numberA, double numberB, string operate) { double result; switch (operate) { case "+" : result = numberA + numberB; break ; case "-" : result = numberA - numberB; break ; case "*" : result = numberA*numberB; break ; case "/" : if (Math.Abs(numberB - 0 ) < double .Epsilon) throw new Exception("除數(shù)為零." ); result = numberA/numberB; break ; default : throw new Exception("輸入的運算符不合法!" ); } return result; } }static void Main (string [] args) { try { Console.WriteLine("請輸入數(shù)字a:" ); string strNumberA = Console.ReadLine(); Console.WriteLine("請選擇輸入運算符(+、-、*、/):" ); string strOperator = Console.ReadLine(); Console.WriteLine("請輸入數(shù)字b:" ); string strNumberB = Console.ReadLine(); string strResult = Convert.ToString( Operation.GetResult(Convert.ToDouble(strNumberA), Convert.ToDouble(strNumberB), strOperator)); Console.WriteLine("結(jié)果是:" + strResult); } catch (Exception ex) { Console.WriteLine("您的輸入有錯:" + ex.Message); } }
小菜 :“鳥哥,我寫好了,你看看!如果你要我寫一個 Windows 應(yīng)用程序的計算器,我就可以復(fù)用這個Operation
類了?!?/p>
大鳥 :“寫的不錯,這樣就完全把業(yè)務(wù)邏輯和界面邏輯分離了。不單是 Windows 程序,Web 程序,哪怕 PDA 、手機等移動系統(tǒng)的軟件需要運算也可以用它?!?/p>
小菜 :“哈,面向?qū)ο蟛贿^如此。下回寫類似代碼不怕啦?!?/p>
大鳥 :“別急,僅此而已,實在談不上完全面向?qū)ο?,你只用了面向?qū)ο笕筇卣髦械囊粋€,還有兩個沒用呢!”
小菜 :“面向?qū)ο笕筇卣鞑痪褪?封裝 、繼承 和 多態(tài) 嗎,這里我用到的應(yīng)該是封裝。不夠嗎?我實在看不出來,這么小的程序如何用到繼承。至于多態(tài),其實我一直也不太了解它到底有什么好處,如何使用它。”
大鳥 :“慢慢來,要學(xué)的東西多著呢,你好好想想該如何應(yīng)用面向?qū)ο蟮?繼承 和 多態(tài) 。”
2. 緊耦合 VS. 松耦合大鳥 :“你先考慮一下,你寫的這個代碼,能做到很靈活的修改和擴展嗎?如果我希望增加一個“求M 數(shù)的N 次方(pow
)運算”,你如何修改?”
小菜 :“修改Operation
類,在switch
中加一個分支就行了?!?/p>
大鳥 :“問題是你要加一個 M 數(shù)的 N 次方運算,卻需要讓加、減、乘、除的運算都來參與編譯,如果你一不小心,把加法改成減法,這豈不是大大的糟糕。打個比方,如果現(xiàn)在公司要求你為公司的薪資管理系統(tǒng)做維護(hù),原來只有三種薪酬算法:
現(xiàn)在要增加兼職工作人員(時薪)的算法,但按照你的寫法,公司就必須要把包含原三種算法的運算類給你,讓你修改。你如果心中小算盤一打,‘公司給我的工資這么低,這下有機會了’,于是你除了增加了兼職算法以外,在技術(shù)人員(月薪)算法中寫了一句:
if (員工是小菜) { salary = salary * 1.1 ; }
那就意味著,你的月薪每月都會增加10%,本來是讓你加一個功能,卻使得原有的運行良好的功能代碼產(chǎn)生了變化,這個風(fēng)險太大了。你明白了嗎?”
小菜 :“哦,你的意思是,我應(yīng)該把加、減、乘、除分離,修改其中一個不影響另外的幾個,增加運算算法也不影響其它代碼,是這樣嗎?”
大鳥 :“自己想去吧,如何用繼承和多態(tài),你應(yīng)該有感覺了。”
小菜第三個版本的程序:
public abstract class Operation { //注意class的修飾符 public double NumberA { get; set ; } public double NumberB { get; set ; } public abstract double GetResult () ; protected Operation () { NumberA = 0.0 ; NumberB = 0.0 ; } protected Operation (double nA,double nB) { NumberA = nA; NumberB = nB; } } internal class OperationAdd : Operation { public override double GetResult () { return NumberA + NumberB; } } internal class OperationSub : Operation { public override double GetResult () { return NumberA - NumberB; } } internal class OperationMul : Operation { public override double GetResult () { return NumberA * NumberB; } } internal class OperationDiv : Operation { public override double GetResult () { if (Math.Abs(NumberB - 0 ) < double .Epsilon) throw new Exception("除數(shù)為零." ); return NumberA/NumberB; } }
小菜 :“鳥哥,我按照你說的方法寫出來了一部分:
首先,是一個運算類,它有兩個Number
屬性,主要用于計算器的前后數(shù); 然后,有一個抽象方法GetResult()
,用于得到結(jié)果; 最后,我把加、減、乘、除都寫成了運算類的子類,繼承它后,復(fù)寫了GetResult()
方法; 這樣如果要修改任何一個算法,就不需要提供其它算法的代碼了。但問題來了,我如何讓計算器知道我是希望用哪一個算法呢?”
大鳥 :“你現(xiàn)在的問題其實就是如何去實例化對象的問題,也就是說,到底要實例化誰,將來會不會增加實例化的對象 ,比如“求 M 數(shù)的 N 次方運算”,這是很容易變化的地方,應(yīng)考慮用一個單獨的類來做這個創(chuàng)造實例的過程,你只需要輸入運算符號,這個類就實例化出合適的對象,通過多態(tài),返回父類的方式實現(xiàn)了計算器的結(jié)果?!?/p>public class OperationFactory { public static Operation CreateOperator (string operate) { Operation oper; switch (operate) { case "+" : oper = new OperationAdd(); break ; case "-" : oper = new OperationSub(); break ; case "*" : oper = new OperationMul(); break ; case "/" : oper = new OperationDiv(); break ; default : throw new Exception("輸入的運算符不合法!" ); } return oper; } }static void Main (string [] args) { try { Console.WriteLine("請輸入數(shù)字a:" ); string strNumberA = Console.ReadLine(); Console.WriteLine("請選擇輸入運算符(+、-、*、/):" ); string strOperator = Console.ReadLine(); Console.WriteLine("請輸入數(shù)字b:" ); string strNumberB = Console.ReadLine(); Operation opr = OperationFactory.CreateOperator( strOperator); opr.NumberA = Convert.ToDouble(strNumberA); opr.NumberB = Convert.ToDouble(strNumberB); string strResult = Convert.ToString(opr.GetResult()); Console.WriteLine("結(jié)果是:" + strResult); } catch (Exception ex) { Console.WriteLine("您的輸入有錯:" + ex.Message); } }
小菜 :“回想那天我面試題寫的代碼,我終于明白我為什么寫得不成功了,原來一個小小的計算器也可以寫出這么精彩的代碼,謝謝鳥哥?!?/p>
大鳥 :“記住哦,編程是一門技術(shù),更是一門藝術(shù) ,不能只滿足于寫完代碼結(jié)果正確就完事,時??紤]如何讓代碼 可維護(hù) ,可復(fù)用 ,可擴展 ,靈活性好 ,只有這樣才可以真正得到提高。寫出優(yōu)雅的代碼真的是一件很爽的事情?!?/p>
軟件設(shè)計的目標(biāo)如何同時提高一個軟件系統(tǒng)的 可復(fù)用性 、可擴展性 、易維護(hù)性 和 靈活性 是面向?qū)ο笤O(shè)計需要解決的核心問題,是我們設(shè)計軟件的目標(biāo)。
然而,什么是 可復(fù)用 、可擴展 、易維護(hù) 和 靈活性好 的設(shè)計呢?
我們通過一個故事來進(jìn)行說明:
話說三國時期,曹操帶領(lǐng)百萬大軍攻打東吳,大軍在長江赤壁駐扎,軍船連成一片,眼看就要滅掉東吳,統(tǒng)一天下,曹操大悅,于是大宴眾文武,在酒席間,曹操詩興大發(fā),不覺吟道:
“喝酒唱歌,人生真爽?!?/strong>”
眾文武齊呼:“丞相好詩!”于是一臣子速命印刷工匠刻版印刷,以便流傳天下。
圖2 印刷第一版
樣張出來給曹操一看,曹操感覺不妥,說道:
“喝與唱,此話過俗,應(yīng)改為 ‘對酒當(dāng)歌 ’ 較好!”
于是此臣就命工匠重新來過。
工匠眼看連夜刻版之工,徹底白費,心中叫苦不迭。只得照辦。
圖3 印刷第二版
樣張再次出來請曹操過目,曹操細(xì)細(xì)一品,覺得還是不好,說:
“人生真爽太過直接,應(yīng)改問句才夠意境,因此應(yīng)改為 ‘對酒當(dāng)歌,人生幾何? ’ ……”
當(dāng)臣轉(zhuǎn)告工匠之時,工匠暈倒……!
圖4 印刷第三版
由于三國時期活字印刷術(shù)尚未發(fā)明,所以要改字的時候,就必須整個刻版全部重新來刻。
如果當(dāng)時有了活字印刷術(shù),則只需要更改四個字即可,其余工作都未白做 。
圖5 活字印刷刻板
通過活字印刷刻板,可以帶給我們?nèi)缦聠l(fā):
這些字并非用完這次就無用,完全可以在后來的印刷中重復(fù)使用,此為 可復(fù)用 ; 字的排列其實可能是豎排也可能是橫排,此時只需將活字移動就可以做到滿足排列需求,此為 靈活性好 。 而在活字印刷術(shù)出現(xiàn)之前,上面的四種特征都無法滿足:
印完這本書后,此版已無任何復(fù)用價值。
傳統(tǒng)印刷術(shù)的問題:所有字都刻在同一版面上導(dǎo)致耦合度太高
在軟件開中有太多類似曹操這樣的客戶要改變需求,更改最初的想法。但客觀地說,客戶的要求并不過分,不就是改幾個字嗎,但面對已完成的程序代碼,卻是需要幾乎重頭來過的尷尬,痛苦不堪。
說白了,原因就是我們原先所寫的程序,不容易維護(hù) 、靈活性差 、不容易擴展 、更談不上復(fù)用 ,因此面對需求變化,加班加點,對程序動大手術(shù)的哪種無奈也就成了非常正常的事了。
所以在進(jìn)行軟件設(shè)計的時候就要考慮通過 封裝 、繼承 、多態(tài) 把程序的耦合度降低,要考慮使用一些 設(shè)計模式 使程序更加靈活,容易修改,并且易于復(fù)用。
面向?qū)ο笤O(shè)計原則 就為以上目標(biāo)而誕生,每一個原則都蘊含一些面向?qū)ο笤O(shè)計的思想,可以從不同的角度提升一個軟件結(jié)構(gòu)的設(shè)計水平。這些原則是從許多設(shè)計方案中總結(jié)出的指導(dǎo)性標(biāo)準(zhǔn)。也是我們用于評價一個設(shè)計模式的使用效果的重要指標(biāo)。
最常用的七種面向?qū)ο笤O(shè)計原則如下表所示:
表1 七種常用的面向?qū)ο笤O(shè)計原則
七大設(shè)計原則 1. 單一職責(zé)原則Sunny 軟件公司開發(fā)人員針對某 CRM 系統(tǒng)的客戶信息圖形統(tǒng)計模塊提出了如下圖所示初始設(shè)計方案:
圖6 初始設(shè)計方案結(jié)構(gòu)圖
在圖6中,CustomerDataChart
類承擔(dān)了太多的職責(zé):
包含與數(shù)據(jù)庫相關(guān)的方法; 包含與圖表創(chuàng)建和顯示相關(guān)的方法; 無論是修改數(shù)據(jù)庫連接方式還是修改圖表顯示方式都需要修改該類,它不止一個引起它變化的原因,違背了我們程序設(shè)計 單一職責(zé)原則 。
圖7 重構(gòu)后的結(jié)構(gòu)圖
將CustomerDataChart
拆分為如下三個類:
DBUtility
:負(fù)責(zé)連接數(shù)據(jù)庫,包括GetConnection()
;CustomerDAO
:負(fù)責(zé)操作數(shù)據(jù)庫中的Customer
表,包括對Customer
表的增、刪、改、查等方法,如FindCustomers()
;CustomerDataChart
:負(fù)責(zé)圖表的生成和顯示,包括CreateChart()
和DisplayChart()
。定義:單一職責(zé)原則 (Single Responsibility Principle,SRP)
一個類只負(fù)責(zé)一個功能領(lǐng)域中的相應(yīng)職責(zé)。即就一個類而言,應(yīng)該只有一個引起它變化的原因。
在軟件系統(tǒng)中,一個類(大到模塊,小到方法)承擔(dān)的職責(zé)越多,它 可復(fù)用性 就越小,而且一個類承擔(dān)的職責(zé)過多,就相當(dāng)于將這些職責(zé)耦合在一起,當(dāng)其中一個職責(zé)變化時,可能會影響其它職責(zé)的運作。
因此要將這些職責(zé)進(jìn)行分離:
不同的職責(zé)封裝在不同的類中,即將不同變化的原因封裝在不同的類中; 如果多個職責(zé)總是同時發(fā)生變化則可將它們封裝在同一個類中; 單一職責(zé)原則 是實現(xiàn)高內(nèi)聚、低耦合的指導(dǎo)方針,遵循該原則我們就可以避免類的粒度過大導(dǎo)致復(fù)用性降低的問題,設(shè)計出 可復(fù)用 、可擴展 、易維護(hù) 、靈活性好 的代碼。
2. 開閉原則Sunny 軟件公司開發(fā)的 CRM 系統(tǒng)可以顯示各種類型的圖表,如餅狀圖和柱狀圖等,為了支持多種圖表顯示方式,原始設(shè)計方案如下圖所示:
圖8 初始設(shè)計方案結(jié)構(gòu)圖
在ChartDisplay
類的display()
方法中存在如下代碼片段:
public void Display(string type ) { //... if (type.Equals("pie" )) { PieChart chart = new PieChart(); chart.Display(); } else if (type.Equals("bar" )) { BarChart chart = new BarChart(); chart.Display(); } //... }
在該代碼中,如果需要增加一個新的圖表類,如折線圖LineChart
,則需要修改ChartDisplay
類的Display()
方法的源碼,增加新的判斷邏輯,從而違反了 開閉原則 。
可以通過抽象化的方式對系統(tǒng)進(jìn)行重構(gòu) ,使之增加新的圖表類時無須修改源碼。具體做法如下:
增加一個抽象圖表類AbstractChart
,將各種具體圖表類作為其子類。 ChartDisplay
類針對抽象圖表類進(jìn)行編程,由客戶端來決定使用哪種具體圖表。重構(gòu)后結(jié)構(gòu)如下圖所示:
圖9 重構(gòu)后的結(jié)構(gòu)圖
在圖9中,我們引入了抽象圖表類AbstractChart
,且ChartDisplay
針對抽象圖表類進(jìn)行編程,并通過SetChart()
方法由客戶端來設(shè)置實例化的具體圖表對象,在ChartDisplay
的Display()
方法中調(diào)用chart
對象的Display()
方法顯示圖表。
如果需要增加一種新的圖表,如折線圖LineChart
,只需LineChart
也作為AbstractChart
的子類,在客戶端向ChartDisplay
中注入一個LineChart
對象即可,無須修改已經(jīng)完成的源碼。
注意:因為 XML 或 Properties 等格式的配置文件是純文本文件,可以直接通過記事本進(jìn)行編輯,且無須編譯,因此在軟件開發(fā)中,一般不把對配置文件的修改認(rèn)為是對系統(tǒng)源碼的修改。如果一個系統(tǒng)在擴展時只涉及到修改配置文件,而原有的代碼沒有做任何修改,該系統(tǒng)即可認(rèn)為是一個符合 開閉原則 的系統(tǒng)。
定義:開閉原則(Open-Closed Principle,OCP)
一個軟件實體應(yīng)當(dāng)對擴展開放,對修改關(guān)閉(Software entities should be open for extension, but closed for modification.),即軟件實體應(yīng)盡量在不修改原有代碼的情況下進(jìn)行擴展。
在 開閉原則 的定義中,軟件實體可以指一個軟件模塊、一個由多個類組成的局部結(jié)構(gòu)或一個獨立的類。
從微觀層面來看,在我們最初編寫代碼時,假設(shè)變化不會發(fā)生。當(dāng)變化發(fā)生時,我們就創(chuàng)建抽象來隔離以后發(fā)生的同類變化 。
比如,之前寫的加法程序,開始在一個Client
類中完成,此時變化還沒有發(fā)生。然后,增加一個減法功能,發(fā)現(xiàn),增加功能需要修改原來這個類,這就違背了“開閉原則 ”,于是就該考慮重構(gòu)程序,增加一個抽象的運算類,通過一些面向?qū)ο蟮氖侄危缋^承,多態(tài)等來隔離具體加法、減法與Client
的耦合,需求依然可以滿足,還能應(yīng)對變化。這時又要加入乘法、除法功能,就不需要再去更改Client
以及加法、減法的類了,而是增加乘法和除法子類就可。
即面對需求,對程序的改動是通過增加新代碼進(jìn)行的,而不是更改現(xiàn)有的代碼。
從宏觀層面來看,為了滿足 開閉原則 ,需要對系統(tǒng)進(jìn)行抽象化設(shè)計,可以為系統(tǒng)定義一個相對穩(wěn)定的抽象層,而將不同的實現(xiàn)行為移至具體的實現(xiàn)層中完成 。
在很多面向?qū)ο缶幊陶Z言中都提供了接口、抽象類等機制,可以通過它們定義系統(tǒng)的抽象層,再通過實體類來進(jìn)行擴展。如果需要修改系統(tǒng)的行為,無須對抽象層進(jìn)行任何改動,只需要增加新的實體類來實現(xiàn)新的業(yè)務(wù)功能即可,實現(xiàn)在不修改已有代碼的基礎(chǔ)上擴展系統(tǒng)的功能,達(dá)到 開閉原則 的要求。
可見,抽象化是 開閉原則 的關(guān)鍵 。我們在面對需求的變更時,利用 開閉原則 設(shè)計的系統(tǒng)能夠保持結(jié)構(gòu)的穩(wěn)定,不但方便系統(tǒng)的維護(hù),更有利于不斷升級程序的版本。
3. 里氏代換原則Sunny 軟件公司開發(fā)的 CRM 系統(tǒng)中,Customer 可以分為 VIPCustomer 和 CommonCustomer 兩類,系統(tǒng)需要提供一個發(fā)送 Email 的功能,原始設(shè)計方案如下圖所示:
圖10 原始結(jié)構(gòu)圖
在對系統(tǒng)進(jìn)一步分析后發(fā)現(xiàn),無論是 CommonCustomer 還是 VIPCustomer ,發(fā)送郵件的過程都是相同的,也就是說兩個send()
方法中的代碼重復(fù),而且在本系統(tǒng)中或許還將增加新類型的客戶。
為了讓系統(tǒng)具有更好的擴展性,同時減少代碼重復(fù) ,需要依據(jù) 里氏代換原則 進(jìn)行重構(gòu)。
在實例中,可以考慮增加一個新的抽象類Customer
,而將CommonCustomer
和VIPCustomer
類作為其子類,郵件發(fā)送類EmailSender
針對抽象客戶類Customer
編程,根據(jù) 里氏代換原則 ,能夠接受基類對象的地方必然能夠接受子類對象,因此將EmailSender
中的send()
方法的參數(shù)類型改為Customer
,如果需要增加新類型的客戶,只需要將其作為Customer
類的子類即可。
重構(gòu)后的結(jié)構(gòu)如下圖所示:
圖11 重構(gòu)后的結(jié)構(gòu)圖
里氏代換原則 由2008年圖靈獎得主、美國第一位計算機科學(xué)女博士 Barbara Liskov 教授和卡內(nèi)基.梅隆大學(xué) Jeannette Wing 教授于1994年提出。
圖12 Barbara Liskov
定義:里氏代換原則 (Liskov Substitution Principle,LSP)
如果對每一個類型為S的對象o1,都有類型為T的對象o2,使得以T定義的所有程序P在所有的對象o1替換o2時,程序P的行為沒有變化,那么類型S是類型T的子類型。
上面的定義比較拗口,因此我們一般使用它的另一個通俗版定義:
定義:里氏代換原則 (Liskov Substitution Principle,LSP)
所有引用基類(父類)的地方必須能透明地使用其子類的對象。
里氏代換原則 告訴我們,在軟件中將一個基類對象替換成它的子類對象,程序?qū)⒉粫a(chǎn)生任何錯誤和異常,反過來則不成立,如果一個軟件實體使用的是一個子類對象的話,那么它不一定能使用基類對象。
例如:有兩個類,一個類為BaseClass
,另一個類是SubClass
,并且SubClass
是BaseClass
的子類,那么一個方法如果可以接收一個BaseClass
類型的基類對象base
的話,如:Method(base)
,那么它必然可以接收一個BaseClass
類型的子類對象sub
,Method(sub)
能夠正常運行。
反過來的替換不成立,如果一個方法Method2
接收BaseClass
類型的子類對象sub
為參數(shù):Method2(sub)
,那么不可以有Method2(base)
。
里氏代換原則 是實現(xiàn) 開閉原則 的重要方式之一,由于使用基類對象的地方都可以使用子類對象,因此在程序中盡量使用基類類型來對對象進(jìn)行定義,而在運行時再確定其子類類型,用子類對象來替換基類對象,正是由于子類型的可替換性才使得使用基類型的模塊在無需修改的情況下就可以擴展。
在使用 里氏代換 原則時需要注意如下兩個問題:
【1】根據(jù) 里氏代換原則 ,為了保證系統(tǒng)的擴展性,在程序中通常使用基類來進(jìn)行定義,如果一個方法只存在子類中,在基類中不提供相應(yīng)的聲明,則無法在以基類定義的對象中使用該方法。
【2】在運用 里氏代換原則 時,盡量把基類設(shè)計為抽象類或者接口,讓子類繼承基類或?qū)崿F(xiàn)接口,并復(fù)寫或?qū)崿F(xiàn)在基類中聲明的方法,運行時用子類實例替換基類實例。
4. 依賴倒轉(zhuǎn)原則Sunny 軟件公司開發(fā)人員在開發(fā)某 CRM 系統(tǒng)時發(fā)現(xiàn):不同的使用者,轉(zhuǎn)換客戶信息到數(shù)據(jù)庫中的數(shù)據(jù)源是不同的,有的是 EXCEL 文件有的是 TXT 文件。
于是,開發(fā)人員準(zhǔn)備在客戶數(shù)據(jù)操作類CustomerDAO
中調(diào)用數(shù)據(jù)格式轉(zhuǎn)換類的方法實現(xiàn)格式轉(zhuǎn)換和數(shù)據(jù)插入操作,針對不同的使用者編譯不同的版本。
初始設(shè)計方案結(jié)構(gòu)如下圖所示:
圖13 初始設(shè)計方案結(jié)構(gòu)圖
在編碼實現(xiàn)圖13所示結(jié)構(gòu)時,開發(fā)人員發(fā)現(xiàn)該方案存在一個非常嚴(yán)重的問題,不但在TXTDataConvertor
與ExcelDataConvertor
相互轉(zhuǎn)換時,需要修改CustomerDAO
的源碼,而且在引入并使用新的數(shù)據(jù)轉(zhuǎn)換類時也不得不修改CustomerDAO
的源碼,系統(tǒng)擴展性較差,違反了 開閉原則 ,需要對該方案依照 依賴倒轉(zhuǎn)原則 進(jìn)行重構(gòu)。
在本實例中,由于CustomerDAO
針對具體數(shù)據(jù)轉(zhuǎn)換類編程,因此在增加新的數(shù)據(jù)轉(zhuǎn)換類或者更換數(shù)據(jù)轉(zhuǎn)換類時都不得不修改CustomerDAO
源代碼。
我們可以通過引入抽象數(shù)據(jù)轉(zhuǎn)換類解決該問題,在引入抽象數(shù)據(jù)轉(zhuǎn)換類DataConvertor
之后,CustomerDAO
針對抽象類DataConvertor
編程,而將具體數(shù)據(jù)轉(zhuǎn)換類名存儲在配置文件中,符合 依賴倒轉(zhuǎn)原則 。
根據(jù) 里氏代換原則 ,程序運行時,具體數(shù)據(jù)轉(zhuǎn)換類對象將替換DataConvertor
類型的對象,程序不會出現(xiàn)任何問題。更換具體數(shù)據(jù)轉(zhuǎn)換類時無須修改源碼,只需要修改配置文件。如果需要增加新的具體數(shù)據(jù)轉(zhuǎn)換類,只需將新增數(shù)據(jù)轉(zhuǎn)換類作為DataConvertor
的子類并修改配置文件即可,原有代碼無須做任何修改,滿足 開閉原則 。
重構(gòu)后的結(jié)構(gòu)如下圖所示:
圖14 重構(gòu)后的結(jié)構(gòu)圖
依賴倒轉(zhuǎn)原則 是 Robert C.Martin 在 1996年 為“C++ Reporter ”所寫的專欄 Engineering Notebook 的第三篇,后來加入到他 2002年 出版的經(jīng)典著作《Agile Software Development,Principle,Patterns,and Practices 》一書中。
圖15 Robert C.Martin:Object Mentor公司總裁
定義:依賴倒轉(zhuǎn)原則(Dependency Inversion Principle,DIP)
抽象不應(yīng)該依賴于細(xì)節(jié),細(xì)節(jié)應(yīng)該依賴于抽象。換言之,要針對接口編程,而不是針對實現(xiàn)編程。
依賴倒轉(zhuǎn)原則 要求我們在程序代碼中傳遞參數(shù)時或在關(guān)聯(lián)關(guān)系中,盡量引用層次高的抽象層類,即使用接口和抽象類進(jìn)行變量類型聲明、參數(shù)類型聲明、方法返回類型聲明,以及數(shù)據(jù)類型的轉(zhuǎn)換等 ,而不要用具體類來做這些事情。
注意:為了確保該原則的應(yīng)用,一個具體類應(yīng)當(dāng)只實現(xiàn)接口或抽象類中聲明過的方法,而不要給出多余的方法,否則將無法調(diào)用到在子類中增加的新方法。
在程序中盡量使用抽象層進(jìn)行編程,而將具體類寫在配置文件中,這樣一來,如果系統(tǒng)行為發(fā)生變化,只需要對抽象層進(jìn)行擴展,寫相應(yīng)的實體類,并修改配置文件,而無須修改原有系統(tǒng)的源碼,在不修改的情況下來擴展系統(tǒng)的功能,滿足 開閉原則 的要求。
在實現(xiàn) 依賴倒轉(zhuǎn)原則 時,我們需要針對抽象層編程,而將具體類的對象通過依賴注入的方式注入到其它對象中,依賴注入是指當(dāng)一個對象要與其它對象發(fā)生依賴關(guān)系時,通過抽象來注入所依賴的對象 。
常用的注入方式有三種,分別是:
構(gòu)造注入 -- 通過構(gòu)造函數(shù)來傳入實體類的對象; Setter注入 -- 通過Setter方法來傳入實體類的對象; 接口注入 -- 通過在接口中聲明業(yè)務(wù)方法來傳入實體類的對象; 這些方法在定義時使用的是抽象類型,在運行時再傳入具體類型的對象,由子類對象來復(fù)寫或?qū)崿F(xiàn)父類對象。
在大多數(shù)情況下,開閉原則 、里氏代換原則 和 依賴倒轉(zhuǎn)原則 會同時出現(xiàn):
它們相輔相成,相互補充,目標(biāo)一致,只是分析問題時所站角度不同而已。
5. 接口隔離原則Sunny 軟件公司開發(fā)人員針對某 CRM 系統(tǒng)的客戶數(shù)據(jù)顯示模塊 設(shè)計了如下圖所示接口。
圖16 初始設(shè)計方案結(jié)構(gòu)圖
在實際使用過程中發(fā)現(xiàn)該接口很不靈活,例如:
【1】如果一個具體的數(shù)據(jù)顯示類無須進(jìn)行數(shù)據(jù)轉(zhuǎn)換(源文件本身就是XML
格式),但由于需要實現(xiàn)該接口,將不得不實現(xiàn)其中聲明的TransformToXml()
(至少需要提供一個空實現(xiàn))。
【2】如果僅需創(chuàng)建和顯示圖表,除了實現(xiàn)與圖表相關(guān)的方法外,還需要實現(xiàn)其中創(chuàng)建和顯示文字報表的方法,否則編譯時將報錯。
由于在接口ICustomerDataDisplay
中定義了太多方法,即該接口承擔(dān)了太多職責(zé)。
一方面導(dǎo)致該接口的實現(xiàn)類很龐大,在不同的實現(xiàn)類中都不得不實現(xiàn)接口中定義的所有方法,靈活性較差,如果出現(xiàn)大量的空方法,將導(dǎo)致系統(tǒng)中產(chǎn)生大量的無用代碼,影響代碼質(zhì)量。 另一方面由于客戶端針對大接口編程,將在一定程度上破壞程序的封裝性,客戶端看到了不該看到的方法,沒有為客戶端定制接口。 需要將該接口按照 單一職責(zé)原則 、接口隔離原則 進(jìn)行重構(gòu),將其中的一些方法封裝在不同的小接口中,確保每一個接口使用起來都較為方便,并都承擔(dān)某一單一的職責(zé)。
圖17 重構(gòu)后的結(jié)構(gòu)圖
定義:接口隔離原則(Interface Segregation Principle,ISP)
使用多個專門的接口,而不使用單一的總接口,即客戶端不應(yīng)該依賴哪些它不需要的接口。
在面向?qū)ο缶幊陶Z言中,實現(xiàn)一個接口就需要實現(xiàn)該接口中定義的所有方法,因此大的總接口使用起來不一定很方便,為了使接口的職責(zé)單一,需要將大接口中的方法根據(jù)其職責(zé)不同分別放在不同的小接口中,以確保每個接口使用起來都較為方便,并都承擔(dān)某一單一的職責(zé) 。
每個接口提供的功能盡可能單一,每個接口中只包含一個客戶端(如子模塊或業(yè)務(wù)邏輯類)所需要的方法即可,不應(yīng)該強迫客戶依賴于哪些他們不用的方法,這種機制也稱為“定制服務(wù) ”,即為不同的客戶端提供寬窄不同的接口,從而方便的為第三方開發(fā)者按需定制方案。
注意:在使用 接口隔離原則 時,我們需要控制接口的粒度:
接口太小,會導(dǎo)致系統(tǒng)中接口泛濫,不利于維護(hù); 接口太大,將違背 接口隔離原則 ,靈活性較差,使用不便; 6. 合成復(fù)用原則Sunny 軟件公司開發(fā)人員在初期的 CRM 系統(tǒng)設(shè)計中,考慮到客戶數(shù)量不多,系統(tǒng)采用 Access 作為數(shù)據(jù)庫。連接數(shù)據(jù)庫的方法GetConnection()
封裝在DBUtil
類中,與數(shù)據(jù)庫操作有關(guān)的類如CustomerDAO
等都需要用DBUtil
類的GetConnection()
方法。
于是,設(shè)計人員將CustomerDAO
作為DBUtil
類的子類,初始設(shè)計方案結(jié)構(gòu)如下圖所示:
圖18 初始設(shè)計方案結(jié)構(gòu)圖
隨著客戶數(shù)量的增加,需要把數(shù)據(jù)庫升級為 SQL Server ,因此需要增加一個新的SQLDBUtil
類來連接 SQL Server 數(shù)據(jù)庫,由于在初始設(shè)計方案中CustomerDAO
和DBUtil
之間是繼承關(guān)系,因此在更換數(shù)據(jù)庫連接方式時需要修改CustomerDAO
類或者DBUtil
類的源碼,這將違反 開閉原則 。需要依據(jù) 合成復(fù)用原則 對其進(jìn)行重構(gòu)。
本案例中我們可以使用 關(guān)聯(lián)復(fù)用 來取代 繼承復(fù)用 ,重構(gòu)后的結(jié)構(gòu)如下圖所示:
圖19 重構(gòu)后的結(jié)構(gòu)圖
在圖19中,CustomerDAO
和DBUtil
之間的關(guān)系由繼承關(guān)系變?yōu)殛P(guān)聯(lián)關(guān)系,采用依賴注入的方式將DBUtil
對象注入到CustomerDAO
中,可以使用構(gòu)造注入,也可以使用Setter
注入。
如果需要對DBUtil
進(jìn)行擴展,可以通過其子類來實現(xiàn),如通過子類SQLDBUtil
來連接 SQL Server 數(shù)據(jù)庫。由于CustomerDAO
針對DBUtil
編程,根據(jù) 里氏代換原則 ,DBUtil
子類的對象可以覆蓋DBUtil
對象,只需在CustomerDAO
中注入子類對象即可使用子類所擴展的方法。
定義:合成復(fù)用原則(Composite/Aggregate Reuse Principle,CARP)又稱為組合/聚合復(fù)用原則。盡量使用對象組合/聚合,而不是繼承來達(dá)到復(fù)用的目的。
在面向?qū)ο笤O(shè)計中,可以通過 繼承關(guān)系 或 關(guān)聯(lián)關(guān)系 在不同環(huán)境中復(fù)用已有的設(shè)計和實現(xiàn)。
首先應(yīng)該考慮使用關(guān)聯(lián)關(guān)系; 其次才考慮繼承關(guān)系,在使用繼承時,需要嚴(yán)格遵守 里氏代換原則 ; 通過繼承來進(jìn)行復(fù)用的主要問題在于繼承復(fù)用會破壞系統(tǒng)的封裝性,因為繼承會將基類的大部分實現(xiàn)細(xì)節(jié)暴露給子類,所以這種復(fù)用又稱為“白箱復(fù)用 ”。如果基類發(fā)生改變,那么子類的實現(xiàn)也不得不發(fā)生改變。而且從基類繼承下來的實現(xiàn)是靜態(tài)的,不可能在運行時發(fā)生改變,沒有足夠的靈活性 。
由于關(guān)聯(lián)關(guān)系可以將已有對象(也稱為成員對象)納入到新對象中,使之成為新對象的一部分,因此新對象可以調(diào)用成員對象的功能,這樣做可以使成員對象的內(nèi)部實現(xiàn)細(xì)節(jié)對于新對象不可見,所以這種復(fù)用又稱為“黑箱復(fù)用 ”。相對繼承關(guān)系而言,其耦合度相對較低,成員對象的變化對新對象的影響不大,可以在新對象中根據(jù)實際需要有選擇性地調(diào)用成員對象的操作。而且合成復(fù)用可以在運行時動態(tài)進(jìn)行,新對象可以動態(tài)地引用與成員對象類型相同的其它對象 。
一般而言,如果兩個類之間是“Has-A”的關(guān)系應(yīng)使用關(guān)聯(lián)關(guān)系,如果是“Is-A”關(guān)系才使用繼承關(guān)系。
“Is-A”是嚴(yán)格的分類學(xué)意義上的定義,意思是一個類是另一個類的“一種”; “Has-A”則不同,它表示某一個角色具有某一項責(zé)任; 7. 迪米特法則Sunny 軟件公司所開發(fā)的 CRM 系統(tǒng)包含很多業(yè)務(wù)操作窗口,在這些窗口中,某些界面控件之間存在復(fù)雜的交互關(guān)系,一個控件事件的觸發(fā)將導(dǎo)致多個其它界面控件響應(yīng),例如,當(dāng)一個 Button 被單擊時,對應(yīng)的 List 、ComboBox 、TextBox 、Label 等都將發(fā)生改變,在初始設(shè)計方案中,界面控件之間的交互關(guān)系可簡化為如下圖所示結(jié)構(gòu):
圖20 初始設(shè)計方案結(jié)構(gòu)圖
在圖20中,每一個控件都與多個其它控件相互關(guān)聯(lián)和調(diào)用,若一個界面控件對象發(fā)生變化,需要跟蹤與之關(guān)聯(lián)的其它所有控件并進(jìn)行處理,控件之間呈現(xiàn)一種較為復(fù)雜的 網(wǎng)狀結(jié)構(gòu) ,控件之間耦合度太高,系統(tǒng)擴展性較差。
在本案例中,可以引入一個專門用于控制界面交互的中間類Mediator
來降低界面控件之間的耦合。引入中間類之后,界面控件之間不再直接發(fā)生引用,而是將請求先轉(zhuǎn)發(fā)給中間類,再由中間類來完成對其它控件的調(diào)用,需要依據(jù) 迪米特法則 進(jìn)行重構(gòu)。
重構(gòu)后的結(jié)構(gòu)如下圖所示:
圖21 重構(gòu)后的結(jié)構(gòu)圖
通過引入一個合理的第三者Mediator
來降低現(xiàn)有對象之間的耦合度。
定義:迪米特法則(Law of Demeter,LoD)又稱為最小知識原則(Least Knowledge Principle,LKP)
一個軟件實體應(yīng)當(dāng)盡可能少地與其它實體發(fā)生相互作用。
迪米特法則 來自于 1987年 美國東北大學(xué)(Northeastern University )一個名為 “Demeter ” 的研究項目。該法則限制了軟件實體之間通信的寬度和深度,可降低系統(tǒng)的耦合度,使類與類之間保持松散的耦合關(guān)系 。如果一個系統(tǒng)符合 迪米特法則 ,那么當(dāng)其中一個模塊發(fā)生修改時,可以盡量少地影響其它模塊,擴展相對容易。
迪米特法則 還有幾種定義形式,包括:不要和“陌生人”說話,只與你的直接朋友通信等,在 迪米特法則 中,對于一個對象,其朋友包括以下幾類:
以參數(shù)形式傳入到當(dāng)前對象方法中的對象; 如果當(dāng)前對象的成員對象是一個集合,那么集合中的元素也都是朋友; 當(dāng)前對象所創(chuàng)建的對象; 任何一個對象,如果滿足上面的條件之一,就是當(dāng)前對象的“朋友”,否則就是“陌生人”(如:局部變量)。
在應(yīng)用 迪米特法則 時,一個對象只能與直接朋友發(fā)生交互,不要與“陌生人”發(fā)生直接交互,這樣做可以降低系統(tǒng)的耦合度,一個對象的改變不會給太多其它對象帶來影響。
補充:
首先來解釋編程中的朋友:兩個對象之間的耦合關(guān)系稱之為朋友,以成員變量,方法的參數(shù)和返回值的形式出現(xiàn)。
那么為什么說是要與直接朋友通信呢?觀察直接朋友出現(xiàn)的地方,我們發(fā)現(xiàn)在直接朋友出現(xiàn)的地方,大部分情況下可以接口或者父類來代替,可以增加靈活性。
在將 迪米特法則 運用到系統(tǒng)設(shè)計時,要注意以下幾點:
在類的劃分上:應(yīng)當(dāng)盡量創(chuàng)建松耦合類,類之間的耦合度越低,就越有利于復(fù)用,一個處在松耦合中的類一旦被修改,不會對關(guān)聯(lián)的類造成太大波及; 在類結(jié)構(gòu)設(shè)計上:要盡量降低每個類成員的訪問權(quán)限,也就是說,一個類包裝好自己的private
狀態(tài),不需要讓別的類知道的字段或行為就不要公開; 在對其它類的引用上:一個對象對其它對象的引用應(yīng)當(dāng)降到最低;