Java泛型中通配符的使用详解

目录

一、为什么要用通配符和边界?--泛型不是协变的

泛型不是协变的

类型擦除

为什么选择这种实现机制?不擦除不行么?

泛型擦除理解

JVM如何保留泛型信息

实例分析

通配符

类型系统

二、上界

三、下界

四、上下界通配符的副作用

五、PECS原则

六、一句话总结


<? extends T> 和 <? super T> 是Java泛型中的 通配符 和 边界 的概念。

  • <? extends T>:上界通配符
  • <? super T>:下界通配符

一、为什么要用通配符和边界?--泛型不是协变的

      开发人员在使用泛型的时候,很容易根据自己的直觉而犯一些错误。比如一个方法如果接收 List<Object> 作为形式参数,那么如果尝试将一个 List<String> 的对象作为实际参数传进去,却发现无法通过编译。

       虽然从直觉上来说,Object 是 String 的父类,这种类型转换应该是合理的。但是实际上这会产生隐含的类型转换问题,因此编译器直接就禁止这样的行为。

如我们有Fruit类,和它的派生类Apple

class Fruit {}
class Apple extends Fruit {}

然后有一个最简单的容器:Plate类,盘子里可以放一个泛型的”东西”

我们可以对这个东西做最简单的“放”和“取”的动作:set( )和get( )方法。

class Plate<T>{
    private T item;
    public Plate(T t){item=t;}
    public void set(T t){item=t;}
    public T get(){return item;}
}

现定义一个“水果盘”,逻辑上水果盘当然可以装苹果。

Plate<Fruit> p=new Plate<Apple>(new Apple());

但实际上Java编译器不允许这个操作。会报错,“装苹果的盘子”无法转换成“装水果的盘子”。

error: incompatible types: Plate<Apple> cannot be converted to Plate<Fruit>

实际上,编译器认定的逻辑是这样的: 

  • 苹果 IS-A 水果
  • 装苹果的盘子 NOT-IS-A 装水果的盘子

所以,就算容器里装的东西之间有继承关系,但容器之间是没有继承关系。

所以我们不可以把Plate<Apple>的引用传递给Plate<Fruit>。

泛型不是协变的

        在 Java 语言中,数组是协变的,也就是说,如果 Integer 扩展了 Number,那么不仅 Integer 是 Number,而且 Integer[] 也是 Number[],在要求 Number[] 的地方完全可以传递或者赋予 Integer[]。(更正式地说,如果 Number是 Integer 的超类型,那么 Number[] 也是 Integer[]的超类型)。

       您也许认为这一原理同样适用于泛型类型 —— List< Number> 是 List< Integer> 的超类型,那么可以在需要 List< Number> 的地方传递 List< Integer>。不幸的是,情况并非如此。为啥呢?这么做将破坏要提供的类型安全泛型。

类型擦除

为什么选择这种实现机制?不擦除不行么?

在Java诞生10年后,才想实现类似于C++模板的概念,即泛型。Java的类库是Java生态中非常宝贵的财富,必须保证向后兼容(即现有的代码和类文件依旧合法)和迁移兼容(泛化的代码和非泛化的代码可互相调用)基于上面这两个背景和考虑,Java设计者采取了“类型擦除”这种折中的实现方式。

同时正有这个这么“坑”的机制,令我们无法在运行期间随心所欲的获取到泛型参数的具体类型。

泛型擦除理解

        正确理解泛型概念的首要前提是理解类型擦除(type erasure)。 Java 中的泛型基本上都是在编译器这个层次来实现的。在生成的 Java 字节代码中是不包含泛型中的类型信息的。使用泛型的时候加上的类型参数,会被编译器在编译的时候去掉。这个过程就称为类型擦除。

        如在代码中定义的 List<Object> 和 List<String> 等类型,在编译之后都会变成 List。JVM 看到的只是 List,而由泛型附加的类型信息对 JVM 来说是不可见的。Java 编译器会在编译时尽可能的发现可能出错的地方,但是仍然无法避免在运行时刻出现类型转换异常的情况。类型擦除也是 Java 的泛型实现方式与C++ 模板机制实现方式之间的重要区别。

代码测试

    @Test
    public void test(){
        ArrayList<String> list1 = new ArrayList<>();
        ArrayList<Integer> list2 = new ArrayList<>();
        System.out.println(list1.getClass());
        System.out.println(list2.getClass());
    }

