0.概述.設計模式的基本概念及原則
設計模式,即Design Patterns,是指在軟件設計中,被反覆使用的一種代碼設計經驗。使用設計模式的目的是爲了可重用代碼,提高代碼的可擴展性和可維護性。
爲什麼要使用設計模式?根本原因還是軟件開發要實現可維護、可擴展,就必須儘量複用代碼,並且降低代碼的耦合度。設計模式主要是基於OOP編程提煉的,它基於以下幾個原則:
0.1.開閉原則
由Bertrand Meyer提出的開閉原則(Open Closed Principle)是指,軟件應該對擴展開放,而對修改關閉。這裏的意思是在增加新功能的時候,能不改代碼就儘量不要改,如果只增加代碼就完成了新功能,那是最好的。
0.2.里氏替換原則
里氏替換原則是Barbara Liskov提出的,這是一種面向對象的設計原則,即如果我們調用一個父類的方法可以成功,那麼替換成子類調用也應該完全可以運行。
設計模式把一些常用的設計思想提煉出一個個模式,然後給每個模式命名,這樣在使用的時候更方便交流。GoF把23個常用模式分爲創建型模式、結構型模式和行爲型模式三類,我們後續會一一講解。
學習設計模式,關鍵是學習設計思想,不能簡單地生搬硬套,也不能爲了使用設計模式而過度設計,要合理平衡設計的複雜度和靈活性,並意識到設計模式也並不是萬能的。
1.創建型模式
創建型模式關注點是如何創建對象,其核心思想是要把對象的創建和使用相分離,這樣使得兩者能相對獨立地變換。
創建型模式包括:
- 工廠方法:Factory Method
- 抽象工廠:Abstract Factory
- 建造者:Builder
- 原型:Prototype
- 單例:Singleton
1.1.工廠方法
1.1.1.工廠方法介紹
說明:定義一個用於創建對象的接口,讓子類決定實例化哪一個類。Factory Method使一個類的實例化延遲到其子類。
工廠方法即Factory Method,是一種對象創建型模式。
工廠方法的目的是使得創建對象和使用對象是分離的,並且客戶端總是引用抽象工廠和抽象產品:
我們以具體的例子來說:假設我們希望實現一個解析字符串到Number的Factory,可以定義如下:
public interface NumberFactory {
Number parse(String s);
}
有了工廠接口,再編寫一個工廠的實現類:
public class NumberFactoryImpl implements NumberFactory {
public Number parse(String s) {
return new BigDecimal(s);
}
}
而產品接口是Number,NumberFactoryImpl返回的實際產品是BigDecimal。
那麼客戶端如何創建NumberFactoryImpl呢?通常我們會在接口Factory中定義一個靜態方法getFactory()來返回真正的子類:
public interface NumberFactory {
// 創建方法:
Number parse(String s);
// 獲取工廠實例:
static NumberFactory getFactory() {
return impl;
}
static NumberFactory impl = new NumberFactoryImpl();
}
在客戶端中,我們只需要和工廠接口NumberFactory以及抽象產品Number打交道:
NumberFactory factory = NumberFactory.getFactory();
Number result = factory.parse("123.456");
調用方可以完全忽略真正的工廠NumberFactoryImpl和實際的產品BigDecimal,這樣做的好處是允許創建產品的代碼獨立地變換,而不會影響到調用方。
1.1.2.靜態工廠方法(Static Factory Method)
有的童鞋會問:一個簡單的parse()需要寫這麼複雜的工廠嗎?實際上大多數情況下我們並不需要抽象工廠,而是通過靜態方法直接返回產品,即:
public class NumberFactory {
public static Number parse(String s) {
return new BigDecimal(s);
}
}
這種簡化的使用靜態方法創建產品的方式稱爲靜態工廠方法(Static Factory Method)。靜態工廠方法廣泛地應用在Java標準庫中。例如:
Integer n = Integer.valueOf(100);
Integer既是產品又是靜態工廠。它提供了靜態方法valueOf()來創建Integer。那麼這種方式和直接寫new Integer(100)有何區別呢?我們觀察valueOf()方法:
public final class Integer {
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
...
}
它的好處在於,valueOf()內部可能會使用new創建一個新的Integer實例,但也可能直接返回一個緩存的Integer實例。對於調用方來說,沒必要知道Integer創建的細節。
工廠方法可以隱藏創建產品的細節,且不一定每次都會真正創建產品,完全可以返回緩存的產品,從而提升速度並減少內存消耗。
如果調用方直接使用Integer n = new Integer(100),那麼就失去了使用緩存優化的可能性。
我們經常使用的另一個靜態工廠方法是List.of():
List<String> list = List.of("A", "B", "C");
這個靜態工廠方法接收可變參數,然後返回List接口。需要注意的是,調用方獲取的產品總是List接口,而且並不關心它的實際類型。即使調用方知道List產品的實際類型是java.util.ImmutableCollections$ListN,也不要去強制轉型爲子類,因爲靜態工廠方法List.of()保證返回List,但也完全可以修改爲返回java.util.ArrayList。這就是里氏替換原則:返回實現接口的任意子類都可以滿足該方法的要求,且不影響調用方。
總是引用接口而非實現類,能允許變換子類而不影響調用方,即儘可能面向抽象編程。
和List.of()類似,我們使用MessageDigest時,爲了創建某個摘要算法,總是使用靜態工廠方法getInstance(String):
MessageDigest md5 = MessageDigest.getInstance("MD5");
MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
調用方通過產品名稱獲得產品實例,不但調用簡單,而且獲得的引用仍然是MessageDigest這個抽象類。
1.1.3.小結
- 工廠方法是指定義工廠接口和產品接口,但如何創建實際工廠和實際產品被推遲到子類實現,從而使調用方只和抽象工廠與抽象產品打交道;
- 實際更常用的是更簡單的靜態工廠方法,它允許工廠內部對創建產品進行優化;
- 調用方儘量持有接口或抽象類,避免持有具體類型的子類,以便工廠方法能隨時切換不同的子類返回,卻不影響調用方代碼。
2.抽象工廠
2.1.概念
說明:提供一個創建一系列相關或相互依賴對象的接口,而無需指定它們具體的類。
抽象工廠模式(Abstract Factory)是一個比較複雜的創建型模式。
抽象工廠模式和工廠方法不太一樣,它要解決的問題比較複雜,不但工廠是抽象的,產品是抽象的,而且有多個產品需要創建,因此,這個抽象工廠會對應到多個實際工廠,每個實際工廠負責創建多個實際產品:
2.2.例子
這種模式有點類似於多個供應商負責提供一系列類型的產品。我們舉個例子:
假設我們希望爲用戶提供一個Markdown文本轉換爲HTML和Word的服務,它的接口定義如下:
public interface AbstractFactory {
// 創建Html文檔:
HtmlDocument createHtml(String md);
// 創建Word文檔:
WordDocument createWord(String md);
}
注意到上面的抽象工廠僅僅是一個接口,沒有任何代碼。同樣的,因爲HtmlDocument和WordDocument都比較複雜,現在我們並不知道如何實現它們,所以只有接口:
// Html文檔接口:
public interface HtmlDocument {
String toHtml();
void save(Path path) throws IOException;
}
// Word文檔接口:
public interface WordDocument {
void save(Path path) throws IOException;
}
這樣,我們就定義好了抽象工廠(AbstractFactory)以及兩個抽象產品(HtmlDocument和WordDocument)。因爲實現它們比較困難,我們決定讓供應商來完成。
現在市場上有兩家供應商:FastDoc Soft的產品便宜,並且轉換速度快,而GoodDoc Soft的產品貴,但轉換效果好。我們決定同時使用這兩家供應商的產品,以便給免費用戶和付費用戶提供不同的服務。
我們先看看FastDoc Soft的產品是如何實現的。首先,FastDoc Soft必須要有實際的產品,即FastHtmlDocument和FastWordDocument:
public class FastHtmlDocument implements HtmlDocument {
public String toHtml() {
...
}
public void save(Path path) throws IOException {
...
}
}
public class FastWordDocument implements WordDocument {
public void save(Path path) throws IOException {
...
}
}
然後,FastDoc Soft必須提供一個實際的工廠來生產這兩種產品,即FastFactory:
public class FastFactory implements AbstractFactory {
public HtmlDocument createHtml(String md) {
return new FastHtmlDocument(md);
}
public WordDocument createWord(String md) {
return new FastWordDocument(md);
}
}
這樣,我們就可以使用FastDoc Soft的服務了。客戶端編寫代碼如下:
// 創建AbstractFactory,實際類型是FastFactory:
AbstractFactory factory = new FastFactory();
// 生成Html文檔:
HtmlDocument html = factory.createHtml("#Hello\nHello, world!");
html.save(Paths.get(".", "fast.html"));
// 生成Word文檔:
WordDocument word = fastFactory.createWord("#Hello\nHello, world!");
word.save(Paths.get(".", "fast.doc"));
如果我們要同時使用GoodDoc Soft的服務怎麼辦?因爲用了抽象工廠模式,GoodDoc Soft只需要根據我們定義的抽象工廠和抽象產品接口,實現自己的實際工廠和實際產品即可:
// 實際工廠:
public class GoodFactory implements AbstractFactory {
public HtmlDocument createHtml(String md) {
return new GoodHtmlDocument(md);
}
public WordDocument createWord(String md) {
return new GoodWordDocument(md);
}
}
// 實際產品:
public class GoodHtmlDocument implements HtmlDocument {
...
}
public class GoodWordDocument implements HtmlDocument {
...
}
客戶端要使用GoodDoc Soft的服務,只需要把原來的new FastFactory()切換爲new GoodFactory()即可。
注意到客戶端代碼除了通過new創建了FastFactory或GoodFactory外,其餘代碼只引用了產品接口,並未引用任何實際產品(例如,FastHtmlDocument),如果把創建工廠的代碼放到AbstractFactory中,就可以連實際工廠也屏蔽了:
public interface AbstractFactory {
public static AbstractFactory createFactory(String name) {
if (name.equalsIgnoreCase("fast")) {
return new FastFactory();
} else if (name.equalsIgnoreCase("good")) {
return new GoodFactory();
} else {
throw new IllegalArgumentException("Invalid factory name");
}
}
}
2.3.小結
- 抽象工廠模式是爲了讓創建工廠和一組產品與使用相分離,並可以隨時切換到另一個工廠以及另一組產品;
- 抽象工廠模式實現的關鍵點是定義工廠接口和產品接口,但如何實現工廠與產品本身需要留給具體的子類實現,客戶端只和抽象工廠與抽象產品打交道。
3.生成器
概念:將一個複雜對象的構建與它的表示分離,使得同樣的構建過程可以創建不同的表示。
生成器模式(Builder)是使用多個“小型”工廠來最終創建出一個完整對象。
當我們使用Builder的時候,一般來說,是因爲創建這個對象的步驟比較多,每個步驟都需要一個零部件,最終組合成一個完整的對象。
3.1.builder模式例子
我們仍然以Markdown轉HTML爲例,因爲直接編寫一個完整的轉換器比較困難,但如果針對類似下面的一行文本:
# this is a heading
轉換成HTML就很簡單:
<h1>this is a heading</h1>
因此,我們把Markdown轉HTML看作一行一行的轉換,每一行根據語法,使用不同的轉換器:
- 如果以#開頭,使用HeadingBuilder轉換;
- 如果以>開頭,使用QuoteBuilder轉換;
- 如果以- - -開頭,使用HrBuilder轉換;
- 其餘使用ParagraphBuilder轉換。
這個HtmlBuilder寫出來如下:
public class HtmlBuilder {
private HeadingBuilder headingBuilder = new HeadingBuilder();
private HrBuilder hrBuilder = new HrBuilder();
private ParagraphBuilder paragraphBuilder = new ParagraphBuilder();
private QuoteBuilder quoteBuilder = new QuoteBuilder();
public String toHtml(String markdown) {
StringBuilder buffer = new StringBuilder();
markdown.lines().forEach(line -> {
if (line.startsWith("#")) {
buffer.append(headingBuilder.buildHeading(line)).append('\n');
} else if (line.startsWith(">")) {
buffer.append(quoteBuilder.buildQuote(line)).append('\n');
} else if (line.startsWith("---")) {
buffer.append(hrBuilder.buildHr(line)).append('\n');
} else {
buffer.append(paragraphBuilder.buildParagraph(line)).append('\n');
}
});
return buffer.toString();
}
}
注意觀察上述代碼,HtmlBuilder並不是一次性把整個Markdown轉換爲HTML,而是一行一行轉換,並且,它自己並不會將某一行轉換爲特定的HTML,而是根據特性把每一行都“委託”給一個XxxBuilder去轉換,最後,把所有轉換的結果組合起來,返回給客戶端。
這樣一來,我們只需要針對每一種類型編寫不同的Builder。例如,針對以#開頭的行,需要HeadingBuilder:
public class HeadingBuilder {
public String buildHeading(String line) {
int n = 0;
while (line.charAt(0) == '#') {
n++;
line = line.substring(1);
}
return String.format("<h%d>%s</h%d>", n, line.strip(), n);
}
}
注意:實際解析Markdown是帶有狀態的,即下一行的語義可能與上一行相關。這裏我們簡化了語法,把每一行視爲可以獨立轉換。
可見,使用Builder模式時,適用於創建的對象比較複雜,最好一步一步創建出“零件”,最後再裝配起來。
JavaMail的MimeMessage就可以看作是一個Builder模式,只不過Builder和最終產品合二爲一,都是MimeMessage:
Multipart multipart = new MimeMultipart();
// 添加text:
BodyPart textpart = new MimeBodyPart();
textpart.setContent(body, "text/html;charset=utf-8");
multipart.addBodyPart(textpart);
// 添加image:
BodyPart imagepart = new MimeBodyPart();
imagepart.setFileName(fileName);
imagepart.setDataHandler(new DataHandler(new ByteArrayDataSource(input, "application/octet-stream")));
multipart.addBodyPart(imagepart);
MimeMessage message = new MimeMessage(session);
// 設置發送方地址:
message.setFrom(new InternetAddress("[email protected]"));
// 設置接收方地址:
message.setRecipient(Message.RecipientType.TO, new InternetAddress("[email protected]"));
// 設置郵件主題:
message.setSubject("Hello", "UTF-8");
// 設置郵件內容爲multipart:
message.setContent(multipart);
很多時候,我們可以簡化Builder模式,以鏈式調用的方式來創建對象。例如,我們經常編寫這樣的代碼:
StringBuilder builder = new StringBuilder();
builder.append(secure ? "https://" : "http://")
.append("www.liaoxuefeng.com")
.append("/")
.append("?t=0");
String url = builder.toString();
由於我們經常需要構造URL字符串,可以使用Builder模式編寫一個URLBuilder,調用方式如下:
String url = URLBuilder.builder() // 創建Builder
.setDomain("www.liaoxuefeng.com") // 設置domain
.setScheme("https") // 設置scheme
.setPath("/") // 設置路徑
.setQuery(Map.of("a", "123", "q", "K&R")) // 設置query
.build(); // 完成build
3.2.小結
Builder模式是爲了創建一個複雜的對象,需要多個步驟完成創建,或者需要多個零件組裝的場景,且創建過程中可以靈活調用不同的步驟或組件。
4.原型
概念:用原型實例指定創建對象的種類,並且通過拷貝這些原型創建新的對象。
原型模式,即Prototype,是指創建新對象的時候,根據現有的一個原型來創建。
4.1.原型模式例子
我們舉個例子:如果我們已經有了一個String[]數組,想再創建一個一模一樣的String[]數組,怎麼寫?
實際上創建過程很簡單,就是把現有數組的元素複製到新數組。如果我們把這個創建過程封裝一下,就成了原型模式。用代碼實現如下:
// 原型:
String[] original = { "Apple", "Pear", "Banana" };
// 新對象:
String[] copy = Arrays.copyOf(original, original.length);
對於普通類,我們如何實現原型拷貝?Java的Object提供了一個clone()方法,它的意圖就是複製一個新的對象出來,我們需要實現一個Cloneable接口來標識一個對象是“可複製”的:
public class Student implements Cloneable {
private int id;
private String name;
private int score;
// 複製新對象並返回:
public Object clone() {
Student std = new Student();
std.id = this.id;
std.name = this.name;
std.score = this.score;
return std;
}
}
使用的時候,因爲clone()的方法簽名是定義在Object中,返回類型也是Object,所以要強制轉型,比較麻煩:
Student std1 = new Student();
std1.setId(123);
std1.setName("Bob");
std1.setScore(88);
// 複製新對象:
Student std2 = (Student) std1.clone();
System.out.println(std1);
System.out.println(std2);
System.out.println(std1 == std2); // false
實際上,使用原型模式更好的方式是定義一個copy()方法,返回明確的類型:
public class Student {
private int id;
private String name;
private int score;
public Student copy() {
Student std = new Student();
std.id = this.id;
std.name = this.name;
std.score = this.score;
return std;
}
}
原型模式應用不是很廣泛,因爲很多實例會持有類似文件、Socket這樣的資源,而這些資源是無法複製給另一個對象共享的,只有存儲簡單類型的“值”對象可以複製。
4.2.小結
原型模式是根據一個現有對象實例複製出一個新的實例,複製出的類型和屬性與原實例相同。
5.單例
5.1.概念
保證一個類僅有一個實例,並提供一個訪問它的全局訪問點。
單例模式(Singleton)的目的是爲了保證在一個進程中,某個類有且僅有一個實例。
因爲這個類只有一個實例,因此,自然不能讓調用方使用new Xyz()來創建實例了。所以,單例的構造方法必須是private,這樣就防止了調用方自己創建實例,但是在類的內部,是可以用一個靜態字段來引用唯一創建的實例的:
public class Singleton {
// 靜態字段引用唯一實例:
private static final Singleton INSTANCE = new Singleton();
// private構造方法保證外部無法實例化:
private Singleton() {
}
}
那麼問題來了,外部調用方如何獲得這個唯一實例?
答案是提供一個靜態方法,直接返回實例:
public class Singleton {
// 靜態字段引用唯一實例:
private static final Singleton INSTANCE = new Singleton();
// 通過靜態方法返回實例:
public static Singleton getInstance() {
return INSTANCE;
}
// private構造方法保證外部無法實例化:
private Singleton() {
}
}
或者直接把static變量暴露給外部:
public class Singleton {
// 靜態字段引用唯一實例:
public static final Singleton INSTANCE = new Singleton();
// private構造方法保證外部無法實例化:
private Singleton() {
}
}
5.2.單例模式的實現方式
- 只有private構造方法,確保外部無法實例化;
- 通過private static變量持有唯一實例,保證全局唯一性;
- 通過public static方法返回此唯一實例,使外部調用方能獲取到實例。
Java標準庫有一些類就是單例,例如Runtime這個類:
Runtime runtime = Runtime.getRuntime();
有些童鞋可能聽說過延遲加載,即在調用方第一次調用getInstance()時才初始化全局唯一實例,類似這樣:
public class Singleton {
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}
private Singleton() {
}
}
遺憾的是,這種寫法在多線程中是錯誤的,在競爭條件下會創建出多個實例。必須對整個方法進行加鎖:
public synchronized static Singleton getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}
但加鎖會嚴重影響併發性能。還有些童鞋聽說過雙重檢查,類似這樣:
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
然而,由於Java的內存模型,雙重檢查在這裏不成立。要真正實現延遲加載,只能通過Java的ClassLoader機制完成。如果沒有特殊的需求,使用Singleton模式的時候,最好不要延遲加載,這樣會使代碼更簡單。
另一種實現Singleton的方式是利用Java的enum,因爲Java保證枚舉類的每個枚舉都是單例,所以我們只需要編寫一個只有一個枚舉的類即可:
public enum World {
// 唯一枚舉:
INSTANCE;
private String name = "world";
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
}
枚舉類也完全可以像其他類那樣定義自己的字段、方法,這樣上面這個World類在調用方看來就可以這麼用:
String name = World.INSTANCE.getName();
使用枚舉實現Singleton還避免了第一種方式實現Singleton的一個潛在問題:即序列化和反序列化會繞過普通類的private構造方法從而創建出多個實例,而枚舉類就沒有這個問題。
那我們什麼時候應該用Singleton呢?實際上,很多程序,尤其是Web程序,大部分服務類都應該被視作Singleton,如果全部按Singleton的寫法寫,會非常麻煩,所以,通常是通過約定讓框架(例如Spring)來實例化這些類,保證只有一個實例,調用方自覺通過框架獲取實例而不是new操作符:
@Component // 表示一個單例組件
public class MyService {
...
}
因此,除非確有必要,否則Singleton模式一般以“約定”爲主,不會刻意實現它。
5.3.小結
- Singleton模式是爲了保證一個程序的運行期間,某個類有且只有一個全局唯一實例;
- Singleton模式既可以嚴格實現,也可以以約定的方式把普通類視作單例。