2024-01-06|閱讀時間 ‧ 約 29 分鐘

同調的泛型

前幾篇文章討論了類型系統的合理性,而這會影響我們對於變數與函式是什麼的理解。其中泛型是當中很重要的一個元素,很多討論都是基於泛型的使用。泛型會大大地增加類型系統的複雜度,因此有些語言選擇不提供泛型(go),但缺少泛型又會使簡單的容器都無法用類型精確描述。泛型的強大必須結合有紀律的類型系統才能顯現,但不能做動態類型檢查的類型系統必須有泛型,否則就連最簡單的抽象化都做不到。這顯示了泛型本質上與抽象化緊密相連,而且使用抽象化的唯一方法就是泛型。在這種類型系統,類型描述的是實際的資料結構,而抽象化的任務全部都交給泛型完成,不像物件導向類別同時可以描述抽象的物件。


「抽象化」是將某些結構或概念從實際案例中抽取出來,並讓它可以應用在未知的情境下,而泛型就是量化未知的方法。例如我們有 fn swapIntAndString(Tuple<int,string>)->Tuple<string,int> 和 fn swapFloatAndBool(Tuple<float,bool>)->Tuple<bool,float>等函式,即使它們的類型都不一樣,但實作看起來都一樣。它們都是把tuple的兩個元素做交換,因此可以將這種結構抽取成泛型函式。就像把相同的程式碼抽取出來時,需要將不同的部分改寫成參數,抽取泛型函式時也需要將不同的類型改寫成類型變數。例如:fn swap<T,S>(Tuple<T,S>)->Tuple<S,T>,其中T和S是類型參數。抽取函數可以把相同的運算邏輯獨立出來,而使用泛型可以進一步把無關的資訊排除,例如交換Tuple的元素不需要知道內容是什麼,因此我們可以把這部分的資訊取代成一個佔位符。在像是c, go這種沒有泛型的程式語言是做不到這種抽象化的,只能藉由類型轉換自己判斷應該是什麼。


泛型函式需要輸入變數與類型作為參數,而參數的類型跟類型參數通常都是有關的,因此編譯器大多時候都能自動推斷類型參數。這似乎代表類型與變數是同等的存在,但事實上在大多數靜態類型程式語言不是如此,通常類型只會出現在編譯時期,類型的值不會受到變數影響,這代表類型與變數是兩個世界。參數可以看作表達式的佔位符,因此類型參數也能看作函式類型表達式的佔位符,而像是Tuple<A,B>, fn(A)->B等類型建構子則可以看作類型的函式:它們接受兩個類型並回傳類型。這種函式和一般的函式還是差了一大截,因為F<A,B>在所有類型之中都是唯一的,不可能有其他的表達式能表示相同的類型,這就是為什麼他稱做類型建構子而非類型函式(在typescript中則真的是一種函式了)。這種特性使得類型看起來是什麼就是什麼,而泛型真的就像是類型的範本一樣一目了然。


如果函式的操作與變數的類型或是資料結構的某個部分類型無關,那麼這個類型就能抽取出來變成類型變數。就算你的情境並不需要這麼做,也應該要嘗試使用泛型,因為這麼做可以表示這部分的邏輯與這個類型無關。相較於直接把類型取代成void pointer/empty interface/any type,泛型把未知的類型參數化,使得變數之間的聯繫變得清楚,更能描述整體結構,這對容器相關的資料結構特別重要。當泛型函式關係到多個未知的類型時,類型變數可以把相關的未知類型形成同調,讓不同的「未知」能夠跨越多個函式而不會混在一起。這就像原本作為範本上的「洞」的類型變數,跨越整個計算過程形成「隧道」,而這些隧道編織出的形狀描述了這個計算過程的整體結構。透過free theorem,我們甚至能直接推斷出這些隧道大概長怎樣。


