iThome 網站首載:多型的本質 (三)思考行為外觀的次型態多型
參數(Parametric)多型為了規範型態變數的範圍,可對型態變數加上約束,某些語言取此概念,允許針對某型態定義的函式,可傳入次型態(Subtype)物件,這種次型態多型是物件導向程式設計中最常見,也是多數開發者所熟悉的多型。次型態關係並非狹義的繼承關係,如果型態S具有型態T所有方法,S可視為T的次型態,次型態多型重點在於思考物件的行為外觀,讓函式定義可以在不同型態間互通。
- 類別繼承與介面實作是一種次型態階層
如果S是T的次型態,S型態實例可被當作T型態來使用,這種無需指定額外屬性或進行程式碼修改的取代性,讓函式定義可在不同型態間互通。以Java為例,當Sprite類別定義了getName與getBlood,SwordsMan與Magician繼承Sprite,若要設計方法使用getName與getBlood顯示角色資訊,以特定(Ad-hoc)多型分別設計show(SwordsMan s)與show(Magician m)並不適當,因為函式實作內容並非特定於SwordsMan或Magician。可設計為show(Sprite s),雖然定義show函式時是針對Sprite型態,但實際執行時可傳入SwordsMan、Magician或任何繼承Sprite的類別實例,雖然是透過s呼叫getName或getBlood,只要傳入實例繼承Sprite,就具有Sprite定義的所有方法,show函式就可正常執行。
Java中只能繼承一個具體類別,但可以實作多個抽象介面(interface),介面是Java中提供的受限多重繼承,目的之一是提供更有彈性的次型態多型。舉例來說,要設計一個所有東西都會游泳的海洋樂園遊戲,將swim行為外觀定義在Fish類別中並不適合,因為這會使得想具備swim行為外觀的物件都得繼承Fish;如果有個Human類別為了具備swim行為外觀而繼承Fish,顯然就不是合理的設計。(Human會是一種Fish嗎?)
考慮海洋樂園的需求是所有東西都會游泳,而不是所有魚都會游泳,因而有必要將swim的行為外觀抽取出來定義於Swimmer介面,Fish可以實作Swimmer介面而擁有swim行為外觀,Human也可以實作Swimmer而擁有swim行為外觀,Fish與Human都可視為Swimmer的次型態,但Fish與Human沒有直接繼承關係,因而Human不是一種Fish。如果今天有個需求是設計doSwim函式,呼叫傳入物件的swim方法,設計doSwim(Swimmer s),顯然會比分別針對Fish及Human設計doSwim(Fish f)及doSwim(Human h)聰明多了,對於doSwim(Swimmer s)來說,雖然定義時是針對Swimmer型態,然而任何實作Swimmer介面的物件必然擁有swim方法,自然就可透過s來參考呼叫。
- 動態語言具有更廣義的次型態多型
動態語言的變數不需要指定型態資訊,開發者定義函式時不用考慮型態,只要思考變數參考的物件擁有哪些行為外觀。例如設計一個doQuack(duck)函式,當中呼叫duck.quack()方法,即使型態S與型態T彼此沒有繼承關係,但都擁有quack方法,就可將S或T實例傳入doQuack函式中執行。由於duck變數實際上沒有規範型態,所以沒辦法說S是T的次型態,或說T是S的次型態,因而動態語言界稱duck具有鴨子型態(Duck typing)的行為外觀。
靜態語言中宣告型態,主要是為編譯器提供型態資訊,讓編譯器透過型態瞭解變數參考的物件有哪些方法可操作;從另一角度來看,型態也是要求編譯器提供約束,確保變數參考的物件確實擁有變數型態所有的方法;動態語言由於變數沒有型態資訊,因而沒有這層約束,然而不變的是,執行時期要求物件必須擁有變數打算操作的所有方法,否則就會執行錯誤,靜態語言只是把這類錯誤儘可能在編譯時期呈現。
型態的意義之一在於規範物件本身擁有的行為外觀,就doQuack(duck)而言,duck可視為隱含著一種型態(例如鴨子型態),此隱含型態擁有的方法即doQuack中對duck的所有操作,所有傳入doQuack的物件,都必須擁有duck隱含型態上所有方法(具備鴨子型態的行為外觀),傳入doQuack函式的物件型態,都是duck隱含型態的次型態。
Java的介面在語法層面來說是屬於受限多重繼承,然而語義層面是將物件的行為外觀具體化型態,實作某個介面代表該物件擁有該介面定義的行為外觀。以先前海洋樂園的例子來說,Fish與Human實作Swimmer,在語義層面上表示Fish與Human都擁有Swimmer定義的行為外觀,即使它們沒有直接繼承關係,設計上是將swim的行為外觀從各種物件中抽取出來具體化為Swimmer型態。
- 思考靜態語言中更廣義的次型態多型
無論如何,靜態語言中必須針對行為外觀定義型態,終究是比動態語言缺少彈性。以Java來說,光是型態名稱就令人煩惱了,如果今天有個Duck類別本來沒有實現Swimmer介面,但確實擁有swim方法,為了要運用doSwim函式,還得修改Duck類別使其實作Swimmer介面。
不過靜態語言還是可以透過某些方式,來支援更廣義的次型態多型。以Java為例,可以透過反射(Reflection)機制,不理會物件實際型態,只針對物件上的方法簽署(Signature),實現基於簽署的多型(Signature-Based Polymorphism)。例如實作讓所有具備swim方法的物件都可以執行的doSwim:
void doSwim(Object o) throws Exception {
o.getClass().getMethod("swim", null).invoke(o, null);
}
o.getClass().getMethod("swim", null).invoke(o, null);
}
就行為外觀上,doSwim函式等於捨棄了型態,只在乎物件上是否具備要呼叫的方法簽署,這與動態語言中鴨子型態的概念類似,然而Java以反射機制換取彈性的代價是付出效能、失去編譯時期檢查以及複雜的程式邏輯。同樣身為靜態語言的Scala作得好一些,可以結構型態(Structural typing)保留編譯時期檢查及避免複雜程式邏輯,例如實作讓所有具備swim方法的物件都可以執行的doSwim:
def doSwim(s: {def swim: Unit}) {
s.swim
}
s.swim
}
捨棄型態而僅思考行為外觀的另一實例,就是物件導向語言導入一級函式特性時的考量。以Java為例,現階段實現接近一級函式概念的方式,是以匿名內部類別實作僅含單一抽象方法(Single abstract method)的介面,然而實際上開發者只在意函式參數與本體,此方式仍迫使開發者要注意介面型態與方法名稱。JDK8預計引入的Lambda在語法上捨棄介面型態與方法名稱,例如實作Comparator<String>時可寫為(s1, s2) -> s1.compareTo(s2),然而為了與既有API相容及避免創造新的型態系統,Lambda語法的目標型態(Target type)必須是函式介面(Functional interface),權宜之計是透過定義通用函式介面,降低API設計者及使用者記憶型態與方法名稱的負擔。
- 針對行為外觀思考以取得通用性
次型態多型的本質,在於設計時思考物件間共通的行為外觀,無論該行為外觀在靜態語言中是否具體化為型態,或只是動態語言中由物件單純擁有。實際上在某些程式語言中(像是Ruby),方法與訊息(Message)的概念是分開的,物件被視為訊息的接收者,有能力將訊息對應至要執行的方法,甚至沒有定義方法,也可以處理接收到的訊息。
在這類語言中定義函式時,甚至可僅要求傳入函式的物件,在接收到訊息後能觸發某些方法或執行對應動作,這樣的函式顯然具有更大的通用性,因為不僅不要求傳入物件的實際型態,連物件是否真正定義了對應方法也不在乎,只要求物件對於傳送的訊息有能力處理,這樣的多型稱為包容性(Inclusion)多型,實際上次型態多型不過是包容式多型的實例之一。
設計時實際思考行為外觀而非型態本身,與Design by contract或是Program to an interface, not an implementation等至理名句要傳達的概念是相同的。型態並非真正要思考的對象,行為外觀才是重點。