在 Kotlin 中“實現”trait/型別類

jywhltj發表於2020-01-12

本文原發於我的個人部落格:https://hltj.me/kotlin/2020/01/11/kotlin-trait-typeclass.html。本副本只用於圖靈社群,禁止第三方轉載。

trait 與型別類都是什麼

trait型別類(type class)分別是 Rust 與 Haskell 語言中的概念,用於特設多型(ad-hoc polymorphism)函數語言程式設計等方面。

值得一提的是雖然英文都是“trait”, Scala 的特質跟 Rust 的 trait [^1] 卻並不相同。 Scala 的特質相當於 Kotlin 與 Java 8+ 的介面,能實現子型別多型;而 Rust 的 trait 更類似於 Swift 的協議與 Haskell 的型別類,能實現特設多型。簡單來說,trait 應同時具備以下三項能力[^2]:

  1. 定義“介面”並可提供預設實現
  2. 用作泛型約束
  3. 給既有型別增加功能

Haskell 的型別類不僅同時具備這三項能力,還能定義函數語言程式設計中非常重要的 Functor、Applicative、Monad 等。 當然這是廢話,因為它們在 Haskell 中本來就是型別類?。 實際上這也不是 trait 與型別類的差異,能否支援 Functor 等的關鍵在於語言的泛型引數能否支援型別構造器(或者說語言能否支援高階型別)。

[^1]: trait:Scala 中文社群傾向於譯為“特質”,Rust 中文社群傾向於不譯。

[^2]: 按說“介面”不支援預設實現也可實現 Rust/Haskell 式的特設多型,但其易用性與表現力都要大打折扣。

在 Kotlin 中尋求對應

在 Kotlin 中並沒有同時具備這三項能力的對應,只有分別提供三項能力的特性。 其中 Kotlin 的介面同時具備前兩項能力。

定義“介面”並可提供預設實現

例如,定義一個帶有預設實現的介面:

interface WithDescription {
    val description: String get() = "The description of $this"
}

Kotlin 的介面中可以定義屬性與方法,二者都可以有預設實現,簡便起見,示例中用了具有預設實現的屬性。它可以這麼用:

class Foo: WithDescription {
    // Foo 類為 description 屬性提供了自己的實現
    override val description = "This is a Foo object"
}

// 物件 Bar 的 description 屬性採用預設實現
object Bar: WithDescription

println(Foo().description)
println(Bar.description)

Kotlin REPL 中執行會得到類似這樣的輸出:

This is a Foo object
The description of Line_7$Bar@5bf4764d

用作泛型約束

接下來還可以將之前定義的 WithDescription 介面用在泛型函式、泛型類或者其他泛型介面中作為泛型約束,例如:

fun <T : WithDescription> T.printDescription() = println(description)

在 REPL 中執行:

>>> Bar.printDescription()
The description of Line_7$Bar@5bf4764d

遺憾的是,在 Kotlin 中不能給既有型別(類或介面)實現新的介面,比如不能為 Boolean 或者 Iterable 實現 WithDescription。 即介面不具備第三項能力,因此它不是 trait/型別類。

給既有型別增加功能

在 Kotlin 中給既有型別增加功能的方式是擴充套件,可以給任何既有型別宣告擴充套件函式與擴充套件屬性。例如可以分別給 IntString 實現二者間的乘法操作符函式:

operator fun Int.times(s: String) = s.repeat(this)

operator fun String.times(n: Int) = repeat(n)

於是就可以像 Python/Ruby 那樣使用了:

>>> "Hello" * 3
res11: kotlin.String = HelloHelloHello
>>> 5 * "漢字"
res12: kotlin.String = 漢字漢字漢字漢字漢字

在 Kotlin 中“實現”trait/型別類

如上文所述,Kotlin 分別用介面與擴充套件兩個不同特性提供了 trait/型別類的三項能力,因此在 Kotlin 中沒有其直接對應。 那麼如果把兩個特性以某種方式結合起來,是不是就可以“實現”trait/型別類了?——還別說,真就可以! Arrow 中的型別類就是這麼實現的。

我們繼續以 WithDescription 為例,不同的是,這回要這麼宣告:

interface WithDescription<T> {
    val T.description get() = "The description of $this"
}

