深入理解 Java 泛型

泛型的产生

泛型最开始是在 C++ 中提出的,实现为模块方法和模板类,主要为了解决与类型相关的算法的重用问题,比如对栈的描述:

如果把上面的伪代码看作算法描述,没问题,因为算法与参数类型无关。但是如果把它写成可编译的源代码就必须指明是什么类型,否则是无法通过编译的,使用重载来解决这个问题,就要对N种不同的参数类型写 N 个

push 和 pop 算法,这样是很麻烦的,代码也无法通用。

若对上面的描述进行改造如下:首先指定一种通用类型 T ,不具体指明是哪一种类型。

这里的参数模板T相当于一个占位符,当我们实例化类 stack 时, T 会被具体的数据类型替换掉。若定义对象 s 为 int 类型,在实例化s时若我们将 T 指定 int 型则:

这时候类s就成为:

这时我可以称class stack是类的类,通过它可以生成具体参数类型不同的类

可以看出泛型是通过对算法中变化的类型的抽象,其它地方的代码相同,唯有类型是可变的,有了泛型后对于编码效率和代码通用有很大的好处。

Java中的泛型

Java 中泛型的实现与 C++ 中不同,Java 中一个泛型类只会产生一份目标代码,而 C++ 则会根据具体的参数实例产生多份目标代码,是什么意思呢?举个例子 对于一个 泛型类如果代码中有对应的 , 实例 Java 只会产生一个 List.class ,而 C++ 中就会产生用 String 、Integer 替换参数 T 的两份代码

这两种方式对应着编译器处理泛型的两种策略:

Code specialization 在实例化一个泛型类或泛型方法时都产生一份新的目标代码(字节码or二进制代码)

Code sharing 对每个泛型类只生成唯一的一份目标代码;该泛型类的所有实例都映射到这份目标代码上,在需要的时候执行类型检查和类型转换。

Java的伪泛型,泛型擦除

由于向上兼容历史代码的原因 Java 采用了 Code sharing 的策略,使得泛型只存在于源码阶段,编译过后的 Class 文件并不存在泛型,虚拟机并不知道泛型的存在,所以说 Java 中的泛型是一种伪泛型,这种参数类型只存在于源码阶段在编译后并不存在的机制我们叫做泛型擦除,举个例子:

上面的代码有两个不同的 ArrayList : 和 。在我们看来它们的参数化类型不同,一个保存整性,一个保存字符串。但是通过比较它们的 Class 对象,上面的代码输出是 true。这说明在 JVM 看来它们是同一个类。而在 C++、C# 这些支持真泛型的语言中,它们就是不同的类。

Java编译器的类型转换和类型检查

类型自动转换

既然上面所说Java会在编译时对泛型进行擦除,那么它要实现同一份泛型代码不同类型通用必然需要类型转换,事实上编译器也是这么干的,比如下面的这一段代码:

编译后:

可以看到编译后的代码增加了类型转换,编译器会帮我们自动添加类型转换的代码

类型检查

由于类型擦除可能导致的一些异常问题,编译器需要做类型检查来尽量确保程序在运行时不会抛出异常,我们在写泛型相关代码时,将泛型擦除考虑进去后再想这段代码在运行时会不会有异常,然后再看编译器报的错误也就理解了,比如:

泛型的方法签名

编译器会报相同签名异常,因为他们的方法签名参数编译后都会被擦除掉

泛型的异常捕获

类型信息被擦除后,那么两个地方的 catch 都变为原始类型 Object,那么也就是说,这两个地方的 catch 变的一模一样,而对应 catch 的语句不一定相同,这就有冲突了。

泛型的实例化

在上面的 create 中的几行代码都是编译器会报错误的,我们写这段代码本意上是为了实现泛型对象的创建比如:

而由于泛型擦除 上面的参数 T 在编译后都会由它的第一个上界即 Object 代替,而编译器想通过去修改 Wrapper 类中的 create 方法达到输出我们的本意代码是不可能的,因为泛型只有在运行时才知道具体的类型。

这里需要提一点的是编译器进行类型检查时是以我们声明的类型为基础依赖去检查的而不是具体创建的类型,什么意思呢?看下面这段我们比较常见的代码:

