從Java說起 kotlin 的協變與逆變

2020-10-13 15:01:20

欄目今天介紹kotlin的協變與逆變。

前言

為了更好地理解 kotlin 和 Java 中的協變與逆變,先看一些基礎知識。

普通賦值

在 Java 中,常見的賦值語句如下:

A a = b;複製程式碼

賦值語句必須滿足的條件是:左邊要麼是右邊的父類別,要麼和右邊型別一樣。即 A 的型別要「大於」B 的型別,比如 Object o = new String("s"); 。為了方便起見,下文中稱作 A > B。

除了上述最常見的賦值語句,還有兩種其他的賦值語句:

函數引數的賦值

public void fun(A a) {}// 呼叫處賦值B b = new B();
fun(b);複製程式碼

在呼叫 fun(b) 方法時,會將傳入的 B b 實參賦值給形參 A a,即 A a = b 的形式。同樣的,必須要滿足形參型別大於實參,即 A > B。

函數返回值的賦值

public A fun() {
    B b = new B();    return b;
} 
複製程式碼

函數返回值型別接收實際返回型別的值,實際返回型別 B b 相當於賦值給了函數返回值型別 A a,即 B b 賦值給了 A a, 即 A a = b,那麼必須滿足 A > B 的型別關係。

所以,無論哪種賦值,都必須滿足左邊型別 > 右邊型別,即 A > B。

Java 中的協變與逆變

有了前面的基礎知識,就可以方便地解釋協變與逆變了。

如果類 A > 類 B,經過一個變化 trans 後得到的 trans(A) 與 trans(B) 依舊滿足 trans(A) > trans(B),那麼稱為協變

逆變則剛好相反,如果類 A > 類 B,經過一個變化 trans 後得到的 trans(A) 與 trans(B) 滿足 trans(B) > trans(A),稱為逆變

比如大家都知道 Java 的陣列是協變的,假如 A > B,那麼有 A[] > B[],所以 B[] 可以賦值給 A[]。舉個例子:

Integer[] nums = new Integer[]{};
Object[] o = nums; // 可以賦值,因為陣列的協變特性所以由 Object > Integer 得到 Object[] > Integer[]複製程式碼

但是 Java 的泛型則不滿足協變,如下:

List<Integer> l = new ArrayList<>();
List<Object> o = l;// 這裡會報錯,不能編譯複製程式碼

上述程式碼報錯,就是因為,雖然 Object > Integer,但是由於泛型不滿足協變,所以 List<Object> > List<Integer> 是不滿足的,既然不滿足左邊大於右邊這個條件,從前言中我們知道,自然就不能將 List<Integer> 賦值給 List<Object>。一般稱 Java 泛型不支援型變。

Java 中泛型如何實現協變與逆變

從前面我們知道,在 Java 中泛型是不支援型變的,但是這會產生一個讓人很奇怪的疑惑,也是很多講泛型的文章中提到的:

如果 B 是 A 的子類,那麼 List<B> 就應該是 List<A> 的子類呀!這是一個非常自然而然的想法!

但是很抱歉,由於種種原因,Java 並不支援。但是,Java 並不是完全抹殺了泛型的型變特性,Java 提供了 <? extends T> 和 <? super T> 使泛型擁有協變和逆變的特性。

<? extends T> 與 <? super T>

<? extends T> 稱為上界萬用字元,<? super T> 稱為下界萬用字元。使用上界萬用字元可以使泛型協變,而使用下界萬用字元可以使泛型逆變。

比如之前舉的例子

List<Integer> l = new ArrayList<>();
List<Object> o = l;// 這裡會報錯,不能編譯複製程式碼

如果使用上界萬用字元,

List<Integer> l = new ArrayList<>();
List<? extends Object> o = l;// 可以通過編譯複製程式碼

這樣,List<? extends Object> 的型別就大於 List<Integer> 的型別了,也就實現了協變。這也就是所謂的「子類的泛型是泛型的子類」。

同樣,下界萬用字元 <? super T> 可以實現逆變,如:

public List<? super Integer> fun(){
    List<Object> l = new ArrayList<>();    return l;
}複製程式碼

