【設計模式總結】對常用設計模式的一些思考

五月的倉頡發表於2017-02-23

前言

在【Java設計模式】系列中,LZ寫了十幾篇關於設計模式的文章,大致是關於每種設計模式的作用、寫法、優缺點、應用場景。

隨著LZ自身的成長,再加上在工作中會從事一定的架構以及底層程式碼設計的原因,在近半年的實踐中,對於設計模式的理解又有了新的認識,因此有了此文,目的是和網友朋友們分享自己對於設計模式的一些思考。LZ本人水平有限,拋磚引玉,寫得不對的地方希望網友朋友們指正,也可留言相互討論。

 

設計模式用不用?如何用?

標題是兩個問題:

1、什麼情況下使用設計模式?

2、使用哪種設計模式?

首先回答一下對於第一個問題我的個人理解:

對於程式碼來說,即使完全不使用設計模式,也是可以將整個流程寫出來,將整個功能實現出來。
使用設計模式的內因,主要來源於開發者對於設計模式本身的理解,因此談論這個問題,首先要自問:我瞭解或者說熟悉幾種設計模式?畢竟,懂都不懂,如何使用設計模式?
使用設計模式的外因,主要來源於開發者對於程式碼可維護性、可擴充套件性的理解。比如使用某個類呼叫方法,不存線上程安全的問題,可以考慮單例模式,避免物件重複建立;比如多重if...
else,可以嘗試提取公共的返回,使用工廠模式。

對於第二個問題的回答,首先是基於第一個問題的,在第一個問題回答的基礎上,如何用設計模式我再提出一點個人的見解:

使用設計模式最怕的是把簡單問題複雜化,為了使用設計模式而使用設計模式。
需要注意的是,使用設計模式,是為了提高程式碼的可用性、可維護性、擴充套件性,而不是為了展示個人的技術有多麼高深。程式碼寫出來最終還是要給別人看,可能寫這段程式碼的人不在了,需要給別人維護的,因此切記,適當的地方使用適當的設計模式,不一定非得用上設計模式。至於具體如何用,就看個人水平的高低以及實踐經驗的多少了,當然必不可少的,還有平時的思考與總結。
另外,有一個比較實用的技巧,使用設計模式的時候,將類的命名體現出設計模式的思想,比如
*Proxy、*Factory、*Observer,這樣會讓他人更方便地可以理解你程式碼的意圖。

 

抽象類還是介面?

大多數的設計模式,都是通過引入抽象層,將模組與模組之間解耦實現的。這裡的抽象層,在Java中的表現就是抽象類或者介面,儘管每種設計模式都有一定的套路(固定寫法),但是必然也要隨著需求的變化而變化,並不是套路是什麼就怎麼寫。那麼我們在設計模式具體寫的時候,應該使用的到底是抽象類還是介面呢?說一下我的看法。

首先,從一個比較理論的角度來分析這個問題,從抽象類和介面語義來看:

  • 抽象類表示的是一種A是B的關係
  • 介面表示的是一種A有B的行為的關係

所以,碰到具體的情況,可以先分析一下,你抽象出來的模組之間的關係表示的是一種什麼是什麼的關係,還是什麼有什麼的行為的關係。

當然,大多數情況下,上面的分析法,是分析不出來到底使用抽象類還是介面的,因為太理論了,從實踐的角度來看,使用抽象類或者介面,我們可以考慮幾個問題:

  • 優先使用介面,因為介面是一種完全的抽象且介面允許多實現
  • 你抽象出來的核心模組中,有沒有例項欄位?
  • 你抽象出來的核心模組中,需不需要普通方法?
  • 你抽象出來的核心模組中,需不需要建構函式進行必要的傳參?

如果後三點在你抽象出來的核心模組中,必須要使用到其中的一點或者幾點,那麼就是用抽象類,否則,介面必然是一種更好的選擇。

 

簡單工廠模式

首先是簡單工廠模式

對於簡單工廠模式的作用描述,LZ當時是這麼寫的:

原因很簡單:解耦。

A物件如果要呼叫B物件,最簡單的做法就是直接new一個B出來。這麼做有一個問題,假如C類和B類實現了同一個介面/繼承自同一個類,系統需要把B類修改成C類,程式不得不重寫A類程式碼。如果程式中有100個地方new了B物件,那麼就要修改100處。

這就是典型的程式碼耦合度太高導致的"牽一髮動全身"。所以,有一個辦法就是寫一個工廠IFactory,A與IFactory耦合,修改一下,讓所有的類都實現C介面並且IFactory生產出C的例項就可以了。

