淺談JavaScript中的介面

悠揚的牧笛發表於2016-10-09

一、什麼是介面

介面是物件導向JavaScript程式設計師的工具箱中最有用的工具之一。在設計模式中提出的可重用的物件導向設計的原則之一就是“針對介面程式設計而不是實現程式設計”,即我們所說的面向介面程式設計,這個概念的重要性可見一斑。但問題在於,在JavaScript的世界中,沒有內建的建立或實現介面的方法,也沒有可以判斷一個物件是否實現了與另一個物件相同的一套方法,這使得物件之間很難互換使用,好在JavaScript擁有出色的靈活性,這使得模擬傳統物件導向的介面,新增這些特性並非難事。介面提供了一種用以說明一個物件應該具有哪些方法的手段,儘管它可以表明這些方法的含義,但是卻不包含具體實現。有了這個工具,就能按物件提供的特性對它們進行分組。例如,假如A和B以及介面I,即便A物件和B物件有極大的差異,只要他們都實現了I介面,那麼在A.I(B)方法中就可以互換使用A和B,如B.I(A)。還可以使用介面開發不同的類的共同性。如果把原本要求以一個特定的類為引數的函式改為要求以一個特定的介面為引數的函式,那麼所有實現了該介面的物件都可以作為引數傳遞給它,這樣一來,彼此不相關的物件也可以被相同地對待。

二、介面的利與弊

既定的介面具有自我描述性,並能夠促進程式碼的重用性,介面可以提供一種資訊,告訴外部一個類需要實現哪些方法。還有助於穩定不同類之間的通訊方式,減少了繼承兩個物件的過程中出現的問題。這對於除錯也是有幫助的,在JavaScript這種弱型別語言中,型別不匹配很難追蹤,使用介面時,如果出現了問題,會有更明確的錯誤提示資訊。當然介面並非完全沒有缺點,如果大量使用介面會一定程度上弱化其作為弱型別語言的靈活性,另一方面,JavaScript並沒有對介面的內建的支援,只是對傳統的物件導向的介面進行模擬,這會使本身較為靈活的JavaScript變得更加難以駕馭。此外,任何實現介面的方式都會對效能造成影響,某種程度上歸咎於額外的方法呼叫開銷。介面使用的最大的問題在於,JavaScript不像是其他的強型別語言,如果不遵守介面的約定,就會編譯失敗,其靈活性可以有效地避開上述問題,如果是在協同開發的環境下,其介面很有可能被破壞而不會產生任何錯誤,也就是不可控性。

在物件導向的語言中,使用介面的方式大體相似。介面中包含的資訊說明了類需要實現的方法以及這些方法的簽名。類的定義必須明確地宣告它們實現了這些介面,否則是不會編譯通過的。顯然在JavaScript中我們不能如法炮製,因為不存在interface和implement關鍵字,也不會在執行時對介面是否遵循約定進行檢查,但是我們可以通過輔助方法和顯式地檢查模仿出其大部分特性。

三、在JavaScript中模仿介面

在JavaScript中模仿介面主要有三種方式:通過註釋、屬性檢查和鴨式辯型法,以上三種方式有效結合,就會產生類似介面的效果。

註釋是一種比較直觀地把與介面相關的關鍵字(如interface、implement等)與JavaScript程式碼一同放在註釋中來模擬介面,這是最簡單的方法,但是效果最差。程式碼如下:

//以註釋的形式模仿描述介面
/*
interface Composite{
    function add(child);
    function remove(child);
    function getName(index);
}

interface FormItem{
    function save();
}
*/

//以註釋的形式模仿使用介面關鍵字
var CompositeForm =function(id , method,action) { //implements Composite , FormItem
    // do something
}
//模擬實現具體的介面方法 此處實現Composite介面
CompositeForm.prototype.Add=function(){
    // do something
}

CompositeForm.prototype.remove=function(){
    // do something
}

CompositeForm.prototype.getName=function(){
    // do something
}

//模擬實現具體的介面方法 此處實現FormItem介面
Composite.prototype.save=function(){
    // do something
}

這種方式其實並不是很好,因為這種模仿還只停留在文件規範的範疇,開發人員是否會嚴格遵守該約定有待考量,對介面的遵守完全依靠開發人員的自覺性。另外,這種方式並不會去檢查某個函式是否真正地實現了我們約定的“介面”。儘管如此,這種方式也有優點,它易於實現而不需要額外的類或者函式,可以提高程式碼的可重用性,因為類實現的介面都有註釋說明。這種方式不會影響到檔案佔用的空間或執行速度,因為註釋的程式碼可以在部署的時候輕鬆剔除。但是由於不會提供錯誤訊息,它對測試和除錯沒什麼幫助。下面的一種方式會對是否實現介面進行檢查,程式碼如下:

