本章主要分析case classes和模式匹配(pattern matching)。
一、简单例子
接下来首先以一个包含case classes和模式匹配的例子来展开本章内容。
下面的例子中将模拟实现一个算术运算,这个算术运算可以基于变量和数字进行一些一元或二元的操作。其中有关数据类型,以及一元和二元操作的类型都定义在如下代码中。
abstract class Expr
case class Var(name: String) extends Expr
case class Number(num: Double) extends Expr
case class UnOp(operator: String, arg: Expr) extends Expr
case class BinOp(operator: String, left: Expr, right: Expr) extends Expr
上面代码中定义了一个名为Expr
的基类,以及四个子类。
1、Case classes
上面例子中后面四个子类在class
关键字前还有一个case
关键字,这种以case
开头的类就是Case classes。Case classes有以下四个特点,
(1)在类定义前面加上case
关键字后,Scala编译器会生成一个与类名相同的工厂方法。
执行上面的五个类定义后,可以直接以类名和参数的形式得到case classes的对象,如下所示
val v = Var("x")
结果如下,
使用case classes在生成新的对象时可以使代码变得更加简洁。
再看一下BinOp
类的使用
val op = BinOp("+", Number(1), v)
结果如下,
(2)参数列表中的所有参数其实都对应一个val
变量
可以使用以下代码中的方式访问参数列表中的val
变量
v.name
op.left
运行结果如下,可以直接访问对象v
和对象op
中的属性。
(3)编译器实现默认的toString
, hashCode
, equals
方法
编译器会自动为Case classes实现三个方法,
println(op)
op.right == Var("x")
运行结果如下,
(4)编译器为case classes实现一个copy
方法
使用该copy
方法,可以复制指定对象,并且可以改变被复制对象的部分参数属性,下面代码将复制一个op
变量,但是将其中的+
改变成-
op.copy(operator = "-")
结果如下,
2、模式匹配
通过使用前面的计算表达式得到的某些结果可能可以得到简化,比如一个数连续两次取负仍然是自身,比如一个数加0仍然为自身,比如一个数乘以1仍然为自身,如下所示,
UnOp("-", UpOp("-", e)) => e // 双重负号
BinOp("+", e, Number(0)) => e // 加0
BinOp("*", e, Number(1)) => e // 乘1
使用模式匹配可以将上面三个规则进行规范化管理,遇到符合上面三个规则的表达式时按照该规则进行处理,
def simplifyTop(expr: Expr): Expr = expr match {
case UnOp("-", UnOp("-", e)) => e // 双重负号
case BinOp("+", e, Number(0)) => e // 加0
case BinOp("*", e, Number(1)) => e // 乘1
case _ => expr
}
使用该模式匹配,
simplifyTop(UnOp("-", UnOp("-", Var("x"))))
使用simplifyTop
规则对表达式UnOp("-", UnOp("-", Var("x")))
进行处理,得到的结果,
在模式匹配中一般包含一系列的匹配条件,每个条件以一个case
关键字开头,接下来有一个匹配模式和匹配成功后会执行的一系列的表达式。
二、模式的种类
1、通配模式
通配符可以匹配任意对象,比如下面例子中的_
,任何不是BinOp(op, left, right)
的都匹配到了_
这里。
expr match {
case BinOp(op, left, right) =>
println(expr +" is a binary operation")
case _ =>
}
通配符同样可以匹配某些不关注的部分,比如下面这样
expr match {
case BinOp(_, _, _) => println(expr +" is a binary operation")
case _ => println("It's something else")
}
2、常量模式
所有的字面量,比如数字5,字符串”hello”以及所有的val
对象和单例对象比如Nil
,都可以作为常量模式的匹配条件。比如下面的表达式中
def describe(x: Any) = x match {
case 5 => "five"
case true => "truth"
case "hello" => "hi!"
case Nil => "the empty list"
case _ => "something else"
}
describe(5)
describe(true)
describe("hello")
describe(Nil)
describe(List(1, 2, 3))
运行结果如下,
3、变量模式
(1)变量模式
变量匹配类似于通配符匹配,可以匹配任何对象。和通配符模型不相同的地方在于,会将匹配到的内容赋值给该变量名,可以在=>
后面的代码中使用到,比如下面这段代码
val expr = 5
expr match {
case 0 => "zero"
case somethingElse => "not zero: "+ somethingElse
}
运行结果如下,
(2)比较变量模式和常量模式
比较一下上面这段代码中的somethingElse
变量名,以及常量模式中的Nil
单例对象,发现变量模式和常量模式在表现形式上还是有些类似的。接下来对这两者加以区分。
import math.{E, Pi}
E match {
case Pi => "strange math? Pi = " + Pi
case _ => "OK"
}
运行结果如下,
常量E
匹配不到常量Pi
,这是正常的。可是编译器怎么知道Pi
代表的是math.Pi
而不是变量名为Pi
的一个变量呢?在这种情况下,Scala编译器将以小写字母开头的匹配项当做一个变量名,所以Pi
被当成了一个常量。
看一下以下代码,将常量Pi
赋值给一个变量pi
,然后进行匹配
val pi = math.Pi
E match {
case pi => "strange math? Pi = " + pi
}
运行结果如下,
可以看到,在这里Scala编译器将pi
当成了一个变量名,所以这里的匹配模式就是变量模式。
(3)变量模式和通配符模式的冲突
在变量模式的情况下,在匹配的最后不能再写一个通配符匹配,否则会报错,如下
E match {
case pi => "strange math? Pi = "+ pi
case _ => "OK"
}
运行结果,
如果非要既使用变量匹配,又写一个通配符匹配的话,还有两个办法,
a、如果该变量是某个对象的属性,可以用this.pi
或者obj.pi
的方式来表示,这样会被当成一个常量匹配
b、用反引号包围该变量名,““”是键盘上1左边那个键。
E match {
case `pi` => "strange math? Pi = " + Pi
case _ => "OK"
}
4、构造器模式
构造器模式是模式匹配中最有用的模式。构造器模式的展现形式如BinOp("+", e, Number(0))
这样,由一个类名BinOp
,以及圆括号中的+, e, Number(0)
组成。假设这里的BinOp
类是一个case class,那么这种模式意味着首先检查匹配对象是否是BinOp
这个case class类型,然后去检查该对象的构造参数是否能与除类名外的其他参数匹配。
即所谓的deep matches,比如下面的代码,
expr match {
case BinOp("+", e, Number(0)) => println("a deep match")
case - =>
}
上面代码中的构造器模式,虽然只有一行代码,但是实现了三层匹配,第一层检查expr
对象是否为BinOp
类型,第二层检查第三个构造参数是否为Number
类型,第三层检查该Number
类型的值是否为0。
5、序列模式
序列模式是说可以用来匹配List
或Array
类型。
比如下面代码检查expr
是否为List
对象,并且该List
中有三个元素,并且该对象需要第一个元素为0
。
expr match {
case List(0, _, _) => println("found it")
case _ =>
}
如果不指定List
对象的元素个数,可以使用_*
来表示,比如下面代码检查expr
是否为List
对象,并且第一个元素为0
。
expr match {
case List(0, _*) => println("found it")
case _ =>
}
6、元组模式
元组是Scala中的一种数据结构,下面这段代码匹配expr
变量是否为三元组形式。
def tupleDemo(expr: Any) =
expr match {
case (a, b, c) => println("matched " + a + b + c)
case _ =>
}
tupleDemo(("a ", 3, "-tuple"))
运行结果如下,
7、类型模式
(1)类型模式示例
类型模型的写法是变量名: 类名
。下面通过使用类型模式实现一个在Scala中通用的求长度的函数generalSize
,当x
是String
类型时,调用length
方法,当x
是Map
类型时,调用size
方法。
def generalSize(x: Any) = x match {
case s: String => s.length
case m: Map[_, _] => m.size
case _ => -1
}
generalSize("abc")
generalSize(Map(1 -> 'a', 2 -> 'b'))
generalSize(math.Pi)
运行结果如下,
上面代码中首先判断变量x
的类型,如果是String
类型,再将变量x
转化成String
类型的变量s
。在Scala中要判断一个对象expr
是否为String
类型,应该用如下代码expr.isInstanceOf[String]
,要将对象expr
转化成String
类型,使用如下代码expr.asInstanceOf[String]
,所以,上面的generalSize
方法,是可以用着两个InstanceOf
方法进行改写的,只不过改写后的代码更加复杂。
(2)类型擦除(Type erasure)
上面的类型模式示例中的Map
部分,其实只是匹配了该变量是否为Map
类型,并没有匹配其中的key和value的类型。如果同时需要匹配精确的key和value的类型的话,首先想到的是如下形式,下面代码中匹配key和value都是Int
类型的Map
,
def isIntIntMap(x: Any) = x match {
case m: Map[Int, Int] => true
case _ => false
}
观察一下运行结果,报出了一个warning,
Scala使用了泛型的类型擦除模式,即代码在运行时会将类型参数忽略掉。所以上面的代码在运行时并不能去判断当前Map
对象的key和value类型是否为Int
或其他类型。下面验证一下,
isIntIntMap(Map(1 -> 1))
isIntIntMap(Map("abc" -> "abc"))
运行结果都为true
,
所以,在Scala的类型匹配上,由于类型擦除的存在,是不能准确匹配Map
对象的key和value的类型的。
但是,可以指定Array
对象中元素的类型,如下所示
def isStringArray(x: Any) = x match {
case a: Array[String] => "yes"
case _ => "no"
}
val as = Array("abc")
isStringArray(as)
val ai = Array(1, 2, 3)
isStringArray(ai)
运行结果如下,
8、变量绑定
其实除了在变量模式中写入变量名之外,还可以在任何其他匹配模式中添加变量名。只不过需要按特定方式来指定,首先写一个变量名,然后写一个@
符号,最后写入该匹配模式。
比如下面代码,使用的是构造器模式,但是可以给构造参数指定一个变量名e
。
expr match {
case UnOp("abs", e @ UnOp("abs", _)) => e
case _ =>
}
三、模式守卫
模式守卫以一个匹配模式开头,后面紧接着一个if
表达式,守卫条件可以是任意的boolean
类型的表达式,这个表达式中可以使用匹配模式中的变量。
当模式匹配到某个匹配项,并且if
表达式的结果为true
,才能匹配成功。即模式守卫相当于在模式匹配的基础上再加一个判断条件。
那么模式守卫会在什么场景下使用呢?有时候上面的匹配模式仍然不够用。还是接着前面的计算表达式的例子往后,比如当遇到e + e
这种类型的表达式时,自动将其转化成2 * e
的形式。用上面的case class表示的话,
BinOp("+", Var("x"), Var("x"))
需要转化成
BinOp("*", Var("x"), Number(2))
使用模式匹配的话,可能会这么写
def simplifyAdd(e: Expr) = e match {
case BinOp("+", x, x) => BinOp("*", x, Number(2))
case _ => e
}
执行时会报错,如下所示。这是由于模式变量在一个匹配模式中只允许出现一次。
可以使用模式守卫来实现要求的功能,
def simplifyAdd(e: Expr) = e match {
case BinOp("+", x, y) if x == y => BinOp("*", x, Number(2))
case _ => e
}
结果如下,
四、模式重叠
待匹配的模式会按照match
后代码块中的书写顺序从上往下进行匹配。所以在这一部分想要表达的是,在写匹配条件时需要注意将匹配范围最小的写在最前面,避免匹配模式重叠的情况。
看下面这个例子,作用是对表达式进行简化。因为有时候一个表达式满足的简化条件可能不止一个,比如-(-(0 + e))
可以按照负负得正以及0
加一个变量为该变量本身这两个条件进行简化。
def simplifyAll(expr: Expr): Expr = expr match {
case UnOp("-", UnOp("-", e)) =>
simplifyAll(e)
case BinOp("+", e, Number(0)) =>
simplifyAll(e)
case BinOp("*", e, Number(1)) =>
simplifyAll(e)
case UnOp(op, e) =>
UnOp(op, simplifyAll(e))
case BinOp(op, l, r) =>
BinOp(op, simplifyAll(l), simplifyAll(r))
case _ => expr
}
simplifyAll
函数比前面的simplifyTop
多了两个匹配条件,第四个和第五个。当匹配到第四个和第五个时,会分别对除了操作符之外的分支进一步调用simplifyAll
函数进行化简。
如果按照如下代码的顺序来写匹配模式,我们仔细看一下,第一个匹配项已经包含了第二个匹配项,即使某个表达式完全满足第二个匹配项,也会被第一个匹配项捕获到,第二个匹配项永远不会匹配到。
def simplifyBad(expr: Expr): Expr = expr match {
case UnOp(op, e) => UnOp(op, simplifyBad(e))
case UnOp("-", UnOp("-", e)) => e
}
看一下运行结果,程序会报出一个warning提示第二个匹配项是unreachable的。
五、封闭类
1、封闭类概念和使用场景
封闭类(seled classes)除了拥有该类所在的文件中定义子类之外,无法在别处再定义新的子类。
Scala为什么要做这种限制?我们可以想一下,在写模式匹配时一般需要确保待匹配项能够匹配所有的场景,前面提到的通配符模式能够匹配到无法匹配的模式。但是使用通配符模式是由于我们知道对其他的模式可以有一种通用的处理方法。如果在模式匹配中不使用通配符来当做默认匹配项,应该如何确保待匹配项能够包含所有的可能性呢?
如果不对第一节中涉及到的四种基本表达式元素类,比如再实现一个第五种类型,对于原有的模式匹配,可能就会多出一种无法匹配的情况。使用封闭类,就可以将模式匹配限定在可控范围内,这样在写模式匹配的匹配项时,Scala编译器会提示匹配项是否完善。
2、封闭类示例
最好将需要进行模式匹配的类定义成封闭类的形式,封闭类的定义是在父类的类定义最前面加一个sealed
关键字。如下所示
sealed abstract class Expr
case class Var(name: String) extends Expr
case class Number(num: Double) extends Expr
case class UnOp(operator: String, arg: Expr) extends Expr
case class BinOp(operator: String, left: Expr, right: Expr) extends Expr
再尝试定义一个匹配项不全的模式匹配
def describe(e: Expr): String = e match {
case Number(_) => "a number"
case Var(_) => "a variable"
}
会看到如下warning信息,提示模式匹配不完善,该模式匹配会在遇到BinOp(_, _, _)
以及UnOp(_, _)
时失败。
3、封闭类的局限性及更合理使用方法
通过封闭类的形式,看到上面的提示信息,在多数情况下这都是很有用的。但是如果根据前面的代码,已经明确在describe
方法中不可能出现Number(_)
和Var(_)
之外的情况,但是编译器仍然给你提示这些信息时,就有些烦了。这种情况下,一种直观的写法是新增一个通配符模式匹配其余可能情况。
def describe(e: Expr): String = e match {
case Number(_) => "a number"
case Var(_) => "a variable"
case _ => throw new RuntimeException // 明确不会发生
}
从结果看一切正常,
在明明知道不可能出现第三种情况时,还需要在代码中额外增加一行逻辑,也会使代码比较冗余。在Scala中对这种情况提供了一个简便方式,在匹配变量处增加一个@unchecked
注解,这个注解可以使模式检查抑制掉,如下所示
def describe(e: Expr): String = (e: @unchecked) match {
case Number(_) => "a number"
case Var(_) => "a variable"
}
六、Option类型
对于一些不确定的值,Scala中还有一种Option
类型,这种类型的值主要有两种形式,一种是Some(x)
,这里面的x
是一个实际的变量值;另一种是None
对象,代表缺失的值。
Scala中对集合类型的数据进行一些操作经常会生成不确定的值。比如,Map
对象的get
方法,可能获取到指定key对应的value值,或者该key无对应value值会产生None
,如下所示,
val capitals = Map("France" -> "Paris", "Japan" -> "Tokyo")
capitals get "France"
capitals get "North Pole"
执行结果如下,
回想一下在Java中,如果Map
对象指定的key没有对应的value,则会得到一个null
值。不加判断的null
值在Java中很容易导致程序出现NullPointerException
的报错,有Java开发经验的应该会意识到Java中经常会看到很多判断null
值的逻辑。
在Scala中,使用Option
类型,有以下好处:
(1)对于有可能为null
的String
类型变量,使用Option[String]
类型可读性更强,表示这里可能会出现None
的情况
(2)使用Option[String]
类型的变量,如果直接调用String
类型提供的方法,在编译时就会报错,而不是像Java在执行时在遇到null
时才报错。
Option
类型经常用在模式匹配中,比如下面代码对None值进行了特殊处理,
def show(x: Option[String]) = x match {
case Some(s) => s
case None => "?"
}
show(capitals get "Japan")
show(capitals get "France")
show(capitals get "North Pole")
结果如下,
七、模式无处不在
在Scala中,模式不仅出现在match
表达式中,还会出现在别的场景,比如以下三种情况。
1、模式在变量定义中
在定义一个val
或者var
变量时,可以使用一个模式,而不仅是一个变量名。比如,用下面的形式可以将一个tuple
值分开,并将不同元素的值赋给不同的变量。
val myTuple = (123, "abc")
val (number, string) = myTuple
运行结果如下,
对于case classes,这种变量定义也使用的十分广泛,比如下面代码,明确知道exp
是一个BinOp
类型的变量,可以将该变量的各构造参数在一个表达式中直接分开赋给三个变量,
val exp = new BinOp("*", Number(5), Number(1))
val BinOp(op, left, right) = exp
运行结果如下,
2、用作部分应用函数的Case序列
case序列是一系列写在花括号中的case表达式。case序列本质上还是一个函数,只不过这个函数可以有多个函数入口和多个参数列表。
参考以下这个简单的例子,函数体有两个函数入口,每个函数入口=>
后面的是函数体的内容,
val withDefault: Option[Int] => Int = {
case Some(x) => x
case None => 0
}
withDefault(Some(10))
withDefault(None)
运行结果如下,
3、for
表达式中的模式
在for
表达式中也可以使用模式,比如下面这个例子,遍历前面定力的capitals
变量,将其中Map
元素的key赋值给country
变量,将value赋值给city
变量。
for ((country, city) <- capitals)
println("The capital of "+ country +" is "+ city)
运行结果如下,
上面这个遍历Map
的方法,不会出现匹配不上的情况,但是在某些情况下,还是可能会出现某个元素匹配不上的情况,比如
val results = List(Some("apple"), None, Some("orange"))
for (Some(fruit) <- results)
println(fruit)
运行结果如下,其中results
变量中的第二个元素为None
,在遍历时匹配不上Some
类型而过滤掉了,