[譯] Scala 型別的型別(四)

ScalaCool發表於2017-07-06

上一篇

Scala 型別的型別(三)

目錄

16. 列舉

Scala 中並沒有像 Java 一樣支援列舉語法,但我們可以使用一些技巧(包含在 Enumeration)來寫出類似的東西。

16.1. Enumeration

在 Scala 2.10.x 版本中可以通過使用 Enumeration 來實現「類似列舉」的結構。

object Main extends App {

① object WeekDay extends Enumeration {               
②    type WeekDay = Valueval Mon, Tue, Wed, Thu, Fri, Sat, Sun = Value    
  }
④  import WeekDay._                                   

  def isWorkingDay(d: WeekDay) = ! (d == Sat || d == Sun)

⑤  WeekDay.values filter isWorkingDay foreach println 
}複製程式碼

① 首先我們宣告一個單例來包含我們的列舉值,它必須繼承 Enumeration

② 在這裡,我們為 Enumeration 內部的 Value 型別定義一個 型別別名 ,因為我們需要取一個匹配單例名字的名字,後面可以始終通過「WeekDay」來引用它。(是的,這幾乎是一個 hack )

③ 在這裡,我們採用了「多重賦值」,因此每個 val 左邊的變數都被賦值了一個不同的 Value 型別的例項

④ 這個 import 帶來了兩點:不僅支援了在沒有 WeekDay 字首的情況下直接使用 Mon ,同時也在這個作用域中引入了 type WeekDay ,於是我們可以在下方的方法定義中使用它

⑤ 最後,我們獲得了一些 Enumeration 的方法,這些並不是魔術,當我們建立新的 Value 例項時,大部分動作都會發生

正如你所見,Scala 中的列舉機制並不是內建的,而是通過巧妙地藉助型別系統來實現。對於一些使用場景,這也許已經足夠了。但當遇到需要增加列舉值以及往每個值增加行為的時候,它就不能像 Java 那樣強大了。

16.2. @enum

@enum 註解現在已經不僅僅只是一個提議了, 已經處在 Scala 內部的討論程式中了:Enumeration must DIE...

@enum 註解可能會跟「註解巨集」一起,在將來被支援。在 Scala 改進計劃文件中有關於此的描述:enum-sip

@enum
class Day {
  Monday    { def goodDay = false }
  Tuesday   { def goodDay = false }
  Wednesday { def goodDay = false }
  Thursday  { def goodDay = false }
  Friday    { def goodDay = true  }
  def goodDay: Boolean
}複製程式碼

譯者注:作者以上提及的方案已被官方棄用,但 enum 關鍵字將在 Dotty 中被支援,參見 dotty.epfl.ch/docs/refere…

17. value 類

value 型別(Value Class)在 Scala 內部存在了很長時間,並且你也已經使用過它們很多次了。因為 Scala 中所有的 Number 都使用這個編譯器技巧來避免數字值的裝箱和拆箱的過程,比如從 intscala.Int 等。提醒下你回想一下 Array[Int] ,它其實在 JVM 中是 int[] ,(如果你對 bytecode 熟悉,會知道它是 JVM 的一種執行時型別:[I])它 會有蠻多效能方面的影響。總的來說,數字的陣列效能很好,但引用的陣列就沒那麼快了。

好的,我們現在知道了編譯器可以在不必要的時候通過奇技淫巧來避免將 ints 裝箱成 Ints 。因此讓我們來看看 Scala 在 2.10.x 之後是如何將這個特性展示給我們的。這個特性被稱為「value 類」,可以相當簡單地應用到你現有的類當中。使用它們簡單到只要把 extends AnyVal 加到你的類中,同時遵循以下將提及的新規則。如果你不熟悉 AnyVal ,這可能是一個很好的學習機會 — 你可以檢視 通用型別系統 — Any, AnyRef, AnyVal

讓我們實現一個 Meter 來作為我們的例子,它將實現一個原生 int 的包裝 ,並支援將以「meter」為單位的數字轉化為以 Foot 型別的數字。我們需要上一課,因為沒人理解皇室的制度 ;-) 。不過,如果 95% 的時候都使用原生的 meter 值,為什麼我們要因為讓一個物件包含一個 int 而支付額外的執行時開銷?(每個例項都有好幾個位元組!)是因為這是一個面向歐洲市場的專案?我們需要「value 類」的救援!

case class Meter(value: Double) extends AnyVal {
  def toFeet: Foot = Foot(value * 0.3048)
}

case class Foot(value: Double) extends AnyVal {
  def toMeter: Meter = Meter(value / 0.3048)
}複製程式碼