//以註釋的形式模仿使用介面關鍵字
var CompositeForm =function(id , method,action) { //implements Composite , FormItem
    // do something
    this.implementsinterfaces=['Composite','FormItem']; //顯式地把介面放在implementsinterfaces中
}

//檢查介面是否實現
function implements(Object){
    for(var i=0 ;i< arguments.length;i++){
        var interfaceName=arguments[i];
        var interfaceFound=false;
        for(var j=0;j<Object.implementsinterfaces.length;j++){
            if(Object.implementsinterfaces[j]==interfaceName){
                interfaceFound=true;
                break;
            }
        }
        if(!interfaceFound){
            return false;
        }else{
            return true;
        }
    }
}

function AddForm(formInstance){
    if(!implements(formInstance,'Composite','FormItem')){ 
        throw new Error('Object does not implements required interface!');
    }
}

上述程式碼是在方式一的基礎上進行完善,在這個例子中,CompositeForm宣稱自己實現了Composite和FormItem這兩個介面,其做法是把這兩個介面的名稱加入一個implementsinterfaces的陣列。顯式地宣告自己支援什麼介面。任何一個要求其引數屬性為特定型別的函式都可以對這個屬性進行檢查,並在所需要的介面未在宣告之中時丟擲錯誤。這種方式相對於上一種方式,多了一個強制性的型別檢查。但是這種方法的缺點在於它並未保證類真正地實現了自稱實現的介面,只是知道它宣告自己實現了這些介面。其實類是否宣告自己支援哪些介面並不重要,只要它具有這些介面中的方法就行。鴨式辯型(像鴨子一樣走路並且嘎嘎叫的就是鴨子)正是基於這樣的認識,它把物件實現的方法集作為判斷它是不是某個類的例項的唯一標準。這種技術在檢查一個類是否實現了某個介面時也可以大顯身手。這種方法的背後觀點很簡單:如果物件具有與介面定義的方法同名的所有方法,那麼就可以認為它實現了這個介面。可以使用一個輔助函式來確保物件具有所有必需的方法,程式碼如下:

//interface
var Composite =new Interface('Composite',['add','remove','getName']);
var FormItem=new Interface('FormItem',['save']);

//class
var Composite=function(id,method,action){

}

//Common Method
function AddForm(formInstance){
    ensureImplements(formInstance,Composite,FormItem);
    //如果該函式沒有實現指定的介面,這個函式將會報錯
}

與另外兩種方式不同,這種方式無需註釋,其餘的各個方面都是可以強制實施的。EnsureImplements函式需要至少兩個引數。第一個引數是想要檢查的物件,其餘的引數是被檢查物件的介面。該函式檢查器第一個引數代表的物件是否實現了那些介面所宣告的方法,如果漏掉了任何一個,就會拋錯,其中會包含被遺漏的方法的有效資訊。這種方式不具備自我描述性,需要一個輔助類和輔助函式來幫助實現介面檢查,而且它只關心方法名稱,並不檢查引數的名稱、數目或型別。

四、Interface類

在下面的程式碼中,對Interface類的所有方法的引數都進行了嚴格的控制,如果引數沒有驗證通過,那麼就會丟擲異常。加入這種檢查的目的就是,如果在執行過程中沒有丟擲異常,那麼就可以肯定介面得到了正確的宣告和實現。

var Interface = function(name ,methods){
    if(arguments.length!=2){
        throw new Error('2 arguments required!');
    }
    this.name=name;
    this.methods=[];
    for(var i=0;len=methods.length;i<len;i++){
        if(typeof(methods[i]!=='String')){
            throw new Error('method name must be String!');
        }
        this.methods.push(methods[i]);
    }
}

Interface.ensureImplements=function(object){
    if(arguments.length<2){
        throw new Error('2 arguments required at least!');
    }
    for(var i=0;len=arguments.length;i<len;i++){
        var interface=arguments[i];
        if(interface.constructor!==Interface){
            throw new Error('instance must be Interface!');
        }
        for(var j=0;methodLength=interface.methods.length;j<methodLength;j++){
            var method=interface.methods[j];
            if(!object[method]||typeof(object[method])=='function')){
                throw new Error('object does not implements method!');
            }    
        }
    }
}

