泛型基础知识
| 变型标记 | 口号 | 可以做的事 | 不能做的事 | 典型场景 |
|---|---|---|---|---|
out T 协变 | “只出不进” (Producer) | 读:拿到 T 或其子类值 | 写:add/set 都被禁止 | List<out Fruit> 作为只读列表、Flow<out T> |
in T 逆变 | “只进不出” (Consumer) | 写:传入 T 或其子类值 | 读:只能拿到 Any?(丢失精确类型) | Comparator<in File>、MutableCollection<in Fruit> |
不声明 (T) 不变 | —— | 可读可写,但不可替换为子类/父类泛型 | —— | MutableList<T> 需要读写同样类型 |
口诀(PECS 原则):Producer Extends (out),Consumer Super (in) 。
读安全(协变) ⇆ 写安全(逆变)是一对权衡:
- 协变:保证“读出来的类型绝不会错”,因此禁止写入以防插入不兼容值。
- 逆变:保证“传进去的值类型绝不会错”,因此读回时只能退回到最通用的
Any?。
1. 泛型与协变、逆变的基本概念
-
协变(Covariance,
? extends T或 Kotlin 中的out T)-
用途:适用于生产者,即只用来读取数据。
-
特点:可以从中读取出 T 或 T 的子类型,但不能写入数据,因为我们不知道容器内部具体是哪种子类型。
-
示例:
ArrayList<Apple> apples2 = new ArrayList<Apple>(); ArrayList<? extends Fruit> fruits2 = apples2; // 允许赋值 // ArrayList<Fruit> fruits2 = apples2; // 这种写法会报错 Banana banana2 = new Banana(); fruits2.add(banana2); // 编译错误,无法添加 -
说明:
? extends Fruit表示一种约定:既然你不知道容器中具体持有哪种 Fruit 的子类型,就不能安全地添加新元素;读取出来的元素可以当作 Fruit 类型使用。
-
-
逆变(Contravariance,
? super T或 Kotlin 中的in T)-
用途:适用于消费者,即只用来写入数据。
-
特点:你可以向其中写入 T 或 T 的子类型,但读取出来的只能当作 Object 来处理,因为不知道容器具体持有哪种父类型。
-
示例:
List<Object> list = new ArrayList<>(); // 实际泛型类型是 Object(Integer 的父类) List<? super Integer> consumer = list; consumer.add(123); // 合法,因为 Integer 是 Object 的子类
-
-
不变(Invariance)
- 用途:既要写又要读的时候使用。
- 特点:不允许在赋值时发生协变或逆变转换;数据安全需通过其他机制(如加锁)保证。
2. 协变与逆变的使用场景
-
只读模式(生产者)使用协变
-
例子:统计水果总重量的方法
float getTotalWeight(List<? extends Fruit> fruits) { float totalWeight = 0; for (Fruit fruit : fruits) { totalWeight += fruit.getWeight(); } return totalWeight; } -
说明:这种方法只读取数据,故使用
? extends Fruit是安全的。
-
-
只写模式(消费者)使用逆变
-
例子:将自身对象添加到列表中
public void addMeToList(List<? super Apple> list) { list.add(this); } -
说明:这种方法只写入数据,故使用
? super Apple是安全的。
-
-
读安全与写安全的区别
-
协变读安全:例如,
List<? extends Number> numbers = new ArrayList<Integer>(); Number num = numbers.get(0); // 读取时安全,因为返回值至少是 Number 的子类 -
逆变写安全:例如,
List<Object> list = new ArrayList<>(); List<? super Integer> consumer = list; consumer.add(123); // 合法,因为 Integer 是 Object 的子类
-
3. Java 与 Kotlin 泛型的设计差异
Kotlin 生成的字节码仍接受 Java 规则,因此两者可无缝调用,只是 Kotlin 提供了更严谨、更表达性的语法糖。
-
Java
-
默认只支持使用处泛型限定,不支持声明处协变或逆变。
-
因此,诸如
List<Fruit> fruits = new ArrayList<Apple>();是不允许的,必须使用通配符(例如? extends Fruit)手动开启协变。 -
JDK 内部对集成关系和数组默认开启了协变。例如:
Object[] objectArray = new String[1]; // 数组协变允许赋值 objectArray[0] = 100; // 运行时抛出 ArrayStoreException,因为尝试放入 Integer
-
-
Kotlin
- 支持声明处泛型限定(如
out T和in T),这使得泛型的协变和逆变更加直观。 - 只读集合默认协变(
List<out T>),而可变集合则为不变类型。
- 支持声明处泛型限定(如
声明处 vs 使用处变型
// Kotlin:一次声明,处处适用
interface Producer<out T> { // 声明处 out
fun produce(): T
}
val p: Producer<Any> = object : Producer<String> { // OK
override fun produce() = "hi"
}
// Java:只能在使用时写 ? extends
interface Producer<T> {
T produce();
}
Producer<? extends Object> p = (Producer<String>) () -> "hi"; // 需显式 ?
reified 类型参数示例(Java 做不到)
inline fun <reified T> Gson.fromJson(json: String): T =
fromJson(json, T::class.java)
val user: User = gson.fromJson<User>(json) // 无需额外 TypeToken
原始类型差异
List raw = new ArrayList(); // 允许,类型检查缺失
raw.add(1); raw.add("oops"); // 编译通过,运行时可能出错
val raw = ArrayList() // 等价 ArrayList<Any?>,仍有类型
raw.add(1); raw.add("oops") // 均合法,但类型清晰为 Any?
4. 泛型参数实例化与泛型擦除
-
泛型擦除
- 在 Java 中,编译后泛型信息在运行时会被擦除,只保留原始类型(例如
List<Integer>变为List)。 - 但编译器会将完整的泛型描述写入 class 文件的 Signature 属性中,从而可以通过反射 API(如
Field.getGenericType()、Method.getGenericReturnType())获取到泛型信息。
- 在 Java 中,编译后泛型信息在运行时会被擦除,只保留原始类型(例如
-
保存泛型信息的实例
-
如果直接写:
List<String> newList = new ArrayList<>();运行时无法通过反射获取具体的泛型类型信息,因为泛型信息被擦除了。
-
如果使用匿名内部类:
List<String> newList = new ArrayList<String>(){};则由于创建了 ArrayList 的匿名子类,编译后的字节码中保留了完整的泛型签名(例如
Ljava/util/ArrayList<Ljava/lang/String;>;),这样可以通过反射获取到。
-
-
Gson 示例
- Gson 就是通过读取 class 文件中的 Signature 属性来获取泛型信息,从而进行正确的序列化和反序列化。
5. 数组协变的优缺点
在 满足父子类关系 的情况下,可以把子类型数组赋给父类型数组;
| 语句 | 编译是否通过 | 运行时是否安全 | 说明 |
|---|---|---|---|
String[] sa = new String[2]; Object[] oa = sa; | ✅ | ⚠️ 可能抛 ArrayStoreException | String 是 Object 的子类, 数组协变允许子类型数组 → 父类型数组赋值 |
Object[] oa = new Object[2]; String[] sa = oa; | ❌ | — | 反方向不行,父类型数组不能赋给子类型数组 |
Integer[] ia = new Integer[2]; Number[] na = ia; | ✅ | 同上,写入非 Integer 会报错 | |
String[] sa = new String[2]; Integer[] ia = sa; | ❌ | — | 组件类型无继承关系,编译器直接拒绝 |
-
优点
-
数组默认开启协变,允许子类型数组赋值给父类型数组,例如:
String[] str1 = new String[2]; Object[] str2 = str1; // 允许赋值
-
-
缺点与风险
-
这种协变设计可能导致运行时异常(
ArrayStoreException),如:str2[1] = Integer.valueOf(666); // 编译期通过,但运行时崩溃,因为 Integer 不是 String 的子类型
-
6. 泛型推断
-
当你这样写:
List<Apple> apples3 = new ArrayList<>();Java 会根据上下文推断出 ArrayList 的泛型参数为 Apple,从而简化了代码书写。
7. Java 通配符与 Kotlin 投影
-
Java 中的
?通配符相当于 Kotlin 中的*投影。?默认相当于? extends Object,而? extends Object在 Kotlin 中类似于out Any。
泛型的擦除与获取
什么是类型擦除,什么是类型安全,为什么类型擦除会导致类型不安全?
类型擦除是指在编译期间,Java 编译器将泛型中使用的类型参数移除,并将其替换为它们的限定类型(如果没有明确限定,则默认为 Object)。这意味着像 List<Integer> 或 List<String> 这样的泛型在运行时都只存在一个原始类型 List,而不会保留具体的泛型类型信息。
类型安全指的是程序在编译或运行时确保所有操作都严格遵守预期的数据类型,从而防止出现由于类型不匹配引起的错误,比如错误的类型转换导致的 ClassCastException。一个类型安全的系统会在编译期间捕捉大部分类型错误,确保程序在运行时不会出现因为数据类型不正确而导致的异常行为。
类型擦除导致类型不安全的原因主要体现在以下几个方面:
-
运行时缺乏泛型信息
由于泛型参数在编译后被擦除,运行时无法获知对象实际使用的泛型类型信息,这使得某些本应在编译期捕获的错误只有在运行时才会暴露出来。例如,开发者在混用原始类型和泛型时,可能会绕过编译器的类型检查,最终导致运行时出现ClassCastException。 -
无法进行某些类型检查
类型擦除使得在运行时无法使用instanceof检查具体的泛型类型,因为所有泛型实例都归并为原始类型。这限制了开发者在运行时对泛型类型进行验证的能力,从而增加了类型不安全的风险。 -
泛型与数组的对比
与数组不同,数组在运行时保留了具体的类型信息(即它们是 reified 的),这使得数组在插入错误类型时能够立即抛出异常。而泛型由于类型擦除,只能依靠编译时检查,如果开发者绕过这些检查(比如通过不安全的类型转换或混用原始类型),就有可能在运行时出现类型错误。需要注意的是:kotlin 的数组在编译和运行时都保留了具体的类型信息,也就是说,数组是运行时类型可知(reified)的。这与 Java 泛型不同,泛型在编译后会经历类型擦除,运行时只保留原始类型信息。
kotlin的数组失去了协变的特性,也就是你不能这样赋值了:
var array = arrayOf<Int>(1, 2, 3)
val newArray : Array<Any> = array // 报错
在kotlin中,使用array存储的数据与list存储的数据的区别:
var array = arrayOf<Int>(1, 2, 3)
var array2 = listOf(1, 2, 3)
这两个表达式虽然都包含三个数字,但它们创建的集合类型和特性不同:
-
类型
arrayOf(1, 2, 3)创建的是一个Array<Int>,代表一个数组。listOf(1, 2, 3)创建的是一个只读List<Int>,代表一个列表。
-
可变性
- 数组(Array) :
数组的大小固定,但其中的元素是可变的。你可以通过索引修改数组中的值(例如array[0] = 100),但是不能增加或删除元素。 - 列表(List) :
listOf返回的是只读列表,意味着你不能添加、删除或替换其中的元素。如果需要修改的列表,应使用mutableListOf创建可变列表。
- 数组(Array) :
-
底层实现和用途
- 数组通常直接映射到底层的 Java 数组,适合对性能有一定要求且需要直接操作元素的场景。
- 列表作为集合接口的一部分,提供了丰富的函数式操作(如
map,filter等),更适合处理数据流和集合操作,同时保证数据不可变性。
总的来说,选择使用数组还是列表取决于你对数据结构的需求:如果需要一个可变的容器来修改元素,数组可能更合适;如果希望数据在创建后保持不变且享受集合操作的便利,列表则是更好的选择。
代码声明的泛型信息
当在代码中声明一个类、接口、方法参数或变量时,编译器会把泛型信息写入到 class 文件的签名中。如果没有为泛型参数指定上界,那么默认上界就是 Object。通过反射获取声明中的泛型信息时,例如对如下声明:
public class MyClass<T> { ... }
反射读取 T 的边界(例如调用 getBounds())会得到一个包含 Object 的数组,因为在这种情况下 T 默认 extends Object。如果你像这样明确指定上界:
public class MyClass<T extends Number> { ... }
那么反射时返回的 T 的边界就是 Number,而不是 Object。所以,返回的泛型边界信息取决于你的声明,如果没有指定上界,确实就是 Object。
这个声明的信息(例如 T 的边界)是存储在 class 文件里的,通过反射(如使用 getGenericSuperclass() 或 getGenericInterfaces())可以获取到这些信息。编译器将类型变量(比如 T)及其约束写入 class 文件,这部分信息是存在的,可以通过反射获得,但只是“类型变量”的信息(如 T extends Object)。
运行时对象的泛型信息缺失
由于 Java 的类型擦除机制,运行时对象所对应的类实例并不保存具体的泛型类型参数信息。例如,当我们直接实例化:
List<String> list = new ArrayList<>();
虽然源码中有 String,但运行时的 ArrayList 实例内部并没有保留关于 String 的信息,所以反射时你只能看到 ArrayList 而看不到 <String> 部分。
为什么声明能获取而运行时对象不能? 直接从运行时对象中获取泛型信息通常是不可行的,因为在编译时已经发生了类型擦除。编译器会把泛型参数擦除掉,仅保留原始类型,这意味着在运行时对象上并没有保存具体的泛型类型信息。如果需要在运行时获得泛型信息,就必须采用比如匿名子类、TypeToken 模式等技巧,让这些信息在 class 文件中得以保留,从而通过反射获取。
- 声明的泛型信息:是编译器在编译过程中写入到 class 文件里的元数据。比如,类的定义中写明了
<T extends Number>,这个信息在 class 文件中是存在的,因此反射能够读取到。 - 对象实例的泛型信息:在对象被创建时,其运行时类型已经是被擦除了的原始类型。也就是说,虽然你声明了
List<String>,但运行时这个对象只是ArrayList,它并不存储任何有关<String>的信息。
利用子类保存泛型信息
如果通过创建一个子类(甚至是匿名子类)来实例化对象,那么这个子类的 class 文件会包含具体的泛型参数信息。例如:
TypeToken<List<String>> typeToken = new TypeToken<List<String>>() {};
或者:
abstract class GenericClass<T> { ... }
GenericClass<String> instance = new GenericClass<String>() { ... };
在这种方式中,匿名子类的 class 文件在继承时明确指定了泛型参数(比如 String),因此反射时可以获取到这个信息。实际上,这种模式正是一些库(例如 Guava 的 TypeToken)所采用的技巧,用来“捕获”泛型参数的信息。
总结
- 声明时:所有代码中声明的类、接口、方法参数等泛型信息会被存入 class 文件中,可以通过反射获取。
- 直接创建的对象:由于类型擦除,运行时直接创建的对象其所属的类不包含具体的泛型参数信息。
- 子类策略:通过创建子类(包括匿名子类),可以让子类的 class 文件包含具体泛型参数,从而能在运行时通过反射恢复泛型信息。
由于类型擦除机制,在直接创建泛型对象时,运行时并不会保留具体的泛型参数信息;只有通过子类(包括匿名内部类)时,编译器会将具体的泛型类型写入子类的签名中,从而可以通过反射读取到。
此外,也有其他方法可以“传递”泛型信息,比如在构造函数中显式传入 Class<T> 对象或者使用类似 Gson 的 TypeToken 模式(本质也是匿名内部类),但这些方法本质上都是绕过类型擦除,而不是直接从对象本身自动获取泛型类型。
所以,如果希望通过反射自动捕获泛型类型信息,使用子类或匿名内部类确实是主要(也是唯一)的方法。 只有在方法或字段的签名中保存了泛型信息,才能通过反射获得;对于局部变量或普通实例的泛型信息,由于类型擦除的原因,运行时是获取不到具体类型的。
简言之:匿名子类并不是绕开擦除,而是创造一个额外的“声明点”,让同一套
Signature机制能记录到你>关心的真实类型。
示例 1:通过显式子类
假设有一个泛型类 MyGenericClass<T>,我们通过定义一个子类并在子类中指定具体的泛型参数,从而在反射时能捕获到该信息。
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
public class MyGenericClass<T> {
// 泛型类
}
// 显式子类,指定 T 为 String
public class StringGenericClass extends MyGenericClass<String> {
}
public class ReflectionExample1 {
public static void main(String[] args) {
// 获取 StringGenericClass 的直接父类(即 MyGenericClass<String>)
Type genericSuperclass = StringGenericClass.class.getGenericSuperclass();
if (genericSuperclass instanceof ParameterizedType) {
ParameterizedType pt = (ParameterizedType) genericSuperclass;
Type[] typeArgs = pt.getActualTypeArguments();
System.out.println("Captured generic type: " + typeArgs[0]); // 输出:class java.lang.String
}
}
}
说明:
- 在
StringGenericClass中,我们指定了T为String。 - 反射通过
getGenericSuperclass()得到父类的参数化类型信息,从而可以读取到实际的泛型参数(这里是String)。
示例 2:通过匿名内部类
另一种方式是使用匿名内部类,这种方式可以在创建实例时捕获具体的泛型类型。
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
public class MyGenericClass<T> {
// 泛型类
}
public class ReflectionExample2 {
public static void main(String[] args) {
// 使用匿名内部类创建 MyGenericClass 的子类实例,并指定 T 为 Integer
MyGenericClass<Integer> instance = new MyGenericClass<Integer>() { };
// 通过实例的运行时类型获取其父类的泛型信息
Type genericSuperclass = instance.getClass().getGenericSuperclass();
if (genericSuperclass instanceof ParameterizedType) {
ParameterizedType pt = (ParameterizedType) genericSuperclass;
Type[] typeArgs = pt.getActualTypeArguments();
System.out.println("Captured generic type: " + typeArgs[0]); // 输出:class java.lang.Integer
}
}
}
说明:
- 这里直接使用匿名内部类的方式创建了
MyGenericClass<Integer>的一个实例。 - 匿名内部类在编译后会生成一个包含具体泛型参数的 class 文件,因此反射时可以捕获到
Integer类型的信息。
关于反射与依赖于 源码声明处是否写了泛型 的再说明
| 能反射到的“泛型签名” | 依赖于 源码声明处是否写了泛型 | 何时可用 | 典型用法 |
|---|---|---|---|
类、字段、方法、父类接口 的 Signature | 写在类定义里就有 (成员变量 List<String> names;、方法返回 Map<K,V> 等) | 当你能拿到 Class<?> 对象 或 Field/Method 对象 | 框架在解析 POJO 字段、方法时 |
| 匿名子类 / TypeToken 捕获的实参 | 运行时“new 子类”时才写入 (new TypeToken<List<String>>() {}) | 当你只有一个 普通对象引用,“外部”又看不到任何带泛型的声明 | Gson、Guava 在调用栈上传递 “我要解析的真实类型” |
1 为什么“签名反射”有时够用?
class Box { // ↓ 字段签名固定写死
List<Map<String,Integer>> data;
}
// 只要我能拿到 Field,就能解析出 <List<Map<String,Integer>>>
Field f = Box.class.getDeclaredField("data");
Type t = f.getGenericType(); // ← 读取 ParameterizedType
- 这里 字段本身 在 class 文件里就带完整泛型,反射即可。
- 适用于 POJO 自动映射、Bean 校验 这类“解析类结构”场景。
2 为何还要匿名子类 / TypeToken?
场景 A:只有“变量”或“临时值”,源码里没留下字段
List<String> list = new ArrayList<>(); // 运行时 class = ArrayList
process(list); // ← 想知道元素究竟是什么?
- 变量
list不是字段,编译器不会把<String>写进任何地方。 - 运行时你只有
ArrayList原始类型,签名反射拿不到元素类型。
解决:把类型信息“绑”到匿名子类上再传递。
Type type = new TypeToken<List<String>>(){}.getType();
processWithTypeToken(json, type); // Gson / Moshi 等库做的事
场景 B:泛型超类的实参要在运行时获取
abstract class BaseDao<T> { ... }
BaseDao<User> dao = new BaseDao<User>() {}; // 匿名子类
Type t = dao.getClass().getGenericSuperclass(); // 得到 <User>
- 如果直接
new BaseDao<User>()(无子类),运行时类型是BaseDao,擦除后看不出User。 - 匿名子类把
BaseDao<User>写进自身签名 → 反射成功。
“声明里没有,但我要把真实类型带到运行时”是什么意思?
一句话:源码的类、字段、方法签名里根本没出现这份泛型实参,可我在运行阶段仍然需要知道它是什么,于是只能“另辟蹊径”把它带过去。
1 什么叫“声明里没有”?
| 代码位置 | 包含泛型签名吗? | 编译后 Signature 是否存在 |
|---|---|---|
| 类头 / extends / implements | 有 | ✔︎ |
字段 List<User> users; | 有 | ✔︎ |
| 方法形参 / 返回值 | 有 | ✔︎ |
| 局部变量 / 临时 new | 没有 | ✘ |
只要泛型只出现在局部变量或即时创建的对象上,编译器就不会把
<User>之类的信息写进.class文件。
示例
void demo() {
List<User> list = new ArrayList<>(); // ← 这里只是局部变量
parseJson(list); // 运行时想知道 List<User> 吗?
}
ArrayList<?>实例在堆里已被擦除;- 函数
demo()的字节码也没有记录<User>; - 反射 API 无法还原
User这个实参。
2 为什么“我要把真实类型带到运行时”?
| 常见需求 | 需要知道的“真实类型” |
|---|---|
| JSON 反序列化 | 目标类 List<User>、Map<String, Order> |
| 网络/数据库框架 | 返回封装类型 ApiResult<Post> |
| 依赖注入 / Service Locator | 运行时代码想根据 <T> 精确注入 |
| 事件总线 / 消息系统 | “订阅 Event<LoginSuccess>” 时需识别泛型事件 |
没有这份类型信息,框架就无法正确构造/转换对象。
3 如何“另辟蹊径”把类型带过去?
3.1 Java:匿名子类 / TypeToken
TypeToken 并不是 JDK 标准类,而是第三方库为了简化“捕获泛型实参”而提供的工具类:
Type type = new TypeToken<List<User>>() {}.getType();
List<User> users = gson.fromJson(json, type);
- 创建
TypeToken$1子类时,编译器把<Ljava/util/List<Lcom/xxx/User;>;>写进子类的Signature; - 运行时
getClass().getGenericSuperclass()就能解析<User>。
3.2 Kotlin:inline + reified
inline fun <reified T> Gson.from(json: String): T =
fromJson(json, T::class.java)
val users: List<User> = gson.from(json)
- 编译器在 调用点 注入
List<User>的真实类型字节码; - 函数体内可直接用
T::class.java,无需 TypeToken。
本质都是人为制造一个“声明处” ,让
<User>进入Signature,从而可反射。
4 小结
-
声明里没有:泛型仅出现在局部变量 / 临时 new,class 文件不会记录它。
-
想带去运行时:
- Java → 创建匿名子类 / TypeToken;
- Kotlin →
inline reified; - 或者显式传
Class<T>/Type参数。
-
目的:让框架在运行时仍能获知精确的类型实参,完成序列化、依赖注入等工作。
总结
- 显式子类:在子类声明时指定泛型参数,从而在反射中获取父类的泛型参数信息。
- 匿名内部类:在创建对象时用匿名内部类的方式指定泛型参数,同样能在生成的匿名类中保留泛型信息,进而通过反射获得。
泛型方法
1. 泛型方法必须在访问修饰符之后有一个<>
// 泛型方法必须在访问修饰符之后有一个<>
public <T> T genericMethod(T...a){
return a[a.length/2];
}
// 泛型方法必须在访问修饰符之后有一个<>
public static <T> T genericMethod2(T...a){
return a[a.length/2];
}
public void test(int x,int y){
System.out.println(x+y);
}
public static void main(String[] args) {
GenericMethod genericMethod = new GenericMethod();
genericMethod.test(23,343);
// 在Java中。<String>已经不是必须得了,因为JDK编译器会自动判定传入的参数是什么类型。
System.out.println(genericMethod.<String>genericMethod("mark","av","lance"));
System.out.println(genericMethod.genericMethod(12,34));
}
public <T,K> K showKeyName(Generic<T> container){
}
2. 下边2个都不是泛型方法
// 虽然在方法中使用了泛型,但是这并不是一个泛型方法。
// 这只是类中一个普通的成员方法,只不过他的返回值是在声明泛型类已经声明过的泛型。
// 所以在这个方法中才可以继续使用 T 这个泛型。
public T getKey(){
return key;
}
// 这个方法显然是有问题的,在编译器会给我们提示这样的错误信息"cannot reslove symbol E"
// 因为在类的声明中并未声明泛型E,所以在使用E做形参和返回值类型时,编译器会无法识别,除非该方法所在的类
// 声明了E
public E setKey(E key){
this.key = key;
}
3.泛型方法辨析:
class GenerateTest<T>{
//普通方法
public void show_1(T t){
System.out.println(t.toString());
}
// 在泛型类中声明了一个泛型方法,使用泛型E,这种泛型E可以为任意类型。
// 可以类型与T相同,也可以不同。
// 由于泛型方法在声明的时候会声明泛型<E>,因此即使在泛型类中并未声明泛型,
// 编译器也能够正确识别泛型方法中识别的泛型。
public <E> void show_3(E t){
System.out.println(t.toString());
}
// 在泛型类中声明了一个泛型方法,使用泛型T,
// 注意这个T是一种全新的类型,可以与泛型类中声明的T不是同一种类型。可以当做完全单独的一个类型。
public <T> void show_2(T t){
System.out.println(t.toString());
}
}
4. 限定类型变量
这个与Java的单继承,多实现有关,因此,我们写的泛型做的继承,可以有一个具体的类,或者抽象类,加上多个不同的接口。类只能写在第一个,且必须第一个。
public static <T extends ArrayList&Comparable> T min(T a, T b){
if(a.compareTo(b)>0) return a; else return b;
}
5. 泛型的约束与局限性
(1)泛型不能直接实例化;
(2)静态域或者静态方法不能使用类型变量 :private static T instance;因为在构造方法执行之前,instance就已经执行了,在此之前,类型不确定。无法确定泛型的类型。但是如果静态方法本身就是泛型类型的,是可以使用的,例如:
private static <T> T getInstance(){}
// 下面这种是不行的。
private static T getInstance(){}
泛型也不能使用 instanceof这样的关键字,就是用来判断一个泛型的类型,例如以下的代码就是不合法的:
if(restrict instanceof Restrict<Double>)
if(restrict instanceof Restrict<T>)
下边这个打印出来的是true,这是因为后续编译完成后会被泛型擦除,但是我们可以通过编译后的字节码中的方法签名对泛型进行还原。具体方式可以参考这里:www.cnblogs.com/cainiao-Shu…
public class Restrict<T>{}
Restrict<Double> restrict = new Restrict<>();
Restrict<String> restrictString= new Restrict<>();
System.out.println(restrict.getClass()==restrictString.getClass());
(3)泛型数组只能声明,不能进行实例化,这是因为:泛型信息在运行时被擦除,所以运行时系统实际上并不知道泛型数组应该具有什么具体类型。这意味着它无法检查你是否将正确类型的对象放入数组中,这违背了泛型的一个主要目的——类型安全。理论上,可以通过反射来创建泛型数组。例如,通过Array.newInstance(Class<?> componentType, int length)方法,你可以在运行时创建任意类型的数组。问题在于,由于类型擦除,在编译时并不知道泛型类型T的具体类是什么,除非在运行时通过额外的方式显式提供这个类型信息(例如,通过传递一个Class<T>对象)。这增加了复杂性,并且与泛型的设计初衷(即在编译时提供类型安全而不是在运行时)相违背;
(4)泛型类不能extends Exception/Throwable,Java的异常处理机制是基于类型检查的。当抛出一个异常时,JVM需要确切地知道这个异常的类型,以便能够匹配相应的catch块。如果异常类型是泛型的,类型擦除就会导致这种类型信息在运行时丢失,这使得JVM无法准确地处理这个异常。Java的异常处理机制是建立在静态类型检查的基础上的,意味着异常的类型需要在编译时就被明确知道。这是因为catch语句需要在编译时确定它们可以捕获哪些异常类型。如果异常类型是泛型的,那么由于类型擦除,其具体类型只能在运行时通过反射确定,这就使得编译时的类型检查变得不可能。
// 不合法
class Problem<T> extends Exception;
/*不能捕获泛型类对象*/
// public <T extends Throwable> void doWork(T x){
// try{
//
// }catch(T e){
// //do sth;
// }
// }
6. 通配符的产生与类型
先观察下边的代码:
public class Employee {
private String firstName;
private String secondName;
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getSecondName() {
return secondName;
}
public void setSecondName(String secondName) {
this.secondName = secondName;
}
}
```js
public class Worker extends Employee {
}
public class Pair<T> {
private T one;
private T two;
public T getOne() {
return one;
}
public void setOne(T one) {
this.one = one;
}
public T getTwo() {
return two;
}
public void setTwo(T two) {
this.two = two;
}
private static <T> void set(Pair<Employee> p){
}
public static void main(String[] args) {
//Pair<Employee>和Pair<Worker>没有任何继承关系
Pair<Employee> employeePair = new Pair<>();
Pair<Worker> workerPair = new Pair<>();
Employee employee = new Worker();
// 为了解决类型之间的继承关系,另外不代表类型变成泛型之后也具有集成关系
// Pair<Employee> employeePair2 = new Pair<Worker>();
Pair<Employee> pair = new ExtendPair<>();
set(employeePair);
//set(workerPair);
}
/*泛型类可以继承或者扩展其他泛型类,比如List和ArrayList*/
private static class ExtendPair<T> extends Pair<T>{
}
}
虚拟机如何实现泛型
如果有extends, 那么总会用第一个作为类型。
public class GenericRaw<T extends ArrayList&Comparable> {
private T data;
public T getData() { // 编译之后变成了public ArrayList getData()
return data;
}
public void setData(T data) {// 编译之后变成了public ArrayList setData(ArrayList data)
this.data = data;
}
public void test(){
data.compareTo(),// 编译成class文件之后,会变成这样(Comparable)data.compareTo(),
}
public static void main(String[] args) {
}
}
在一些开发工具中,下边的方法会报错,因为他们指判断了方法名和类型名。JDK还判断了返回值,因此下边的代码在JDK中不会报错。
public static String method(List<String> stringList){
System.out.println("List");
return "OK";
}
public static Integer method(List<Integer> stringList){
System.out.println("List");
return 1;
}
在真实的字节码中,Signature会保留相关的泛型的信息。一种弱记忆。
Kotlin中的泛型
场景跟 Java ⼀样,不过⽤法有⼀点不⼀样;
-
Java 的
<? extends>在 Kotlin ⾥写作<out>;Java 的<? super>在Kotlin ⾥写作<in>; -
另外,Kotlin 还增加了 out T in T 的修饰,来在类或接⼝的声明处就限制使⽤,这样你在使⽤时就不必再每次都写;
-
Kotlin 的 * 号相当于 Java 的 ? 号,基本⼀样,只是有些细节不⼀样。
Kotlin 的 out T 和 in T 是在类或接口声明时指定类型参数的协变或逆变。
-
out T: 表示该类型参数是协变的,只能用作返回值(生产者),类似于 Java 中使用通配符
? extends T。例如:interface Producer<out T> { fun produce(): T } interface Consumer<? extends Object> { ... } // 错误的写法,java中不能这么写 Consumer<? extends Object> consumer = ...; // 只能在使用的时候这么写。在这种情况下,你只能从
Producer中获取 T 类型的数据,而不能传入 T 类型的数据,因为这可能破坏类型安全。 -
in T: 表示该类型参数是逆变的,只能用作参数(消费者),类似于 Java 中使用通配符
? super T。例如:interface Consumer<in T> { fun consume(item: T) }这样声明后,你只能把 T 类型的数据传入 Consumer 中,而不能从 Consumer 中取出具体的 T 类型数据,因为其可能是一个更广泛的类型。
Java 中的情况:
Java 的泛型默认是不可变的,不支持在类或接口声明时直接标注协变或逆变。Java 采用的是使用通配符在使用处来声明变型,例如:
- 协变(生产者):
List<? extends T> - 逆变(消费者):
List<? super T>
这意味着 Java 中没有类似 Kotlin 那样在声明处直接限制类型参数用途的语法;必须在使用时通过通配符来实现。Kotlin 的这种声明处变型(declaration-site variance)提供了更好的语义表达和安全性,而 Java 只能通过使用处变型(use-site variance)来达到类似效果。
泛型与变型综合测评
| 题目 | 参考答案 | |
|---|---|---|
| 1 | 填空:PECS 原则中,Producer 用 ____(Java 通配符)或 ____(Kotlin 关键字)表示;Consumer 用 ____ 或 ____。 | Producer:? extends/out;Consumer:? super/in |
| 2 | 选择:下列哪个接口声明最适合“只读仓库”? A. interface Box<T> B. interface Box<in T> C. interface Box<out T> | C。协变 out T 只允许读,禁止写,符合只读语义。 |
| 3 | 判断:MutableList<out Fruit> 可以安全执行 add(Apple())。 | 错。out 列表禁止写入,编译直接报错。 |
| 4 | 简答:为什么 Java 数组协变会导致 ArrayStoreException? | 因为 String[] 可赋给 Object[],写入非 String 时编译通过,运行时 JVM 发现类型冲突抛异常,说明仅有运行时检查,类型安全不足。 |
| 5 | 填空:在 Kotlin 中,函数类型 (T) -> R 的参数位置_____变,返回值位置_____变。 | 参数逆变(in),返回协变(out)。 |
| 6 | 代码阅读:说明下面赋值为何在 Kotlin 报错而 Java 可通过(忽略可空差异)。 val ints = arrayOf(1,2); val objs:Array<Any> = ints | Kotlin 的 Array 不协变,编译期阻止类型逃逸;Java 数组协变但有运行时风险。 |
| 7 | 选择:以下哪种场景应使用 ? super Number 而不是 ? extends Number? A. 统计列表中数字之和 B. 向列表中批量写入 Integer 值 | B。写入数据需要逆变,选 ? super Number。 |
| 8 | 简答:什么是“类型擦除”?它如何导致“运行时缺乏泛型信息”? | 编译器将泛型参数替换为上界或 Object,生成字节码只保留原始类型;运行时对象无法知道具体类型,如 List<String> 变成原始 List。 |
| 9 | 填空:保留运行时泛型信息的常见技巧是创建一个带______的匿名内部类,例如 new TypeToken<List<String>>() {}。 | 具体类型参数写死在子类签名中(子类/匿名子类)。 |
| 10 | 简答:为何泛型类不能直接 extends Exception? | 因类型擦除,JVM 捕获异常需确切类型;若异常含类型参数,擦除后只剩原始类型,无法精确匹配 catch,破坏异常机制。 |
| 11 | 代码填空:下列泛型方法声明正确的是哪一个?为什么? A. static T foo(T x) { return x; } B. static <T> T foo(T x) { return x; } | 正确为 B;泛型方法必须在修饰符后显式写 <T> 声明类型变量。 |
| 12 | 综合:解释 List<Nothing> 与 MutableList<Any?> 在读写安全上的“极端对称”。 | List<Nothing> 协变极端:永不写,只能读(且为空);MutableList<Any?> 逆变极端:任意写,但读失去精确类型,仅得 Any?。 |