从子类化到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使用组合的方式替代继承,避免了继承带来的种种问题,也使得代码更加符合设计原则。
参考资料
推荐阅读