上述程式碼怎麼就實現逆變了呢?首先,Object > Integer;另外,從前言我們知道,函數返回值型別必須大於實際返回值型別,在這裡就是 List<? super Integer> > List<Object>,和 Object > Integer 剛好相反。也就是說,經過泛型變化後,Object 和 Integer 的型別關係翻轉了,這就是逆變,而實現逆變的就是下界萬用字元 <? super T>。

從上面可以看出,<? extends T> 中的上界是 T,也就是說 <? extends T> 所泛指的型別都是 T 的子類或 T 本身,所以 T 大於 <? extends T> 。<? super T> 中的下界是 T,也就是說 <? super T> 所泛指的型別都是 T 的父類別或 T 本身,所以 <? super T> 大於 T。

雖然 Java 使用萬用字元解決了泛型的協變與逆變的問題,但是由於很多講到泛型的文章都晦澀難懂,曾經讓我一度感慨這 tm 到底是什麼玩意?直到我在 stackoverflow 上發現了通俗易懂的解釋(是的,前文大部分內容都來自於 stackoverflow 中大神的解釋),才終於瞭然。其實只要抓住賦值語句左邊型別必須大於右邊型別這個關鍵點一切就都很好懂了。

PECS

PECS 準則即 Producer Extends Consumer Super,生產者使用上界萬用字元,消費者使用下界萬用字元。直接看這句話可能會讓人很疑惑,所以我們追本溯源來看看為什麼會有這句話。

首先,我們寫一個簡單的泛型類:

public class Container<T> {    private T item;    public void set(T t) { 
        item = t;
    }    public T get() {        return item;
    }
}複製程式碼

然後寫出如下程式碼:

Container<Object> c = new Container<String>(); // (1)編譯報錯Container<? extends Object> c = new Container<String>(); // (2)編譯通過c.set("sss"); // (3)編譯報錯Object o = c.get();// (4)編譯通過複製程式碼

程式碼 (1),Container<Object> c = new Container<String>(); 編譯報錯,因為泛型是不型變的,所以 Container<String> 並不是 Container<Object> 的子型別,所以無法賦值。

程式碼 (2),加了上界萬用字元以後,支援泛型協變,Container<String> 就成了 Container<? extends Object> 的子型別,所以編譯通過,可以賦值。

既然程式碼 (2) 通過編譯,那程式碼 (3) 為什麼會報錯呢?因為程式碼 (3) 嘗試把 String 型別賦值給 <? extends Object> 型別。顯然,編譯器只知道 <? extends Object> 是 Obejct 的某一個子型別,但是具體是哪一個並不知道,也許並不是 String 型別,所以不能直接將 String 型別賦值給它。

從上面可以看出,對於使用了 <? extends T> 的型別,是不能寫入元素的,不然就會像程式碼 (3) 處一樣編譯報錯。

但是可以讀取元素,比如程式碼 (4) 。並且該型別只能讀取元素,這就是所謂的「生產者」,即只能從中讀取元素的就是生產者,生產者就使用 <? extends T> 萬用字元。

消費者同理,程式碼如下:

Container<String> c = new Container<Object>(); // (1)編譯報錯Container<? super String> c = new Container<Object>(); // (2)編譯通過
 c.set("sss");// (3) 編譯通過
 String s = c.get();// (4) 編譯報錯複製程式碼

程式碼 (1) 編譯報錯,因為泛型不支援逆變。而且就算不懂泛型,這個程式碼的形式一眼看起來也是錯的。

程式碼 (2) 編譯通過,因為加了 <? super T> 萬用字元後,泛型逆變。

程式碼 (3) 編譯通過,它把 String 型別賦值給 <? super String>,<? super String> 泛指 String 的父類別或 String,所以這是可以通過編譯的。

程式碼 (4) 編譯報錯,因為它嘗試把 <? super String> 賦值給 String,而 <? super String> 大於 String,所以不能賦值。事實上,編譯器完全不知道該用什麼型別去接受 c.get() 的返回值,因為在編譯器眼裡 <? super String> 是一個泛指的型別,所有 String 的父類別和 String 本身都有可能。

同樣從上面程式碼可以看出,對於使用了 <? super T> 的型別,是不能讀取元素的,不然就會像程式碼 (4) 處一樣編譯報錯。但是可以寫入元素,比如程式碼 (3)。該型別只能寫入元素,這就是所謂的「消費者」,即只能寫入元素的就是消費者,消費者就使用 <? super T> 萬用字元。

