Scala 学习笔记——模式匹配

Scala 确实很有趣,虽然它并非像 Haskell 那样优雅简洁,某些抽象,如代数数据类型,仍旧需要通过一些“模式”来实现,而它在 Haskell 中是能够非常优雅清晰地表述的,或许这是使用 OOP 对 FP 进行建模的必然结果吧?但了解它的抽象方式也是非常有趣的,相较于 Haskell 更为啰嗦这一点,也让我对一些之前学习 Haskell 时未曾理解的概念,如模式匹配的视图等有了更加深刻的理解。

总之,做一些关键概念的笔记,当前考虑几个需要重点了解的地方是——

  • implicit, type class
  • trait,class,object,sealed 等的具体语义以及最佳实践
  • 函数/方法定义的各种语法及其差别
  • 花括号/括号的语义及最佳实践
  • 模式匹配
  • Scala 类型系统

这里主要是记录一下关于 Scala 中模式匹配的使用方式,同时利用自己实现的一个 List 举一些例子,至于模式匹配的具体原理,那必须得从 PLT 着手了,当前是没有精力和能力去学习那种东西的。

模式匹配——对数据的解构

学习过 Haskell 的话,对模式匹配应当是非常熟悉的,比如,要获取列表的第二个元素,我们会直接这么写——

1
2
3
second :: [a] -> Maybe a
second (_:x:_) = Just x
second _ = Nothing

在初学 Haskell 时,我曾以为解构的形式和结构的形式是一一对应的,比如,对于列表data [] a = [] | a : [a],我曾以为在解构中就只能使用:[]了;但这是错误的——ViewPattern 提供了创建更多解构符号的能力,在 Scala 中也能够实现类似的效果。

在 Scala 中,也容易有等价的实现——

1
2
3
4
def second[A](lst : List[A]) : Option[A] = lst match {
case _ :: x :: _ => Some(x)
case _ => None
}

关于 Scala 自定义操作符时的结合性问题,(这里不考虑=结尾的操作符)操作符默认为左结合,如果操作符的最右边是:,则右结合,如1 +: 2 +: List(),结合性为1 +: (2 +: List())。这里也可以认为,:符号贴近对象的先计算,可以通过 Seq 的+::+建立形象化的理解(想象一下我抄袭发明的>=>符号,哈哈)——

1
2
1 +: 2 +: 3 +: Nil 
List(1, 2, 3) :+ 4 :+ 5

如果说构造函数用于构造数据,则模式匹配则是用于解构数据

考虑这样一个数据类型——

1
2
3
4
5
case class ShouJyo (
name: String,
age: Int,
bestFriend: String
)

它是使用case class进行定义的,因此编译器会自动重写它的toStringhashCodeequals等方法;重写伴生对象的apply方法,使其同构造器一致;将所有类参数的访问暴露出来,因此我们可以通过伴生对象直接创建该类型的实例,并通过字段名直接访问它的数据——

1
2
val shoujyo = ShouJyo("Chihaya", 16, "Haruka")
println(s"少女的名字是 ${shoujyo.name},她的最好的朋友是 ${shoujyo.bestFriend}")

那么,当我们需要将它的字段保存到变量中呢?如果对每个字段都分别创建变量的话需要三行,实在有点不够优雅,这里可以使用解构语法——

1
2
3
val shoujyo = ShouJyo("Haruka", 16, "Chihaya")
val ShouJyo(name, age, _) = shoujyo
println(s"名:$name,年龄:$age")

这种写法同 Haskell 一致,我们通过“值构造器”把数据中的值取了出来,同时可以使用_符号来忽略值;同理,我们可以对 tuple 等数据结构进行解构。

1
2
3
4
5
val (a, b) = (1, 2)
println(a, b)

val x :: xs = List(1, 2, 3)
println(xs) // List(2, 3)

这种解构不一定能够成功——比如对[1,2]解构成x::y::z::xs,这种解构必然会抛出一个MatchError。为了应付各种情况的解构,我们需要模式匹配语法。

Scala 的模式匹配

Scala 的模式匹配可以认为是 Haskell 的模式匹配以及守卫表达式的综合,下面的示例展示了模式匹配的一些语法——

