多型的差異

閱讀時間約 8 分鐘

所謂的多型是讓一個函式或是資料結構能擁有多個不同的類型,其中上一篇文章所談的就是參數多型(parametric polymorphism),這篇文章將繼續討論特設多型(ad hoc polymorphism)。特設多型跟泛型的差別在於:泛型函式對於所有的類型只能有一種實作,而特設多型會根據類型有不同的實作。物件導向就是藉由改寫方法的機制實現特設多型,另外一種方法是藉由實作介面。特設多型的所有可能的實作不需要放在同一個地方,你可以在其他檔案/模組/專案寫下它對特定類型的實作,而在使用時(大部分的)編譯器會為你找出最合適的實作。這個特性使得它能用來表達抽象的概念,而非特定的函式實作,這樣才能讓程式碼具有可延展性。


多型能讓一個名詞囊括一類特性,使它們凝聚成一種抽象概念,缺乏多型的能力會讓程式碼與概念的重用性降低。當我們要為不同的類型實作某種共通的抽象操作時,我們必須使用多型。例如fn swap<A,B>(Tuple<A,B>)->Tuple<B,A>就是將所有交換Tuple元素的操作抽象化的多型函式。但並不是所有的概念都能用同一種實作描述,只有泛型是不夠用的。例如fn toString<A>(A) -> String是將變數轉換成文字以供閱讀的函式,對於每個類型都有不同的實作,這時就需要使用特設多型為每個類型實作不同的操作。特設多型讓我們能夠在不知道實際類型的情況下做出不同的行為。例如,我們沒辦法直接實作出fn sort<T>(Array<T>) -> Array<T>,因為我們不知道如何比較未知的類型T,我們至少需要知道如何比較T,因此需要給類型參數一個約束T: Ord,這代表存在函式fn compare(T, T) -> Ordering的實作,因此排序函式可以利用這個函式完成操作。如果沒有這個約束,所有類型的列表丟進去都會受到一樣的交換,如果sort({1,2,3}) == {2,3,1},那麽sort({'a','s','d'})就會得到{'s','d','a'}。物件導向的程式語言則可以透過檢查實體類型做出不同的行為,但這只能針對已知的類別進行差別處理,這並不能用來實現sort這種需要你不知道的類型的某種特性的函式。


「特設多型」的定義是指函式根據類型有不同的行為,然而這並不是最重要的功能。特設多型函式還允許讓不同類型的實作放在不同地方,你甚至可以在其他檔案/模組/專案寫下它對特定類型的實作,這使得這個函式可以被延展。因此使用(未實例化的)特設多型函式時是不可能知道它實際會怎麼運作,這強迫我們必須以抽象的方式思考。這種延展性與物件導向的介面實作機制類似,它能讓一個抽象概念(參數化類型)在不同地方被明確描述(被實作)。就如上面所提到的,像是sort這種需要非特定類型的特性的情境,大部分時候這只能依靠特設多型的可延展性實現,而這才是特設多型的最大用處。


物件導向可以藉由介面實現特設多型,它將這種函式放在一個類型名下,並給予此名稱一個概念,而定義類別時可以透過實作此介面附加針對此類型的實現。在TypeScript寫成 interface Display { toString(): String; },其中物件本身是隱含在toString的一個泛型變數。然而對於物件導向來說,這個方法是屬於物件的而非附加給Display的,所謂的「介面」只是為了介接給其他人使用的一個「特殊類別」,它並不是獨立於類別的存在。這種觀點使得介面只能在定義類別時實作,若是你新定義了一個介面想讓已有的類別實作,就必須回頭去改寫類別的實作,這樣會讓類別的定義包含各種抽象方法的實作,使得概念與關注點沒辦法集中在個別的模組內。物件導向的這種設計迫使我們必須把關注點與物件綁在一起,在這種情況下通常會將這些想要抽離的實作再分出一個類別,並以組合的方式合併。相對地,rust的trait用法類似於介面,它底下的方法被稱為關聯方法而非成員方法,因為它並不屬於此類型。為特定類型實作trait的程式碼不是寫在定義類型時,在一些情況下也可以在跟類型不同的模組實作trait,甚至可以為符合某種條件的類型實作trait。因此trait應被視為獨立於類型的存在,它是用來描述這個類型帶有什麼樣的特性,而所謂的特性並不是資料,因此不應跟用來描述資料的類型綁在一起。rust也允許把方法綁定到類型名稱上,它只代表這些方法與此類型有很緊密的關係,但它不具有繼承的特性,所有繼承能實現的功能都應交由trait完成,因為這是trait的職責。


