搞明白 Java 的通配符泛型

简介: Java 泛型中的通配符泛型问题困扰我很久,即 <? super T> 和 <? extends T> 和 <?> 这几种泛型,到底是什么,到底怎么用。从含义上理解, super 是指 T 和 T 的父类,extends 是指 T 和 T 的子类。网上有一个简单的原则叫PECS(Producer Extends Consumer Super)原则:往外读取内容的,适合用上界 Extends,往里插入的,适合用下界 Super。不过这个原则讲得很含糊,而且没有考虑到全部情境,所以我写一篇文章再来讲一下这几个泛型到底怎么用。通配符泛型用在哪里?网上很多资料连用在哪里都没有说清楚,导致我们用的

Java 泛型中的通配符泛型问题困扰我很久,即 <? super T> 和 <? extends T> 和 <?> 这几种泛型,到底是什么,到底怎么用。从含义上理解, super 是指 T 和 T 的父类,extends 是指 T 和 T 的子类。网上有一个简单的原则叫PECS(Producer Extends Consumer Super)原则:往外读取内容的,适合用上界 Extends,往里插入的,适合用下界 Super。不过这个原则讲得很含糊,而且没有考虑到全部情境,所以我写一篇文章再来讲一下这几个泛型到底怎么用。

通配符泛型用在哪里?

网上很多资料连用在哪里都没有说清楚,导致我们用的时候一头雾水,在这里我有必要先说清楚。

首先,我们先说泛型 ,会在三个地方用到(不是通配符泛型):

  • 新建和表示一个泛型类变量
1
List<String> list = new ArrayList<>();
  • 泛型类的定义中
1
publicinterfaceList<E>
  • 函数定义中
1
<T> T[] toArray(T[] a)

那么,一般来说,我们的通配符泛型只适用于:

函数中的参数部分

比如 Collections.copy() 方法

1
publicstatic <T> voidcopy(List<? super T> dest,List<? extends T> src)

或者是 Stream.map() 方法

1
<R> Stream<R> map(Function<? super T, ? extends R> mapper)

从语法上说,用在新建和表示一个泛型类变量也可以用,但是如果不在通配符泛型作参数的函数中使用,没有任何用处,请不要被网上的资料的 demo 误导。

1
List<? extends Number> list = new ArrayList<>(); // 这个代码没有任何用处!

没有用处的原因可以接着往下看。

为什么要用通配符泛型

我们现在有这样一个函数

1
2
3
publicvoidtest(List<Number> data){
}

根据泛型规则,这个函数只能传进来 List<Number> 一种类型,我想传 List<Object>List<Integer> 都是传不进去的。

但是,我既要泛型,又想把这两个类型的子类或者父类的泛型传进去,可不可以呢,是可以的,就是使用通配符泛型。

但是,通配符泛型限制也很多:

  1. 只能选择一个方向,要么选 【List 和 List】 要么选 【List 和 List】
  2. 有副作用

通配符泛型的方向和限制

我们先看一下 List 的接口

1
2
3
4
publicinterfaceList<E> { // 固定一个类型 E
E get(int index);
booleanadd(E e);
}

get() 方法的返回值和 E 关联,我们姑且称之为取返回值

而 add() 方法是参数和 E 关联,我们姑且称之为传参数

向父类扩大泛型 <? super T>

super 在这里也叫父类型通配符

我们把上面的函数升级一下,变成下面的方法

1
2
3
publicvoidtest(List<? super Number> data){
}

那么,现在,好消息是,我既可以传 List<Number> ,也可以传 List<Object> 进上面的函数。

但是,从 向父类扩大泛型的 List 的获取返回值【E get(int i)】的时候, E 的类型没有办法获取了,因为不知道你传进去的到底是 List<Number> 还是 List<Object>,所以统一向上转 E 为 Object

1
2
3
publicvoidtest(List<? super Number> data){
    Object object = data.get(1); // 只能用 Object 接住变量
}

而往 向父类扩大泛型的 List 传参数【add(E e) 】时,只要是 Number 或者 Number 子类,都可以传。因为不管你 E 是 Number 还是 Object ,我传一个 Integer 进去总是可以的。

1
2
3
4
publicvoidtest(List<? super Number> data){
    Integer i = 5;
    data.add(i);
}