我們將在所有的例子中使用樣例類(value 類),但它在技術上不是硬性要求的(儘管非常方便)。雖然你也可以通過在一個普通類使用 val 來實現一個 value 類,相比樣例類通常會是最佳方案。你可能會問「為什麼只有一個引數」,這是因為我們會盡量避免去包裝值,這對於單個值是有意義的,否則我們就必須在某些地方保持一個元組,這樣很快就會變得含糊,同時我們也將失去「不包裝」策略下的效能。因此記住,value 類僅適用於一個值,雖然沒人可以說這個引數必須是一個原始型別,它也可以是一個普通類,如 FruitPerson ,我們有時候依舊可以避免在 value 類中進行包裝。

所有你在定義一個 value 類時需要做的,就是擁有一個包含「繼承 AnyVal變數」的類,同時遵循一些它的限制。這個變數不一定就是原始型別,它可以是任何東西。這些限制換句話說,就是一個更長的列表,比如一個 value 型別不能包含除了 def 成員外的其它欄位,並且不能被擴充套件,等等。完整的限制清單以及更深入的例子,可以參加 Scala 文件 — [Value Classes - summary of limitations])(docs.scala-lang.org/overviews/c…) 。

好了,現在我們擁有了 MeterFoot 值樣例類,我們首先檢查下當新增了 extends AnyVal 部分之後,生成的位元組碼如何使 Meter 從一個普通的樣例類,變成一個 value 類:

// case class
scala> :javap Meter

public class Meter extends java.lang.Object implements scala.Product,scala.Serializable{
    public double value();
    public Foot toFeet();
    // ...
}

scala> :javap Meter$
public class Meter$ extends scala.runtime.AbstractFunction1 implements scala.Serializable{
    // ... (skipping not interesting in this use-case methods)
}複製程式碼

為 value 類生成的位元組碼如下:

// case value class

scala> :javap Meter
public final class Meter extends java.lang.Object implements scala.Product,scala.Serializable{
    public double value();
    public Foot toFeet();
    // ...
}

scala> :javap Meter$
public class Meter$ extends scala.runtime.AbstractFunction1 implements scala.Serializable{
    public final Foot toFeet$extension(double);
    // ...
}複製程式碼

有一件事情應該引起我們的重視,就是當 Meter 作為一個 value 類被建立時,它的伴生物件獲得了一個新的方法 — toFeet$extension(double): Foot 。在這個方法成為 Meter 類的例項方法之前,它沒有任何引數(所以它是:toFeet(): Foot)。生成的方法被標記為「extension」(toFeet$extension),實際上這也是我們給這些方法所取得名字。( .NET 開發者已經看到這種趨勢了)

由於我們的 value 類的目標是避免必須分配整個 value 類物件,從而直接跟包裝後的值打交道,所以我們必須停止使用例項方法,因為它們將迫使我們產生一個包裝( Meter )類的例項。我們能做的事情是,將這個例項方法變成一個「擴充套件方法」,它將儲存在 Meter 的伴生物件中。我們通過傳入 Double 型別值,而不是使用例項的 value: Double 來呼叫這個擴充套件方法。

擴充套件方法的作用跟隱式轉換類似(後者是一個更通用,以及更強大的武器),但它是更加簡單的一種方式 — 避免了必須分配整個包裝後的物件。相對的,隱式轉換會需要它來提供「額外的方法」。擴充套件方法有點採用「重寫生成的方法」的路線,以便它們將「要擴充套件的型別」作為它們第一個引數。舉個例子,假如你寫了 3.toHexString ,這個方法會通過一個隱式轉換被新增到 Int ,然而由於目標是 class RichInt extends AnyVal ,所以一個 value 類的呼叫並不會導致 RichInt 的分配,而是會被重寫成 RichInt$.$MODULE$.toHexString$extension(3),這樣子就避免了 RichInt 的分配。

讓我們用新學習到的知識來調查下在 Meter 的例子中,編譯器到底為我們做了什麼。原始碼旁邊註釋的部分解釋了編譯器實際上生成的東西。(如此來發現程式碼執行時發生了什麼):

// source code                 // what the emited bytecode actualy doesval m: Meter  = Meter(12.0)    // store 12.0                                      val d: Double = m.value * 2    // double multiply (12.0 * 2.0), store             val f: Foot   = m.toFeet       // call Meter$.$MODULE$.toFeet$extension(12.0)複製程式碼

① 有人可能會期待在這裡分配一個 Meter 物件,然而由於我們正在使用一個 value 類,只有被包裝的值被儲存 — 即我們在執行時一直在處理的一個原生 double 值。(賦值和型別檢查依舊會驗證這是否個 Meter 例項)

② 在這裡,我們訪問了 value 類的 value(這個欄位名的名字沒有關係)。請注意,執行時這裡操作的是原生的 doubles ,因此不必像往常一個普通的樣例類一樣,呼叫一個 value 的方法。

