变性是 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 父类的子类. 所以, 编译期就可以确定类型是安全的.
来源: https://www.cnblogs.com/tjxing/p/10419993.html