這裡利用了分發接收者可以子類化、擴充套件接收者靜態解析的特性,可以為任何既有型別新增實現。例如分別為 CharString 實現如下:

object CharWithDescription : WithDescription<Char> {
    override val Char.description get() = "${this.category} $this"
}

// 採用預設實現
object StringWithDescription: WithDescription<String>

不過使用時會麻煩一點,需要藉助 run() 或者 with() 這樣的作用域函式在相應上下文中執行:

println(StringWithDescription.run { "hello".description })

with(CharWithDescription) {
    println('a'.description)
}

在 REPL 中執行的輸出如下:

The description of hello
LOWERCASE_LETTER a

用作泛型約束也不成問題:

fun <T, Ctx : WithDescription<T>> Ctx.printDescription(t: T) = println(t.description)

StringWithDescription.run {
    CharWithDescription.run {
        printDescription("Kotlin")
        printDescription('①')
    }
}

這裡實現的 printDescription() 與上文的函式簽名不同,因為接收者型別用於實現基於作用域上下文的泛型約束了,這也是利用介面、擴充套件、子型別多型以及作用域函式這些特性來“實現”trait/型別類的關鍵所在。 當然,如果仍然希望目標型別(如例中的 CharString)作為 printDescription 的接收者,只要將其接收者與引數互換即可:

fun <T, Ctx : WithDescription<T>> T.printDescription(ctx: Ctx) = ctx.run {
    println(description)
}

"hltj.me".printDescription(StringWithDescription)

上述兩種方式中提供泛型約束的上下文要麼佔用了函式的擴充套件接收者、要麼佔用了函式引數。實際上還有一種方式——佔用分發接收者,顯然只要在 WithDescription 內宣告 printDescription() 就可以了。 不過我們這裡要假設 printDescription() 是自己定義的函式,而 WithDescription 是無法修改的既有型別,那麼還能做到嗎?——當然不成問題!只要用一個新介面繼承 WithDescription 就可以了:

interface WithDescriptionAndItsPrinter<T>: WithDescription<T> {
    fun T.printDescription() = println(description)
}

object StringWithDescriptionAndItsPrinter: WithDescriptionAndItsPrinter<String>

object CharWithDescriptionAndItsPrinter:
    WithDescriptionAndItsPrinter<Char>, WithDescription<Char> by CharWithDescription

StringWithDescriptionAndItsPrinter.run {
   CharWithDescriptionAndItsPrinter.run {
        "hltj.me".printDescription()
        '★'.printDescription()
    }
}

將三種方式放一起對比會更直觀:

// 方式 1
StringWithDescription.run {
    printDescription("hltj.me")
}

// 方式 2
"hltj.me".printDescription(StringWithDescription)

// 方式 3
interface WithDescriptionAndItsPrinter { /*……*/ }
object StringWithDescriptionAndItsPrinter: WithDescriptionAndItsPrinter<String>
StringWithDescriptionAndItsPrinter.run {
    "hltj.me".printDescription()
}

第三種方式的優點是提供泛型約束的上下文既不佔用擴充套件接收者也不佔用引數,但其代價是需要為每個用到的目標型別(如例中的 CharString)提供新介面(如例中的 WithDescriptionAndItsPrinter<T>)的相應實現,並且依然需要藉助作用域函式 run()with()。 因此通常採用前兩種方式即可,但是如果要自定義操作符函式或者中綴函式時就只能採用第三種方式了,例如:

interface DescriptionMultiplier<T>: WithDescription<T> {
    infix fun T.rep(n: Int) = (1..n).joinToString { description }

    operator fun T.times(n: Int) = this rep n
}

object CharDescriptionMultiplier:
    DescriptionMultiplier<Char>, WithDescription<Char> by CharWithDescription

println(object : DescriptionMultiplier<String> {}.run { "hltj.me" rep 2 })

println(CharDescriptionMultiplier.run { 'A' * 3 })

在 REPL 中執行的輸出為:

The description of hltj.me, The description of hltj.me
UPPERCASE_LETTER A, UPPERCASE_LETTER A, UPPERCASE_LETTER A

擴充套件與成員的優先順序

我們知道,在 Kotlin 中擴充套件與成員衝突時總是取成員。 但是在使用基於作用域上下文的泛型約束時卻並非如此,例如:

