泛型基礎 (二) — Java 與 Kotlin 的變型點與 Wildcard 的使用

Jimmy Liu
Kuo’s Funhouse
Published in
12 min readDec 17, 2021

在介紹泛型與變型時,我有提到在 Java 與 Kotlin 中他們是如何被定義與使用。這篇我們來談談在 Java 與 Kotlin 中定義變型的時機吧。

如果還不知道什麼事變型 (Variance) 的,歡迎參考泛型基礎的第一篇。

繼 Java 與 Kotlin 在定義泛型與變型的文章,我們知道他們在如何定義上有所差異:

List <T> // Java 與 Kotlin 的 GenericList <? extends T> // Java Covariance read only
List <out T> // Kotlin Covariance
List <? super T> // Java Contravariance write only
List <in T> // Kotlin Contravariance

但是你可知道定義變型也講究使用點嗎?

在 Java 中,我們無法使用 wildcard 來定義型別的泛型:

class MyClass <? extends T> // compile error, cannot resolve T

這是因為 wildcard 只能用來定義參數。

所以我們最多只能限制 T 所繼承的型別:

class MyClass <T extends OtherClass> // ok
public interface List<E> extends Collection<E> // ok

因為 wildcard 的限制,Java 只能在建立物件時定義我們想要的變型,這個定義變型的時機我們稱為 使用點變型 (Use Site Variance)。

使用點變型 Use Site Variance

建立型別時才定義泛型的變型

Java 範例如下:

public interface List<E> extends Collection<E> {
<T> T[] toArray(T[] a);
//...
}
List <? extends NormalDog> eDogs = new ArrayList<SuperDog>();
List <? super NormalDog> sDogs = new ArrayList<SuperDog>();

當然, Kotlin 也可以用 Use Site Variance 來定義變型,但在 Kotlin 中我們使用了 Type Projection

Type Projection 基本上就是將 Java 變型中的 ? extends/super 濃縮成一個關鍵字 out/in 並取而代之。

class MutableList<T> {}val eDogs: MutableList<out NormalDog> = mutableListOf()
val sDogs: MutableList<in NormalDog> = mutableListOf()

雖然說 使用點變型 能讓程式更有彈性,但每次都要定義還是有點麻煩。如果我希望能建立一個預設就是某變型的類別我該如何做呢?

這時我們就要看看 宣告處變型 (Declaration Site Variance)。

宣告處變型 Declaration Site Variance

設計型別時就設定變型

雖說 Java 的 wildcard 有所使用限制,但如果能用的話,那對我們有什麼好處呢?

Use Site Variance vs Declaration Site Variance

其實這兩種定義變型的方式各有千秋,也被不同的語言所使用。

譬如,Scala 與 C# 便使用了 DSV,而 Java 則選擇 USV。

USV 的優缺點

USV 與 DSV 相較之下更有可塑性,因為 USV 讓使用者隨時隨意地定義出自己需要的變型

但是,這也表示大多數的泛型責任都是由使用者來承擔,所以複雜度也相對增加

DSV 的優缺點

DSV 能幫使用者將部分的變型設計由套件設計師負責,因此減少使用上的複雜度

但由於變型是在宣告時就被設定,所以相對的少了可塑性。

另外,根據 Altidor et al, 2011 [1], 若 Java 也能用 Declaration site,那麼 Java Library 中大約有 39% 的 Use site 可被取代

由此可見,無論是 Use site 與 Declaration site 都各自有各自的優缺點。

而 Kotlin 就是為了滿足兩者 (mix site variance) 而設計出來的。

在定義 宣告處變型 時,Kotlin 也是使用 Type Projection :

public interface List<out E> : Collection<E> {}

這種設計,不僅僅讓我們可以簡單使用 List 也強行定義 List 為 逆變。因此 List 屬於 Read Only。

在了解這些變型的定義點後,你可好奇,Kotlin 中的 wildcard 又要如何定義呢?

Java wildcard

從上一篇得知,Java 的 wildcard 使用方式如下:

List<?> wildList = new ArrayList<Any Type>();List<? extends AnyType> coList 
= new ArrayList<Type that extends from AnyType>
List<? super AnyType> contraList
= new ArrayList<Type that extends from AnyType>

那麼 Kotlin 呢?

Kotlin 其實也有一個類似 wildcard 的關鍵字元:* (又稱作 Star Projection)

以下是 Star Projection 在 Kotlin 中的用法。

Kotlin Star Projection

val ls: List<*> = listOf<Any Type>()

因為 Star Projection 無法跟變型同時使用,因此這就是唯一的使用方法。

那 Star Projection 在不同的變型代表著什麼呢? 為此,我們先創立一個什麼變型都有的類別:

class KBigBox<T, out O, in I> {
private var _t: T? = null
private var _o: O? = null
private var _i: I? = null

fun getT(): T? = _t
fun getO(): O? = _o

fun setT(t: T) { _t = t }
fun setI(i: I) { _i = i }
}

然後我們宣告如下:

val kBigBox: KBigBox<*, *, *> = KBigBox<Int, Int, Int>()

當我們試著呼叫 get/set 時你會發現:

get 回傳的是 T?/O? => Any?

不變與協變都會回傳 Any?

首先,Any 其實就是 Java 中的 Object,但是沒有 null 值。

而 Any? 則是 Object 與 null 值了。

相對的,當呼叫 set 時:

set 帶入的值是 Nothing (無法寫入)

不變與逆變都只能帶入 Nothing