物件導向用子類關係把無關的資訊隱藏起來,而泛型透過參數化把無關的資訊抽離,它們的差別在於抹除資訊的方法,泛型的做法保留了同調性。利用同樣的概念可以把物件導向的繼承機制用泛型模擬出來。PureScript擁有一種無序的乘法資料結構Record,這種結構能夠擴展成員變數,這很像TypeScript的Record,但他具有更健全的語義。例如 { a :: String, b :: Boolean } 代表一個帶有a, b成員的物件,但不能有其他成員,這一點跟TypeScript很不一樣。TypeScript的Record只告訴你可以存取哪些成員,但它可能有其他成員。從物件導向的角度來看,擁有其他成員的物件可以看作這個類型的子類,也就是它同時包含了這個類型的物件實體和其子類的物件實體,因此他更像抽象類別/介面而非資料類型。PureScript的Record類型明確描述所有的成員,若要像TypeScript只關注某些成員的存取,可以透過泛型把未知的部分參數化(row polymorphism),寫成 { a :: String, b :: Boolean | r },其中r是特殊的類型變數,它代表所有其他的成員。這與TypeScript的差別在於它不會丟掉任何資訊,例如函式 increase :: { counter :: Number | r } -> { counter :: Number | r },使用這個函式不會丟任何類型資訊。在TypeScript要達到相同效果必須使用帶有約束的泛型函式 <T extends {id:Number}>(val: T) => T。反之,在PureScript要像TypeScript丟掉類型資訊就必須使用quantifier,寫成 exist r. { counter :: Number | r }(事實上PureScript沒有exist,但可以使用forall做出來)。


定義函式時應盡量不要丟掉類型資訊,因為這樣很容易忽略未考慮的狀況,而類別的方法一般都是在這樣的情況下定義的函式,因此應該少用方法。例如在為類別定義相等方法時一般寫作:equals(other: ThisClass): boolean,然而當遇到繼承它的子類物件實體時該怎麼辦?很明顯子類的新成員變數也應該一起比較,而且比較不同類別的實體是沒有意義的,因此這個方法本質上就是不可繼承的。若是寫成泛型函式問題就很明顯:function equals<T extends ThisClass>(a: T, b: T): boolean,這個函式看起來就不可能實作,因為你不知道類型T是什麼是要怎麼比較。如果你真的把所有子類的判斷都寫死在裡面,就違反了依賴抽象的規則,這很容易出錯,除非你能確定不會有其他類別繼承ThisClass,不過這時你應該嘗試使用加法類型(至少在TypeScript是可以這麼做的)。物件導向的變數類型宣告預設會丟掉實體類型的資訊,使得在實作時必須使用動態型別檢查(或是自己檢查),這讓類型資訊不能順暢地流動,從而產生更多的錯誤機會。而使用泛型雖然會讓變數變多,因而產生太多雜音影響清晰度,但類型得以串聯起來,讓編譯器能為你檢查錯誤。物件導向傾向在執行時期使用類型,而函數式編程一般只能在編譯時期使用類型,好處是這麼做可以在編譯時期就檢查出錯誤。然而有時仍需要丟掉類型資訊,或是把類型資訊下放到執行時期(為了減少雜音或是聯集不同類型),而這對大部分函數式程式語言來說不可能做到或是很難用。例如rust提供了trait object把類型資訊丟掉,並提供Any trait在執行時期存取類型資訊;Haskell或是PureScript可以使用forall把類型資訊隱藏起來,但沒辦法在執行時期存取類型資訊,Idris2則可以使用Sigma type做到。事實上並不常需要丟掉類型資訊(在物件導向常常用到只是因為它的設計比較容易這麼使用),而且大多時候仍可以透過自己寫加法類型手動把類型資訊帶到執行時期。

分享至
成為作者繼續創作的動力吧!
這不是教你如何從物件導向到函數式編程的入門教程。我會深入探討物件導向與函數式編程的差異,並討論為什麼你應該使用函數式編程並徹底放棄物件導向。
© 2024 vocus All rights reserved.