向子类扩大泛型 <? extends T> 和 <?>

extends 在这里也叫子类型通配符

我们把上面的函数升级一下,变成下面的方法

1
2
3
publicvoidtest(List<? extends Number> data){
}

那么,现在,好消息是,我既可以传 List<Number> ,也可以传 List<Integer> 进去

但是,从 向子类扩大泛型的 List 的获取返回值【E get(int i)】的时候,E 的类型被统一为 Number,因为不知道你传进去的到底是 List<Number> 还是List<Integer>,返回的时候都可以向上转到 Number。

1
2
3
publicvoidtest(List<? extends Number> data){
    Number number = data.get(2);
}

而往 向子类扩大泛型的 List 传参数【add(E e) 】时,你不可以传。因为 E 这个时候没法确定了。因为你有可能传 List<Number>List<Integer>List<Double>,而 e 如果是一个 Number,是传不进子类的参数类型的,比如现在传进来一个 List<Integer>,那函数就变成 add(Integer e),你不能传一个 Number 进来,所以不可以往这个 向子类扩大泛型的 List 传参数

1
2
3
4
publicvoidtest(List<? extends Number> data){
    Integer i = 5;
    data.add(i); // 错误,无法通过编译
}

还有一个 <?> 有什么用呢?它等价于 <? extends Object> ,具体用的时候和没有泛型大体一致。

怎么用?JDK 中的使用例子

相信你看完上面的限制之后,已经不再想用这个麻烦的玩意了,或者更加奇怪为什么要设计一个这样的东西出来。让我们看一下 JDK 里面的用法吧。

ArrayList.forEach

1
2
3
4
5
6
7
8
9
10
publicvoidforEach(Consumer<? super E> action){
    Objects.requireNonNull(action);
finalint expectedModCount = modCount;
final Object[] es = elementData;
finalint size = this.size;
for (int i = 0; modCount == expectedModCount && i < size; i++)
        action.accept(elementAt(es, i));
if (modCount != expectedModCount)
thrownew ConcurrentModificationException();
}

表示消费 E 或者 E 的父类的消费者可以消费这些元素。

比如对于一个 ArrayList<Integer> ,我们可以传一个 Consumer<Integer> 也可以传一个 Consumer<Number>,表示的意思是,既然你可以消费 XXX 的父类,那么,我也可以把你的子类传给你。

<? super E> 的向父类扩大泛型,向 action 取返回值有影响,向 action 传参数没有影响。而 Consumer本身就是一个没有返回值的接口。

1
2
3
publicinterfaceConsumer<T> {
voidaccept(T t);
}
1
Consumer<Number> numberConsumer = number -> System.out.println(number.doubleValue());

Collections.copy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
publicstatic <T> voidcopy(List<? super T> dest, List<? extends T> src){
int srcSize = src.size();
if (srcSize > dest.size())
thrownew IndexOutOfBoundsException("Source does not fit in dest");
if (srcSize < COPY_THRESHOLD ||
            (src instanceof RandomAccess && dest instanceof RandomAccess)) {
for (int i=0; i<srcSize; i++)
                dest.set(i, src.get(i));
        } else {
            ListIterator<? super T> di=dest.listIterator();
            ListIterator<? extends T> si=src.listIterator();
for (int i=0; i<srcSize; i++) {
                di.next();
                di.set(si.next());
            }
        }
    }

这个函数可以把一个 Integer 的 List 转成 Number 的 List

1
Collections.copy(new ArrayList<Number>(),new ArrayList<Integer>());

这里不知道你有没有疑问,为什么它既用 super 又用 extends 呢,因为这里用于静态函数,所以T的类型是调用时才确定,那么T到底应该是 Integer 还是 Number 呢,虽然这不影响最终调用结果,但这多少给调用者造成一些困惑。

还有第二个问题,按照我们上面说的,用了 super 之后,取返回值的话,会有一个限制,即强转到 Object。

有人认为这是该函数的作者强调 PECS 原则,但是在这个情境下,这个原则并不合适。

其实,我们可以只把 T 固定为 Number,然后少用一个 <? super T> ,既可以解决歧义,同时又避免函数内部取返回值时强转到 Object 。

1
publicstatic <T> voidcopy(List<T> dest, List<? extends T> src)

