對不起,來晚了,御姐趣講設計模式
御姐力作,深入淺出,妙趣橫生,值得一看!
## 引言
你好,歡迎來到設計模式的世界,這一篇我將用一種引導、啟迪的思路去講述設計模式。在程序員的世界里,設計模式就相當于武俠世界的劍招、套路。掌握了招式,你的武學修為會得到極大提升,最終達到無招勝有招的境界。
+ 首先,我會告訴大家設計模式是什么,不是什么。
+ 然后,簡單介紹一下設計模式的分類,簡單羅列一下各設計模式。
+ 接著,闡述面向對象設計一個非常重要的設計原則:**合成復用原則**,它是核心原則,提高復用一直是軟件工程師的不懈追求,它貫穿于設計模式一書。
+ 最后,從實用出發,我會詳細描述兩個最經典最常用的設計模式:單例和觀察者。我不只是介紹這兩種模式的用途和實現方式,還會結合自己工作實踐,拋出限制與約束,提醒注意點,以及跟其他模式的配合方式。
希望你學完這一節,可以觸類旁通,在實際項目中用好設計模式,為社會做貢獻。
## 什么是設計模式
一門工程一定會有很多實踐性的經驗總結。就好比造大橋,人們會總結拱橋有哪些部件組成,有什么特點,有什么適用場合,懸索橋又有什么部件、特點、使用場合。這些從實踐中提煉出來的建筑模式又可以指導新出現的需求,比如去設計一個某市長江大橋,你會思考有哪個成熟的模式可以適用,在這個模式下,又要如何根據實際需求定制化地設計各個部件。
軟件工程也是如此。
設計模式是設計模式是軟件開發人員在軟件開發過程中面臨的一般問題的解決方案,是被反復使用,多數人知曉的,經過分類編目的代碼設計經驗的總結。
+ 設計模式是一般問題的解決方案。分析多種多樣的具體需求,常常會發現結構上和行為上具有的共性,常常會產生相似的設計。設計模式是脫離了具體需求的,某類共性問題的解決方案。
+ 設計模式是程序設計的經驗總結。在其適用范圍內正確地使用設計模式通常會產生高質量的設計。
+ 設計模式彌補了編程語言的缺陷。設計模式實現了創建時多態、雙重分派等在主流編程語言中不直接提供的功能。反過來,近年來設計思想和設計模式的發展也影響了新興語言的語言規范。
+ 設計模式是軟件工程師的一套術語。完整地描述一個設計通常要花費相當的篇幅,通過對設計歸類,可以便于快速表達設計的特點。
## 設計模式不是什么
+ 不是普適原則。設計模式并不是如SOLID設計原則一樣是放之四海而皆準的普適的原則。每個設計模式都有其適用場景,必須根據實際情況分析決定采用哪種設計模式或不使用設計模式。在一個軟件項目中設計模式并不是用得越多越好,符合實際需求的高質量的獨特設計也是好設計。
+ 不是嚴格規范。設計模式是經驗的總結,允許根據實際需要改變和改進。采用了設計模式并不意味著類的結構甚至命名都要與模式嚴格符合。在應用設計模式時應著重吸取其設計思路,根據實際需求進行設計。尤其是很多設計模式中的名稱過于寬泛,在實際項目中并不適合用作類名。
+ 不是具體類庫。設計模式有助于代碼復用,但模式本身并不是可直接復用的代碼。在設計模式中擔任特定角色的并不是特定的一個類,通常需要在具體設計中結合具體需求來實現。現代編程語言中的模板、泛型等語言特性有助于寫出更加通用的代碼,但對于很多設計模式,完全通用的代碼庫既難實現,又難使用。
+ 不是行業解決方案。并沒有說哪個模式特別適合互聯網、哪個模式專門針對自動化。設計模式關注軟件結構內在的共性,而與具體的業務領域無關。
有工程師言必稱設計模式,生搬硬套設計模式,之后又出現反設計模式的思潮,認為設計模式是騙局,無助于軟件質量提升。我認為,無論是神化設計模式亦或是反設計模式都是走極端,都是錯誤的。設計模式為我們解決一些通用性的問題提供了良好借鑒,且在大多數情況下,行之有效。設計模式并不絕對通用,在實際項目中如何抉擇用哪個設計模式或是不用設計模式,非常考驗工程師的水平和經驗。
## GOF設計模式
設計模式的流行源于一本叫《設計模式:可復用面向對象軟件的基礎》的書,這本書的作者是4個博士,也叫GOF(Gang of Four),軟件設計模式一詞由作者從建筑設計領域引入計算機科學。
書中介紹了 23 種設計模式。這些模式可以分為三大類:
- + 創建型模式:單例、原型、工廠方法、抽象工廠、建造者
- + 結構型模式:代理、適配、橋接、裝飾、外觀、享元、組合
- + 行為型模式:模板方法、策略、命令、職責鏈、狀態、觀察者、中介者、迭代器、訪問者、備忘錄、解釋器
## 合成復用原則
對于軟件復用來說,組合優于繼承,在軟件復用時,優先考慮組合關系,其次才考慮繼承關系。
面向對象設計的特點之一是繼承,子類包含父類的所有屬性和方法,因此一個很自然的想法是為了復用父類的代碼而繼承。但是實踐發現,用繼承關系來實現軟件的復用有很多缺點,一般來說更為合理的方式是,用多個對象的組合關系來實現復用。
+ 繼承關系是子類“是一個”父類的關系,但如果是為了復用父類的已有功能來實現子類的新功能,常常會違反里氏替換原則。
+ 組合關系更容易處理有多個可復用模塊的情況。多重繼承會導致結構復雜不易維護。
+ 組合關系更靈活易擴展,只要使用適當的設計模式,使用者和被使用者都可被修改、擴展、替換。
+ 組合關系可以提供運行時的靈活性。可以在運行時指定一個模塊的底層實現,或者運行時替換一個對象的內部實現。
為了體現它的重要性,這里我們看一個具體的例子。
我們知道,隊列是一種先進先出的數據結構。在隊列上可以執行添加和刪除一個元素的操作,添加元素稱為入隊,刪除元素稱為出隊,并且元素出隊的順序與入隊的順序相同。顯然,隊列可以用雙向鏈表來實現,那么,我們要不要把隊列設計成雙向鏈表的子類呢?
咋一看,可以讓queue私有繼承list,隱藏掉list所有的方法,然后實現隊列的push方法調用list的push_pack方法,隊列的pop方法調用list的pop_front方法。非常簡單直接。
但是,這種實現方式是有問題的。到底啥問題?一言兩語也講不清楚,你自己想去吧。
因此,C++和Java的標準庫都沒有采用這種繼承的方式實現隊列。
在C++的stl中,queue被設計成一個容器適配器。只要是是實現了push_back、pop_front的容器,都可以作為queue的底層容器。stl中就提供了2種可以套用queue的容器,是list和deque。list就是雙向鏈表。deque的實現是數組指針的數組,與list相比減少了內存分配的次數。
在JDK中,Queue是一個interface,實現了Queue接口的有LinkedList、ArrayDeque、ConcurrentLinkedQueue、LinkedBlockingQueue等許多具體類。
為了體現它的重要性,這里我將用一個實例來加深你對它的印象。如果設計一個網絡組件庫,HttpConnection應該繼承TcpConnection嗎?
HttpConnection不再能夠提供符合TcpConnection的功能,不能當作TcpConnection使用。考慮read方法,若直接暴露TcpConnection的read方法,則破壞內部結構;若提供基于HTTP協議的read方法,又無法做到功能跟父類一致。
Http協議能夠使用不同的下層協議,例如TCPv6。繼承自TcpConnection就失去了這種擴展性。
如果設計另一個類"HttpOverTcp6Connection",會導致二者有大量的重復代碼,而這些代碼恰恰是實現HTTP協議本身的功能,應復用為好。
如果希望一個程序在IPv4和IPv6網絡下都可使用,需要做很多的工作來實現在運行時(而非編譯時)根據配置文件或用戶輸入選擇HttpConnection或HttpOverTcp6Connection。
繼承關系表達類的對外提供的功能,而非類的內部實現。Java中HttpURLConnection繼承URLConnection,與之并列的是JarURLConnection,二者都提供了根據URL建立連接并通信的功能。
**下面以2個常用的設計模式為例,說明它們的應用場景和應用價值,讓大家有一個比較直觀具體的感受。**
## 單例模式
單例模式是指,某個類負責創建自己的對象,同時確保只能創建單個對象。單例模式最簡單的設計模式,也是最容易用錯的設計模式。
### 如何實現單例模式
單例模式非常簡單,這個模式中只包含一個類。實現單例模式的重點是管理單例實例的創建。
+ C++,可以通過static局部變量的方式,也可以通過static指針成員變量的條件創建方式做到(即每次GetInstance的時候判空,如果為空則new,否則直接返回)。Java可以用static指針成員變量的方式。
+ 通常為了避免使用者錯誤創建多余的對象,單例的構造函數和析構函數聲明為私有函數。
+ 多線程環境下,創建單例的代碼需要謹慎處理并發的問題。一般做法是雙重檢查加鎖(即每次判空的時候先判空一次,如果為空則加鎖再次判空)。C++的靜態局部變量可以保證線程安全,java要使用synchronized實現。
+ 多種單例,如果有依賴關系,需要仔細處理構建順序。C++的靜態局部變量在程序首次運行到變量聲明處時執行其構造函數。Java的靜態變量初始化發生在類被加載時。
### 單例模式的好處
+ 使用簡單,任何需要用到類實例的地方,直接用類的GetInstance()方法就便利的獲取到實例。
+ 可以避免使用全局變量,讓開發者有更好的OOP感,且可以讓程序員更好地控制初始化順序。
+ 它隱藏了對象的構建細節,且能避免多次構建引起的錯誤。
### 單例模式的探討
從原則上說,一個類應努力提供它應有的功能,而不應對它的使用者做出過多限制。而單例模式限制這個類的對象只存在唯一實例。因此單例模式只應在確有必要的情況下使用:
+ 技術上必須保證此對象全局唯一,例如代表應用本身、對象管理器、全局服務等。
+ 程序中多處依賴此對象,采用單例模式能使代碼得到極大簡化,例如全局配置選項。
要避免根據一時的具體需求將某類設計為單例,而極大地限制了可擴展性。例如一個選課系統如果把學校信息設計為單例,將來想要支持跨校選課時就比較困難。
尤其注意,一旦某個類設計為單例,就會形成在程序各處隨意地引用這個對象的一種傾向。這正是單例模式的便利之處,但如果并不希望一個類有如此廣泛的耦合關系,則應避免將其設計為單例。
此外,由這種便利性會引發更不利的傾向。在未經仔細設計的系統中,隨著需求變更和系統演進,單例類可能會無節制地擴展,包含各種難以歸類的數據成員和各個模塊的中轉方法。
### 替代方案
通常有以下方法可以避免使用單例模式:
+ 享元模式。例如Android SDK使用activity.getApplication() ,避免“Application.getSingleton() ”。這樣取得Application實例并不像單例模式那么方便,從而限制了Application的耦合性。而通過Activity獲取Application是符合邏輯的設計,大多數真正需要用到Application的場合并不影響使用。
+ 靜態方法。例如Unity引擎的物體查詢接口是GameObject.Find(name) ,而不是由比如“GameObjectManager”的單例類提供。靜態方法只提供單一的功能,并且調用時的寫法比單例模式更加簡潔。但須注意,只有邏輯上與某個類有緊密聯系的功能才適合作為靜態方法。靜態方法如果濫用,會導致軟件結構實際上變成了面向過程的設計。
## 觀察者模式
觀察者模式,當一個對象發生改變時,把這種改變通知給其他多個對象,從而影響其他對象的行為。又稱訂閱模式、事件模式等。
### 觀察者模式的組成
觀察者模式中包含兩個角色:
+ 被觀察者,它維護觀察者列表,并在自身發生改變時通知觀察者。也可稱為發布者、事件源等。
+ 觀察者,它將自身注冊到被觀察者維護的觀察者列表,并在接收到被觀察者的通知時做出響應。觀察者也稱訂閱者。
### 如何實現觀察者模式
被觀察者的接口應包含3個方法:增加觀察者、刪除觀察者、向觀察者發送通知。其中,增加觀察者、刪除觀察者通常由觀察者調用,用于表明哪些觀察者對象需要得到通知。發送通知方法通常由被觀察者調用,因此可以考慮定義為protected方法。發送通知方法應遍歷自身的觀察者列表,逐一調用觀察者的接收通知方法。這3個方法功能較為明確,可以用抽象類、模板、泛型等技術提供通用實現。
觀察者的接口需要提供接收通知方法,以供被觀察者調用。不同的具體觀察者類型實現各自的接收通知方法,實現當被觀察者發生改變時,觀察者應做出的響應。
由于觀察者接口只有一個方法,在C#語言中deligate來代替,在C++中可以用std::function代替,這樣進一步解耦了不同類型的觀察者,其不必派生自同一個公共接口。當然,當系統中的觀察者的確有所聯系時,則不應該過度追求解耦,顯式定義一個觀察者接口或抽象類可以使結構更為清晰、嚴謹。
觀察者模式常常與命令模式配合使用。命令模式是,將一個請求封裝為一個對象,使發出請求的責任和執行請求的責任分割開。采用命令模式,將通知或事件封裝成對象,可以使觀察者和被觀察者之間進一步解耦。例如,如果不希望在被觀察者的運行過程中穿插執行觀察者的函數,則可以保存命令稍后執行。
### 觀察者模式的特點和適用場景
每種設計模式都有其最適合的應用場景,如果正確使用,可以幫助理清復雜的耦合關系,簡化設計。但如果在不合適的場景中生搬硬套,則會把原本簡單的事情搞復雜,并不能真正解決需求。觀察者模式也不例外,在實際項目中,必須具體問題具體分析,考察需求是否符合觀察者模式的特點,決定是否選用觀察者模式。
+ 觀察者模式適合一對多的關聯關系。一個被觀察者可以有零個或多個觀察者。當然,一個程序中被觀察者可以有多個,每個被觀察者都有自己的一對多關系,而相互之間沒有關聯。
+ 邏輯上的依賴關系是單向的。被觀察者往往可以獨立運行,并不依賴觀察者。而觀察者的順利運行依賴于被觀察者的推動,離開被觀察者就運行不起來了。
+ 調用關系與邏輯關系是反向的。邏輯上被觀察者不依賴觀察者,但有事件發生時卻是被觀察者調用了觀察者的方法。
下面我們用一個例子來看如何應用觀察者模式來解決具體的需求,以及使用觀察者模式帶來的好處。
我們假設需求是這樣:某個應用程序中有多處要用到定時執行的功能,就是到一個固定的時間需要執行一個特定的函數。很自然,多處要用到的功能應該提煉出來作為一個子模塊。但另一方面,我們又不希望這個定時模塊與每一個用到了定時功能的其他模塊都有很強的耦合。
觀察者模式可以幫助我們設計定時模塊,既能服用,又有低耦合性。這里我們的示例實現如下。為了突出展示觀察者模式,我對需求做了一定簡化,我們的定時模塊固定在每天上午9點觸發,不支持自定義時間。
+ [C++語言實現AlarmClock](AlarmClock.c++)
- #include <iostream>
- #include <list>
- //簡單鬧鐘,每天早上9點響
- class AlarmClock {
- public:
- class Alarm {
- public:
- virtual ~Alarm() {}
- virtual void onClockAlarmed() = 0;
- };
- private:
- static const int TimeZone = 8; // 北京時間東8區
- static const int AlarmHour = 9;
- std::list<Alarm*> alarms;
- time_t tomorrow;
- public:
- AlarmClock() {
- //將tomorrow設置為明天9點鐘
- time_t now = time(0);
- tomorrow = now - now % 86400 - TimeZone * 3600 + AlarmHour * 3600;
- if (tomorrow < now)
- tomorrow += 86400;
- }
- AlarmClock(AlarmClock&) = delete;
- void setAlarm(Alarm* alarm) {
- alarms.push_back(alarm);
- }
- void unsetAlarm(Alarm* alarm) {
- alarms.remove(alarm);
- }
- void advance() {
- tomorrow += 86400;
- for (auto alarm : alarms) {
- alarm->onClockAlarmed();
- }
- }
- void update(time_t now) {
- while (now >= tomorrow) {
- advance();
- }
- }
- };
- // 資深程序員張三
- class TestZhangSan : public AlarmClock::Alarm {
- public:
- ~TestZhangSan() {}
- TestZhangSan(AlarmClock& clock) {
- clock.setAlarm(this);
- }
- // 開始了996的一天
- void onClockAlarmed() {
- std::cout << "Zhang San is going to work..." << std::endl;
- }
- };
- // 隔壁上夜班的王叔叔
- class TestLaoWang : public AlarmClock::Alarm {
- public:
- ~TestLaoWang(){}
- TestLaoWang(AlarmClock& clock) {
- clock.setAlarm(this);
- }
- // 下班回家睡覺
- void onClockAlarmed() {
- std::cout << "Lao Wang is going to bed..." << std::endl;
- }
- };
- int main(int argc, char **argv)
- {
- AlarmClock clock;
- TestZhangSan zhang(clock);
- TestLaoWang wang(clock);
- time_t now = time(0);
- now -= now % 3600;
- for (int i = 0; i < 24; i++) {
- std::cout << "Now:" << ctime(&now);
- clock.update(now);
- now += 3600;
- }
- return 0;
- }
+ [Java語言實現AlarmClock](AlarmClock.java)
- import java.util.Calendar;
- import java.util.List;
- import java.util.LinkedList;
- //簡單鬧鐘,每天早上9點響
- public class AlarmClock {
- public static interface Alarm {
- void onClockAlarmed();
- }
- private static final int AlarmHour = 9;
- private final List<Alarm*> alarms = new LinkedList<>();
- private Calendar tomorrow;
- public AlarmClock() {
- //將tomorrow設置為明天9點鐘
- tomorrow = Calendar.getInstance();
- boolean addDay = tomorrow.get(Calendar.HOUR_OF_DAY) >= AlarmHour;
- tomorrow.set(Calendar.HOUR_OF_DAY, AlarmHour);
- tomorrow.set(Calendar.MINUTE, 0);
- tomorrow.set(Calendar.SECOND, 0);
- tomorrow.set(Calendar.MILLISECOND, 0);
- if (addDay) {
- tomorrow.add(Calendar.DAY_OF_MONTH, 1);
- }
- }
- public void setAlarm(Alarm alarm) {
- alarms.add(alarm);
- }
- public void unsetAlarm(Alarm alarm) {
- alarms.remove(alarm);
- }
- public void advance() {
- tomorrow += 86400;
- for (Alarm alarm : alarms) {
- alarm.onClockAlarmed();
- }
- }
- public void update(Calendar now) {
- while (now >= tomorrow) {
- advance();
- }
- }
- // 資深程序員張三
- private class TestZhangSan : public Alarm {
- public:
- TestZhangSan(AlarmClock& clock) {
- clock.setAlarm(this);
- }
- // 開始了996的一天
- public void onClockAlarmed() {
- System.out.println("Zhang San is going to work...");
- }
- }
- // 隔壁上夜班的老王
- private class TestLaoWang : public Alarm {
- public TestLaoWang(AlarmClock& clock) {
- clock.setAlarm(this);
- }
- // 下班回家睡覺
- public void onClockAlarmed() {
- System.out.println("Lao Wang is going to bed...");
- }
- }
- public static void main(String []args){
- AlarmClock clock = new AlarmClock();
- TestZhangSan zhang = new TestZhangSan(clock);
- TestLaoWang wang = new TestLaoWang(clock);
- Calendar now = Calendar.getInstance();
- now.set(Calendar.MINUTE, 0);
- now.set(Calendar.SECOND, 0);
- now.set(Calendar.MILLISECOND, 0);
- //假裝時間經過了24小時
- for (int i = 0; i < 24; i++) {
- System.out.println("Now:" + now.getTime());
- clock.update(now);
- now.add(Calendar.HOUR_OF_DAY, 1);
- }
- }
- }
在這個例子中,AlarmClock類是被觀察者,Alarm接口及其具體子類是觀察者。按照觀察者模式,被觀察者AlarmClock維護了它的觀察者的列表。當時間進行到新一天的早晨,AlarmClock的狀態發生變化,也就是產生了一個事件,這時AlarmClock調用每個Alarm的方法。這樣,Alarm的具體子類對象,即每個希望定時執行的模塊,就能夠在正確的時間得到執行。
由于采用了觀察者模式,AlarmClock與其它模塊之間只通過Alarm接口交互,AlarmClock只引用Alarm,而不需要關心每個Alarm到底是哪個具體類,也不關心調用Alarm后究竟會執行哪些操作。如果Alarm的具體子類需要修改,我們并不需要修改AlarmClock類。如果有新的模塊需要用到定時功能,只需要讓新模塊實現Alarm接口即可。這就是觀察者模式降低耦合性的作用。
因為這個例子中被觀察者只有一個,因此被觀察者的抽象接口被省略了。并且我們沒有使用Observer、Subject等非常寬泛的名字,而是結合實際情況,觀察目標就是具體類AlarmClock類,觀察者被稱為Alarm。這樣使得整個設計非常自然,沒有生搬硬套設計模式的痕跡,哪怕是沒有學過設計模式的人也能夠看懂。這就是在具體應用設計模式時常常應該做的剪裁和調整。
需要指出,這個例子是為了能夠清晰演示觀察者模式而專門假設的場景。你可以嘗試把例子進行擴展。如果希望支持為每個Alarm指定不同的執行時間,應如何設計?如果張三多件事情需要分別定時執行,又應如何設計?
在實際項目中,業務需求一定會更為復雜,工程師需要在復雜需求中識別出在哪里使用哪種設計模式能夠帶來好處,這是需要鍛煉提升的能力。實際項目的設計也會根據需求做出更多的調整,多一些類或少一些類,常常看起來跟最初學習設計模式時看到的很不一樣。因此學習設計模式重在掌握思想,不能生搬硬套。無招勝有招。
## 總結
短短一篇文章,想要講清設計模式的所有內容幾乎是不可能完成的任務,所以我沒有逐一講解,而是結合我自己工作中遇到過的問題,來帶你重新認識設計模式,為你樹立它的重要性的觀念,避免陷入細節泥潭,歡樂的時間過太快,又是時候說拜拜,最后,恭喜大家,你已經掌握了設計模式,去干一番對人類有益的事業吧。
本篇由御姐供稿,版權和解釋權歸御姐所有,文章內容代表御姐意見,本農夫自媒體對文章觀點不持立場。
本文轉載自微信公眾號「碼磚雜役」,可以通過以下二維碼關注。轉載本文請聯系碼磚雜役公眾號。