interface WithLength<T> {
    val T.length: Int
}

object StringWithFakeLength: WithLength<String> {
    override val String.length get() = 128
}

fun <T, U: WithLength<T>> U.printLength(t: T) = println(t.length)

StringWithFakeLength.run {
    printLength("hltj.me")
}

在 REPL 中執行輸出是 128,表明 printLenth() 取到的 lengthStringWithFakeLength 中定義的擴充套件屬性而不是 String 自身的屬性。因此使用時需要特別注意。此外對於 Any 的三個成員 toString()hashCode()equals() 會始終呼叫成員函式,即便在泛型約束上下文中宣告瞭具有相同簽名的擴充套件函式也是一樣。

“實現”Functor 等

按照上文介紹的方式,我們可以輕鬆實現 ShowEqOrd 等簡單型別類,無需贅述。 但是如果要實現 FunctorApplicativeMonad 等卻會遇到問題。 以 Functor 為例,按說要這麼定義:

interface Functor<C<*>> {
    fun <T, R> C<T>.fmap(f: (T) -> R): C<T>
}

但遺憾的是上述程式碼無法通過編譯,因為 Kotlin 目前不支援高階型別,在泛型引數中用 C<*> 表示型別構造器只是假想的語法 。 因此,需要有一種方式來變通。按 Arrow 的方式引入 Kind 介面來表示:

interface Kind<out F, out A>

interface Functor<F> {
    fun <T, R> Kind<F, T>.fmap(f: (T) -> R): Kind<F, R>
}

然後寫一個標記類,讓具體型別作為 Kind<標記類, T> 的實現類。再定義一個由 Kind<標記類, T> 向具體型別轉換的擴充套件函式 fix(),以便在具體實現中使用。 例如:

class ForMaybe private constructor()

sealed class Maybe<out T> : Kind<ForMaybe, T> {
    object `Nothing#` : Maybe<Nothing>() {
        override fun toString(): String = "Nothing#"
    }
    data class Just<out T>(val value: T) : Maybe<T>()
}

fun <T> Kind<ForMaybe, T>.fix(): Maybe<T> = this as Maybe<T>

這樣就可以為 Maybe 實現 Functor<ForMaybe> 了:

object MaybeFunctor : Functor<ForMaybe> {
    override fun <T, R> Kind<ForMaybe, T>.fmap(f: (T) -> R): Maybe<R> = when (val maybe = fix()) {
        is Maybe.Just -> Maybe.Just(f(maybe.value))
        else -> Maybe.`Nothing#`
    }
}

fun main() = with(MaybeFunctor) {
    println(Maybe.Just(5).fmap { it + 1 })
    println(Maybe.`Nothing#`.fmap { x: Int -> x + 1 })
}

可以看出這種實現方式會有明顯的侷限性:只能為 Arrow 中定義的型別或者按照 Arrow 方式實現的既有型別實現 FunctorApplicativeMonad 等接受型別構造器作為泛型引數的“型別類”。 好在 Arrow 已經自帶了大量有用的型別,很多場景都夠用。

需要注意的是這段程式碼無法在當前版本(1.3.61)的 Kotlin REPL 中執行,需要放在普通的 Kotlin 檔案中編譯執行。

Arrow

Arrow(按其官網寫作 Λrrow)是 Kotlin 標準庫的函式式“伴侶”。目前主要以下四套件:

此外還有若干套件/特性還在孵化中。 關於 Arrow 整體與模式的介紹也在 Arrow Core 的文件中,其中 Functional Programming Glossary 提供了一些使用 Arrow 進行函數語言程式設計的背景知識可供參考。

一點意外

在嘗試寫這些示例時意外發現了一個會導致當前版本的 Kotlin JVM 編譯器拋異常的 bug,最小重現程式碼如下:

interface WithIntId<T> {
    val T.intId get() = 1
}

object BooleanWithIntId : WithIntId<Boolean>

val x = BooleanWithIntId.run {
    true.intId
}

隻影響 Kotlin JVM 編譯器,Kotlin JS 與 Kotlin Native 都不存在這個問題。 查了下 YouTrack,看起來是個已知 bug。 不過文中的其他示例程式碼都能正常編譯執行,儘可放心。


相關文章