很多泛型的奇怪特性都与这个类型擦除的存在有关,包括:

  • 泛型类并没有自己独有的 Class 类对象。比如并不存在 List<String>.class 或是 List<Integer>.class,而只有 List.class,因此在运行时无法获得泛型的真实类型信息。
  • 静态变量是被泛型类的所有实例所共享的。对于声明为 MyClass<T> 的类,访问其中的静态变量的方法仍然是 MyClass.myStaticVar。不管是通过 new MyClass<String> 还是 new MyClass<Integer> 创建的对象,都是共享一个静态变量。
  • 泛型的类型参数不能用在 Java 异常处理的 catch 语句中。因为异常处理是由 JVM 在运行时刻来进行的。由于类型信息被擦除,JVM 是无法区分两个异常类型 MyException<String> 和 MyException<Integer> 的。对于 JVM 来说,它们都是 MyException 类型的。也就无法执行与异常对应的 catch 语句。

      类型擦除的基本过程也比较简单,首先是找到用来替换类型参数的具体类。这个具体类一般是 Object。如果指定了类型参数的上界的话,则使用这个上界。把代码中的类型参数都替换成具体的类。同时去掉出现的类型声明,即去掉 <> 的内容。比如 T get() 方法声明就变成了 Object get();List<String> 就变成了 List。

接下来就可能需要生成一些桥接方法。这是由于擦除了类型之后的类可能缺少某些必须的方法。比如考虑下面的代码:

class MyString implements Comparable<String> {
    public int compareTo(String str) {        
        return 0;    
    }
} 

当类型信息被擦除之后,上述类的声明变成了 class MyString implements Comparable。但是这样的话,类 MyString 就会有编译错误,因为没有实现接口 Comparable 声明的 int compareTo(Object) 方法。这个时候就由编译器来动态生成这个方法。

JVM如何保留泛型信息

这个想法是这样子的,既然像ArrayList<String> 这样中的泛型会被擦除掉,那么我用一个子类 SubList extends ArrayList<String>这样的话,在JVM内部中会不会把父类泛型的类型给保存下来呢?

我这个子类需要继承的父类的泛型都是已经确定了的呀,果然,JVM是有保存这部分信息的,它是保存在子类的Class信息中。

那么我们怎么获取这部分信息呢?还好,Java有提供API出来

public class TestMethod {
    @Test
    public void test1(){
        Student student = new Student();
        /**
         * getGenericSuperclass()获得带有泛型的父类
         * Type是 Java 编程语言中所有类型的公共高级接口。它们包括原始类型、参数化类型、数组类型、类型变量和基本类型。
         */
        Type mySuperClass = student.getClass().getGenericSuperclass();
 
        Type type = ((ParameterizedType) mySuperClass).getActualTypeArguments()[0];
        System.out.println(type);
    }
}
 
class Student extends ArrayList<Map<Integer, Boolean>>{
}

概括来说就是对于带有泛型的class,返回一个ParameterizedType对象,对于Object、接口和原始类型返回null,对于数组class则是返回Object.class。

ParameterizedType是表示带有泛型参数的类型的Java类型,JDK1.5引入了泛型之 后,Java中所有的Class都实现了Type接口,ParameterizedType则是继承了Type接口,所有包含泛型的Class类都会实现 这个接口。

实例分析

了解了类型擦除机制之后,就会明白编译器承担了全部的类型检查工作。编译器禁止某些泛型的使用方式,正是为了确保类型的安全性。以上面提到的 List<Object> 和 List<String> 为例来具体分析:

public void inspect(List<Object> list) {    
    for (Object obj : list) {        
        System.out.println(obj);    
    }    
    list.add(1); // 这个操作在当前方法的上下文是合法的。 
}
public void test() {    
    List<String> strs = new ArrayList<String>();    
    inspect(strs); // 编译错误 
}  

这段代码中,inspect 方法接受 List<Object> 作为参数,当在 test 方法中试图传入 List<String> 的时候,会出现编译错误。

假设这样的做法是允许的,那么在 inspect 方法就可以通过 list.add(1) 来向集合中添加一个数字。这样在 test 方法看来,其声明为 List<String> 的集合中却被添加了一个 Integer 类型的对象。这显然是违反类型安全的原则的,在某个时候肯定会抛出ClassCastException。因此,编译器禁止这样的行为。

编译器会尽可能的检查可能存在的类型安全问题。对于确定是违反相关原则的地方,会给出编译错误。当编译器无法判断类型的使用是否正确的时候,会给出警告信息。 

为了让泛型用起来更舒服,Sun的大师们就想出了<? extends T>和<? super T>的办法,来让”水果盘子“和”苹果盘子“之间发生正当关系。

通配符

在使用泛型类的时候,既可以指定一个具体的类型,如 List<String> ,也可以用通配符? 来表示未知类型,如 List<?> 就声明了 List 中包含的元素类型是未知的。

通配符所代表的其实是一组类型,但具体的类型是未知的。

List<?> 所声明的就是所有类型都是可以的。但是 List<?> 并不等同于 List<Object>。

