在軟件開發中,爲了提高軟件系統的可維護性和可複用性,增加軟件的可擴展性和靈活性,程序員要儘量根據 7 條原則來開發程序,從而提高軟件開發效率、節約軟件開發成本和維護成本。
一、開閉原則
1、定義
開閉原則(Open Closed Principle,OCP)由勃蘭特·梅耶(Bertrand Meyer)提出,他在 1988 年的著作《面向對象軟件構造》(Object Oriented Software Construction)中提出:軟件實體應當對擴展開放,對修改關閉(Software entities should be open for extension,but closed for modification),這就是開閉原則的經典定義。
這裏的軟件實體包括以下幾個部分:
①項目中劃分出的模塊
②類與接口
③方法
開閉原則的含義是:當應用的需求改變時,在不修改軟件實體的源代碼或者二進制代碼的前提下,可以擴展模塊的功能,使其滿足新的需求。
2、開閉原則的作用
開閉原則是面向對象程序設計的終極目標,它使軟件實體擁有一定的適應性和靈活性的同時具備穩定性和延續性。具體來說,其作用如下:
①對軟件測試的影響
軟件遵守開閉原則的話,軟件測試時只需要對擴展的代碼進行測試就可以了,因爲原有的測試代碼仍然能夠正常運行。
②可以提高代碼的可複用性
粒度越小,被複用的可能性就越大;在面向對象的程序設計中,根據原子和抽象編程可以提高代碼的可複用性。
③可以提高軟件的可維護性
遵守開閉原則的軟件,其穩定性高和延續性強,從而易於擴展和維護。
3、開閉原則的實現方法
可以通過“抽象約束、封裝變化”來實現開閉原則,即通過接口或者抽象類爲軟件實體定義一個相對穩定的抽象層,而將相同的可變因素封裝在相同的具體實現類中。
因爲抽象靈活性好,適應性廣,只要抽象的合理,可以基本保持軟件架構的穩定。而軟件中易變的細節可以從抽象派生來的實現類來進行擴展,當軟件需要發生變化時,只需要根據需求重新派生一個實現類來擴展就可以了。
下面以 Windows 的桌面主題爲例介紹開閉原則的應用。
分析:Windows 的主題是桌面背景圖片、窗口顏色和聲音等元素的組合。用戶可以根據自己的喜愛更換自己的桌面主題,也可以從網上下載新的主題。這些主題有共同的特點,可以爲其定義一個抽象類(Abstract Subject),而每個具體的主題(Specific Subject)是其子類。用戶窗體可以根據需要選擇或者增加新的主題,而不需要修改原代碼,所以它是滿足開閉原則的,其類圖如下圖所示:
二、里氏替換原則
1、定義
里氏替換原則(Liskov Substitution Principle,LSP)由麻省理工學院計算機科學實驗室的里斯科夫(Liskov)女士在 1987 年的“面向對象技術的高峯會議”(OOPSLA)上發表的一篇文章《數據抽象和層次》(Data Abstraction and Hierarchy)裏提出來的,她提出:繼承必須確保超類所擁有的性質在子類中仍然成立(Inheritance should ensure that any property proved about supertype objects also holds for subtype objects)。
里氏替換原則主要闡述了有關繼承的一些原則,也就是什麼時候應該使用繼承,什麼時候不應該使用繼承,以及其中蘊含的原理。里氏替換原則是繼承複用的基礎,它反映了基類與子類之間的關係,是對開閉原則的補充,是對實現抽象化的具體步驟的規範。
2、里氏替換原則的作用
里氏替換原則的主要作用如下:
①里氏替換原則是實現開閉原則的重要方式之一
②它克服了繼承中重寫父類造成的可複用性變差的缺點
③它是動作正確性的保證。即類的擴展不會給已有的系統引入新的錯誤,降低了代碼出錯的可能性
3、里氏替換原則的實現方法
里氏替換原則通俗來講就是:子類可以擴展父類的功能,但不能改變父類原有的功能。也就是說:子類繼承父類時,除添加新的方法完成新增功能外,儘量不要重寫父類的方法。
如果通過重寫父類的方法來完成新的功能,這樣寫起來雖然簡單,但是整個繼承體系的可複用性會比較差,特別是運用多態比較頻繁時,程序運行出錯的概率會非常大。
如果程序違背了里氏替換原則,則繼承類的對象在基類出現的地方會出現運行錯誤。這時其修正方法是:取消原來的繼承關係,重新設計它們之間的關係。
關於里氏替換原則的例子,最有名的是“正方形不是長方形”。當然,生活中也有很多類似的例子,例如,企鵝、鴕鳥和幾維鳥從生物學的角度來劃分,它們屬於鳥類;但從類的繼承關係來看,由於它們不能繼承“鳥”會飛的功能,所以它們不能定義成“鳥”的子類。同樣,由於“氣球魚”不會游泳,所以不能定義成“魚”的子類;“玩具炮”炸不了敵人,所以不能定義成“炮”的子類等。
下面以“幾維鳥不是鳥”爲例來說明裏氏替換原則。
分析:鳥一般都會飛行,如燕子的飛行速度大概是每小時 120 千米。但是新西蘭的幾維鳥由於翅膀退化無法飛行。假如要設計一個實例,計算這兩種鳥飛行 300 千米要花費的時間。顯然,拿燕子來測試這段代碼,結果正確,能計算出所需要的時間;但拿幾維鳥來測試,結果會發生“除零異常”或是“無窮大”,明顯不符合預期,其類圖如下所示:
程序代碼如下:
public class LSPtest {
public static void main(String[] args) {
Bird bird1=new Swallow();
Bird bird2=new BrownKiwi();
bird1.setSpeed(120);
bird2.setSpeed(120);
System.out.println("如果飛行300公里:");
try {
System.out.println("燕子將飛行"+bird1.getFlyTime(300)+"小時.");
System.out.println("幾維鳥將飛行"+bird2.getFlyTime(300)+"小時.");
} catch(Exception err) {
System.out.println("發生錯誤了!");
}
}
}
//鳥類
class Bird {
double flySpeed;
public void setSpeed(double speed) {
flySpeed=speed;
}
public double getFlyTime(double distance) {
return(distance/flySpeed);
}
}
//燕子類
class Swallow extends Bird{
}
//幾維鳥類
class BrownKiwi extends Bird {
public void setSpeed(double speed) {
flySpeed = 0;
}
}
程序的運行結果如下:
如果飛行300公里:
燕子將飛行2.5小時.
幾維鳥將飛行Infinity小時.
程序運行錯誤的原因是:幾維鳥類重寫了鳥類的 setSpeed(double speed) 方法,這違背了里氏替換原則。正確的做法是:取消幾維鳥原來的繼承關係,定義鳥和幾維鳥的更一般的父類,如動物類,它們都有奔跑的能力。幾維鳥的飛行速度雖然爲 0,但奔跑速度不爲 0,可以計算出其奔跑 300 千米所要花費的時間。其類圖如下:
三、依賴倒置原則
1、定義
依賴倒置原則(Dependence Inversion Principle,DIP)是 Object Mentor 公司總裁羅伯特·馬丁(Robert C.Martin)於 1996 年在 C++ Report 上發表的文章。
依賴倒置原則的原始定義爲:高層模塊不應該依賴低層模塊,兩者都應該依賴其抽象;抽象不應該依賴細節,細節應該依賴抽象(High level modules shouldnot depend upon low level modules.Both should depend upon abstractions.Abstractions should not depend upon details. Details should depend upon abstractions)。其核心思想是:要面向接口編程,不要面向實現編程。
依賴倒置原則是實現開閉原則的重要途徑之一,它降低了客戶與實現模塊之間的耦合。
由於在軟件設計中,細節具有多變性,而抽象層則相對穩定,因此以抽象爲基礎搭建起來的架構要比以細節爲基礎搭建起來的架構穩定得多。這裏的抽象指的是接口或者抽象類,而細節是指具體的實現類。
使用接口或者抽象類的目的是制定好規範和契約,而不去涉及任何具體的操作,把展現細節的任務交給它們的實現類去完成。
2、依賴倒置原則的作用
依賴倒置原則的主要作用如下:
①依賴倒置原則可以降低類間的耦合性
②依賴倒置原則可以提高系統的穩定性
③依賴倒置原則可以減少並行開發引起的風險
④依賴倒置原則可以提高代碼的可讀性和可維護性
3、依賴倒置原則的實現方法
依賴倒置原則的目的是通過要面向接口的編程來降低類間的耦合性,所以我們在實際編程中只要遵循以下4點,就能在項目中滿足這個規則:
①每個類儘量提供接口或抽象類,或者兩者都具備
②變量的聲明類型儘量是接口或者是抽象類
③任何類都不應該從具體類派生
④使用繼承時儘量遵循里氏替換原則
下面以“顧客購物程序”爲例來說明依賴倒置原則的應用。
分析:本程序反映了 “顧客類”與“商店類”的關係。商店類中有 sell() 方法,顧客類通過該方法購物。以下代碼定義了顧客類通過韶關網店 ShaoguanShop 購物:
class Customer{
public void shopping(ShaoguanShop shop){
// 購物
System.out.println(shop.sell());
}
}
但是,這種設計存在缺點,如果該顧客想從另外一家商店(如婺源網店 WuyuanShop)購物,就要將該顧客的代碼修改如下:
class Customer {
public void shopping(WuyuanShop shop) {
// 購物
System.out.println(shop.sell());
}
}
顧客每更換一家商店,都要修改一次代碼,這明顯違背了開閉原則。存在以上缺點的原因是:顧客類設計時同具體的商店類綁定了,這違背了依賴倒置原則。解決方法是:定義“婺源網店”和“韶關網店”的共同接口 Shop,顧客類面向該接口編程,其代碼修改如下:
class Customer {
public void shopping(Shop shop) {
// 購物
System.out.println(shop.sell());
}
}
這樣,不管顧客類 Customer 訪問什麼商店,或者增加新的商店,都不需要修改原有代碼了,其類圖如下:
程序代碼如下:
public class DIPtest{
public static void main(String[] args) {
Customer wang=new Customer();
System.out.println("顧客購買以下商品:");
wang.shopping(new ShaoguanShop());
wang.shopping(new WuyuanShop());
}
}
//商店
interface Shop {
public String sell(); //賣
}
//韶關網店
class ShaoguanShop implements Shop{
public String sell() {
return "韶關土特產:香菇、木耳……";
}
}
//婺源網店
class WuyuanShop implements Shop {
public String sell() {
return "婺源土特產:綠茶、酒糟魚……";
}
}
//顧客
class Customer {
public void shopping(Shop shop) {
// 購物
System.out.println(shop.sell());
}
}
運行結果如下:
顧客購買以下商品:
韶關土特產:香菇、木耳……
婺源土特產:綠茶、酒糟魚……
四、單一職責原則
1、定義
單一職責原則(Single Responsibility Principle,SRP)又稱單一功能原則,由羅伯特·C.馬丁(Robert C. Martin)於《敏捷軟件開發:原則、模式和實踐》一書中提出的。這裏的職責是指類變化的原因,單一職責原則規定一個類應該有且僅有一個引起它變化的原因,否則類應該被拆分(There should never be more than one reason for a class to change)。
該原則提出對象不應該承擔太多職責,如果一個對象承擔了太多的職責,至少存在以下兩個缺點:
①一個職責的變化可能會削弱或者抑制這個類實現其他職責的能力;
②當客戶端需要該對象的某一個職責時,不得不將其他不需要的職責全都包含進來,從而造成冗餘代碼或代碼的浪費;
2、單一職責的優點
單一職責原則的核心就是控制類的粒度大小、將對象解耦、提高其內聚性。如果遵循單一職責原則將有以下優點:
①降低類的複雜度。一個類只負責一項職責,其邏輯肯定要比負責多項職責簡單得多;
②提高類的可讀性。複雜性降低,自然其可讀性會提高;
③提高系統的可維護性。可讀性提高,那自然更容易維護了;
④變更引起的風險降低。變更是必然的,如果單一職責原則遵守得好,當修改一個功能時,可以顯著降低對其他功能的影響;
3、單一職責原則的實現方法
單一職責原則是最簡單但又最難運用的原則,需要設計人員發現類的不同職責並將其分離,再封裝到不同的類或模塊中。而發現類的多重職責需要設計人員具有較強的分析設計能力和相關重構經驗。下面以大學學生工作管理程序爲例介紹單一職責原則的應用。
分析:大學學生工作主要包括學生生活輔導和學生學業指導兩個方面的工作,其中生活輔導主要包括班委建設、出勤統計、心理輔導、費用催繳、班級管理等工作,學業指導主要包括專業引導、學習輔導、科研指導、學習總結等工作。如果將這些工作交給一位老師負責顯然不合理,正確的做法是生活輔導由輔導員負責,學業指導由學業導師負責,其類圖如下圖所示:
注意:單一職責同樣也適用於方法。一個方法應該儘可能做好一件事情。如果一個方法處理的事情太多,其顆粒度會變得很粗,不利於重用。
五、接口隔離原則
1、定義
接口隔離原則(Interface Segregation Principle,ISP)要求程序員儘量將臃腫龐大的接口拆分成更小的和更具體的接口,讓接口中只包含客戶感興趣的方法。
2002 年羅伯特·C.馬丁給“接口隔離原則”的定義是:客戶端不應該被迫依賴於它不使用的方法(Clients should not be forced to depend on methods they do not use)。該原則還有另外一個定義:一個類對另一個類的依賴應該建立在最小的接口上(The dependency of one class to another one should depend on the smallest possible interface)。
以上兩個定義的含義是:要爲各個類建立它們需要的專用接口,而不要試圖去建立一個很龐大的接口供所有依賴它的類去調用。
接口隔離原則和單一職責都是爲了提高類的內聚性、降低它們之間的耦合性,體現了封裝的思想,但兩者是不同的:
單一職責原則注重的是職責,而接口隔離原則注重的是對接口依賴的隔離;
單一職責原則主要是約束類,它針對的是程序中的實現和細節;接口隔離原則主要約束接口,主要針對抽象和程序整體框架的構建;
2、接口隔離原則的優點
接口隔離原則是爲了約束接口、降低類對接口的依賴性,遵循接口隔離原則有以下 5 個優點:
①將臃腫龐大的接口分解爲多個粒度小的接口,可以預防外來變更的擴散,提高系統的靈活性和可維護性;
②接口隔離提高了系統的內聚性,減少了對外交互,降低了系統的耦合性;
③如果接口的粒度大小定義合理,能夠保證系統的穩定性;
但是,如果定義過小,則會造成接口數量過多,使設計複雜化;如果定義太大,靈活性降低,無法提供定製服務,給整體項目帶來無法預料的風險。
④使用多個專門的接口還能夠體現對象的層次,因爲可以通過接口的繼承,實現對總接口的定義;
⑤能減少項目工程中的代碼冗餘。過大的大接口裏面通常放置許多不用的方法,當實現這個接口的時候,被迫設計冗餘的代碼;
3、接口隔離原則的實現方法
在具體應用接口隔離原則時,應該根據以下幾個規則來衡量。
①接口儘量小,但是要有限度。一個接口只服務於一個子模塊或業務邏輯;
②爲依賴接口的類定製服務。只提供調用者需要的方法,屏蔽不需要的方法;
③瞭解環境,拒絕盲從。每個項目或產品都有選定的環境因素,環境不同,接口拆分的標準就不同,要深入瞭解業務邏輯;
④提高內聚,減少對外交互。使接口用最少的方法去完成最多的事情;
下面以學生成績管理程序爲例介紹接口隔離原則的應用。
分析:學生成績管理程序一般包含插入成績、刪除成績、修改成績、計算總分、計算均分、打印成績信息、査詢成績信息等功能,如果將這些功能全部放到一個接口中顯然不太合理,正確的做法是將它們分別放在輸入模塊、統計模塊和打印模塊等 3 個模塊中,其類圖如下:
程序代碼如下:
public class ISPtest{
public static void main(String[] args) {
InputModule input =StuScoreList.getInputModule();
CountModule count =StuScoreList.getCountModule();
PrintModule print =StuScoreList.getPrintModule();
input.insert();
count.countTotalScore();
print.printStuInfo();
//print.delete();
}
}
//輸入模塊接口
interface InputModule {
void insert();
void delete();
void modify();
}
//統計模塊接口
interface CountModule {
void countTotalScore();
void countAverage();
}
//打印模塊接口
interface PrintModule {
void printStuInfo();
void queryStuInfo();
}
//實現類
class StuScoreList implements InputModule,CountModule,PrintModule {
private StuScoreList(){}
public static InputModule getInputModule() {
return (InputModule)new StuScoreList();
}
public static CountModule getCountModule() {
return (CountModule)new StuScoreList();
}
public static PrintModule getPrintModule() {
return (PrintModule)new StuScoreList();
}
public void insert() {
System.out.println("輸入模塊的insert()方法被調用!");
}
public void delete() {
System.out.println("輸入模塊的delete()方法被調用!");
}
public void modify()
{
System.out.println("輸入模塊的modify()方法被調用!");
}
public void countTotalScore() {
System.out.println("統計模塊的countTotalScore()方法被調用!");
}
public void countAverage() {
System.out.println("統計模塊的countAverage()方法被調用!");
}
public void printStuInfo() {
System.out.println("打印模塊的printStuInfo()方法被調用!");
}
public void queryStuInfo() {
System.out.println("打印模塊的queryStuInfo()方法被調用!");
}
}
運行結果如下:
輸入模塊的insert()方法被調用!
統計模塊的countTotalScore()方法被調用!
打印模塊的printStuInfo()方法被調用!
六、迪米特法則
1、定義
迪米特法則(Law of Demeter,LoD)又叫作最少知識原則(Least Knowledge Principle,LKP),產生於 1987 年美國東北大學(Northeastern University)的一個名爲迪米特(Demeter)的研究項目,由伊恩·荷蘭(Ian Holland)提出,被 UML 創始者之一的布奇(Booch)普及,後來又因爲在經典著作《程序員修煉之道》(The Pragmatic Programmer)提及而廣爲人知。
迪米特法則的定義是:只與你的直接朋友交談,不跟“陌生人”說話(Talk only to your immediate friends and not to strangers)。其含義是:如果兩個軟件實體無須直接通信,那麼就不應當發生直接的相互調用,可以通過第三方轉發該調用。其目的是降低類之間的耦合度,提高模塊的相對獨立性。
迪米特法則中的“朋友”是指:當前對象本身、當前對象的成員對象、當前對象所創建的對象、當前對象的方法參數等,這些對象同當前對象存在關聯、聚合或組合關係,可以直接訪問這些對象的方法。
2、迪米特法則的優點
迪米特法則要求限制軟件實體之間通信的寬度和深度,正確使用迪米特法則將有以下兩個優點。
①降低了類之間的耦合度,提高了模塊的相對獨立性;
②由於親合度降低,從而提高了類的可複用率和系統的擴展性;
但是,過度使用迪米特法則會使系統產生大量的中介類,從而增加系統的複雜性,使模塊之間的通信效率降低。所以,在釆用迪米特法則時需要反覆權衡,確保高內聚和低耦合的同時,保證系統的結構清晰。
3、迪米特法則的實現方法
從迪米特法則的定義和特點可知,它強調以下兩點:
①從依賴者的角度來說,只依賴應該依賴的對象;
②從被依賴者的角度說,只暴露應該暴露的方法;
所以,在運用迪米特法則時要注意以下 6 點:
①在類的劃分上,應該創建弱耦合的類。類與類之間的耦合越弱,就越有利於實現可複用的目標;
②在類的結構設計上,儘量降低類成員的訪問權限;
③在類的設計上,優先考慮將一個類設置成不變類;
④在對其他類的引用上,將引用其他對象的次數降到最低;
⑤不暴露類的屬性成員,而應該提供相應的訪問器(set 和 get 方法);
⑥謹慎使用序列化(Serializable)功能;
明星與經紀人的關係實例
分析:明星由於全身心投入藝術,所以許多日常事務由經紀人負責處理,如與粉絲的見面會,與媒體公司的業務洽淡等。這裏的經紀人是明星的朋友,而粉絲和媒體公司是陌生人,所以適合使用迪米特法則,其類圖如下:
程序代碼如下:
public class LoDtest
{
public static void main(String[] args)
{
Agent agent=new Agent();
agent.setStar(new Star("林心如"));
agent.setFans(new Fans("粉絲韓丞"));
agent.setCompany(new Company("中國傳媒有限公司"));
agent.meeting();
agent.business();
}
}
//經紀人
class Agent
{
private Star myStar;
private Fans myFans;
private Company myCompany;
public void setStar(Star myStar)
{
this.myStar=myStar;
}
public void setFans(Fans myFans)
{
this.myFans=myFans;
}
public void setCompany(Company myCompany)
{
this.myCompany=myCompany;
}
public void meeting()
{
System.out.println(myFans.getName()+"與明星"+myStar.getName()+"見面了。");
}
public void business()
{
System.out.println(myCompany.getName()+"與明星"+myStar.getName()+"洽淡業務。");
}
}
//明星
class Star
{
private String name;
Star(String name)
{
this.name=name;
}
public String getName()
{
return name;
}
}
//粉絲
class Fans
{
private String name;
Fans(String name)
{
this.name=name;
}
public String getName()
{
return name;
}
}
//媒體公司
class Company
{
private String name;
Company(String name)
{
this.name=name;
}
public String getName()
{
return name;
}
}
運行結果如下:
粉絲韓丞與明星林心如見面了。
中國傳媒有限公司與明星林心如洽淡業務。
七、合成複用原則
1、定義
合成複用原則(Composite Reuse Principle,CRP)又叫組合/聚合複用原則(Composition/Aggregate Reuse Principle,CARP)。它要求在軟件複用時,要儘量先使用組合或者聚合等關聯關係來實現,其次才考慮使用繼承關係來實現。
如果要使用繼承關係,則必須嚴格遵循里氏替換原則。合成複用原則同里氏替換原則是相輔相成的,兩者都是開閉原則的具體實現規範。
2、合成複用原則的重要性
通常類的複用分爲繼承複用和合成複用兩種,繼承複用雖然有簡單和易實現的優點,但它也存在以下缺點:
①繼承複用破壞了類的封裝性。因爲繼承會將父類的實現細節暴露給子類,父類對子類是透明的,所以這種複用又稱爲“白箱”複用;
②子類與父類的耦合度高。父類的實現的任何改變都會導致子類的實現發生變化,這不利於類的擴展與維護;
③它限制了複用的靈活性。從父類繼承而來的實現是靜態的,在編譯時已經定義,所以在運行時不可能發生變化;
採用組合或聚合複用時,可以將已有對象納入新對象中,使之成爲新對象的一部分,新對象可以調用已有對象的功能,它有以下優點:
①它維持了類的封裝性。因爲成員對象的內部細節是新對象看不見的,所以這種複用又稱爲“黑箱”複用;
②新舊類之間的耦合度低。這種複用所需的依賴較少,新對象存取成員對象的唯一方法是通過成員對象的接口;
③複用的靈活性高。這種複用可以在運行時動態進行,新對象可以動態地引用與成員對象類型相同的對象;
3、合成複用原則的實現方法
合成複用原則是通過將已有的對象納入新對象中,作爲新對象的成員對象來實現的,新對象可以調用已有對象的功能,從而達到複用。
下面以汽車分類管理程序爲例來介紹合成複用原則的應用。
分析:汽車按“動力源”劃分可分爲汽油汽車、電動汽車等;按“顏色”劃分可分爲白色汽車、黑色汽車和紅色汽車等。如果同時考慮這兩種分類,其組合就很多。下圖所示是用繼承關係實現的汽車分類的類圖:
可以看出用繼承關係實現會產生很多子類,而且增加新的“動力源”或者增加新的“顏色”都要修改源代碼,這違背了開閉原則,顯然不可取。但如果改用組合關係實現就能很好地解決以上問題,其類圖如下:
這 7 種設計原則是軟件設計模式必須儘量遵循的原則,各種原則要求的側重點不同。其中,開閉原則是總綱,它告訴我們要對擴展開放,對修改關閉;里氏替換原則告訴我們不要破壞繼承體系;依賴倒置原則告訴我們要面向接口編程;單一職責原則告訴我們實現類要職責單一;接口隔離原則告訴我們在設計接口的時候要精簡單一;迪米特法則告訴我們要降低耦合度;合成複用原則告訴我們要優先使用組合或者聚合關係複用,少用繼承關係複用。