Scala样本类

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

Scala样本类

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

JSON递归结构

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

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的数组,它可以用下图描述:

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]的对象,它可以用下图描述:

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)的时间复杂度,显然上述实现效率是非常低下的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容

  • 变量初始化可以用用 _ 作占位符,赋值为默认值,字符串 null,Float、Int、Double 等为 0var...
    FaDeo_O阅读 897评论 0 0
  • java笔记第一天 == 和 equals ==比较的比较的是两个变量的值是否相等,对于引用型变量表示的是两个变量...
    jmychou阅读 1,483评论 0 3
  • 本文由我们团队的 纠结伦 童鞋撰写。 写在前面 本篇文章是对我一次组内分享的整理,大部分图片都是直接从keynot...
    知识小集阅读 15,221评论 11 172
  • 也曾同数堂前落花 也曾共赏西天晚霞 最终选择浪迹天涯 带上你的遗憾出发 也曾问...
    明月牵你阅读 265评论 1 4
  • 我十分惊讶,连忙问道:“怎么会?!” 璐璐说道:“被录取的那人是公司老总的女儿。夕夕酱,我突然觉得我的努力都是徒劳...
    夕夕酱阅读 1,226评论 0 3