泛型基礎 (一) — Java 與 Kotlin 的變型

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

參數多型 (Parametric Polymorphism)是一種將類型參數化進而讓程式設計更為彈性的編程概念。 這便是大家所熟知的 Generic (泛型)。

泛型

泛型是 1975年 在 ML (Meta Language) 中推出的程式編寫風格。

泛性的主要特色就是型別 (類型與介面) 的參數化 (Parameterized)。

經由參數化的型別,我們可以在建立具有泛型型別的物件時,將目標型別代入其中。

以下是 使用泛型前 與 使用泛型後 的比較:

假設我們現在有個能像盒子一樣存取任何物件的類別 (Box)

因為無法使用泛型,因此放入 Box 中的物件會以 Object 做為參數類別。

Non-Generic Box

由於無法知道內部的 object 是那個類別,因此很有可能會發生放入和期許取出的類別不一致。

但是因為 compiler 無法偵測,所以會直接導致 runtime error

Box box = new Box();
box.set(1);
String str = (String)box.get(); // runtime error

若使用了泛型,我們就能將 Box 定義成一個 泛型類型 (Generic Type):

其中,我們稱 <T>類型參數 (Type Parameter)。

當我們要使用或建立這泛型類型時,我們就可以給予 <T> 一個特定的類型並形成 參數類別 (Parameterized Type, Box<String>):

Box<Integer> intBox = new Box<>();
Box<String> strBox = new Box<>();
// raw type
// 會將 T 設成 Object
Box rawBox = new Box();

這下就建立了 參數類別 (Parameterized Type)。

在 Java 中,使用泛型後其中之一的重要特色就是 編輯器 可以即時偵測類別是否正確並即時匯報錯誤。

當然,除此之外,泛型的主要優點還包括了:

增加程式的可塑性 (Flexibility)

重複使用性 (Reusability)

編輯器的型別安全性 (Type safety)

雖說泛型的加入使得編程順暢許多,但是其實泛型本身還是有所缺陷的。

變型 (Variance)

Variance refers to how subtyping between more complex types relates to subtyping between their components. [wiki]

首先,subtype 指的是子類型

若 S 為 T 的子類型,我們就可以說 S is a subtype of T
並且可以用 :> 來表明繼承關係 (subtyping relation): T :> S
另外,我們可以說:
S is more SPECIFIED than T
T is more GENERIC than S

而變型指的是 繼承關係 在更為複雜的情況下是否能維持 T :> S 的關係。

變型有分成四種:

  1. 協變 (Covariance) : 保留父子關係 (T :> S=> I(T) :> I(S))
  2. 逆變 (Contravariance):與協變相反 (T :> S => I(S) :> I(T))
  3. 雙變 (Bivariance) : 協變、逆變兩者皆可 (T :> S => I(S) ≡ I(T)) (Java 中並不存在)
  4. 不變 (Invariance) : 兩者皆不可

其中 I(X) 指的就是使用 X 為參數類型的泛型。

這些變型的用途應該很難用文字理解,所以我們就用例子來邊做邊學吧

假設我們是一位天才科學家。

經過多次試驗後,我們成功地將NormalDog 進化成 SmartDog 與 SuperDog:

NormalDog :> SmartDog :> SuperDog

本質上,他們會做的事都一樣 ,只是 IQ 有所不同:

class NormalDog {
int iq;
NormalDog(int iq) {this.iq = iq;}
void eat() {}
void drink() {}
}
class SmartDog extends NormalDog {
SmartDog(int iq) { super(iq); }
}
class SuperDog extends SmartDog {
SuperDog(int iq) { super(iq); }
}

因為我們需要餵食這些狗狗們,所以我就寫了個餵食的方法 (feed):

static void feed(NormalDog dog) {}feed(new NormalDog(...)); // pass
feed(new SmartDog(...)); // pass
feed(new SuperDog(...)); // pass

又因為 NormalDog :> SmartDog :> SuperDog,所以很理所當然地只要是 NormalDog 的子類別都會通過測試。

但是,若我們想要餵食一群呢?

不變 (Invariance)

若要餵食一群的狗,我們就會設計 feedDogs 方法如下:

static void feedDogs(List<NormalDog> dogs) {}feedDogs(normalDogs); // pass
feedDogs(smartDogs); // compile error
feedDogs(superDogs); // compile error

由上的測試結果可得出以下結論:

雖然 NormalDog :> SmartDog :> SuperDog

但是,I(NormalDog) 、I (SmartDog) 與 I (SuperDog) 毫無關係。

這裡的 I 指的是 List

由此可知泛型是一種不變型 (Invariance)。

那麼,我們要如何解決這問題呢?

協變 (Covariance)

由於我們希望 feedDogs 可以接受

List<NormalDog>、List<SmartDog> 與 List<SuperDog>,這表示我們需要 :

T :> S 時 List<T> :> List<S>

這正是 協變 的特性。

以下便是 Java 與 Kotlin 定義 協變 的語法:

List<? extends T> // Java
List<out T> // Kotlin

其中,Java 所用的 「?」被稱為 wildcard,而 Kotlin 的 out 則是 variance annotation

當然,Kotlin 也有一個與 wildcard 相似的關鍵字元:「*」, 我們稱之為 Star Projection。

Wildcard 的功用就是用來表示一個「未知的型別」。

? extends T 則表示 一個未知但繼承 T 的型別

換句話說

? extends T 定義了這未知類型的繼承上限 (Upper Bound)

另外,當我們只定義 List<?> 時,這會被看成 List<? extends Object>。

所以當我們將 feedDogs 改成:

static void feedDogs(List<? extends NormalDog> dogs) {}feedDogs(normalDogs); // pass
feedDogs(smartDogs); // pass
feedDogs(superDogs); // pass

feedDogs 就可以接受 NormalDog 與其繼承者們。

那麼,協變有什麼限制嗎?

其實,當我們定義出協變 (? extends NormalDog) 時,List 只會知道內部有 NormalDog 的子類別,卻無法得知到底確切是哪個。

List<? extends NormalDog> 是 List<NormalDog>、List<SmartDog> 還是 List<SuperDog> 呢 ?

因為這原因,所以 List<? extends T> 無法寫入除了 null 以外的任何物件

雖說 List<? extends T> 無法提供寫入功能,但因為 List 確定內部的物件一定是 NormalDog 類型,所以還是可以提供讀取功能。

也因為如此,我們也稱 協變的參數類別 為 Producer。

IQ 排名

接下來,我們想要知道實驗結果,所以需要知道狗狗們的 IQ 排名。

當然,我們可以用內建的 List#sort(Comparator c) 方法,但是我們還是自己來實作一個 sort 方法吧 ~

static <T> void sort(List<T> list, Comparator<T> comparator) {
int minIndex = -1;
for (int i = 0; i < list.size() - 1; i++) {
minIndex = i;
// j = i + 1 可減少一次判斷
for (int j = i + 1; j < list.size(); j++) {
// smaller => swap
if (comparator.compare(list.get(minIndex),
list.get(j)) < 0)
{
minIndex = j;
}
}
if (minIndex != i) {
// swap
swap
(list, i, minIndex);
}
}
}

這方法是運用 Selection Sort 來進行。

當中的 Comparator 其實是一個介面:

public interface Comparator<T> {
int compare(T o1, T o2);
}

它的實作如下:

Comparator<T> comparator = new Comparator<T>() {
@Override
public int compare(T o1, T o2) {
return o2.xxx - o1.xxx;
}
};

整個邏輯是:

只要 前者(o1) 比 後者 (o2) 大,o1 就會被放置在 o2 後面。

由於 泛型 本身是 Invariance,所以我們只能如此使用:

sort(normalDogs, normalDogComparator);
sort(smartDogs, smartDogComparator);
sort(superDogs, superDogComparator);

這時你可能想,我們是否能用一個共用的 Comparator 呢?

第一個想法當然就是使用 Comparator<? extends T> 了。

但是,下面這行卻會出現 compile error:

這是由於,int compare(? extends T o1, ? extends T o2) 中的 o1, o2 是 協變 所以無法接收 T 的類型。

那我們該怎麼辦呢?

我們需要一個可以寫入,但卻能夠接受 NormalDog、SmartDog 與 SuperDog 類別的變型。

逆變 (Contravariance)

定義 : T :> S => I (T) <: I(S)

不同於協變,逆變用的關鍵字是 super in,而使用方法如下:

List<? super T> // Java
List<in T> // Kotlin

這行的意思是

List 所包含的類型是 某未知 且 是被 T 類型繼承的類型

譬如,當 T 是 NormalDog 時,S 可以是 SmartDog 與 SuperDog:

List<? super NormalDog> suDs = new ArrayList<SuperDog>(); // failed
List<? super NormalDog> smDs = new ArrayList<SmartDog>(); // failed
List<? super NormalDog> nDs = new ArrayList<NormalDog>(); // pass

根據 逆變 的定義:

NormalDog :> SmartDog , 因此 List<SmartDog> :> List<NormalDog>

因為我們不可能將父類 (List<SmartDog>)別指向子類別 (List<NormalDog>),這是將物件由 Generic 至 Specific,就如同:

NormalDog dog = new SmartDog(1); // pass
SmartDog nDog = new NormalDog(1); // failed

因此,List<? super NormalDog> 只能接受 List<NormalDog> 和 List<Object>。

所以,逆變其實是給型別定義了一個 下限類別 (Lower Bound)

List<? super SuperDog> suDs = new ArrayList<SuperDog>(); // pass
List<? super SuperDog> smDs = new ArrayList<SmartDog>(); // pass
List<? super SuperDog> nDs = new ArrayList<NormalDog>(); // pass

那,逆變 又有哪些限制呢?

由於 List<? super SmartDog> 只知道內部放的是 某個未知 且 被 SmartDog 繼承的類別。

所以,I<? super T> 無法被讀取。 但是,卻可以被寫入,只是被寫入的資料必須要繼承 T 的型別

也因為如此,所以 逆變 也被稱為 Consumer

既然我們知道 逆變 可以被寫入,且必須繼承 T 類型。

因此,我們可以改寫 sort 中的 Comparator 為:

static <T> void sort(List<T> list, Comparator<? super T> comparator)

而如果我們希望全部狗狗都用相同的 Comparator,我們的 T 自然是 NormalDog 了,因為 NormalDog 是狗狗共同繼承的類別:

Comparator<NormalDog> superDogComparator = 
new Comparator<NormalDog>() {
@Override
public int compare(NormalDog o1, NormalDog o2) {
return o2.iq - o1.iq;
}
};

口訣

我們可以用一句話將 Java 變型所用的語法記起來:

PECS

Producer Extends Consumer Super

Kotin 中雖然沒有,但是我們還是可以寫一個出來的:

POCI

Producer Out Consumer In

貨出得去,人進得來

補充

最後我想補充一點,雖然 Producer 是 Read-only 而 Consumer 是 Write-only,但是對 Kotlin 而言,這些都可以用 annotation @UnsafeVariance 來解決:

open class KBigBox<T, out O, in I> {
private var _t: T? = null
private var _o: O? = null
private var _i: I? = null
private val ls: List<T> = listOf()
private val ml: MutableList<T> = mutableListOf()

fun getT(): T? = _t
fun getO(): O? = _o
fun getI(): @UnsafeVariance I? = _i

open fun setT(t: T) { _t = t }
open fun setO(o: @UnsafeVariance O) { _o = o }
open fun setI(i: I) { _i = i }
}

總結

我們在這篇學到在 Java 與 Kotlin 中,變型有三種:

Invariance

  • 一般泛型
  • 使用固定類型,所以彈性或可塑性不夠

Covariance (aka Producer)

  • Java 用 extends ; Kotlin 用 out
  • T :> S ; I(T) :> I(S)
  • 可以以 T 型別讀取無法寫入

Contravariance (aka Consumer)

  • Java 用 super; Kotlin 用 in
  • T :> S ; I(T) <: I(S)
  • 只可寫入T 的繼承者無法讀取

他們看似相似但卻用途不同。

當要選擇合適的變型時,就可以使用

  • Java 口訣 PECS 或
  • Kotlin 口訣 POCI (貨出得去,人進得來)

來為你選擇需要的變型。

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

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

後序

看完這篇後,你應該對變型多少有點了解了。

現在,你雖然知道 Java 與 Kotlin 用來定義變型的關鍵字有所差異,但差異點卻不止於此。如果想要多暸解,那就看看:

另外,你可否有想過 泛型 本身是在 Java 5 才導入 Java,那麼 JVM 是如何做到向下相容 (Backward Compatible) 呢?

其中的關鍵行為是 Type Erasure。如果想知道更多關於 Type Erasure 以及如何避面被 Erase 掉的話 (reifiable),請看看:

--

--

Jimmy Liu
Kuo’s Funhouse

App Developer who enjoy learning from the ground up.