③ 這裡,我們似乎在呼叫一個定義在 Meter 裡的例項方法,然而事實上,編譯器已經用一個擴充套件方法呼叫代替了這個呼叫,它在 12.0 這個值中傳遞。我們獲得了一個 Foot 例項… 等一下!但是 Foot 這裡也被定義成了一個 value 類,所以在執行時我們再次得到了一個原生 double

這些都是「擴充套件方法」和 「value 類」的基礎知識。如果你想閱讀更多,瞭解不同的邊界情況,請參考官方關於 value 類的章節,Mark Harrah 在這裡用了很多例子,解釋得很好。所以除了基本介紹外,我就不再重複勞動了。

18. 型別類

❌ 該章節作者尚未完成,或需要修改

型別類(Type Class)屬於 Scala 中可利用的最強大的模式,可以總結為(如果你比較喜歡華麗的措施)「特定多型」。等到本章結束之後,你就可以理解它了。

Scala 為我們解決的典型的問題就是,在無需顯式繫結兩個類的前提下,提供可擴充的 API 。舉一個嚴格繫結的例子,我們不使用型別類,如擴充套件一個 Writable 介面,為了讓我們自定義的資料型別可寫:

// no type classes yet
trait Writable[Out] {
  def write: Out
}

case class Num(a: Int, b: Int) extends Writable[Json] {
  def write = Json.toJson(this)
}複製程式碼

使用這種風格,只是擴充套件和實現了一個介面,我們將 Num 轉化為 Writable ,同時我們也必須提供 write 的實現,「必須在這裡馬上實現」,這使得其他人難以提供不同的實現 — 它們必須繼承實現一個 Num 子類。這裡的另一個痛點是,我們不能從一個相同的特質繼承兩次,提供不同的序列化目標(你不能同時繼承 Writable[Json]Writable[Protobuf])。

所有這些問題都可以通過基於型別類的方法解決,而不是直接繼承 Writable[Out] 。讓我們試一試,並詳細解釋下這到底是如何做的:

trait Writes[In, Out] {                                               
    def write(in: In): Out
  }

② trait Writable[Self] {                                               
    def write[Out]()(implicit writes: Writes[Self, Out]): Out =
      writes write this
  }

③ implicit val jsonNum = Writes[Num, Json] {                            
    def (n1: Num, n2: Num) = n1.a < n1.
  }

case class Num(a: Int) extends Writable[Num]複製程式碼

① 首先我們定義下型別類,它的 API 跟之前的 Writable 特質類似,但我們保持分離,而不是將它們混合到一個寫入的類中。這是為了知道我們用「自型別註解」定義了什麼

② 接下來我們將我們的 Writable 特質改為使用 Self 進行引數化,並將「目標序列化型別」移動到 write 的簽名中。它現在還需要一個隱式的 Writes[Self, Out] 實現,它將處理序列化 — 這就是我們的型別類

③ 這是型別類的實現。請注意,我們將例項標記為 implicit ,所以

Universal traits 是 extend Any 的特質,它們應該只有 def ,並且沒有初始化程式碼。

這裡作者還需要有很多補充

19. 自身型別註解

「自身型別」(Self Types)可被用來給一個特質混入外部型別,如果一個其他的類使用了這個特質,它也必須提供該特質混入部分的實現。

來看一個例子,該例子中 Service 特質混入了 Module 特質,後者內部提供了其它的 services。我們可以通過如下的「自身型別註解」來表示:

trait Module {
  lazy val serviceInModule = new ServiceInModule
}

trait Service {
  this: Module =>

  def doTheThings() = serviceInModule.doTheThings()
}複製程式碼

Service 定義部分的第二行可以被閱讀為「 I’m a Module 」。這看起來與繼承一個 Module 並沒什麼兩樣,究竟哪裡不同呢?

前者意味著我們必須在例項化一個 Service 的同時也提供 Module

trait TestingModule extends Module { /*...*/ }

new Service with TestingModule複製程式碼

如果你沒有混入所需的特質,就會像如下一樣失敗:

new Service {}

// class Service cannot be instantiated because it does not conform to its self-type Service with Module
//              new Service {}
//              ^複製程式碼

同時你也應該瞭解,我們可以利用「自身型別」語法混入多個特質。寫到這,讓我們討論下為什麼它被叫做 self-type(除了「是的,它看起來很通」的因素)。答案可能要歸於這還是一種流行的使用風格,就像下面這樣:

class Service {
  self: MongoModule with APIModule =>

  def delegated = self.doTheThings()
}複製程式碼

事實上,你可以使用任何識別符號(不僅僅是 this 或者 self),然後在你的類中引用它。

