Scala 中的 case class 和 pattern matching

摘要

本文將詳細介紹 Scala 中兩個非常重要的概念 case class 和 pattern matching,并通過具體的案例來說明兩者的具體用途。

1、從一個簡單的例子開始

慣例,先貼代碼,再詳細說明

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,和四個子類:Var(變量)、Number(數字)、UnOp(一元運算)、BinOp(二元運算),且每個子類使用 case 關鍵字修飾

case class

簡單的說,使用 case 修飾的 class 就是 case class,使用 case 會給我們帶來很多方便的地方:
1、自動為該類添加了一個和類名一樣的工廠方法,所以在實例化的時候我們可以直接寫成 Var("x"),不再寫成 new Var("x"),這種寫法在嵌套的情況下非常簡潔,例如:

val op = BinOp("+", Number(1.0), Var("x"))

2、所有 case class 的參數被隱式的添加上了 val 前綴,也就是說我們可以直接像這樣訪問參數

op.operator

3、編譯器添加了 toString、hashCode、equals 方法的實現

println(op)  //BinOp(+,Number(1.0),Var(x))

4、編譯器添加了一個 copy 方法,可以通過修改特定的參數生成一個新的 case class 實例

op.copy(operator = "-")  //BinOp = BinOp(-,Number(1.0),Var(x))

pattern matching

接下來我們要實現三種運算律:負負得正、任何數加 0 還是它本身、任何數乘以 1 還是它本身,即如下形式:

UnOp("-", UnOp("-", null)) => null // Double negation 
BinOp("+", null, Number(0)) => null // Adding zero 
BinOp("*", null, Number(1)) => null // Multiplying by one

具體的實現如下:

object Expr {
  def main(args: Array[String]): Unit = {
    val result = simplifyTop(UnOp("-", UnOp("-", Number(2.0))))
    println(result)  //Number(2.0)
  }
  
  def simplifyTop(expr: Expr): Expr = expr match {
    case UnOp("-", UnOp("-", e)) => e     // Double negation
    case BinOp("+", e, Number(0)) => e    // Adding zero
    case BinOp("*", e, Number(1)) => e    // Multiplying by one
    case _ => expr
  }
}

可以看出 Scala 中模式匹配(pattern matching)使用如下形式:

selector match { alternatives }

而 Java 中的 switch 形如:

switch (selector) { alternatives }

case 后面是匹配的某種情況,匹配的種類有很多,這里我們使用的是 Constructor patterns,=> 后面是匹配成功后執行的操作,但是不管執行什么操作返回的結果都必須是函數的返回值類型,這里是 Expr;最后一行 case _ 中的 _ 是通配符,表示匹配任何值,匹配成功后不執行任何操作,只是將 expr 返回

對比 Java 中的 switch,match 有三個不同點:第一、在 Scala 中 match 是一個表達式,這說明 match 是有返回值的;第二、只要匹配到一個,下面的語句就不會執行;第三、如果沒有匹配到任何項就會剖出一個名為 MatchError 的異常,所以要確保能夠匹配到值。

2、匹配的種類

Wildcard patterns

即通配符匹配 _ ,匹配所有的情況

Constant patterns

即字面量匹配,代碼說明一切:

scala> def describe(x: Any): Any = x match {
     | case 5 => "five"
     | case true => "truth!"
     | case "spark" => "the future!"
     | case Nil => "the empty list"
     | case _ => "something else"
     | }
describe: (x: Any)Any

測試結果:

scala> describe(5)
res10: Any = five

scala> describe(true)
res11: Any = truth!

scala> describe("spark")
res12: Any = the future!

scala> describe(Nil)
res13: Any = the empty list

scala> describe(Array(1,2,3,4,5,6))
res14: Any = something else

scala> describe(1)
res15: Any = something else

Variable patterns

variable patterns 跟通配符(_)類似可以匹配任何對象,跟通配符不同的是 Scala 會將需要匹配的對象傳遞給 case 后面的變量,測試如下:

scala> 1 match {
     | case v => "the variable is " + v
     | }
res19: String = the variable is 1

scala> "scala" match {
     | case v => "the variable is " + v
     | }
res20: String = the variable is scala

scala> case class Person(name: String)
defined class Person

scala> Person("Jack") match {
     | case v => "the variable is " + v
     | }
res21: String = the variable is Person(Jack)

另外需要注意的是,上例中的匹配不需要 case _,否則會報錯:

scala> 1 match {
     | case v => "the variable is " + v
     | case _ => "something else"
     | }

<console>:14: warning: unreachable code
       case _ => "something else"