List<Object> 实际上确定了 List 中包含的是 Object 及其子类,在使用的时候都可以通过 Object 来进行引用。而 List<?> 则其中所包含的元素类型是不确定。其中可能包含的是 String,也可能是 Integer。如果它包含了 String 的话,往里面添加 Integer 类型的元素就是错误的。

正因为类型未知,就不能通过 new ArrayList<?>() 的方法来创建一个新的 ArrayList 对象。因为编译器无法知道具体的类型是什么。但是对于 List<?> 中的元素确总是可以用 Object 来引用的,因为虽然类型未知,但肯定是 Object 及其子类。

考虑下面的代码:

public void wildcard(List<?> list) {
    list.add(1);// 编译错误 
}  

如上所示,试图对一个带通配符的泛型类进行操作的时候,总是会出现编译错误。其原因在于通配符所表示的类型是未知的。

?”不能添加元素,只能作为消费者

因为对于 List<?> 中的元素只能用 Object 来引用,在有些情况下不是很方便。在这些情况下,可以使用上下界来限制未知类型的范围。 如 List<? extends Number> 说明 List 中可能包含的元素类型是 Number 及其子类。而 List<? super Number> 则说明 List 中包含的是 Number 及其父类。当引入了上界之后,在使用类型的时候就可以使用上界类中定义的方法。比如访问 List<? extends Number> 的时候,就可以使用 Number 类的 intValue 等方法。

类型系统

在 Java 中,大家比较熟悉的是通过继承机制而产生的类型体系结构。比如 String 继承自 Object,当需要 Object 类的引用的时候,如果传入一个 String 对象是没有任何问题的。但是反过来的话,即用父类的引用替换子类引用的时候,就需要进行强制类型转换。编译器并不能保证运行时刻这种转换一定是合法的。这种自动的子类替换父类的类型转换机制,对于数组也是适用的(数组是协变的)。 String[] 可以替换 Object[]。但是泛型的引入,对于这个类型系统产生了一定的影响。正如前面提到的 List<String> 是不能替换掉 List<Object> 的。

       引入泛型之后的类型系统增加了两个维度:一个是类型参数自身的继承体系结构,另外一个是泛型类或接口自身的继承体系结构。第一个指的是对于 List<String> 和 List<Object> 这样的情况,类型参数 String 是继承自 Object 的。而第二种指的是 List 接口继承自 Collection 接口。对于这个类型系统,有如下的一些规则:

  • 相同类型参数的泛型类的关系取决于泛型类自身的继承体系结构。即 List<String> 是 Collection<String> 的子类型,List<String> 可以替换 Collection<String>。这种情况也适用于带有上下界的类型声明。
  • 当泛型类的类型声明中使用了通配符的时候, 其子类型可以在两个维度上分别展开。如对 Collection<? extends Number> 来说,其子类型可以在 Collection 这个维度上展开,即 List<? extends Number> 和 Set<? extends Number> 等;也可以在 Number 这个层次上展开,即 Collection<Double> 和 Collection<Integer> 等。如此循环下去,ArrayList<Long> 和 HashSet<Double> 等也都算是 Collection<? extends Number> 的子类型。
  • 如果泛型类中包含多个类型参数,则对于每个类型参数分别应用上面的规则。

理解了上面的规则之后,就可以很容易的修正实例分析中给出的代码了。只需要把 List<Object> 改成 List<?> 即可。List<String> 是 List<?> 的子类型,因此传递参数时不会发生错误。

二、上界

下面就是上界通配符

Plate<? extends Fruit>

一个能放水果以及一切是水果派生类的盘子

Plate<? extends Fruit>和Plate<Apple>最大的区别就是:Plate<? extends Fruit>是Plate<Fruit>及Plate<Apple>的基类

直接的好处就是,我们可以用“苹果盘”给“水果盘”赋值了。

Plate<? extends Fruit> p=new Plate<Apple>(new Apple());

再扩展一下,食物分成水果和肉类,水果有苹果和香蕉,肉类有猪肉和牛肉,苹果还有两种青苹果和红苹果。

//Lev 1
class Food{}
 
//Lev 2
class Fruit extends Food{}
class Meat extends Food{}
 
//Lev 3
class Apple extends Fruit{}
class Banana extends Fruit{}
class Pork extends Meat{}
class Beef extends Meat{}
 
//Lev 4
class RedApple extends Apple{}
class GreenApple extends Apple{}

在这个体系中,上界通配符Plate<? extends Fruit>覆盖下图中蓝色的区域。 

三、下界

相对应的下界通配符

Plate<? super Fruit>

表达的就是相反的概念:一个能放水果以及一切是水果基类的盘子。