感謝@一線碼農的指正,原來我以為這段話是有問題的,現在仔細思考來看這段話沒問題。舉個最簡單的程式碼例子,定義一個工廠類:

 1 public class ObjectFactory {
 2 
 3     public static Object getObject(int i) {
 4         if (i == 1) {
 5             return new Random();
 6         } else if (i == 2) {
 7             return Runtime.getRuntime();
 8         }
 9         
10         return null;
11     }
12     
13 }

呼叫方假如不使用工廠模式,那麼我定義一段程式碼:

 1 public class UseObject {
 2 
 3     public void funtionA() {
 4         Object obj = new Random();
 5     }
 6     
 7     public void funtionB() {
 8         Object obj = new Random();
 9     }
10     
11     public void funtionC() {
12         Object obj = new Random();
13     }
14     
15 }

假如現在我不想用Random類了,我想用Runtime類了,此時三個方法都需要把"Object obj = new Random()"改為"Object obj = Runtime.getRuntime();",如果類似的程式碼有100處、1000處,那麼得改100處、1000處,非常麻煩,使用了工廠方法就不一樣了,呼叫方完全可以這麼寫:

 1 public class UseObject {
 2 
 3     private static Properties properties;
 4     
 5     static {
 6         // 載入配置檔案
 7     }
 8     
 9     public void funtionA() {
10         Object obj = ObjectFactory.getObject(Integer.parseInt(properties.getProperty("XXX")));
11     }
12     
13     public void funtionB() {
14         Object obj = ObjectFactory.getObject(Integer.parseInt(properties.getProperty("XXX")));
15     }
16     
17     public void funtionC() {
18         Object obj = ObjectFactory.getObject(Integer.parseInt(properties.getProperty("XXX")));
19     }
20     
21 }

搞一個配置檔案,每次呼叫方從配置檔案中讀出一個列舉值,然後根據這個列舉值去ObjectFactory裡面拿一個Object物件例項出來。這樣,未來不管是3處還是100處、1000處,如果要修改,只需要修改一次配置檔案即可,不需要所有地方都修改,這就是使用工廠模式帶來的好處。

不過簡單工廠模式這邊自身還有一個小問題,就是如果工廠這邊新增加了一種物件,那麼工廠類必須同步新增if...else...分支,不過這個問題對於Java語言不難解決,只要定義好包路徑,完全可以通過反射的方式獲取到新增的物件而不需要修改工廠自身的程式碼。

上面的講完,LZ覺得簡單工廠模式的主要作用還有兩點:

(1)隱藏物件構造細節

(2)分離物件使用方與物件構造方,使得程式碼職責更明確,使得整體程式碼結構更優雅

先看一下第一點,舉幾個例子,比如JDK自帶的構造不同的執行緒池,最終獲取到的都是ExecutorService介面實現類:

1 @Test
2 public void testExecutors() {
3     ExecutorService es1 = Executors.newCachedThreadPool();
4     ExecutorService es2 = Executors.newFixedThreadPool(10);
5     ExecutorService es3 = Executors.newSingleThreadExecutor();
6     System.out.println(es1);
7     System.out.println(es2);
8     System.out.println(es3);
9 }

這個方法構造執行緒池是比較簡單的,複雜的比如Spring構造一個Bean物件:

1 @Test
2 public void testSpring() {
3     ClassPathXmlApplicationContext applicationContext = new ClassPathXmlApplicationContext("classpath:spring.xml");
4     Object obj = applicationContext.getBean(Object.class);
5     System.out.println(obj);
6         
7     applicationContext.close();
8     
9 }

中間流程非常長(有興趣的可以看下我寫的Spring原始碼分析的幾篇文章),構造Bean的細節不需要也沒有必要暴露給Spring使用者(當然那些想要研究框架原始碼以便更好地使用框架的除外),使用者關心的只是呼叫工廠類的某個方法可以獲取到想要的物件即可。

至於前面說的第二點,可以用設計模式六大原則的單一職責原則來理解:

單一職責原則(SRP):
1,SRP(Single Responsibilities Principle)的定義:就一個類而言,應該僅有一個引起它變化的原因。簡而言之,就是功能要單一
2,如果一個類承擔的職責過多,就等於把這些職責耦合在一起,一個職責的變化可能會削弱或者抑制這個類完成其它職責的能力。這種耦合會導致脆弱的設計,當變化發生時,設計會遭受到意想不到的破壞
3,軟體設計真正要做的許多內容,就是發現職責並把那些職責相互分離