1
2
3
4
5
6
7
8
def objectInfo (obj : Any) = obj match {
case d : Double => s"%d 是一个浮点数"
case n : Int if n > 0 => s"$n 是一个正整数"
case "ping" => "pong!"
case _ : String => "一个字符串"
case lst @ (x :: xs) : List[Any] => s"一个 list,第一个元素为$x,长度为${lst.length}"
case _ => "不造啊"
}

该语法会从上到下匹配每一个 case,直到匹配成功,如果没有任何匹配,则抛出MatchError异常。

case d : Double => s"%d 是一个浮点数",这个 case 检查 obj 是否是 Double 类型,如果是,则将其赋值到变量 d,执行操作。

case n : Int if n > 0 => s"$n 是一个正整数",这个 case 检查 obj 是否是 Int 类型,如果是,则将其赋值到变量 n,检查 n 是否大于 0,如果是则匹配,这里的行为类似 Haskell 的守卫表达式。

case "ping" => "pong!",这个 case 检查 obj 是否等于”ping”,如果等于则匹配。

case shouJyo@ShouJyo(name, age, _) => s"$name 是少女,她的好朋友是${shouJyo.bestFriend}",该 case 有两个语法——@,语义同 Haskell 一致,解构的同时保留原对象;以及解构数据到变量中。

case _ : String => "一个字符串"检查 obj 是否是字符串,如果是则匹配。

case _ => "不造啊"是一个恒真的 case,它匹配任何值。

之前的例子val ShouJyo(...) = ...,其实等价于模式匹配语句,但只有一个 case。

Haskell 做得到嘛!(战术后仰)

解构的原理——以 List 为例子

考虑模仿 Scala 自定义一个 List,通过其了解模式匹配的机制。

使用 Haskell 的语法表述的话(符号用的 Idris 的),List 的定义为——

1
data List a = Nil | a :: List a

在 Scala 中,定义会更加复杂——类型构造器需要使用sealed traitsealed abstract class来定义,类型参数设为+A,而值构造器,如果它的集合大小仅为 1,比如 Nil,则使用case object定义并继承类型构造器,类型参数设为Nothing,如果大于一,则使用final case class定义,类型参数设为A;类型的所有方法都放置在类型构造器中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sealed trait List[+A] {
def head : A
def last : List[A]
}
case object Nil extends List[Nothing] {
override def head = throw new RuntimeException("head of empty list")
override def last = throw new RuntimeException("last of empty list")
}
// 通过类参数直接覆盖了父类的方法
final case class Cons [A] (head : A, last : List[A]) extends List[A]

case object List {
// 用于演示
def apply[A](elems : A*) : List[A] =
elems.foldRight(Nil:List[A])(Cons.apply)
}

注意,这里的Nil:List[A]是类型标注(没错,值也可以做类型标注),而非类型转换,类型转换是 asInstanceOf 方法, 对应 Java 的强制转换,这种类型标注在 Java 中没有对应。

有了这个定义后,我们已经可以通过 Cons 进行类型匹配了,下面的方法获取 List 的第二个元素。

1
2
3
4
def secondElem[A](lst : List[A]) : Option[A] = lst match {
case Cons(_, Cons(x, _)) => Some(x)
case _ => None
}

但这好用吗?一点都不好用,就感觉自己像在写 Lisp,Scala 提供了我们另一种方式——自定义 ViewPattern——来解决这一问题(这个名词来自于 Haskell,不确定 Scala 有没有)。

但首先我们要看模式匹配的原理。进行模式匹配时,Scala 会试图去寻找匹配的类(在上面的例子里,为 Cons)的伴生对象的unapply方法,该方法的签名和 apply 正好对应——参数是该类的实例,返回值为所有数据的元组(使用 Option 包装,因为解构有失败的可能性),如上面的 Cons,它的 unapply 方法可能是这样的——

1
2
def unapply(lst : List[A]) : Option[(A, List[A])] =
if (lst == Nil) None else Some((lst.head, lst.last))

这就给我们一个想法——我们可以定义另外的 object 并重写 object,达到编写模式匹配中的模式的目的!比如,我们可以编写一个名为::的 object,它的实现和 Cons 的 unapply 一致——

