前言
在【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中的適配器。
裝飾器模式與代理模式的差別
代理模式與裝飾器模式,我在博客里面都有寫過相關的文章,比較詳細地寫明了兩種設計模式是什么、如何寫。觀察仔細或者喜歡思考的朋友們一定會注意到這兩種設計模式是非常相似的兩種設計模式,其核心歸納起來都可以表示為這樣一種流程:
解釋起來就是三句話:
- 定義一個頂層的抽象
- 實現頂層的抽象
- 實現頂層的的類中持有頂層抽象的一個引用
因此,這兩種設計模式的實現機制基本是一致的。既然如此,那么他們的區別在哪呢?就這個問題說說我個人的思考,分別是從使用和語義的角度來說。
從使用的角度來說:
- 裝飾器模式通過構造函數遞歸地創建對象
- 代理模式(動態代理,靜態代理一來不常用、二來和裝飾器模式差不多)通過Jdk自帶的InvocationHandler與Proxy創建被代理對象的代理對象,並通過代理對象控制被代理對象的訪問
從語義的角度來說:
- 裝飾器模式強調的是給對象增加功能
- 代理模式強調的是控制對象的訪問
舉個例子,一個西瓜:
- 我們可以給西瓜加上冰,成為冰鎮西瓜,讓西瓜更可口,這是裝飾,我們不會說加冰這個動作是為了控制西瓜的訪問
- 我們買西瓜可以通過中間商幫我們去買,因為有可能以更便宜的價格拿到西瓜,這是代理,我們不會說中間商增加了西瓜的功能,因為西瓜還是那個西瓜
因此,相當於說代理模式,被代理對象功能沒有變化,還是那個功能;裝飾器模式,被裝飾對象的功能是增強了的。
從問題的語義上,我們應當比較好判斷應當使用裝飾器模式還是代理模式去解決此問題。
結語
IT圈流傳着一句話:“Talk is cheap,show me the code”。本文的內容都是基於個人平時工作經驗,對於設計模式使用的總結,一切來源於實踐又回歸於實踐,網友朋友們平時一定要多用、多想,一定會有更大的收獲,對設計模式也才會有更多的思考。