Nothing 比較特別,它表示著一個不存在的值。 所以當輸入型別為 Nothing 時,表示無法輸入。 相反,當方法回傳 Nothing 時,代表這方法不會回傳

所以從上面得到的結論是:

KBigBox <*, out *, in *>讀取時:KBigBox <out Any?, out Any?, ---> // 逆變無法讀取寫入時: KBigBox <in Nothing, ---, in Nothing> // 協變無法輸入

因此,泛型與 Star Projection配合下,讀取會被當作協變處理,而寫入時則會以逆變處理。

另外,當我們類別參數有設 Upper BoundLower Bound 時:

class KBigBox<T: SmartDog, out O: SmartDog, in I: SmartDog>

協變 (out) 設的是 Upper Bound ; 逆變 (in) 則是 Lower Bound

這時:

KBigBox <*: SmartDog, out *: SmartDog, in *: SmartDog>讀取時:KBigBox <out SmartDog?, out SmartDog?, ---> // 逆變無法讀取寫入時: KBigBox <in Nothing, ---, in Nothing> // 協變無法輸入

所以,結合上下結果我們可以得出以下規則

KBigBox <*: T, out *: T, in *: T>讀取時:KBigBox <out T, out T, ---> // 逆變無法讀取寫入時: KBigBox <in Nothing, ---, in Nothing> // 協變無法輸入

經由這些測試,雖然 Kotlin 官方是說 star projection 很像 Java 的 raw type,但是我倒是認為比較像是 wildcard

現在你已經知道泛型可以用在型別 (類別、介面) 上,但好像都沒談過如何使用在方法上。

泛型方法

想在方法中定義泛型有兩種方式:

  1. 針對類別已知的泛型來設計
  2. 針對未知的泛型來設計

Java

public interface List<E> extends Collection<E> {
E get(int index); // 已知泛型
<T> T[] toArray(T[] a); // 為知泛型
}

當然,我們也可以定義出類別泛型方法:

class MyClass<T> {
static <U> void doSomethingWith(U u) {} // ok
static <T> void doSomethingWithT(T t) {} // failed
}

當中要注意的是 static 是無法取得類別的泛型的。

除此之外,我們也可以定出繼承上限:

class MyClass2<T> {
static <U extends Comparable<U>> void doSomethingWith(U u) {}
}

Kotlin

Kotlin 與 Java 的泛型定義很類似:

public interface List<out E> : Collection<E> {
public operator fun get(index: Int): E
// toArray (deprecated) 則是呼叫 Java 中的方法
}

由於 Kotlin 可以隨時隨地定義方法,包括 Extension。 所以我們還可以定義方法如下:

fun <T> getItem(list: List<T>, index: Int): T = list[index]
fun <T> T.getString(): String = this.toString()

當然,Kotlin 也可以加上繼承上限:

fun <T: Comparable<T>> T.getString(): String = this.toString()

結論

變型的定義可以發生在兩個地方:

  1. 使用點變型 (Use Site Variance)
  2. 宣告點變型 (Declaration Site Variance)

目前 Java 雖然只支援使用點變型,但是 USV 的優點在於它的靈活度與可塑性。

而 Kotlin 除了支援使用點變型 也支援 宣告處變型DSV 的優點是將部分的變型的定義責任分給框架設計師,而不是全部都由使用者設計。

相較於 Java 的 wildcard,在 Mixed Site Variance 的情形下,Kotlin 的 Star Projection (*) 在不同的變型下的結果如下:

Foo<T: SomeClass>
Foo<?> 表示 Foo<? extends SomeClass>
Foo<*> 讀取時表示 Foo<out SomeClass> 寫入時表示 Foo<in Nothing>
Foo<out T: SomeClass> (Java 無法使用 Declaration Site Variance)
Foo<*> 表示 Foo<out SomeClass?>
Foo<in T: SomeClass>
Foo<*> 表示 Foo<in Nothing>

若沒有設 SomeClass → 則 Java 預設為 Object,Kotlin 預設為 Any

最後,除了能在型別 (類別、介面) 定義泛型和變型外,我們還可以在方法上定義泛型。而方法也分成兩種:類別方法 、實體方法

Java :

// 類別方法
class MyClass<T> {
static <U> void doSomethingWith(U u) {} // ok
static <T> void doSomethingWithT(T t) {} // failed
}
// 實體方法
public interface List<E> extends Collection<E> {
E get(int index); // 已知泛型
<T> T[] toArray(T[] a); // 為知泛型
}

Kotlin :

// 類別方法
class MyInOutClass<in T, out T2> {
companion object {
fun <U> doSomethingWith(u: U) {
}
}
// 實體方法
public interface List<out E> : Collection<E> {
public operator fun get(index: Int): E
}

Kotlin 除了在型別中定義外,也可以定義全域方法與 extension :

fun <T> getItem(list: List<T>, index: Int): T = list[index]
fun <T> T.getString(): String = this.toString()

有不明白的或是有寫錯的,歡迎留言指教。

如果這篇文章對您有幫助,那就請您分享和給我點掌聲吧 ~~

後序

看完基本泛型的作法時,你有沒有想過 Java 5 才出來的泛型是怎麼向下相容的呢?

那就請繼續看下去

Reference

[1] Altidor, J., Huang, S.S., & Smaragdakis, Y. (2011). Taming the wildcards: combining definition- and use-site variance. PLDI ‘11.

--

--

Jimmy Liu
Kuo’s Funhouse

App Developer who enjoy learning from the ground up.