返回
Featured image of post Scala - 模式匹配

Scala - 模式匹配

Scala 提供了强大的模式匹配机制,应用也非常广泛,match case 只是其中一隅

参考来源: https://windor.gitbooks.io/beginners-guide-to-scala/content https://docs.scala-lang.org/scala3/book

一个不同数据类型的模式匹配如下:

object Test {
   def main(args: Array[String]) {
      println(matchTest("two"))
      println(matchTest("test"))
      println(matchTest(1))
      println(matchTest(6))
   }
   def matchTest(x: Any): Any = x match {
      case 1 => "one"
      case "two" => 2
      case y: Int => "scala.Int"
      case _ => "many" // Same as default in switch
   }
}

case 的用法相当多,包括逻辑符号 | 以及 if 条件语句都可以适用,与 switch 的区别在于只会匹配一项,所以也不需要 break


case 0 | "" => false
case 2 | 4 | 6 | 8 | 10 => println("偶数")
case x if x == 2 || x == 3 => println("two's company, three's a crowd")

提取器 (Extractor Objects)

典型的提取器具有两个方法

object Twice {                              
  def apply(x: Int): Int = x * 2
  def unapply(z: Int): Option[Int] = if (z%2 == 0) Some(z/2) else None
}
  • unapply 最为关键,将被用于模式匹配

    x match { case Twice(n) => println(n) }
    

    这里的 case Twice(n) 实际调用的就是 Twice.unapply

    • 如果只需要判断,unapply 的返回值只要 Boolean 就行,匹配代码也只需要 case Twice() ,因为没有次值 (sub-value)
    • 如果需要返回的是单个次值,如这里例子所示,返回 Option[T]
    • 如果需要返回多个次值,用 Option[Seq[B]]
  • apply 并不必要,很多情况下只是模拟一个构造器,或者说是一个 工厂方法,尤其是对于样例类而言

提取器并不一定需要要在伴生对象中定义,匹配成功后的实例也可以通过 @ 操作符来绑定到一个变量上

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)
}

以上是一个布尔提取器的例子


样例类 (Case Class)

样例类本质是个语法糖,可以看作是不可变的数据对象(var是允许的,但官方文档并不推荐),对所有构造参数增加 getter 访问和 toString hashCode equals 等方法 除了这些 “lombok” 功能外,最重要的是实现了一个伴生对象,并定义了 apply 方法 和 unapply 方法

样例类在编译阶段会混入 Product 特质 和 Serializable 特质,同时增加 copy / equals / hashCode / toString 等方法,这里的 copy 进行的是 浅拷贝; 伴生对象则增加了 applyunapply 方法,继承了 AbstractFunction (这其实是因为 Scala 中的工厂方法遵从 AbstractFunction,而这个伴生对象也有工厂方法的成分)

当参数个数大于等于2,AbstractFunction 提供一个 tupled 方法作为参数来构造出结果

scala> case class A(x: Int, y:Int)

scala> A.tupled
res11: ((Int, Int)) => A = <function1>

scala> val t = (100,100)
t: (Int, Int) = (100,100)

scala> A.tupled(t)
res9: A = A(100,100)

样例类与模式识别

Scala 会自动为样例类创建一个 伴生对象 —— 包含了 applyunapply 方法的 单例对象,因此可以直接被当作提取器使用


流提取器

中缀表达方式

列表、流 的解析和创建方法类似,都可以用 cons 操作符 :: #:: ,比如

val xs = 58 #:: 43 #:: 93 #:: Stream.empty

匹配也使用中缀方式,head #:: tail 也可以写成 #::(head, tail)

xs match {
  case first #:: second #:: _ => first - second
  case _ => -1
}

流提取器

完整的 #:: 提取器代码如下

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

如果流为空,提取器直接返回 None ,匹配直接失败。否则返回一个 Tuple2 ,第一个元素是流的头,第二个元素是流的尾,尾本身当然也是一个流。这样,case head #:: tail 就会匹配有一个或多个元素的流。如果只有一个元素,则 tail 又会被绑定成空流。

将中缀写法换成一般写法,case first #:: second #:: _ => first - second 就成了 case #::(first, #::(second, _)) => first - second 。第一次调用时的 head 是 58,被绑给 first ,其余继续提取,second 被绑定成 43 ,剩下的绑到 _ ,也就直接扔掉。


序列提取

对于多个元素的列表可以对元素个数进行匹配:

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
}

要构造对应的提取器就需要用到 unapplySeq 来将某一对象解析成列表,且这一列表的长度在编译器并不确定

