你需要知道的关于泛函编程的一些知识 —— 类型类

类型类的形式

类型类在泛函编程中被广泛用于需要实现重载(overload)的场合,它本质上是一个带有类型参数的trait,它包含了所有对该类型的方法的合集。比如 Monoid就是一个类型类:

// Type class
trait Monoid[A] {
    def empty: A
    def combine(x: A, y: A): A
}

由此可见type class的出发点是将数据类型和数据类型的处理方法分离,它通过类型参数提供不同的实现实体来对应不同的重载需求。它为我们提供了以下几个主要的优点:

类型类的优点

1)方法独立于数据,避免了当数据实体不存在时无法进行运算的问题:

传统的OOP,所有的方法都建立在数据实体对象上(静态方法除外),假设我们现在要对一个Int列表求和,如果这个列表中存在数据,那么我们就直接基于数据进行计算,但是如果这个列表是空的,那么它的应该直接返回初始值0本身(实际上即便列表不是空的,我们也需要一个不存在列表中的初始值作为计算的起点)但是初始值本身来自什么地方呢?在OOP中,所有的方法都必须基于对象,如果对象本身不存在,那么我们将没有办法得到这个初始值,因此在OOP中我们必须单独提供一个特定的实体,比如数字“0”作为初始“零值”,但这样一来就违背了依赖倒置原则,让方法和具体类型产生了偶合。而类型类则允许我们将方法和类型剥离,基于抽象类型提供特定方法,例如 Monoid[A] 的 empty 方法,通过它我们可以为类型提供一个产生“零值”的方法。

具体例子可以参考 Cats 的 type class 文档。

2)实现编译时类型安全:

通过类型类的特质签名,我们可以看出它属于参数多态。它和传统的多态主要区别在于,传统的多态允许运行时改变数据的类型,因此编译器无法保证运行时安全,比如:

class Parent{
    public void say(){
        System.out.println("I'm Father");
    }
}

class Son extends Parent{
    @Override public void say() {
        System.out.println("I'm Son");
    }
}

Parent p = new Son();
p.say();

以上的输出结果是:“I’m Son”。

我们看到虽然我们声明类型为 Parent,但是我们实际上得到的是Son。因为编译器无法预知它在运行时被分配的实际类型,也就无法保证它的安全。而类型类将数据实体从对象中分离出去后,就可以使用泛型来表达它,这样就将它转变成了参数多态,参数多态属于静态指派,静态指派发生在编译期,所以类型类允许我们利用静态分派机制来实现动态类型的安全检查。(参考Monoid的定义)

3)解藕类型递归

同样得益于数据类型和函数的解偶,使得我们可以通过代码递归来解决类型递归带来的问题。

假设我们存在Monoid[T] 类型类,现在我们希望它的某个实现能够处理Monoid[Pair[A, B]],从逻辑上来讲,我们就需要在 Monoid[Pair[A,B]] 的实现内支持对Monoid[T] 的递归处理(以下例子参考自Cats 的 type class 文档):

object Demo {
    trait Monoid[A] {
        def empty: A
        def combine(x: A, y: A): A
    }

    final case class Pair[A, B](first: A, second: B)

    def main(args: Array[String]): Unit = {
        def combineAll[A](list: List[A])(implicit A: Monoid[A]): A = list.foldRight(A.empty)(A.combine)

        /** 在上下文中定义 Monoid[A] 就可以让 combineAll 工作于Int。*/
        implicit def intAdditionMonoid: Monoid[Int] = new Monoid[Int] {
            def empty: Int = 0
            def combine(x: Int, y: Int): Int = x + y
        }
        println(combineAll(List(2, 3)))

        /**
         * 只要上下文中恰好存在 Monoid[A] 和 Monoid[B],Monoid[Pair[A, B]] 就可以让 combineAll 工作于 List[Pair[A, B]]。
         */
        implicit def tuple2Instance[A, B](implicit a: Monoid[A], b: Monoid[B]): Monoid[Pair[A, B]] =
            new Monoid[Pair[A, B]] {
                def empty: Pair[A, B] = Pair(a.empty, b.empty)
                def combine(x: Pair[A, B], y: Pair[A, B]): Pair[A, B] =
                    Pair(a.combine(x.first, y.first), b.combine(x.second, y.second))
            }
        println(combineAll(List(Pair(2,3), Pair(2,3))))
    }
}

如果我们通过继承来使用类型类,则我们对Pair[A, B]的处理需要的就不是 Monoid[Pair[A, B]] 而是 Monoid[Pair[A <: Monoid[A], B <: Monoid[B]]]。

final case class MonoidPair[A <: Monoid[A], B <: Monoid[B]](first: A, second: B) extends Monoid[Pair[A, B]] {
    def empty: Pair[A, B] = ???
    def combine(x: Pair[A, B], y: Pair[A, B]): Pair[A, B] = ???
}

这样不仅使得签名变得复杂,而且还强制要求开发者必须先提供Monoid[T]的实现。这是因为 OOP(subtyping) 完全基于(数据)对象自身的能力来计算,因此需要对象对自身数据类型(成员变量 first和second)具备完备的认知,因此签名会陷入逻辑递归。而类型类的数据对象来自函数的参数而不是自身,因此它可以用函数递归来代替签名递归,而函数体则可以进一步通过隐式将处理代理给外部依赖,这样就在函数体内解藕了类型的递归。

类型类的用法

类型类的用法一般直接产生一个(隐式)实例,然后将它作为函数的隐式参数传递给作用的数据实体,比如将作用的数据实体通过隐式转换成它的操作类,然后将类型类的实例作为隐式参数传递给操作函数,然后在函数内将实体作为参数传递给类型类中的函数。

定义实体:

/**  ADT */
sealed trait Vehicle
final case class Car(name:String) extends Vehicle

定义类型类:

/** Type class */
trait Drivable[V <: Vehicle]{
    def drive(v:V)
}

定义隐式转换(Vehicle -> VehicleOps):

implicit class VehicleOps[V <: Vehicle](val vehicle: V) extends AnyVal {
    def drive(implicit d: Drivable[V]): Unit = d.drive(vehicle)
}

drive函数谋求一个类型类隐式实例,然后将Vehicle实体作为参数传递给类型类。

最后在上下文中声明类型类的隐式实例即可完成依赖注入:

implicit val carDrive: = new Drivable[Car] {
    override def drive(car: Car): Unit = println(car.name + " is running."))
}

Car("A") .drive

Leave a Reply
Your email address will not be published.
*
*

BACK TO TOP