首頁 > Java > Java基礎 > 主體

從Java說起 kotlin 的協變與逆變

coldplay.xixi
發布: 2020-10-13 11:14:23
轉載
1929 人瀏覽過

java基礎教學專欄今天介紹kotlin的協變與逆變。

從Java說起 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 就應該是List 的子類呀!這是一個非常自然而然的想法!

但很抱歉,因為種種原因,Java 並不支援。但是,Java 並不是完全抹殺了泛型的型變特性,Java 提供了 讓泛型擁有協變和逆變的特性。

稱為上界通配符, 稱為下界通配符。使用上界通配符可以使泛型協變,而使用下界通配符可以使泛型逆變。

例如之前舉的例子

如果使用上界通配符,

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

同樣,下界通配符 可以實現逆變,如:

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

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

虽然 Java 使用通配符解决了泛型的协变与逆变的问题,但是由于很多讲到泛型的文章都晦涩难懂,曾经让我一度感慨这 tm 到底是什么玩意?直到我在 stackoverflow 上发现了通俗易懂的解释(是的,前文大部分内容都来自于 stackoverflow 中大神的解释),才终于了然。其实只要抓住赋值语句左边类型必须大于右边类型这个关键点一切就都很好懂了。

PECS

PECS 准则即 Producer Extends Consumer Super,生产者使用上界通配符,消费者使用下界通配符。直接看这句话可能会让人很疑惑,所以我们追本溯源来看看为什么会有这句话。

首先,我们写一个简单的泛型类:

然后写出如下代码:

代码 (1),Container<Object> c = new Container<String>(); 编译报错,因为泛型是不型变的,所以 Container 并不是 Container 的子类型,所以无法赋值。

代码 (2),加了上界通配符以后,支持泛型协变,Container 就成了 Container 的子类型,所以编译通过,可以赋值。

既然代码 (2) 通过编译,那代码 (3) 为什么会报错呢?因为代码 (3) 尝试把 String 类型赋值给 类型。显然,编译器只知道 是 Obejct 的某一个子类型,但是具体是哪一个并不知道,也许并不是 String 类型,所以不能直接将 String 类型赋值给它。

从上面可以看出,对于使用了 的类型,是不能写入元素的,不然就会像代码 (3) 处一样编译报错。

但是可以读取元素,比如代码 (4) 。并且该类型只能读取元素,这就是所谓的“生产者”,即只能从中读取元素的就是生产者,生产者就使用 通配符。

消费者同理,代码如下:

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) 编译通过,因为加了 通配符后,泛型逆变。

代码 (3) 编译通过,它把 String 类型赋值给 泛指 String 的父类或 String,所以这是可以通过编译的。

代码 (4) 编译报错,因为它尝试把 赋值给 String,而 大于 String,所以不能赋值。事实上,编译器完全不知道该用什么类型去接受 c.get() 的返回值,因为在编译器眼里 是一个泛指的类型,所有 String 的父类和 String 本身都有可能。

同样从上面代码可以看出,对于使用了 的类型,是不能读取元素的,不然就会像代码 (4) 处一样编译报错。但是可以写入元素,比如代码 (3)。该类型只能写入元素,这就是所谓的“消费者”,即只能写入元素的就是消费者,消费者就使用 通配符。

综上,这就是 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) 处直接使用 指定 T 类型只能出现在生产者的位置上。虽然多了一些限制,但是,在 kotlin 编译器在知道了 T 的角色以后,就可以像 (2) 处一样将 Container 直接赋值给 Container,好像泛型直接可以协变了一样,而不需要再使用 Java 当中的通配符

同样的,对于消费者来说,

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) 处使用 指定 T 类型只能出现在消费者的位置上。代码 (2) 可以编译通过, Any > String,但是 Container 可以被 Container 赋值,意味着 Container 大于 Container ,即它看上去就像 T 直接实现了泛型逆变,而不需要借助 通配符来实现逆变。如果是 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> 类型复制代码
登入後複製

從Java說起 kotlin 的協變與逆變

这样使用的话,编译器报错,因为我们把两个不一样的类型做了赋值。用 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) //  报错复制代码
登入後複製

從Java說起 kotlin 的協變與逆變

其实从上面可以看到,类型投影和 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 可以赋值给 List,List 是协变的。接下来往 List 中 add 一个 Cat(),如代码 (1) 处。这样就有可能造成代码 (2) 处的接收者 Dog dogdogs.get(0) 的类型不匹配的问题。会引发运行时的异常。所以 Java 在编译期就要阻止这种行为,把泛型设计为默认不型变的。

總結

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

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

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

相關免費學習推薦:java基礎教學

以上是從Java說起 kotlin 的協變與逆變的詳細內容。更多資訊請關注PHP中文網其他相關文章!

相關標籤:
來源:juejin.im
本網站聲明
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
作者最新文章
熱門教學
更多>
最新下載
更多>
網站特效
網站源碼
網站素材
前端模板
關於我們 免責聲明 Sitemap
PHP中文網:公益線上PHP培訓,幫助PHP學習者快速成長!