Scala 学习笔记——模式匹配
Scala 确实很有趣,虽然它并非像 Haskell 那样优雅简洁,某些抽象,如代数数据类型,仍旧需要通过一些“模式”来实现,而它在 Haskell 中是能够非常优雅清晰地表述的,或许这是使用 OOP 对 FP 进行建模的必然结果吧?但了解它的抽象方式也是非常有趣的,相较于 Haskell 更为啰嗦这一点,也让我对一些之前学习 Haskell 时未曾理解的概念,如模式匹配的视图等有了更加深刻的理解。
总之,做一些关键概念的笔记,当前考虑几个需要重点了解的地方是——
- implicit, type class
- trait,class,object,sealed 等的具体语义以及最佳实践
- 函数/方法定义的各种语法及其差别
- 花括号/括号的语义及最佳实践
- 模式匹配
- Scala 类型系统
这里主要是记录一下关于 Scala 中模式匹配的使用方式,同时利用自己实现的一个 List 举一些例子,至于模式匹配的具体原理,那必须得从 PLT 着手了,当前是没有精力和能力去学习那种东西的。
模式匹配——对数据的解构
学习过 Haskell 的话,对模式匹配应当是非常熟悉的,比如,要获取列表的第二个元素,我们会直接这么写——
1 |
|
在初学 Haskell 时,我曾以为解构的形式和结构的形式是一一对应的,比如,对于列表
data [] a = [] | a : [a]
,我曾以为在解构中就只能使用:
和[]
了;但这是错误的——ViewPattern 提供了创建更多解构符号的能力,在 Scala 中也能够实现类似的效果。
在 Scala 中,也容易有等价的实现——
1 |
|
关于 Scala 自定义操作符时的结合性问题,(这里不考虑=结尾的操作符)操作符默认为左结合,如果操作符的最右边是:
,则右结合,如1 +: 2 +: List()
,结合性为1 +: (2 +: List())
。这里也可以认为,:
符号贴近对象的先计算,可以通过 Seq 的+:
,:+
建立形象化的理解(想象一下我抄袭发明的>=>符号,哈哈)——
1 |
|
如果说构造函数用于构造数据,则模式匹配则是用于解构数据。
考虑这样一个数据类型——
1 |
|
它是使用case class
进行定义的,因此编译器会自动重写它的toString
,hashCode
,equals
等方法;重写伴生对象的apply
方法,使其同构造器一致;将所有类参数的访问暴露出来,因此我们可以通过伴生对象直接创建该类型的实例,并通过字段名直接访问它的数据——
1 |
|
那么,当我们需要将它的字段保存到变量中呢?如果对每个字段都分别创建变量的话需要三行,实在有点不够优雅,这里可以使用解构语法——
1 |
|
这种写法同 Haskell 一致,我们通过“值构造器”把数据中的值取了出来,同时可以使用_
符号来忽略值;同理,我们可以对 tuple 等数据结构进行解构。
1 |
|
这种解构不一定能够成功——比如对[1,2]
解构成x::y::z::xs
,这种解构必然会抛出一个MatchError
。为了应付各种情况的解构,我们需要模式匹配语法。
Scala 的模式匹配
Scala 的模式匹配可以认为是 Haskell 的模式匹配以及守卫表达式的综合,下面的示例展示了模式匹配的一些语法——
1 |
|
该语法会从上到下匹配每一个 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 |
|
在 Scala 中,定义会更加复杂——类型构造器需要使用sealed trait
或sealed abstract class
来定义,类型参数设为+A
,而值构造器,如果它的集合大小仅为 1,比如 Nil,则使用case object
定义并继承类型构造器,类型参数设为Nothing
,如果大于一,则使用final case class
定义,类型参数设为A
;类型的所有方法都放置在类型构造器中。
1 |
|
注意,这里的
Nil:List[A]
是类型标注(没错,值也可以做类型标注),而非类型转换,类型转换是 asInstanceOf 方法, 对应 Java 的强制转换,这种类型标注在 Java 中没有对应。
有了这个定义后,我们已经可以通过 Cons 进行类型匹配了,下面的方法获取 List 的第二个元素。
1 |
|
但这好用吗?一点都不好用,就感觉自己像在写 Lisp,Scala 提供了我们另一种方式——自定义 ViewPattern——来解决这一问题(这个名词来自于 Haskell,不确定 Scala 有没有)。
但首先我们要看模式匹配的原理。进行模式匹配时,Scala 会试图去寻找匹配的类(在上面的例子里,为 Cons)的伴生对象的unapply
方法,该方法的签名和 apply 正好对应——参数是该类的实例,返回值为所有数据的元组(使用 Option 包装,因为解构有失败的可能性),如上面的 Cons,它的 unapply 方法可能是这样的——
1 |
|
这就给我们一个想法——我们可以定义另外的 object 并重写 object,达到编写模式匹配中的模式的目的!比如,我们可以编写一个名为::
的 object,它的实现和 Cons 的 unapply 一致——
1 |
|
使用该“模式”重写 secondElem 方法——
1 |
|
BINGO! 我们达到了和原生 Scala 代码一样的清晰度!
但我们也可以做的更多,超越(没有扩展的)Haskell 以及 js——谁说列表只能解构前缀呢?我们定义一下 Scala 同时提供的:+
——
1 |
|
精彩!这应该就是所谓的 View 模式,借助它,我们可以对那些实现并非 trival 的数据类型,比如 Map 进行模式匹配了!就是感觉要做的额外的类型标注有点多麻烦……怀疑是我的写法并非最佳实践
一个需要特别注意的一点是,结构和解构的方法并非是一一对应的——这里实现的::
,:+
仅能够用于解构,如果想实现结构操作,需要通过隐式参数等方式在 List 上实现同名方法!实现其的 apply 方法也是不能奏效的——如此只能实现类似::(1, Nil)
的操作!Scala 能够在解构时把模式当作中缀操作符来看待,但结构时无法如此!
关于+A
在这里的 List 的定义里,类型参数 A 中有一个+
,这代表着该参数是协变的,这是说,该类型参数A
的类型关系会被原样映射到List[A]
,举个例子,假如Dog
是Animal
的子类,则List[Dog]
是List[Animal]
的子类。如果不给定该+
,则List[Dog]
和List[Animal]
将没有继承关系了。
那么为什么使用协变呢?再重申一下上面的List
的定义:
1 |
|
考虑我们需要定义一个Int
的List
的空列表,我们可能会这么写:
1 |
|
再回想一下我们在 Java 中是如何使用数据结构的——
1 |
|
类比一下,就能够明确出来,为了满足类型的匹配,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,可以认为这里的<
和>
表示是类型的“范围”的大小,越是子类,范围显然越小。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 协议 ,转载请注明出处!