更新於 2024/01/15閱讀時間約 8 分鐘

介面不能被繼承

類似於trait/typeclass的特性系統能提供程式「延展性」,它能讓函式針對不同的類型做出不同的行為。這種機制與物件導向的繼承非常像,然而特性系統的彈性比較大一點,而且概念上也有一些差別。為了探討討論這些差異,我們必須深入了解繼承機制到底是什麼。


繼承並不是建立子類關係的唯一方法。所謂的子類關係代表存在一種單向的類型轉換,能把子類的變數無痛轉換成父類的變數,而這種轉換並不是建構新的變數,轉換前後都代表同一個變數,只有類型改變了。透過類型轉換能夠將子類的變數值設定給父類的變數,這代表這個變數的類型與變數值的實體類型可以不一樣。例如類別Cat的值可以設定給類別Animal的變數,因為Cat繼承自Animal。子類關係也可以透過協變和逆變產生,例如List<Cat>是List<Animal>的子類。有些語言例如TypeScript擁有匿名的結構類型,而這些結構自動擁有子類關係,例如 {x: number, y: number, z: number} 是 {x: number, y: number} 的子類。物件導向的(子類)多型就是透過子類關係讓一個函式能接受多種類型的參數,並根據實體類型的不同而做出不同的行為。但子類關係並不是只能靠繼承,因此子類多型並不一定直接由繼承完成,然而最底層還是透過繼承機制實現。


物件導向的類別定義由兩個部分組成,一個是定義資料(成員變數),另一個是定義介面(方法),而繼承機制同時具有建立子類資料和繼承介面的功能。因為類別同時具有介面的功能,當子類變數轉換成父類變數時,介面仍是使用子類所實作的介面方法(因為那些方法是屬於這個物件的),而子類多型正是依賴於這個機制實現的。然而介面不應該被繼承,也就是父類實作的介面子類不一定要實作。舉一個最明顯的例子,Equal介面定義了方法equals,用來描述物件的相等性。它必須符合一些規則,例如obj.equals(obj) == true。首先,這個方法應該只有比較相同的類型時有意義,但我們不能把它宣告成bool equals(Self obj),其中Self代表物件的類型,編譯器沒辦法幫你推斷這個類型,因為它會因繼承而改變。因此只能退而求其次,宣告成bool equals(T obj),其中T是類型參數。如果一個類別(例如User)想要實作這個介面,就必須明確指定要比較的類型(例如實作Equals<User>)。但是當繼承這個類別時,這個方法就變成比較不同類型的方法,或者可以說他應該是「比較User定義的成員變數」的介面。它的本意不是只比較部分資料,這可能會使繼承自User的類別(例如Admin)與User比較時得到非預期的結果:user.equals(admin) == true。事實上比較具有不同實體類別的物件本來就不是合理的,尤其是把它當作資料操作時。因此像是相等性、比較大小、複製等會自我參考類型的特性都不能被繼承或允許類型轉換,就算需要繼承也應該使用其他機制完成繼承,而非直接預設不覆寫方法。


之前的文章提過利用泛型可以做到擁有類似子類關係的資料結構,而特性系統則可以做到介面的功能。例如我們可以定義Display特性,它帶有方法fn show(T) -> String,所有實作這個特性的類型都可以當作是繼承這個介面(抽象類別)的類別。它並不是真的子類關係,類型資訊只是被參數化而並沒有被抹除,因此像是List<Display>的類型跟List<T> where T: Display是不同的,前者可以裝不同類型且都實作了Display的物件,而後者只能裝同類型的物件。具有子類關係的類型在進行類型轉換時會丟失類型資訊,因此只能在執行時期透過子類多型取得物件的實體類型,但它們能因此被當作同一個類型使用,這在一些時候是很有用的。rust可以使用trait object做到類似的事,因此上面的例子可以改寫成List<Box<dyn Display>>,其中dyn Display包含了所有實作Display特性的類型,而實際類型已經被抹除。dyn代表它利用dynamic dispatch呼叫方法,應該呼叫哪個方法是沒辦法在編譯時期推斷的,因為類型資訊已經丟失。這個特性在物件導向的程式語言是稀鬆平常的,因此在無意中浪費了一點時間與記憶體,而rust關注記憶體與執行效率,因此它把這個特性明確地用關鍵字標示起來。


