变性是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父类的子类。所以,编译期就可以确定类型是安全的。

Java数组协变与范型不变性的更多相关文章

  1. 为什么Java不允许创建范型数组

    问题示例 List<Integer>[] intListArr = new ArrayList<Integer>[8]; // 编译时报错 能看到这么看似没啥问题的一个简单语句 ...

  2. Java数据结构与算法分析-第一章(引论)-Java中的范型<T,E>构件

    一.为什么需要使用范型? 官方的说法是:Java 泛型(generics)是 JDK 5 中引入的一个新特性, 泛型提供了编译时类型安全检测机制,该机制允许程序员在编译时检测到非法的类型. 泛型的本质 ...

  3. Java范型随笔

    最近在帝都好无聊啊, 排遣寂寞就只有让自己不要停下来,不断的思考了 QWQ; 最近做ndk, java有点忘了,突然看到了一些java范型方面的问题, 踌躇了一会, 想着想着,决定还是写个随笔记录下来 ...

  4. Java Comparator的范型类型推导问题

    问题 在项目中,有一处地方需要对日期区间进行排序 我需要以日期区间的开始日为第一优先级,结束日为第二优先级进行排序 代码 我当时写的代码如下: List<Pair<LocalDate, L ...

  5. Java范型

    泛型不用考虑对象的具体类型.优点在于,因为不用考虑对象的具体类型所以可以对一类对象执行一定的相同操作:缺点在于,因为没有考虑对象的具体类型所以就不能使用对象自带的接口函数.泛型的最佳用同是实现容器类. ...

  6. Java范型学习笔记

    对于范型的使用或者说印象只有集合,其他地方即使使用过也不知道,反正就是只停留在List<E> Map<K, V>,最近刚好闲来无事,就找找资料学习一下:下列为个人学习总结,欢迎 ...

  7. java范型集合中的成员排序

    范型集合中的类是JsonObject,不是自定义类,如果是自定义类就直接取要比较的字段值. ArrayList<JSONObject> TList = new ArrayList<J ...

  8. java数组与字符串相互转换、整型与字符串相互转换【详解】

      java 数组->字符串 1.char数组(字符数组)->字符串 可以通过:使用String.copyValueOf(charArray)函数实现.  举例: char[] arr={ ...

  9. 关于java范型

    1 范型只在编译阶段有效 编译器在编译阶段检查范型结果之后,就会将范型信息删除.范型信息不会进入运行时阶段. 泛型类型在逻辑上看以看成是多个不同的类型,实际上都是相同的基本类型. 2 不能对确定的范型 ...

随机推荐

  1. C#多线程中的异常处理

    常规Thread中处理异常 使用Thread创建的子线程,需要在委托中捕捉,无法在上下文线程中捕捉 static void Main(string[] args) { ThreadStart thre ...

  2. netty源码学习

    概述 Netty is an asynchronous event-driven network application framework for rapid development of main ...

  3. 【转】AB实验设计思路及实验落地

    这篇文章会讨论: 在什么情况下需要做 AB 实验 从产品/交互角度,如何设计一个实验 前端工程师如何打点 如何统计数据,并保证数据准确可信 如何分析实验数据,有哪些数据需要重点关注 附:如何搭建前端实 ...

  4. 从壹开始微服务 [ DDD ] 之十一 ║ 基于源码分析,命令分发的过程(二)

    缘起 哈喽小伙伴周三好,老张又来啦,DDD领域驱动设计的第二个D也快说完了,下一个系列我也在考虑之中,是 Id4 还是 Dockers 还没有想好,甚至昨天我还想,下一步是不是可以写一个简单的Angu ...

  5. Word页眉、页码的使用:利用分隔符设置指定页显示页眉,解决页码显示{PAGE \* MERGEFORMAT}问题

    不常编辑对文档有格式要求的朋友来说,偶尔需要编辑指定格式页眉页码的word文档时,会一时不记得如何使用,在网上搜索半天,异常烦躁. 特整理一下,记录下来,备不时只需. 以下操作环境为word2016. ...

  6. [小技巧]EF Core中如何获取上下文中操作过的实体

    原文地址:https://www.cnblogs.com/lwqlun/p/10576443.html 作者:Lamond Lu 源代码:https://github.com/lamondlu/EFC ...

  7. 很详细的Django入门详解

    Django 是用Python开发的一个免费开源的Web框架,可以用于快速搭建高性能,优雅的网站!采用了MVC的框架模式,即模型M,视图V和控制器C,也可以称为MVT模式,模型M,视图V,模板T.在学 ...

  8. Asp.Net Core&钉钉开发系列

    阿里钉钉在商业领域的规模越来越大,基于钉钉办公的企业越来越多,将一个企业内现有用到的工具(如钉钉)能够更融入到他们的工作中,提高工作效率,那便需要开发者不断的学习.应用了,同时,个人也有一个预感,未来 ...

  9. Go:学习笔记兼吐槽(2)

    Go:学习笔记兼吐槽(1) Go:学习笔记兼吐槽(2) Go:学习笔记兼吐槽(3) 基本数据类型和string之间的转换 (1) 基本类型转string 使用 fmt.Sprintf(“%参数”, 表 ...

  10. C# 操作Word目录——生成、删除目录

    目录,是指书籍.文档正文前所载的目次,将主要内容以一定次第顺序编排,起指导阅读.检索内容的作用.在Word中生成目录前,需要设置文档相应文字或者段落的大纲级别,根据设定的大纲级别可创建文档的交互式大纲 ...