The Neophyte's Guide to Scala Part 12: Type Classes
過去的兩週我們討論了一些使我們保持DRY和靈活性的函數語言程式設計技術,特別是函式組合,partial function的應用,以及currying.接下來,我將會繼續討論如何使你的程式碼儘可能的靈活.
但是,這次我們將不會討論怎麼使用函式作為一等物件來達到這個目的,而是使用型別系統,這次它不是阻礙著我們,而是使得我們的程式碼更靈活:你將會學到關於 type classes 的知識.
你可能會覺得這是一個外來的並不很相關的概念,被一些吵鬧的Haskell粉絲帶到Scala社群中來.但是,很顯然並非如此.Type classes已經是Scala標準庫很重要的部分,而且越來越重要,對於很多流行的而且被廣泛使用第三方開源庫也是如此.所以你的確應該熟悉一下它們.
我會討論type class的概念,為什麼它是有用的,怎麼作為使用者受益於type class, 以及怎麼實現你自己的type class並且使它產生很大作用.
問題
我不會以直接給出"type class是什麼"的抽象解釋,而是以一種更簡單但又實際的方式來處理這個話題,那就是-舉例子.
想象一下我們想要寫一個酷斃了的統計相關的庫.這意味著我們將會提供很多用於處理數字集合的函式,多數用於對它們的值進行聚合.進一步,假如我們被限制只能通過索引來的訪問這個集合的元素,並且需要使用Scala集合庫的reduce方法.我們為自己新增這個限制是因為我們想要重新實現一些Scala標準庫已經提供的東西--只是因為這是一個很好的沒有多少限制的例子,並且它對於一篇部落格來說也足夠小.終後,我們的實現假定我們獲取的數值是排序過的.
我們以對Double型別的median, quatiles, iqr的實現開始.
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(中位數)把資料集分成兩半,四分位數(quartile)中最小的和最大的那個(我們的quartile方法返回的tuple中的第一個和第三個元素)從資料集中分離出前25%和後25%.我們的iqr方法返回四分位範圍(interquartile range),也就是最大的四分位和最小的四分位的差.
現在,我們想要支援double以外的數.所以,我們重新把這些方法為Int實現一遍,對嗎?
當然不是!首先,這會有一些重複,不是嗎?並且,在像這個例子這樣的情況下,我們很快就會遇到一些不得不使用骯髒的小技巧來進行方法覆蓋的情況,因為型別引數會被擦除.
如果Int和Double是繼承了同樣的基類或者實現了相同的trait,比如Number,那麼我們就可以修改我們的方法的引數型別和返回型別來使使用那個更一般的型別.我們的方法引數就會看起來是這樣的:
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 = ??? }
謝天謝地,在這個例子中,Int和Double並沒有共同的trait,所以這條邪路就不通了.在其它例子中,有可能一些型別會有共同的父類或trait--但這仍然是一個壞主意.不光是因為我們丟掉了之前可用的型別資訊,我們的API也會對將來的其它擴充套件(它們的來源是我們控制不了的)關上了大門:我們不能在第三方的擴充套件中增加繼承Number trait的型別(譯註:意思是,在第三方擴充套件中,我們可能沒有辦法繼承Numeric這個trait,但是還是想要讓第三方擴充套件中的類可以被Statistics這個API呼叫).
Ruby對這個問題的答案是monkey patching. 以汙染全域性名稱空間為代價為新的型別做擴充套件,使它表現得像Number一樣.而被Gang of the Four(指設計模式的四名作者)打敗的Java開發者,則會使認為介面卡(Adaptor)可以解決所有問題.
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) }
現在我們用擴充套件的方式解決了這個問題:使用我們的庫的人可以傳入一個為Int寫的NumerLike的介面卡(我們也可以自己提供(譯註:指我們作為API的開發者,可以自己提供一個Int的介面卡))或者傳入任何想要表現得像numer一樣的型別的介面卡,而不用重新編譯我們的統計方法的實現模組.
但是,總是把你的數字包裝在介面卡中不僅寫起來和讀起來都很費勁,它也意味著你必須建立很多的介面卡物件來和你的庫互動.
Type class 來救你
在以上提供的解決方法之外的一個強大的選項,當然,就是定義和使用type class.Type class,作為Haskell語言的一個強大的特性,雖然也叫class, 但是和麵向物件裡的類的概念沒有任何關係.
一個type class C定義了一些操作,這些行為是任何想要成為C的一員的型別T必須支援的.但是T是否是type class C的一員不是T本身決定的,任何開發者如果想要一個型別成為一個type class的一員,只需要提供這個型別必須支援的操作就行了.現在,一旦T成為了type class C的一員,限制自己的一個或多個引數為C的一員的函式就都可以使用T作為引數了.
就像這樣,type class允許進行即時以及有追溯能力的多型.依賴於type class的程式碼對於擴充套件是開放的,不用建立介面卡物件.
建立一個type class
在Scala中,type class可以通過一系列技術的組合來實現.這比在Haskell中需要做的事情要多一些,但是也讓開發者有了更多控制.
在Scala中建立一個type class包括幾個步驟.首先,讓我們定義一個trait.這就是實際的typc class.
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
}
}
我們建立了一個叫NumberLike的type class. Type class總是有一個或多個型別引數,並且他們能常被設計為無狀態的,比如,在我們的NumberLike這個trait中定義的方法都只依賴於它們的引數.需要指出的是,我們上邊的介面卡的方法依賴於它們適配的T型別的物件以及一個引數,但是在我們的NumberLike type class中定義的方法有兩個T型別的引數--而在NumberLike中,這個T型別的物件成了操作的第一個引數.
提供預設成員
實現一個type class的第二步通常是提供在它的伴隨物件(companion object)中提供一些預設的你的type class trait的實現.我們過一會將會看到為啥這是一個好的策略.首先,讓我們照這樣來把Double和Int弄成NumberLike這個type class的一員.
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 } } }
兩件事: 第一,你會看到這兩個實現基本上是相同的.但在建立type class的成員時,並不總是這樣.我們的的NumerLike trait是一個相對較小的領域.在這個文章的後面,我將會給出一些type class的例子,在實現他們的成員時,就會有少得多的重複的空間了.第二,請忽略在NumerLikeInt中我們進行整數除法時丟掉的精度,這純粹是為了保持例子簡單.
正像你看到的那樣,type class的成員通常都是單例物件.也請注意在每個type class的實現前邊的implicit關鍵字.這是使得type class在Scala中成為可能的關鍵成員,也就是在一些條件下使得type class的成員成為隱式可用的.下一節會更多討論這個內容.
針對type class進行程式設計
現在,我們有了type class,以及它的兩個預設的實現.現在讓我們在statistic模組中針對type class作程式設計.讓我們暫時關注在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) }
這在一開始看起來有些嚇人,但是實際上很簡單.我們的方法接受一個型別引數T,以及唯一的一個引數Vector[T]
把引數限制為一個特定的type class的成員是通過引數列表中的第二個implicit引數實現的.這意味著什麼呢?簡單地說,在當前的作用域中必須有一個NumerLike[T]型別的值是隱式可用的.這也就是說在當前的作用域中必須有一個implicit value被宣告,並使之可用.通常這是通過import一個包或者物件中的implicit value來達成的.
只有當沒有其它的隱式值被發現時,編譯器才會在隱式引數的型別的伴生成象(companion object)中尋找.因此,作為一個庫的設計者,把你的預設的type class的實現放在你的type class trait的伴生物件中,就可以使得你的庫的使用者可以方便地用它們自己的實現覆蓋你的實現,這也就是你想要做的.使用者也可以在隱式引數的位置傳入一個顯式的值來覆蓋當前的作用域中的隱式值.
讓我們看一下預設的type class實現參否被識別.
val numbers = Vector[Double](13, 23.0, 42, 45, 61, 73, 96, 100, 199, 420, 900, 3839)
println(Statistics.mean(numbers))
非常好.如果我們想要用Vector[String]來試一下,我們就會得在編譯器得到一個錯誤,說沒有用於引數e: numberLike[String]的隱式值可用.如果你不想看到這個錯誤資訊,你可以在自己的type class trait上加個@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 } }
上下文界定 Context Bounds
在引數列表中包含一個期待type class成員的隱式引數列表有些繁瑣.作為對於只有一個型別引數的隱式引數的簡化,Scala提供了一種叫做context bounds的語法.為了展示怎麼使用這個語法,我們接下來就會使用這個語法實現我們的statistics方法.
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]型別的值必須可用,所以就和在引數列表中加入第二個隱式引數NumberLike[T]是一樣的.但是,如果你想使用這個隱式可用的值,就必須呼叫implicitly方法,就像我們在iqr方法中作的那樣.如果你的type class需要多於一個型別引數,你就不能用context bound語法了.
自制type class成員
作為一個使用type class的庫的使用者,你早晚會想要把一個類做成這樣type class的成員.經如,你可能想要對於Joda Time的Duration型別使用我們的統計庫.為些,我們當然需要把Joda Time放在我們的classpath上.
libraryDependencies += "joda-time" % "joda-time" % "2.1"
libraryDependencies += "org.joda" % "joda-convert" % "1.3"
現在我們只需要建立一個實現了NumerLike的隱式值(在嘗試時,請確保Joda Time在你的classpath上)
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) } }
如果我們引入了包含這個Numberlike的包或者物件,我們就可以計算一些時間段的均值了.
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 type class是一個很好的練習.但是Scala已經自帶了一個Numeric type class了,它使得你可以在T的Numeric[T]存在的時候對一個集合使用sum或者product.另外一個你在標準庫中常用到的type class是Ordering,它使得你能夠為自己的型別提供一個隱式地Ordering,來被Scala集合的sort方法使用.
在標準庫中還有更多的type class,但是作為一個常規的Scala開發者,它們不都是需常會用到的.
一個在第三方庫中常見的用例是序列化和反序列化,特別是轉成或者從JSON轉化.通過使得你的類成為這種庫需要的一個formatter type class的成員,你就能定製你的類序列化成JSON, XML或者其東西.(譯註:典型是是Spray-Json)
在Scala型別和你的資料庫驅動需要型別之間的對映也通常使用type class來定製和擴充套件.
總結
一旦你真的拿Scala來做些嚴肅的事情,你就不可避免的要遇到type class.我希望在讀完這篇文章以後,你已經準備好來利用這個強大的技術的.
Scala的type class使得你可以Scala程式碼即可以對於回溯擴充套件開放,又可以儘可能得保持型別資訊.與其它語言比較,Scala的type class可以讓開發者空全控制,也就是說預設的type class實現可以無障礙地被覆蓋,並且type class的實現不會在全域性名稱空間中都可見.
在你想要寫一個用來被其它人使用的庫時,你會發現這項技術非常有用,但是type class在程式程式碼中也有用,它可以在不同的模組中降低耦合.