Plate<? super Fruit>是Plate<Fruit>的基类,但不是Plate<Apple>的基类

对应刚才那个例子,Plate<? super Fruit>覆盖下图中红色的区域。

四、上下界通配符的副作用

边界让Java不同泛型之间的转换更容易了。但不要忘记,这样的转换也有一定的副作用。那就是容器的部分功能可能失效。

还是以刚才的Plate为例。我们可以对盘子做两件事,往盘子里set( )新东西,以及从盘子里get( )东西。

class Plate<T>{
    private T item;
    public Plate(T t){item=t;}
    public void set(T t){item=t;}
    public T get(){return item;}
}

1、上界<? extends T>不能往里存,只能往外取

  • <? extends Fruit>会使往盘子里放东西的set( )方法失效,但取东西get( )方法还有效
  • 取出来的东西只能存放在Fruit或它的基类里面,向上造型。

比如下面例子里两个set()方法,插入Apple和Fruit都报错。

Plate<? extends Fruit> p=new Plate<Apple>(new Apple());
    
//不能存入任何元素
p.set(new Fruit());    //Error
p.set(new Apple());    //Error
 
//读取出来的东西只能存放在Fruit或它的基类里。
Fruit newFruit1=p.get();
Object newFruit2=p.get();
Apple newFruit3=p.get();    //Error

编译器只知道容器内是Fruit或者它的派生类,但具体是什么类型不知道,因此取出来的时候要向上造型为基类。

通俗理解:

我们给这两个盘子贴标签,上面写着(本盘子为装食物的盘子:Plate<? extends Food>),编号为plate1、plate2:

Plate<? extends Food> plate1 = new Plate<Fruit>();

Plate<? extends Food> plate2 = new Plate<Meat>();

此时,有人使用这两个盘子,他根据标签只知道这两个都是食物的盘子,他有下面的行为:

  • 他想要从盘子中拿出Food(get),他随便拿两个盘子中的任意一个,都获得了Food。
  • 他想存放一个Apple(put),他走到box2前面,但是box2是装肉的盘子,所有他放Apple进去失败了。

2、下界<? super T>不影响往里存,但往外取只能放在Object对象里

  • 使用下界<? super Fruit>会使从盘子里取东西的get( )方法部分失效,只能存放到Object对象里。
  • 因为规定的下界,对于上界并不清楚,所以只能放到最根本的基类Object中
Plate<? super Fruit> p=new Plate<Fruit>(new Fruit());
 
//存入元素正常
p.set(new Fruit());
p.set(new Apple());
 
//读取出来的东西只能存放在Object类里。
Apple newFruit3=p.get();    //Error
Fruit newFruit1=p.get();    //Error
Object newFruit2=p.get();

因为下界规定了元素的最小粒度的下限,实际上是放松了容器元素的类型控制。既然元素是Fruit的基类,那往里存粒度比Fruit小的都可以。

但往外读取元素就费劲了,只有所有类的基类Object对象才能装下。但这样的话,元素的类型信息就全部丢失。

通俗理解:

假如我们还有可以装水果的盘子,上面的标签是(本盘子为装RedApple或者苹果父类的盘子:Plate<? super Apple> ),编号plate3:

Plate<? super RedApple> plate3 = new Plate<Fruit>();

此时,有人想用这个盘子,他根据标签只知道这个盘子是装RedApple或者RedApple父类的盘子,他有以下行为:

  • 他想从箱子里面拿东西(Get),但拿出来了一个Meat,但他之前没见过Meat,很迷惑,这个世界上竟然有Meat,能吃吗。
  • 他想放一个RedApple到这个箱子,于是放进去了。他想放一个GreenApple,也放进去了。

五、PECS原则

PECS原则的全拼是"Producer Extends Consumer Super"

当需要频繁取值,而不需要写值则使用 " ? extends T " 作为数据结构泛型。即extends 可用于返回类型限定,不能用于参数类型限定。

相反,当需要频繁写值,而不需要取值则使用 " ? super T " 作为数据结构泛型。 super 可用于参数类型限定,不能用于返回类型限定。

六、一句话总结

<? extends T>确定了上界为T,那么获取值时类型一定是T或T的父类,但赋值时由于不能确定具体是T或者T的某个子类而赋值失败。

<? super T>确定了下界为T,那么在获取值时不确定类型是T或T的哪个父类,只能用Object接收,使得类型信息丢失,但赋值时只要为T或T的子类都可以。

参考文章:Java泛型解惑之 extends T>和 super T>上下界限_yaoshengting的博客-CSDN博客_泛型extends
Java 泛型 <? super T> <? extend T> 的通俗理解_水心可乐的博客-CSDN博客

  • 13
    点赞
  • 36
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值