把類別全部丟光,這是設計模式
話說雖然使用者可能沒有感覺,不過 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 改成另一個通用的介面,然後讓 Score 和 Weight 都實作這個介面,像下面一樣:
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 到底提供了哪些現成的工具可以使用,好讓我們兜出解決這個問題的解決方案。
回響