Scala 的模式匹配

2022-07-19 13:47:23 浏览数 (2)

最近开始学习 Scala,相较于学习 Haskell 的过程来看,Scala 真是直观得多,友好得多,更容易上手。以前写过关于从熟悉的 Java 和 JavaScript 来逐步学习 Groovy 和 Haskell 的文章,这以后再来学习 Scala 的话,就可以不断比较了。如果和我一样有 Java 经验的话但是从来没有接触过 Scala 的话,建议先阅读这篇文章,A Scala Tutorial for Java Programmers,一边比较,一边熟悉,同时配套的还有这个,Scala for Java programmers – Joakim Ohlrogge & Enno Runne,Youtube 上的视频,很直观,然后再从 Scala 官网的文档上面逐步涉入。

这里的模式匹配可能是历经函数式编程才引入的概念,是广泛存在于编程语言函数使用中的,而并非以前接触的 “正则表达式” 这样仅仅用于字符串处理的特性。在此之前,先来看看 Haskell 中的模式匹配,我在这里曾经举过这个阶乘的例子:

代码语言:javascript复制
factorial :: (Integral a) => a -> a  
factorial 0 = 1 
factorial n = n * factorial (n - 1)

根本不需要多余的解释,一眼就看懂。模式匹配在这里起到了 if-else 的作用,对于逻辑的执行,起到了一个 “变化点” 的作用。在以往传统的静态语言中,要在程序中植入 “变化点”,要么就是 if-else 语句(本质上 switch-case 和使用 Map 去寻找匹配的 value 也属于 if-else),要么就是多态,要么就是方法重载。现在我们看到了一个根据参数改变程序执行逻辑步骤的新武器。虽然说,这个例子可以说和使用 if-else 相比,似乎没有太大的区别,但是在存在不同的参数组合情况的时候,这个写法的优势就体现出来了:

代码语言:javascript复制
translate :: String -> String
translate ('$':x) = "Dollar: "    x
translate (_:x) = "Unknown: "    x

其中的下划线 “_” 就是通配符,这种写法上的 pattern 很像带有 default 语句的 switch-case,最后一个通配符保证了不会有异常抛出,所有 case 都被涵盖。

再挪到 Scala 里面看模式匹配,上面的情况也都能够支持。模式匹配可不一定只作用在单个参数作为整体来实现匹配,参数还可以拆分,比如说:

代码语言:javascript复制
List(1,2,3) match{ case List(_,_,3) => println("ok") }

这就是忽略了前两个参数,直接比对第三个参数是否为 3。当然,除了上面的情形,模式匹配还可以匹配参数的类型。

不止作用在参数的级别上,还可以作用在类和对象的级别上,比如 Scala 官网首页上面的这个例子:

代码语言:javascript复制
// Define a set of case classes for representing binary trees.
sealed abstract class Tree
  case class Node(elem: Int, left: Tree, right: Tree) extends Tree
  case object Leaf extends Tree
// Return the in-order traversal sequence of a given tree.
def inOrder(t: Tree): List[Int] = t match {
  case Node(e, l, r) => inOrder(l) ::: List(e) ::: inOrder(r)
  case Leaf          => List()
}

Tree 本身可以有两种类型的实现,一种是 Node,它是个类,接受本身的值、左子树、右子树这三个构造参数;另一种是 Leaf,就是一个叶子实例(不是类)。那么在实现中序遍历的 inOrder 方法的时候,如果是分支节点,那么就递归执行中序遍历的方法(左子树-> 节点自己-> 右子树),然后把着三个结果 List 拼接起来;否则对于叶子节点,就创建一个空的 List。

在我们的印象中,传统语言的多态实现,一定是基于 “类和对象” 的,换言之,在运行时才能确定执行某一个接口(或者抽象类)方法的实体到底是谁(哪个对象)。但是在这里的模式匹配上,这个变化点被移到了函数(或者说方法)上,看起来实现的功能是类似的,但是二者各有优劣:

  • 如果使用传统的多态方式,思维基于类和对象,方法只是某一类或对象的附庸,方法本身单独存在并无意义,因此如果增加了某一个新的实现类,那么我需要把这个新实现类中需要重载/实现接口(或抽象类)的放的所有方法全部实现一遍,而这些增加的方法都是集中在这个新增的类/对象里的。比如说,如果写 Java 代码去实现上述类似的功能,我可以定义一个接口 Tree,内有方法 inOrder,然后再分别定义实现类 Node 和 Leaf,去实现这个接口。这种方式对于新增一个类的时候,显得直观、内聚,所有的代码都在新增加的那个类里面,符合了开闭原则。但是,如果是要在接口中新增一个方法的话,就完蛋了,就是所谓的 “要改接口”,还得把所有的子类实现全部修改一遍。在 Java 8 中,为了 Lambda 表达式这个特性,给一些以往所谓的纯粹的、不含逻辑的接口,引入了 “函数接口” 的概念——被允许存在 “一个非 java.lang.Object 中定义过的抽象的方法”,这个看起来有点像抽自己脸的行为(最初对 “接口” 这个概念的定义,是要求它 “纯粹”,没有任何方法实现),正是由于上面说的这个原因造成的——接口不具备开放修改的能力,如今要在接口中增加一个默认行为,又要保持向后兼容性,还没有 Trait 之类的嫁接别处功能的特性,就只能用这种奇怪的路子来实现了。
  • 相反,模式匹配使得关注的核心点变成了函数本身,函数变成了一等公民,它可以脱离类和对象的附庸而独立存在了。如果要增加某一类或者对象,就变成了特别麻烦的事情,要修改现有的所有相关函数,增加一个 case 分支;但如果要给某一类类和对象增加一个方法,只需要修改一处即可(上面例子中,如果我想增加先序遍历的逻辑,只需要实现 “preOrder” 一个函数即可),而这个增加的函数内部是内聚的,增加这个修改符合开闭原则。因此,二者各有利弊,要看设计和使用场景。

上面的这些模式匹配方式组合起来,可以执行一些复杂的匹配,比如基于构造器:

代码语言:javascript复制
case Node(_, Node(1,_,_), Node(2,_,_))

这样的,是要求构造器的三个参数中,左子树参数的值是 1,右子树参数是 2。

甚至可以这样:

代码语言:javascript复制
case Node(_, nodeToReturn@Node(1,_,_), Node(1,_,_)) => nodeToReturn

表示碰到这个 case 的时候,返回构造器的第二个参数。

文章未经特殊标明皆为本人原创,未经许可不得用于任何商业用途,转载请保持完整性并注明来源链接 《四火的唠叨》

0 人点赞