Simple Scala: Extractor

简介

模式匹配中经常结构各种数据结构,包括列表/流/样例类等,这种结构数据结构功能的实现,都要归功于提取器.

提取器使用很广泛,具有与构造器相反的效果:构造器从给定的参数列表创建一个对象,而提取器却是从传递给他的对象中提取出构造该对象的参数.同时Scala标准库中包含了一些预定义的提取器.

样例类比较特殊,Scala会为其创建一个半生对象:一个包含了apply和unapply方法的单例对象.apply用来创建样例类的实例,unapply需要被半生对象实现,以使其成为提取器.

第一个提取器

unapply方法可能不止有一种方法签名,不过,我们还是从最简单的开始,毕竟使用更广泛的还是只有一种方法签名的unapply.假设要创建一个User特质,还有两个类包含他,并带有一个字段:

trait User {
  def name: String
}
class FreeUser(val name: String) extends User
class PremiumUser(val name: String) extends User

我们想再各自的伴生对象中实现FreeUser和PremiumUser类的提取器,就像Scala为样例类做得一样.如果想让样例类只支持从给定对象中提取单个参数,那unapply的方法签名看起来像这个样子:

def unapply(object: S): Option[T]

这个方法接受一个类型为S的对象,返回类型T的Option,T就是要提取的参数类型.

下面实现为两个类实现提取器:

trait User {
  def name: String
}
class FreeUser(val name: String) extends User
class PremiumUser(val name: String) extends User
object FreeUser {
  def unapply(user: FreeUser): Option[String] = Some(user.name)
}
object PremiumUser {
  def unapply(user: PremiumUser): Option[String] = Some(user.name)
}

现在就可以在REPL中调用他们了:

scala> FreeUser.unapply(new FreeUser("Daniel"))
res0: Option[String] = Some(Daniel)

如果调用返回的结果是 Some[T],说明提取模式匹配成功,如果是None,说明模式不匹配.
一般不会直接调用它,因为用于提取器模式时,Scala会隐式的调用提取器的apply方法:

val user: User = new PremiumUser("Daniel")
user match {
    case FreeUser(name) => "Hello" + name
    case PremiumUser(name) => "Welcome back, dear" + name
}

你会发现,两个提取器绝对不会返回None,这个例子展示的提取器比之前更有意义,如果你有一个类型不确定的对象,你可以同时检查其类型并解构.
上面的例子中,FreeUser并不会匹配,因为它接受的类型跟我们传递给他得不一样,这样,user对象就会被传递给第二个模式,也就是PremiumUser半生对象的unapply方法,而这个模式就会匹配成功,从而返回值就被绑定在name上了.

提取多个值

现在,假设类有多个字段:

trait User {
  def name: String
  def score: Int
}
class FreeUser(
  val name: String,
  val score: Int,
  val upgradeProbability: Double
) extends User
class PremiumUser(
  val name: String,
  val score: Int
) extends User

如果想要结构出多个参数,那它的unapply方法就应该有这样的方法签名:

def unapply(object: S): Option[(T1, ..., T2)]

这个方法接受S类型的对象,返回类型参数为TupleN的Option实例,TupleN中的N是要提取的参数个数.

修改类之后,提取器也要做对应的修改:

object FreeUser {
  def unapply(user: FreeUser): Option[(String, Int, Double)] =
    Some((user.name, user.score, user.upgradeProbability))
}
object PremiumUser {
  def unapply(user: PremiumUser): Option[(String, Int)] =
    Some((user.name, user.score))
}

现在就可以拿来做模式匹配了:

val user: User = new FreeUser("Daniel", 3000, 0.7d)
user match {
  case FreeUser(name, _, p) =>
    if (p > 0.75) "$name, what can we do for you today?"
    else "Hello $name"
  case PremiumUser(name, _) =>
    "Welcome back, dear $name"
}

布尔提取器

有些时候,进行模式匹配并不是为了提取参数,而是为了检查是否匹配.这种情况下,第三种unapply方法签名就有用了.这个方法接受一个S类型的对象,返回一个布尔值:

def unapply(object: S): Boolean

使用的时候,如果这个提取器返回true,模式会匹配成功,否则,Scala会尝试那Object匹配下一个模式.

