2024-01-20|閱讀時間 ‧ 約 27 分鐘

特性系統的技能樹

上一篇文章提到有些介面不應被繼承,但物件導向的子類別只能繼承父類別的介面,因而產生一些不合適的介面實作。trait/typeclass則沒有這種繼承機制,這似乎使需要繼承的特性無法直接使用。然而函數式導向比起繼承,更適合使用組合,根本不需要使用繼承疊加特性。


資料類型的定義往往跟怎麼建構模型相關,透過類型我們可以知道哪些資訊與功能是關聯在一起的,而如何分類、拆解變數就是一門藝術。這門藝術有幾個流派,而物件導向與函數式編程就是其中之一。物件導向建議把資料抽象化,將其中的共同變數與相關方法蒐集起來定義成父類別。繼承就是透過增加資料結構的成員變數與方法達成擴展,讓我們可以描述更複雜的模型。而函數式編程傾向使用固定的資料結構描述模型,並利用組合構造出更複雜的系統,也就是定義一個新類型將多個資料透過成員變數組合成新的資料。我們可以透過為類型實作trait/typeclass讓物件擁有關聯方法,但與定義類別相比,這些方法不會因為組合而被繼承到外部類型,若要繼承就必須手動實作trait/typeclass並把方法委派到成員變數上。繼承成員的特性本來就不是常見的操作,因為成員自己的特性應該獨立於外部類型,而不是為了擴展而定義的。若要為擴展而定義類型與特性,應該使用泛型把可擴展的部分參數化,這種方法可以把未知的擴展部分也納入約束之中。因此可以說物件導向是向外的抽象化,它把類別之間的交集作為內部的/核心的特性抽取出來,其他特性只是它的外部延伸;而函數式則是向內的抽象化,它把特性看作是一種約束而不是某些結構的共通部分,而約束的廣適性代表了它的抽象程度,所謂的擴展只不過是其中的一個更多約束的實例。在這裡約束並不是壞事,它並不是指你不應該做什麼,這裡是指對未知的約束,約束越強,代表類型越明確,能做的事也就越多。


特性系統的擴展是藉由要求實作此特性的類型也必須實作另一個特性,特性的定義如 trait Ord: Eq { … } 指的是實作Ord的類型也必須實作Eq,而它們之間必須符合:a.eq(b)的結果必須是 a.cmp(b) == Ordering::Equal,這通常會寫在說明文件裡。這種隱含關係形成了特性的延展關係,這就像是介面的延展。在上面rust的例子中,雖然它寫成像是子類的語法,但它只是以下寫法的語法糖:trait Ord where Self: Eq { … }。其中where是用來描述這個trait的約束,其他類型參數的約束通常都會放在這裡,當然Self也不例外,而這跟介面很不一樣,因為介面沒有Self這種類型參數。在PureScript則是寫成class Eq a <= Ord a where …,其中箭頭方向是向左的(跟Haskell相反),它可以看作是一個從Ord a到Eq a的函式。它並不是要你實作這樣的函式,而是編譯器會為你產生這種函式,並在你擁有Ord a的實作時幫你使用這個函式得到Eq a。為了讓這個函式能夠正確建立,你必須在實作Ord MyType同時實作Eq MyType。這種特性的延展關係形成一棵技能樹(有向無環圖),要實作尾端的特性就必須同時實作前面的特性。反過來說,當我們有了一個特性實作,就也能存取前面所有的特性。介面也有一樣的功能,但特性的延展不限於單一的Self類型,因為Self對特性而言只是一個類型參數。


實作特性的方法與介面類似,你必須宣告你要實作哪個特性,並寫下關聯方法的實作。跟介面不一樣的是,我們可以分別對不同的類型參數實作特性,例如可以同時為一個類型MyCounter實作AddAssign<u8>和AddAssign<&[u8]>,甚至可以根據所有可能的類型參數T: IntoIterator<u8>實作AddAssign<T> 。一般而言,類別雖然不能像這樣實作無限多種介面,但這種情況只需要實作像是AddAssign<Iterable<u8>>的介面就行了。而特性系統的這種能力讓我們不用抹除類型資訊就能達到一樣的效果。它還能應用於更複雜的情況,例如可以為MyCounterWithType<T>根據所有可能的類型參數S實作AddAssign<&[S]>,其中他們必須符合約束T: AddAssign<S>。這種條件式的特性實作就跟帶有約束的泛型函式一樣自然,這在rust和Haskell等具有特性系統的語言很常看到,它讓我們可以根據狀況賦予類型不同的特性,而不需要使用動態檢查判斷合法性。在PureScript則寫成instance AddAssign t s => AddAssign (MyCounterawithType t) (List s) where …,箭頭前面的就是對類型參數 t, s 的約束/特性,它可以看作類似函式的東西,其中約束就是輸入參數,編譯器會在需要時使用這個函式建構出你需要的特性實作。ocaml的module function也是一樣的概念,它接受模組作為參數並構造出你要的模組,但你必須自己手動建構,編譯器是不會幫你的。這種動態的特性系統雖然很直覺,但在使用上有點麻煩。


有了條件式的特性實作,我們可以手動把成員的特性「繼承」到外部類型。例如struct ConnectionManager<Conn: Connectable> = { conn: Conn, … }透過組合為連線服務增加管理功能,如果要使用連線服務的一些方法,可以直接存取成員並使用它的特性。但如果不能,就必須把部分功能帶到外部類型,這時需要手動把它們接上:fn isClosed(&self) -> bool { self.conn.isClosed() }。如果要把一整個特性帶到外部,可以這樣:impl<Conn: Pingable> for ConnectionManager<Conn> { fn ping(&self) -> bool { self.conn.ping() } },如此只要連線服務有這種特性,管理器也同樣會有一樣的特性。雖然必須手動委派方法,但比起物件導向的繼承機制好多了,畢竟不是所有的方法都要帶出來,而且有例外時也比較好處理。如果還是覺得麻煩,可以考慮使用rust的巨集功能。事實上,所謂的繼承就只是讓子類實作父類的方法,它只是把子類物件轉型成父類,再呼叫父類實作的方法。現在只是把這部分明確寫下來,變成透過外部類型的變數取得成員,再呼叫成員實作的方法。必須明確寫下是因為繼承本身不是自然的,部分介面不應被繼承,也不應隨便暴露內部成員的方法。

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