其實多數情況下,介面並不是經常被使用的,嚴格的型別檢查並不總是明智的。但是在設計複雜的系統的時候,介面的作用就體現出來了,這看似降低了靈活性,卻同時也降低了耦合性,提高了程式碼的重用性。這在大型系統中是比較有優勢的。在下面的例子中,宣告瞭一個displayRoute方法,要求其引數具有三個特定的方法,通過Interface物件和ensureImplements方法來保證這三個方法的實現,否則將會丟擲錯誤。

//宣告一個介面,描述該介面包含的方法
 var DynamicMap=new Interface{'DynamicMap',['centerOnPoint','zoom','draw']};

 //宣告一個displayRoute方法
 function displayRoute(mapInstance){
    //檢驗該方法的map
    //檢驗該方法的mapInsstance是否實現了DynamicMap介面,如果未實現則會丟擲
    Interface.ensureImplements(mapInstance,DynamicMap);
    //如果實現了則正常執行
    mapInstance.centerOnPoint(12,22);
    mapInstance.zoom(5);
    mapInstance.draw();
 }

下面的例子會將一些資料以網頁的形式展現出來,這個類的構造器以一個TestResult的例項作為引數。該類會對TestResult物件所包含的資料進行格式化(Format)後輸出,程式碼如下:

var ResultFormatter=function(resultObject){
     //對resultObject進行檢查,保證是TestResult的例項
     if(!(resultObject instanceof TestResult)){
         throw new Error('arguments error!');
     }
     this.resultObject=resultObject;
 }

 ResultFormatter.prototype.renderResult=function(){
     var dateOfTest=this.resultObject.getData();
     var resultArray=this.resultObject.getResults();
     var resultContainer=document.createElement('div');
     var resultHeader=document.createElement('h3');
     resultHeader.innerHTML='Test Result from '+dateOfTest.toUTCString();
     resultContainer.appendChild(resultHeader);

     var resultList=document.createElement('ul');
     resultContainer.appendChild(resultList);

     for(var i=0;len=resultArray.length;i<len;i++){
         var listItem=document.createElement('li');
         listItem.innerHTML=resultArray[i];
         resultList.appendChild('listItem');
     }
     return resultContainer;
 }

該類的構造器會對引數進行檢查,以確保其的確為TestResult的類的例項。如果引數達不到要求,構造器將會丟擲一個錯誤。有了這樣的保證,在編寫renderResult方法的時候,就可以認定有getData和getResult兩個方法。但是,建構函式中,只對引數的型別進行了檢查,實際上這並不能保證所需要的方法都得到了實現。TestResult類會被修改,致使其失去這兩個方法,但是構造器中的檢查依舊會通過,只是renderResult方法不再有效。

此外,構造器中的這個檢查施加了一些不必要的限制。它不允許使用其他的類的例項作為引數,否則會直接拋錯,但是問題來了,如果有另一個類也包含並實現了getData和getResult方法,它本來可以被ResultFormatter使用,卻因為這個限制而無用武之地。

解決問題的辦法就是刪除構造器中的校驗,並使用介面代替。我們採用這個方案對程式碼進行優化:

//介面的宣告
var resultSet =new Interface('ResultSet',['getData','getResult']);

//修改後的方案
 var ResultFormatter =function(resultObject){
     Interface.ensureImplements(resultObject,resultSet);
     this.resultObject=resultObject;
 }

上述程式碼中,renderResult方法保持不變,而構造器卻採用的ensureImplements方法,而不是typeof運算子。現在的這個構造器可以接受任何符合介面的類的例項了。

五、依賴於介面的設計模式

<1>工廠模式:物件工廠所建立的具體物件會因具體情況而不同。使用介面可以確保所建立的這些物件可以互換使用,也就是說物件工廠可以保證其生產出來的物件都實現了必需的方法;

<2>組合模式:如果不使用介面就不可能使用這個模式,其中心思想是可以將物件群體與其組成物件同等對待。這是通過介面來做到的。如果不進行鴨式辯型或型別檢查,那麼組合模式就會失去大部分意義;

<3>裝飾者模式:裝飾者通過透明地為另一個物件提供包裝而發揮作用。這是通過實現與另外那個物件完全一致的介面實現的。對於外界而言,一個裝飾者和它所包裝的物件看不出有什麼區別,所以使用Interface來確保所建立的裝飾者實現了必需的方法;

<4>命令模式:程式碼中所有的命令物件都有實現同一批方法(如run、ecxute、do等)通過使用介面,未執行這些命令物件而建立的類可以不必知道這些物件具體是什麼,只要知道他們都正確地實現了介面即可。藉此可以建立出模組化程度很高的、耦合度很低的API。

相關文章