欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

从子类化到Typeclass

程序员文章站 2022-05-24 18:22:52
...

前言

提及面向对象,大家可能非常熟悉:继承、封装、多态三大特性想必早已烂熟于心。但是在某些场景下,面向对象(或者说Java的面向对象)却存在一些问题或者说是缺陷。

问题

现在有一个类型层次结构如下:

从子类化到Typeclass

我们要怎样才能在父类中定义一个通用的方法,而这个方法可以返回一个属于调用者当前的类型的对象?

举个例子:比如我们希望给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使用组合的方式替代继承,避免了继承带来的种种问题,也使得代码更加符合设计原则。


参考资料