1. 程式人生 > >從封裝變化的角度看設計模式——元件協作

從封裝變化的角度看設計模式——元件協作

## 什麼是設計模式 ​ 要了解設計模式,首先得清楚什麼是模式。什麼是模式?模式即解決一類問題的方法論,簡單得來說,就是將解決某類問題的方法歸納總結到理論高度,就形成了模式。 ​ 設計模式就是將程式碼設計經驗歸納總結到理論高度而形成的。其目的就在於:1)可重用程式碼,2)讓程式碼更容易為他人理解,3)保證程式碼的可靠性。 ​ 使用面向物件的語言很容易,但是做到面向物件卻很難。更多人用的是面向物件的語言寫出結構化的程式碼,想想自己編寫的程式碼有多少是不用修改原始碼可以真正實現重用,或者可以實現拿來主義。這是一件很正常的事,我在學習過程當中,老師們總是在說c到c++的面向物件是一種巨大的進步,面向物件也是極為難以理解的存在;而在開始的學習過程中,我發現c++和c好像差別也不大,不就是多了一個類和物件嗎?但隨著愈發深入的學習使我發現,事實並不是那麼簡單,老師們舉例時總是喜歡用到簡單的物件群體,比如:人,再到男人、女人,再到擁有具體家庭身份的父親、母親、孩子。用這些來說明類、物件、繼承......似乎都顯得面向物件是一件輕而易舉的事。 ​ 但事實真是如此嗎?封裝、粒度、依賴關係、靈活性、效能、演化、複用等等,當這些在一個系統當中交錯相連,互相耦合,甚至有些東西還互相沖突時,你會發現自己可能連將系統物件化都是那麼的困難。 ​ 而在解決這些問題的過程當中,也就慢慢形成了一套被反覆使用、為多數人知曉、再由人分類編目的程式碼設計經驗總結——設計模式。 ## 設計原則 ​ 模式既然作為一套解決方案,自然不可能是沒有規律而言的,而其所遵循的內在規律就是設計原則。在學習設計模式的過程當中,不能脫離原則去看設計模式,而是應該透過設計模式去理解設計原則,只有深深地把握了設計原則,才能寫出真正的面向物件程式碼,甚至創造自己的模式。 1. **開閉原則(Open Close Principle)** ​ 開閉原則的意思是:**對擴充套件開放,對修改關閉**。在程式需要進行拓展的時候,不要去修改原有的程式碼。這樣是為了使程式的擴充套件性更好,更加易於維護和升級。而想要達到這樣的效果,就需要使用介面和抽象類。 2. **里氏替換原則(Liskov Substitution Principle)** ​ 里氏替換原則中說,任何基類可以出現的地方,子類一定可以出現。也就是說只有當派生類可以替換掉基類,且軟體單位的功能不受到影響時,基類才能真正被複用,而派生類也能夠在基類的基礎上增加新的行為。里氏代換原則是對開閉原則的補充。實現開閉原則的關鍵步驟就是抽象化,而基類與子類的繼承關係就是抽象化的具體實現,所以里氏代換原則是對實現抽象化的具體步驟的規範。 3. **依賴倒置原則(Dependence Inversion Principle)** ​ 依賴倒置原則是開閉原則的基礎,具體內容:抽象不應該依賴具體,而是具體應當依賴抽象;高層模組不應該依賴底層模組,而是高層和底層模組都要依賴抽象。因為抽象才是穩定的,這個原則想要說明的就是針對介面程式設計。 4. **介面分離原則(Interface Segregation Principle)** ​ 這個原則的意思是:使用多個隔離的介面,比使用單個介面要好。它還有另外一個意思是:降低類之間的耦合度。這個原則所要求的就是儘量將介面最小化,避免一個介面當中擁有太多不相關的功能。 5. **迪米特法則,又稱最少知道原則(Demeter Principle)** ​ 最少知道原則是指:如果兩個軟體實體無須直接通訊,那麼就不應當發生直接的相互呼叫,可以通過第三方轉發該呼叫。其目的是降低類之間的耦合度,提高模組的相對獨立性。迪米特法則在解決訪問耦合方面有著很大的作用,但是其本身的應用也有著一個很大的缺點,就是物件之間的通訊造成效能的損失,這是在使用過程中,需要去折衷考慮的。 6. **組合複用原則(Composite Reuse Principle)** ​ 組合複用原則或者說組合優先原則,也就是在進行功能複用的過程當中,組合往往是比繼承更好的選擇。這是因為繼承的形式會使得父類的實現細節對子類可見,從而違背了封裝的目的。 7. **單一職責原則(Single Responsibility Principle)** ​ 一個類只允許有一個職責,即只有一個導致該類變更的原因。類職責的變化往往就是導致類變化的原因:也就是說如果一個類具有多種職責,就會有多種導致這個類變化的原因,從而導致這個類的維護變得困難。 ​ 設計模式是設計原則在應用體現,設計原則是解決面向物件問題處理方法。在面對訪問耦合的情況下,有針對介面程式設計、介面分離、迪米特法則;處理繼承耦合問題,有里氏替換原則、優先組合原則;在保證類的內聚時,可以採用單一職責原則、集中類的資訊與行為。這一系列的原則都是為了一個目的——儘可能的實現開閉。設計模式不是萬能的,它是設計原則互相取捨的成果,而學習設計模式是如何抓住變化和穩定的界線才是設計模式的真諦。 ## GOF-23 模式分類 ​ 從**目的**來看,即模式是用來完成什麼工作的;可以劃分為建立型、結構型和行為型。建立型模式與物件的建立有關,結構型模式處理類或物件的組合,行為型模式對類和物件怎樣分配職責進行描述。 ​ 從**範圍**來看,即模式是作用於類還是物件;可以劃分為類模式和物件模式。類模式處理類和子類之間的關係,這些關係通過繼承建立,是靜態的,在編譯時刻就確定下來了;物件模式處理物件間的關係,這些關係可以在執行時刻變化,更加具有動態性。 組合之下,就產生了以下六種模式類別: 1. 類建立型模式:將物件的建立工作延遲到子類中。 2. 物件建立型模式:將物件的建立延工作遲到另一個物件的中。 3. 類結構型模式:使用繼承機制來組合類。 4. 物件建立型模式:描述物件的組裝形式。 5. 類行為型模式:使用繼承描述演算法和控制流。 6. 物件行為型模式:描述了一組物件怎樣協作完成單個物件所無法完成的任務。 ## 從封裝變化的角度來看 ​ GOF(“四人組”)對設計模式的分類更多的是從用途方法進行劃分,而現在,我們希望從設計模式中變化和穩定結構分隔上來理解所有的設計模式,或許有著不同的收穫。 ​ 首先要明白的是,獲得最大限度複用的關鍵在於對新需求和已有需求發生變化的預見性,這也就要求系統設計能夠相應地改進。而設計模式可以確保系統以特定的方式變化,從而避免系統的重新設計,並且設計模式同樣允許系統結構的某個方面的變化獨立於其他方面,這樣就在一定程度上加強了系統的健壯性。 ​ 根據封裝變化,可以將設計模式劃分為:元件協作、單一職責、物件建立、物件效能、介面隔離、狀態變化、資料結構、行為變化以及領域問題等等。 ## 設計模式之元件協作 ​ 現代軟體專業分工之後的第一個結果就是“框架與應用程式的劃分”,“元件協作”就是通過晚期繫結,來實現框架與應用程式之間的鬆耦合,是二者之間協作時常用的模式。其典型模式就是模板方法、策略模式和觀察者。 ##### 模板方法——類行為型模式 1. **意圖** ​ 定義一個操作中的演算法的骨架,並將其中一些步驟的實現延遲到子類中。模板方法使得子類可以重定義一個演算法的步驟而不會改變演算法的結構。 2. **例項** ​ 程式開發庫和應用程式之間的呼叫。假設現在存在一個開發庫,其內容是實現對一個檔案或資訊的操作,操作包含:open、read、operation、commit、close。但是呢!只有open、commit、close是確定的,其中read需要根據具體的operation來確定讀取方式,所以這兩個方法是需要開發人員自己去實現的。 ​ 那我們第一次的實現可能就是這種方式: ```java //標準庫實現 public class StdLibrary { public void open(String s){ System.out.println("open: "+s); } public void commit(){ System.out.println("commit operation!"); } public void close(String s){ System.out.println("close: "+s); } } ``` ```java //應用程式的實現 public class MyApplication { public void read(String s,String type){ System.out.println("使用"+type+"方式read: "+s); } public void operation(){ System.out.println("operation"); } } //或者這樣實現 public class MyApplication extends StdLibrary{ public void read(String s,String type){ System.out.println("使用"+type+"方式read: "+s); } public void operation(){ System.out.println("operation"); } } ``` ```java //這裡兩種實現方式的程式碼呼叫寫在一起,就不分開了。 public class MyClient { public static void main(String[] args){ //方式1 String file = "ss.txt"; StdLibrary lib = new StdLibrary(); MyApplication app = new MyApplication(); lib.open(file); app.read(file,"STD"); app.operation(); lib.commit(); lib.close(file); //方式2 MyApplication app = new MyApplication(); app.open(file); app.read(file,"STD"); app.operation(); app.commit(); app.close(file); } } ``` ​ 這種實現,無論是方式1還是方式2,對於僅僅是作為應用來說,當然是可以的。其問題主要在什麼地方呢?就方式1 而言,他是必須要使用者瞭解開發庫和應用程式兩個類,才能夠正確的去應用。 ​ 方式2相較於方式1,使用更加的簡單些,但是仍然有不完善的地方,就是呼叫者,需要知道各個方法的執行順序,這也是1和2共同存在的問題。而這剛好就是Template Method發揮的時候了,一系列操作有著明確的順序,並且有著部分的操作不變,剩下的操作待定。 ```java //按照Template Method結構可以將標準庫作出如下修改 public abstract class StdLibrary { public void open(String s){ System.out.println("open: "+s); } public abstract void read(String s, String type); public abstract void operation(); public void commit(){ System.out.println("commit operation!"); } public void close(String s){ System.out.println("close: "+s); } public void doOperation(String s,String type){ open(s); read(s,"STD"); operation(); commit(); close(s); } } ``` ​ 在修改過程中,將原來的類修改成了抽象類,並且新增了兩個抽象方法和一個`doOperation()`。通過使用抽象操作定義一個演算法中的一些步驟,模板方法確定了它們的先後順序,但它允許Library和Application子類改變這些具體的步驟以滿足它們各自的需求,並且還對外隱藏了演算法的實現。當然,**如果標準庫中的不變方法不能被重定義,那麼就應該將其設定為private或者final**。 ```java //修改過後的Appliaction和Client public class MyApplication extends StdLibrary { @Override public void read(String s, String type){ System.out.println("使用"+type+"方式read: "+s); } @Override public void operation(){ System.out.println("operation"); } } public class MyClient { public static void main(String[] args){ String file = "ss.txt"; MyApplication app = new MyApplication(); app.doOperation(file,"STD"); } } ``` ​ 模板方法的使用在類庫當中極為常見,尤其是在c++的類庫當中,它是一種基本的程式碼複用技術。這種實現方式,產生了一種反向的控制結構,或者我們稱之為“好萊塢法則”,即“別找我們,我們找你”;換名話說,這種**反向控制結構**就是**父類呼叫了子類的操作(父類中的`doOperation()`呼叫了子類實現的`read()`和`operation()`)**,因為在平時,我們的繼承程式碼複用更多的是呼叫子類呼叫父類的操作。 3. **結構** ![templateMethod.png](https://img2020.cnblogs.com/other/1218435/202007/1218435-20200712210850649-1686018690.png) 4. **參與者** + AbstractClass(StdLibrary) 定義抽象的原語操作(可變部分)。 實現一個模板方法(`templateMethod()`),定義演算法的骨架。 + ConcreteClass(具體的實現類,如MyApplication) 實現原語操作以完成演算法中與特定子類相關的步驟。 除了以上參與者之外,還可以有OperatedObject這樣一個參與者即被操作物件。比如對文件的操作,文件又有不同的型別,如pdf、word、txt等等;這種情況下,就需要根據不同的文件型別,定製不同的操作,即一個ConcreteClass對應一個OperatedObject,相當於對結構當中由一個特定操作物件,擴充套件到多個操作物件,並且每個操作物件對應一個模板方法子類。 5. **適用性** 對於模板方法的特性,其可以應用於下列情況: + 一次性實現一個演算法的不變部分,並將可變的行為留給子類來實現。 + 各子類中公共的行為應被提取出來並集中到一個公共父類中,以避免程式碼重複。重構方式即為首先識別現有程式碼中的不同之處,並且將不同之處分離為新的操作。最後用一個模板方法呼叫這些新的操作,來替換這些不同的程式碼。 + 控制子類的擴充套件。模板方法只有特定點呼叫"hook"操作,這樣就只允許在這些擴充套件點進行相應的擴充套件。 6. **相關模式** ​ Factory Method經常被Template Method所呼叫。比如在參與者當中提到的,如果需要操作不同的檔案物件,那麼在操作的過程中就需要`read()`方法返回不同的檔案物件,而這個`read()`方法不正是一個Factory Method。 ​ Strategy:Template Method使用繼承來改變演算法的**一部分**,而Strategy使用委託來改變**整個**演算法。 7. **思考** + **訪問控制** 在定義模板的時候,除了簡單的定義原語操作和演算法骨架之外,操作的控制權也是需要考慮的。原語操作是可以被重定義的,所以不能設定為final,還有原語操作能否為其他不相關的類所呼叫,如果不能則可以設定為protected或者default。模板方法一般是不讓子類重定義的,因此就需要設定為final. + **原語運算元量** 定義模板方法的一個重要目的就是儘量減少一個子類具體實現該演算法時,必須重定義的那些原語操作的數目。因為,需要重定義的操作越多,應用程式就越冗長。 + **命名約定** 對於需要重定義的操作可以加上一個特定的字首以便開發人員識別它們。 + **hook操作** hook操作就是指那些在模板方法中定義的可以重定義的操作,子類在必要的時候可以進行擴充套件。當然,如果可以使用父類的操作,不擴充套件也是可以的;因此,在Template Method中,應該去指明哪些操作是不能被重定義的、哪些是**hook**(可以被重定義)以及哪些是抽象操作(必須被重定義)。 ##### 策略模式——物件行為型模式 1. **意圖** ​ 定義一系列的演算法,把它們一個個封裝起來,並且使它們可相互替換。Strategy使得演算法可以獨立於使用它的客戶而變化。 2. **例項** ​ 策略模式是一種非常經典的設計模式,可能也是大家經常所見到和使用的設計模式;重構過程中選擇使用策略模式的一個非常明顯的特徵,就是程式碼當中出現了多重條件分支語句,這種時候為了程式碼的擴充套件性,就可以選擇使用策略模式。 ​ 比如正面這樣的程式碼,實現一個加減乘除運算的操作。 ```java public class Operation { public static void main(String[] args) { binomialOperation(1,1,'+'); binomialOperation(1,3,'-'); binomialOperation(1,2,'*'); binomialOperation(1,1,'/'); binomialOperation(1,0,'/'); } public static int binomialOperation(int num1,int num2,char ch){ switch(ch){ case '+': return num1+num2; case '-': return num1+num2; case '*': return num1*num2; case '/': if(num2!=0){return num1/num2;} else { System.out.println("除數不能為0!"); } } return num2; } } ``` ​ 上面的程式碼完全可以實現我們想要的功能,但是如果現在需求有變,需要再增加一個‘與’和‘或’的二目運算;那在這種情況下,勢必需要去修改原始碼,這樣就違背了開閉原則的思想。因此,使用策略模式,將上面程式碼修改為下列程式碼。 ```java //Strategy public interface BinomialOperation { public int operation(int num1,int num2); } public class AddOperation implements BinomialOperation { @Override public int operation(int num1, int num2) { return num1+num2; } } public class SubstractOperation implements BinomialOperation { @Override public int operation(int num1, int num2) { return num1-num2; } } public class MultiplyOperation implements BinomialOperation { @Override public int operation(int num1, int num2) { return num1*num2; } } public class DivideOperation implements BinomialOperation { @Override public int operation(int num1, int num2) { if(0!=num2){ return num1/num2; }else{ System.out.println("除數不能為0!"); return num2; } } } //Context public class OperatioContext { BinomialOperation binomialOperation; public void setBinomialOperation(BinomialOperation binomialOperation) { this.binomialOperation = binomialOperation; } public int useOperation(int num1,int num2){ return binomialOperation.operation(num1,num2); } } public class Client { public static void main(String[] args) { OperatioContext oc = new OperatioContext(); oc.setBinomialOperation(new AddOperation()); oc.useOperation(1,2); //...... } } ``` 程式碼很簡單,就是將運算類抽象出來,形成一種策略,每個不同的運算子對應一個具體的策略,並且實現自己的操作。Strategy和Context相互作用以實現選定的演算法。當演算法被呼叫時,Context可以將自身作為一個引數傳遞給Strategy或者將所需要的資料都傳遞給Strategy,也就是說 `OperationContext`中`useOperation()`的`num1`和`num2`可以作為為`OperationContext`類的屬性,在使用過程中直接將`OperationContext`的物件作為一個引數傳遞給`Strategy`類即可。 通過策略模式的實現,使得增加新的策略變得簡單,但是其缺點就在於客戶必須瞭解 不同的策略。 3. **結構 ** ![Strategy.png](https://img2020.cnblogs.com/other/1218435/202007/1218435-20200712211031783-1106070672.png) 4. **參與者** + **Strategy (如BinomialOperation)** 定義所有支援的演算法的公共介面。Context使用這個介面來呼叫某具體的Strategy中定義的演算法。 + **ConcreteStrategy(如AddOperation...)** 根據Strategy介面實現具體演算法。 + **Context(如OperationContext)** + 需要一個或多個ConcreteStrategy來進行配置,使用多個策略時,這些具體的策略可能是不同的策略介面的實現。比如,實現一個工資計算系統,工人身份有小時工、周結工、月結工,這種情況下,就可以將工人身份獨立為一個策略,再將工資支付計劃(用以判斷當天是否為該工人支付工資日期)獨立為一個策略,這樣Context中就需要兩個策略來配置。 + 需要**存放**或者**傳遞**Strategy需要使用到的所有資料。 5. **適用性** 當存在以下情況時,可以使用策略模式: + 許多相關的類僅僅是行為有異。“策略”提供了一種多個行為中的一些行為來配置一個類的方法。 + 需要使用一個演算法的不同變體。例如,你可以會定義一些反映不同空間/時間權衡的演算法,當這些變體需要實現為一個演算法的類層次時,就可以採用策略模式。 + 演算法使用客戶不應該知道的資料。可以採用策略模式避免暴露覆雜的、與演算法相關的資料結構。 + 一個類定義了多種行為,並且這些行為在這個類的操作中以多個條件語句的形式出現。 6. **相關模式** ​ Flyweight(享元模式)的共享機制可以減少需要生成過多Strategy物件,因為在使用過程中,策略往往是可以共享使用的。 7. **思考** + **Strategy和Context之間的通訊問題。**在Strategy和Contex介面中,必須使得ConcreteStrategy能夠有效的訪問它所需要的Context中的任何資料,反之亦然。這種實現一般有兩種方式: ​ 1)讓Context將資料放在引數中傳遞給Strategy——也就是說,將資料直接傳送給Strategy。這可以使得Strategy和Context之間解耦(印記耦合是可以接受的),但有可能會有一些Strategy不需要的資料。 ​ 2)將Context自身作為一個引數傳遞給Strategy,該Strategy顯示的向Context請求資料,或者說明在Strategy中保留一個Context的引用,這樣便不需要再傳遞其他的資料了。 + **讓Strategy成為可選的。**換名話說,在有些實現過程中,客戶可以在不指定具體策略的情況下使用Context完成自己的工作。這是因為,我們可以為Context指定一個預設的Strategy的存在,如果有指定Strategy就使用客戶指定的,如果沒有,就使用預設的。 ##### 觀察者模式——物件行為型模式 1. **意圖** ​ 定義物件間的一種一對多的依賴關係,當一個物件的狀態發生改變時,所有依賴於它的物件都得到通知並被自動更新。 2. **例項** ​ 觀察者模式很常見於圖形使用者介面當中,比如常見的Listener。觀察者模式可以使得應用資料的類和負責介面表示的類可以各自獨立的複用。比如,當介面當中存在一個輸入表單,在我們對錶單進行輸入的時候,介面上又會顯示這樣一個數據的柱狀圖,以些來對比各項資料。其偽碼可以描述成下列這種形式:`Histogram`作為柱狀圖類只需要負責接收資料並且顯示出來,`InputForm`作為一個輸入表單。在這個 過程中,只要`InputForm`中的資料發生變化,就相應的改變`Histogram` 的顯示。 ​ 這種實現方式,明顯在`InputForm`中產生了一種強耦合,如果顯現圖形發生變化,現在不需要顯示為一個柱狀圖而是一個餅狀圖,勢必又要去修改原始碼。 ```java public class Histogram { public void draw(int[]nums){ for (int i:nums ) { System.out.print(i+" "); } } } public class InputForm { private int[] data; Histogram histogram; public InputForm(Histogram histogram){ this.histogram = histogram; show(); } public void change(int... data){ this.data = data; show(); } public void show(){ histogram.draw(data); } } public class Client { public static void main(String[] args) { InputForm inputForm = new InputForm(new Histogram()); inputForm.change(3,4,5); inputForm.change(5,12,13); } } ``` ​ 同時,`InputForm `和顯示圖形之間的關係,剛好符合觀察者模式所說的一個物件的狀態變化,引起其他物件的更新,同時兼顧考慮開閉問題,可以將`Histogram`和`PieChart`公共特性提取出來,形成一個`Graph`介面。另外,有可能`InputFrom`不只需要顯示一種圖表,而是需要同時將柱狀圖和餅狀圖顯示出來,因此在`InputFrom`中定義的是一個List的結構來存放所有的相關顯示圖形。 ```java //Observer public interface Graph { public void update(Input input); public void draw(); } public class Histogram implements Graph { private InputForm inputForm; public Histogram(InputForm inputForm){ this.inputForm = inputForm; } @Override public void update(Input inputForm) { if(this.inputForm == inputForm){ draw(); } } @Override public void draw(){ System.out.println("柱狀圖:"); for (int i: inputForm.getData()) { System.out.println(i+" "); } System.out.println(); } } public class PieChart implements Graph { private InputForm inputForm; public PieChart(InputForm inputForm){ this.inputForm = inputForm; this.inputForm.addGraph(this); draw(); } @Override public void update(Input inputForm) { if(this.inputForm == inputForm){ draw(); } } @Override @Override public void draw(){ System.out.println("餅狀圖:"); for (int i: inputForm.getData()) { System.out.println(i+" "); } System.out.println(); } } ``` ​ 在實際的應用過程中,既然有輸入表單的形式,也有可能以其他的形式輸入資料,為了以後的擴充套件,可以將輸入形式抽象出來,形成一個`Input`介面,以便後續的擴充套件。 ```java //Subject 目標物件 public interface Input { public void addGraph(Graph graph); public void removeGraph(Graph graph); public void notifyGraphs(); } public class InputForm implements Input { private int[] data; priv