從子類化到Typeclass
前言
提及物件導向,大家可能非常熟悉:繼承、封裝、多型三大特性想必早已爛熟於心。但是在某些場景下,物件導向(或者說Java的物件導向)卻存在一些問題或者說是缺陷。
問題
現在有一個型別層次結構如下:
我們要怎樣才能在父類中定義一個通用的方法,而這個方法可以返回一個屬於呼叫者當前的型別的物件?
舉個例子:比如我們希望給Pet
實現一個rename
的方法,該方法可以返回一個擁有新名字的等價拷貝。
子類化
熟悉物件導向的你可能會這麼做:
interface Pet {
val name: String
fun rename(newName: String): Pet
}
data class Cat(override val name: String) : Pet {
override fun rename(newName: String) = this.copy(name = newName)
}
val cat1: Cat = Cat("BuDing")
val cat2: Cat = cat1.rename("DouHua") // 沒問題,還是隻貓
因為協變返回型別的特性,rename
方法成功的返回了我們所希望的型別。但是它還存在一個問題:這個返回型別的約束比較寬泛,只要是Pet
的子型別就能通過編譯。比如我們在編寫Dog
的程式碼時,不小心寫錯了:
data class Dog(override val name: String) : Pet {
override fun rename(newName: String) = Cat(newName) // 手抖,寫錯了
}
val dog1 = Dog("WangCai")
val dog2 = dog1.rename("FuGui") // Oops!狗不是狗,改個名字就成貓了
而且由於型別推斷的存在,這種錯誤將變得難以發現。另外,我們沒法給它們實現一個公用方法,還能保住型別。
// 編譯失敗,無法向下轉型
fun <A : Pet> esquire(pet: A): A = pet.rename("${pet.name}, Esq.")
F_Bounded Polymorphism
F-bounded polymorphism (a.k.a self-referential types, recursive type signatures, recursively bounded quantification) is a powerful object-oriented technique that leverages the type system to encode constraints on generics.
F有界多型(又稱:自引用型別、遞迴型別簽名、遞迴有界量化)是一種強大的OOP技巧,它利用型別系統對泛型進行約束。這個技術在Scala中討論的比較多,詳細可參考F-Bounded Polymorphism: Recursive Type Signatures in Scala。當然,該技術基於引數多型與子型別多型,因此在Java/Kotlin中也是可以實現的。
下面我們使用F有界多型對Pet
進行改造,使Pet
的任何子類都必須傳遞「自身型別」作為型別引數:
interface Pet<A : Pet<A>> {
val name: String
fun renamed(newName: String): A
}
class Cat(override val name: String) : Pet<Cat> {
override fun renamed(newName: String) = this.copy(name = newName)
}
此時,我們就可以為Pet的子類編寫公用的方法了。
// 不錯!通用方法可以工作了
fun <A : Pet<A>> esquire(pet: A) = pet.renamed("${pet.name}, Esq.")
但是傳遞「自身型別」這個限制也是寬鬆的,依然存在寫錯的可能性。
data class Dog(override val name: String, val color: Int) : Pet<Cat> {
// 不小心又寫錯了,狗子又變成了貓
override fun renamed(newName: String) = Cat(newName)
}
在Scala中可以使用自身型別限制型別引數必須為當前型別。
trait Pet[A <: Pet[A]] { this: A => // self-type
def name: String
def renamed(newName: String): A
}
遺憾的是這在Kotlin中並不支援,而且即便可以限制型別引數為當前型別,我們還是可以通過繼承滿足約束的型別來繞過限制。
class Kitty(name: String) : Cat(name)
val kitty1 = Kitty("MiaoMiao")
val kitty2 = kitty1.renamed("MiMi") // 小貓長大了!!
Typeclass
Typeclass起源於Haskell,可以認為它是對型別的進一步抽象,它抽象出了型別的共同的行為,這種行為的具體實現由它的型別引數決定。以下是《Learn You a Haskell for Great Good! 》中的解釋:
A typeclass is a sort of interface that defines some behavior. If a type is a part of a typeclass, that means that it supports and implements the behavior the typeclass describes.
在Scala的型別系統中,並沒有將Typeclass內建為原生特性,但是可以通過implicit
實現Type Class Pattern。在Kotlin中,Typeclass也沒有得到支援,而且更因為缺乏implicit
,使得Kotlin中實現的Type Class Pattern變得醜陋且難以理解。儘管如此,它還是能在某些方面為我們帶來一些好處。下面看看Type Class Pattern是如何解決前面的問題吧:
首先我們將Pet
的rename
行為抽象為一個Typeclass:
interface Pet {
val name: String
}
interface Rename<T : Pet> {
fun T.rename(newName: String): T
}
然後在實現子類時,根據需要為其實現相應的rename
方法:
data class Cat(override val name: String) : Pet
object RenameCat : Rename<Cat> {
override fun Cat.rename(newName: String) = copy(name = newName)
}
不過使用時有些蹩腳,需要帶上Rename
的例項:
val cat1 = Cat("BuDing")
val cat2 = RenameCat.run { cat1.rename("DouHua") }
定義和使用一個公用方法也是沒問題的:
fun <T : Pet> esquire(pet: T, rename: Rename<T>): T {
return rename.run { pet.rename("${pet.name}, Esq.") }
}
val cat3 = esquire(cat1, RenameCat)
最好的是,不再擔心寫型別引數了:
data class Dog(override val name: String) : Pet
object RenameDog : Rename<Cat> { // 又又手滑了
override fun Cat.rename(newName: String) = copy(name = newName)
}
val dog1 = Dog("WangCai")
val dog2 = RenameDog.run { dog1.rename("FuGui") } // 這下編譯器不幹了,rename編譯時報錯
val dog3 = esquire(dog1, RenameDog) // 公用方法也無法使用
使用Typeclass處理容器
當然,如果Typeclass只能解決這麼個問題的話,似乎也不比F有界多型厲害多少。來看另外一個問題:
如何為不同的泛型型別定義一個通用的方法?
假設我們現在有三個泛型型別:List<T>
, Set<T>
,以及一個我們自己定義的Store<T>
(它是一個僅儲存一個值的容器)。那麼我們如何為這些型別實現一個通用的mapToString
方法,將容器內的元素變換為String
型別?
class Store<T>(private val value: T) {
fun read(): T = value
}
讓我們先嚐試給出mapToString
的方法簽名,然後你就遇到了第一個問題,用什麼來指代這三種型別呢?List
和Set
還好,它們擁有共同的父類Collection
,但是Store
呢?總不可能用Any
吧?有沒有什麼辦法可以保住型別呢?
你或許想到了泛型,並寫出瞭如下程式碼:
// 虛擬碼
fun <C<T>> mapToString(container: C<T>): C<String>
不幸的是,編譯器報錯了,不支援<C<T>>
這種泛型。其實,這裡的<C<T>>
叫做高階型別,在Scala 中早已內建支援了,只是寫法有些不一樣:[C[_]]
。
高階型別
通常,我們會將高階函式與高階型別進行類比:
普通函式,也就是一階函式,引數與返回值只能是一個具體的值。與之對應的一階型別構造器,它接受一個具體的型別變數,然後返回另一個具體的型別,我們熟知的泛型就是一階型別構造器。
我們知道,高階函式就是把函式作為引數或者是返回值的函式。而高階型別(或許更應該稱之為高階型別構造器),則是把型別構造器作為引數或者是返回值的構造器,比如Interable[C[_]]
,我們可以給C[_]
賦值為List[T]
,就得到了一個Interable[List[T]]
的一階構造器。
這裡你可能有些疑惑,[C[_]]
怎麼就是高階型別了?可以這麼理解:[C[_]]
接收一個型別構造器,然後返回這個型別構造器。
通常,許多人習慣將
C[_]
稱為高階型別,就我個人而言,這為我理解高階型別造成了不小的困擾,而上述的定義相對的比較容易理解。
如何在Kotlin中使用高階型別
雖然Kotlin不支援高階型別,但是我們可以使用一些方法來模擬它。
interface Kind<out F, out A>
interface Functor<F> {
fun <A, B> Kind<F, A>.map(f: (A) -> B): Kind<F, B>
}
我們首先定義Kind<out F, out A>
,它代表型別構造器F
應用型別引數A
所產生的型別。而F
用來代表型別構造器,替代F[_]
成為高階型別Functor
的型別構造器引數。
這裡,我們的Functor
也是一個Typeclass,它抽象出了一個適用於容器型別的通用map
方法。
那麼,如何使用這個結合了高階型別的Typeclass呢?
使用結合了高階型別的Typeclass
為新的型別應用Typeclass
以前面的Store<T>
型別為例,首先我們定義一個StoreHK
用來代替Store
這個型別構造器,然後將其應用給Kind
,再讓Store
繼承這個Kind
:
object StoreHK
class Store<T>(private val value: T) : Kind<StoreHK, T> {
fun read(): T = value
}
因為Kind<StoreHK, T>
在這裡唯一指代Store<T>
,因此可以直接轉換。
fun <T> Kind<StoreHK, T>.fix(): Store<T> = this as Store<T>
接著我們為StoreHK
實現Functor
的例項:
object StoreFunctor : Functor<StoreHK> {
override fun <A, B> Kind<StoreHK, A>.map(f: (A) -> B): Kind<StoreHK, B> {
val oldValue = this.fix().read()
return Store(f(oldValue))
}
}
然後使用這個Typeclass
fun main() {
val intStore = Store(100)
val stringStore = StoreFunctor.run {
intStore.map { "String-$it" }
}
}
為已有型別應用Typeclass
在來看看如何為List
,Set
這種無法修改的型別應用Typeclass。首先我們需要為它們定義一個代理型別用於繼承Kind
。這裡以List
為例,Set
依葫蘆畫瓢即可:
object ListHK
class ListW<T>(list: List<T>) : Kind<ListHK, T>, List<T> by list
@Suppress("UNCHECKED_CAST")
fun <T> Kind<ListHK, T>.fix(): List<T> = this as List<T>
然後實現對應的Typeclass例項:
object ListFunctor : Functor<ListHK> {
override fun <A, B> Kind<ListHK, A>.map(f: (A) -> B): Kind<ListHK, B> {
val newList = this.fix().map { f(it) }
return ListW(newList)
}
}
為它們實現通用方法
最後一步,為Store
、List
、Set
實現mapToString
方法:
fun <F, A> mapToString(container: Kind<F, A>, functor: Functor<F>): Kind<F, String> {
return functor.run {
container.map { "String-$it" }
}
}
總結
Typeclass在更高的抽象層次上描畫業務,因此可以有效的減少重複程式碼,不過限於Kotlin語言本身,文中Typeclass的實現比較繁瑣,使得最終得到的效果顯得有些微不足道。幸運的是,有個不錯的函式式庫—Arrow-kt,可以幫助我們自動生成很大一部分的冗餘程式碼。
另外Typeclass使用組合的方式替代繼承,避免了繼承帶來的種種問題,也使得程式碼更加符合設計原則。
參考資料
相關文章
- Python 繼承和子類示例:從 Person 到 Student 的演示Python繼承
- iOS子類化iOS
- 從電子遊戲到DevOps遊戲dev
- 實現不可變類如何禁止子類化?
- java中父類宣告子類例項化Java
- 從Spring中學到的【2】--容器類Spring
- [短文速度-4] new子類是否會例項化父類
- <<從0到1學C++>> 第3篇 從結構到類的演變C++
- Java子類和父類的初始化執行順序Java
- python類的子類Python
- python 類的子類Python
- 【獨立開發賺錢】從點子到創收
- 從Oracle 11.2.0.4 BUG到Oracle子查詢展開分析Oracle
- 電子遊戲互動簡史:從“電子玩具”到“第九藝術”遊戲
- 【QT】子類化QThread實現多執行緒QTthread執行緒
- Java超類與子類Java
- vue-cli3 從搭建到優化Vue優化
- 談談元件化-從原始碼到理解元件化原始碼
- 從零到一,元件庫的進化元件
- SSD新正規化|從SATA到NVMe(上篇)
- 從組合語言到類庫框架的隨感組合語言框架
- 從零到有模擬實現一個Set類
- 【機器學習】--譜聚類從初始到應用機器學習聚類
- 《Haskell趣學指南》讀書筆記(2):Type And TypeclassHaskell筆記
- ProgressBar及其子類
- HTML5從入門到精通電子書pdf下載HTML
- NPC會夢見電子羊嗎?從遊戲AI到AI遊戲AI
- 從模組化到NPM私有倉庫搭建NPM
- 從 java 8到 java 11變化一覽Java
- 類的繼承_子類繼承父類繼承
- 【QT】子類化QObject+moveToThread實現多執行緒QTObjectthread執行緒
- vim從入門到棄坑:基礎指令的歸類
- 從字串到常量池,一文看懂String類設計字串
- Java從入門到精通 第七章 類和物件Java物件
- 《Java從入門到失業》第四章:類和物件(4.2):String類Java物件
- 貝葉斯分類器詳解 從零開始 從理論到實踐
- Geopandas——從“視覺化”到“字母化”的空間資料分析視覺化
- 原型繼承:子類原型繼承