如何理解Scala>:迷之翻轉喵 —— 協變逆變全解析 頂 原 薦

At first, 我想談的並不是這隻喵🐱 ~ 👇👇

小雨同學贈送的龍貓

一、背景回顧

熱愛 Scala 的童鞋們,可能都曾見識過這隻 迷之翻轉喵,令人悵然若失,而又神魂顛倒!~

abstract class Cat[-T, +U] {
    def meow[W-](volume: T-, listener: Cat[U+, T-]-)
        : Cat[Cat[U+, T-]-, U+]+
}

說實話,一年前在讀到這段的時候,我並沒有真正搞懂,尤其是協/逆變類型翻轉。書上說:這部分你完全可以跳過,因爲在實際編程實踐中,編譯器會幫你檢查是否寫錯。因此便沒去深究。

最近打算全面進軍 Scala 技術棧,在回顧這門語言的時候,發現唯有這個喵是一直沒有搞懂的地方,這對我這個完美主義者、技術潔癖者來講是個遺憾!

重新翻開原文(中文電子書,其實有買英文原版,爲了向 Martin Odersky 博士致敬),翻譯的有些凌亂:

爲了覈實變化型註解的正確性,Scala 編譯器會把類或特質結構體的所有位置分類爲正、負,或中立。所謂的“位置”是指類(或特質,但從此開始我們只用“類”代表)的結構體內可能會用到類型參數的地方。例如,任何方法的值參數都是這種位置,因爲方法值參數具有類型,所以類型參數可以出現在這個位置上。編譯器檢查類的類型參數的每一個用法。註解了+號的類型參數只能被用在正的位置上,而註解了-號的類型參數只能用在負的位置上。沒有變化型註解的類型參數可以用於任何位置,因此它是唯一能被用在類結構體的中性位置上的類型參數。
爲了對這些位置分類,編譯器首先從類型參數的聲明開始,然後進入更深的內嵌層。處於聲明類的最頂層被劃爲正的位置。默認情況下,更深的內嵌層的位置的分類會與它的外層一致,不過仍有屈指可數的幾種例外會改變具體的分類。方法值參數位置是方法外部的位置的翻轉類別,這裏正的位置翻轉爲負的,負的位置翻轉爲正的,而中性位置仍然保持中立。
除了方法值參數位置外,方法的類型參數的當前類別也會同時被翻轉。而類型的類型參數位置,如 C[Arg] 中的 Arg, 也有可能被翻轉,這取決於對應類型參數的變化型。如果 C 的類型參數標註了+號,那麼類別保持不變。如果 C 的類型參數標註了-號,那麼當前類別被翻轉。如果C的類型參數沒有變化型註解那麼當前類別將改爲中性。
下面是個顯得有點兒生編硬造的例子,我們考慮如下的類型定義,其中若干位置的變化型被標註了+(正的) 或-(負的):

abstract class Cat[-T, +U] {
    def meow[W-](volume: T-, listener: Cat[U+, T-]-)
        : Cat[Cat[U+, T-]-, U+]+
}

類型參數W, 以及兩個值參數,volume 和 listener 的位置都是負的。注意 meow 的結果類型,第一個 Cat[U, T] 參數的位置是負的,因爲 Cat 的第一個類型參數 T 被標註了-號。這個參數中的類型 U 重新轉爲正的位置(兩次翻轉),而參數中的類型 T 仍然是負的位置。

這段話的陳述,不知道你有沒有讀懂,反正我是讀了無數遍,仍不知所云。尤其是最後一句的“翻轉兩次”和“類型 T 仍然是負的位置”,說明作者已經把自己繞暈了。其實並不是那個“位置”(Cat[Cat[U+)的 U 因爲翻轉了兩次又變回來,而是由於 內層 Cat 佔用了外層-號標註的位置而必須翻轉,使得內層本來是負的位置翻轉變成了正的,然後正的位置只能使用+號標註的參數(即協變),只能用 U, 所以那裏出現了 U。

二、重走絲路(重溫類型系統)

既然教材和網文都說不清楚這個 Cat 的來龍去脈,我們不妨自己試着從設計者的角度去思考探索這個規則是如何建立起來的。不管怎麼說,能夠把一個 全功能的 圖靈完備 的類型系統的 類型安全推斷模型,高度概括並簡化到如此精練的程度,非智者所能爲也。讓我們去一探究竟!

  abstract class Cat[-T, +U, -X, +Y, A] {
    def meow[W](volume: T, listener: Cat[U, T, Y, Cat[U, T, Cat[T,
                Cat[A, U, Cat[U, T, A, A, A], Y, A], X, Y, A], X, A], A])
    : Cat[T, U,
        Cat[Cat[T, U, X, Y, A], Cat[U, T, Y, X, A], U, T, A],
        Cat[T, U, X, Y, A],
        Cat[A, A, A, A, A]]
  }

關於翻轉的問題,或許用複雜一點的代碼更容易找到規律。這裏先貼一段已經編譯通過的原文 code 的變種,先睹爲快,留到最後再詳解。

1. 基本公理

我們都知道,子類對象父類型變量 賦值是天經地義的 —— 因爲這樣做只能導致 父類型變量 使用更少的、子類型必然都有的功能,這沒有任何邏輯衝突而導致運行錯誤,因此是合理的 —— 我們稱之爲對象的 上轉型對象

先賢們設計定義了 繼承賦值 這些基本概念,並推導出了 上轉型對象 的合理性,這些概念如同:

順序分支循環 三種結構可以構建一切 過程

一樣基礎,是程序世界的 基本思想、世界觀、準則共識,是憲法,在任何時候都是合理的。在這裏我將它們稱爲 基本公理

2. 程序邏輯與公理

之所以稱爲 基本公理,是因爲在多年的編程實踐中,我發現:

任何對 對錯 的判定,都是以是否違背這些公理爲準則。

這個判定的執行者,包括編譯器、運行時等。即:這些環境的設計者同樣是遵循基本公理的。

3. 參數化類型

參數化類型 是 Scala 特有的概念,類似 Java 的泛型,包括 協變(用 + 號標記,如:class A[+T])、逆變(用 - 號標記,如:class A[-T]) 和 無變化型(無標記,如:class A[T]。事實上,Java 泛型是 Scala 參數化類型 的一個子集,即:無變化型。可能有人會問:Java 的 A<? super B> 難道不是變化型嗎?還真不是,這是 上界下界,等同於 Scala 的 A[T >: B])。

參數化類型 的實例也需要賦值,那麼類型之間應該具備怎樣的關係纔可以賦值,而什麼關係不可以,這是個問題。如果按照 上轉型對象 的理論來界定,問題最終轉換爲:

到底 的子類。

“這還用問嗎?父類就是父類,子類就是子類!” —— 這是初學編程的夥伴們的第一反應。

“父類還是父類,子類還是子類,但只有泛型參數類型 相同 的纔可以賦值!” —— 這是 Java 轉型過來的夥伴的第一反應。

我要告訴你的是:到底 誰是誰的子類 這個問題,還真不是一眼就能看出來的。 先來看個例子:

  class A[+T]
  class B[T] extends A[T]
  class C[-T]
  class D[-T] extends C[T]
  class X
  class Y extends X

  val aaxx: A[X] = new A[X]
  val aaxy: A[X] = new A[Y]
  val abxx: A[X] = new B[X]
  val abxy: A[X] = new B[Y]
  val aayx: A[Y] = new A[X] // 報錯
  val aayy: A[Y] = new A[Y]
  val abyx: A[Y] = new B[X] // 報錯
  val abyy: A[Y] = new B[Y]

  val dcxx: D[X] = new C[X] // 報錯
  val dcxy: D[X] = new C[Y] // 報錯
  val dcyx: D[Y] = new C[X] // 報錯
  val dcyy: D[Y] = new C[Y] // 報錯
  val ddxx: D[X] = new D[X]
  val ddxy: D[X] = new D[Y] // 報錯
  val ddyx: D[Y] = new D[X]
  val ddyy: D[Y] = new D[Y]

這段代碼中,我窮舉出了 賦值 的所有情況,顯然有些是合理的,有些不可以。爲避免話題戰線拉得太長,先來總結一句話以說明 在參數化類型中,關於 誰是誰的子類 這個問題到底是怎麼定義的(雖然有點 事後諸葛 的嫌疑):

假如一個 變量(或常量)可以被某 實例 合法的(編譯通過)賦值,那麼這個 實例 類型就是該 變量 定義類型的子類型(或本身)。

B[Y]A[X] 的子類型(很正常)、D[X]D[Y] 的子類型(要開始 顛覆 了)。簡述一下相關定義:

  • 由於定義了 A[+T]T協變 的,即:

同時是 AT 的子類(或本身)的參數化類型,纔是 A[+T] 的子類。

所以 B[Y]A[X] 的子類型。

  • 由於定義了 D[-T]T逆變 的,即:

同時是 D子類(或本身)且是 T父類(或本身)的參數化類型,纔是 D[-T] 的子類。

所以 D[X]D[Y] 的子類型。

但從這個例子中,我們無法看出 協變逆變 概念的設計到底有何意義,難道僅僅是爲了多樣性嗎?當然不是!

三、用途決定變化型

參數化類型的設計,是爲了在具有嚴格、完備、強制的類型檢查環境下,同時提供更多的靈活性,讓我們開發者具有更多的選擇,使得程序變得更加豐富和有趣,同時讓一些本需要繞道而行的寫法有了 捷徑。如何定義變化型,應該視具體應用場景而定。相信在讀完下面的場景化分析之後,你會對之前產生的問題有一個清晰的答案。

1. 集合類用途

  • 假設 List 是協變的即 List[+T] ,會發生什麼?(這裏先以 java.util.List 爲例,下同)

    class Animal
    class Cat extends Animal {
      def run(): Unit
    }
    class Bird extends Animal {
      def fly(): Unit
    }
    
    val cats: util.List[Cat] = new util.ArrayList[Cat]()
    cats.add(new Cat)
    val list: util.List[Animal] = cats  // 對於協變的 List 來說,合法。
    addAnimals(list)
    list.foreach { t =>
      t.as[Cat].run() // 類型轉換錯誤
    }
    
    def addAnimals(list: util.List[Animal]) {
      list.add(new Bird)  // 悖論
    }
    

    如果 List 是協變的,則 list 變量的賦值合法,但後面的某些操作就有問題了,雖然從變量定義的角度來看,似乎並沒有問題:

    add(t: T) 方法接受 T 類型的變量,在定義上是 Animal 類型,此時 add Bird 類型實例,即:將 Bird 實例賦值給 Animal,符合前邊講到的 基本公理,所以沒問題。

    但由於在內存中運行的是 ArrayList[Cat] 的實例,即任何操作都會當做 Cat 來處理,雖然邏輯上是把 Bird 實例賦值給 Animal,實際上都是當做 Cat 進行處理,其中必然存在着諸多強制類型轉換,後續操作也會調用 Cat 的相關方法,顯然把實際塞進去的 Bird 強轉爲 Cat 是不合理的,違背了 基本公理,而 Bird 也沒有 run() 方法。我們應該阻止這種事情發生,怎麼阻止?因爲 問題的根源是協變,因此應該將其改爲逆變或不變。

  • 假設 List 是逆變的,會發生什麼?

    class Animal
    class Cat extends Animal {
      def run(): Unit
    }
    class Bird extends Animal {
      def fly(): Unit
    }
    
    val anims: util.List[Animal] = new util.ArrayList[Animal]()
    anims.add(new Cat)
    val list: util.List[Bird] = anims  // 對於逆變的 List 來說,合法。
    addBirds(list)
    list.foreach { t =>
      t.fly()  // 叫 add 進去的 Cat 怎麼想?
    }
    
    def addBirds(list: util.List[Bird]) {
      list.add(new Bird)
    }
    

    對於逆變的 List來說,也存在着類似協變的問題。因此也需要阻止違背 基本公理 的事情發生。

總結:變化型會導致集合類的相關操作出現違背 基本公理 的情況,因此集合類通常必須是無變化型的。

從源碼中可以看到,java.util.List[E] 是沒有變化型的(本來就不支持協/逆變),但 Scala 的 scala.collection.mutable.ListBuffer[A] 也是沒有變化型的,其它如 mutable.HashMap[A, B] 等也都是。

可能你要問了,爲什麼 immutable.List[+A] 是協變的?這個 List 其實是鏈表的一個 元素,而不是同上面例子一樣的 真正的列表。而爲了讓元素具備 Elem[Sub] 可以給 Elem[Super] 賦值這樣的能力,才爲其定義了協變。

2. 其它類用途

前兩個月重構技術棧,打算全面運用 Scala 開發 Android, 包括徹底扔掉 gradle 構建工具。sbt-android 能夠爲我們自動生成很多代碼,例如所有在 layout xml 中定義了 android:idView 都會自動生成在 TypedViewHolder[V <: View] 裏面(題外話:生成這麼多東西會不會導致臃腫多餘呢,Proguard 是幹嘛的,順便推薦我的工具集 Annoguard這裏)。

話說,TypedViewHolder[V <: View] 雖然很棒,但導致了一個 intellij-idea 語法不兼容的問題,雖然編譯沒問題,但看着礙眼。我有一個 implicit 工具可以 fix 這類問題,但是需要這個自動生成的類是協變的:

trait TypedViewHolder[+T <: View] {
  val rootViewId: Int
  val rootView: T
}

如果協變,則 val tvg: TypedViewHolder[ViewGroup] = new TypedViewHolder[LinearLayout] { //... } 這個賦值是合法的,也導致了其中變量 rootView 的類型由 LinearLayout 變成了 ViewGroup,但這顯然沒有任何問題。而同時也使得我的另一個工具起作用了。

可以看到,在這個應用場景下,這個增加協變能力的更改,完美的 fix 了幾個問題,不僅增強了功能,而且非常和諧。 事實上,前一節提到的 immutable.List[+A] 與本應用場景類似,而它就是協變的。

總結:在合適的場景下合理的運用 變化型 會發揮意想不到的效果,往往事半功倍。

四、變化型翻轉

相信到這裏,我們對參數化類型的變化型有了全新的認識,那麼回到最初的問題 —— 迷之翻轉喵~

  abstract class Cat[-T, +U, -X, +Y, A] {
    def meow[W](volume: T, listener: Cat[U, T, Y, Cat[U, T, Cat[T,
                Cat[A, U, Cat[U, T, A, A, A], Y, A], X, Y, A], X, A], A])
    : Cat[T, U,
        Cat[Cat[T, U, X, Y, A], Cat[U, T, Y, X, A], U, T, A],
        Cat[T, U, X, Y, A],
        Cat[A, A, A, A, A]]
  }

略過教材版本,直接上手這個複雜版。

(未完待續)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章