Scala入门之四:模式匹配和条件类

本文介绍了Scala的模式匹配。模式匹配的基本思想就是试图对一个值进行多种模式的匹配,并且在匹配的同时将匹配值拆分成若干子项,最后对匹配值与其子项执行某些代码。

本文源自Michel Schinz和Philipp Haller所写的A Scala Tutorial for Java programmers,由Bearice成中文。第一篇为Scala简单做了一下入门,第二篇描述Scala对象,第三篇对Scala类做了一些介绍。这一篇将介绍Scala中的一个重要特性:Scala的模式匹配。

6 Scala的模式匹配和条件类

树是在程序中常用的一个数据结构。例如编译器和解析器常常吧程序表示为树;XML文档结构也是树状的;还有一些集合是基于树的,例如红黑树。

接下来我们将通过一个计算器程序来研究树在Scala中是如何表示和操纵的。这个程序的目标是处理一些由整数常量、变量和加号组成的简单的算数表达式,例如1 + 2 和 (x + x ) + (7 + y )。

我们首先要决定如何表示这些表达式。最自然的方法就是树了,树的节点表示操作符(在这里只有加法),而树的叶节点表示值(这里表示常数和变量)。 在Java中,这样的树可以表示为一个超类的树的集合,节点由不同子类的实例表示。而在函数式语言中,我们可以使用代数类型(algebraic data-type)来达到同样的目的。Scala提供了一种介于两者之间的叫做条件类(Case Classes)的东西。

1
2
3
4
abstract class Tree
case class Sum(l: Tree, r: Tree) extends Tree
case class Var(n: String) extends Tree
case class Const(v: Int) extends Tree

我们实际上定义了三个条件类 Sum ,Var 和 Const 。这些类和普通类有若干不同:

  1. 实例化时可以省略new关键字(例如你可以使用 Const(5)而不必使用 new Const(5) )
  2. 参数的getter函数自动定义(例如你可以通过c.v来访问类Const的实例c在实例化时获取的参数v)
  3. 拥有默认的预定义equals和hashCode实现,这些实现可以按照值区别类实例是否相等,而不是通过用。
  4. 拥有默认的toString实现。这些实现返回值的代码实现(例如表达式x+1可以被表达成Sum(Var(x),Const(1)))
  5. 条件类的实例可以通过模式匹配进行分析,我们接下来就要讲这个特性。

现在我们已经定义了表示我们算数表达式的数据类型,于是我们可以开始给他们定义对应的操作。我们将会首先编写一个在上下文中下计算表达式的函数。这里的上下文指的是变量与值的绑定关系。例如表达式x+1在x=5上下文中应该得出结果6。

这样一来我们需要找到一个表示这种绑定关系的方法。当然我们可以使用某种类似hash-table的数据结构,不过我们也可以直接使用函数!一个上下文无非就是一个吧名称映射到值的函数。例如上面给出的{x → 5}的这个映射我们就可以在Scala中表示为:

> { case “x” => 5 }

这个定义了一个函数:当参数等于字符串”x” 时返回整数5,否则抛出异常。

在编写求值函数之前我们,我们需要给我们的上下文起个名字,以便在后面的代码里面引用。理所应当的我们使用了类型String=>Int,但是如果我们给这个类型起个名字,将会让程序更加简单易读,而且更加容易维护。在scala中,这件事情可以通过以下代码完成:

1
type Environment = String => Int

从现在开始,类型Environment就当作String到Int的函数类型名来使用了。

现在我们可以开始定义求值函数了。从概念上来说,这是很简单的一个过程:两个表达式之和等于两个表达式分别求值后再求和;变量的值可以从上下文中提取;常量的值就是他本身。在Scala中表达这个没有什么难度:

1
2
3
4
5
def eval(t: Tree, env: Environment): Int = t match {
case Sum(l, r) => eval(l, env) + eval(r, env)
case Var(n) => env(n)
case Const(v) => v
}