綜上,這就是 PECS 原則。

kotlin 中的協變與逆變

kotlin 拋棄了 Java 中的萬用字元,轉而使用了宣告處型變型別投影

宣告處型變

首先讓我們回頭看看 Container 的定義:

public class Container<T> {    private T item;    public void set(T t) { 
        item = t;
    }    public T get() {        return item;
    }
}複製程式碼

在某些情況下,我們只會使用 Container<? extends T> 或者 Container<? super T> ,意味著我們只使用 Container 作為生產者或者 Container 作為消費者。

既然如此,那我們為什麼要在定義 Container 這個類的時候要把 get 和 set 都定義好呢?試想一下,如果一個類只有消費者的作用,那定義 get 方法完全是多餘的。

反過來說,如果一個泛型類只有生產者方法,比如下面這個例子(來自 kotlin 官方檔案):

// Javainterface Source<T> {
  T nextT(); // 只有生產者方法}// Javavoid demo(Source<String> strs) {
  Source<Object> objects = strs; // !!!在 Java 中不允許,要使用上界萬用字元 <? extends Object>
  // ……}複製程式碼

Source<Object> 型別的變數中儲存 Source<String> 範例的參照是極為安全的——因為沒有消費者-方法可以呼叫。然而 Java 依然不讓我們直接賦值,需要使用上界萬用字元。

但是這是毫無意義的,使用萬用字元只是把型別變得更復雜,並沒有帶來額外的價值,因為能呼叫的方法還是隻有生產者方法。但 Java 編譯器只認死理。

所以,如果我們能在使用之前確定一個類是生產者還是消費者,那在定義類的時候直接宣告它的角色豈不美哉?

這就是 kotlin 的宣告處型變,直接在類宣告的時候,定義它的型變行為。

比如:

class Container<out T> { // (1)
    private  var item: T? = null 
        
    fun get(): T? = item
}

val c: Container<Any> = Container<String>()// (2)編譯通過,因為 T 是一個 out-引數複製程式碼

(1) 處直接使用 <out T> 指定 T 型別只能出現在生產者的位置上。雖然多了一些限制,但是,在 kotlin 編譯器在知道了 T 的角色以後,就可以像 (2) 處一樣將 Container<String> 直接賦值給 Container<Any>,好像泛型直接可以協變了一樣,而不需要再使用 Java 當中的萬用字元 <? extends String>。

同樣的,對於消費者來說,

class Container<in T> { // (1) 
    private  var item: T? = null 
     fun set(t: T) {
        item = t
    }
}val c: Container<String> = Container<Any>() // (2) 編譯通過,因為 T 是一個 in-引數複製程式碼

程式碼 (1) 處使用 <in T> 指定 T 型別只能出現在消費者的位置上。程式碼 (2) 可以編譯通過, Any > String,但是 Container<String> 可以被 Container<Any> 賦值,意味著 Container<String> 大於 Container<Any> ,即它看上去就像 T 直接實現了泛型逆變,而不需要藉助 <? super String> 萬用字元來實現逆變。如果是 Java 程式碼,則需要寫成 Container<? super String> c = new Container<Object>();

這就是宣告處型變,在類宣告的時候使用 out 和 in 關鍵字,在使用時可以直接寫出泛型型變的程式碼。

而 Java 在使用時必須藉助萬用字元才能實現泛型型變,這是使用處型變

型別投影

有時一個類既可以作生產者又可以作消費者,這種情況下,我們不能直接在 T 前面加 in 或者 out 關鍵字。比如:

class Container<T> {    private  var item: T? = null
    
    fun set(t: T?) {
        item = t
    }    fun get(): T? = item
}複製程式碼

考慮這個函數:

fun copy(from: Container<Any>, to: Container<Any>) {
    to.set(from.get())
}複製程式碼

當我們實際使用該函數時:

val from = Container<Int>()val to = Container<Any>()
copy(from, to) // 報錯,from 是 Container<Int> 型別,而 to 是 Container<Any> 型別複製程式碼

image-20201011204330187.png