若要在PureScript或是Haskell實現帶有介面的子類關係,可以使用動態方法把原本在編譯時期的資訊,也就是trait/typeclass的方法,帶到執行時期。這個方法很直觀,就是把特性的方法寫成成員變數,然後使用泛型預留延展空間,再使用exist把類型參數抹除。例如使用PureScript的Record可以寫成:


type Concrete r =

{ value :: int,

setValue :: Concrete r -> int -> Concrete r

| r }

type AbstractClass = exist r. Concrete r


這裡的 Concrete r 代表物件的實體類型,它沒有子類,但預留了類型參數r提供擴展。value是它的成員變數,而setValue是方法,這個方法接收實體類型並回傳同樣類型的物件。AbstractClass則透過存在量化把所有可能的類型參數r都包含進來,所有 Concrete r 都是它的子類。跟物件導向不同的是,它的方法類型可以帶有實體類型,因為我們先使用泛型定義實體類型,再把它們包成抽象類型,而不是直接定義抽象類型,因此可以很自然地使用物件的實體類型作為回傳值。相比之下,物件導向只有自己(this)可以是實體類型,因此它很依賴變數修改與改變狀態來傳遞訊息。


這種方法的缺點是它的方法可以被任意改寫,即使兩個物件具有相同的實體類型,因為它的方法實際上只是類型剛好是函式的成員變數而已。在這種模型下,要建構一個物件就必須先準備方法給他,再把其他資料作為參數讓使用者傳入。這某種程度上可以看作是一種原型(prototype)的機制,只要修改原型就能改變物件的行為,這使得類型不再能描述變數的行為,並且可能會因此造成非預期的結果。例如考慮兩個帶有compare :: Concrete r -> Concrete r -> Ordering方法的物件,但它們的實作是不同的,這時使用誰的方法比較順序就會影響排序結果。物件的方法不應被當作成員函式,方法是被類型或介面描述的特性,它應該要擁有一致的行為。若是要修正這個問題,就必須把方法另外包成獨立且有意義的類型,而正好這就是trait/typeclass所做的事。因此應該改成:


type Concrete r = { value :: int | r }

class SetValue r where

setValue :: Concrete r -> int -> Concrete r

type AbstractClass =

exist r. (SetValue r, Concrete r)


在AbstractClass的定義中,SetValue r 代表實作此特性的結構,它跟資料 Concrete r 綁定在一起形成物件。因為一個類型基本上只能有一個實作特定特性的結構,因此不會出現前面的情況。rust的trait object基本上就是這種東西,其中 SetValue r 就是vtable,而 Concrete r 則是資料的部分。


事實上Haskell和PureScript的類型系統不支援一般的子類關係,例如定義一個延伸實體類型type Extend s = Concrete ( … | r ),它雖然也是AbstractClass的子類,但依據此類型定義的抽象類型則不是,它並不能直接轉換成AbstractClass。在這裡量化類型與實體類型的機制是不同的,因此不應把它們當成是同等的概念。rust雖然有子類關係,但只支援基於lifetime的子類關係,因為它們的變數類型與實體類型必須一致。具有子類關係的類型基本上就是擁有無限種可能的加法類型,因為一般來說類別總可以被延展。然而大部分時候一般的加法類型就足夠使用了。相反地,有限可能的加法類型反而有好處,因為我們總可以確定所有可能的狀態。Java引入sealed/permit也是為了限制這個過強的特性。即使很複雜,函數式編程的確能在某種程度模擬繼承機制,但它並不是合適的編程範式,其中的困難點都顯示了物件導向機制的問題。泛型、特性、加法類型等都已經足夠實現類似的功能,甚至可以做的更好,還有什麼理由要使用繼承?


分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.