求值函数通过对树t进行模式匹配来完成工作。直观的来看,上述代码的思路是十分清晰的:

  1. 第一个模式检查传入的树的根节点是否是一个Sum,如果是,它将会吧树的左边子树赋值给l,右边的子树赋值给r,然后按照箭头后面的代码进行处理;这里的代码可以(并且的确)使用了在左边匹配时所绑定的变量,比如这里的l和r。
  2. 如果第一个检查没有成功,表明传入的树不是Sum,程序继续检查他是不是一个Var;如果是,则吧变量名赋给n然后继续右边的操作。
  3. 如果第二个检查也失败了,表示t既不是Sum也不是Var,程序检查他是不是Const。如果是着赋值变量并且继续。
  4. 最后,如果所有检查都失败了。就抛出一个异常表示模式匹配失败。这只有在Tree的其他之类被定义时才可能发生。

我们可以看出模式匹配的基本思想就是试图对一个值进行多种模式的匹配,并且在匹配的同时将匹配值拆分成若干子项,最后对匹配值与其子项执行某些代码。

一个熟练的面向对象的程序员可能想知道为什么我们不吧eval定义为Tree或者其之类的成员函数。我们事实上可以这么做。因为Scala允许条件类象普通类那样定义成员。决定是否使用模式匹配或者成员函数取决于程序员的喜好,不过这个取舍还和可扩展性有重要联系:

  1. 当你使用成员函数时,你可以通过继承Tree从而很容易的添加新的节点类型,但是另外一方面,添加新的操作也是很繁杂的工作,因为你不得不修改Tree的所有子类。
  2. 当你使用模式匹配是,形势正好逆转过来,添加新的节点类型要求你修改所有的对树使用模式匹配的函数,但是另一方面,添加一个新的操作只需要再添加一个模式匹配函数就可以了。

下面我们来更详细的了解模式匹配,让我们再给表达式定义一个操作:对符号求导数。读者们也许想先记住下面关于此操作的若干规则:

  1. 和的导数等于导数的和,
  2. 如果符号等以求导的符号,则导数为1,否则为0.
  3. 参数的导数永远为0。

上述规则可以直接翻译成Scala代码:

1
2
3
4
5
def derive(t: Tree, v: String): Tree = t match {
case Sum(l, r) => Sum(derive(l, v), derive(r, v))
case Var(n) if (v == n) => Const(1)
case _ => Const(0)
}

这个函数使用了两个关于模式匹配的功能,首先case语句可以拥有一个guard子句:一个if条件表达式。除非guard的条件成立,否则该模式不会成功匹配。其次是通配符:_ 。这个模式表示和所有值匹配而不对任何变量赋值。

事实上我们还远没有触及模式匹配的全部精髓。但是我们限于篇幅原因不得不再此停笔了。下面我们看看这个两个函数是如何在一个实例上运行的。为了达到这个目前我们写了一个简单的main函数来对表达式(x + x ) + (7 + y )进行若干操作:首先计算当{x → 5, y → 7}时表达式的值,然后分别对x和y求导。

1
2
3
4
5
6
7
8
def main(args: Array[String]) {
val exp: Tree = Sum(Sum(Var("x"),Var("x")),Sum(Const(7),Var("y")))
val env: Environment = { case "x" => 5 case "y" => 7 }
println("Expression: " + exp)
println("Evaluation with x=5, y=7: " + eval(exp, env))
println("Derivative relative to x:\n " + derive(exp, "x"))
println("Derivative relative to y:\n " + derive(exp, "y"))
}

执行程序,我们能得到以下输出:

1
2
3
Expression: Sum(Sum(Var(x),Var(x)),Sum(Const(7),Var(y))) Evaluation with x=5, y=7: 24
Derivative relative to x: Sum(Sum(Const(1),Const(1)),Sum(Const(0),Const(0)))
Derivative relative to y: Sum(Sum(Const(0),Const(0)),Sum(Const(0),Const(1)))

通过研究程序输出,我们能看到求导的输出可以在被打印之前简化,使用模式匹配定义一个简化函数是挺有意思的(不过也需要一定的技巧)工作。读者可以尝试自己完成这个函数。

看到这里,希望大家对Scala的模式匹配有了一个大概的理解。下面一篇将介绍Scala Trait。