Programming In Scala笔记-第十五章、Case Classes和模式匹配

  本章主要分析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")

  结果如下,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

  使用case classes在生成新的对象时可以使代码变得更加简洁。

  再看一下BinOp类的使用

val op = BinOp("+", Number(1), v)

  结果如下,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

(2)参数列表中的所有参数其实都对应一个val变量

  可以使用以下代码中的方式访问参数列表中的val变量

v.name
op.left

  运行结果如下,可以直接访问对象v和对象op中的属性。

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

(3)编译器实现默认的toString, hashCode, equals方法

  编译器会自动为Case classes实现三个方法,

println(op)
op.right == Var("x")

  运行结果如下,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

(4)编译器为case classes实现一个copy方法

  使用该copy方法,可以复制指定对象,并且可以改变被复制对象的部分参数属性,下面代码将复制一个op变量,但是将其中的+改变成-

op.copy(operator = "-")

  结果如下,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

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")))进行处理,得到的结果,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

  在模式匹配中一般包含一系列的匹配条件,每个条件以一个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))

  运行结果如下,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

3、变量模式

(1)变量模式

  变量匹配类似于通配符匹配,可以匹配任何对象。和通配符模型不相同的地方在于,会将匹配到的内容赋值给该变量名,可以在=>后面的代码中使用到,比如下面这段代码

val expr = 5

expr match {
case 0 => "zero"
case somethingElse => "not zero: "+ somethingElse
}

  运行结果如下,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

(2)比较变量模式和常量模式

  比较一下上面这段代码中的somethingElse变量名,以及常量模式中的Nil单例对象,发现变量模式和常量模式在表现形式上还是有些类似的。接下来对这两者加以区分。

import math.{E, Pi}

E match {
case Pi => "strange math? Pi = " + Pi
case _ => "OK"
}

  运行结果如下,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

  常量E匹配不到常量Pi,这是正常的。可是编译器怎么知道Pi代表的是math.Pi而不是变量名为Pi的一个变量呢?在这种情况下,Scala编译器将以小写字母开头的匹配项当做一个变量名,所以Pi被当成了一个常量。

  看一下以下代码,将常量Pi赋值给一个变量pi,然后进行匹配

val pi = math.Pi

E match {
case pi => "strange math? Pi = " + pi
}

  运行结果如下,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

  可以看到,在这里Scala编译器将pi当成了一个变量名,所以这里的匹配模式就是变量模式。

(3)变量模式和通配符模式的冲突

  在变量模式的情况下,在匹配的最后不能再写一个通配符匹配,否则会报错,如下

E match {
case pi => "strange math? Pi = "+ pi
case _ => "OK"
}

  运行结果,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

  如果非要既使用变量匹配,又写一个通配符匹配的话,还有两个办法,

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、序列模式

  序列模式是说可以用来匹配ListArray类型。

  比如下面代码检查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"))

  运行结果如下,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

7、类型模式

(1)类型模式示例

  类型模型的写法是变量名: 类名。下面通过使用类型模式实现一个在Scala中通用的求长度的函数generalSize,当xString类型时,调用length方法,当xMap类型时,调用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)

  运行结果如下,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

  上面代码中首先判断变量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,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

  Scala使用了泛型的类型擦除模式,即代码在运行时会将类型参数忽略掉。所以上面的代码在运行时并不能去判断当前Map对象的key和value类型是否为Int或其他类型。下面验证一下,

isIntIntMap(Map(1 -> 1))
isIntIntMap(Map("abc" -> "abc"))

  运行结果都为true

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

  所以,在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)

  运行结果如下,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

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
}

  执行时会报错,如下所示。这是由于模式变量在一个匹配模式中只允许出现一次。

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

  可以使用模式守卫来实现要求的功能,

def simplifyAdd(e: Expr) = e match {
case BinOp("+", x, y) if x == y => BinOp("*", x, Number(2))
case _ => e
}

  结果如下,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

四、模式重叠

  待匹配的模式会按照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的。

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

五、封闭类

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(_, _)时失败。

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

3、封闭类的局限性及更合理使用方法

  通过封闭类的形式,看到上面的提示信息,在多数情况下这都是很有用的。但是如果根据前面的代码,已经明确在describe方法中不可能出现Number(_)Var(_)之外的情况,但是编译器仍然给你提示这些信息时,就有些烦了。这种情况下,一种直观的写法是新增一个通配符模式匹配其余可能情况。

def describe(e: Expr): String = e match {
case Number(_) => "a number"
case Var(_) => "a variable"
case _ => throw new RuntimeException // 明确不会发生
}

  从结果看一切正常,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

  在明明知道不可能出现第三种情况时,还需要在代码中额外增加一行逻辑,也会使代码比较冗余。在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"

  执行结果如下,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

  回想一下在Java中,如果Map对象指定的key没有对应的value,则会得到一个null值。不加判断的null值在Java中很容易导致程序出现NullPointerException的报错,有Java开发经验的应该会意识到Java中经常会看到很多判断null值的逻辑。

  在Scala中,使用Option类型,有以下好处:

(1)对于有可能为nullString类型变量,使用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")

  结果如下,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

七、模式无处不在

  在Scala中,模式不仅出现在match表达式中,还会出现在别的场景,比如以下三种情况。

1、模式在变量定义中

  在定义一个val或者var变量时,可以使用一个模式,而不仅是一个变量名。比如,用下面的形式可以将一个tuple值分开,并将不同元素的值赋给不同的变量。

val myTuple = (123, "abc")
val (number, string) = myTuple

  运行结果如下,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

  对于case classes,这种变量定义也使用的十分广泛,比如下面代码,明确知道exp是一个BinOp类型的变量,可以将该变量的各构造参数在一个表达式中直接分开赋给三个变量,

val exp = new BinOp("*", Number(5), Number(1))
val BinOp(op, left, right) = exp

  运行结果如下,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

2、用作部分应用函数的Case序列

  case序列是一系列写在花括号中的case表达式。case序列本质上还是一个函数,只不过这个函数可以有多个函数入口和多个参数列表。

  参考以下这个简单的例子,函数体有两个函数入口,每个函数入口=>后面的是函数体的内容,

val withDefault: Option[Int] => Int = {
case Some(x) => x
case None => 0
} withDefault(Some(10))
withDefault(None)

  运行结果如下,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

3、for表达式中的模式

  在for表达式中也可以使用模式,比如下面这个例子,遍历前面定力的capitals变量,将其中Map元素的key赋值给country变量,将value赋值给city变量。

for ((country, city) <- capitals)
println("The capital of "+ country +" is "+ city)

  运行结果如下,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

  上面这个遍历Map的方法,不会出现匹配不上的情况,但是在某些情况下,还是可能会出现某个元素匹配不上的情况,比如

val results = List(Some("apple"), None, Some("orange"))
for (Some(fruit) <- results)
println(fruit)

  运行结果如下,其中results变量中的第二个元素为None,在遍历时匹配不上Some类型而过滤掉了,

  Programming In Scala笔记-第十五章、Case Classes和模式匹配

上一篇:2018-11-16 中文代码示例之Programming in Scala笔记第四五六章


下一篇:python 安装模块 更新模块