譯者注:原文出處http://danielwestheide.com/blog/2012/11/21/the-neophytes-guide-to-scala-part-1-extractors.html,翻譯:Thomas
有超過5萬位學員(譯者注:現在已經大大超過這個數了)報名參加了 Martin Odersky’s的 “Functional Programming Principles in Scala” 課程. 考慮到有不少開發者是第一次接觸Scala或者函數式程式設計,這是一個很大的人數。
正在讀此文章的你也許是其中一員吧,又或者你已經開始學習Scala。不管怎樣,隻要你開始學習了Scala,你就會因深入研究這門優雅的語言而興奮,當然你可能還是會對Scala有點感覺有點陌生或者迷糊,那麼以此篇開始的系列文章就是為你而準備的。
即使Coursera的課程已經覆寫了很多關于Scala的必要知識,但受限于時間,它無法将所有知識點展開太細。結果對于初學者來說,有些Scala的特性看上去就像是魔術。你仍然可以去用這些特性,但是并沒有知道是以然:他們是如何工作的,為什麼要設計成這樣。
在這個系列文章中,我将為你解開謎底以消除你大腦中的問号。我還會就我個人學習scala時苦于找不到好的文章講解而磕磕絆絆難以掌握的Scala特性和類庫加以解釋。某些場景下,我還會試着給你一些如何以符合語言習慣的使用這些特性的指點。
前言就這麼多。在開始之前還請注意到參加Coursera課程并不是讀這個系列文章的前提條件(譯者注:當然,Scala的基本文法還是應該很熟悉的,至少要能看懂Scala代碼,最好有實際開發經驗),當然從該課程裡擷取的Scala知識對看懂文章還是有幫助的,我也會時不時的引用到課程。
--------------------------------
神奇的模式比對是如何工作的?
在Coursera的課程中,你接觸到了Scala的一個非常強大的特性:模式比對。它讓你可以分解給定的資料結構,綁定構造的數值到變量。這并不是Scala獨有的,它在其它一些語言中也發揮着重要價值,如Haskell,Erlang。
在視訊教程中你注意到模式比對可以用來分解很多類型的資料結構,包含list,stream和case class的任何執行個體。那麼可以被分解的資料類型是固定數量的嗎?換句話說,我們可以讓自定義的資料類型也能被模型比對嗎?首先的問題應該是,它是如何工作的?你可以像下面的例子一樣爽爽的用模式比對是為什麼呢?
case class User(firstName: String, lastName: String, score: Int)
def advance(xs: List[User]) = xs match {
case User(_, _, score1) :: User(_, _, score2) :: _ => score1 - score2
case _ => 0
}
當然這裡沒啥神奇的魔術,至少沒那麼多魔法。 上面那樣的代碼(不要去在意這段代碼的具體價值)之是以行得通是因為有個叫做提取器(Extractor)的東東存在。
在最常用的場景下,提取器是構造器的反操作:構造器根據參數清單構造一個對象執行個體,而提取器從一個現有對象執行個體中将構造時的參數提取出來。
Scala類庫裡自帶了一些提取器,等下我們來看一些。Case class有一點特别,因為Scala為每個case class自動建構一個夥伴對象(companion object),夥伴對象是一個單例對象,同時包含建構夥伴類執行個體的apply方法以及一個unapply方法,一個對象想要成為一個提取器就需要實作此方法。
我的第一個提取器,好興奮耶!
一個合法的unapply方法可以有很多種形式,我們從最常用的形式開始講起。假定我們的User(前述代碼中)類不再是一個case class,而是一個trait,有兩個類實作它,并且它隻包含一個字段:
trait User {
def name: String
}
class FreeUser(val name: String) extends User
class PremiumUser(val name: String) extends User
我們想要在FreeUser和PremiumUser的夥伴對象中實作各自的提取器,類似Scala為case class做的一樣。如果提取器僅從給定對象執行個體中提取單個屬性,那麼unapply的形式會是這樣的:
def unapply(object: S): Option[T]
這方法需要一個類型S的對象參數,傳回一個T類型Option,T是要提取的參數的類型。Option是Scala裡對null值的安全表達方式(譯者注:也更優雅),後面會有專門篇幅講解,目前你隻需要知道這個unapply方法傳回為Some[T](如果能夠成功從對象中提取出參數)或者None,None表示參數不能夠被提取,由提取器的實作來制定是否能被提取的規則。
下面是提取器的實作:
trait User {
def name: String
}
class FreeUser(val name: String) extends User
class PremiumUser(val name: String) extends User
object FreeUser {
def unapply(user: FreeUser): Option[String] = Some(user.name)
}
object PremiumUser {
def unapply(user: PremiumUser): Option[String] = Some(user.name)
}
我們可以在REPL裡測試下:
scala> FreeUser.unapply(new FreeUser("Daniel"))
res0: Option[String] = Some(Daniel)
當然通常我們不直接呼叫unapply方法。 當用于提取模式場景時,Scala會幫我們呼叫提取器的unapply方法。
如果unapply傳回了Some[T],意味着模式比對了,提取出的值将被賦予模式中定義的變量。如果傳回None,意味着模式不比對,将會進入下一個case語句。
下面的方式将我們的提取器用于模式比對:
val user: User = new PremiumUser("Daniel")
user match {
case FreeUser(name) => "Hello " + name
case PremiumUser(name) => "Welcome back, dear " + name
}
你可能已經注意到,我們定義的兩個提取器從不傳回None。在這樣的場景下倒不算是缺陷,因為如果你的對象是屬于某個資料類型,你可以同時做類型檢查和提取。就像上面的例子,因為user為PremiumUser類型,是以FreeUser的case子句将不會被比對,将不會呼叫FreeUser的提取器,相應的第二個case将會比對,user執行個體會傳遞給PremiumUser的夥伴對象(提取器)的unapply方法,該方法傳回name的值。
我們會在本章節的後面看到不總是傳回Some[T]類型的提取器。
提取多個值
假設我們想要進行比對的類有多個屬性:
trait User {
def name: String
def score: Int
}
class FreeUser(val name: String, val score: Int, val upgradeProbability: Double)
extends User
class PremiumUser(val name: String, val score: Int) extends User
如果提取器要能夠解構給定的資料到多個參數,提取器的unapply方法應該是這樣的類型:
def unapply(object: S): Option[(T1, ..., Tn)]
這方法需要一個類型為S的參數,傳回一個TupleN的Option類型,N(譯者注:從1到22)是提取的參數數量。 我們來修改下提取器以适用到修改後的類:
trait User {
def name: String
def score: Int
}
class FreeUser(val name: String, val score: Int, val upgradeProbability: Double)
extends User
class PremiumUser(val name: String, val score: Int) extends User
object FreeUser {
def unapply(user: FreeUser): Option[(String, Int, Double)] =
Some((user.name, user.score, user.upgradeProbability))
}
object PremiumUser {
def unapply(user: PremiumUser): Option[(String, Int)] = Some((user.name, user.score))
}
我們可以将這新的提取器用于模式比對,就和之前的比對例子差不多:
val user: User = new FreeUser("Daniel", 3000, 0.7d)
user match {
case FreeUser(name, _, p) =>
if (p > 0.75) name + ", what can we do for you today?" else "Hello " + name
case PremiumUser(name, _) => "Welcome back, dear " + name
}
Boolean型提取器
有時候你并不想要從比對的資料中提取參數,你隻想要做一個簡單的boolean檢查。在這樣的場景下,第三種unapply的形式就可以幫到你了,這個unapply需要一個S類型的參數并傳回一個Boolean型結果:
def unapply(object: S): Boolean
在模式比對時,如果提取器傳回true,則比對到,否則繼續下一個比對測試。
在前面的例子中,我們在模式比對時插入了一個判斷邏輯來确定一個FreeUser是否有潛在的可能更新賬号。我們接下來把這段邏輯放到提取器裡去:
object premiumCandidate {
def unapply(user: FreeUser): Boolean = user.upgradeProbability > 0.75
}
就像你看到的,提取器不必一定定義在類的夥伴對象中。下面的例子告訴你如何使用這個boolean型提取器:
val user: User = new FreeUser("Daniel", 2500, 0.8d)
user match {
case freeUser @ premiumCandidate() => initiateSpamProgram(freeUser)
case _ => sendRegularNewsletter(user)
}
在這個例子中,我們傳遞了空的參數給提取器,因為你不需要提取任何參數并指派到變量。
這例子裡看起來還有點奇怪:我假定虛構的initiateSpamProgram函數需要傳入一個FreeUser執行個體作為參數因為我不想給付費使用者(PremiumUser)發送廣告。因為user是User類型的資料,在沒有模式比對時,我非得用難看的類型轉換才能将user傳遞給initiateSpamProgram函數。
好在Scala的模式比對支援将比對的變量賦給一個新變量,新變量的類型和比對到的提取器所期待的資料類型一緻。通過@符号來指派。我們的premiumCandidate期待一個FreeUser類型的執行個體作為參數,那麼freeUser就會是一個滿足比對條件的FreeUser執行個體。
我個人不是很常用boolean型提取器,不過知道有這樣的用法還是必須的,說不定你什麼時候就會發現這種用法的好處。
中間操作符模式
如果你參加了Coursera的那個Scala課程,你應該還記得你可以和構造一個list或Stream類似的方式用連接配接操作符(list用::,stream用#::)來解構它們:
val xs = 58 #:: 43 #:: 93 #:: Stream.empty
xs match {
case first #:: second #:: _ => first - second
case _ => -1
}
你是否好奇為啥可以這麼整呢。答案是我們目前所看到的提取模式比對的文法有一個替代寫法,Scala可以把提取器當成中間操作符。
是以,像e(p1,p2)的中,e為提取器,p1,p2是從給定參數中提取出的兩個值,你可以寫成p1 e p2。
是以,上面代碼中,中間操作模式head #:: tail可以寫成#::(head, tail),PremiumUser提取器也可以被這樣使用name PremiumUser score。當然我不推薦這樣的寫法。中間操作符模式的寫法更多應該用于那些看起來像是操作符的提取器(譯者注:即那些名稱以符号表達的提取器),就像List和Stream的組合操作符一樣(::,#::),PremiumUser看上去顯然不像是個操作符。
進一步了解Stream提取器
雖然在模式比對中使用#::并沒有太多特别之處,我還是想再仔細解讀一下上面的模式比對代碼。這段代碼也是通過檢查傳入的資料來決定是否傳回None表示不比對(譯者注:而不是僅僅檢查資料類型是否一緻)的一個例子。
下面是摘錄自Scala 2.9.2的#::提取器的源代碼:
object #:: {
def unapply[A](xs: Stream[A]): Option[(A, Stream[A])] =
if (xs.isEmpty) None
else Some((xs.head, xs.tail))
}
如果傳入的Stream執行個體為空,就傳回None。也就是說case head #:: tail不會比對空的stream,否則傳回一個Tuple2類型的資料(譯者注:封裝在Option中),Tuple2中的第一個元素是stream的首個元素值,Tuple2的第二個元素是stream的tail,stream的tail也是一個Stream。因而
case head #:: tail
将會比對一個有一個或多個元素的stream。如果stream隻有一個元素,它的tail将傳回一個空stream。
為了更好的了解這段代碼,我們來把模式比對部分從内嵌操作比對改成另一種寫法:
val xs = 58 #:: 43 #:: 93 #:: Stream.empty
xs match {
case #::(first, #::(second, _)) => first - second
case _ => -1
}
首先,xs被傳入提取器進行比對,提取器傳回Some(xs.head,xs.tail),結果是first被賦予58,xs的剩餘部分被再次傳給被包含的提取器進行比對,這次提取器還是傳回一個Tuple2包含xs.tail的首元素和xs.tail
的tail,也就是second會被賦予Tuple2的第一個元素43,Tuple2的第二個元素被賦予通配符_,也即被丢棄。
使用提取器
那麼既然你可以輕易的從case class中獲得一些有用的提取器,在什麼場景下仍然需要用到自定義提取器呢?
有人認為使用case class的模式比對破壞了封裝原則,它耦合了資料和資料表達實作,這種批評通常是源于面向對象的出發點。從Scala的函數式程式設計方面出發,你把case class當成一個隻包含資料而沒有任何行為的代數資料類型(ADTs)(譯者注:對這個術語我比較陌生,原文是lgebraic data types (ADTs))仍然不失為一個好主意。
通常,僅在你需要從你無法控制的資料類型中提取些東西或者你想從指定資料中進行額外的模式比對時才有必要實作自定義的提取器。例如,提取器的一種常見用法是從一些字串中提取有意義的值。留個回家作業吧:實作一個叫做URLExtractor的提取器并使用它,傳入String,進行URL比對。
結論
在這片文章裡,我們對提取器進行了解釋,Scala在模式比對的身後幹着各種苦活。你也學到了如何實作自己的提取器,提取器是如何在模式中被使用的。
限于篇幅(譯者注:翻譯的好辛苦啊),關于提取器的一切還沒有都覆寫到,在下一篇裡,我會再來講講提取器,涉及到如何實作從模式中提取多個變量的提取器。
如果本文對你有所幫助或者有任何不清楚的,請讓我知道。(譯者注:如果翻譯的不清楚或者有關内容的讨論也可以聯系我:Thomas)
作者:Daniel Westheide,2012.11.21