在 Java 中,协变(Covariance) 和 逆变(Contravariance) 是泛型系统中的重要概念,用于描述子类型如何在复杂类型(如集合)中传递。
1. 基础概念:什么是协变和逆变?
假设 B
是 A
的子类型(例如 Integer
是 Number
的子类型),那么:
- 协变:
List<B>
是List<A>
的子类型(允许从子类型向父类型转换)。 - 逆变:
List<A>
是List<B>
的子类型(允许从父类型向子类型转换)。 - 不变:
List<B>
和List<A>
之间没有继承关系(不允许直接转换)。
Java 中的规则:
- 数组是协变的,但可能导致运行时错误。
- 泛型是不变的,但可以通过通配符(
? extends T
和? super T
)实现有限的协变和逆变。
2. 数组的协变(Java 中的特殊情况)
Java 的数组是协变的,即如果 B
是 A
的子类,则 B[]
可以赋值给 A[]
。但这可能导致运行时异常:
Number[] numbers = new Integer[5]; // 合法,数组是协变的
numbers[0] = 3.14; // 运行时错误:ArrayStoreException(数组类型不匹配)
类比:
想象一个 “水果篮子”(Fruit[]
),可以放入苹果(Apple[]
)或香蕉(Banana[]
)。但如果篮子实际装的是苹果(new Apple[5]
),你却试图放入香蕉(Banana
),就会报错。
3. 泛型的不可协变性(不变性)
Java 的泛型是不可协变的,即 List<B>
不是 List<A>
的子类型,即使 B
是 A
的子类。例如:
List<Number> numbers = new ArrayList<Integer>(); // 编译错误!
为什么泛型不可协变?
如果允许这样的赋值,会破坏类型安全。例如:
List<Integer> ints = new ArrayList<>();
ints.add(10);
// 假设允许 List<Integer> 赋值给 List<Number>
List<Number> nums = ints; // 编译错误(实际不允许)
nums.add(3.14); // 如果允许,这里会向 List<Integer> 中添加 Double!
Integer i = ints.get(0); // 运行时 ClassCastException!
类比:
想象一个 “苹果篮子”(List<Apple>
)和一个 “水果篮子”(List<Fruit>
)。虽然苹果是水果的一种,但 “苹果篮子”不是“水果篮子” 的子类。如果允许将 “苹果篮子” 当作 “水果篮子”,就可能向其中放入香蕉,导致类型错误。
4. 泛型通配符实现有限的协变和逆变
虽然泛型本身不可协变,但可以通过通配符实现安全的类型转换:
(1)上限通配符 ? extends T
(协变)
允许读取 T
类型的元素,但不能写入(除了 null
):
List<? extends Number> numbers = new ArrayList<Integer>(); // 合法
Number num = numbers.get(0); // 合法,读取为 Number
// numbers.add(10); // 编译错误,不能添加任何类型(除了 null)
作用:安全地从集合中读取元素(生产者)。
类比:
有一个 “水果篮子”(List<? extends Fruit>
),你知道里面装的是水果(或其子类),但不确定具体是哪种水果。因此,你可以安全地从中取出水果(作为 Fruit
类型),但不能向其中添加任何水果(因为不知道篮子的具体类型)。
(2)下限通配符 ? super T
(逆变)
允许写入 T
类型的元素,但读取时只能作为 Object
:
List<? super Integer> numbers = new ArrayList<Number>(); // 合法
numbers.add(10); // 合法,可添加 Integer
Object obj = numbers.get(0); // 读取为 Object
作用:安全地向集合中写入元素(消费者)。
类比:
有一个 “篮子”(List<? super Apple>
),你知道它可以装苹果(或其父类,如水果、食物)。因此,你可以安全地向其中放入苹果(Apple
),但取出时只能当作 “物品”(Object
),因为不确定篮子的具体类型。
5. 协变与逆变的对比表
场景 | 数组(协变) | 泛型(不变) | 泛型通配符(有限协变 / 逆变) |
---|---|---|---|
赋值 | Number[] = Integer[] (合法) | List<Number> = List<Integer> (编译错误) | List<? extends Number> = List<Integer> (合法) |
写入元素 | 可能运行时错误 | 编译错误 | ? extends T :禁止写入 ? super T :允许写入 T |
读取元素 | 安全 | 安全 | ? extends T :读取为 T ? super T :读取为 Object |
类型安全 | 运行时可能出错 | 编译阶段强制检查 | 编译阶段保证安全 |
6. 总结:何时使用协变和逆变?
-
协变(
? extends T
):当你只需要从集合中读取元素时使用。例如:// 安全地读取 List<Integer> 中的元素 List<? extends Number> nums = List.of(1, 2, 3); Number n = nums.get(0); // 合法
-
逆变(
? super T
):当你只需要向集合中// 安全地向 List<Object> 中添加 Integer List<? super Integer> nums = new ArrayList<>(); nums.add(10); // 合法
通过通配符,Java 泛型在保证类型安全的前提下,提供了一定的灵活性。理解协变和逆变是掌握 Java 泛型高级用法的关键。
7. 使用场景
(1)只读场景:使用 ? extends T
(协变)
如果你只需要从集合中读取元素,使用上限通配符 ? extends T
。
示例:计算数字集合的总和:
public double sum(List<? extends Number> numbers) {
double sum = 0;
for (Number num : numbers) {
sum += num.doubleValue();
}
return sum;
}
// 可以传入 List<Integer> 或 List<Double>
List<Integer> ints = List.of(1, 2, 3);
sum(ints); // 合法
List<Double> doubles = List.of(1.5, 2.5);
sum(doubles); // 合法
限制:不能向 List<? extends Number>
中添加元素(除了 null
),因为无法确定具体类型。
(2)只写场景:使用 ? super T
(逆变)
如果你只需要向集合中写入元素,使用下限通配符 ? super T
。
示例:将整数添加到集合中:
public void addNumbers(List<? super Integer> numbers) {
for (int i = 0; i < 10; i++) {
numbers.add(i); // 合法:可以添加 Integer 类型
}
}
// 可以传入 List<Integer>、List<Number> 或 List<Object>
List<Number> nums = new ArrayList<>();
addNumbers(nums); // 合法
限制:从 List<? super Integer>
中读取元素时,只能作为 Object
类型,因为无法确定具体类型。
(3)读写兼顾场景:使用不变(无通配符)
如果你需要对集合进行读写操作,则不使用通配符,直接指定具体类型。
示例:操作整数列表:
public void processIntegers(List<Integer> ints) {
// 读取元素
int first = ints.get(0);
// 写入元素
ints.add(first * 2);
}
// 只能传入 List<Integer>,不能传入 List<Number> 或 List<Object>
List<Integer> ints = new ArrayList<>();
processIntegers(ints); // 合法
限制:类型必须精确匹配,不允许类型转换。
8. 常见误区与注意事项
-
数组的协变陷阱:
数组的协变可能导致运行时异常(ArrayStoreException
),应尽量避免将子类数组赋值给父类数组变量。 -
通配符与子类的区别:
List<? extends Number>
不代表 “所有Number
子类的列表”,而是 “某个具体子类的列表”。例如:List<? extends Number> nums = new ArrayList<Integer>(); // 合法 // nums.add(3.14); // 编译错误:无法确定具体类型
-
逆变的读取限制:
List<? super Integer>
中读取的元素只能作为Object
,因为无法确定集合的具体类型。 -
泛型方法 vs 通配符:
如果需要在多个参数或返回值之间建立类型关联,优先使用泛型方法而非通配符。例如:<T> void swap(List<T> list, int i, int j); // 泛型方法,保证两个索引位置的元素类型一致
9. 实战案例
案例 1:生产者场景(读取数据)
// 计算集合中所有数字的平均值
public double average(List<? extends Number> numbers) {
double sum = 0;
for (Number num : numbers) {
sum += num.doubleValue();
}
return sum / numbers.size();
}
案例 2:消费者场景(写入数据)
// 将元素添加到集合
public void fill(List<? super String> list, String value, int count) {
for (int i = 0; i < count; i++) {
list.add(value); // 合法:可以添加 String
}
}
案例 3:读写兼顾场景(精确类型)
// 对列表中的每个元素执行操作
public void transform(List<Integer> list, Function<Integer, Integer> mapper) {
for (int i = 0; i < list.size(); i++) {
list.set(i, mapper.apply(list.get(i))); // 读写操作,必须精确类型
}
}