這樣使用的話,編譯器報錯,因為我們把兩個不一樣的型別做了賦值。用 kotlin 官方檔案的話說,copy 函數在」幹壞事「, 它嘗試一個 Any 型別的值給 from, 而我們用 Int 型別來接收這個值,如果編譯器不報錯,那麼執行時將會丟擲一個 ClassCastException 異常。

所以應該怎麼辦?直接防止 from 被寫入就可以了!

將 copy 函數改為如下所示:

fun copy(from: Container<out Any>, to: Container<Any>) { // 給 from 的型別加了 out
    to.set(from.get())
}val from = Container<Int>()val to = Container<Any>()
copy(from, to) // 不會再報錯了複製程式碼

這就是型別投影:from 是一個類受限制的(投影的)Container 類,我們只能把它當作生產者來使用,它只能呼叫 get() 方法。

同理,如果 from 的泛型是用 in 來修飾的話,則 from 只能被當作消費者使用,它只能呼叫 set() 方法,上述程式碼就會報錯:

fun copy(from: Container<in Any>, to: Container<Any>) { // 給 from 的型別加了 in
    to.set(from.get())
}val from = Container<Int>()val to = Container<Any>()
copy(from, to) //  報錯複製程式碼

image-20201011210124162.png

其實從上面可以看到,型別投影和 Java 的萬用字元很相似,也是一種使用時型變

為什麼要這麼設計?

為什麼 Java 的陣列是預設型變的,而泛型預設不型變呢?其實 kolin 的泛型預設也是不型變的,只是使用 out 和 in 關鍵字讓它看起來像泛型型變。

為什麼這麼設計呢?為什麼不預設泛型可型變呢?

在 stackoverflow 上找到了答案,參考:stackoverflow.com/questions/1…

Java 和 C# 早期都是沒有泛型特性的。

但是為了支援程式的多型性,於是將陣列設計成了協變的。因為陣列的很多方法應該可以適用於所有型別元素的陣列。

比如下面兩個方法:

boolean equalArrays (Object[] a1, Object[] a2);void shuffleArray(Object[] a);複製程式碼

第一個是比較陣列是否相等;第二個是打亂陣列順序。

語言的設計者們希望這些方法對於任何型別元素的陣列都可以呼叫,比如我可以呼叫 shuffleArray(String[] s) 來把字串陣列的順序打亂。

出於這樣的考慮,在 Java 和 C# 中,陣列設計成了協變的。

然而,對於泛型來說,卻有以下問題:

// Illegal code - because otherwise life would be BadList<Dog> dogs = new List<Dog>();
List<Animal> animals = dogs; // Awooga awoogaanimals.add(new Cat());// (1)Dog dog = dogs.get(0); //(2) This should be safe, right?複製程式碼

如果上述程式碼可以通過編譯,即 List<Dog> 可以賦值給 List<Animal>,List 是協變的。接下來往 List<Dog> 中 add 一個 Cat(),如程式碼 (1) 處。這樣就有可能造成程式碼 (2) 處的接收者 Dog dogdogs.get(0) 的型別不匹配的問題。會引發執行時的異常。所以 Java 在編譯期就要阻止這種行為,把泛型設計為預設不型變的。

總結

1、Java 泛型預設不型變,所以 List<String> 不是 List<Object> 的子類。如果要實現泛型型變,則需要 <? extends T> 與 <? super T> 萬用字元,這是一種使用處型變的方法。使用 <? extends T> 萬用字元意味著該類是生產者,只能呼叫 get(): T 之類的方法。而使用 <? super T> 萬用字元意味著該類是消費者,只能呼叫 set(T t)、add(T t) 之類的方法。

2、Kotlin 泛型其實預設也是不型變的,只不過使用 out 和 in 關鍵字在類宣告處型變,可以達到在使用處看起來像直接型變的效果。但是這樣會限制類在宣告時只能要麼作為生產者,要麼作為消費者。

使用型別投影可以避免類在宣告時被限制,但是在使用時要使用 out 和 in 關鍵字指明這個時刻類所充當的角色是消費者還是生產者。型別投影也是一種使用處型變的方法。

相關免費學習推薦:

以上就是從Java說起 kotlin 的協變與逆變的詳細內容,更多請關注TW511.COM其它相關文章!