上个例子存在一些代码逻辑,用来检查一个免费用户有没有可能被说服去升级他得账户.可以把这个逻辑放在一个单独的提取器中:

object premiumCandidate {
  def unapply(user: FreeUser): Boolean = user.upgradeProbability > 0.75
}    

你会发现,提取器不一定要在这个类的半生对象中定义.正如其定义一样,这个提取器的使用方法也很简单:

val user: User = new FreeUser("Daniel", 2500, 0.8d)
user match {
  case freeUser @ premiumCandidate() => initiateSpamProgram(freeUser)
  case _ => sendRegularNewsletter(user)
}

使用的时候,只需要把一个空的参数列表传递给提取器,因为它并不是真的需要提取数据,自然也就没有必要绑定变量.

这个例子有一个看起来比较奇怪的地方: 我假设存在一个空想的 initiateSpamProgram 函数,其接受一个 FreeUser 对象作为参数。 模式可以与任何一种 User 类型的实例进行匹配,但 initiateSpamProgram 不行, 只有将实例强制转换为 FreeUser 类型, initiateSpamProgram 才能接受。

因为如此,Scala 的模式匹配也允许将提取器匹配成功的实例绑定到一个变量上, 这个变量有着与提取器所接受的对象相同的类型。这通过 @ 操作符实现。 premiumCandidate 接受 FreeUser 对象,因此,变量 freeUser 的类型也就是 FreeUser 。

布尔提取器的使用并没有那么频繁(就我自己的情况来说),但知道它存在也是很好的, 或迟或早,你会遇到一个使用布尔提取器的场景.

中缀表达方式

解构列表、流的方法与创建它们的方法类似,都是使用 cons 操作符: :: 、 #:: ,比如:

val xs = 58 #:: 43 #:: 93 #:: Stream.empty
xs match {
  case first #:: second #:: _ => first - second
  case _ => -1
}

你可能会对这种做法产生困惑。 除了我们已经见过的提取器用法,Scala 还允许以中缀方式来使用提取器。 所以,我们可以写成 e(p1, p2) ,也可以写成 p1 e p2 , 其中 e 是提取器, p1 、 p2 是要提取的参数。

同样,中缀操作方式的 head #:: tail 可以被写成 #::(head, tail) , 提取器 PremiumUser 可以这样使用: name PremiumUser score 。 当然,这样做并没有什么实践意义。 一般来说,只有当一个提取器看起来真的像操作符,才推荐以中缀操作方式来使用它。 所以,列表和流的 cons 操作符一般使用中缀表达,而 PreimumUser 则不用。

进一步看流提取器

尽管 #:: 提取器在模式匹配中的使用并没有什么特殊的, 但是,为了更好的理解上面的代码,还是进一步来分析一下。 而且,这是一个很好的例子,根据要匹配的数据结构的状态,提取器很可能返回 None 。
如下是 Scala 2.9.2 源代码中完整的 #:: 提取器代码:

object #:: {
  def unapply[A](xs: Stream[A]): Option[(A, Stream[A]) =
    if (xs.isEmpty) None
    else Some((xs.head, xs.tail))
}

如果给定的流是空的,提取器就直接返回 None 。 因此, case head #:: tail 就不会匹配任何空的流。 否则,就会返回一个 Tuple2 ,其第一个元素是流的头,第二个元素是流的尾,尾本身又是一个流。 这样, case head #:: tail 就会匹配有一个或多个元素的流。 如果只有一个元素, tail 就会被绑定成空流。

为了理解流提取器是怎么在模式匹配中工作的,重写上面的例子,把它从中缀写法转成普通的提取器模式写法:

val xs = 58 #:: 43 #:: 93 #:: Stream.empty
xs match {
  case #::(first, #::(second, _)) => first - second
  case _ => -1
}

首先为传递给模式匹配的初始流 xs 调用提取器。 由于提取器返回 Some(xs.head, xs.tail) ,从而 first 会绑定成 58, xs 的尾会继续传递给提取器,提取器再一次被调用,返回首和尾, second 就被绑定成 43 , 而尾就绑定到通配符 _ ,被直接扔掉了。

序列提取