参考:https://stackoverflow.com/questions/34985220/differences-between-copylist-super-t-dest-list-extends-t-src-and-co

我再提一下很流行的 PECS 原则:往外读取内容的,适合用上界 extends,往里插入的,适合用下界 super。这句话确实没错,用来解释这个函数,dest是被写入的,用 super ,src 是读取的,用 extends

然而,PECS 还漏了一种情况,就是我不用上下界的时候,我既可以读,也可以插入。如果条件允许,比如这个函数中的 是根据参数类型确定的,我们应该优先使用 T,而不是生搬硬套 PECS 原则。

Stream.flatMap

从这里开始,就讲的比较复杂了:

1
2
3
publicinterfaceStream<T> {
    <R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);
}

flatMap 是 一个什么函数呢?他可以把 Stream 里面的元素转成 Stream,再在内部合并,比如 Stream<Integer>,对于每一个 Integer 都执行一次这个 mapper, Integer 经过这个 mapper ,就变成 Stream,最后再把所有 Stream 合并成一个 Stream,再返回。

1
2
Stream<String> stringStream = 
new ArrayList<Integer>().stream().flatMap(integer -> Stream.of("1", "2"));

使用示例,一个 Integer 的流转成了一个 String 的流,如果原来是 [1,1] ,那么现在变成 [“1”,”2”,”1”,”2”]

我们先看一下 Function 接口

1
2
3
publicinterfaceFunction<T, R> {
R apply(T t);
}

意思就是输入一个 T 类型的参数,返回一个 R 类型的返回值

我们的 integer -> Stream.of(“1”, “2”) 也可以写成这样

1
2
3
public Stream<String> apply(Integer integer){
return Stream.of("1", "2");
}

回到我们的 flatMap 函数,这里的 T 已经在 Stream 创建的时候确定了,我们以 Stream<Integer> 为例,T就是 Integer

我们看到 Function中的 T 类型是: ? super T 意味着不光 Integer 可以作为 Function 的传入参数,它的父类也可以,比如 Number,上面例子是 Integer

接着是定义 R 的类型即返回值类型:? extends Stream<? extends R>,对应例子里面是 Stream 的识别

先看? extends Stream,为什么要有这个呢,因为 Stream 是接口,而有时候我们可能会传一个 Stream 的实现类进去(当然,这个机会很少),这样就放宽了门槛。上面的例子返回的 Stream 是 Stream

接着看? extends R,这里的 R 包括 R 和 R 的子类,R由输入的 Function 的泛型 Stream 确定,这个例子里面是 String。那么既然总是可以通过输入的参数确定R,那 extends R 有什么用呢?这样写可以多一个功能,这样你可以显式修改 R 的类型,从而改变返回值类型。

1
2
Stream<Number> numberStream = 
new ArrayList<Integer>().stream().<Number>flatMap(integer -> Stream.of(1, 2));

原来应该返回 Stream<Integer> ,但是现在被我在 flatMap 前面用 显式指定了 R 的类型,这样子 最后返回 Stream 的时候不再是 Stream<Integer>

而反观 Colletions.copy 也有类似的 <? super T> ,因为 T 总是可以被输入的参数确定,而和上面的不同的是,这个即使显式指定,也无法修改返回值,所以除了副作用没别的作用,所以我还是坚持我的看法。

总结

虽然说上面的例子看起来比较难懂,但是说实话,在我们平常的开发中,通配符泛型并没有经常用到,我们只需要调用库的时候看懂库的参数是什么意思就好。

我简单的再分析下两个通配符泛型的使用场景:

<? super T> 可能会在一些消费者的函数里面用到,比如参数是 Consumer 接口的时候,我们可以带上一个 super T

<? extends T> 的副作用是比较大的,适用于给多种不同的子类的集合做归约操作,比如有 List<Integer>List<Double>,你可以写一个函数统一处理 List <? extends Number> 。

另外,在写完一个带泛型参数的函数之后,我们可以思考一下要不要用通配符泛型扩大范围,从而让我们的函数更加具有通用性。

关于为什么在普通代码中

1
List<? extends Number> list = new ArrayList<>();

没有用的原因,因为你创建了之后,因为 extends 的副作用,你根本没法修改这个 ArrayList 。 所以在普通代码中,用到 通配符泛型的情景很少。

