Subtyping vs Typeclasses(二)

ScalaCool發表於2017-09-18

本文由 Yison 發表在 ScalaCool 團隊部落格。

本文我們將介紹 Type Classes,類似 上一篇文章 提及的 Subtyping ,這也是一種實現多型的技術,然而卻更靈活。

什麼是 Type Classes

Type Classes 是發源於 Haskell 的一個概念。顧名思義,不少人把它理解成 「class of types」,這其實並不科學。事實上,Haskell 並沒有類似 Java 中的 class 的概念,一個更準確的理解,可以是「constructor class」 — 本質上它區別於單態,但也不是多型,而是提供一個介於兩者之間的過渡機制。

讓我們看看 《Learn You a Haskell for Great Good! 》 中對 Type classes 的相關描述:

A typeclass is a sort of interface that defines some behavior. If a type is a part of a typeclass, that means that it supports and implements the behavior the typeclass describes.

簡單理解,我們可以基於一個 type class 創造不同的型別,來實現多型的需求。

接下來我們將通過具體的例子來進一步認識 type classes,目前,你可能仍然不明白,但你可以把它想象為類似於 Java 中的 Interfaces,雖然這也不準確。

排序問題

想象我們現在要為某兩款 Moba 遊戲(G1 和 G2)寫段程式,支援在有限的玩家中篩選出 MVP 選手。

假設兩遊戲在評價 MVP 中對 KDA 中的助攻指標權重不同, 公式如下:

MVP (G1) = (人頭數 + 助攻數 x 0.8) / 死亡數
MVP (G2) = (人頭數 + 助攻數 x 0.6) / 死亡數

case class Player1(kill: Int, death: Int, assist: Int) = {
  def score = (kill + assist * 0.8) / death
}
case class Player2(kill: Int, death: Int, assist: Int) = {
  def score = (kill + assist * 0.6) / death
}複製程式碼

有經驗的朋友很快發現這其實是一個排序問題,又熟悉 Java 的朋友自然聯想到了 ComparableComparator 介面。

Comparable 方案

我們先來看下 Comparable 介面的定義:

public interface Comparable<T> {
  int compareTo(T o)
}複製程式碼

非常簡單,內部只定義一個 compareTo 方法,實現介面的類可以自定義該方法的實現,由此對具體的型別比較大小。

Scala 相容 Java 的類庫,所以我們可以這樣實現:

case class Player1(kill: Int, death: Int, assist: Int) extends Comparable[Player1] = {
  def score = (kill + assist * 0.8) / death
  // 覆寫 compareTo
  override def compareTo(o: Player1): Int = java.lang.Long.compare(score, o.score)
}
case class Player2(kill: Int, death: Int, assist: Int) extends Comparable[Player2] = {
  def score = (kill + assist * 0.8) / death
  // 覆寫 compareTo
  override def compareTo(o: Player2): Int = java.lang.Long.compare(score, o.score)
}複製程式碼

在 Java 中,這是對排序問題很標準的一種處理方式,它的優點顯而易見 — 只需定義一次,則可以在任何有 PlayerX 的地方進行 compare。然而它的缺點也同樣明顯,如果我想要在不同的地方對 PlayerX 採用其它的排序演算法,那麼就有點捉襟見肘了。

此外,該種方式還有個較大的問題,它並不是「型別安全」的,需要額外的處理,類似的原因我們會在後續的文章中作更深入的介紹。

Comparator 方案

Comparator 相比 Comparable 要靈活一些,這其實是一種很常見的思路。我們先在 Scala 中如此實現:

val players = List(Player1(12, 3, 4), Player1(5, 9, 10), Player(2, 1, 4))
players.sortWith((p1, p2) => p1.score >= p2.score).head複製程式碼

顯然它可以在呼叫處隨意定義排序演算法,然而卻又增加了每次呼叫時定義演算法的成本。

好吧,我們還是需要模擬一個 Comparator 介面:

trait Comparator[T] {
  def compare(first: T, second: T): Int
  def >=(first: T, second: T): Boolean = compare(first, second) >= 0
}

