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

ScalaCool發表於2017-07-16

上一篇

Scala 型別的型別(四)

目錄

21. 結構型別

結構型別(Strucural Types)經常被描述為「型別安全的鴨子型別(duck typing)」,如果你想獲得一個直觀的理解,這是一個很好的比較。

迄今為止,我們在型別方面考慮的都是這樣的問題:「它實現了介面 X 嗎?」,有了「結構型別」,我們就可以深入一步,開始對一個指定物件的結構(因此得名)進行推理。當我們在檢查一個採用了結構型別的型別匹配問題時,我們需要把問題改為:「這裡存在帶有這種簽名的方法嗎?」。

讓我們舉一個很常見的例子,來看看它為什麼如此強大。想象你有很多支援被 closed 的東西,在 Java 裡,通常會實現 java.io.Closeable 介面,以便寫出一些常用的 Closeable 工具類(事實上,Google Guava 就有這樣的一個類)。現在再想象有人還實現了一個 MyOwnCloseable 類,但沒有實現 java.io.Closeable 。由於靜態型別的緣故,你的 Closeables 類庫就會出問題,你就不能傳 MyOwnCloseable 的例項給它。讓我們使用結構型別來解決這個問題:

type JavaCloseable = java.io.Closeable
// reminder, it's body is: { def close(): Unit }

class MyOwnCloseable {
  def close(): Unit = ()
}


// method taking a Structural Type
def closeQuietly(closeable: { def close(): Unit }) =
  try {
    closeable.close()
  } catch {
    case ex: Exception => // ignore...
  }


// accepts a java.io.File (implements Closeable):
closeQuietly(new StringReader("example"))

// accepts a MyOwnCloseable
closeQuietly(new MyOwnCloseable)複製程式碼

這個結構型別被作為方法的一個引數。基本上可以說,我們對這個型別唯一的期望就是它應該存在內部(close)這樣一個方法。它可以擁有更多的方法,因此這裡並不是一個完全匹配,而是這個型別必須定義最小的一組方法,這樣才能有效。

另外需要注意的是,使用結構型別對執行時效能存在很大的負面影響,因為實際上它是通過反射實現的。我們這裡不再通過位元組碼來調研了,記住檢視 scala (或 java)類生成的位元組碼是一件很容易的事情,只需使用 :javap in the Scala REPL ,所以你應該自己試一試。

在我們進入下一個話題之前,再來講一種精煉的使用風格。想象你的結構型別相當的豐富,比如是一個代表某種事物的型別,你可以開啟它,使用它,然後必須關閉。通過使用「型別別名」(在另一部分中有詳細描述)與「結構型別」,我們就可以將型別定義與方法分離,做法如下:

type OpenerCloser = {
  def open(): Unit
  def close(): Unit
}

def on(it: OpenerCloser)(fun: OpenerCloser => Unit) = {
  it.open()
  fun(it)
  it.close()
}複製程式碼

通過使用這樣一個型別別名,def 的部分變得更加清晰了。我極力推薦這種「對更大的結構型別採用型別別名」的做法,同時也最後提醒大家,確認自己是否真的沒有其它辦法了,再決定採用結構型別。你需要多考慮它負面的效能影響。

22. 路徑依賴型別

這個型別(Path Dependent Type)允許我們對型別內部的型別進行「型別檢查」,這看起來似乎比較奇怪,但下面的例子非常直觀:

class Outer {
  class Inner
}

val out1 = new Outer
val out1in = new out1.Inner // concrete instance, created from inside of Outer

val out2 = new Outer
val out2in = new out2.Inner // another instance of Inner, with the enclosing instance out2

// the path dependent type. The "path" is "inside out1".
type PathDep1 = out1.Inner


// type checks

val typeChecksOk: PathDep1 = out1in
// OK

val typeCheckFails: PathDep1 = out2in
// <console>:27: error: type mismatch;
// found   : out2.Inner
// required: PathDep1
//    (which expands to)  out1.Inner
//       val typeCheckFails: PathDep1 = out2in複製程式碼

這裡你可以理解為「每個外部類都有自己的內部類」。所以它們是不同的型別 — 差異取決於我們使用哪種路徑獲得。

使用這種型別很有用,我們能夠強制從一個具體引數的內部去獲得型別。一個具體的採用該型別的簽名如下:

class Parent {
  class Child
}

class ChildrenContainer(p: Parent) {
  type ChildOfThisParent = p.Child

  def add(c: ChildOfThisParent) = ???
}複製程式碼

我們現在使用的路徑依賴型別,已經被編碼到了型別系統的邏輯中。這個容器應該只包含這個 ParentChild 物件,而不是任何 Parent

