從子類化到Typeclass

碎星發表於2020-10-26

前言

提及物件導向,大家可能非常熟悉:繼承、封裝、多型三大特性想必早已爛熟於心。但是在某些場景下,物件導向(或者說Java的物件導向)卻存在一些問題或者說是缺陷。

問題

現在有一個型別層次結構如下:

FunctionalUntitled.png

我們要怎樣才能在父類中定義一個通用的方法,而這個方法可以返回一個屬於呼叫者當前的型別的物件?

舉個例子:比如我們希望給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是如何解決前面的問題吧:

首先我們將Petrename行為抽象為一個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的方法簽名,然後你就遇到了第一個問題,用什麼來指代這三種型別呢?ListSet還好,它們擁有共同的父類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

在來看看如何為ListSet這種無法修改的型別應用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)
    }
}

為它們實現通用方法

最後一步,為StoreListSet實現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使用組合的方式替代繼承,避免了繼承帶來的種種問題,也使得程式碼更加符合設計原則。


參考資料

相關文章