因為永遠不可能匹配到 _
如果要匹配一個變量的值,需要使用“`”符號,例如:

scala> val pi = math.Pi
pi: Double = 3.141592653589793

scala> 1 match {
     | case `pi` => "success"
     | case _ => "fail"
     | }
res24: String = fail

*如果不使用“”符號,進行的就是 variable pattern,使用的話就是 constant pattern** *“”符號的另外一個作用就是將關鍵字變成標識符

Constructor patterns

也就是最開始的例子中使用的匹配模式:case BinOp("+", e, Number(0)) => e,首先要檢查這個對象是否為指定的 case class 的成員,然后檢查構造方法的參數是否符合額外匹配(deep matches:意味著進行深層次的匹配),例如上例中的 "+" 是 constant pattern,e 是 variable 匹配,而 Number(0) 又是一個 constructor pattern...

Sequence patterns

和 constructor patterns 類似,但是可以指定任意數量的元素,例如匹配以 0 開頭的總共有 3 個元素的 List

scala> List(0, 1, 2) match {
     | case List(0, _, _) => "success"
     | }
res26: String = success

匹配以 0 開頭任意元素的數組:

scala> Array(0, 1, 2, 3, 4, 5, 6) match{
     | case Array(0, _*) => "success"
     | }
res27: String = success
Tuple patterns

匹配 Tuple,Tuple 中的元素類型可以不同

scala> (1, "scala", true) match {
     | case (a, b, c) => "matched " + a + b + c
     | }
res30: String = matched 1scalatrue
Typed patterns

即類型匹配,例如:

scala> def generalSize(x: Any) = x match{
     | case s: String => s.length
     | case m: Map[_, _] => m.size
     | case _ => -1
     | }
generalSize: (x: Any)Int

scala> generalSize("abc")
res31: Int = 3

scala> generalSize(Map(1 -> 'a', 2 -> 'b'))
res32: Int = 2

scala> generalSize(123)
res33: Int = -1

也可以寫成如下形式:

if (x.isInstanceOf[String]) {
      val s = x.asInstanceOf[String]
      s.length
} else ...

其中 isInstanceOf 判斷是否為某種類型,而 x.asInstanceOf[String] 將 x 轉換成 String 類型
下面來看一個非常重要的概念:
類型擦除(Type erasure)
先看測試:

scala> def isIntIntMap(x: Any) = x match {
     |    case m: Map[Int, Int] => true
     |    case _ => false
     | }
<console>:12: warning: non-variable type argument Int in type pattern scala.collection.immutable.Map[Int,Int] (the underlying of Map[Int,Int]) is unchecked since it is eliminated by erasure
                  case m: Map[Int, Int] => true
                          ^
isIntIntMap: (x: Any)Boolean

warning 說的很清楚,在運行時范型會被擦出,所以不能判斷 Map 的具體元素的類型是否匹配,但是 Array 除外,因為數組存儲的時候將值和類型一起進行存儲:

scala> def isStringArray(x: Any) = x match{
     | case a: Array[String] => "success"
     | case _ => "failed"
     | }
isStringArray: (x: Any)String

scala> isStringArray(Array("scala", "spark"))
res36: String = success

scala> isStringArray(Array(1, 2))
res37: String = failed
Variable binding

直接看代碼:

scala> UnOp("abs", UnOp("abs", Number(1.0))) match {
     | case UnOp("abs", e @ UnOp("abs", _)) => e
     | case _ => "something else"
     | }
res40: java.io.Serializable = UnOp(abs,Number(1.0))

注意使用 @ 將 e 綁定到 UnOp("abs", _),所以最終的返回結果是 UnOp(abs,Number(1.0))

Pattern guard

類似于 for() 中可以使用 if 守衛,case 后面也可以使用,例如我們期望將 x + x 變成 x * 2,按照上面的例子我們會這樣寫

scala> def simplifyAdd(e: Expr) = e match {
     | case BinOp("+", x, x) => BinOp("*", x, Number(2))
     | }
<console>:17: error: x is already defined as value x
       case BinOp("+", x, x) => BinOp("*", x, Number(2))
                          ^

但是報錯:x 已經被定義過了,因為同一個 x 不能出現兩次,解決方法是使用兩個變量 x 和 y,并判斷 x 是否等于 y:

scala> def simplifyAdd(e: Expr) = e match {
     | case BinOp("+", x, y) if x==y => BinOp("*", x, Number(2))
     | case _ => "something else"
     | }
simplifyAdd: (e: Expr)java.io.Serializable

scala> simplifyAdd(BinOp("+", Number(1), Number(1)))
res1: java.io.Serializable = BinOp(*,Number(1.0),Number(2.0))

我們在 case 的后面加入了 if x==y,只有在滿足此條件才能匹配成功執行后面的操作,我們也可以匹配一個以 a 開頭的字符串:

scala> "apple" match{
     | case s: String if s(0) == 'a' => "the " + s + " is start with a "
     | case _ => "something else"
     | }
res3: String = "the apple is start with a "

3、匹配的順序

接下來看一下 case 的書寫順序:

def simplifyAll(expr: Expr): Expr = expr match {
  case UnOp("-", 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
}

我們來看一下上面代碼中第 1 個 case 和第 4 個 case 的順序,因為第 1 個的匹配要比第 4 個的匹配更為嚴格,所以需要放在第 4 個的前面,否則永遠不可能匹配到第 1 個的情況:

scala> def simplifyBad(expr: Expr): Expr = expr match {
     | case UnOp(op, e) => UnOp(op, simplifyBad(e))
     | case UnOp("-", UnOp("-", e)) => simplifyBad(e)
     | case _ => expr
     | }
<console>:16: warning: unreachable code
       case UnOp("-", UnOp("-", e)) => simplifyBad(e)

所以在書寫 case 時需要將更為嚴格的匹配放在前面

4、Sealed Class

如果想確保進行模式匹配的時候不會漏掉一些情況,可以使用 sealed 關鍵字修飾 class 如下所示:

sealed abstract class Expr
case class Var(name: String) extends Expr
case class Number(num: Double) extends Expr
case class UnOp(op: String, arg: Expr) extends Expr
case class BinOp(op: String, left: Expr, right: Expr) extends Expr

然后我們寫個函數測試一下:

scala> def describe(e: Expr): String = e match {
     |     case Number(_) => "a number"
     |     case Var(_)    => "a variable"
     | }

<console>:16: warning: match may not be exhaustive.
It would fail on the following inputs: BinOp(_, _, _), UnOp(_, _)
       def describe(e: Expr): String = e match {

可見交互式終端剖出一個 warning 提示 匹配不是完全的,比如 BinOp(_, _, _), UnOp(_, _) 就不能匹配到,由此可以看出如果使用 sealed 關鍵字修飾類,那么進行模式匹配的時候,編譯器會檢查匹配的是否全面。
但是有的時候我們根據上下文可以確定只有上面代碼中寫到的兩種結果,這時可以在 e 的后面加上 : @unchecked 防止編譯器對其進行檢查

scala> def describe(e: Expr): String = (e: @unchecked) match {
     |     case Number(_) => "a number"
     |     case Var(_)    => "a variable"
     | }
describe: (e: Expr)String

可以看出這時就不會有 warning 了,這里的 @unchecked 是 Annotations(注釋),這時我們不做展開說明

5、Option 類型

Option 代表一個可選值,有兩種情況 Some(x) 代表有值,None 代表沒有找到對應值,這里我們以 Map 為例進行說明

scala> val writeCode = Map("Spark" -> "Scala", "Hadoop" -> "Java")
writeCode: scala.collection.immutable.Map[String,String] = Map(Spark -> Scala, Hadoop -> Java)

scala> writeCode.get("Spark")
res1: Option[String] = Some(Scala)

scala> writeCode.get("Kafka")
res2: Option[String] = None

使用 get 獲取 Spark 返回的是 Some(Scala) 說明 writeCode 中有這個值,而 Kafka 不再里面,所以返回的是 None

最常用的方法就是通過模式匹配來獲得可選值,示例如下:

scala> def show(x: Option[String]) = x match {
     | case Some(s) => s
     | case None => "?"
     | }
show: (x: Option[String])String

scala> show(writeCode.get("Hadoop"))
res3: String = Java

scala> show(writeCode.get("Kafka"))
res4: String = ?

6、隨處可見的模式匹配

可以一次定義多個變量

例如:

scala> val (number, string) = (1, "abc")
number: Int = 1
string: String = abc

常用來接收函數的返回值,例如 Spark 源碼 SparkContext 中的如下部分

val (sched, ts) = SparkContext.createTaskScheduler(this, master)

同樣可以這樣使用:

scala> val BinOp(op, left, right) = BinOp("+", Number(1), Number(2))
op: String = +
left: Expr = Number(1.0)
right: Expr = Number(2.0)
一系列的 case 可以作為函數的一部分

例如:

scala> val withDefault: Option[Int] => Int = {
     | case Some(x) => x
     | case None => 0
     | }
withDefault: Option[Int] => Int = <function1>

scala> withDefault(Some(100))
res7: Int = 100

scala> withDefault(None)
res8: Int = 0

可以看出 case 后面的部分其實是作為函數 withDefault 的函數體,這種寫法在消息通信中非常有用,例如 Spark 源碼 Worker 中的如下部分:

override def receive: PartialFunction[Any, Unit] = synchronized {
    case SendHeartbeat =>
      if (connected) { sendToMaster(Heartbeat(workerId, self)) }

    case WorkDirCleanup =>
    ...

Worker 接收到消息后會判斷是什么消息,然后針對每種消息進行具體的操作

for 語句中使用模式匹配

例如我們要遍歷上面例子中的 Map 類型的 writeCode,代碼如下:

scala> for( (architecture, writeCode) <- writeCode )
     | println("Architecture: " + architecture + " writeCode: " + writeCode)
Architecture: Spark writeCode: Scala
Architecture: Hadoop writeCode: Java

再來看另外一個例子:

scala> val results = List(Some("apple"), None, Some("orange"))
results: List[Option[String]] = List(Some(apple), None, Some(orange))

scala> for( Some(fruit) <- results) println(fruit)
apple
orange

總結

本文詳細闡述了 Scala 中的 case class 和 pattern matching,使我們更加便捷的編寫代碼,但是 Scala 中的模式匹配遠遠不止這么簡單,需要了解的可以研究 Scala 中的 Extractors。

本文參照:Programming in Scala, 3rd Edition 中的 Chapter 15:Case Class and Pattern Matching

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,505評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,556評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,463評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,009評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,778評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,218評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,281評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,436評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,969評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,795評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,993評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,537評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,229評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,659評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,917評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,687評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,990評論 2 374

推薦閱讀更多精彩內容