关于 PECS,我至今没记住这几个英文单词的顺序,我认为不能生搬硬套,还是要根据实际情况分析是否合理。因为 PECS 最大的问题是它只告诉你用通配符泛型的情景下你应该如何选择,没有告诉你什么时候用 通配符泛型,什么时候不用。

相关文章
|
4月前
|
安全 Java 编译器
揭秘JAVA深渊:那些让你头大的最晦涩知识点,从泛型迷思到并发陷阱,你敢挑战吗?
【8月更文挑战第22天】Java中的难点常隐藏在其高级特性中,如泛型与类型擦除、并发编程中的内存可见性及指令重排,以及反射与动态代理等。这些特性虽强大却也晦涩,要求开发者深入理解JVM运作机制及计算机底层细节。例如,泛型在编译时检查类型以增强安全性,但在运行时因类型擦除而丢失类型信息,可能导致类型安全问题。并发编程中,内存可见性和指令重排对同步机制提出更高要求,不当处理会导致数据不一致。反射与动态代理虽提供运行时行为定制能力,但也增加了复杂度和性能开销。掌握这些知识需深厚的技术底蕴和实践经验。
96 2
|
2月前
|
Java API
[Java]泛型
本文详细介绍了Java泛型的相关概念和使用方法,包括类型判断、继承泛型类或实现泛型接口、泛型通配符、泛型方法、泛型上下边界、静态方法中使用泛型等内容。作者通过多个示例和测试代码,深入浅出地解释了泛型的原理和应用场景,帮助读者更好地理解和掌握Java泛型的使用技巧。文章还探讨了一些常见的疑惑和误区,如泛型擦除和基本数据类型数组的使用限制。最后,作者强调了泛型在实际开发中的重要性和应用价值。
49 0
[Java]泛型
|
2月前
|
存储 安全 Java
🌱Java零基础 - 泛型详解
【10月更文挑战第7天】本文收录于「滚雪球学Java」专栏,专业攻坚指数级提升,希望能够助你一臂之力,帮你早日登顶实现财富自由🚀;同时,欢迎大家关注&&收藏&&订阅!持续更新中,up!up!up!!
20 1
|
3月前
|
Java 编译器 容器
Java——包装类和泛型
包装类是Java中一种特殊类,用于将基本数据类型(如 `int`、`double`、`char` 等)封装成对象。这样做可以利用对象的特性和方法。Java 提供了八种基本数据类型的包装类:`Integer` (`int`)、`Double` (`double`)、`Byte` (`byte`)、`Short` (`short`)、`Long` (`long`)、`Float` (`float`)、`Character` (`char`) 和 `Boolean` (`boolean`)。包装类可以通过 `valueOf()` 方法或自动装箱/拆箱机制创建。
47 9
Java——包装类和泛型
|
2月前
|
Java 语音技术 容器
java数据结构泛型
java数据结构泛型
28 5
|
2月前
|
存储 Java 编译器
Java集合定义其泛型
Java集合定义其泛型
20 1
|
2月前
|
存储 Java 编译器
【用Java学习数据结构系列】初识泛型
【用Java学习数据结构系列】初识泛型
23 2
|
3月前
|
安全 Java API
【Java面试题汇总】Java基础篇——String+集合+泛型+IO+异常+反射(2023版)
String常量池、String、StringBuffer、Stringbuilder有什么区别、List与Set的区别、ArrayList和LinkedList的区别、HashMap底层原理、ConcurrentHashMap、HashMap和Hashtable的区别、泛型擦除、ABA问题、IO多路复用、BIO、NIO、O、异常处理机制、反射
|
3月前
|
存储 安全 搜索推荐
Java中的泛型
【9月更文挑战第15天】在 Java 中,泛型是一种编译时类型检查机制,通过使用类型参数提升代码的安全性和重用性。其主要作用包括类型安全,避免运行时类型转换错误,以及代码重用,允许编写通用逻辑。泛型通过尖括号 `&lt;&gt;` 定义类型参数,并支持上界和下界限定,以及无界和有界通配符。使用泛型需注意类型擦除、无法创建泛型数组及基本数据类型的限制。泛型显著提高了代码的安全性和灵活性。
|
2月前
|
安全 Java 编译器
Java基础-泛型机制
Java基础-泛型机制
18 0