对于某种数据结构来说,Scala提供了提取任意多个参数的模式匹配方法.

比如,可以匹配只有两个或只有三个元素的列表:

val xs = 3 :: 6 :: 12 :: Nil
xs match {
 case List(a, b) => a * b           // 匹配两个元素的列表
 case List(a, b, c) => a + b + c    // 匹配三个元素的列表
 case _ => 0
}

除此之外,可以使用通配符_*匹配不确定长度的列表:

val xs = 3 :: 6 :: 12 :: 24 :: Nil
xs match {
 case List(a, b, _*) => a * b       // 匹配任意长度列表的前连个元素
 case _ => 0
}

这个例子中,第一个模式成功匹配,把xs的前两个元素分别绑定到a/b,而剩余的列表,无论其还有多少个元素,都直接被忽略掉.

显然,这种方式的提取器是无法通过上一章介绍的方式来实现的.需要一种特殊的方式,来使一个提取器可以接收某一类型的对象,将其结构成列表,而这个列表的长度在编译器是不确定的.
unapplySeq就是用来做这件事的,下面的代码是可能的方法签名:

def unapplySeq(object: S): Option[Seq[T]]

这个方法接收一个类型为S的对象,返回一个类型参数为Seq[T]的Option.

例子:提取给定的名字

假设有一个应用,其某处代码接收了一个表示人名且类型为String的参数,这个字符串可能包含了这个人的第二个名字甚至第三个名字,比如:Daniel/Catherina Johanna/Matthew John Michael.而我们想做的是从这个字符串中提取出单个的名字.

下面的代码是一个用unapplySeq方法实现的提取器:

object GivenNames {
  def unapplySeq(name: String): Option[Seq[String]] = {
    val names = name.trim.split(" ")
    if (name.forall(_.isEmpty)) None
    else Some(names)
  }
}

给定一个含有一个或多个名字的字符串,这个提取器会将其结构成一个列表.如果字符串不包含有任何名字,提取器会返回None,提取器所在的那个模式就匹配失败.下面对提取器进行测试:

def greetWithFirstName(name: String) = name match {
    case GivenNames(firstName, _*) => "Good morning, $firstname!"
    case _ => "Welcome! Please make sure to fill in your name!"
}

调用greetWithFirstName(“Daniel”),会返回”Good morning, Daniel!”,而调用greetWithFirstName(“Catherina Johanna”) 会返回 “Good morning, Catherina!”.

固定和可变的参数提取

有些时候需要提取出多个值,这样,在编译期,就必须知道要提取出多少个值出来,在外加一个可选的序列,用来保存不确定的那一部分.

在我们的例子中,假设输入的字符串包含了一个完整的姓名,而不仅仅是名字.比如字符串可能是”John Doe”/“Catherina Johanna Peterson”,其中”Doe”、”Peterson”是姓,”John”/“Catherina”/“Johanna”是名.我们想做的是匹配这样的字符串,把姓绑定到一个变量,把第一个名绑定到第二个变量,第三个变量存放剩下的任意名字.
稍微修改unapplySeq方法就可以解决上述问题:

def unapplySeq(object: S): Option[(T1, .., Tn-1, Seq[T])]

unapplySeq 返回的同样是 Option[TupleN],只不过,其最后一个元素是Seq[T],这个方法签名看起来应该很熟悉,它和之前的一个unapply签名类似.

下列代码是使用这个方法生成的提取器:

object Names {
  def unapplySeq(name: String): Option[(String, String, Seq[String])] = {
    val names = name.trim.split(" ")
    if (names.size < 2) None
    else Some((names.last, names.head, names.drop(1).dropRight(1)))
  }
}

仔细看其返回值和构造Some的方式.代码返回一个类型参数为Tuple3的Option,这个元祖包含了姓/名以及剩余的名构成的序列.

如果这个提取器用在一个模式中,那只有当给定的字符串至少含有姓和名时,模式才能匹配成功.

下面这个提取器重写greeting方法:

def greet(fullName: String) = fullName match {
  case Names(lastName, firstName, _*) =>
    "Good morning, $firstName $lastName!"
  case _ =>
    "Welcome! Please make sure to fill in your name!"
}

参考

参考链接