博观而约取,厚积而薄发。
Scala样本类
本章将重点介绍样本类(case class
),及其在模式匹配(Pattern Matching
)中的工作机制,及其具体运用。
JSON递归结构
JSON(JavaScript Object Notation)
是一种轻量级的数据交换格式。使用Scala
,可以很容易地实现JSON
递归结构的定义。
sealed trait JsValue
case class JsBoolean(value: Boolean) extends JsValue
case class JsString(value: String) extends JsValue
case class JsNumber(value: BigDecimal) extends JsValue
case class JsArray(value: List[JsValue] = Nil) extends JsValue
case class JsObject(value: Map[JsString, JsValue]) extends JsValue
case object JsNull extends JsValue
样本类
「样本类」常常用于描述「不可变」的「值对象」(Value Object
)。样本类的实现模式相当简单,它不仅消除了大量的样板代码,而且在模式匹配中扮演了重要角色。
例如,样本类JsBoolean
是一个持有Boolean
值的JSON
对象。
case class JsBoolean(value: Boolean)
自动规则
一旦定义了样本类,将免费得到很多特性。
- 隐式地声明字段为
val
; - 自动混入特质
ProductN
; - 自动地生成
equals, canEqual, hashCode, toString, copy
等方法; - 伴生对象中自动生成
apply, unapply
方法。
揭秘样本类
以样本类JsBoolean
为例,它等价于如下实现:
class JsBoolean(val value: Boolean) extends Product1[Boolean]
override def equals(obj: Any): Boolean = other match {
case other: JsBoolean => value == other.value
case _ => false
}
override def hashCode: Int = value.hashCode
override def toString: String = s"JsBoolean($value)"
def canEqual(other: Object): Boolean = other.isInstanceOf[JsBoolean]
def copy(value: Boolean): JsBoolean = new JsBoolean(value)
}
object JsBoolean {
def apply(value: Boolean) = new JsBoolean(value)
def unapply(b: JsBoolean): Option[Boolean] =
if (b != null) Some(b.value) else None
}
得与失
剖析样本类JsBoolean
发现,定义样本类是非常简单的,而且可以免费得到很多方法实现。唯一的副作用就是,样本类扩大了类的空间及其对象的大小。
例如,对于JsBoolean
可以进行如下改进。相对于样本类的实现,实现了对象的共享,提高了效率。但是,代码实现就没有样本类那么简洁了。
sealed abstract class JsBoolean(val value: Boolean) extends JsValue
case object JsTrue extends JsBoolean(true)
case object JsFalse extends JsBoolean(false)
object JsBoolean {
def apply(value: Boolean) =
if (value) JsTrue else JsFalse
def unapply(b: JsBoolean): Option[Boolean] =
if (b != null) Some(b.value) else None
}
工厂方法
样本类在伴生对象中自动地生成了apply
的工厂方法。在构造样本类的对象时,可以略去new
关键字,言简意赅,提高了代码的表达力。例如:
val capitals = JsObject(Map(
JsString("China") -> JsString("Beijing"),
JsString("France") -> JsString("Paris"),
JsString("US") -> JsString("Washington")))
析取器
定义了一个show
方法,它递归地将JsValue
转换为字符串表示。
def show(json: JsValue): String = json match {
case JsArray(elems) => showArray(elems)
case JsObject(value) => showObject(value)
case JsString(str) => str
case JsNumber(num) => num.toString
case JsBoolean(bool) => bool.toString
case JsNull => "null"
}
其中,JsArray
是一个JsValue
的数组,它可以用下图描述:
字符串化一个JsArray
对象可以如下实现:
private def showArray(values: List[JsValue]): String =
"[" + (values map show mkString ",") + "]"
其中,它等价于:
private def showArray(values: List[JsValue]): String =
"[" + (values.map(show).mkString(",")) + "]"
而JsObject
是一个包含Map[String, JsValue]
的对象,它可以用下图描述:
字符串化一个JsObject
对象可以如下实现:
private def showObject(bindings: Map[JsString, JsValue]): String = {
val pairs = bindings map {
case (key, value) => s""""${show(key)}":${show(value)}"""
}
s"""{${(pairs mkString ",")}}"""
}
当对样本类进行模式匹配时,将调用伴生对象的unapply
方法。当匹配成功后,它返回一个使用Some
包装的结果,然后被析取到相应的变量之中去。
因此,为了理解样本类模式匹配的过程,必须先透彻理解unapply
的工作机制。
单值析取器
JsValue
的所有样本子类,都是单值的样本类。其unapply
方法将返回单值的Option
类型。例如,JsString
的unapply
方法实现如下。
object JsString {
def unapply(s: JsString): Option[String] =
if (s != null) Some(s.value) else None
}
当对case JsString(value) => value
进行模式匹配时,首先调用其伴生对象的unapply
方法。当匹配成功后,返回Some
包装的结果,最后被析取到value
的变量之中去了。
变量定义
也就是说,当对样本类JsString
的构造参数进行模式匹配时,其类似于发生如下的赋值过程,它将根据右边的值,自动提取出capital
的值。
val JsString(capital) = JsString("Washington")
事实上,上述形式的变量定义是模式匹配的典型应用场景。
迭代Map
再将目光投放回对JsObject
字符串化的过程。因为此处map
接受一个(JsString, JsValue) => String
类型的回调函数,应此实现可以等价变换为:
def showObject(bindings: Map[JsString, JsValue]): String = {
val pairs = bindings map {
binding => s""""${show(binding._1)}":${show(binding._2)}"""
}
s"""{${(pairs mkString ",")}}"""
}
事实上,回调的binding
类型为一个二元组,它的类型为(JsString, JsValue)
。可以通过调用_1, _2
方法分别提取出二元组的第1
个和第2
个元素的值,即Map
元素的键和值。
但是,调用_1, _2
方法,实现也显得较为复杂,语义不太明确。接下来尝试提取「有名变量」的重构手法,改善代码的表现力。
for推导式
首先,尝试使用for
推导式,可以得到等价的重构效果。
def showObject(bindings: Map[JsString, JsValue]): String = {
val pairs = for ((key, value) <- bindings)
yield s""""${show(key)}":${show(value)}"""
s"""{${(pairs mkString ",")}}"""
}
此处,(key, value) <- bindings
直接析取出Map
的键值对,避免了_1, _2
的神秘调用;其中,key
的类型为JsString
,value
的类型为JsValue
。
偏函数
其次,也可以使用偏函数,直接获取出Map
的键值对。
def showObject(bindings: Map[JsString, JsValue]): String = {
val pairs = bindings map {
case (key, value) => s""""${show(key)}":${show(value)}"""
}
s"""{${(pairs mkString ",")}}"""
}
此处,传递给map
的实际上是一个「偏函数」,它的类型为:PartialFunction[(JsString, JsValue), String]
。
当模式匹配成功后,它直接析取出(key, value)
的值;其中,key
的类型为JsString
,value
的类型为JsValue
。
也可以对上述实现进行局部重构,凸显偏函数的类型信息。
def showObject(bindings: Map[JsString, JsValue]): String = {
val f: PartialFunction[(JsString, JsValue), String] = {
case (key, value) => s""""${show(key)}":${show(value)}"""
}
s"""{${(bindings map f mkString ",")}}"""
}
因为PartialFunction[-T, +R]
是T => R
的子类型,上述实现也可以重构为:
def showObject(bindings: Map[JsString, JsValue]): String = {
val f: (JsString, JsValue) => String = {
case (key, value) => s""""${show(key)}":${show(value)}"""
}
s"""{${(bindings map f mkString ",")}}"""
}
多值析取器
对于for
推导式,及其应用偏函数。例如,对于偏函数f
,是如何析取键值对(key, value)
的值呢?
val f: PartialFunction[(JsString, JsValue), String] = {
case (key, value) => s""""${show(key)}":${show(value)}"""
}
事实上,该偏函数背后由Tuple2
支撑完成工作的。首先,Tuple2
大致如下定义:
case class Tuple2[+T1, +T2](_1: T1, _2: T2)
在合成的伴生对象中,unapply
方法大致如下实现:
object Tuple2 {
def unapply[T1, T2](t: Tuple2[T1, T2]): Option[Tuple2[T1, T2]] =
if (t != null) Some(t._1 -> t._2) else None
}
当它模式匹配成功后,isDefinedAt
返回true
;然后调用Tuple2
伴生对象的unapply
方法返回Some(t._1 -> t._2)
的值,最后将t._1, t._2
的值分别赋予(key, value)
。
形式化
综上述,可以得到apply,unapply
的一般规则,并可以进行形式化地描述。一般地,对于任意的样本类CaseObject
,其拥有T1, T2, ..., Tn
构造参数。
case class CaseObject[+T1, +T2, ..., +Tn](t1: T1, t2: T2, ..., tn: Tn)
其伴生对象中的apply, unapply
方法将存在如下的实现。
object CaseObject {
def apply[T1, T2, ..., Tn](
t1: T1, t2: T2, ..., tn: Tn) = new CaseObject(t1, t2, ..., tn)
def unapply[T1, T2, ..., Tn](
o: CaseObject[T1, T2, ..., Tn]): Option[(T1, T2, ..., Tn)] =
if (o != null) Some(o.t1, o.t2, ..., o.tn) else None
}
当模式匹配成功,它将返回Some[T1, T2, ..., Tn]
类型的结果;否则返回None
。
当 n == 1
特殊地,当n == 1
,对于任意的样本类Unary[+T]
,其拥有一个构造参数。
case class Unary[+T](t: T)
因为不存在单值的元组类型,因此其伴生对象中合成的unapply
将直接返回Option[T]
。
object Unary {
def apply[T](t: T): Unary[T] = new Unary(t)
def unapply[T](o: Unary[T]): Option[T] =
if (o != null) Some(o.t) else None
}
当 n == 2
特殊地,当n == 2
,对于任意的样本类Binary[+T1, +T2]
,其拥有两个构造参数。
case class Binary[+T1, +T2](t1: T1, t2: T2)
在其合成的伴生对象中,unapply
的返回值类型为Option[(T1, T2)]
。
object Binary {
def apply[T1, T2](t1: T1, t2: T2): Binary[T1, T2] =
new Binary(t1, t2)
def unapply[T1, T2](o: Binary[T1, T2]): Option[(T1, T2)] =
if (o != null) Some(o.t1 -> o.t2) else None
}
特殊地,对于二元的样本类,当它被应用于模式匹配时,case
表达式可以使用中缀表示。
def f[T1, T2, R]: Binary[T1, T2] => R = {
case t1 Binary t2 => ???
}
中缀表达式
例如,对于样本类Tuple2
,其伴生对象中合成的unapply
方法就是返回了一个Option
修饰的二元组。
因此,上例的showObject
定义的偏函数f
也可以变换为:
val f: Tuple2[JsString, JsValue] => String = {
case key Tuple2 value => s""""${show(key)}":${show(value)}"""
}
对于Tuple2
,中缀表示的可读性显然不佳。但是,Tuple2[T1, T2]
可以简化为(T1, T2)
的语法糖表示,因此上例可以等价转换为:
val f: (JsString, JsValue) => String = {
case (key, value) => s""""${show(key)}":${show(value)}"""
}
但是,但对于使用“操作符”命名的样本类,使用中缀表示的case
表达式会极大地改善代码的可读性。
接下来,以List
的非空节点::
,及其单键对象+:, :+
为例,讲解中缀表示在case
表达式中原理与运用。
样本类:::
首先,List
是一个递归的数据结构。其中,对于非空节点::
,它并非操作符,而是一个类名。
sealed trait List[+A]
case class ::[A](head: A, tail: List[A]) extends List[A]
case object Nil extends List[Nothing]
事实上,这样的命名方式是有特殊意图的。剖析case class ::
,它存在如下部分实现。
class ::[A](val head: A, val tail: List[A])
object :: {
def apply[A](head: A, tail: List[A]) =
new ::(head, tail)
def unapply[A](l: ::[A]): Option[(A, List[A])] =
if (l.isEmpty) None else Some(l.head -> l.tail)
}
因为伴生对象::
的unapply
方法返回一个二元组;当使用模式匹配时,case
表达式可以使用中缀表示。例如,count
方法用于计算满足谓词的列表元素的数目。
def count[A](l: List[A])(p: A => Boolean): Int = l match {
case head :: tail if(p(head)) => 1 + count(tail)(p)
case _ :: tail => count(tail)(p)
case _ => 0
}
中缀表示鲜明地描述了List
的特征,case
表达式很形象地表达了析取List
的「头节点」和「尾列表」的意图,具有很强的表达力。
事实上,它等价于如下的实现,但表达力显然不如前者。
def count[A](l: List[A])(p: A => Boolean): Int = l match {
case ::(head, tail) if(p(head)) => 1 + count(tail)(p)
case ::(_, tail) => count(tail)(p)
case _ => 0
}
单键对象:+:与:+
事实上,对于任何的单键对象op
,只要其unapply
能够将容器C
进行解构,并返回Option[(T1, T2)]
。则在使用模式匹配时,都可以使用中缀表示提取t1
与t2
的值。
例如,在标准库中存在一个单键对象+:
,它的功能类似于伴生对象::
,用于将SeqLike
类型的集合中析取出「头节点」和「尾序列」。
object +: {
def unapply[T, C <: SeqLike[T, C]](
c: C with SeqLike[T, C]): Option[(T, C)] =
if(c.isEmpty) None else Some(c.head -> c.tail)
}
例如,上述count
也可以实现为:
def count[A](l: List[A])(p: A => Boolean): Int = l match {
case head +: tail if(p(head)) => 1 + count(tail)(p)
case _ +: tail => count(tail)(p)
case _ => 0
}
同样地,标准库中也存在另一个单键对象:+
,它的功能与+:
相反,它用于析取集合中的「头列表」和「尾节点」。
object :+ {
def unapply[T, C <: SeqLike[T, C]](
c: C with SeqLike[T, C]): Option[(C, T)] =
if(c.isEmpty) None else Some(c.init -> c.last)
}
例如,上述count
也可以实现为:
def count[A](l: List[A])(p: A => Boolean): Int = l match {
case init :+ last if(p(last)) => count(init)(p) + 1
case init :+ _ => count(init)(p)
case _ => 0
}
因为调用List
的init, last
都将耗费O(n)
的时间复杂度,显然上述实现效率是非常低下的。