面向物件設計的七大原則
1.開閉原則 - Open Close Principle(OCP)
1)定義
- 一個軟體實體如類、模組和函式應該對擴充套件開放,對修改關閉
- Software entities like classes,modules and functions should be open for extension but closed for modifications.
2)基本概念
- 開:對擴充套件開放,支援方便的擴充套件
- 閉:對修修改關閉,嚴格限制對已有的內容修改
- 說明:一個軟體產品只要在生命週期內,都會發生變化,即然變化是一個事實,我們就應該在設計時儘量適應這些變化,以提高專案的穩定性和靈活性,真正實現“擁抱變化”。開閉原則告訴我們應儘量通過擴充套件軟體實體的行為來實現變化,而不是通過修改現有程式碼來完成變化,它是為軟體實體的未來事件而制定的對現行開發設計進行約束的一個原則
3)優點
- 提高系統的靈活性、可複用性和可維護性
4)示例:現在,以課程為例說明什麼是開閉原則
/** * 定義課程介面 */ public interface ICourse { String getName();// 獲取課程名稱 Double getPrice(); // 獲取課程價格 Integer getType(); // 獲取課程型別 } /** * 英語課程介面實現 */ public class EnglishCourse implements ICourse { private String name; private Double price; private Integer type; public EnglishCourse(String name, Double price, Integer type) { this.name = name; this.price = price; this.type = type; } @Override public String getName() { return null; } @Override public Double getPrice() { return null; } @Override public Integer getType() { return null; } } // 測試 public class Main { public static void main(String[] args) { ICourse course = new EnglishCourse("小學英語", 199D, "Mr.Zhang"); System.out.println( "課程名字:"+course.getName() + " " + "課程價格:"+course.getPrice() + " " + "課程作者:"+course.getAuthor() ); } }
專案上線,課程正常銷售,但是我們產品需要做些活動來促進銷售,比如:打折。那麼問題來了:打折這一動作就是一個變化,而我們要做的就是擁抱變化,現在開始考慮如何解決這個問題,可以考慮下面三種方案:
1)修改介面
- 在之前的課程介面中新增一個方法 getSalePrice() 專門用來獲取打折後的價格;
-
如果這樣修改就會產生兩個問題,所以此方案否定
(1) ICourse 介面不應該被經常修改,否則介面作為契約的作用就失去了
(2) 並不是所有的課程都需要打折,加入還有語文課,數學課等都實現了這一介面,但是隻有英語課打折,與實際業務不符
public interface ICourse { // 獲取課程名稱 String getName(); // 獲取課程價格 Double getPrice(); // 獲取課程型別 String getAuthor(); // 新增:打折介面 Double getSalePrice(); }
2)修改實現類
- 在介面實現裡直接修改 getPrice()方法,此方法會導致獲取原價出問題;或新增獲取打折的介面getSalePrice(),這樣就會導致獲取價格的方法存在兩個,所以這個方案也否定,此方案不貼程式碼了。
3)通過擴充套件實現變化
- 直接新增一個子類 SaleEnglishCourse ,重寫 getPrice()方法,這個方案對原始碼沒有影響,符合開閉原則,所以是可執行的方案,程式碼如下,程式碼如下:
public class SaleEnglishCourse extends EnglishCourse { public SaleEnglishCourse(String name, Double price, String author) { super(name, price, author); } @Override public Double getPrice() { return super.getPrice() * 0.85; } }
綜上所述,如果採用第三種,即開閉原則,以後再來個語文課程,數學課程等等的價格變動都可以採用此方案,維護性極高而且也很靈活
2.單一職責原則 - Single Responsibility Principle(SRP)
1)定義
- 不要存在多於一個導致類變更的原因
- There should never be more than one reason for a class to change.
2)基本概念
- 單一職責是高內聚低耦合的一個體現
- 說明:通俗的講就是一個類只能負責一個職責,修改一個類不能影響到別的功能,也就是說只有一個導致該類被修改的原因
3)優點
- 低耦合性,影響範圍小
- 降低類的複雜度,職責分明,提高了可讀性
- 變更引起的風險低,利於維護
4)示例:現在,以動物為例說明什麼是單一原則
假如說,類 A 負責兩個不同的職責,T1 和 T2,當由於職責 T1 需求發生改變而需要修改類 A 時,有可能會導致原本執行正常的職責 T2 功能發生改變或出現異常。為什麼會出現這種問題呢?程式碼耦合度太高,實現複雜,簡單一句話就是:不夠單一。那麼現在提出解決方案:分別建立兩個類 A 和 B ,使 A 完成職責 T1 功能,B 完成職責 T2 功能,這樣在修改 T1 時就不會影響 T2 了,反之亦然。
說到單一職責原則,很多人都會不屑一顧。因為它太簡單了。稍有經驗的程式員即使從來沒有讀過設計模式、從來沒有聽說過單一職責原則,在設計軟體時也會自覺的遵守這一重要原則,因為這是常識。在軟體程式設計中,誰也不希望因為修改了一個功能導致其他的功能發生故障。而避免出現這一問題的方法便是遵循單一職責原則。雖然單一職責原則如此簡單,並且被認為是常識,但是即便是經驗豐富的程式設計師寫出的程式,也會有違背這一原則的程式碼存在。為什麼會出現這種現象呢?因為有職責擴散。所謂職責擴散,就是因為某種原因,職責 T 被分化為粒度更細的職責 T1 和 T2
/** * 定義動物類 */ public class Animal { public void move(String animal){ System.out.println(animal + "用翅膀飛"); } } /** * 第一次測試 */ public class Main { public static void main(String[] args) { Animal animal = new Animal(); animal.move("麻雀"); animal.move("老鷹"); animal.move("鯨魚"); } }
經過上面程式碼示例發現:麻雀和老鷹會飛是可以理解的,但是鯨魚就有點不合常理了,那麼我們遵循單一對程式碼進行第(一) 次修改,程式碼如下:
/** * 會飛的動物 */ public class FlyAnimal { public void move(String animal){ System.out.println(animal + "用翅膀飛"); } } /** * 在水裡的動物 */ public class WaterAnimal { public void move(String animal){ System.out.println(animal + "在水裡游泳"); } } /** * 測試 */ public class Main { public static void main(String[] args) { FlyAnimal flyAnimal = new FlyAnimal(); flyAnimal.move("麻雀"); flyAnimal.move("老鷹"); WaterAnimal waterAnimal = new WaterAnimal(); waterAnimal.move("鯨魚"); } }
遵循單一原則發現確實是職責單一了,但是我們會發現如果這樣修改花銷是很大的,除了將原來的類分解之外還需要修改客戶端程式碼。如果我們不去遵循單一原則,而是直接在原有程式碼進行第(二) 次修改,程式碼如下:
/** * 直接修改原始碼 */ public class Animal { public void move(String animal){ if ("鯨魚".equals(animal)) { System.out.println(animal + "在水裡游泳"); } else { System.out.println(animal + "用翅膀飛"); } } } /** * 第三次測試 */ public class Main { public static void main(String[] args) { Animal animal = new Animal(); animal.move("麻雀"); animal.move("老鷹"); animal.move("鯨魚"); } }
直接修改原始碼確實簡單很多,但是卻存在巨大的隱患,加入需求變了:將在水裡的動物分為淡水的和海水的,那麼又要繼續修改 Animal 類的 move() 方法,這也就對會飛的動物造成了一定的風險,所以我們繼續摒棄單一原則對程式碼進行第(三) 次修改,程式碼如下:
public class Animal { public void move(String animal){ System.out.println(animal + "在水裡游泳"); } public void moveA(String animal){ System.out.println(animal + "用翅膀飛"); } } public class Main { public static void main(String[] args) { Animal animal = new Animal(); animal.move("麻雀"); animal.move("老鷹"); animal.moveA("鯨魚"); } }
在最後一種修改中可以看到,這種修改方式沒有改動原來的方法,而是在類中新加了一個方法,這樣雖然也違背了單一職責原則,但在方法級別上卻是符合單一職責原則的,因為它並沒有動原來方法的程式碼。綜上所述,這三種方式各有優缺點,那麼在實際程式設計中,採用哪一中呢?結論:只有邏輯足夠簡單,才可以在程式碼級別上違反單一職責原則;只有類中方法數量足夠少,才可以在方法級別上違反單一職責原則;
3.裡士替換原則 - Liskov Substitution Principle(LSP)
1)定義
- 定義一:所有引用基類的地方必須能透明地使用其子類的物件。
- 定義二:如果對每一個型別為 T1的物件 o1,都有型別為 T2 的物件o2,使得以 T1定義的所有程式 P 在所有的物件 o1 都代換成 o2 時,程式 P 的行為沒有發生變化,那麼型別 T2 是型別 T1 的子型別。
- Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.
2)基本概念
- 強調的是設計和實現要依賴於抽象而非具體;子類只能去擴充套件基類,而不是隱藏或者覆蓋基類它包含以下4層含義
1)子類可以實現父類的抽象方法,但不能覆蓋父類的非抽象方法。
2)子類中可以增加自己特有的方法。
3)當子類的方法過載父類的方法時,方法的前置條件(即方法的形參)要比父類方法的輸入引數更寬鬆。
4)當子類的方法實現父類的抽象方法時,方法的後置條件(即方法的返回值)要比父類更嚴格。
3)優點
- 開閉原則的體現,約束繼承氾濫
- 提高系統的健壯性、擴充套件性和相容性
4)示例:
程式碼講解第三個概念 :
當子類的方法過載父類的方法時,方法的前置條件(即方法的形參)要比父類方法的輸入引數更寬鬆
public class ParentClazz { public void say(CharSequence str) { System.out.println("parent execute say " + str); } } public class ChildClazz extends ParentClazz { public void say(String str) { System.out.println("child execute say " + str); } } /** * 測試 */ public class Main { public static void main(String[] args) { ArrayList list = new ArrayList(); ParentClazz parent = new ParentClazz(); parent.say("hello"); ChildClazz child = new ChildClazz(); child.say("hello"); } } 執行結果: parent execute say hello child execute say hello
以上程式碼中我們並沒有重寫父類的方法,只是過載了同名方法,具體的區別是:子類的引數 String 實現了父類的引數 CharSequence。此時執行了子類方法,在實際開發中,通常這不是我們希望的,父類一般是抽象類,子類才是具體的實現類,如果在方法呼叫時傳遞一個實現的子類可能就會產生非預期的結果,引起邏輯錯誤,根據裡士替換原則的子類的輸入引數要寬於或者等於父類的輸入引數,我們可以修改父類引數為String,子類採用更寬鬆的 CharSequence,如果你想讓子類的方法執行,就必須覆寫父類的方法。程式碼如下:
public class ParentClazz { public void say(String str) { System.out.println("parent execute say " + str); } } public class ChildClazz extends ParentClazz { public void say(CharSequence str) { System.out.println("child execute say " + str); } } public class Main { public static void main(String[] args) { ParentClazz parent = new ParentClazz(); parent.say("hello"); ChildClazz child = new ChildClazz(); child.say("hello"); } } 執行結果: parent execute say hello parent execute say hello
程式碼講解第四個概念 :
public abstract class Father { public abstract Map hello(); } public class Son extends Father { @Override public Map hello() { HashMap map = new HashMap(); System.out.println("son execute"); return map; } } public class Main { public static void main(String[] args) { Father father = new Son(); father.hello(); } } 執行結果: son execute
4.依賴倒置原則 - Dependence Inversion Principle(DIP)
1)定義
- 高層模組不應該依賴低層模組,二者都應該依賴其抽象;抽象不應該依賴細節,細節應該依賴抽象。
- High level modules should not depend upon low level modules,Both should depend upon abstractions.Abstractions should not depend upon details.Details should depend upon abstracts.
2)基本概念
- 依賴倒置原則的核心就是要我們面向介面程式設計,理解了面向介面程式設計,也就理解了依賴倒置
- 低層模組儘量都要有抽象類或介面,或者兩者都有
- 變數的宣告型別儘量是抽象類或介面
- 使用繼承時遵循里氏替換原則
- 設計和實現要依賴於抽象而非具體。一方面抽象化更符合人的思維習慣;另一方面,根據里氏替換原則,可以很容易將原來的抽象替換為擴充套件後的具體,這樣可以很好的支援開-閉原則
3)優點
- 減少類間的耦合性,提高系統的穩定性
- 降低並行開發引起的風險
- 提高程式碼的可讀性和可維護性
4)示例:
public class MrZhang { public void study(ChineseCourse course) { course.content(); } } public class ChineseCourse { public void content() { System.out.println("開始學習語文課程"); } } public class Main { public static void main(String[] args) { MrZhang zhang = new MrZhang(); zhang.study(new ChineseCourse()); } } 執行結果: 開始學習語文課程
執行之後,結果正常。那麼,考慮一個問題假如此時要學習數學課程呢? 數學課程程式碼如下:
public class MathCourse { public void content() { System.out.println("開始學習數學課程"); } }
很顯然,MrZhang 無法學習,因為他只能接受ChineseCourse ,學習語文課程。當然如果我們修改接受引數為 MathCourse 的話就可以學習了,但是不能學習語文,英語,化學等等。造成此現象的具體原因是:MrZhang 和 ChineseCourse 耦合度太高了,必須降低耦合度才可以。程式碼如下:
public interface ICourse { void content(); } public class MrZhang { public void study(ICourse course) { course.content(); } } public class ChineseCourse implements ICourse { @Override public void content() { System.out.println("開始學習語文課程"); } } public class MathCourse implements ICourse { @Override public void content() { System.out.println("開始學習數學課程"); } } public class Main { public static void main(String[] args) { MrZhang zhang = new MrZhang(); zhang.study(new ChineseCourse()); zhang.study(new MathCourse()); } }
MrZhang 與 ICourse 具有依賴關係,ChineseCourse 和 MathCourse 屬於課程範疇,並且各自實現了 ICourse 介面,這樣就符合了依賴倒置原則。這樣修改後無論再怎麼擴充套件 Main 類,都不用繼續修改 MrZhang 了,MrZhang.java 作為高層模組就不會依賴低層模組的修改而引起變化,減少了修改程式造成的風險。
5.介面隔離原則 - Interface Segration Principle(ISP)
1)定義
- 定義一:客戶端不應該依賴它不需要的介面
- Clients should not be forced to depend upon interfaces that they don’t use.
- 定義二:類間的依賴關係應該建立在最小的介面上
-
The dependency of one class to another one should depend on the
smallest possible interface.
2)基本概念
- 一個類對另一個類的依賴應該建立在最小的介面上,通俗的講就是需要什麼就提供什麼,不需要的就不要提供
- 介面中的方法應該儘量少,不要使介面過於臃腫,不要有很多不相關的邏輯方法
3)優點
- 高內聚,低耦合
- 可讀性高,易於維護
4)程式碼示例:
public interface IAnimal { void eat(); void talk(); void fly(); } public class BirdAnimal implements IAnimal { @Override public void eat() { System.out.println("鳥吃蟲子"); } @Override public void talk() { //並不是所有的鳥都會說話 } @Override public void fly() { //並不是所有的鳥都會飛 } } public class DogAnimal implements IAnimal { @Override public void eat() { System.out.println("狗狗吃飯"); } @Override public void talk() { //狗不會說話 } @Override public void fly() { //狗不會飛 } }
通過上面的程式碼發現:狗實現動物介面,必須實現三個介面,根據常識我們得知,第二個和第三個介面不一定會有實際意義,換句話說也就是這個方法有可能一直不會被呼叫。但是就是這樣我們還必須實現這個方法,儘管方法體可以為空,但是這就違反了介面隔離的定義。我們知道由於Java類支援實現多個介面,可以很容易的讓類具有多種介面的特徵,同時每個類可以選擇性地只實現目標介面 ,基於此特點我們可以對功能進一步的細化,編寫一個或多個介面,程式碼如下:
public interface IEat { void eat(); } public interface IFly { void fly(); } public interface ITalk { void talk(); } public class DogAnimal implements IEat { @Override public void eat() { System.out.println("狗狗吃飯"); } } public class ParrotAnimal implements IEat, IFly, ITalk { @Override public void eat() { System.out.println("鸚鵡吃東西"); } @Override public void fly() { System.out.println("鸚鵡吃飛翔"); } @Override public void talk() { System.out.println("鸚鵡說話"); } }
說到這裡,很多人會覺的介面隔離原則跟之前的單一職責原則很相似,其實不然。
其一,單一職責原則原注重的是職責;而介面隔離原則注重對介面依賴的隔離。
其二,單一職責原則主要是約束類,其次才是介面和方法,它針對的是程式中的實現和細節;而介面隔離原則主要約束介面介面,主要針對抽象,針對程式整體框架的構建
介面隔離原則一定要適度使用,介面設計的過大或過小都不好,過分的細粒度可能造成介面數量龐大不易於管理
6.迪米特法則/最少知道原則 - Law of Demeter or Least Knowledge Principle(LoD or LKP)
1)定義
- 一個物件應該對其他物件保持最少的瞭解
- 這個原理的名稱來源於希臘神話中的農業女神,孤獨的得墨忒耳。
2)基本概念
- 每個單元對於其他的單元只能擁有有限的知識:只是與當前單元緊密聯絡的單元;
- 每個單元只能和它的朋友交談:不能和陌生單元交談;
- 只和自己直接的朋友交談。
3)優點
- 使得軟體更好的可維護性與適應性
- 物件較少依賴其它物件的內部結構,可以改變物件容器(container)而不用改變它的呼叫者(caller)
4)詳細講解:
迪米特法則通俗的來講,就是一個類對自己依賴的類知道的越少越好。也就是說,對於被依賴的類來說,無論邏輯多麼複雜,都儘量地的將邏輯封裝在類的內部,對外除了提供的public方法,不對外洩漏任何資訊。迪米特法則還有一個更簡單的定義:只與直接的朋友通訊。首先來解釋一下什麼是直接的朋友:每個物件都會與其他物件有耦合關係,只要兩個物件之間有耦合關係,我們就說這兩個物件之間是朋友關係。耦合的方式很多,依賴、關聯、組合、聚合等。其中,我們稱出現成員變數、方法引數、方法返回值中的類為直接的朋友,而出現在區域性變數中的類則不是直接的朋友。也就是說,陌生的類最好不要作為區域性變數的形式出現在類的內部。
程式碼舉例:通過老師要求班長告知班級人數為例,講解迪米特法則。先來看一下違反迪米特法則的設計,程式碼如下
public class Student { private Integer id; private String name; public Student(Integer id, String name) { this.id = id; this.name = name; } } public class Teacher { public void call(Monitor monitor) { List<Student> sts = new ArrayList<>(); for (int i = 0; i < 10; i++) { sts.add(new Student(i + 1, "name" + i)); } monitor.getSize(sts); } } public class Monitor { public void getSize(List list) { System.out.println("班級人數:" + list.size()); } }
現在這個設計的主要問題出在 Teacher 中,根據迪米特法則,只與直接的朋友發生通訊,而 Student 類並不是 Teacher 類的直接朋友(以區域性變量出現的耦合不屬於直接朋友),從邏輯上講 Teacher 只與 Monitor 耦合就行了,與 Student 並沒有任何聯絡,這樣設計顯然是增加了不必要的耦合。按照迪米特法則,應該避免類中出現這樣非直接朋友關係的耦合。修改後的程式碼如下:
public class Student { private Integer id; private String name; public Student(Integer id, String name) { this.id = id; this.name = name; } } public class Teacher { public void call(Monitor monitor) { monitor.getSize(); } } public class Monitor { public void getSize() { List<Student> sts = new ArrayList<>(); for (int i = 0; i < 10; i++) { sts.add(new Student(i + 1, "name" + i)); } System.out.println("班級人數" + sts.size()); } }
將Student 從 Teacher 抽掉,也就達到了 Student 和 Teacher 的解耦,從而符合了迪米特原則。
迪米特法則的初衷是降低類之間的耦合,由於每個類都減少了不必要的依賴,因此的確可以降低耦合關係。但是凡事都有度,雖然可以避免與非直接的類通訊,但是要通訊,必然會通過一個“中介”來發生聯絡,例如本例中,老師(Teacher) 就是通過班長(Monitor) 這個“中介”來與學生(Student) 發生聯絡的。過分的使用迪米特原則,會產生大量這樣的中介和傳遞類,導致系統複雜度變大。所以在採用迪米特法則時要反覆權衡,既做到結構清晰,又要高內聚低耦合。
7.合成/聚合複用原則 - Composite/Aggregate Reuse Principle(CARP / CRP)
1) 定義
- 儘量採用組合(contains-a)、聚合(has-a)的方式而不是繼承(is-a)的關係來達到軟體的複用目的
2)基本概念
- 如果新物件的某些功能在別的已經建立好的物件裡面已經實現,那麼應當儘量使用別的物件提供的功能,使之成為新物件的一部分,而不要再重新建立
組合/聚合的優缺點:類之間的耦合比較低,一個類的變化對其他類造成的影響比較少,缺點:類的數量增多實現起來比較麻煩
繼承的優點:由於很多方法父類已經實現,子類的實現會相對比較簡單,缺點:將父類暴露給了子類,一定程度上破壞了封裝性,父類的改變對子類影響比較大
3)優點
- 可以降低類與類之間的耦合程度
- 提高了系統的靈活性
4)講解
public class Person { public void talk(String name) { System.out.println(name + " say hello"); } public void walk(String name) { System.out.println(name + " move"); } } public class Manager extends Person { } public class Employee extends Person { }
按照組合複用原則我們應該首選組合,然後才是繼承,使用繼承時應該嚴格的遵守里氏替換原則,必須滿足“Is-A”的關係是才能使用繼承,而組合卻是一種“Has-A”的關係。導致錯誤的使用繼承而不是使用組合的一個重要原因可能就是錯誤的把“Has-A”當成了“Is-A”。
由上沒看的程式碼可以看出,經理和員工繼承了人,但實際中每個不同的職位擁有不同的角色,如果我們添加了角色這個類,那麼繼續使用繼承的話只能使每個人只能具有一種角色,這顯然是不合理的。
備註:Java設計模式的橋接模式 很好的體現了這一原則