跳到主要内容
Java java
Java 与 Kotlin 泛型核心难点解析 Java 和 Kotlin 泛型涉及类型参数、擦除、协变逆变等难点。Java 泛型通过类型擦除实现,运行时丢失类型信息,导致无法判断具体泛型类型及多态限制。Kotlin 泛型兼容 Java 但要求显式声明,支持实化类型参数(reified)和内联函数以解决擦除问题。通配符和星号投影处理类型不确定性,out/in 关键字分别对应协变和逆变,List 接口天然支持协变。掌握这些机制有助于编写类型安全且通用的代码。
锁机制 发布于 2025/2/7 更新于 2026/6/3 17 浏览Java 和 Kotlin 的泛型难点
Java 和 Kotlin 的泛型算作是一块挺大的知识难点,涉及到很多很难理解的概念:泛型型参、泛型实参、类型参数、不变、型变、协变、逆变、内联 等等。本篇文章将 Java 和 Kotlin 结合着一起讲,按照个人理解来阐述泛型的各个知识难点。
一、泛型类型
泛型允许你定义带类型形参 的数据类型,当这种类型的实例被创建出来后,类型形参 便被替换为称为类型实参 的具体类型。例如,对于 List<T>,List 称为基础类型 ,T 便是类型型参 ,T 可以是任意类型。当没有指定 T 的具体类型时,我们只能知道 List<T> 是一个集合列表,但不知道承载的具体数据类型。而对于 List<String>,当中的 String 便是类型实参 ,我们可以明白地知道该列表承载的都是字符串。在这里 String 就相当于一个参数传递给了 List,在这语义下 String 也称为类型参数 。
此外,在 Kotlin 中我们可以实现实化类型参数 ,在运行时的内联函数 中拿到作为类型实参 的具体类型,即可以实现 T::class.java,但在 Java 中却无法实现,因为内联函数 是 Kotlin 中的概念,Java 中并不存在。
二、为什么需要泛型
泛型是在 Java 5 版本开始引入的,先通过几个小例子来明白泛型的重要性。
以下代码可以成功编译,但是在运行时却抛出了 ClassCastException。了解 ArrayList 源码的同学就知道其内部是用一个 Object[] 数组来存储数据的,这使得 ArrayList 能够存储任何类型的对象,所以在没有泛型的年代开发者一不小心就有可能向 ArrayList 存入了非期望值,编译期完全正常,等到在运行时就会抛出类型转换异常了。
public class GenericTest {
public static void main (String[] args) {
List stringList = new ArrayList ();
addData(stringList);
String str = (String) stringList.get(0 );
}
public static void addData (List dataList) {
dataList.add(1 );
}
}
Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String
而有了泛型后,我们就可以写出更加健壮安全的代码,以下错误就完全可以在编译阶段被发现,且取值的时候也不需要进行类型强转。
public static void main (String[] args) {
List<String> stringList = new ArrayList <>();
addData(stringList);
String str = stringList.get(0 );
}
public static void addData (List<Integer> dataList) {
dataList.add(1 );
}
此外,利用泛型我们可以写出更加具备通用性的代码。例如,假设我们需要从一个 List 中筛选出大于 0 的全部数字,那我们自然不想为 Integer、Float、Double 等多种类型各写一个筛选方法,此时就可以利用泛型来抽象筛选逻辑。
public static void main (String[] args) {
List<Integer> integerList = new ArrayList <>();
integerList.add(-1 );
integerList.add(1 );
integerList.add(2 );
List<Integer> result1 = filter(integerList);
List<Float> floatList = new ArrayList <>();
floatList.add(-1f );
floatList.add(1f );
floatList.add(2f );
List<Float> result2 = filter(floatList);
}
public static <T extends Number > List<T> filter (List<T> data) {
List<T> filterList = new ArrayList <>();
for (T datum : data) {
if (datum.doubleValue() > 0 ) {
filterList.add(datum);
}
}
return filterList;
}
类型检查,在编译阶段就能发现错误
更加语义化,看到 List<String> 我们就知道存储的数据类型是 String
自动类型转换,在取值时无需进行手动类型转换
能够将逻辑抽象出来,使得代码更加具有通用性
三、类型擦除 泛型是在 Java 5 版本开始引入的,所以在 Java 4 中 ArrayList 还不属于泛型类,其内部通过 Object 向上转型 和外部强制类型转换 来实现数据存储和逻辑复用,此时开发者的项目中已经充斥了大量以下类型的代码。
而在推出泛型的同时,Java 官方也必须保证二进制的向后兼容性,用 Java 4 编译出的 Class 文件也必须能够在 Java 5 上正常运行,即 Java 5 必须保证以下两种类型的代码能够在 Java 5 上共存且正常运行。
为了实现这一目的,Java 就通过类型擦除 这种比较别扭的方式来实现泛型。编译器在编译时会擦除类型实参,在运行时不存在任何类型相关的信息,泛型对于 JVM 来说是透明的,有泛型和没有泛型的代码通过编译器编译后所生成的二进制代码是完全相同的。
例如,分别声明两个泛型类和非泛型类,拿到其 class 文件。
public class GenericTest {
public static class NodeA {
private Object obj;
public NodeA (Object obj) {
this .obj = obj;
}
}
public static class NodeB <T> {
private T obj;
public NodeB (T obj) {
this .obj = obj;
}
}
public static void main (String[] args) {
NodeA nodeA = new NodeA ("示例文本" );
NodeB<String> nodeB = new Node <>("示例文本" );
System.out.println(nodeB.obj);
}
}
可以看到 NodeA 和 NodeB 两个对象对应的字节码其实是完全一样的,最终都是使用 Object 来承载数据,就好像传递给 NodeB 的类型参数 String 不见了一样,这便是类型擦除。
而如果让 NodeA 直接使用 String 类型,并且为泛型类 NodeB 设定上界约束 String,两者的字节码也会完全一样。
如果泛型没有设置上界约束,那么将泛型转化成 Object 类型
如果泛型设置了上界约束,那么将泛型转化成该上界约束
该结论也可以通过反射泛型类的 Class 对象来验证。
NodeA 对应的是 Object,NodeB 对应的是 String。
那既然在运行时不存在任何类型相关的信息,泛型又为什么能够实现类型检查 和类型自动转换 等功能呢?其实,类型检查是编译器在编译前 帮我们完成的,编译器知道我们声明的具体的类型实参,所以类型擦除并不影响类型检查功能。而类型自动转换其实是通过内部强制类型转换来实现的,上面给出的字节码中也可以看到有一条类型强转 checkcast 的语句。
例如,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<String> stringList = new ArrayList <>();
if (stringList instanceof ArrayList<?>) {
}
if (stringList instanceof ArrayList<String>) {
}
我们只能对具体的对象实例进行类型校验,但无法判断出泛型形参的具体类型。
此外,类型擦除也会导致 Java 中出现多态问题。例如,以下两个方法的方法签名并不完全相同,但由于类型擦除的原因,入参参数的数据类型都会被看成 List<Object>,从而导致两者无法共存在同一个区域内。
五、Kotlin 泛型 Kotlin 泛型在大体上和 Java 一致,毕竟两者需要保证兼容性。
class Plate <T >(val t: T) {
fun cut () {
println(t.toString())
}
}
class Apple
class Banana
fun main () {
val plateApple = Plate<Apple>(Apple())
val plateBanana = Plate(Banana())
plateApple.cut()
plateBanana.cut()
}
需要注意的是,为了实现向后兼容,目前高版本 Java 依然允许实例化没有具体类型参数的泛型类,这可以说是一个对新版本 JDK 危险但对旧版本友好的兼容措施。但 Kotlin 要求在使用泛型时需要显式声明泛型类型 或者是编译器能够类型推导出具体类型 ,任何不具备具体泛型类型的泛型类都无法被实例化。因为 Kotlin 一开始就是基于 Java 6 版本的,一开始就存在了泛型,自然就不存在需要兼容老代码的问题,因此以下例子和 Java 会有不同的表现。
还有一个比较容易让人误解的点。我们经常会使用 as 和 as? 来进行类型转换,但如果转换对象是泛型类型的话,那就会由于类型擦除而出现误判。如果转换对象有正确的基础类型 ,那么转换就会成功,而不管类型实参是否相符。因为在运行时转换发生的时候类型实参是未知的,此时编译器只会发出 'unchecked cast' 警告,代码还是可以正常编译的。
例如,在以下例子中代码的运行结果还符合我们的预知。第一个转换操作由于类型相符,所以打印出了相加值。第二个转换操作由于基础类型是 Set 而非 List,所以抛出了 IllegalAccessException。
而在以下例子中抛出的却是 ClassCastException,这是因为在运行时不会判断且无法判断出类型实参到底是否是 Int,而只会判断基础类型 List 是否相符,所以 as? 操作会成功,等到要执行相加操作时才会发现拿到的是 String 而非 Number。
六、上界约束 泛型本身已经带有类型约束的作用,我们也可以进一步细化其支持的具体类型。
例如,假设存在一个盘子 Plate,我们要求该 Plate 只能用于装水果 Fruit,那么就可以对其泛型声明做进一步约束,Java 中使用 extend 关键字来声明约束规则,而 Kotlin 使用的是 : 。这样 Plate 就只能用于 Fruit 和其子类,而无法用于 Noodles 等不相关的类型,这种类型约束就被称为上界约束 。
如果上界约束拥有多层类型元素,Java 是使用 & 符号进行链式声明,Kotlin 则是用 where 关键字来依次进行声明。
此外,没有指定上界约束的类型形参会默认使用 Any? 作为上界,即我们可以使用 String 或 String? 作为具体的类型实参。如果想确保最终的类型实参一定是非空类型,那么就需要主动声明上界约束为 Any。
七、类型通配符 & 星号投影 假设现在有个需求,需要我们提供一个方法用于遍历所有类型的 List 集合并打印元素。
第一种做法就是直接将方法参数类型声明为 List,不包含任何泛型类型声明。这种做法可行,但编译器会警告无法确定 list 元素的具体类型,所以这不是最优解法。
可能会想到的第二种做法是:将泛型类型直接声明为 Object,希望让其适用于任何类型的 List。这种做法完全不可行,因为即使 String 是 Object 的子类,但 List<String> 和 List<Object> 并不具备从属关系,这导致 printList2 方法实际上只能用于 List<Object> 这一种具体类型。
最优解法就是要用到 Java 的类型通配符 ? 了,printList3 方法完全可行且编译器也不会警告报错。
? 表示我们并不关心具体的泛型类型,而只是想配合其它类型进行一些条件限制。例如,printList3 方法希望传入的是一个 List,但不限制泛型的具体类型,此时 List<?> 就达到了这一层限制条件。
类型通配符也存在着一些限制。因为 printList3 方法并不包含具体的泛型类型,所以我们从中取出的值只能是 Object 类型,且无法向其插入值,这都是为了避免发生 ClassCastException。
Java 的类型通配符 对应 Kotlin 中的概念就是星号投影 ,Java 存在的限制在 Kotlin 中一样有。
此外,星号投影只能出现在类型形参 的位置,不能作为类型实参 。
八、协变 & 不变 看以下例子。Apple 和 Banana 都是 Fruit 的子类,可以发现 Apple[] 类型的对象是可以赋值给 Fruit[] 的,且 Fruit[] 可以容纳 Apple 对象和 Banana 对象,这种设计就被称为协变 ,即如果 A 是 B 的子类,那么 A[] 就是 B[] 的子类型。相对的,Object[] 就是所有数组对象的父类型。
而 Java 中的泛型是不变 的,这意味着 String 虽然是 Object 的子类,但 List<String> 并不是 List<Object> 的子类型,两者并不具备继承关系。
那为什么 Java 中的泛型是不变 的呢?这可以通过看一个例子来解释。假设 Java 中的泛型是协变 的,那么以下代码就可以成功通过编译阶段的检查,在运行时就不可避免地将抛出 ClassCastException,而引入泛型的初衷就是为了实现类型安全,支持协变的话那泛型也就没有比数组安全多少了,因此就将泛型被设计为不变 的。
再来想个问题,既然协变 本身并不安全,那么数组为何又要被设计为协变呢?Arrays 类包含一个 equals 方法用于比较两个数组对象是否相等。如果数组是协变的,那么就需要为每一种数组对象都定义一个 equals 方法,包括开发者自定义的数据类型。想要避免这种情况,就需要让 Object[] 可以接收任意数组类型,即让 Object[] 成为所有数组对象的父类型 ,这就使得数组必须支持协变,这样多态才能生效。
需要注意的是,Kotlin 中的数组和 Java 中的数组并不一样,Kotlin 数组并不支持协变,Kotlin 数组类似于集合框架,具有对应的实现类 Array,Array 属于泛型类,支持了泛型因此也不再协变。
Java 的泛型也并非完全不变 的,只是实现协变 需要满足一些条件,甚至也可以实现逆变 ,下面就来介绍下泛型如何实现协变 和逆变 。
九、泛型协变 假设我们定义了一个 copyAll 希望用于 List 数据迁移。那以下操作在我们看来就是完全安全的,因为 Integer 是 Number 的子类,按道理来说是能够将 Integer 保存为 Number 的,但由于泛型不变性,List<Integer> 并不是 List<Number> 的子类型,所以实际上该操作将报错。
思考下该操作为什么会报错?编译器的作用之一就是进行安全检查并阻止可能发生不安全行为的操作 ,copyAll 方法会报错,那么肯定就是编译器觉得该方法有可能会触发不安全的操作。开发者的本意是希望将 Integer 类型的数据转移到 NumberList 中,只有这种操作且这种操作在我们看来肯定是安全的,但是编译器不知道开发者最终所要做的具体操作啊。
假设 copyAll 方法可以正常调用,那么 copyAll 方法自然只会把 from 当做 List<Number> 来看待。因为 Integer 是 Number 的子类,从 integerList 获取到的数据对于 numberList 来说自然是安全的。而如果我们在 copyAll 方法中偷偷向 integerList 传入了一个 Number 类型的值的话,那么自然就将抛出异常,因为 from 实际上是 List<Integer> 类型。
为了阻止这种不安全的行为,编译器选择通过直接报错来进行提示。为了解决报错,我们就需要向编译器做出安全保证:从 from 取出来的值只会当做 Number 类型,且不会向 from 传入任何值 。
为了达成以上保证,需要修改下 copyAll 方法。
? extends T 表示 from 接受 T 或者 T 的子类型,而不单单是 T 自身,这意味着我们可以安全地从 from 中取值并声明为 T 类型,但由于我们并不知道 T 代表的具体类型,写入操作并不安全,因此编译器会阻止我们向 from 执行传值操作。有了该限制后,从 integerList 中取出来的值只能是当做 Number 类型,且避免了向 integerList 插入非法值的可能,此时 List<Integer> 就相当于 List<? extends Number> 的子类型了,从而使得 copyAll 方法可以正常使用。
简而言之,带 extends 限定了上界的通配符类型使得泛型参数类型是协变的 ,即如果 A 是 B 的子类,那么 Generic<A> 就是 Generic<? extends B> 的子类型。
十、泛型逆变 协变 所能做到的是:如果 A 是 B 的子类,那么 Generic<A> 就是 Generic<? extends B> 的子类型。逆变 相反,其代表的是:如果 A 是 B 的子类,那么 Generic<B> 就是 Generic<? super A> 的子类型。
协变还比较好理解,毕竟其继承关系是相同的,但逆变就比较反直觉了,整个继承关系都倒过来了。
逆变的作用可以通过相同的例子来理解,copyAll 方法如下修改也可以正常使用,此时就是向编译器做出了另一种安全保证:向 numberList 传递的值只会是 Integer 类型,且从 numberList 取出的值也只会当做 Object 类型 。
? super T 表示 to 接收 T 或者 T 的父类型,而不单单是 T 自身,这意味着我们可以安全地向 to 传类型为 T 的值,但由于我们并不知道 T 代表的具体类型,所以从 to 取出来的值只能是 Object 类型。有了该限制后,integerList 只能向 numberList 传递类型为 Integer 的值,且避免了从 numberList 中获取到非法类型值的可能,此时 List<Number> 就相当于 List<? super Integer> 的子类型了,从而使得 copyAll 方法可以正常使用。
简而言之,带 super 限定了下界的通配符类型使得泛型参数类型是逆变的 ,即如果 A 是 B 的子类,那么 Generic<B> 就是 Generic<? super A> 的子类型。
十一、out & in Java 中关于泛型的困境在 Kotlin 中一样存在,out 和 in 都是 Kotlin 的关键字,其作用都是为了来应对泛型问题。in 和 out 是一个对立面,同时它们又与泛型不变 相对立,统称为型变 。
out 本身带有出去 的意思,本身带有倾向于取值操作 的意思,用于泛型协变
in 本身带有进来 的意思,本身带有倾向于传值操作 的意思,用于泛型逆变
再来看下相同例子,该例子在 Java 中存在的问题在 Kotlin 中一样有。
报错原因和 Java 完全一样,因为此时编译器无法判断出我们到底是否会做出不安全的操作,所以我们依然要来向编译器做出安全保证。
此时就需要在 Kotlin 中来实现泛型协变 和泛型逆变 了,以下两种方式都可以实现:
out 关键字就相当于 Java 中的 <? extends T>,其作用就是限制了 from 不能用于接收值而只能向其取值,这样就避免了从 to 取出值然后向 from 赋值这种不安全的行为了,即实现了泛型协变。
in 关键字就相当于 Java 中的 <? super T>,其作用就是限制了 to 只能用于接收值而不能向其取值,这样就避免了从 to 取出值然后向 from 赋值这种不安全的行为了,即实现了泛型逆变。
从这也可以联想到,MutableList<*> 就相当于 MutableList<out Any?> 了,两者都带有相同的限制条件:不允许写值操作,允许读值操作,且读取出来的值只能当做 Any? 进行处理。
十二、支持协变的 List 在上述例子中,想要实现协变还有另外一种方式,那就是使用 List。
将 from 的类型声明从 MutableList<T> 修改为 List<T> 后,可以发现 copyAll 方法也可以正常调用了。
对 Kotlin 有一定了解的同学应该知道,Kotlin 中的集合框架分为两种大类:可读可写 和只能读不能写 。
以 Java 中的 ArrayList 为例,Kotlin 将之分为了 MutableList 和 List 两种类型的接口。而 List 接口中的泛型已经使用 out 关键字进行修饰了,且不包含任何传入值并保存 的方法,即 List 接口只支持读值而不支持写值 ,其本身就已经满足了协变所需要的条件,因此 copyAll 方法可以正常使用。
虽然 List 接口中有几个方法也接收了 E 类型的入参参数,但该方法本身不会进行写值操作,所以实际上可以正常使用,Kotlin 也使用 @UnsafeVariance 抑制了编译器警告。
十三、reified & inline 上文讲了,由于类型擦除,Java 和 Kotlin 的泛型类型实参都会在编译阶段被擦除,在 Kotlin 中存在一个额外手段可以来解决这个问题,即内联函数 。
用关键字 inline 标记的函数就称为内联函数,再用 reified 关键字修饰内联函数中的泛型形参,编译器在进行编译的时候便会将内联函数的字节码插入到每一个调用的地方,当中就包括泛型的类型实参。而内联函数的类型形参能够被实化,就意味着我们可以在运行时引用实际的类型实参了。
例如,我们可以写出以下这样的一个内联函数,用于判断一个对象是否是指定类型。
将以上的 Kotlin 代码反编译为 Java 代码,可以看出来 main() 方法最终是没有调用 isInstanceOf 方法的,具体的判断逻辑都被插入到了 main() 方法内部,最终是执行了 instanceof 操作,且指定了具体的泛型类型参数 String 和 Integer。
inline 和 reified 比较有用的一个场景是用在 Gson 反序列的时候。由于泛型运行时类型擦除 的问题,目前用 Gson 反序列化泛型类时步骤是比较繁琐的,利用 inline 和 reified 我们就可以简化很多操作。
开发者常利用 Kotlin 的这个强大特性来简化 Java/Kotlin 平台的序列化和反序列化操作。
十四、总结 协变 逆变 不变 Kotlin <out T>,只能作为消费者,只能读取不能添加<in T>,只能作为生产者,只能添加,读取出的值只能当做 Any 类型<T>,既可以添加也可以读取Java <? extends T>,只能作为消费者,只能读取不能添加<? super T>,只能作为生产者,只能添加,读取出的值只能当做 Object 类型<T>,既可以添加也可以读取
相关免费在线工具 Keycode 信息 查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
Escape 与 Native 编解码 JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
JavaScript / HTML 格式化 使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
JavaScript 压缩与混淆 Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online