1
2
3
4
5
// 根据结合性的规则,:: 符号右结合
case object :: {
def unapply[A](lst : List[A]) : Option[(A, List[A])] =
if (lst == Nil) None else Some((lst.head, lst.last))
}

使用该“模式”重写 secondElem 方法——

1
2
3
4
def secondElem[A](lst : List[A]) : Option[A] = lst match {
case _ :: y :: _ => Some(y)
case _ => None
}

BINGO! 我们达到了和原生 Scala 代码一样的清晰度!

但我们也可以做的更多,超越(没有扩展的)Haskell 以及 js——谁说列表只能解构前缀呢?我们定义一下 Scala 同时提供的:+——

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 根据结合性规则,左结合
case object :+ {
// 工具函数
private def foldr[A, B] (lst : List[A], zero : B, acc : (A, B) => B) : B = lst match {
case x :: xs => acc(x, foldr(xs, zero, acc))
case _ => zero
}
private def init_last[A] (lst : List[A]) : (List[A], Option[A]) =
foldr[A, (List[A], Option[A])](lst, (List[A](), Option.empty), {(x, acc) =>
val (count, init) = acc
if (init.isEmpty) (Nil, Some(x))
else (Cons(x, count), init)
})

def unapply[A](lst : List[A]) : Option[(List[A], A)] = {
val (init, optionLast) = init_last(lst)
optionLast.map((init, _))
}
}

def last3Elem[A](lst : List[A]) : Option[(A,A,A)] = lst match {
case xs :+ x :+ y :+ z => Some((x,y,z))
case _ => None
}
last3Elem(List(1,2,3,4,5)) // Some((3,4,5))

精彩!这应该就是所谓的 View 模式,借助它,我们可以对那些实现并非 trival 的数据类型,比如 Map 进行模式匹配了!就是感觉要做的额外的类型标注有点多麻烦……怀疑是我的写法并非最佳实践

一个需要特别注意的一点是,结构和解构的方法并非是一一对应的——这里实现的:::+仅能够用于解构,如果想实现结构操作,需要通过隐式参数等方式在 List 上实现同名方法!实现其的 apply 方法也是不能奏效的——如此只能实现类似::(1, Nil)的操作!Scala 能够在解构时把模式当作中缀操作符来看待,但结构时无法如此

关于+A

在这里的 List 的定义里,类型参数 A 中有一个+,这代表着该参数是协变的,这是说,该类型参数A的类型关系会被原样映射到List[A],举个例子,假如DogAnimal的子类,则List[Dog]List[Animal]的子类。如果不给定该+,则List[Dog]List[Animal]将没有继承关系了。

那么为什么使用协变呢?再重申一下上面的List的定义:

1
2
3
sealed trait List[+A] { /* ... */}
case object Nil extends List[Nothing]
final case class Cons [A] (head : A, last : List[A]) extends List[A]

考虑我们需要定义一个IntList的空列表,我们可能会这么写:

1
2
// 为什么类型使用 List[Int] 而非 Nil 呢?从 Haskell 来的话应该能知晓,List 是类型构造器,而 Cons 和 Nil 是值构造器,值构造器出现在类型签名中的话实在品味不太好,而且对于 Nil 的话,就根本无法表达这里的 Int 类型信息
val emptyIntList : List[Int] = Nil

再回想一下我们在 Java 中是如何使用数据结构的——

1
List<Integer> lst = new ArrayList<Integer>();

类比一下,就能够明确出来,为了满足类型的匹配,Nil必须是List[Int]的子类!而 Nil 本身是List[Nothing]的子类,因此,**List[Nothing]必须是List[Int]的子类**!而我们已知Nothing是所有类型的子类,因此它也是Int的子类,因此这里如果是协变的话,从Nothing <: Int,就能够得到Nil <: List[Nothing] <: List[Int]

A <: B表示 A 是 B 的子类,类似 java 泛型的 extends,A >: B表示 B 是 A 的子类,即 java 泛型的 super,可以认为这里的<>表示是类型的“范围”的大小,越是子类,范围显然越小。