現代程式語言使用型別來確保程式的可維護性與正確性,而型別又衍生了子型別影響了現代物件導向程式設計的發展。這篇文章會探討,除了物件型別之外,函式型別的子型別關係是如何運作的。
定義子型別:里氏替換原則¶
開始討論之前我們先定義什麼是子型別,根據里氏替換原則,子型別可以用於在程式中替換原型別。以物件導向程式設計中常見的例子為例,狗是動物的子型別,所以任何在程式中需要動物的地方都可以用狗來替換。
從資訊量看子型別¶
狗是動物的子型別很容易理解,一般在程式語言實作上,狗這個型別的成員變數會包含動物的所有公開成員變數,再加上狗特有的成員變數,因此狗所攜帶的資訊量比較多。
但現在考慮另一種子型別,u8 與 u16 為八位元與十六位元的無號整數,哪一個型別是另一個型別的子型別?可能沒辦法靠直覺直接得到答案,這時我們回到里氏替換原則,因為任何需要 u16 的地方都可以用 u8 替代,程式需要一個 0-65535 的數字的話當然可以用一個範圍是 0-127 的數字替代,所以 u8 是 u16 的子型別。直覺上這個情況和狗的情況相反,u16 位元數量看起來明明比 u8 多,理論上 u16 可攜帶的資訊量也確實比 u8 多,那為什麼 u8 會是子型別且可以用作 u16?
這是因為看的角度錯了,我們應該要問:一個數字是 u8 型別或 u16 型別在被視為 u16 時,資訊量有什麼差異?一個數字的型別是 u8,代表它落在 0–255 這個範圍內;而 0–255 是 u16(0–65535)的子集,因此 u8 隱含了「這個數字是合法的 u16」這個資訊,同時還額外縮小了範圍。也就是 u8 資訊量比 u16 更多的意思。
在狗的範例中也是相同的,動物有千百種,狗不僅帶有該生物是動物的資訊,還更進一步告訴我們更精確的資訊:牠是狗。因為在物件導向程式語言中可以用成員變數來描述狗是動物的子型別,所以看起來比較直觀;而 u8 和 u16 就很難用成員變數的數量來表達子型別的關係,所以我們不容易看出 u8 是 u16 的子型別。
另外值得注意的是,當我們將 u8 當成 u16 傳入函式時,函式會把參數看成是 u16,這其實是將 u8 多出來的資訊丟棄,僅保留 u16 的資訊。
總結來說,子型別的資訊量比較多,除了原型別的所有資訊,還帶有額外的資訊。因為子型別能夠提供原型別帶有的所有資訊,所以在程式中可以用子型別替換原型別,但替換之後會丟失子型別額外帶有的資訊。
函式的子型別¶
理解了資訊量與子型別的關係後,我們就能更容易地理解型別理論中最反直覺的性質了。在函式程式語言中,函式可以被放進變數中儲存、傳遞與使用,因此函式也是有型別的。例如現有一個將 u16 加一的函式,則這個函式的型別為 u16 -> u16,假設現在程式需要一個 u16 -> u16 的函式來透過 map 將一個 u16 陣列中的所有元素加一,那我們可以用哪些型別的函式來替代這個 u16 -> u16 函式呢,或是說哪些型別是 u16 -> u16 的子型別呢?
直覺上,既然 u8 是 u16 的子型別,u8 -> u8 應該也是 u16 -> u16 的子型別才對——但事實上並非如此,這正是函式子型別最反直覺的地方。
先考慮函式輸出,程式預期 u16 -> u16 會輸出一個 u16 然後接著在某個地方被使用,u8 -> u8 輸出的 u8 的資訊包含 u16,所以可以丟棄資訊後當成 u16 使用。
但是函式輸入的情況就不同了,程式會將一個 u16 輸入至該 u16 -> u16 函式,但如果將 u16 輸入至 u8 -> u8 函式就會出現問題,因為 u16 的值域比 u8 更寬,呼叫端可能傳入 u8 無法表示的值(例如 300),函式將無法正確處理。我們應該要把 u8 -> u8 的輸入型別換成一個資訊量要求較少的型別如 u32 -> u8,如此才可正確執行。
因此,若 A 函式的型別 $S_1 \to S_2$ 是 B 函式的型別 $T_1 \to T_2$ 的子型別,那麼 $S_2$ 的資訊量應該要多於 $T_2$,$S_2$ 是子型別,這樣同向的關係稱為 covariant;但 $S_1$ 的資訊量應該要少於 $T_1$,$T_1$ 是子型別,這樣反向的關係稱為 contravariant。
整理一下:函式的輸出型別與子型別關係同向(covariant)——子型別的輸出也必須是子型別;函式的輸入型別則反向(contravariant)——子型別反而需要能接受更寬泛的輸入。
| 子型別 | 原型別 |
|---|---|
| $S_1 \to S_2$ | $T_1 \to T_2$ |
| $T_1$ | $S1$ |
| $S_2$ | $T_2$ |
案例:Kotlin 透過 in/out 關鍵字擴充 Java 的子型別系統¶
在 Java 中,String 是 Object 的子型別,但是 List<String> 卻不是 List<Object> 的子型別,這是出於安全性考量。Java 中 List 可用於讀取與寫入,考慮以下程式:
List<Object> objectList = stringList;
objectList.add(new Object());
String s = stringList.get(0);
讀取 objectList 時讀出一個 String 是沒有問題的,因為 String 的資訊量多於 Object;但是當我們將 Object 寫入 objectList 時實際上是寫入 stringList,這樣是不安全的,若其他地方嘗試從 stringList 讀出一個資訊量較多的 String,卻讀到我們放進去資訊量較少的 Object 就會出錯。因此在設計上 Java 並不允許 List<String> 作為 List<Object 的子型別,畢竟編譯器不知道我們會對 objectList 寫入還是讀出。Java 的 ListA 和 B 有任何子型別關係,List<A> 與 List<B> 之間都沒有子型別關係,兩者完全獨立。
但是 Kotlin 則有針對這種情況採用特殊設計,可以讓我們在定義泛型時就決定要採用 covariant、contravariant 或是 invariant。例如 Kotlin 中的 List 分為可變的 MutableList 與不可變的 List,List 除了初始化的過程外是不可變的,只能用於讀取。這種情況下就可以使用 Kotlin 的 out 關鍵字來表示這個型別只用於讀取或輸出,參考 Kotlin 程式碼中 List 的定義:
public expect interface List<out E> : Collection<E> {
override val size: Int
override fun isEmpty(): Boolean
override fun iterator(): Iterator<E>
public operator fun get(index: Int): E
public fun listIterator(): ListIterator<E>
public fun listIterator(index: Int): ListIterator<E>
public fun subList(fromIndex: Int, toIndex: Int): List<E>
}
這個介面中泛型 E 被加上了 out 關鍵字,代表 E 只用於輸出,並不會出現在輸入參數中,否則編譯器將會報錯。使用了 out 後,若 String 是 Any 的子型別的話,List<String> 也會是 List<Any> 的子型別。原理上編譯器確保當 List<String> 被指派至一個 List<Any> 變數後,這個變數只會讀取其中的內容而不會修改其中的元素,就能保證這個行為是安全的。
而 in 關鍵字則是用於輸入的型別,參考以下程式碼:
interface Comparable<in T> {
operator fun compareTo(other: T): Int
}
fun demo(x: Comparable<Number>) {
val y: Comparable<Double> = x
}
Comparable<T> 的泛型 T 被標記了 in 代表只用於輸入,所以這個泛型採用的規則就是 contravariant,程式碼中可以看到雖然 Double 是 Number 的子型別,但 Comparable<Number> 卻是 Comparable<Double> 的子型別,可以被指派至 y。