雖然rust的trait system用起來很像介面,但它其實來自於Haskell的typeclass,因此有些特性用介面的角度來看很神奇。rust的trait寫成類似於介面的語法,例如 where A: Add<B>代表A擁有加上B的特性,它更適合寫成addImpl: Add<A, B>,也就是addImpl是描述”A加上B”這個特性的物件,但這個物件是由編譯器在編譯時期幫你建構的。在Haskell則是寫成Add a b => …,這個寫法就好像Add a b是某種參數,但它也是由編譯器幫你填上的,因此並不需要煩惱這點。trait的實作代表的是一種描述某種類型的特性的函式集,而這套函式集會綁定到當前的情境,因此不需明確地說明你是要使用哪種trait的函式。而且一般來說是不會遇到同時使用同一個trait但多種實作的情況,因此Haskell和rust都沒有給trait/typeclass參數一個名稱(PureScript可以,但沒有用途),也不能為同樣的類型實作兩個版本,Haskell甚至要求typeclass必須能唯一地決定。rust跟Haskell的這些機制都是發生在編譯時期,因此實際上並沒有addImpl這個物件,前面提的這些看法只是我的想像,事實上可能不是這麼運作的,但ocaml真的把trait的實作當作是函式集的物件。ocaml把類型與相關函式包成一個模組,就像一個struct一樣,而這個模組也有類型。它的類型包含了定義的類型名稱、定義的變數類型和函式類型,其中它們的類型可以跟它自己定義的類型有關,具有相同類型結構的模組都被當作是同樣類型的。若要讓某個類型具有可以比較的特性,只要定義有相應結構的模組就行了,到時如果某個函式要用到這個特性,就把這個模組傳給他。例如你可以為類型mytype定義一個模組module mytypeComparable: sig type t = mytype; val compare: t -> t -> int; end,若要為mytype list做排序時,只要把他丟給sort: (module M: Comparable with type t = 't) -> 't list -> 't list就行了。


這種只看結構的類型稱做鴨子型別(duck type),我們不需要明說它實作了哪個特性,使用上會直接假設它符合我們的要求,這種規則跟直接傳入函式幾乎是一樣的:sortBy: ('t -> 't -> int) -> 't list -> 't list。使用鴨子型別會產生隱藏的依賴關係,當你為了排序而實作一個模組時,沒人知道知道這個模組描述什麼概念,直到把它傳入sort函式。TypeScript的介面也是鴨子型別,定義的名稱實際上只是別名,但language server還是會記得你使用的介面是什麼名稱,以方便在編輯時提供資訊。為資料類型或是特性取唯一的名字不只是為了方便,最主要是為了讓他們擁有明確的意義,而非只是看起來像什麼。同樣是類型為t -> t -> int的函式,如果沒有說明,很難知道他是做什麼的。而把它包裝成名為Comparable的特性,很快就能知道他是用來比較順序的,如果還不知道可以去看Comparable的說明文件。包裝成特性還能讓它應該符合哪些約束變得更明瞭,而這些約束是組成特性很重要的部分,或者說只有當這些約束存在才能形成特性本身。


3會員
23內容數
這不是教你如何從物件導向到函數式編程的入門教程。我會深入探討物件導向與函數式編程的差異,並討論為什麼你應該使用函數式編程並徹底放棄物件導向。
留言0
查看全部
發表第一個留言支持創作者!