我們將很快在 型別投影 章節中看到如何引入任何一個 ParentChild 物件。

23. 型別投影

型別投影(Type Projections)類似「路徑依賴型別」,它們允許你引用一個內部類的型別。在語法上看,你可以組織內部類的路徑結構,然後通過 # 符號分離開來。我們先來看看這些路徑依賴型別(. 語法)和型別投影(# 語法)的第一個且主要的差別:

// our example class structure
class Outer {
  class Inner
}

// Type Projection (and alias) refering to Inner
type OuterInnerProjection = Outer#Inner

val out1 = new Outer
val out1in = new out1.Inner複製程式碼

另一個準確的直覺是相比「路徑依賴」,「型別投影」可以用於「型別層面的程式設計」,如 (存在型別)Existential Types。

「存在型別」是跟「型別擦除」密切相關的東西。

val thingy: Any = ???

thingy match {
  case l: List[a] =>
     // lower case 'a', matches all types... what type is 'a'?!
}複製程式碼

因為執行時型別被擦除了,所以我們不知道 a 的型別。我們知道 List 是一個型別構造器 * -> * ,所以肯定有某個型別,它可以用來構造一個有效的 List[T]。這個「某個型別」,就是 存在型別

Scala 為它提供了一種快捷方式:

List[_]
 //  ^ some type, no idea which one!複製程式碼

假設你在使用一些抽象型別成員,在我們的例子中將會是一些 Monad 。我們想要強制我們的使用者只能使用這個 Monad 中的 Cool 例項,因為比如我們的 Monad 只有針對這些型別才有意義。我們可以通過這些存在型別 T 的型別邊界來實現:

type Monad[T] forSome { type T >: Cool }複製程式碼

mikeslinn.blogspot.com/2012/08/sca…

譯者注:

建議閱讀以下文章,以加深對本部分的理解:

24. Specialized Types

24.1. @specialized

型別專業化(Type specialization)與普通的「型別系統的東西」相比,更多的是一種效能方面的技巧。但如果你想編寫出良好效能的集合,它是非常重要的,我們需要掌握它。舉個例子,我們將實現一個非常有用的集合,稱為 Parcel[A],它可以儲存一個指定型別的值 — 確實有用!

case class Parcel[A](value: A)複製程式碼

以上是我們最基本的實現。有什麼問題嗎?沒錯,因為 A 可以是任何東西,所以它就會被表示為一個 Java 物件,就算我們僅對 Int 值進行裝箱。因此上面的類會導致對原始值進行裝箱和拆箱,因為容器正在處理物件:

val i: Int = Int.unbox(Parcel.apply(Int.box(1)))複製程式碼

眾所周知,當你不是真正需要的時候,裝箱不是一個好主意,因為它通過在 intobject Int 之間進行來回轉換,產生了更多執行時的工作。怎樣才能消除這個問題呢?一種技巧就是將我們的 Parcel 對所有的原始型別進行「專業化」(這裡拿 LongInt 做例子就夠了),如下:

如果你已經閱讀過 value 類,那麼也許已經注意到 Parcel 可以用它很好地代替實現!確實如此。然而,specialized 在 Scala 2.8.1 中就有了,相對地 value 類是在 2.10.x 才被引進。並且,前者能夠專業化一種以上的值(雖然它以指數級增長生成程式碼),value 類卻只能限制為一種。

case class Parcel[A](value: A) {
  def something: A = ???
}

// specialzation "by hand"
case class IntParcel(intValue: Int) {
  override def something: Int = /* works on low-level Int, no wrapping! */ ???
}

case class LongParcel(intValue: Long) {
  override def something: Long = /* works on low-level Long, no wrapping! */ ???
}複製程式碼

IntParcelLongParcel 的實現將有效地避開裝箱,因為它們直接在原始值上進行處理,並且無需進入物件領域。現在我們只需根據我們的例項,選擇想要的 *Parcel

這看起來很好,但是程式碼基本上變得更難維護了。它有 N 個實現,每種我們需要支援的原始值型別各一個(如包括:int, long, byte, char, short, float, double, boolean, void, 再加上 Object)! 這需要維護很多樣板。

既然我們已經熟悉了「型別專業化」,也知曉了手動實現它並不是很友好,就來看看 Scala 是如何通過引入 @specialized 註解來幫我們改善這個問題:

case class Parcel[@specialized A](value: A)複製程式碼

如上所示我們將 @specialized 註解應用到了型別引數 A 上,從而指示編譯器生成該類的所有專業化變數,它們是:ByteParcel, IntParcel, LongParcel, FloatParcel, DoubleParcel, BooleanParcel, CharParcel, ShortParcel, CharParcel 甚至以及 VoidParcel (這並不是實際的名字,但你應該明白了大概的意思)。編譯器也同時承擔呼叫正確的版本,所以我們只需要專心寫程式碼,而不必關心一個類是否被專業化了,編譯器會盡可能使用適合的版本(如果有的話):

