The Neophyte's Guide to Scala Part 12: Type Classes
过去的两周我们讨论了一些使我们保持DRY和灵活性的函数式编程技术,特别是函数组合,partial function的应用,以及currying.接下来,我将会继续讨论如何使你的代码尽可能的灵活.
但是,这次我们将不会讨论怎么使用函数作为一等对象来达到这个目的,而是使用类型系统,这次它不是阻碍着我们,而是使得我们的代码更灵活:你将会学到关于 type classes 的知识.
你可能会觉得这是一个外来的并不很相关的概念,被一些吵闹的Haskell粉丝带到Scala社区中来.但是,很显然并非如此.Type classes已经是Scala标准库很重要的部分,而且越来越重要,对于很多流行的而且被广泛使用第三方开源库也是如此.所以你的确应该熟悉一下它们.
我会讨论type class的概念,为什么它是有用的,怎么作为使用者受益于type class, 以及怎么实现你自己的type class并且使它产生很大作用.
问题
我不会以直接给出"type class是什么"的抽象解释,而是以一种更简单但又实际的方式来处理这个话题,那就是-举例子.
想象一下我们想要写一个酷毙了的统计相关的库.这意味着我们将会提供很多用于处理数字集合的函数,多数用于对它们的值进行聚合.进一步,假如我们被限制只能通过索引来的访问这个集合的元素,并且需要使用Scala集合库的reduce方法.我们为自己添加这个限制是因为我们想要重新实现一些Scala标准库已经提供的东西--只是因为这是一个很好的没有多少限制的例子,并且它对于一篇博客来说也足够小.终后,我们的实现假定我们获取的数值是排序过的.
我们以对Double类型的median, quatiles, iqr的实现开始.
object Statistics {
def median(xs: Vector[Double]): Double = xs(xs.size / 2)
def quartiles(xs: Vector[Double]): (Double, Double, Double) =
(xs(xs.size / 4), median(xs), xs(xs.size / 4 * 3))
def iqr(xs: Vector[Double]): Double = quartiles(xs) match {
case (lowerQuartile, _, upperQuartile) => upperQuartile - lowerQuartile
}
def mean(xs: Vector[Double]): Double = {
xs.reduce(_ + _) / xs.size
}
}
median(中位数)把数据集分成两半,四分位数(quartile)中最小的和最大的那个(我们的quartile方法返回的tuple中的第一个和第三个元素)从数据集中分离出前25%和后25%.我们的iqr方法返回四分位范围(interquartile range),也就是最大的四分位和最小的四分位的差.
现在,我们想要支持double以外的数.所以,我们重新把这些方法为Int实现一遍,对吗?
当然不是!首先,这会有一些重复,不是吗?并且,在像这个例子这样的情况下,我们很快就会遇到一些不得不使用肮脏的小技巧来进行方法覆盖的情况,因为类型参数会被擦除.
如果Int和Double是继承了同样的基类或者实现了相同的trait,比如Number,那么我们就可以修改我们的方法的参数类型和返回类型来使使用那个更一般的类型.我们的方法参数就会看起来是这样的:
object Statistics {
def median(xs: Vector[Number]): Number = ???
def quartiles(xs: Vector[Number]): (Number, Number, Number) = ???
def iqr(xs: Vector[Number]): Number = ???
def mean(xs: Vector[Number]): Number = ???
}
谢天谢地,在这个例子中,Int和Double并没有共同的trait,所以这条邪路就不通了.在其它例子中,有可能一些类型会有共同的父类或trait--但这仍然是一个坏主意.不光是因为我们丢掉了之前可用的类型信息,我们的API也会对将来的其它扩展(它们的来源是我们控制不了的)关上了大门:我们不能在第三方的扩展中增加继承Number trait的类型(译注:意思是,在第三方扩展中,我们可能没有办法继承Numeric这个trait,但是还是想要让第三方扩展中的类可以被Statistics这个API调用).
Ruby对这个问题的答案是monkey patching. 以污染全局命名空间为代价为新的类型做扩展,使它表现得像Number一样.而被Gang of the Four(指设计模式的四名作者)打败的Java开发者,则会使认为适配器(Adaptor)可以解决所有问题.
object Statistics {
trait NumberLike[A] {
def get: A
def plus(y: NumberLike[A]): NumberLike[A]
def minus(y: NumberLike[A]): NumberLike[A]
def divide(y: Int): NumberLike[A]
}
case class NumberLikeDouble(x: Double) extends NumberLike[Double] {
def get: Double = x
def minus(y: NumberLike[Double]) = NumberLikeDouble(x - y.get)
def plus(y: NumberLike[Double]) = NumberLikeDouble(x + y.get)
def divide(y: Int) = NumberLikeDouble(x / y)
}
type Quartile[A] = (NumberLike[A], NumberLike[A], NumberLike[A])
def median[A](xs: Vector[NumberLike[A]]): NumberLike[A] = xs(xs.size / 2)
def quartiles[A](xs: Vector[NumberLike[A]]): Quartile[A] =
(xs(xs.size / 4), median(xs), xs(xs.size / 4 * 3))
def iqr[A](xs: Vector[NumberLike[A]]): NumberLike[A] = quartiles(xs) match {
case (lowerQuartile, _, upperQuartile) => upperQuartile.minus(lowerQuartile)
}
def mean[A](xs: Vector[NumberLike[A]]): NumberLike[A] =
xs.reduce(_.plus(_)).divide(xs.size)
}
现在我们用扩展的方式解决了这个问题:使用我们的库的人可以传入一个为Int写的NumerLike的适配器(我们也可以自己提供(译注:指我们作为API的开发者,可以自己提供一个Int的适配器))或者传入任何想要表现得像numer一样的类型的适配器,而不用重新编译我们的统计方法的实现模块.
但是,总是把你的数字包装在适配器中不仅写起来和读起来都很费劲,它也意味着你必须创建很多的适配器对象来和你的库交互.
Type class 来救你
在以上提供的解决方法之外的一个强大的选项,当然,就是定义和使用type class.Type class,作为Haskell语言的一个强大的特性,虽然也叫class, 但是和面向对象里的类的概念没有任何关系.
一个type class C定义了一些操作,这些行为是任何想要成为C的一员的类型T必须支持的.但是T是否是type class C的一员不是T本身决定的,任何开发者如果想要一个类型成为一个type class的一员,只需要提供这个类型必须支持的操作就行了.现在,一旦T成为了type class C的一员,限制自己的一个或多个参数为C的一员的函数就都可以使用T作为参数了.
就像这样,type class允许进行即时以及有追溯能力的多态.依赖于type class的代码对于扩展是开放的,不用创建适配器对象.
创建一个type class
在Scala中,type class可以通过一系列技术的组合来实现.这比在Haskell中需要做的事情要多一些,但是也让开发者有了更多控制.
在Scala中创建一个type class包括几个步骤.首先,让我们定义一个trait.这就是实际的typc class.
object Math {
trait NumberLike[T] {
def plus(x: T, y: T): T
def divide(x: T, y: Int): T
def minus(x: T, y: T): T
}
}
我们创建了一个叫NumberLike的type class. Type class总是有一个或多个类型参数,并且他们能常被设计为无状态的,比如,在我们的NumberLike这个trait中定义的方法都只依赖于它们的参数.需要指出的是,我们上边的适配器的方法依赖于它们适配的T类型的对象以及一个参数,但是在我们的NumberLike type class中定义的方法有两个T类型的参数--而在NumberLike中,这个T类型的对象成了操作的第一个参数.
提供默认成员
实现一个type class的第二步通常是提供在它的伴随对象(companion object)中提供一些默认的你的type class trait的实现.我们过一会将会看到为啥这是一个好的策略.首先,让我们照这样来把Double和Int弄成NumberLike这个type class的一员.
object Math {
trait NumberLike[T] {
def plus(x: T, y: T): T
def divide(x: T, y: Int): T
def minus(x: T, y: T): T
}
object NumberLike {
implicit object NumberLikeDouble extends NumberLike[Double] {
def plus(x: Double, y: Double): Double = x + y
def divide(x: Double, y: Int): Double = x / y
def minus(x: Double, y: Double): Double = x - y
}
implicit object NumberLikeInt extends NumberLike[Int] {
def plus(x: Int, y: Int): Int = x + y
def divide(x: Int, y: Int): Int = x / y
def minus(x: Int, y: Int): Int = x - y
}
}
}
两件事: 第一,你会看到这两个实现基本上是相同的.但在创建type class的成员时,并不总是这样.我们的的NumerLike trait是一个相对较小的领域.在这个文章的后面,我将会给出一些type class的例子,在实现他们的成员时,就会有少得多的重复的空间了.第二,请忽略在NumerLikeInt中我们进行整数除法时丢掉的精度,这纯粹是为了保持例子简单.
正像你看到的那样,type class的成员通常都是单例对象.也请注意在每个type class的实现前边的implicit关键字.这是使得type class在Scala中成为可能的关键成员,也就是在一些条件下使得type class的成员成为隐式可用的.下一节会更多讨论这个内容.
针对type class进行编程
现在,我们有了type class,以及它的两个默认的实现.现在让我们在statistic模块中针对type class作编程.让我们暂时关注在mean方法中.
object Statistics {
import Math.NumberLike
def mean[T](xs: Vector[T])(implicit ev: NumberLike[T]): T =
ev.divide(xs.reduce(ev.plus(_, _)), xs.size)
}
这在一开始看起来有些吓人,但是实际上很简单.我们的方法接受一个类型参数T,以及唯一的一个参数Vector[T]
把参数限制为一个特定的type class的成员是通过参数列表中的第二个implicit参数实现的.这意味着什么呢?简单地说,在当前的作用域中必须有一个NumerLike[T]类型的值是隐式可用的.这也就是说在当前的作用域中必须有一个implicit value被声明,并使之可用.通常这是通过import一个包或者对象中的implicit value来达成的.
只有当没有其它的隐式值被发现时,编译器才会在隐式参数的类型的伴生成象(companion object)中寻找.因此,作为一个库的设计者,把你的默认的type class的实现放在你的type class trait的伴生对象中,就可以使得你的库的使用者可以方便地用它们自己的实现覆盖你的实现,这也就是你想要做的.使用者也可以在隐式参数的位置传入一个显式的值来覆盖当前的作用域中的隐式值.
让我们看一下默认的type class实现参否被识别.
val numbers = Vector[Double](13, 23.0, 42, 45, 61, 73, 96, 100, 199, 420, 900, 3839)
println(Statistics.mean(numbers))
非常好.如果我们想要用Vector[String]来试一下,我们就会得在编译器得到一个错误,说没有用于参数e: numberLike[String]的隐式值可用.如果你不想看到这个错误信息,你可以在自己的type class trait上加个@implicitNotFound注解来定制这个错误信息.
object Math {
import annotation.implicitNotFound
@implicitNotFound("No member of type class NumberLike in scope for ${T}")
trait NumberLike[T] {
def plus(x: T, y: T): T
def divide(x: T, y: Int): T
def minus(x: T, y: T): T
}
}
上下文界定 Context Bounds
在参数列表中包含一个期待type class成员的隐式参数列表有些繁琐.作为对于只有一个类型参数的隐式参数的简化,Scala提供了一种叫做context bounds的语法.为了展示怎么使用这个语法,我们接下来就会使用这个语法实现我们的statistics方法.
object Statistics {
import Math.NumberLike
def mean[T](xs: Vector[T])(implicit ev: NumberLike[T]): T =
ev.divide(xs.reduce(ev.plus(_, _)), xs.size)
def median[T : NumberLike](xs: Vector[T]): T = xs(xs.size / 2)
def quartiles[T: NumberLike](xs: Vector[T]): (T, T, T) =
(xs(xs.size / 4), median(xs), xs(xs.size / 4 * 3))
def iqr[T: NumberLike](xs: Vector[T]): T = quartiles(xs) match {
case (lowerQuartile, _, upperQuartile) =>
implicitly[NumberLike[T]].minus(upperQuartile, lowerQuartile)
}
}
一个T: Numberlike样式的上下文绑定是说一个Numberlike[T]类型的值必须可用,所以就和在参数列表中加入第二个隐式参数NumberLike[T]是一样的.但是,如果你想使用这个隐式可用的值,就必须调用implicitly方法,就像我们在iqr方法中作的那样.如果你的type class需要多于一个类型参数,你就不能用context bound语法了.
自制type class成员
作为一个使用type class的库的用户,你早晚会想要把一个类做成这样type class的成员.经如,你可能想要对于Joda Time的Duration类型使用我们的统计库.为些,我们当然需要把Joda Time放在我们的classpath上.
libraryDependencies += "joda-time" % "joda-time" % "2.1" libraryDependencies += "org.joda" % "joda-convert" % "1.3"
现在我们只需要创建一个实现了NumerLike的隐式值(在尝试时,请确保Joda Time在你的classpath上)
object JodaImplicits {
import Math.NumberLike
import org.joda.time.Duration
implicit object NumberLikeDuration extends NumberLike[Duration] {
def plus(x: Duration, y: Duration): Duration = x.plus(y)
def divide(x: Duration, y: Int): Duration = Duration.millis(x.getMillis / y)
def minus(x: Duration, y: Duration): Duration = x.minus(y)
}
}
如果我们引入了包含这个Numberlike的包或者对象,我们就可以计算一些时间段的均值了.
import Statistics._
import JodaImplicits._
import org.joda.time.Duration._ val durations = Vector(standardSeconds(20), standardSeconds(57), standardMinutes(2),
standardMinutes(17), standardMinutes(30), standardMinutes(58), standardHours(2),
standardHours(5), standardHours(8), standardHours(17), standardDays(1),
standardDays(4))
println(mean(durations).getStandardHours)
用例
我们的NumberLike type class是一个很好的练习.但是Scala已经自带了一个Numeric type class了,它使得你可以在T的Numeric[T]存在的时候对一个集合使用sum或者product.另外一个你在标准库中常用到的type class是Ordering,它使得你能够为自己的类型提供一个隐式地Ordering,来被Scala集合的sort方法使用.
在标准库中还有更多的type class,但是作为一个常规的Scala开发者,它们不都是需常会用到的.
一个在第三方库中常见的用例是序列化和反序列化,特别是转成或者从JSON转化.通过使得你的类成为这种库需要的一个formatter type class的成员,你就能定制你的类序列化成JSON, XML或者其东西.(译注:典型是是Spray-Json)
在Scala类型和你的数据库驱动需要类型之间的映射也通常使用type class来定制和扩展.
总结
一旦你真的拿Scala来做些严肃的事情,你就不可避免的要遇到type class.我希望在读完这篇文章以后,你已经准备好来利用这个强大的技术的.
Scala的type class使得你可以Scala代码即可以对于回溯扩展开放,又可以尽可能得保持类型信息.与其它语言比较,Scala的type class可以让开发者空全控制,也就是说默认的type class实现可以无障碍地被覆盖,并且type class的实现不会在全局命名空间中都可见.
在你想要写一个用来被其它人使用的库时,你会发现这项技术非常有用,但是type class在程序代码中也有用,它可以在不同的模块中降低耦合.