把這段話加上我的理解就是:該使用的地方只關注使用,該構造物件的地方只關注構造物件,不需要把兩段邏輯聯絡在一起,保持一個類或者一個方法100~200行左右的程式碼量,能描述清楚要做的一件事情即可

 

單例模式

第二點講講單例模式

拿我比較喜歡的餓漢式單例模式的寫法舉例吧:

 1 public class Object {
 2 
 3     private static final Object instance = new Object();
 4     
 5     private Object() {
 6         
 7     }
 8     
 9     public static Object getInstance() {
10         return instance;
11     }
12     
13     public void functionA() {
14         
15     }
16     
17     public void functionB() {
18         
19     }
20     
21     public void functionC() {
22         
23     }
24     
25 }

然後我們呼叫的時候,會使用如下的方式呼叫functionA()、functionB()、functionC()三個方法:

1 @Test
2 public void testSingleton() {
3     Object.getInstance().functionA();
4     Object.getInstance().functionB();
5     Object.getInstance().functionC();
6 }

這麼做是沒有問題,使用單例模式可以保證Object類在物件池(也就是堆)中只被建立一次,節省了系統的開銷。但是問題是:是否需要使用單例模式,為什麼一定要把Object這個物件例項化出來?

意思是Java裡面有static關鍵字,如果將functionA()、functionB()、functionC()都加上static關鍵字,那麼呼叫方完全可以使用如下方式呼叫:

1 @Test
2 public void testSingleton() {
3     Object.functionA();
4     Object.functionB();
5     Object.functionC();
6 }

物件都不用例項化出來了,豈不是更加節省空間?

這個問題總結起來就到了使用static關鍵字呼叫方法和使用單例模式呼叫方法的區別上了,關於這兩種做法有什麼區別,我個人的看法是沒什麼區別。所謂區別,說到底,也就是兩種,哪種消耗記憶體更少,哪種呼叫效率更高對吧,逐一看一下:

  • 從記憶體消耗上來看,真沒什麼區別,static方法也好,例項方法也好,都是佔用一定的記憶體的,但這些方法都是類初始化的時候被載入,載入完畢被儲存在方法區中
  • 從呼叫效率上來看,也沒什麼區別,方法最終在解析階段被翻譯為直接引用,儲存在方法區中,然後呼叫方法的時候拿這個直接引用去呼叫方法(學過C、C++的可能會比較好理解這一點,這叫做函式指標,意思是每個方法在記憶體中都有一個地址,可以直接通過這個地址拿到方法的起始位置,然後開始呼叫方法)

所以,無論從記憶體消耗還是呼叫效率上,通過static呼叫方法和通過單例模式呼叫方法,都沒多大區別,所以,我認為這種單例的寫法,也是完全可以把所有的方法都直接寫成靜態的。使用單例模式,無非是更加符合物件導向(OO)的程式設計原則而已。

寫程式碼這個事情,除了讓程式碼更優雅、更簡潔、更可維護、更可複用這些眾所周知的之外,不就是圖個簡單嗎,怎麼寫得簡單怎麼來,所以用哪種方式呼叫方法在我個人看來真的是純粹看個人喜好,說一下我個人的原則:整個類程式碼比較少的,一兩百行乃至更少的,使用static直接調方法,不例項化物件;整個類程式碼比較多的,邏輯比較複雜的,使用單例模式

畢竟,單例單例,這個物件還是存在的,那必然可以繼承。整個類程式碼比較多的,其中有一個或者多個方法不符合我當前業務邏輯,沒法繼承,使用靜態方法直接呼叫的話,得把整個類都複製一遍,然後改其中幾個方法,相對麻煩;使用單例的話,其中有一個或者多個方法不符合我當前業務邏輯,直接繼承一下改這幾個方法就可以了。類程式碼比較少的類,反正複製黏貼改一下也無所謂。

 

模板模式

接著是模板模式,模板模式我本人並沒有專門寫過文章,因此這裡網上找了一篇我認為把模板模式講清楚的文章。

對於一個架構師、CTO,反正只要涉及到寫底層程式碼的程式設計師而言,模板模式都是非常重要的。模板模式簡單說就是程式碼設計人員定義好整個程式碼處理流程,將變化的地方抽象出來,交給子類去實現。根據我自己的經驗,模板模式的使用,對於程式碼設計人員來說有兩個難點:

(1)主流程必須定義得足夠寬鬆,保證子類有足夠的空間去擴充套件

(2)主流程必須定義得足夠嚴謹,保證抽離出來的部分都是關鍵的部分

