前兩章討論了幾種保持 DRY 和靈活性的函數(shù)式編程技術(shù):
這一章依舊圍繞代碼靈活性而來,不過不再討論作為頭等公民的函數(shù),而是類型系統(tǒng)(注意:并不是要真的去研究類型系統(tǒng))。你將學(xué)習(xí) 類型類 !
可能你會覺得這沒有實際意義,認為這是被 Haskell 狂熱分子帶入 Scala 社區(qū)的異國情調(diào),顯然不是這樣。類型類已經(jīng)成為 Scala 標(biāo)準(zhǔn)庫,甚至是很多流行的、廣泛使用的第三方開源庫的重要組成部分,了解和熟悉類型類是很有必要的。
本章會討論:
我們用例子,而不是一個對類型類的抽象解釋,開始本文的主題,例子簡化了概念,也相當(dāng)實用。
假設(shè)想提供一系列可以操作數(shù)字集合的函數(shù),主要是計算它們的聚合值。進一步假設(shè)只能通過索引來訪問集合的元素,只能使用定義在 Scala 集合上的 reduce
方法。(施加這些限制,是因為要實現(xiàn)的東西,Scala 標(biāo)準(zhǔn)庫已經(jīng)提供了)最后,假定得到的值已排序。
我們先從 median
, quartiles
, iqr
的一個粗暴實現(xiàn)開始:
object Statistics {
def median(xs: Vector[Double]): Double = xs(xs.size / 2)
def quartiles(xs: Vector[Double]): (Double, Double, Double) =
(xs(xs.size / 4), median(xs), xs(xs.size / 4 * 3))
def iqr(xs: Vector[Double]): Double = quartiles(xs) match {
case (lowerQuartile, _, upperQuartile) => upperQuartile - lowerQuartile
}
def mean(xs: Vector[Double]): Double = {
xs.reduce(_ + _) / xs.size
}
}
median
將數(shù)據(jù)集分成兩半,下四分位數(shù)和上四分位數(shù)( quartiles
方法返回的元組的第一、第三個元素)分別分割了數(shù)據(jù)集的 25% 。iqr
方法返回四分差(上四分衛(wèi)數(shù)和下四分位數(shù)的差)。
現(xiàn)在我們想支持更多的類型,比如,Int
,所以應(yīng)該為這個類型實現(xiàn)上面這些方法,對吧?
不!不能想當(dāng)然的為 Vector[Int]
重載上面的方法(詭異的技巧除外),因為類型參數(shù)會被擦除,而且這樣做有代碼冗余的嫌疑。
要是 Int
和 Double
擴展自一個共同的基類,或者都實現(xiàn)了一個像是 Number
這樣的特質(zhì),那該多好!
你可能會想著去把上述方法需要的參數(shù)類型替換成更通用的類型,看起來會是這樣:
object Statistics {
def median(xs: Vector[Number]): Number = ???
def quartiles(xs: Vector[Number]): (Number, Number, Number) = ???
def iqr(xs: Vector[Number]): Number = ???
def mean(xs: Vector[Number]): Number = ???
}
這樣做,不僅丟掉了先前的類型信息,還違背了擴展性:不能強制第三方的數(shù)字類型擴展 Number
特質(zhì)。幸運的是,本例并不存在這樣一個通用的特質(zhì)。
對于這種問題,Ruby 的做法是 猴子補?。╩onkey patching) ,擴展新類型讓它看起來像一個 Number
,但是這樣會污染全局命名空間。年輕時遭到 “四人幫” 打擊的 Java 開發(fā)者,則會認為 適配器(Adpater) 能解決上面所有問題:
“四人幫”這里指的是設(shè)計模式一書的作者:Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides,具體見:http://en.wikipedia.org/wiki/Design_Patterns
object Statistics {
trait NumberLike[A] {
def get: A
def plus(y: NumberLike[A]): NumberLike[A]
def minus(y: NumberLike[A]): NumberLike[A]
def divide(y: Int): NumberLike[A]
}
case class NumberLikeDouble(x: Double) extends NumberLike[Double] {
def get: Double = x
def minus(y: NumberLike[Double]) = NumberLikeDouble(x - y.get)
def plus(y: NumberLike[Double]) = NumberLikeDouble(x + y.get)
def divide(y: Int) = NumberLikeDouble(x / y)
}
type Quartile[A] = (NumberLike[A], NumberLike[A], NumberLike[A])
def median[A](xs: Vector[NumberLike[A]]): NumberLike[A] = xs(xs.size / 2)
def quartiles[A](xs: Vector[NumberLike[A]]): Quartile[A] =
(xs(xs.size / 4), median(xs), xs(xs.size / 4 * 3))
def iqr[A](xs: Vector[NumberLike[A]]): NumberLike[A] = quartiles(xs) match {
case (lowerQuartile, _, upperQuartile) => upperQuartile.minus(lowerQuartile)
}
def mean[A](xs: Vector[NumberLike[A]]): NumberLike[A] =
xs.reduce(_.plus(_)).divide(xs.size)
}
上述代碼解決了擴展性問題:使用這個庫的用戶可以將類型通過 NumberLike
適配器傳遞過來,無需重新編譯統(tǒng)計庫。
但是,把數(shù)字封裝在適配器里,這樣的代碼會令人厭倦,無論讀寫,而且和統(tǒng)計庫交互時,必須創(chuàng)建一大堆適配器實例。
對目前所介紹的方法來說,類型類是一個強大的替代。類型類是 Haskell 語言一個突出的特征,雖然它的名字里有類,但它和面向?qū)ο缶幊汤锏念悰]有任何關(guān)系。
一個類型類 C
定義了一些行為,要想成為 C
的一員,類型 T
必須支持這些行為。一個類型 T
到底是不是 類型類 C
的成員,這一點并不是與生俱來的。開發(fā)者可以實現(xiàn)類必須支持的行為,使得這個類變成類型類的成員。一旦 T
變成 類型類 C
的一員,參數(shù)類型為類型類 C
成員的函數(shù)就可以接受類型 T
的實例。
這樣,類型類支持臨時的、追溯性的多態(tài),依賴類型類的代碼支持擴展性,且無需創(chuàng)建任何適配器對象。
Scala 中,類型類可以通過技術(shù)組合來實現(xiàn)和使用,比之 Haskell,它在 Scala 里的參與度更高,而且?guī)Ыo開發(fā)者更多的控制。
創(chuàng)建一個類型類涉及到幾個步驟。
首先,我們來定義一個特質(zhì):
object Math {
trait NumberLike[T] {
def plus(x: T, y: T): T
def divide(x: T, y: Int): T
def minus(x: T, y: T): T
}
}
上述代碼創(chuàng)建了名為 NumberLike
的類型類特質(zhì)。類型類總會帶著一個或多個類型參數(shù),通常是無狀態(tài)的,比如:里面定義的方法只對傳入的參數(shù)進行操作。前文的適配器操作的是它自己的字段和接受的一個參數(shù),而這里定義的方法都需要兩個參數(shù),其中第一個參數(shù)對應(yīng)適配器中的字段。
第二步通常是在伴生對象里提供一些默認的類型類特質(zhì)實現(xiàn),之后你會知道為什么要這么做。在這之前,先來實現(xiàn) Double
和 Int
的類型類特質(zhì):
object Math {
trait NumberLike[T] {
def plus(x: T, y: T): T
def divide(x: T, y: Int): T
def minus(x: T, y: T): T
}
object NumberLike {
implicit object NumberLikeDouble extends NumberLike[Double] {
def plus(x: Double, y: Double): Double = x + y
def divide(x: Double, y: Int): Double = x / y
def minus(x: Double, y: Double): Double = x - y
}
implicit object NumberLikeInt extends NumberLike[Int] {
def plus(x: Int, y: Int): Int = x + y
def divide(x: Int, y: Int): Int = x / y
def minus(x: Int, y: Int): Int = x - y
}
}
}
兩件事情:第一,這兩個實現(xiàn)基本相同。但不總是這樣,畢竟 NumberLike
只是一個很小的域。后面會給出類型類的一些例子,當(dāng)為這些例子實現(xiàn)多個類型時,重復(fù)的余地就少很多。第二, NumberLikeInt
做整數(shù)除法的時候,會損失一些精度,請忽略這一事實,這只是為簡單起見。
你也許會發(fā)現(xiàn),類型類的成員通常是單例對象,而且會有一個 implicit
關(guān)鍵字位于前面,這是類型類在 Scala 中成為可能的幾個重要因素之一,在某些條件下,它讓類型類成員隱式可用。更多相關(guān)的知識在下一節(jié)。
有了類型類和兩個默認實現(xiàn)之后,就可以根據(jù)它們來實現(xiàn)統(tǒng)計。我們先將重點放在 mean
方法上:
object Statistics {
import Math.NumberLike
def mean[T](xs: Vector[T])(implicit ev: NumberLike[T]): T =
ev.divide(xs.reduce(ev.plus(_, _)), xs.size)
}
這樣的代碼初看起來可能有點嚇人,實際上是相當(dāng)簡單,方法帶有一個類型參數(shù) T
,接受類型為 Vector[T]
的參數(shù)。
將參數(shù)限制在特定類型類的成員上,是通過第二個 implicit
參數(shù)列表實現(xiàn)的。這是什么意思?這是說,當(dāng)前作用域中必須存在一個隱式可用的 NumberLike[T]
對象,比如說,當(dāng)前作用域聲明了一個 隱式值(implicit value)。這種聲明很多時候都是通過導(dǎo)入一個有隱式值定義的包或者對象來實現(xiàn)的。
當(dāng)且僅當(dāng)沒有發(fā)現(xiàn)其他隱式值時,編譯器會在隱式參數(shù)類型的伴生對象中尋找。作為庫的設(shè)計者,將默認的類型類實現(xiàn)放在伴生對象里意味著庫的使用者可以輕易的重寫默認實現(xiàn),這正是庫設(shè)計者喜聞樂見的。用戶還可以為隱式參數(shù)傳遞一個顯示值,來重寫作用域內(nèi)的隱式值。
讓我們來驗證下默認的實現(xiàn)是否可以被正確解析:
val numbers = Vector[Double](13, 23.0, 42, 45, 61, 73, 96, 100, 199, 420, 900, 3839)
println(Statistics.mean(numbers))
漂亮極了!試試 Vector[String]
,你會在編譯期得到一個錯誤,這個錯誤指出參數(shù) ev: NumberLike[String]
沒有隱式值可用。如果你不喜歡這個錯誤消息,你可以用 @implicitNotFound
為類型類添加批注,來自定義錯誤消息:
object Math {
import annotation.implicitNotFound
@implicitNotFound("No member of type class NumberLike in scope for ${T}")
trait NumberLike[T] {
def plus(x: T, y: T): T
def divide(x: T, y: Int): T
def minus(x: T, y: T): T
}
}
總是帶著這個隱式參數(shù)列表顯得有些冗長。對于只有一個類型參數(shù)的隱式參數(shù),Scala 提供了一種叫做 上下文綁定(context bound) 的簡寫。為了說明這一使用方法,我們用它來實現(xiàn)剩下的統(tǒng)計方法:
object Statistics {
import Math.NumberLike
def mean[T](xs: Vector[T])(implicit ev: NumberLike[T]): T =
ev.divide(xs.reduce(ev.plus(_, _)), xs.size)
def median[T : NumberLike](xs: Vector[T]): T = xs(xs.size / 2)
def quartiles[T: NumberLike](xs: Vector[T]): (T, T, T) =
(xs(xs.size / 4), median(xs), xs(xs.size / 4 * 3))
def iqr[T: NumberLike](xs: Vector[T]): T = quartiles(xs) match {
case (lowerQuartile, _, upperQuartile) =>
implicitly[NumberLike[T]].minus(upperQuartile, lowerQuartile)
}
}
上下文綁定 T: NumberLike
意思是,必須有一個類型為 NumberLike[T]
的隱式值在當(dāng)前上下文中可用,這和隱式參數(shù)列表是等價的。如果想要訪問這個隱式值,需要調(diào)用 implicitly
方法,就像上述 iqr
方法所做的那樣。如果類型類需要多個類型參數(shù),就不能使用上下文綁定語法了。
含有類型類的庫的使用者,或遲或早會想將他自己的類型加入到類型類成員中。比如說,可能想將統(tǒng)計用在 Joda Time 的 Duration
實例上。
我們來試試吧。首先將 Joda Time 加入到路徑里:
libraryDependencies += "joda-time" % "joda-time" % "2.1"
libraryDependencies += "org.joda" % "joda-convert" % "1.3"
現(xiàn)在,只需創(chuàng)建 NumberLike
的一個隱式實現(xiàn):
object JodaImplicits {
import Math.NumberLike
import org.joda.time.Duration
implicit object NumberLikeDuration extends NumberLike[Duration] {
def plus(x: Duration, y: Duration): Duration = x.plus(y)
def divide(x: Duration, y: Int): Duration = Duration.millis(x.getMillis / y)
def minus(x: Duration, y: Duration): Duration = x.minus(y)
}
}
導(dǎo)入包含這個實現(xiàn)的包或者對象,就可以計算一堆 durations 的平均值了:
import Statistics._
import JodaImplicits._
import org.joda.time.Duration._
val durations = Vector(standardSeconds(20), standardSeconds(57), standardMinutes(2),
standardMinutes(17), standardMinutes(30), standardMinutes(58), standardHours(2),
standardHours(5), standardHours(8), standardHours(17), standardDays(1),
standardDays(4))
println(mean(durations).getStandardHours)
NumberLike
類型類是一個非常好的例子,但 Scala 已經(jīng)有 Numeric
了。對于集合的類型參數(shù) T
,只要存在一個可用的 Numeric[T]
,就可以在該集合上調(diào)用 sum
、 product
這樣的方法。標(biāo)準(zhǔn)庫中另一個使用比較多的類型類是 Ordering
,可以為自定義類型提供一個隱式排序,用在 Scala 集合的 sort
方法。
標(biāo)準(zhǔn)庫中還有更多這樣的類型類,不過,Scala 開發(fā)者并不需要與它們中的每一個都打交道。
第三方庫中一個非常常見的用例是對象序列化和反序列化,尤其是 JSON 對象。使一個類成為某個格式器類型類的成員,就可以自定義類的序列化方式,序列化成 JSON、XML 或者是任何新的格式。
Scala 類型和數(shù)據(jù)庫驅(qū)動支持的類型之間的映射,通常也是通過類型類獲得自定義和可擴展性的。
一旦開始用 Scala 來做些正式的工作,不可避免的會遇到類型類。希望讀者在讀完這一章后,能夠利用好這一強大技術(shù)。
Scala 類型類使得在開發(fā) Scala 應(yīng)用時,一方面可以有無限可追加的擴展,另一方面又可以保留盡可能多的具體類型信息。
和其他語言應(yīng)對這種問題的方法想比,Scala 給予了開發(fā)者完全的控制權(quán),因為類型類的實現(xiàn)可以被輕易的重寫,而且在全局命名空間里不可用。
你將看到這種技術(shù)在編寫由其他人使用的庫時尤其有用,在應(yīng)用程序代碼中,為了減少模塊之間的耦合,類型類也是有用武之地的。
更多建議: