協變與逆變:Kotlin 和 Java 泛型的深入探討

工程師 Hiking
Dcard Tech Blog
Published in
7 min readJul 14, 2019
Java 中的萬用字元子類別階層可以畫成立方體形狀。(維基百科)

協變(covariance)、逆變(contravariance)和不變(invariance)

Java 的泛型型態是不變的(invariant),意即 List<String> 不是 List<Object> 的子類別。

// Java
ArrayList<String> strs = new ArrayList<String>();
ArrayList<Object> objs = strs; // 這裡編譯器會跟你抗議
objs.add(1);
String s = strs.get(0); // 不然這裡應該拿到什麼?

但是會造成一些合理的操作受到限制:

// Java
interface Collection<E> ... {
void addAll(Collection<E> items);
}
void copyAll(Collection<Object> to, Collection<String> from) {
// 編譯器錯誤:Collection<String> 不是 Collection<Object> 的子類別
to.addAll(from);
}

所以,實際的實作是這樣的:

// Java
interface Collection<E> ... {
void addAll(Collection<? extends E> items);
}

這樣下來 items 可以是 ? extends E通配符類型 wildcard type),用聽起來比較厲害的方式說,指定了邊界的通配符類型,使它成為一種協變的(covariant)類型 。任何 E 的子類別。我們可以放心從 items 裡面取值(必定是 instanceof E),但不能寫入 items(不過也並不代表 items 就是 immutable)。

另一種情況成為逆變(contravariance),比如 List<? super String>

Joshua Bloch(Google 的首席 Java 架構師)稱只能讀取的對象為生產者(Producer),只能寫入的對象為消費者(Consumer)

口訣 PECSProducer extends, Consumer super.

宣告處類型變異 (Declaration-site variance)

但今天一個泛型即使只提供傳出而沒有任何傳入,如下列 Producer<String> 指派給 Producer<Object> 其實是很安全的,因為一個 String 生產者理所當然也是一個很好的 Object 生產者,但編譯器並不知道。

// Java
interface Producer<T> {
T produce(); // 身為生產者,我只提供拿的方法
}
void demo(Producer<String> strProducer) {
Producer<Object> objProducer = strProducer; // 還是會發生編譯錯誤
}

要修好這個錯誤,你可能要把 objects 定義成 List<? extends Object>,但這種寫法除了避開問題以外意義不大,也沒帶來什麼好處:它和 List<Object> 能呼叫的方法沒什麼區別。

在 Kotlin,我們可以使用宣告處類型變異(declaration-site variance)

interface Producer<out T> {
fun nextT(): T
}
fun demo(strProducer: Producer<String>) {
val objProducer: Producer<Any> = strProducer // OKder
}

此處 out 是一個變異標註(variance annotation),讓類型參數變成協變的(covariant),能夠確保 T 只處現在輸出位置,當它被放到輸入位置時就會發生編譯器錯誤。

上述出現在宣告處的用法稱作宣告處類型變異(declaration-site variance),相較於前述 Java 的用法為使用處類型變異(use-site variance)

除了 out 以外還有另一種變異標註 in,用來讓類型參數變成逆變的(contravariant)。

interface Comparable<in T> {
operator fun compareTo(other: T): Int
}
fun demo(x: Comparable<Number>) {
x.compareTo(1.0) // Double 是一個 Number
val y: Comparable<Double> = x // OKder
}

於是 PECS 口訣現在可以變成:

Consumer in, Producer out!

類型投射(Type projections)

宣告處類型變異 (Declaration-site variance) 的類型投射(Type projections)

舉例來說你寫了一個複製陣列的函數:

fun copy(from: Array<Any>, to: Array<Any>) {
assert(from.size == to.size)
for (i in from.indices) {
to[i] = from[i]
}
}

還是會碰到老問題:

val intArray: Array<Int> = arrayOf(1, 2, 3)
val anyArray = Array<Any>(3) { "" }
// 合理,但出現錯誤:from 是 Array<Int> 而不是 Array<Any>
copy(from = intArray, to = anyArray)

上述操作把 Array<Int> 拷貝到 Array<Any> 是合情合理的事,但我們也要確保以下操作是不合法的:

val intArray: Array<Int> = arrayOf(1, 2, 3)
val anyArray = Array<Any>(3) { "" }
// 錯誤,而且必須要出現錯誤:我們正在把 Any 塞進 Int 陣列裡!
copy(from = anyArray, to = intArray)

於是你的函數應當宣告成:

fun copy(from: Array<out Any>, to: Array<Any>) { ... }

這種宣告在 Kotlin 稱作類型投射(Type projection),這裡的涵義是 from 不只是普通的陣列,而是一個被限制(投射)的陣列。這樣一來,我們只能從 from 取值,而不能寫入。

你也可以使用 in 做類型投射:

fun fill(dest: Array<in String>, value: String) { ... }

星號投射(Star projections)

星號投射可以自動根據類別宣告進行投射,讓變數宣告時更方便;有點類似 Java 的原始類型(raw types),但是相較安全。

  • 協變的(covariant) Foo<out T : TUpper>Foo<*> 相當於 Foo<out TUpper>,意即當 T 未知時可以安全從中讀取 TUpper
  • 逆變的(contravariant) Foo<in T>Foo<*> 相當於 Foo<in Nothing>,意即當 T 未知時你無法安全地寫入。
  • 不變的(invariant) Foo<T : TUpper>Foo<*> 讀取時相當於 Foo<out TUpper>,寫入時相當於 Foo<in Nothing>

--

--