[Scala] TypeClass 簡介(三)--實作 TypeClass

複習一下要解決的問題

昨天我們介紹了 Scala 提供的一些工具,今天我們就來看一下在 Scala 裡面,怎麼用這些工具來實現 TypeClass 的設計模式。

不過在開始之前,我們要先重新複習一下我們想要達成的目標:

  • 我們寫了一個 average 的函式
  • 我們希望這個函式可以處理任何「可以被平均」的型別的資料
  • 我們希望保留一定的彈性,讓使用者可以使用 average 來平均自己定義的資料結構

而我們手上有的工具有:

  • Trait
  • Curried Function
  • Implicit Parameter

好,現在我們就來看看要如何實作 TypeClass 來解決這個類型的問題吧!

第一步--抽出與類型相依的動作至 Trait 中

還記得我們一開始的整數版本的 average 函式是怎麼實作的嗎?

def average(elements: List[Int]): Double = {

  var sum: Double = 0
  var count = 0

  for (value <- elements) {
    sum += value
    count += 1
  }

  sum / count
}

如果分析上面的程式的話,會發現其實最主要的問題是下面這行:

sum += value

在這行裡面,相加的動作是與我們要處理的資料息息相關的,例如當遇到使用自訂的資料型態的時候,要用哪個欄位來計算呢?因為在撰寫 average 函式的時候,我們不會知道這些事情,所以我們第一件事情,就是把這個處理邏輯抽出到抽象的介面中,用 Scala 的角度來說,就是定義一個 Trait。

trait Addable[T] {
  def plus(x: Double, data: T): Double
}

在這裡要注意的是,和第一篇裡的 Trait 不一樣,在這篇裡的 Addable 裡有一個 T 的型態參數,而且 plus 裡吃的是兩個參數,我們把要處理的自訂的資料型態也當成參數丟進去了。這是在實作 TypeClass 時候的一個慣例--你的介面是 Stateless 的,他和你要處理的物件的狀態是獨立,不相互依存的。

有了上面的 Addable[T] 之後,我們可以把他當做參數,並當成給 average 用的參數,使 average 變得像下面這樣:

trait Addable[T] {
  def plus(x: Double, data: T): Double
}

def average[T](elements: List[T])(ev: Addable[T]): Double = {

  var sum: Double = 0
  var count = 0

  for (value <- elements) {
    sum = ev.plus(sum, value)
    count += 1
  }

  sum / count
}

你可以看到,由於 ev 幫我們抽象化掉如何相加的問題,因此我們現在的 average 可以把原本相當侷限的 List[Int] 改成 Generic 的 List[T],讓他可以處理存放任何類型的資料的 List --只要我們有 ev 這個東西可以用。

第二步--實作預設可以處理的資料

現在問題來了,要使用上面的 average 函式的話,我們勢必要提供他實作了計算 plus 這個函的 Addable 物件。

所以在第二步裡面,我們就是要實作我們認為的,可以被 average 的資料型態,在這裡為了簡潔,我們只實作以下兩種可以被平均的 List:

  • List[Int]
  • List[Double]

所以我們需要實作兩種 Addable,好讓 average 知道如何處理 List[Int]List[Double] 兩種資料型態:

object IntAddable extends Addable[Int] {
  override def plus(x:Double, data: Int): Double = x + data
}

object DoubleAddable extends Addable[Double] {
  override def plus(x:Double, data: Double): Double = x + data
}

然後在使用的時候,就可以像這樣用,來計算 IntDouble 的平均:

val listOfInt = List(1, 3, 5, 7, 9)
val listOfDouble = List(3.2, 7.4, 1.5, 6.9)

println(average(listOfInt)(IntAddable))
println(average(listOfDouble)(DoubleAddable))

第三步--改成 Implicit Parameter

當然,上面的這個 average 函式用法實在太難看了--我們不過就是算個 List[Int]List[Double] 的平均值,為什麼還要再傳額外的參數進去呢?

沒錯,這樣的程式碼實在很醜,用起來也很不方便……不過還好,還記得 Scala 提供了 Implicit Parameter 嗎?現在讓我們把上面的程式碼整合一下,把那兩個 Addable 的實作放在 Addable 的 companion object 裡面,並且加上 implicit 的關鍵字:

trait Addable[T] {
  def plus(x: Double, data: T): Double
}

object Addable {

  implicit object IntAddable extends Addable[Int] {
    override def plus(x:Double, data: Int): Double = x + data
  }

  implicit object DoubleAddable extends Addable[Double] {
    override def plus(x:Double, data: Double): Double = x + data
  }

}

接下來,我們再把 average 的第二個參數列表改成 implicit parameter 試試看:

def average[T](elements: List[T])(implicit ev: Addable[T]): Double = {

  var sum: Double = 0
  var count = 0

  for (value <- elements) {
    sum = ev.plus(sum, value)
    count += 1
  }

  sum / count
}

average 的第二個列表變成 implicit 後,只要能找到相對應的 implicit object,我們就不用自己提供,換句話說,我們可以直接像下面這樣使用 average 函式,就像第二個參數不存在一樣:

