[Scala] TypeClass 簡介(一)--要解決的問題

把類別全部丟光,這是設計模式

話說雖然使用者可能沒有感覺,不過 TypeClass 這個東西在 Scala 裡的許多函式庫都有用到,但中文的資料好像有點少,就來寫一篇好了。

不過在開始之前,要先提醒一下,如果你和我一樣是被 Java 荼毒過的,可能看到 TypeClass 這個詞會一開始想到物件導向裡的類別或什麼的,但實際上兩者沒啥關係--Typeclass 在 Scala 比較像是一個設計模式,是幫你用來解決某個題型的問題的方法論,而不是特定的物件或類別。

一開始我就是沒搞懂這點,所以在看關於 TypeClass 的資料的時候,總是一頭霧水,看不出所謂的「TypeClass」到底在哪--因為他哪都不存在,他是解決一個特定題型的公式,而針對不同的問題,公式裡的值當然會不一樣。

要解決的問題

Note

這篇文章裡的例子是個極度簡化而且無用的例子,因為在 Scala 裡實際上有更簡潔的方式解決這個問題,不過就請讀者們將就一下唄。

看了一些 TypeClass 的文章後,我自己對於 TypeClass 的心得是他毫無反應,就是個設計模式。而我們知道,設計模式的基本上就類似於數學的公式,當遇到這個題型的時候就套上那個公式,因此我自己覺得理解 TypeClass 最簡單的方式,就是先搞清楚他要處理的題型是什麼。

至於 TypeClass 要解決的問題的題型,其實沒那麼複雜,我覺得簡單說來就是:

如何把通用的計算邏輯抽出,並且使計算邏輯與物件類型沒有直接的相依性。

舉一個實際的例子,數學裡有「平均」這件事,他的計算邏輯如下:

把所有的數字加總得出和,並將和除以數字的個數。

這個邏輯不論是我們算的是整數、小數、學生的成積、學生的身高、學生的體重、員工的薪資……其算法都是一樣的,不論我們算的是哪個「類別」的資料,都不會影響到上述的計算邏輯,算法都是一樣的,只是我們運算時的資料來源不一樣。

如果把上面的問題轉換成程式碼領域的問題,就會變成下面的問題:

我們要怎麼寫出一個 average() 函式,可以計算上述所有資料類型的平均,甚至是使用者自己定義的資料類型的平均,但同時又保持他是 compile-time type-safe 的。

請注意最後加上的 type-safe 的條件,這是因為在動態程式語言(Ruby)之中,這件事的前半部份並不難做到,反正我們就是一直把傳進來的東西相加就是了,如果他是不能相加的資料類型,就讓他在執行時期爆掉就好,那是使用我們 Library 的人的問題,我們不管那麼多。

但 Scala 裡的 Typeclass 的一點是他是靜態型別的,會在編譯時期就檢查你傳進去的資料類型是不是能夠相加,所以你不用擔心不小心把不能做平均的資料類型丟進 average() 這個函式裡,因為編譯器會直接和你抱怨。

第一個版本--加整數

Note

這裡的實作使用了 Java-style 的方式,以及有 var 變數(可以被改變的變數)出現,這是為了簡化說明,實際上比較符合 Scala 風格的程式碼是不太用 var 這東西。

我們的第一個計算平均的程式碼版本如下:

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

  var sum = 0
  var count = 0

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

  sum.toDouble / count
}

val xs = List(1, 3, 4, 5, 6, 7)
println(average(xs))

當然,這段程式碼的用途實在不太大,因為我們這段程式只能處理 List 裡面放的是 Int 這個類型的資料,如果我們今天需要處理像下面的資料:

case class Score(point: Double)
case class Weight(kg: Double)

val scores = List(Score(100), Score(85.5), Score(60.1), Score(77))
val weights = List(Weight(40), Weight(70), Weight(60))

average(scores) // Compile Error !
average(weights) // Compile Error !

那上面的程式碼就毫無用武之處了,因為他只能處理 Int 型態的資料,而 Score 和 Weight 都和 Int 無關,所以丟到編譯器裡的時候,他會抱怨。

版本二--使用介面

OK,上面的方式實在沒啥彈性,加上我們知道物件導向裡有一個原則是「針對介面寫程式」,所以我們可以把上面的 average() 裡的 Int 改成另一個通用的介面,然後讓 ScoreWeight 都實作這個介面,像下面一樣:

trait Addiable {
  def plus(x: Double): Double
}

case class Score(point: Double) extends Addiable {
  def plus(x: Double) = x + point
}

case class Weight(kg: Double) extends Addiable {
  def plus(x: Double) = x + kg
}

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

  var sum: Double = 0
  var count = 0

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

  sum.toDouble / count
}

val scores = List(Score(100), Score(85.5), Score(60.1), Score(77))
val weights = List(Weight(40), Weight(70), Weight(60))

println (average(scores))
println (average(weights))

看起來還不錯,現在我們只要讓我們寫的資料結構實作 Addiable 這個介面的話,就可以直接使用 average() 這隻函式了。

好,我們現在有一個身高和體重的類別了,現在讓我們來做一個「學生」的類別,並且假設一個學生有姓名、體重和學期分數三個屬性:

case class Stundent(name: String, weight: Weight, score: Score)

好,現在我們想要讓 average() 可以計算學生的成績,而我們知道只要幫 Stundent 實作 Addiable 這個介面就好了。

case class Student(name: String, weight: Weight, score: Score) extends Addiable {
  def plus(x: Double) = x + score.point
}

等等,那如果我們想要用 average() 來計算學生的體重呢?該怎麼辦?在這個例子中,我們的 Addiable 裡只能有一個 plus 的實作啊……而且如果我們要處理的物件類別是來自別的 Library 的 final class,他既沒有實作 Addiable 而且又不讓我們繼承的話要怎麼辦?明明計算平均值的邏輯是一樣的啊,難道還得再重新寫一個針對體重來計算平均的函式嗎?我們不是說 OO / Functional Programming 的好處是程式碼的重用嗎?

如果你有以上的疑問,恭喜你抓到重點了,上面的問題正是 Scala 裡的 TypeClass 這個設計模式想要解決的問題。

為了要了解 TypeClass 如何解決這個問題,我們下次會來看 Scala 到底提供了哪些現成的工具可以使用,好讓我們兜出解決這個問題的解決方案。

回響