val pi = Parcel(1)     // will use `int` specialized methods
val pl = Parcel(1L)    // will use `long` specialized methods
val pb = Parcel(false) // will use `boolean` specialized methods
val po = Parcel("pi")  // will use `Object` methods複製程式碼

「太棒了,讓我們盡情使用它吧」 — 這是大部分人發現「專業化」帶來的好處之後的反應,因為它可以在降低記憶體使用率的同時成倍的加速低階操作。不幸的是,它的代價也很高:當使用多個引數時,生成的程式碼量很快變得巨大,就像這樣子:

class Thing[A, B](@specialized a: A, @specialized b: B)複製程式碼

在上面的例子中,我們使用了第二種應用專業化的風格 — 加在引數上,這效果等同於我們直接對 AB 進行專業化。請注意,上述程式碼將生成 8 * 8 = 64 種實現,因為它必須處理如「A 是一個 int,B是一個 int」以及「A 是一個 boolean,但是 B 是一個 long」的情況 — 你可以看到這是在哪裡。事實上生成的類的數量大約在 2 * 10^(nr_of_type_specializations),對於已經有了 3 個型別引數的情況,它很容易達到了數千個類。

有一些方法可以防止這個「指數級爆炸」,例如通過限制專業化的目標型別。假設 Parcel 大部分情況只處理整數,從不跟浮點數打交道,我們就可以編譯器只專業化 LongInt,如:

case class Parcel[@specialized(Int, Long) A](value: A)複製程式碼

這次讓我們使用 :javap Parcel 來研究一點位元組碼:

// Parcel, specialized for Int and Long
public class Parcel extends java.lang.Object implements scala.Product,scala.Serializable{
    public java.lang.Object value(); // generic version, "catch all"
    public int value$mcI$sp();       // int specialized version
    public long value$mcJ$sp();}     // long specialized version

    public boolean specInstance$();  // method to check if we're a specialized class impl.
}複製程式碼

如你所見,編譯器提供了額外的專業化方法,如 value$mcI$sp(),它將返回 intlong 也有類似的方法。值得一提的是這裡還有另外一個叫做 specInstance$ 的方法,如果使用的實現是一個專業化的類,它會返回 true

可能你比較好奇當前在 Scala 中哪些類被專業化了,它們有(可能不完整):Function0, Function1, Function2, Tuple1, Tuple2, Product1, Product2, AbstractFunction0, AbstractFunction1, AbstractFunction2 。由於當前專業化 2 個引數的成本已經很高,一個趨勢是我們不要再專業化更多的引數了,雖然我們可以這麼幹。

為什麼我們要避免進行裝箱,一個典型的例子就是「記憶體效率」。想象一個 boolean 值,如果它的儲存只消耗 1 位那是極好的,可惜它不是(包含我瞭解的所有 JVM),例如在 HotSpot 上一個 boolean 被當做一個 int,所以它要佔用 4 個位元組的空間。它的兄弟 java.lang.Boolean 類似所有 Java 物件一樣,則有 8 位元組的物件頭,然後再儲存 boolean (額外增加 4 位元組)。由於 Java 物件佈局的排列規則,這個物件佔用的空間再分配 16 位元組(8 個位元組給物件頭,4 個位元組給值,4 個位元組給 填充)。這就是為啥我們希望避免裝箱的另外一個悲傷的原因。

24.2. Miniboxing

❌ 該章節作者尚未完成

這不是 Scala 的一個特性,但是可以與 scalac 一起作為編譯器外掛。

我們已經在上一節解釋了,專業化非常強大,但同時也是一個「編譯器炸彈」,具有指數級程式碼增長的問題。現在已經有一個被證實的概念可以解決這個問題,Miniboxing 是一個編譯器外掛,它實現了 @specialized 相同的效果,然而卻不會生成數千個類。

TODO, there’s a project from withing EPFL to make specialization more efficient: Scala Miniboxing

25. Type Lambda

❌ 該章節作者尚未完成

在 type lambda 的部分我們會使用 「路徑依賴型別」及 「結構型別」,如果你忽略了這兩個章節,你可以先跳回去看看。

在瞭解 Type Lambdas 之前,讓我們先回顧下關於「函式」和「柯里化」的某些細節:

class EitherMonad[A] extends Monad[({type λ[α] = Either[A, α]})#λ] {
  def point[B](b: B): Either[A, B]
  def bind[B, C](m: Either[A, B])(f: B => Either[A, C]): Either[A, C]
}複製程式碼

相關文章