Scala样本类

博观而约取,厚积而薄发。

Scala样本类

本章将重点介绍样本类(case class),及其在模式匹配(Pattern Matching)中的工作机制,及其具体运用。

JSON递归结构

JSON(JavaScript Object Notation)是一种轻量级的数据交换格式。使用Scala,可以很容易地实现JSON递归结构的定义。

Scala样本类_第1张图片
JsValue的递归结构
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的数组,它可以用下图描述:

Scala样本类_第2张图片
JsArray的结构

字符串化一个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]的对象,它可以用下图描述:

Scala样本类_第3张图片
JsObject的结构

字符串化一个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类型。例如,JsStringunapply方法实现如下。

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的类型为JsStringvalue的类型为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的类型为JsStringvalue的类型为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)]。则在使用模式匹配时,都可以使用中缀表示提取t1t2的值。

例如,在标准库中存在一个单键对象+:,它的功能类似于伴生对象::,用于将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
}

因为调用Listinit, last都将耗费O(n)的时间复杂度,显然上述实现效率是非常低下的。

你可能感兴趣的:(Scala样本类)