20. 幽靈型別

幽靈型別(Phantom Types)儘管是個古怪的名字,但似乎非常貼切。它可以被解釋為「不是例項的型別」,我們不直接使用它們,但是可以用來執行一些更嚴格的邏輯。

我們要舉的例子是一個 Service 類,它有 startstop 方法。現在我們想要確保你不能「開始」一個已經在執行的服務(型別系統不允許這麼幹),反之亦然。

一開始我們先來定義一些標記狀態的特質,它們不包含任何邏輯,我們只會用它們來表示一個服務的狀態型別:

sealed trait ServiceState
final class Started extends ServiceState
final class Stopped extends ServiceState複製程式碼

注意這裡給 ServiceState 特質採用了 sealed 來確保不會有人在系統裡突然增加其它狀態。同時我們也把子型別定義為 final ,因此它們也不會被繼承,系統不會被引入其它更多狀態。

關於 sealed 關鍵詞

sealed 確保所有繼承一個類或者特質的行為都必須在相同的一個編譯單元。舉例而言,如果你在一個 State.scala 的檔案裡定義了 sealed trait State 以及一些狀態實現,這沒毛病。然而,如果你不能再其它的檔案來繼承 State (如 MyStates.scala)。

注意了,以上情況只針對使用了 sealed 關鍵詞的型別有效,但不適用於它的子類。如果你不能在其它檔案裡繼承 State ,但是如果你準備了一個型別如 trait UserDefinedState extends State ,我們則可以定義更多 UserDefinedState 的子類,即使是通過其它的檔案。假如你要阻止這樣的情況發生,你應該給你的子類們加上 final,正如我們在以上例子中所做的。

瞭解了這些,我們終於可以來研究如何將它們作為幽靈型別來使用。首先我們先來定義一個 Service 類,它有一個 State 型別引數。這裡請注意了,我們將不會在這個類中使用任何 State 型別的值!它只是靜靜地在這裡,像一個幽靈 —— 這也是它名字的由來。

class Service[State <: ServiceState] private () {
  def start[T >: State <: Stopped]() = this.asInstanceOf[Service[Started]]
  def stop[T >: State <: Started]() = this.asInstanceOf[Service[Stopped]]
}
object Service {
  def create() = new Service[Stopped]
}複製程式碼

因此在這個伴身物件裡,我們先建立了一個 Service 的例項,在最開始它的狀態是 Stopped 。這個狀態也符合型別引數(<: ServiceState)的型別邊界,這很好。

當我們想要開始/停止一個已經存在的 Service 的時候,有趣的事情來了。比如在 start 方法裡定義的這個型別邊界,只針對一個 T 的值有效,也就是 Stopped 。在我們的例子中,進行狀態切換是一個空操作,它還是會返回相同的例項,同時顯式地轉化為所需要的狀態。由於這個型別沒有被任何東西呼叫,你也不會在這個操作中遇到類轉換異常。

現在我們使用 REPL 來調查下以上的程式碼,作為本章節一個很好的收場:

① scala> val initiallyStopped = Service.create() 
  initiallyStopped: Service[Stopped] = Service@337688d3

②  scala> val started = initiallyStopped.start()  
  started: Service[Started] = Service@337688d3

③  scala> val stopped = started.stop()            
  stopped: Service[Stopped] = Service@337688d3

④  scala> stopped.stop()                          
  <console>:16: error: inferred type arguments [Stopped] do not conform to method stop's
                     type parameter bounds [T >: Stopped <: Started]
              stopped.stop()
                      ^

⑤  scala> started.start()                         
  <console>:15: error: inferred type arguments [Started] do not conform to method start's
                     type parameter bounds [T >: Started <: Stopped]
              started.start()複製程式碼

① 這裡我們建立了一個初始化例項,它開始的狀態是 Stopped
② 成功開啟一個 Stopped 的 service,返回的型別為 Service[Started]
③ 成功結束一個 Started 的 service,返回的型別為 Service[Stopped]
④ 然而結束一個已經停止的 service (Service[Stopped])是無效的,不能通過編譯,注意列印出來的型別邊界
⑤ 類似地,結束一個已經開始的 service (Service[Started])是無效的,不能通過編譯,注意列印出來的型別邊界

正如你所看到的,幽靈型別是另一種強大的工具,可以讓我們的程式碼更加的型別安全(或者我應該說「狀態安全」!?)。

如果你好奇哪些「不是過於瘋狂的類庫」使用了這些特性,這裡一個值得推薦的例子是 Foursquare Rogue(the MongoDB query DSL)。它利用幽靈型別來確保一個 query builder 的狀態正確性,如 limit(3) 被正確地呼叫。

相關文章