譬如,某个人可能名字很长,比如 Albus Percival Wulfric Brian Dumbledore ,我们现在希望通过提取器仅将姓氏和名字提出来,中间名单独分开来就行,写出来大致是这样的

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)))
  }
}

用字符串操作把名字分割成列表,如果长度小于2则连姓名都无法提取到,返回 None 这里分割完后的是一个 Array ,直接通过 last headdrop 操作就可以将姓名的三部分分开来,以构造 Some 的方式返回一个类型参数为 Tuple3Option


值定义中的模式匹配

类似于 Python 中的 序列解包,Scala 在值定义过程中也可以使用模式,从而在赋值给变量的同时去结构它

case class Player(name: String, score: Int)	 // 每个玩家有姓名和得分

val Player(name, _) = currentPlayer() // 模式匹配
doSomethingWithName(name)

当然,如果无法匹配会抛 MatchError ,例如下面这个例子:

def scores: List[Int] = List()
val best :: rest = scores // 列表为空,匹配不到 best
println("The score of our champion is " + best)

值定义模式匹配可以很好地配合元组使用,从而提高代码可读性,如下面这个匹配方式:

def gameResult(): (String, Int) = ("Daniel", 3500)

如果我们用一个变量记录这一元组,会出现一个问题——Tuple访问内部变量可读性很差:

val result = gameResult()
println(result._1 + ": " + result._2) // 有点回到Java Pair的意思

但是在赋值同时做解构就非常安全,且很整洁:

val result(name, score) = gameResult()
println(name + ": " + score)

循环中的模式匹配

结合 for {} yield 使用:

def gameResults(): Seq[(String, Int)] =
	("Daniel", 3500) :: ("Melissa", 13000) :: ("John", 7000) :: Nil

def hallOfFame = for {
  result <- gameResults()
  (name, score) = result
  if (score > 5000)
} yield name

可以看到,这个 result 实际也是多余,可以改成这样:

def hallOfFame = for {
  (name, score) <- gameResults()
  if (score > 5000)
} yield name

生成器左侧的模式也可以用来过滤,因为匹配失败的元素也会被直接滤掉 如下面这个滤掉所有空列表的方案:

val lists = List(1,2,3) :: List.empty :: List(5,3) :: Nil
for {
  list @ head :: _ <- lists
} yield list.size

起到过滤效果的是这句 head :: _ ,因为空列表无法匹配到 head ,因此只有非空列表能被匹配,从而绑定给 list


用模式匹配改造代码

匿名函数

对于一个 List ,可以很轻松地搭配匿名函数使用,如一个 List[String]

val songTitles = List("The White Hare", "Childe the Hunter", "Take no Rogues")

可以很轻松地使用 .map 搭配占位符 (placeholder) 和匿名函数:

songTitles.map(_.toLowerCase)

但是对于元组列表,模式匹配就很有用,考虑一个 (String, Int) 的元组列表 我们现在要对列表中第二个值高于3的对象,取出第一个对象组成新的列表

wordFrequencies.filter { case (_, f) => f > 3 && f < 25 } map { case (w, _) => w }

匿名函数的缺点在于,编译器无法从中推导出值的类型,必须显式声明

在上面这种方法中,虽然先 filtermap 解决了问题,但必须遍历两次,无论从效率还是代码整洁上看都不是最佳,因此可以用到偏函数 (PartialFunction)

偏函数

偏函数出现在集合 API 的 collect 方法,这个方法签名如下:

def collect[B](pf: PartialFunction[A, B]): Seq[B]

类型 PartialFunction[A, B] 扩展了类型 (A) => B ,是一个一元函数,主要包含 applyisDefinedAt 两个方法,因此如果显示扩展一个 PartialFunction 特质,就要像这样实现这两个方法:

val pf = new PartialFunction[(String, Int), String] {
  def apply(wordFrequency: (String, Int)) = wordFrequency match {
    case (word, freq) if freq > 3 && freq < 25 => word
  }
  def isDefinedAt(wordFrequency: (String, Int)) = wordFrequency match {
    case (word, freq) if freq > 3 && freq < 25 => true
    case _ => false
  }
}

可以看到,idSefinedAt 只用来过滤入参范围,而 apply 才是真正的业务逻辑,而 apply 也不能省略筛选部分代码,idDefinedAt 部分功能多少有些多余 我们完全可以用这样的方法定义偏函数:

val pf: PartialFunction[(String, Int), String] = {
  cas (word, freq) if freq > 3 && freq < 25 => word
}

当然,偏函数只能用在 .collect 函数而不能用在 .map 函数中,因为这个函数是为处理一个集合中的所有元素设计的,有不匹配项会抛 MatchError

comments powered by Disqus