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")

  結果如下,
  這裏寫圖片描述

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

  序列模式是說可以用來匹配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"))

  運行結果如下,
  這裏寫圖片描述

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)

  運行結果如下,
  這裏寫圖片描述

  上面代碼中首先判斷變量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)對於有可能爲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")

  結果如下,
  這裏寫圖片描述

七、模式無處不在

  在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類型而過濾掉了,
  這裏寫圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章