scala> val listOfInt = List(1, 3, 5, 7, 9)
listOfInt: List[Int] = List(1, 3, 5, 7, 9)

scala> val listOfDouble = List(3.2, 7.4, 1.5, 6.9)
listOfDouble: List[Double] = List(3.2, 7.4, 1.5, 6.9)

scala> average(listOfInt)
res0: Double = 5.0

scala> average(listOfDouble)
res1: Double = 4.75

但是當我們丟一個不應該能夠計算平均值的資料型態的時候(例如字串),編譯器就會幫我們擋下來,告訴我們這是不合法的:

scala> average(List("ABC", "DEF", "GHIJK", "Hello World"))
<console>:10: error: could not find implicit value for parameter ev: Addable[String]
              average(List("ABC", "DEF", "GHIJK", "Hello World"))
                     ^

因此我們可以達成我們原先的目標裡的 compile-time type-safe 的要求,只要是我們認為不能被相加被平均的資料,編譯器都會幫我們直接擋下來,而不會在執行時期爆炸。

處理不能被繼承的類別--平均字串

好,假設今天我們失心瘋了,認為 average 應該要可以平均一個 List[String] 的話,那要怎麼做呢?

如果是使用第一篇的繼承 Addiable 介面的方式的話,這平事是無法達成的--在 Java / Scala 裡面,String 是一個 final class,也就是說他不能被繼承……哇,是條死路。

但是當我們使用 TypeClass 的設計模式時,這件事情是可以被達到的--我們直接來實作一個 Addable[String] 的 implicit object 就可以了!

假設我們今天把 average(List[String]("ABC", "DEF", "Hello World")) 的結果定義為把字串裡的字元數除以 List 的長度的話,我們可以這樣做:

scala> implicit object StringAddable extends Addable[String] {
     |   override def plus(x: Double, data: String): Double = x + data.size
     | }
defined module StringAddable

scala> average(List("ABC", "DEF", "GHIJK", "Hello World"))
res3: Double = 5.5

瞧,我們現在可以用 average 來算 List[String] 的平均了!

這裡值得注意的是 StringAddable 是在我們使用時才定義的,也就是說,即使你原本給使用者的 average 函式庫裡沒定義如何計算 List[String] 也沒關--使用者可以自己擴充他!

處理多重的計算邏輯--計算學生的體重和成積平均

還記得在第一篇裡我們提出的,如何在不更動 average 函式的情況下,計算學生的體重平均與分數平均嗎?我們現在就來解決這個問題。

看到了這篇,你應該會發現,其實我們只要代換 average 裡的 ev 的話,就可以抽換不同的加法邏輯--沒錯,在解決用同時計算體重和分數平均的問題上,我們只要提供兩個版本的 ev 就行了。

同樣的,我們先定義代表學生的資料結構:

case class Student(name: String, weight: Double, score: Double)

然後一樣定義預設平均意義,這邊我們假設是預設平均體重:

implicit object StudentWeightAddable extends Addable[Student] {
  override def plus(x: Double, data: Student) = x + data.weight
}

然後就同樣地可以直接使用 average 來計算學生的平均:

scala> case class Student(name: String, weight: Double, score: Double)
defined class Student

scala> implicit object StudentWeightAddable extends Addable[Student] {
     |   override def plus(x: Double, data: Student) = x + data.weight
     | }
defined module StudentWeightAddable

scala> val students = List(Student("Brian", 65, 78.9), Student("Wang", 56.3, 83.2))
students: List[Student] = List(Student(Brian,65.0,78.9), Student(Wang,56.3,83.2))

scala> average(students)
res0: Double = 60.65

但如果今天我們要計算的是學生的成績平均?沒問題!我們直接提供另一個 Addable 的實作就一切 OK 了!

scala> object StudentScoreAddable extends Addable[Student] {
     |   override def plus(x: Double, data: Student) = x + data.score
     | }
defined module StudentScoreAddable

scala> average(students)(StudentScoreAddable)
res1: Double = 81.05000000000001

看,現在我們現在在沒有更動 averageStudent 的程式碼的情況下,把預設的計算體重平均的行為,更改成計算成績的平均了。

結論

在這一系列文章中,我們用 Step by Step 的方式,來看 Scala 中所謂的 TypeClass 到底是怎麼回事。

其實這一切並沒有想像中的複雜,我們只是利用 Scala 提供的一些語言上功能,來把通用的程式碼隔離出來,並且提供合理的預設行為讓使用者可以使用。但在這同時也保留了一些彈性,讓使用者決定是否要使用預設的行為而已。

所以下次再看到什麼 TypeClass 之類的,或是看到 API 文件裡的函式參數怎麼一堆 implicit 之類,不要被嚇到囉--因為做為 Library 的使用者,通常我們根本是不用管那些 implicit 參數的,把他當做不存在就好--除非我們要處理的是 Library 設計者一開始沒想到的類別。

回響