這兩點看似有點矛盾,其實是不矛盾的。第一點是站在擴充套件性的角度而言,第二點是站在業務角度而言的。假如有這麼一段模板程式碼:

 1 public abstract class Template {
 2 
 3     protected abstract void a();
 4     protected abstract void b();
 5     protected abstract void c();
 6     
 7     public void process(int i, int j) {
 8         if (i == 1 || i == 2 || i == 3) {
 9             a();
10         } else if (i == 4 || i == 5 || i == 6) {
11             if (j > 1) {
12                 b();
13             } else {
14                 a();
15             }
16         } else if (i == 6) {
17             if (j < 10) {
18                 c();
19             } else {
20                 b();
21             }
22         } else {
23             c();
24         }
25     }
26     
27 }

我不知道這段程式碼例子舉得妥當不妥當,但我想說說我想表達的意思:這段模板程式碼定義得足夠嚴謹,但是缺乏擴充套件性。因為我認為在抽象方法前後加太多的業務邏輯,比如太多的條件、太多的迴圈,會很容易將一些需要抽象讓子類自己去實現的邏輯放在公共邏輯裡面,這樣會導致兩個問題:

(1)抽象部分細分太厲害,導致擴充套件性降低,子類只能按照定義死的邏輯去寫,比如a()方法中有一些值需要在c()方法中使用就只能通過ThreadLocal或者某些公共類去實現,反而增加了程式碼的難度

(2)子類發現該抽象的部分被放到公共邏輯裡面去了,無法完成程式碼要求

最後提一點,我認為模板模式對梳理程式碼思路是非常有用的。因為模板模式的核心是抽象,因此在遇到比較複雜的業務流程的時候,不妨嘗試一下使用模板模式,對核心部分進行抽象,以梳理邏輯,也是一種不錯的思路,至少我用這種方法寫出過一版比較複雜的程式碼。

 

策略模式

策略模式,一種可以認為和模板模式有一點點像的設計模式,至於策略模式和模板模式之間的區別,後面視篇幅再聊。

策略模式其實比較簡單,但是在使用中我有一點點的新認識,舉個例子吧:

 1 public void functionA() {
 2     // 一段邏輯,100行
 3 
 4     System.out.println();
 5     System.out.println();
 6     System.out.println();
 7     System.out.println();
 8     System.out.println();
 9     System.out.println();   
10 }

一個很正常的方法funtionA(),裡面有段很長(就假設是這裡的100行的程式碼),以後改程式碼的時候發現這100行程式碼寫得有點問題,這時候怎麼辦,有兩種做法:

(1)直接刪除這100行程式碼。但是直接刪除的話,有可能後來寫程式碼的人想檢視以前寫的程式碼,怎麼辦?肯定有人提出用版本管理工具SVN、Git啊,不都可以檢視程式碼歷史記錄嗎?但是,一來這樣比較麻煩每次都要檢視程式碼歷史記錄,二來如果當時的網路不好無法檢視程式碼歷史記錄呢?

(2)直接註釋這100行程式碼,在下面寫新的邏輯。這樣的話,可以是可以檢視以前的程式碼了,但是長長的百行註釋放在那邊,非常影響程式碼的可讀性,非常不推薦

這個時候,就推薦使用策略模式了,這100行邏輯完全可以抽象為一段策略,所有策略的實現放在一個package下,這樣既把原有的程式碼保留了下來,可以在同一個package下方便地檢視,又可以根據需求更換策略,非常方便。

應網友朋友要求,補充一下程式碼,這樣的,functionA()可以這麼改,首先定義一段抽象策略:

1 package org.xrq.test.design.strategy;
2 
3 public interface Strategy {
4 
5     public void opt();
6     
7 }

然後定義一個策略A:

 1 package org.xrq.test.design.strategy.impl;
 2 
 3 import org.xrq.test.design.strategy.Strategy;
 4 
 5 public class StrategyA implements Strategy {
 6 
 7     @Override
 8     public void opt() {
 9         
10     }
11     
12 }

用的時候這麼使用:

 1 public class UseStrategy {
 2 
 3     private Strategy strategy;
 4     
 5     public UseStrategy(Strategy strategy) {
 6         this.strategy= strategy; 
 7     }
 8     
 9     public void function() {
10         strategy.opt();
11         
12         System.out.println();
13         System.out.println();
14         System.out.println();
15         System.out.println();
16         System.out.println();
17         System.out.println();
18     }
19     
20 }

使用UseStrategy類的時候,只要在建構函式中傳入new StrategyA()即可。此時,如果要換策略,可以在同一個package下定義一個策略B:

 1 package org.xrq.test.design.strategy.impl;
 2 
 3 import org.xrq.test.design.strategy.Strategy;
 4 
 5 public class StrategyB implements Strategy {
 6 
 7     @Override
 8     public void opt() {
 9         
10     }
11     
12 }

