Scala Type Class 101

怎样在不改动原代码的前提下为一个类添加功能?Type Class提供了有别于继承的方法。

本文主要是Scala Type Classes 101: Introduction的翻译。

动机

Advanced Scala with Cats 的作者们对 继承 提出了一些有趣的观点,而我将在本文中从另一个不同的角度来进行介绍。

在面向对象(OOP)的编程理念中,一个披萨长这样:

class Pizza(var crustSize: CrustSize, var crustType: CrustType) {

val toppings = ArrayBuffer[Topping]()

def addTopping(t: Topping): Unit = { toppings += t }
def removeTopping(t: Topping): Unit = { toppings -= t }
def removeAllToppings(): Unit = { toppings.clear() }

}

如果想要修改披萨的数据或者方法,一般来说就需要修改披萨类的代码。
而函数式编程(FP)中的披萨长这样:

case class Pizza (
crustSize: CrustSize,
crustType: CrustType,
toppings: Seq[Topping]
)

trait PizzaService {
def addTopping(p: Pizza, t: Topping): Pizza = ???
def removeTopping(p: Pizza, t: Topping): Pizza = ???
def removeAllToppings(p: Pizza): Pizza = ???
}

在这里,如果想要修改数据,就需要修改case class。(有关case class,可以参考这篇文章),而如果想要修改方法,就需要修改PizzaService。

而Type Class提供了完全不同的方法。与修改任何现存的代码不同,你需要创建Type Class来实现这些改动。

Type Class的三要素

  1. Type Class本身,以trait的形式接收至少一个泛型参数(这里即泛型类)
  2. Type Class的实例,就是想要扩展的类
  3. 新的API通过接口方法暴露给用户

第一个例子:数据

sealed trait Animal
final case class Dog(name: String) extends Animal
final case class Cat(name: String) extends Animal
final case class Bird(name: String) extends Animal

假设你想为Dog类添加一个新的行为,能够模仿人类说话的方式speak,但是不想给CatBird添加。在这里展示一下如何通过Type Class来添加这个方法。

第一步:Type Class

第一步是创建一个接收至少一个泛型参数的trait。在这里我想定义一个模仿人类的speak函数:

trait BehavesLikeHuman[A] {
def speak(a: A): Unit
}

泛型A使得任何类都可以使用它,包括CatBird

第二步:Type Class 的实例

第二步就是针对你需要修改的类,将Type Class实例化。在这个例子里面,我们只需要修改dog类的行为,因此只需要创造一个实例:

object BehavesLikeHumanInstances {
// only for `Dog`
implicit val dogBehavesLikeHuman = new BehavesLikeHuman[Dog] {
def speak(dog: Dog): Unit = {
println(s"I'm a Dog, my name is ${dog.name}")
}
}
}

这一步的要点如下:

  1. 我只创建了BehavesLikeHuman的一个实例。
  2. 我没有为CatBird创建实例,因为我不希望他们拥有这个特性。
  3. 我为Dog类实现了专属于它的speak方法。
  4. 我把这个实例标记为implicit,使他作为隐式参数,很容易被接下来的代码所调用。(有关implicit关键字,请参考这篇文章)
  5. 我把这个变量用Object封装。在这个小例子里面不是很关键,但是对于真实场景中更大规模的应用有帮助。

第三步:API(接口)

在第三步中,我们创建暴露给用户的API,这里有两种手段:

  1. 在一个Object中定义一个函数
  2. 定义一个implicit函数,被Dog类隐式地触发

这两种方法在本文中用3a和3b表示。用户希望使用的应该是如下代码

BehavesLikeHuman.speak(aDog)   //3a
aDog.speak //3b

3a: The Interface Objects Approach

Advanced Scala with Cats book一书中,作者将这种方法形容为接口类(Interface Objects),而我将其形容为“显式”的方法,因为这一手段需要显式地调用Object的方法。
对这里的Dog示例,只需要在一个Object中定义speak函数即可:

object BehavesLikeHuman {
def speak[A](a: A)(implicit behavesLikeHumanInstance: BehavesLikeHuman[A]): Unit = {
behavesLikeHumanInstance.speak(a)
}
}

由于speak可以被用于任何类,因此我依然需要使用泛型的方式定义它。这个函数需要BehavesLikeHuman(也就是我们的Type Class)的实例作为隐式参数,通过隐式参数调用speak方法。

作为用户,你需要这样使用我们的API。首先,import我们的实例作为隐式参数:

import BehavesLikeHumanInstances.dogBehavesLikeHuman

记住我们的实例包含一个专门为Dog编写的speak函数。接着创建实例:

val ein = Dog("Ein")

最后,对ein使用我们的接口类:

BehavesLikeHuman.speak(ein)

结果是

I'm a Dog, my name is Ein

这就是3a方法的总结。注意我们也可以显式地传入隐式参数:

BehavesLikeHuman.speak(rover)(dogBehavesLikeHuman)

由于实例已经被implicit标识为隐式参数了,这么做并不必要,我只是展示了一下这么做也是可以的。
注意到这个方法的最终结果是你拥有了一个新的speak函数,它只接收Dog类型的实例。这很棒,但是为了给Dog类型创建’Utils’花费了很多精力。我认为方案3b才是真正使我们的付出得到回报的手段。

3b: The Interface Syntax approach

Interface Syntax方法的要点是

  • 最终,你可以通过dog.speak调用方法
  • 由于使用新的方法扩展了已有的类型,这个方法被称为”extension methods”
  • 被称为type class的”syntax”

我们快速回顾一下前两步的内容:

// 这是type class
trait BehavesLikeHuman[A] {
def speak(a: A): Unit
}
// 这是type class的实例
object BehavesLikeHumanInstances {

// only for `Dog`
implicit val dogBehavesLikeHuman = new BehavesLikeHuman[Dog] {
def speak(dog: Dog): Unit = {
println(s"I'm a Dog, my name is ${dog.name}")
}
}

}

在第三步,我们这么写一个syntax:

object BehavesLikeHumanSyntax {
implicit class BehavesLikeHumanOps[A](value: A) {
def speak(implicit behavesLikeHumanInstance: BehavesLikeHuman[A]): Unit = {
behavesLikeHumanInstance.speak(value)
}
// 通过context bound语法糖的等价描述
def speak[A: BehavesLikeHuman](value: A): Unit = implicitly(BehavesLikeHuman[A]).speak(value)
}
}

用户调用的时候这么做:

// 首先引入隐式参数
import BehavesLikeHumanInstances.dogBehavesLikeHuman
// 其次引入隐式类
import BehavesLikeHumanSyntax.BehavesLikeHumanOps
// 接着创建实例
val ein = Dog("Ein")
// 最后可以直接使用实例的方法
ein.speak

发生了什么?对于隐式类,编译器会自动生成一个隐式转换函数:

// Automatically generated
implicit def BehavesLikeHumanOps[A](value: A) = new BehavesLikeHumanOps[A](value: A)

当编译器看到ein.speak的时候,发现ein属于的Dog类没有speak方法,于是寻找隐式转换,发现隐式类BehavesLikeHumanOps拥有speak方法,于是用Dog类实例化隐式类,而隐式类的speak方法实际上是个空壳,通过隐式参数来调用了(类型参数化的Type Class的)speak方法。

如果是Cat或者Bird类的话,在搜索隐式参数的时候会找不到。因为我们只import了BehavesLikeHuman[Dog]这个实例作为隐式参数。