object G1 {
  def ordering(o: (Player1, Player1) => Int) = new Comparator[Player1] {
    def compare(first: Player1, second: Player1) = o(first, second)
  }
  val mvp = ordering(_.score - _.score)
}

object G2 {
  def ordering(o: (Player2, Player2) => Int) = new Comparator[Player2] {
    def compare(first: Player2, second: Player2) = o(first, second)
  }
  val mvp = ordering(_.score - _.score)
}複製程式碼

大功告成,我們對樣板資料篩選 MVP:

def findMvp[T](list: List[T])(ordering: Comparator[T]): T = {
  list.reduce((a, b) => if (ordering >=(a, b)) a else b)
}

val players1 = List(Player1(12, 3, 4), Player1(5, 9, 10), Player(2, 1, 4))
findMvp(players1)(G1.mvp)

val players2 = List(Player1(12, 3, 4), Player1(5, 9, 10), Player(2, 1, 4))
findMvp(players2)(G2.mvp)複製程式碼

看起來不錯,美中不足是每次呼叫 findMvp 時都必須顯式地指定排序演算法。

Type Class 方案

Type Class 可以很好地解決以上的幾個問題。在 Scala 中,型別系統其實並沒有像 Haskell 一樣內建 Type Class 原生特性,不過我們可以通過 implicit 來實現所謂的 Type Class Pattern,因此反而更加強大。

相比 Haskell,Scala 中的 Type Class Pattern 可以對不同的作用域採取選擇性生效,可參見 Scala Implicits : Type Classes Here I Come

首先,我們先來改造下 findMvp:

def findMvp[T](list: List[T])(implicit ordering: Comparator[T]): T = {
  list.reduce((a, b) => if (ordering >=(a, b)) a else b)
}複製程式碼

緊接著,再給我們的排序演算法定義增加 implicit

object G1 {
  def ordering(o: (Player1, Player1) => Int) = new Comparator[Player1] {
    def compare(first: Player1, second: Player1) = o(first, second)
  }
  implicit val mvp = ordering(_.score - _.score)
}

object G2 {
  def ordering(o: (Player2, Player2) => Int) = new Comparator[Player2] {
    def compare(first: Player2, second: Player2) = o(first, second)
  }
  implicit val mvp = ordering(_.score - _.score)
}複製程式碼

然後,我們就可以如此呼叫了:

import G1.mvp
import G2.mvp

val players1 = List(Player1(12, 3, 4), Player1(5, 9, 10), Player(2, 1, 4))
findMvp(players1)

val players2 = List(Player1(12, 3, 4), Player1(5, 9, 10), Player(2, 1, 4))
findMvp(players2)複製程式碼

如此神奇?由於定義了 implicit ordering,Scala 編譯器會在 Comparator[T] 特質中自動尋找到相關的 ordering 。

Scala 中的 Type Class 就是如此的簡單,也許你還是對 findMvp 的定義有點不適,好吧,我們可以利用 Context Bounds 來優化它。

Context Bounds

這個名字看起來也有點怵,其實它無非只是一種語法糖而已。拿以上的例子來講,[T: Comparator] 就是一個 context bound,它告訴編譯器當 findMvp 被呼叫時,Comparator[T] 型別的一個 implict 值會存在作用域當中。之後我們就可以 implicitly[Comparator[T]] 來獲取這個值。

因此,優化語法後的程式碼如下:

def findMvp[T:Comparator](list: List[T]): T = {
  list.reduce((a, b) => if (implicitly[Comparator[T]] >=(a, b)) a else b)
}複製程式碼

總結

通過以上的介紹,我們發現 Type Classes 是一種靈活且強大的技術,Scala 標準庫以及其它很多知名的類庫(如 Cats)都大量使用了這種模式。

它有點類似我們熟悉的 Interfaces(對應 Scala 中的 Trait),都可以通過名字、輸入、輸出,描述一系列相關的操作。然而,它們又顯著地不同,在下一篇文章中,我們將對 Subtyping 和 Typeclasses 這兩種技術做進一步的分析比較。

參考

相關文章