使用使用UseStrategy類的時候,需要更換策略,可在建構函式中傳入new StrategyB()。這樣一種寫法,就達到了我說的目的:

1、程式碼的實現更加優雅,呼叫方只需要傳入不同的Strategy介面的實現即可

2、原有的程式碼被保留了下來,因為所有的策略都放在同一個package下,可以方便地檢視原來的程式碼是怎麼寫的

 

介面卡模式

介面卡模式,這種設計模式有一定的寫法,但是從我的實踐經驗以及對Jdk原始碼閱讀的思考來說,介面卡模式以一種思想的角度來理解似乎更為合適,其思想的核心就是:將一個介面通過某種方式轉換為另一種介面

比如我們說到Java IO使用了介面卡模式,典型場景就是位元組流和字元流的轉換,看一下原始碼:

 1 public class InputStreamReader extends Reader {
 2 
 3     private final StreamDecoder sd;
 4 
 5     /**
 6      * Creates an InputStreamReader that uses the default charset.
 7      *
 8      * @param  in   An InputStream
 9      */
10     public InputStreamReader(InputStream in) {
11         super(in);
12         try {
13             sd = StreamDecoder.forInputStreamReader(in, this, (String)null); // ## check lock object
14         } catch (UnsupportedEncodingException e) {
15             // The default encoding should always be available
16             throw new Error(e);
17         }
18     }
19     
20     ....

看到,輸入的是一個InputStream即位元組輸入流,但輸出的是InputStreamReader即字元輸入流,兩個介面的轉換是通過StreamDecoder來進行轉換的,但是這裡我們說位元組流與字元流的轉換使用到了介面卡模式。

再比如說Arrays這個陣列工具類,可傳入一個陣列,返回一個List:

 1 public static <T> List<T> asList(T... a) {
 2     return new ArrayList<>(a);
 3 }

這裡實現了陣列(T... a這種不可變引數的寫法,在JVM層面就是轉換為陣列進行處理的)到介面的轉換,我們也認為是一種介面卡模式。

就這兩個例子來看,並沒有遵從介面卡模式的寫法,所以,我認為不用太過於糾結介面卡模式的寫法,將介面卡模式換一個角度,認為是一種思想,或許能更好地理解Java中的介面卡。

 

裝飾器模式與代理模式的差別

代理模式裝飾器模式,我在部落格裡面都有寫過相關的文章,比較詳細地寫明瞭兩種設計模式是什麼、如何寫。觀察仔細或者喜歡思考的朋友們一定會注意到這兩種設計模式是非常相似的兩種設計模式,其核心歸納起來都可以表示為這樣一種流程:

解釋起來就是三句話:

  1. 定義一個頂層的抽象
  2. 實現頂層的抽象
  3. 實現頂層的的類中持有頂層抽象的一個引用

因此,這兩種設計模式的實現機制基本是一致的。既然如此,那麼他們的區別在哪呢?就這個問題說說我個人的思考,分別是從使用和語義的角度來說。

從使用的角度來說:

  • 裝飾器模式通過建構函式遞迴地建立物件
  • 代理模式(動態代理,靜態代理一來不常用、二來和裝飾器模式差不多)通過Jdk自帶的InvocationHandler與Proxy建立被代理物件的代理物件,並通過代理物件控制被代理物件的訪問

從語義的角度來說:

  • 裝飾器模式強調的是給物件增加功能
  • 代理模式強調的是控制物件的訪問

舉個例子,一個西瓜:

  • 我們可以給西瓜加上冰,成為冰鎮西瓜,讓西瓜更可口,這是裝飾,我們不會說加冰這個動作是為了控制西瓜的訪問
  • 我們買西瓜可以通過中間商幫我們去買,因為有可能以更便宜的價格拿到西瓜,這是代理,我們不會說中間商增加了西瓜的功能,因為西瓜還是那個西瓜

因此,相當於說代理模式,被代理物件功能沒有變化,還是那個功能;裝飾器模式,被裝飾物件的功能是增強了的

從問題的語義上,我們應當比較好判斷應當使用裝飾器模式還是代理模式去解決此問題。

 

結語

IT圈流傳著一句話:“Talk is cheap,show me the code”。本文的內容都是基於個人平時工作經驗,對於設計模式使用的總結,一切來源於實踐又迴歸於實踐,網友朋友們平時一定要多用、多想,一定會有更大的收穫,對設計模式也才會有更多的思考。

相關文章