[1]定義 [2]咖啡與茶 [3]創建子類 [4]抽象類 [5]鉤子方法 ...
前面的話
在javascript開發中用到繼承的場景其實並不是很多,很多時候喜歡用mix-in的方式給對象擴展屬性。但這不代表繼承在javascript里沒有用武之地,雖然沒有真正的類和繼承機制,但可以通過原型prototype來變相地實現繼承。本文將詳細介紹一種基於繼承的設計模式——模板方法(TemplateMethod)模式
定義
模板方法模式是一種只需使用繼承就可以實現的非常簡單的模式。模板方法模式由兩部分結構組成,第一部分是抽象父類,第二部分是具體的實現子類。通常在抽象父類中封裝了子類的演算法框架,包括實現一些公共方法以及封裝子類中所有方法的執行順序。子類通過繼承這個抽象類,也繼承了整個演算法結構,並且可以選擇重寫父類的方法
假如有一些平行的子類,各個子類之間有一些相同的行為,也有一些不同的行為。如果相同和不同的行為都混合在各個子類的實現中,說明這些相同的行為會在各個子類中重覆出現。但實際上,相同的行為可以被搬移到另外一個單一的地方,模板方法模式就是為解決這個問題而生的。在模板方法模式中,子類實現中的相同部分被上移到父類中,而將不同的部分留待子類來實現。這也很好地體現了泛化的思想
咖啡與茶
咖啡與茶是一個經典的例子,經常用來講解模板方法模式,這個例子的原型來自《HeadFirst設計模式》。下麵用javascript來實現這個例子
首先,先來泡一杯咖啡,如果沒有什麼太個性化的需求,泡咖啡的步驟通常如下:1、把水煮沸;2、用沸水沖泡咖啡;3、把咖啡倒進杯子;4、加糖和牛奶
通過下麵這段代碼,可以得到一杯香濃的咖啡
var Coffee = function(){}; Coffee.prototype.boilWater = function(){ console.log( '把水煮沸' ); }; Coffee.prototype.brewCoffeeGriends = function(){ console.log( '用沸水沖泡咖啡' ); }; Coffee.prototype.pourInCup = function(){ console.log( '把咖啡倒進杯子' ); }; Coffee.prototype.addSugarAndMilk = function(){ console.log( '加糖和牛奶' ); }; Coffee.prototype.init = function(){ this.boilWater(); this.brewCoffeeGriends(); this.pourInCup(); this.addSugarAndMilk(); }; var coffee = new Coffee(); coffee.init();
接下來,開始準備茶,泡茶的步驟跟泡咖啡的步驟相差並不大:1、把水煮沸;2、用沸水浸泡茶葉 3、把茶水倒進杯子;4、加檸檬
下麵用一段代碼來實現泡茶的步驟:
var Tea = function(){}; Tea.prototype.boilWater = function(){ console.log( '把水煮沸' ); }; Tea.prototype.steepTeaBag = function(){ console.log( '用沸水浸泡茶葉' ); }; Tea.prototype.pourInCup = function(){ console.log( '把茶水倒進杯子' ); }; Tea.prototype.addLemon = function(){ console.log( '加檸檬' ); }; Tea.prototype.init = function(){ this.boilWater(); this.steepTeaBag(); this.pourInCup(); this.addLemon(); }; var tea = new Tea(); tea.init();
現在分別泡好了一杯咖啡和一壺茶,經過思考和比較,發現咖啡和茶的沖泡過程是大同小異的
泡咖啡和泡茶主要有以下不同點:
1、原料不同。一個是咖啡,一個是茶,但可以把它們都抽象為“飲料”
2、泡的方式不同。咖啡是沖泡,而茶葉是浸泡,可以把它們都抽象為“泡”
3、加入的調料不同。一個是糖和牛奶,一個是檸檬,但可以把它們都抽象為“調料”
經過抽象之後,不管是泡咖啡還是泡茶,都能整理為下麵四步:
1、把水煮沸
2、用沸水沖泡飲料
3、把飲料倒進杯子
4、加調料
所以,不管是沖泡還是浸泡,都能給它一個新的方法名稱,比如說brew()。同理,不管是加糖和牛奶,還是加檸檬,都可以稱之為addCondiments()
現在可以創建一個抽象父類來表示泡一杯飲料的整個過程。不論是Coffee,還是Tea,都用Beverage來表示,代碼如下:
var Beverage = function(){}; Beverage.prototype.boilWater = function(){ console.log( '把水煮沸' ); }; Beverage.prototype.brew = function(){}; // 空方法,應該由子類重寫 Beverage.prototype.pourInCup = function(){}; // 空方法,應該由子類重寫 Beverage.prototype.addCondiments = function(){}; // 空方法,應該由子類重寫 Beverage.prototype.init = function(){ this.boilWater(); this.brew(); this.pourInCup(); this.addCondiments(); };
創建子類
現在創建一個Beverage類的對象沒有意義,因為世界上能喝的東西沒有一種真正叫“飲料”的,飲料在這裡還只是一個抽象的存在。接下來要創建咖啡類和茶類,並讓它們繼承飲料類:
var Coffee = function(){}; Coffee.prototype = new Beverage();
接下來要重寫抽象父類中的一些方法,只有“把水煮沸”這個行為可以直接使用父類Beverage中的boilWater方法,其他方法都需要在Coffee子類中重寫,代碼如下:
Coffee.prototype.brew = function(){ console.log( '用沸水沖泡咖啡' ); }; Coffee.prototype.pourInCup = function(){ console.log( '把咖啡倒進杯子' ); }; Coffee.prototype.addCondiments = function(){ console.log( '加糖和牛奶' ); }; var Coffee = new Coffee(); Coffee.init();
至此Coffee類完成了,當調用coffee對象的init方法時,由於coffee對象和Coffee構造器的原型prototype上都沒有對應的init方法,所以該請求會順著原型鏈,被委托給Coffee的“父類”Beverage原型上的init方法。而Beverage.prototype.init方法中已經規定好了泡飲料的順序,所以能成功地泡出一杯咖啡
接下來照葫蘆畫瓢,來創建Tea類:
var Tea = function(){}; Tea.prototype = new Beverage(); Tea.prototype.brew = function(){ console.log( '用沸水浸泡茶葉' ); }; Tea.prototype.pourInCup = function(){ console.log( '把茶倒進杯子' ); }; Tea.prototype.addCondiments = function(){ console.log( '加檸檬' ); }; var tea = new Tea(); tea.init();
本文討論的是模板方法模式,那麼在上面的例子中,到底誰才是所謂的模板方法呢?答案是Beverage.prototype.init。Beverage.prototype.init被稱為模板方法的原因是,該方法中封裝了子類的演算法框架,它作為一個演算法的模板,指導子類以何種順序去執行哪些方法。在Beverage.prototype.init方法中,演算法內的每一個步驟都清楚地展示在眼前
抽象類
模板方法模式是一種嚴重依賴抽象類的設計模式。javascript在語言層面並沒有提供對抽象類的支持,也很難模擬抽象類的實現
在Java中,類分為兩種,一種為具體類,另一種為抽象類。具體類可以被實例化,抽象類不能被實例化。要瞭解抽象類不能被實例化的原因,可以思考“飲料”這個抽象類
想象這樣一個場景:口渴了去便利店想買一瓶飲料,不能直接跟店員說:“來一瓶飲料”。如果這樣說了,那麼店員接下來肯定會問:“要什麼飲料?”飲料只是一個抽象名詞,只有當真正明確了的飲料類型之後,才能得到一杯咖啡、茶或者可樂
由於抽象類不能被實例化,如果有人編寫了一個抽象類,那麼這個抽象類一定是用來被某些具體類繼承的。抽象類表示一種契約。繼承了這個抽象類的所有子類都將擁有跟抽象類一致的介面方法,抽象類的主要作用就是為它的子類定義這些公共介面。如果在子類中刪掉了這些方法中的某一個,那麼將不能通過編譯器的檢查,這在某些場景下是非常有用的
Beverage類的init方法里規定了沖泡一杯飲料的順序如下:
this.boilWater(); //把水煮沸 this.brew(); //用水泡原料 this.pourInCup(); //把原料倒進杯子 this.addCondiments(); //添加調料
如果在Coffee子類中沒有實現對應的brew方法,那麼百分之百得不到一杯咖啡。既然父類規定了子類的方法和執行這些方法的順序,子類就應該擁有這些方法,並且提供正確的實現
抽象方法被聲明在抽象類中,抽象方法並沒有具體的實現過程,是一些“啞”方法。比如Beverage類中的brew方法、pourInCup方法和addCondiments方法,都被聲明為抽象方法。當子類繼承了這個抽象類時,必須重寫父類的抽象方法
除了抽象方法之外,如果每個子類中都有一些同樣的具體實現方法,那這些方法也可以選擇放在抽象類中,這可以節省代碼以達到復用的效果,這些方法叫作具體方法。當代碼需要改變時,只需要改動抽象類里的具體方法就可以了。比如飲料中的boilWater方法,假設沖泡所有的飲料之前,都要先把水煮沸,那自然可以把boilWater方法放在抽象類Beverage中
下麵嘗試著把Coffee和Tea的例子換成Java代碼,這有助於理解抽象類的意義
//Java代碼 public abstract class Beverage{ //飲料抽象類 final void init(){ //模板方法 boilWater(); brew(); pourInCup(); addCondiments(); } void boilWater(){ //具體方法 boilWaterSystem.out.println("把水煮沸"); } abstract void brew(); //抽象方法brew abstract void addCondiments(); //抽象方法addCondiments abstract void pourInCup(); //抽象方法pourInCup } public class Coffee extends Beverage{ //Coffee類 @Override void brew(){ //子類中重寫brew方法 System.out.println("用沸水沖泡咖啡"); } @Override void pourInCup(){ //子類中重寫pourInCup方法 System.out.println("把咖啡倒進杯子"); } @Override void addCondiments(){ //子類中重寫addCondiments方法 System.out.println("加糖和牛奶"); } } public class Tea extends Beverage{ //Tea類 @Override voidbrew(){ //子類中重寫brew方法 System.out.println("用沸水浸泡茶葉"); } @Override voidpourInCup(){ //子類中重寫pourInCup方法 System.out.println("把茶倒進杯子"); } @Override voidaddCondiments(){ //子類中重寫addCondiments方法 System.out.println("加檸檬"); } } public class Test{ private static void prepareRecipe(Beveragebeverage){ beverage.init(); } public static void main(Stringargs[]){ Beverage coffee = new Coffee(); //創建coffee對象 prepareRecipe(coffee); //開始泡咖啡 //把水煮沸 //用沸水沖泡咖啡 //把咖啡倒進杯子 //加糖和牛奶 Beverage tea = new Tea(); //創建tea對象 prepareRecipe(tea); //開始泡茶 //把水煮沸 //用沸水浸泡茶葉 //把茶倒進杯子 //加檸檬 } }
javascript並沒有從語法層面提供對抽象類的支持。抽象類的第一個作用是隱藏對象的具體類型,由於javascript是一門“類型模糊”的語言,所以隱藏對象的類型在javascript中並不重要。另一方面,在javascript中使用原型繼承來模擬傳統的類式繼承時,並沒有編譯器幫助進行任何形式的檢查,沒有辦法保證子類會重寫父類中的“抽象方法”
Beverage.prototype.init方法作為模板方法,已經規定了子類的演算法框架,代碼如下:
Beverage.prototype.init=function(){ this.boilWater(); this.brew(); this.pourInCup(); this.addCondiments(); };
如果Coffee類或者Tea類忘記實現這4個方法中的一個呢?拿brew方法舉例,如果忘記編寫Coffee.prototype.brew方法,那麼當請求coffee對象的brew時,請求會順著原型鏈找到Beverage“父類”對應的Beverage.prototype.brew方法,而Beverage.prototype.brew方法到目前為止是一個空方法,這顯然是不符需要的
在Java中編譯器會保證子類會重寫父類中的抽象方法,但在javascript中卻沒有進行這些檢查工作。在編寫代碼的時候得不到任何形式的警告,完全寄托於程式員的記憶力和自覺性是很危險的,特別是當使用模板方法模式這種完全依賴繼承而實現的設計模式時
下麵提供兩種變通的解決方案:第1種方案是用鴨子類型來模擬介面檢查,以便確保子類中確實重寫了父類的方法。但模擬介面檢查會帶來不必要的複雜性,而且要求程式員主動進行這些介面檢查,這就要求在業務代碼中添加一些跟業務邏輯無關的代碼;第2種方案是讓Beverage.prototype.brew等方法直接拋出一個異常,如果因為粗心忘記編寫Coffee.prototype.brew方法,那麼至少會在程式運行時得到一個錯誤
Beverage.prototype.brew = function(){ throw new Error( '子類必須重寫brew 方法' ); }; Beverage.prototype.pourInCup = function(){ throw new Error( '子類必須重寫pourInCup 方法' ); }; Beverage.prototype.addCondiments = function(){ throw new Error( '子類必須重寫addCondiments 方法' ); };
第2種解決方案的優點是實現簡單,付出的額外代價很少;缺點是得到錯誤信息的時間點太靠後。一共有3次機會得到這個錯誤信息,第1次是在編寫代碼的時候,通過編譯器的檢查來得到錯誤信息;第2次是在創建對象的時候用鴨子類型來進行“介面檢查”;而目前不得不利用最後一次機會,在程式運行過程中才知道哪裡發生了錯誤
【使用場景】
從大的方面來講,模板方法模式常被架構師用於搭建項目的框架,架構師定好了框架的骨架,程式員繼承框架的結構之後,負責往裡面填空
在Web開發中能找到很多模板方法模式的適用場景,比如在構建一系列的UI組件,這些組件的構建過程一般如下所示:
1、初始化一個div容器
2、通過ajax請求拉取相應的數據;
3、把數據渲染到div容器裡面,完成組件的構造;
4、通知用戶組件渲染完畢
任何組件的構建都遵循上面的4步,其中第(1)步和第(4)步是相同的。第(2)步不同的地方只是請求ajax的遠程地址,第(3)步不同的地方是渲染數據的方式。於是可以把這4個步驟都抽象到父類的模板方法裡面,父類中還可以順便提供第(1)步和第(4)步的具體實現。當子類繼承這個父類之後,會重寫模板方法裡面的第(2)步和第(3)步
鉤子方法
通過模板方法模式,在父類中封裝了子類的演算法框架。這些演算法框架在正常狀態下是適用於大多數子類的,但如果有一些特別“個性”的子類呢?比如在飲料類Beverage中封裝了飲料的沖泡順序:
1、把水煮沸
2、用沸水沖泡飲料
3、把飲料倒進杯子
4、加調料
這4個沖泡飲料的步驟適用於咖啡和茶,在飲料店裡,根據這4個步驟製作出來的咖啡和茶,一直順利地提供給絕大部分客人享用。但有一些客人喝咖啡是不加調料(糖和牛奶)的。既然Beverage作為父類,已經規定好了沖泡飲料的4個步驟,那麼有什麼辦法可以讓子類不受這個約束呢?
鉤子方法(hook)可以用來解決這個問題,放置鉤子是隔離變化的一種常見手段。在父類中容易變化的地方放置鉤子,鉤子可以有一個預設的實現,究竟要不要“掛鉤”,這由子類自行決定。鉤子方法的返回結果決定了模板方法後面部分的執行步驟,也就是程式接下來的走向,這樣一來,程式就擁有了變化的可能
在下麵這個例子里,把掛鉤的名字定為customerWantsCondiments,接下來將掛鉤放入Beverage類,看看如何得到一杯不需要糖和牛奶的咖啡,代碼如下:
var Beverage = function(){}; Beverage.prototype.boilWater = function(){ console.log( '把水煮沸' ); }; Beverage.prototype.brew = function(){ throw new Error( '子類必須重寫brew 方法' ); }; Beverage.prototype.pourInCup = function(){ throw new Error( '子類必須重寫pourInCup 方法' ); }; Beverage.prototype.addCondiments = function(){ throw new Error( '子類必須重寫addCondiments 方法' ); }; Beverage.prototype.customerWantsCondiments = function(){ return true; // 預設需要調料 }; Beverage.prototype.init = function(){ this.boilWater(); this.brew(); this.pourInCup(); if ( this.customerWantsCondiments() ){ // 如果掛鉤返回true,則需要調料 this.addCondiments(); } }; var CoffeeWithHook = function(){}; CoffeeWithHook.prototype = new Beverage(); CoffeeWithHook.prototype.brew = function(){ console.log( '用沸水沖泡咖啡' ); }; CoffeeWithHook.prototype.pourInCup = function(){ console.log( '把咖啡倒進杯子' ); }; CoffeeWithHook.prototype.addCondiments = function(){ console.log( '加糖和牛奶' ); }; CoffeeWithHook.prototype.customerWantsCondiments = function(){ return window.confirm( '請問需要調料嗎?' ); }; var coffeeWithHook = new CoffeeWithHook(); coffeeWithHook.init();
【好萊塢原則】
下麵引入一個新的設計原則——“好萊塢原則”。好萊塢無疑是演員的天堂,但好萊塢也有很多找不到工作的新人演員,許多新人演員在好萊塢把簡歷遞給演藝公司之後就只有回家等待電話。有時候該演員等得不耐煩了,給演藝公司打電話詢問情況,演藝公司往往這樣回答:“不要來找我,我會給你打電話。”
在設計中,這樣的規則就稱為好萊塢原則。在這一原則的指導下,允許底層組件將自己掛鉤到高層組件中,而高層組件會決定什麼時候、以何種方式去使用這些底層組件,高層組件對待底層組件的方式,跟演藝公司對待新人演員一樣,都是“別調用我們,我們會調用你”
模板方法模式是好萊塢原則的一個典型使用場景,它與好萊塢原則的聯繫非常明顯,用模板方法模式編寫一個程式時,就意味著子類放棄了對自己的控制權,而是改為父類通知子類,哪些方法應該在什麼時候被調用。作為子類,只負責提供一些設計上的細節。除此之外,好萊塢原則還常常應用於其他模式和場景,例如發佈——訂閱模式和回調函數
在發佈—訂閱模式中,發佈者會把消息推送給訂閱者,這取代了原先不斷去fetch消息的形式。例如假設乘坐計程車去一個不瞭解的地方,除了每過5秒鐘就問司機“是否到達目的地”之外,還可以在車上美美地睡上一覺,然後跟司機說好,等目的地到了就叫醒你。這也相當於好萊塢原則中提到的“別調用我們,我們會調用你”
在ajax非同步請求中,由於不知道請求返回的具體時間,而通過輪詢去判斷是否返回數據,這顯然是不理智的行為。所以通常會把接下來的操作放在回調函數中,傳入發起ajax非同步請求的函數。當數據返回之後,這個回調函數才被執行,這也是好萊塢原則的一種體現。把需要執行的操作封裝在回調函數里,然後把主動權交給另外一個函數。至於回調函數什麼時候被執行,則是另外一個函數控制的
【基於繼承】
模板方法模式是基於繼承的一種設計模式,父類封裝了子類的演算法框架和方法的執行順序,子類繼承父類之後,父類通知子類執行這些方法,好萊塢原則很好地詮釋了這種設計技巧,即高層組件調用底層組件
模板方法模式是為數不多的基於繼承的設計模式,但javascript語言實際上沒有提供真正的類式繼承,繼承是通過對象與對象之間的委托來實現的。也就是說,雖然在形式上借鑒了提供類式繼承的語言,但本文的模板方法模式並不十分正宗。而且在javascript這般靈活的語言中,實現這樣一個例子,是否真的需要繼承這種重武器呢?在好萊塢原則的指導之下,下麵這段代碼可以達到和繼承一樣的效果
var Beverage = function( param ){ var boilWater = function(){ console.log( '把水煮沸' ); }; var brew = param.brew || function(){ throw new Error( '必須傳遞brew 方法' ); }; var pourInCup = param.pourInCup || function(){ throw new Error( '必須傳遞pourInCup 方法' ); }; var addCondiments = param.addCondiments || function(){ throw new Error( '必須傳遞addCondiments 方法' ); }; var F = function(){}; F.prototype.init = function(){ boilWater(); brew(); pourInCup(); addCondiments(); }; return F; }; var Coffee = Beverage({ brew: function(){ console.log( '用沸水沖泡咖啡' ); }, pourInCup: function(){ console.log( '把咖啡倒進杯子' ); }, addCondiments: function(){ console.log( '加糖和牛奶' ); } }); var Tea = Beverage({ brew: function(){ console.log( '用沸水浸泡茶葉' ); }, pourInCup: function(){ console.log( '把茶倒進杯子' ); }, addCondiments: function(){ console.log( '加檸檬' ); } }); var coffee = new Coffee(); coffee.init(); var tea = new Tea(); tea.init();
在這段代碼中,把brew、pourInCup、addCondiments這些方法依次傳入Beverage函數,Beverage函數被調用之後返回構造器F。F類中包含了“模板方法”F.prototype.init。跟繼承得到的效果一樣,該“模板方法”里依然封裝了飲料子類的演算法框架
模板方法模式是一種典型的通過封裝變化提高系統擴展性的設計模式。在傳統的面向對象語言中,一個運用了模板方法模式的程式中,子類的方法種類和執行順序都是不變的,所以把這部分邏輯抽象到父類的模板方法裡面。而子類的方法具體怎麼實現則是可變的,於是把這部分變化的邏輯封裝到子類中。通過增加新的子類,便能給系統增加新的功能,並不需要改動抽象父類以及其他子類,這也是符合開放——封閉原則的。但在javascript中,很多時候都不需要依樣畫瓢地去實現一個模版方法模式,高階函數是更好的選擇