Scala Cats - Type Class 类型类
前言
参考自 cats 官网文档
类型类(Type Class) 是函数式编程里面用来实现 特设多态 (ad hoc polymorphism) (或者说成是众所周知的重载) 的强力工具.
换言之,参数多态对各模板参数的实现,是根据模板的通用(generically)的行为的抽象,即泛型的语义;而特设多态可以针对不同的版本实现完全不同的行为,或曰对于每个不同的模版参数都有单独的版本来应对。打个比方:假如我们要把原材料切成两半——
- 参数多态:只要能“切”,就用工具来切割它;
- 特设多态:根据原材料是铁还是木头还是什么来选择不同的工具来切。
所谓特设多态, 我个人理解, 就是使用类型作为参数来实现的多态, 以往的参数多态(方法重载) 就是预先定义了含有不同参数的方法签名, 然后根据参数的类型和数量编译器自动选择要调用哪个方法. 而在scala中, 使用的是 类型参数(Type Parameter) 结合隐式注入来决定使用哪个实现类的方法.
许多的面向对象的语言都使用子类型来实现多态, 但是函数式编程中倾向于使用参数多态(考虑类型参数, 像 Java 里面的泛化) 和 特设多态.
例子
下面的例子展示了对整型列表的求和, 字符串列表的连接 和 集合列表的联合
def sumInts(list: List[Int]): Int = list.foldRight(0)(_ + _)
def concatStrings(list: List[String]): String = list.foldRight("")(_ ++ _)
def unionSets[A](list: List[Set[A]]): Set[A] = list.foldRight(Set.empty[A])(_ union _)
所这些操作都有共同点: 一个初始值(0, 空字符串, 空集) 和一个组合函数 (+,++,union). 我们可以将其抽象成一个函数这样就不用每种类型都去实现一次了. 我们把通用的部分组合成接口:
trait Monoid[A] {
def empty: A
def combine(x: A, y: A): A
}
// Implementation for Int
val intAdditionMonoid: Monoid[Int] = new Monoid[Int] {
def empty: Int = 0
def combine(x: Int, y: Int): Int = x + y
}
Moniod
这个名称是取用自抽象代数, 它表示 加, 联合 这种结构. (好像在离散数学里面就是 群 的概念, 有兴趣可以去翻翻书)
上面的例子中实现了 Int 类型的 Monoid
,现在我们使用这个接口来实现 List 的 Moniod
接口:
def combineAll[A](list: List[A], m: Monoid[A]): A = list.foldRight(m.empty)(m.combine)
类型类 vs 子类型
上面的例子中我们把 Monoid 直接作为参数 而不是使用面向对象中的子类型泛化:
// Subtyping
def combineAll[A <: Monoid[A]](list: List[A]): A = ???
对于前面这个例子, 两种方式有一些隐晦的不同. 为了给 foldRight 方法提供一个 空值, 我们需要 根据类型 A
拿到一个空值. 使用 Monoid[A]
作为参数的时候, 我们可以直接调用其 empty 方法来拿到这个空值. 但是在 子类型 的例子中, empty 方法 会变成 Monoid[A]
本身的值, 只能从 list 参数里面拿. 如果 list 为空, 我们就没有办法获取到所谓的 空值, 更别说从 非静态对象中 获取一个常量值这种古怪的操作了.
再来看看另一个不同, 考虑下面的这个简单的 pair 类型
final case class Pair[A, B](first: A, second: B)
定义 Monoid[Pair[A, B]]
依赖于能够定义 Monoid[A]
和 Monoid[B]
, 这种定义是点对点的, 比如,如果我们要实现 combine 方法的话, 第一个pair的 first 属性 和 第二个pair 的 first 属性 需要 combine, 第一个pair的 second 属性 和 第二个pair 的 second 属性 需要 combine, 依次来组合成新的 Pair. 如果使用子类型的方式来实现就会像这样:
final case class Pair[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] = ???
}
这方法签名看起来一坨屎一样, 而且它要强制要求所有的 Pair
的时候实例都拥有 Monoid
实例, 然后 Pair
中的 first 和 second 属性本应该是什么类型都可以, 并且如果携带的类型恰好拥有 Moniod
实例那么 Pair
自己也能成为 Monoid. 现在我们试试将类型约束下放到方法签名里面:
final case class Pair[A, B](first: A, second: B) extends Monoid[Pair[A, B]] {
def empty(implicit eva: A <:< Monoid[A], evb: B <:< Monoid[B]): Pair[A, B] = ???
def combine(x: Pair[A, B], y: Pair[A, B])(implicit eva: A <:< Monoid[A], evb: B <:< Monoid[B]): Pair[A, B] = ???
}
// error: class Pair needs to be abstract, since:
// it has 2 unimplemented members.
// /** As seen from class Pair, the missing signatures are as follows.
// * For convenience, these are usable as stub implementations.
// */
// def combine(x: Pair[A,B],y: Pair[A,B]): Pair[A,B] = ???
// def empty: Pair[A,B] = ???
//
// final case class Pair[A, B](first: A, second: B) extends Monoid[Pair[A, B]] {
// ^
但是现在我们又和 Monoid
接口不一致因为增加了隐式参数.
隐式推导(Implicit derivation)
注意 Monoid[Pair[A, B]]
是从 Monoid[A
] 和 Monoid[B]
中推导出来的.
final case class Pair[A, B](first: A, second: B)
def deriveMonoidPair[A, B](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))
}
类型类最强大的特性之一就是能够像这样自动派生. 我们通过 Scala 的隐式机制来实现:
import cats.Monoid
object Demo {
final case class Pair[A, B](first: A, second: B)
object Pair {
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))
}
}
}
我们同时也将类型参数中的限制移动到了方法签名中作为隐式参数了, 并且把任何 类型类的实例(上面中的 tuple2Instance 方法) 也设置为了 implicit .
implicit val intAdditionMonoid: Monoid[Int] = new Monoid[Int] {
def empty: Int = 0
def combine(x: Int, y: Int): Int = x + y
}
def combineAll[A](list: List[A])(implicit A: Monoid[A]): A = list.foldRight(A.empty)(A.combine)
现在我们能够 combineAll 一个 Pair
列表, 只要 Pair中的参数本身具有 Monoid 实例.
implicit val stringMonoid: Monoid[String] = new Monoid[String] {
def empty: String = ""
def combine(x: String, y: String): String = x ++ y
}
import Demo.{Pair => Paired}
combineAll(List(Paired(1, "hello"), Paired(2, " "), Paired(3, "world")))
// res2: Demo.Pair[Int, String] = Pair(6, "hello world")
语法笔记
大多数情况下, 包括上面的 combineAll 方法, 隐式参数都可以写成下面的语法糖形式:
def combineAll[A : Monoid](list: List[A]): A = ???
然后虽然方便了定义, 但是使用的时候还是需要额外的写法
import cats.Monoid
// Defined in the standard library, shown for illustration purposes
// Implicitly looks in implicit scope for a value of type `A` and just hands it back
def implicitly[A](implicit ev: A): A = ev
def combineAll[A : Monoid](list: List[A]): A =
list.foldRight(implicitly[Monoid[A]].empty)(implicitly[Monoid[A]].combine)
因为这个原因, 许多库都提供了一个工具方法在类型类的伴生对象中, 通常都命名为 apply, 这样就避免了到处调用 implicitly 的问题.
object Monoid {
def apply[A : Monoid]: Monoid[A] = implicitly[Monoid[A]]
}
def combineAll[A : Monoid](list: List[A]): A =
list.foldRight(Monoid[A].empty)(Monoid[A].combine)
Cats 使用 simulacrum 库类定义类型类, 它会自动生成这种 apply 方法.
规则(Laws)
概念上, 所有的 类型类 都拥有规则. 这些规则限制了给定类型的实现并且可以用来分析泛型代码.
比如, Monoid 类型类 要求 combine 满足结合律 并且 empty 对于 combine 是 单位元素. 这意味着对于 x,y,z 以下等式成立:
combine(x, combine(y, z)) = combine(combine(x, y), z)
combine(x, id) = combine(id, x) = x
有了这些法则,通过Monoid参数化的函数就可以在性能方面利用它们。一个函数将 List[A] 折叠为一个单独的 A可以既可以使用 foldLeft
也可以使用 foldRight
函数 因为 combine
是被认为是满足结合律的, 或者它可以将 list 分割成两个更小的 list 并行地进行折叠, 比如 :
val list = List(1, 2, 3, 4, 5)
val (left, right) = list.splitAt(2)
// Imagine the following two operations run in parallel
val sumLeft = combineAll(left)
// sumLeft: Int = 3
val sumRight = combineAll(right)
// sumRight: Int = 12
// Now gather the results
val result = Monoid[Int].combine(sumLeft, sumRight)
// result: Int = 15
Cats 通过 kernel-laws
和 laws
模块提供了类型类的规则, 使得检查实例的规则变得简单, 更多的规则测试参考这个.
Cats 中的类型类
参考自这里