欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

Java数组协变与范型不变性

程序员文章站 2022-04-08 20:44:41
变性是OOP语言不变的大坑,Java的数组协变就是其中的一口老坑。因为最近踩到了,便做一个记录。顺便也提一下范型的变性。 解释数组协变之前,先明确三个相关的概念,协变、不变和逆变。 一、协变、不变、逆变 假设,我为一家餐馆写了这样一段代码 有一个范型类Soup,表示用食材T做的汤,它的方法ad ......

变性是oop语言不变的大坑,java的数组协变就是其中的一口老坑。因为最近踩到了,便做一个记录。顺便也提一下范型的变性。

解释数组协变之前,先明确三个相关的概念,协变、不变和逆变。

 

一、协变、不变、逆变

假设,我为一家餐馆写了这样一段代码

class soup<t> {
    public void add(t t) {}
}

class vegetable { }

class carrot extends vegetable { }

有一个范型类soup<t>,表示用食材t做的汤,它的方法add(t t)表示向汤中添加食材t。类vegetable表示蔬菜,类carrot表示胡萝卜。当然,carrot是vegetable的子类。

那么问题来了,soup<vegetable>和soup<carrot>之间是什么关系呢?

第一反应,soup<carrot>应该是soup<vegetable>的子类,因为胡萝卜汤显然是一种蔬菜汤。如果真是这样,那就看看下面的代码。其中tomato表示西红柿,是vegetable的另一个子类

soup<vegetable> soup = new soup<carrot>();
soup.add(new tomato());

第一句没问题,soup<carrot>是soup<vegetable>的子类,所以可以将soup<carrot>的实例赋给变量soup。第二句也没问题,因为soup声明为soup<vegetable>类型,它的add方法接收一个vegetable类型的参数,而tomato是vegetable,类型正确。

但是,两句放在一起却有了问题。soup的实际类型是soup<carrot>,而我们给它的add方法传递了一个tomato的实例!换言之,我们在用西红柿做胡萝卜汤,肯定做不出来。所以,把soup<carrot>视为soup<vegetable>的子类在逻辑上虽然是通顺的,在使用过程中却是有缺陷的。

那么,soup<carrot>和soup<vegetable>究竟应该是什么关系呢?不同的语言有不同的理解和实现。总结起来,有三种情况。

(1)如果soup<carrot>是soup<vegetable>的子类,则称泛型soup<t>是协变的
(2)如果soup<carrot>和soup<vegetable>是无关的两个类,则称泛型soup<t>是不变的
(3)如果soup<carrot>是soup<vegetable>的父类,则称泛型soup<t>是逆变的。(不过逆变不常见)

理解了协变、不变和逆变的概念,再看java的实现。java的一般泛型是不变的,也就是说soup<vegetable>和soup<carrot>是毫无关系的两个类,不能将一个类的实例赋值给另一个类的变量。所以,上面那段用西红柿做胡萝卜汤的代码,其实根本无法通过编译。

 

二、数组协变

java中,数组是基本类型,不是泛型,不存在array<t>这样的东西。但它和泛型很像,都是用另一个类型构建的类型。所以,数组也是要考虑变性的。

与泛型的不变性不同,java的数组是协变的。也就是说,carrot[]是vegetable[]的子类。而上一节中的例子已经表明,协变有时会引发问题。比如下面这段代码

vegetable[] vegetables = new carrot[10];
vegetables[0] = new tomato(); // 运行期错误

因为数组是协变的,编译器允许把carrot[10]赋值给vegetable[]类型的变量,所以这段代码可以顺利通过编译。只有在运行期,jvm真的试图往一堆胡萝卜中插入一个西红柿的时候,才发现大事不好。所以,上面的代码在运行期会抛出一个java.lang.arraystoreexception类型的异常。

数组协变性,是java的著名历史包袱之一。使用数组时,千万要小心!

如果把例子中的数组替换为list,情况就不同了。就像这样

arraylist<vegetable> vegetables = new arraylist<carrot>(); // 编译期错误
vegetables.add(new tomato());

arraylist是一个泛型类,它是不变的。所以,arraylist<carrot>和arraylist<vegetable>之间并无继承关系,这段代码在编译期就会报错。

两段代码虽然都会报错,但通常情况下,编译期错误总比运行期错误好处理一些。

 

三、当泛型也想要协变、逆变

泛型是不变的,但某些场景里我们还是希望它能协变起来。比如,有一个天天喝蔬菜汤减肥的小姐姐

class girl {
    public void drink(soup<vegetable> soup) {}
}

我们希望drink方法可以接受各种不同的蔬菜汤,包括soup<carrot>和soup<tomato>。但受到不变性的限制,它们无法作为drink的参数。

要实现这一点,应该采用一种类似于协变性的写法

public void drink(soup<? extends vegetable> soup) {}

意思是,参数soup的类型是泛型类soup<t>,而t是vegetable的子类(也包括vegetable自己)。这时,小姐姐终于可以愉快地喝上胡萝卜汤和西红柿汤了。

但是,这种方法有一个限制。编译器只知道泛型参数是vegetable的子类,却不知道它具体是什么。所以,所有非null的泛型类型参数均被视为不安全的。说起来很拗口,其实很简单。直接上代码

public void drink(soup<? extends vegetable> soup) {
    soup.add(new tomato()); // 错误
    soup.add(null); // 正确
}

方法内的第一句会在编译期报错。因为编译器只知道add方法的参数是vegetable的子类,却不知道它具体是carrot、tomato、或者其他的什么类型。这时,传递一个具体类型的实例一律被视为不安全的。即使soup真的是soup<tomato>类型也不行,因为soup的具体类型信息是在运行期才能知道的,编译期并不知道。

但是方法内的第二句是正确的。因为参数是null,它可以是任何合法的类型。编译器认为它是安全的。

同样,也有一种类似于逆变的方法

public void drink(soup<? super vegetable> soup) {}

这时,soup<t>中的t必须是vegetable的父类。

这种情况就不存在上面的限制了,下面的代码毫无问题

public void drink(soup<? super vegetable> soup) {
    soup.add(new tomato());
}

tomato是vegetable的子类,自然也是vegetable父类的子类。所以,编译期就可以确定类型是安全的。