通过上面的例子,我们可以明白,类型检查就是针对声明的,变量声明是什么类型就用这个类型去调用泛型方法,就会对这个调用的方法进行类型检测,而无关它真正引用的对象。

Java数组的“泛型化”

Java中数组相比于Java 类库中的容器类是比较特殊的,主要体现在三个方面:

数组创建后大小便固定,但效率更高

数组能追踪它内部保存的元素的具体类型,插入的元素类型会在编译期得到检查

数组可以持有原始类型 ( int,float等 ),不过有了自动装箱,容器类看上去也能持有原始类型了

由于Java中数组设计之初就是类型安全的,创建的时候必须知道内部元素的类型,而且一直都会记得这个类型信息。由于泛型不是一个具体的类型所以我们不能显式创建一个泛型数组如:

而且Java 语言规范明确规定:数组内的元素必须是“物化”的。

对“物化”的第一条定义就是不能是泛型:

所以对于上面的代码第一行不可以,第二行可以,因为原生类 Wrapper 不是泛型

通配符

通配符的产生

里式替换原则与协变

任何使用父类的地方可以被它的子类替换,我们在使用类和对象时经常会接触到里式替换原则,其实在数组中一样也符合这种原则,如:

数组中的这种向上转变称为数组的协变,而泛型中是不支持协变的,如上面的

会产生编译时错误,之所以这么设计是因为数组支持运行时检查而集合类不支持运行时检查。

Java的泛型的这种特性对于有需要向上转型的需求时就无能为力,所以 Java 为了满足这种需求设计出了通配符.

上边界限定通配符

利用 形式的通配符可以实现泛型的向上转型:

使用上通配符后编译器为了保证运行时的安全,会限定对其写的操作,开放读的操作,因为编译器只能保证 fruits 集合中存在的是 Fruits 及它的子类,并不知道具体的类型,所以对于下面的代码第二行会报错:

下边界限定通配符

利用形式的通配符可以实现泛型的向下转型

与上通配符相反,下边界通配符通常限定读的操作,开放写的操作,对于上面的代码writeTo 方法的参数 apples 的类型是 ,它表示某种类型的 List,这个类型是 Apple 的基类型。也就是说,我们不知道实际类型是什么,但是这个类型肯定是 Apple 的父类型。因此,我们可以知道向这个 List 添加一个 Apple 或者其子类型的对象是安全的,这些对象都可以向上转型为 Apple。但是我们不知道加入 Fruit 对象是否安全,因为那样会使得这个 List 添加跟 Apple 无关的类型.

无边界通配符

还有一种通配符是无边界通配符,它的使用形式是一个单独的问号:,也就是没有任何限定。不做任何限制,跟不用类型参数的 List 有什么区别呢?

表示 list 是持有某种特定类型的 List,但是不知道具体是哪种类型。那么我们可以向其中添加对象吗?当然不可以,因为并不知道实际是哪种类型,所以不能添加任何类型,这是不安全的。而单独的 ,也就是没有传入泛型参数,表示这个 list 持有的元素的类型是 Object,因此可以添加任何类型的对象,只不过编译器会有警告信息。

Kotlin的泛型

Kotlin中的泛型也是伪泛型,存在泛型擦除,因为它们都是JVM语言?Kotlin 相比于Java 类型使用更为安全,泛型数组不支持协变(Java中支持)避免了数组运行时可能导致的类型转换异常,Kotlin中集合类和数组的泛型是有特定关键字来达到“协变”和“逆变”的效果的,Kotlin 中相对于 Java 的通配符提出了一种新的定义:声明处型变(declaration-site variance)与类型投影(type projections)

这两种都是型变,不过一个是在声明处,一个是在使用处,通过 out 、in 关键字来支持型变,类比于 Java 中的 , 类比于Java 中的 , 例如:

Java

Kotlin

另外 Kotlin 除了 out , in 关键字来支持型变外,它还有上边界泛型约束对应于Java中 extends

冒号之后指定的类型是上界:只有 的子类型可以替代 T。例如:

默认的上界(如果没有声明)是 Any?。在尖括号中只能指定一个上界。如果同一类型参数需要多个上界,我们需要一个单独的 where子句:

喜欢,在看

相关文章