一文读懂 Java 和 Kotlin 的泛型难点

三、类型擦除

泛型是在 Java 5 版本开始引入的,所以在 Java 4 中 ArrayList 还不属于泛型类,其内部通过 Object 向上转型外部强制类型转换来实现数据存储和逻辑复用,此时开发者的项目中已经充斥了大量以下类型的代码:

List stringList = new ArrayList();

stringList.add(“业志陈”);

stringList.add(“https://juejin.cn/user/923245496518439”);

String str = (String) stringList.get(0);

复制代码

而在推出泛型的同时,Java 官方也必须保证二进制的向后兼容性,用 Java 4 编译出的 Class 文件也必须能够在 Java 5 上正常运行,即 Java 5 必须保证以下两种类型的代码能够在 Java 5 上共存且正常运行

List stringList = new ArrayList();

List stringList = new ArrayList();

复制代码

为了实现这一目的,Java 就通过类型擦除这种比较别扭的方式来实现泛型。编译器在编译时会擦除类型实参,在运行时不存在任何类型相关的信息,泛型对于 JVM 来说是透明的,有泛型和没有泛型的代码通过编译器编译后所生成的二进制代码是完全相同的

例如,分别声明两个泛型类和非泛型类,拿到其 class 文件

public class GenericTest {

public static class NodeA {

private Object obj;

public NodeA(Object obj) {

this.obj = obj;

}

}

public static class NodeB {

private T obj;

public NodeB(T obj) {

this.obj = obj;

}

}

public static void main(String[] args) {

NodeA nodeA = new NodeA(“业志陈”);

NodeB nodeB = new NodeB<>(“业志陈”);

System.out.println(nodeB.obj);

}

}

复制代码

可以看到 NodeA 和 NodeB 两个对象对应的字节码其实是完全一样的,最终都是使用 Object 来承载数据,就好像传递给 NodeB 的类型参数 String 不见了一样,这便是类型擦除

public class generic.GenericTest {

public generic.GenericTest();

Code:

0: aload_0

1: invokespecial #1 // Method java/lang/Object.“”😦)V

4: return

public static void main(java.lang.String[]);

Code:

0: new #2 // class generic/GenericTest$NodeA

3: dup

4: ldc #3 // String 业志陈

6: invokespecial #4 // Method generic/GenericTest$NodeA.“”:(Ljava/lang/Object;)V

9: astore_1

10: new #5 // class generic/GenericTest$NodeB

13: dup

14: ldc #3 // String 业志陈

16: invokespecial #6 // Method generic/GenericTest$NodeB.“”:(Ljava/lang/Object;)V

19: astore_2

20: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;

23: aload_2

24: invokestatic #8 // Method generic/GenericTest$NodeB.access 000 : ( L g e n e r i c / G e n e r i c T e s t 000:(Lgeneric/GenericTest 000:(Lgeneric/GenericTestNodeB;)Ljava/lang/Object;

27: checkcast #9 // class java/lang/String

30: invokevirtual #10 // Method java/io/PrintStream.println:(Ljava/lang/String;)V

33: return

}

复制代码

而如果让 NodeA 直接使用 String 类型,并且为泛型类 NodeB 设定上界约束 String,两者的字节码也会完全一样

public class GenericTest {

public static class NodeA {

private String obj;

public NodeA(String obj) {

this.obj = obj;

}

}

public static class NodeB {

private T obj;

public NodeB(T obj) {

this.obj = obj;

}

}

public static void main(String[] args) {

NodeA nodeA = new NodeA(“业志陈”);

NodeB nodeB = new NodeB<>(“业志陈”);

System.out.println(nodeB.obj);

}

}

复制代码

可以看到 NodeA 和 NodeB 的字节码是完全相同的

public class generic.GenericTest {

public generic.GenericTest();

Code:

0: aload_0

1: invokespecial #1 // Method java/lang/Object.“”😦)V

4: return

public static void main(java.lang.String[]);

Code:

0: new #2 // class generic/GenericTest$NodeA

3: dup

4: ldc #3 // String 业志陈

6: invokespecial #4 // Method generic/GenericTest$NodeA.“”:(Ljava/lang/String;)V

9: astore_1

10: new #5 // class generic/GenericTest$NodeB

13: dup

14: ldc #3 // String 业志陈

16: invokespecial #6 // Method generic/GenericTest$NodeB.“”:(Ljava/lang/String;)V

19: astore_2

20: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;

23: aload_2

24: invokestatic #8 // Method generic/GenericTest$NodeB.access 000 : ( L g e n e r i c / G e n e r i c T e s t 000:(Lgeneric/GenericTest 000:(Lgeneric/GenericTestNodeB;)Ljava/lang/String;

27: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V

30: return

}

复制代码

所以说,当泛型类型被擦除后有两种转换方式

  • 如果泛型没有设置上界约束,那么将泛型转化成 Object 类型

  • 如果泛型设置了上界约束,那么将泛型转化成该上界约束

该结论也可以通过反射泛型类的 Class 对象来验证

public class GenericTest {

public static class NodeA {

private T obj;

public NodeA(T obj) {

this.obj = obj;

}

}

public static class NodeB {

private T obj;

public NodeB(T obj) {

this.obj = obj;

}

}

public static void main(String[] args) {

NodeA nodeA = new NodeA<>(“业志陈”);

getField(nodeA.getClass());

NodeB nodeB = new NodeB<>(“https://juejin.cn/user/923245496518439”);

getField(nodeB.getClass());

}

private static void getField(Class clazz) {

for (Field field : clazz.getDeclaredFields()) {

System.out.println("fieldName: " + field.getName());

System.out.println("fieldTypeName: " + field.getType().getName());

}

}

}

复制代码

NodeA 对应的是 Object,NodeB 对应的是 String

fieldName: obj

fieldTypeName: java.lang.Object

fieldName: obj

fieldTypeName: java.lang.String

复制代码

那既然在运行时不存在任何类型相关的信息,泛型又为什么能够实现类型检查类型自动转换等功能呢?

其实,类型检查是编译器在编译前帮我们完成的,编译器知道我们声明的具体的类型实参,所以类型擦除并不影响类型检查功能。而类型自动转换其实是通过内部强制类型转换来实现的,上面给出的字节码中也可以看到有一条类型强转 checkcast 的语句

27: checkcast #9 // class java/lang/String

复制代码

例如,ArrayList 内部虽然用于存储数据的是 Object 数组,但 get 方法内部会自动完成类型强转

transient Object[] elementData;

public E get(int index) {

rangeCheck(index);

return elementData(index);

}

@SuppressWarnings(“unchecked”)

E elementData(int index) {

//强制类型转换

return (E) elementData[index];

}

复制代码

所以 Java 的泛型可以看做是一种特殊的语法糖,因此也被人称为伪泛型

四、类型擦除的后遗症

Java 泛型对于类型的约束只在编译期存在,运行时仍然会按照 Java 5 之前的机制来运行,泛型的具体类型在运行时已经被删除了,所以 JVM 是识别不到我们在代码中指定的具体的泛型类型的

例如,虽然List<String>只能用于添加字符串,但我们只能泛化地识别到它属于List<?>类型,而无法具体判断出该 List 内部包含的具体类型

List stringList = new ArrayList<>();

//正常

if (stringList instanceof ArrayList<?>) {

}

//报错

if (stringList instanceof ArrayList) {

}

复制代码

我们只能对具体的对象实例进行类型校验,但无法判断出泛型形参的具体类型

public void filter(T data) {

//正常

if (data instanceof String) {

}

//报错

if (T instanceof String) {

}

//报错

Class tClass = T::getClass;

}

复制代码

此外,类型擦除也会导致 Java 中出现多态问题。例如,以下两个方法的方法签名并不完全相同,但由于类型擦除的原因,入参参数的数据类型都会被看成 List<Object>,从而导致两者无法共存在同一个区域内

public void filter(List stringList) {

}

public void filter(List stringList) {

}

复制代码

五、Kotlin 泛型

Kotlin 泛型在大体上和 Java 一致,毕竟两者需要保证兼容性

class Plate(val t: T) {

fun cut() {

println(t.toString())

}

}

class Apple

class Banana

fun main() {

val plateApple = Plate(Apple())

//泛型类型自动推导

val plateBanana = Plate(Banana())

plateApple.cut()

plateBanana.cut()

}

复制代码

Kotlin 也支持在扩展函数中使用泛型

fun List.find(t: T): T? {

val index = indexOf(t)

return if (index > -1) get(index) else null

}

复制代码

需要注意的是,为了实现向后兼容,目前高版本 Java 依然允许实例化没有具体类型参数的泛型类,这可以说是一个对新版本 JDK 危险但对旧版本友好的兼容措施。但 Kotlin 要求在使用泛型时需要显式声明泛型类型或者是编译器能够类型推导出具体类型,任何不具备具体泛型类型的泛型类都无法被实例化。因为 Kotlin 一开始就是基于 Java 6 版本的,一开始就存在了泛型,自然就不存在需要兼容老代码的问题,因此以下例子和 Java 会有不同的表现

val arrayList1 = ArrayList() //错误,编译器报错

val arrayList2 = arrayListOf() //正常

val arrayList3 = arrayListOf(1, 2, 3) //正常

复制代码

还有一个比较容易让人误解的点。我们经常会使用 asas? 来进行类型转换,但如果转换对象是泛型类型的话,那就会由于类型擦除而出现误判。如果转换对象有正确的基础类型,那么转换就会成功,而不管类型实参是否相符。因为在运行时转换发生的时候类型实参是未知的,此时编译器只会发出 “unchecked cast” 警告,代码还是可以正常编译的

例如,在以下例子中代码的运行结果还符合我们的预知。第一个转换操作由于类型相符,所以打印出了相加值。第二个转换操作由于基础类型是 Set 而非 List,所以抛出了 IllegalAccessException

fun main() {

printSum(listOf(1, 2, 3)) //6

printSum(setOf(1, 2, 3)) //IllegalAccessException

}

fun printSum(c: Collection<*>) {

val intList = c as? List ?: throw IllegalAccessException(“List is expected”)

println(intList.sum())

}

复制代码

而在以下例子中抛出的却是 ClassCastException,这是因为在运行时不会判断且无法判断出类型实参到底是否是 Int,而只会判断基础类型 List 是否相符,所以 as? 操作会成功,等到要执行相加操作时才会发现拿到的是 String 而非 Number

printSum(listOf(“1”, “2”, “3”))

Exception in thread “main” java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Number

复制代码

六、上界约束

泛型本身已经带有类型约束的作用,我们也可以进一步细化其支持的具体类型

例如,假设存在一个盘子 Plate,我们要求该 Plate 只能用于装水果 Fruit,那么就可以对其泛型声明做进一步约束,Java 中使用 extend 关键字来声明约束规则,而 Kotlin 使用的是 : 。这样 Plate 就只能用于 Fruit 和其子类,而无法用于 Noodles 等不相关的类型,这种类型约束就被称为上界约束

open class Fruit

class Apple : Fruit()

class Noodles

class Plate(val t: T)

fun main() {

val applePlate = Plate(Apple()) //正常

val noodlesPlate = Plate(Noodles()) //报错

}

复制代码

如果上界约束拥有多层类型元素,Java 是使用 & 符号进行链式声明,Kotlin 则是用 where 关键字来依次进行声明

interface Soft

class Plate(val t: T) where T : Fruit, T : Soft

open class Fruit

class Apple : Fruit()

class Banana : Fruit(), Soft

fun main() {

val applePlate = Plate(Apple()) //报错

val bananaPlate = Plate(Banana()) //正常

}

复制代码

此外,没有指定上界约束的类型形参会默认使用 Any? 作为上界,即我们可以使用 String 或 String? 作为具体的类型实参。如果想确保最终的类型实参一定是非空类型,那么就需要主动声明上界约束为 Any

七、类型通配符 & 星号投影

假设现在有个需求,需要我们提供一个方法用于遍历所有类型的 List 集合并打印元素

第一种做法就是直接将方法参数类型声明为 List,不包含任何泛型类型声明。这种做法可行,但编译器会警告无法确定 list元素的具体类型,所以这不是最优解法

public static void printList1(List list) {

for (Object o : list) {

System.out.println(o);

}

}

复制代码

可能会想到的第二种做法是:将泛型类型直接声明为 Object,希望让其适用于任何类型的 List。这种做法完全不可行,因为即使 StringObject 的子类,但 List<String>List<Object>并不具备从属关系,这导致 printList2 方法实际上只能用于List<Object>这一种具体类型

public static void printList2(List list) {

for (Object o : list) {

System.out.println(o);

}

}

复制代码

最优解法就是要用到 Java 的类型通配符 ? 了,printList3方法完全可行且编译器也不会警告报错

public static void printList3(List<?> list) {

for (Object o : list) {

System.out.println(o);

}

}

复制代码

? 表示我们并不关心具体的泛型类型,而只是想配合其它类型进行一些条件限制。例如,printList3方法希望传入的是一个 List,但不限制泛型的具体类型,此时List<?>就达到了这一层限制条件

类型通配符也存在着一些限制。因为 printList3 方法并不包含具体的泛型类型,所以我们从中取出的值只能是 Object 类型,且无法向其插入值,这都是为了避免发生 ClassCastException

Java 的类型通配符对应 Kotlin 中的概念就是**星号投影 * **,Java 存在的限制在 Kotlin 中一样有

fun printList(list: List<*>) {

for (any in list) {

println(any)

}

}

复制代码

此外,星号投影只能出现在类型形参的位置,不能作为类型实参

val list: MutableList<*> = ArrayList() //正常

val list2: MutableList<> = ArrayList<>() //报错

复制代码

八、协变 & 不变

看以下例子。Apple 和 Banana 都是 Fruit 的子类,可以发现 Apple[] 类型的对象是可以赋值给 Fruit[] 的,且 Fruit[] 可以容纳 Apple 对象和 Banana 对象,这种设计就被称为协变,即如果 A 是 B 的子类,那么 A[] 就是 B[] 的子类型。相对的,Object[] 就是所有数组对象的父类型

static class Fruit {

}

static class Apple extends Fruit {

}

static class Banana extends Fruit {

}

public static void main(String[] args) {

Fruit[] fruitArray = new Apple[10];

//正常

fruitArray[0] = new Apple();

//编译时正常,运行时抛出 ArrayStoreException

fruitArray[1] = new Banana();

}

复制代码

而 Java 中的泛型是不变的,这意味着 String 虽然是 Object 的子类,但List<String>并不是List<Object>的子类型,两者并不具备继承关系

List stringList = new ArrayList<>();

List objectList = stringList; //报错

复制代码

那为什么 Java 中的泛型是不变的呢?

这可以通过看一个例子来解释。假设 Java 中的泛型是协变的,那么以下代码就可以成功通过编译阶段的检查,在运行时就不可避免地将抛出 ClassCastException,而引入泛型的初衷就是为了实现类型安全,支持协变的话那泛型也就没有比数组安全多少了,因此就将泛型被设计为不变

List strList = new ArrayList<>();

List objs = strList; //假设可以运行,实际上编译器会报错

objs.add(1);

String str = strList.get(0); //将抛出 ClassCastException,无法将整数转换为字符串

复制代码

再来想个问题,既然协变本身并不安全,那么数组为何又要被设计为协变呢?

Arrays 类包含一个 equals方法用于比较两个数组对象是否相等。如果数组是协变的,那么就需要为每一种数组对象都定义一个 equals方法,包括开发者自定义的数据类型。想要避免这种情况,就需要让 Object[] 可以接收任意数组类型,即让 Object[] 成为所有数组对象的父类型,这就使得数组必须支持协变,这样多态才能生效

public class Arrays {

public static boolean equals(Object[] a, Object[] a2) {

if (a==a2)

return true;

if (anull || a2null)

return false;

int length = a.length;

if (a2.length != length)

return false;

for (int i=0; i<length; i++) {

Object o1 = a[i];

Object o2 = a2[i];

if (!(o1null ? o2null : o1.equals(o2)))

return false;

}

return true;

}

}

复制代码

需要注意的是,Kotlin 中的数组和 Java 中的数组并不一样,Kotlin 数组并不支持协变,Kotlin 数组类似于集合框架,具有对应的实现类 Array,Array 属于泛型类,支持了泛型因此也不再协变

val stringArray = arrayOfNulls(3)

val anyArray: Array<Any?> = stringArray //报错

复制代码

Java 的泛型也并非完全不变的,只是实现协变需要满足一些条件,甚至也可以实现逆变,下面就来介绍下泛型如何实现协变逆变

九、泛型协变

假设我们定义了一个copyAll希望用于 List 数据迁移。那以下操作在我们看来就是完全安全的,因为 Integer 是 Number 的子类,按道理来说是能够将 Integer 保存为 Number 的,但由于泛型不变性,List<Integer>并不是List<Number>的子类型,所以实际上该操作将报错

public static void main(String[] args) {

List numberList = new ArrayList<>();

List integerList = new ArrayList<>();

integerList.add(1);

integerList.add(2);

integerList.add(3);

copyAll(numberList, integerList); //报错

}

private static void copyAll(List to, List from) {

to.addAll(from);

}

复制代码

思考下该操作为什么会报错?

编译器的作用之一就是进行安全检查并阻止可能发生不安全行为的操作copyAll 方法会报错,那么肯定就是编译器觉得该方法有可能会触发不安全的操作。开发者的本意是希望将 Integer 类型的数据转移到 NumberList 中,只有这种操作且这种操作在我们看来肯定是安全的,但是编译器不知道开发者最终所要做的具体操作啊

假设 copyAll方法可以正常调用,那么copyAll方法自然只会把 from 当做 List<Number>来看待。因为 Integer 是 Number 的子类,从 integerList 获取到的数据对于 numberList 来说自然是安全的。而如果我们在copyAll方法中偷偷向 integerList 传入了一个 Number 类型的值的话,那么自然就将抛出异常,因为 from 实际上是 List<Integer>类型

为了阻止这种不安全的行为,编译器选择通过直接报错来进行提示。为了解决报错,我们就需要向编译器做出安全保证:从 from 取出来的值只会当做 Number 类型,且不会向 from 传入任何值

为了达成以上保证,需要修改下 copyAll 方法

private static void copyAll(List to, List<? extends T> from) {

to.addAll(from);

}

复制代码

? extends T 表示 from 接受 T 或者 T 的子类型,而不单单是 T 自身,这意味着我们可以安全地从 from 中取值并声明为 T 类型,但由于我们并不知道 T 代表的具体类型,写入操作并不安全,因此编译器会阻止我们向 from 执行传值操作。有了该限制后,从integerList中取出来的值只能是当做 Number 类型,且避免了向integerList插入非法值的可能,此时List<Integer>就相当于List<? extends Number>的子类型了,从而使得 copyAll 方法可以正常使用

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数Java工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)

img

笔者福利

以下是小编自己针对马上即将到来的金九银十准备的一套“面试宝典”,不管是技术还是HR的问题都有针对性的回答。

有了这个,面试踩雷?不存在的!

回馈粉丝,诚意满满!!!




《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!
由于我们并不知道 T 代表的具体类型,写入操作并不安全,因此编译器会阻止我们向 from 执行传值操作。有了该限制后,从integerList中取出来的值只能是当做 Number 类型,且避免了向integerList插入非法值的可能,此时List<Integer>就相当于List<? extends Number>的子类型了,从而使得 copyAll 方法可以正常使用

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数Java工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。[外链图片转存中…(img-whvqhTBd-1713435305913)]

[外链图片转存中…(img-3Arvbm0O-1713435305914)]

[外链图片转存中…(img-PTIwvNok-1713435305914)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)

img

笔者福利

以下是小编自己针对马上即将到来的金九银十准备的一套“面试宝典”,不管是技术还是HR的问题都有针对性的回答。

有了这个,面试踩雷?不存在的!

回馈粉丝,诚意满满!!!

[外链图片转存中…(img-UbmANseP-1713435305914)]
[外链图片转存中…(img-8zhmg36A-1713435305915)]
[外链图片转存中…(img-UwMS6FFe-1713435305915)]
[外链图片转存中…(img-aCG8mwxK-1713435305915)]

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值