java泛型的那些事

2020-05-13 16:04:09来源:博客园 阅读 ()

新老客户大回馈,云服务器低至5折

java泛型的那些事

学习泛型的理由

首先明确为什么需要学习泛型?个人觉得至少有三个理由:1、使用泛型可以让你在声明类(或者创建方法)的时候不着急立即去指定它的类型,而是等到你实例化对象(或者方法调用)的时候才明确它的类型;2、避免通过使用Object类型来泛指java对象时,因类型强制向下转型时发生错误;3、可以毫无障碍的阅读Java相关源码。你经常遇到诸如Comparator<? super E> comparatorList<? extends Number>此类的代码,可能不太明白其中的含义,如果你学会了泛型,就毫无压力可以从容地面对那些代码了。

什么是泛型

泛型不光是在java,在很多面向对象语言及各种设计模式中有广泛的应用。所谓的泛型,其实就是把类型“参数化”。

一提到参数,大家最熟悉的就是定义方法时的形参和调用方法时的实参。那么类型“参数化”到底怎么理解呢?顾名思义,类型“参数化”就是将类型由原来的具体类型,变成参数化的“类型”,有点类似于方法中的变量参数,不过此时是类型定义成参数形式(你可以理解为类型形参),然后在使用时传入具体的类型(也就是类型实参)。为什么这样操作呢?因为它能让类型"参数化",也就是在不创建新的类型的情况下,通过泛型可以指定不同类型来控制形参具体限制的类型。

举一个你可能在实际编程过程中遇到过的例子:

  public static void main(String[] args){
        List arrayList = Arrays.asList("hello","world",2018);
        for(int i=0;i<arrayList.size();i++){
            String result = (String) arrayList.get(i);
            System.out.println(result);
        }
    }

猜猜运行结果,可以发现输出信息为:

Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String

为什么会这样呢?我们知道ArrayList可以存放任意类型,在本例中仅仅是添加了两个String类型和一个Integer类型,只不过在获取对象的时候都变成String类型,由于Integer类型无法强制转为String类型,所以程序报出异常。问题就在于程序在编译时期并没有报错,而是在运行期报错了。我们希望有这么一个类型机制,就是只要程序在编译时期没有出现警告,那么运行时期就不会出现上述的ClassCastException异常,其实这就是Java泛型设计的原则。泛型其实就是将类型明确的工作推迟到创建对象或方法调用的时候才去明确的特殊类型。

为了解决上面那个问题,你需要将声明arrayList的代码修改一下,这样编译器在编译阶段就能发现类似的问题:

List<String> arrayList = Arrays.asList("hello","world",2020);
// Incompatible types.在编译阶段,编译器就会报错,无需运行

通过"泛型"这种语法糖,我们就能在编译阶段发现由于类型异常而导致的程序运行问题。

泛型只存在于编译阶段

泛型只存在于编译阶段,到了程序运行时刻就不存在了,这一现象称为“泛型擦除”。当你使用javac编译器将.java源文件转为字节码的.class文件时,泛型就不存在了。接下来通过一个例子来验证泛型只存在于编译阶段这一论据:

   public static void main(String[] args){
        List<String> stringList = new ArrayList<>();

        List<Integer> integerList = new ArrayList<>();

        Class classStringList = stringList.getClass();
        Class classIntegerList = integerList.getClass();

        System.out.println(classStringList==classIntegerList);
    }

猜猜运行结果,可以发现输出信息为:

true

这里我们使用了java反射机制,这样可以在运行时动态获取类的相关信息,这从侧面验证了论据的正确性。程序会编译后去除泛型,即Java中的泛型只在编译阶段有效。在编译过程正确检验泛型结果后,会将泛型的相关信息擦除,且在对象进入和离开方法的边界处添加类型检查和类型转换的方法。也就是说,泛型信息不会进入到运行时阶段。泛型类型在逻辑上看似乎是多个不同的类型,但其实都是相同的类型。(注意泛型不能使用基本数据类型,必须是其对应的包装类。

由于你在声明stringList对象的时候,使用泛型确定了它存储的元素类型为String,因此可以使用增强for循环来遍历stringList对象。

Java泛型中的标记符

在使用Java泛型前,了解其中一些标识符的含义,有助于提升开发效率。常用的标识符及含义如下:

 E - Element (集合使用,因集合中存放元素)
 T - Type(Java 类)
 K - Key(键)
 V - Value(值)
 N - Number(数值类型)
 ? -  表示不确定的java类型
 S、U、V  - 2nd、3rd、4th types

你可能会有疑问,弄这么多标识符干嘛,直接使用万能的Object难道不香么?我们知道Object是所有类的基类(任何类如果没有指明其继承类,都默认继承于Object类),因此任何类的对象都可以设置Object的引用,只不过在使用的时候可能要类型强制转换。但是如果设置了泛型E、T等这些标识符,那么在实际使用之前类型就已经确定,因此不再需要类型强制转换。

泛型的使用

在泛型使用过程中,操作的数据类型被指定为一个参数,这种参数类型可以用在类、接口和方法中,分别被称为泛型类、泛型接口和泛型方法。

泛型类

顾名思义,泛型类就是把泛型定义在类上,用户使用该类的时候,才把类型确定下来。用户确定什么类型,该类就代表什么类型,就不用担心在使用的时候需要类型强转及运行时转换异常等问题。泛型使用最多的就是各种容器类,如List、Set、Map等,通过泛型可以对一组类的操作提供对外相同的接口。请注意,在类上定义的泛型,在类的方法中同样也能使用(普通静态方法除外)。

泛型类的基本写法:

class 类名称 <泛型标识:一般是T,标识指定的泛型类型>{
  private 泛型标识 variable; 
  .....

  }
}

这么看你可能会感到困惑,这里结合一个实例来进行说明。新建一个泛型类:

package com.envy.parameter;

public class ParameterClass<T> {
    private T type;

    public T getType(){
        return type;
    }

    public void setType(T type){
        this.type = type;
    }

    //无参的构造方法
    public ParameterClass(){
    }

    //有参的构造方法
    public ParameterClass(T type){
        this.type =type;
    }
}

接下来开始进行测试,前面说过用户需要哪种类型,就可以在实例化对象的时候指定哪种类型:

   public static void main(String[] args){
        ParameterClass<String> stringParameterClass = new ParameterClass<>("envy");
        String result = stringParameterClass.getType();
        System.out.println(result);  //envy

        ParameterClass<Integer> integerParameterClass = new ParameterClass<>(100);
        Integer item = integerParameterClass.getType();
        System.out.println(item);  //100
    }

现在有一个疑问就是定义的泛型类,在使用时必须传入泛型类型实参么?答案是不一定,非必须。

如果你在使用泛型的时候传入了泛型实参,那么该泛型类会根据传入的泛型实参来限制相应的类型,此时泛型开始进行“本职工作”。如果不传入泛型类型实参,那么此处的泛型就显得尴尬,没有起到任何作用,你在泛型类中使用泛型方法或成员变量定义的类型可以为任意类型。其实该类是一个万花筒,什么都可以是它的子类,举个例子来说:

    public static void main(String[] args){
        ParameterClass stringType = new ParameterClass("envy");
        ParameterClass integerType = new ParameterClass(2018);
        ParameterClass booleanType = new ParameterClass<>(false);
        System.out.println("【stringType】="+stringType.getType());  //【stringType】=envy
        System.out.println("【integerType】="+integerType.getType());  //【integerType】=2018
        System.out.println("【booleanType】="+booleanType.getType());  //【booleanType】=false
    }

再次强调一下,泛型的类型参数只能是类类型,不能是基本类型。同时不能对确切的泛型类型使用instanceof操作,因为该操作是非法的,编译时会报错。

Collection cs = new ArrayList<String>();
//该操作是非法的,编译时会报错
if (cs instanceof Collection<String>) { ... }

泛型接口

顾名思义,泛型接口就是把泛型定义在接口上,泛型接口与泛型类的定义及使用基本相同,但是也有一些不同之处。先举一个非常简单的泛型接口的例子来感受一下泛型接口的使用:

package com.envy.parameter;

public interface ParameterInterface<T> {
    T test();
}

我们知道接口肯定是要被类实现的,否则设计就显得毫无意义。根据这个接口实现类的泛型判断,可能存在两种情况:“接口实现类中未传入泛型实参”和“接口实现类中已传入泛型实参”。

接口实现类中未传入泛型实参,这种情况其实和泛型类的定义相同,那么要求你在声明类的时候,需要将泛型的声明加到类中,举个例子来说:

package com.envy.parameter;

public class OneClass<T>  implements ParameterInterface<T> {
    @Override
    public T test() {
        return null;
    }
}

注意如果上面你不将泛型的声明加到类中,也就是public class OneClass implements ParameterInterface<T>时,程序会抛异常。

接口实现类中已传入泛型实参,此时由于接口实现类已经指定了具体的类型,那么接口类中的T必须和指定的具体类型保持一致,否则程序会抛异常:

package com.envy.parameter;

public class TwoClass<String> implements ParameterInterface<String>{
    @Override
    public String test() {
        return null;
    }
}

泛型方法

前面说过在泛型类上定义的泛型,在类的方法中也能使用(普通静态方法除外)。但是有的时候我们只想在某个方法上使用泛型,而不是整个类,这也是被允许的,只不过泛型方法的定义就显得比较复杂。在大部分情况下,你遇到的都是泛型类中存在泛型方法的例子,尤其是在阅读源码的时候。

还记得前面推荐你学习泛型的第一个理由吗?使用泛型可以让你在声明类(或者创建方法)的时候不着急立即去指定它的类型,而是等到你实例化对象(或者方法调用)的时候才明确它的类型。也就是说泛型都是先定义后使用的,理解这一点非常重要。

同样还是先举一个非常简单的泛型方法的例子来感受一下泛型方法的使用:

package com.envy.parameter;

public class ThreeClass {
    public <T> T get(T t){
        return t;
    }
}

接下来开始进行测试,前面说过方法是在被调用的时候才去指定它的类型:

package com.envy.parameter;

public class ThreeClass {
    public <T> T get(T t){
        return t;
    }

    public static void main(String[] args){
        ThreeClass three = new ThreeClass();
        System.out.println(three.get("hello"));  // hello
        System.out.println(three.get(2018));  // 2018
        System.out.println(three.get(6.6));  // 6.6
    }
}

这么看来好像和前面泛型类的情况很相似啊,并没有什么复杂之处,其实这是最简单的情况,往下看:

  public T hello(T t){
        return t;
    }

这个是不是和前面的非常相似,一不留神你还以为两者是一样的呢。请注意后者publicT之间没有使用<T>,也就是说明这个方法并不是泛型方法,而且你会发现编译器还抛异常:

也就是说只有public与返回值之间存在<T>这个泛型标识,才可以认为该方法是一个泛型方法,与参数是否包含泛型无关。

这样你就能一眼看出到底哪个才是“真的”泛型方法,举个例子来考考你:

package com.envy.parameter;

public class FourClass<T> {
    private T t;

    public FourClass(T t){
        this.t = t;
    }
    
    public T getT(){ 
        return t;
    }
}

这里的FourClass被定义为一个泛型类,然后使用属性t,不过它的类型是泛型T,它的作用就是用于控制这个泛型类在被实例化的时候的具体类型。这么看你肯定知道getT只是这个属性t的get方法,只不过它的返回类型就是泛型T吧了,肯定知道它不是一个泛型方法。这样理解是没有任何问题的,之所以举这个例子是想加深对上面那个泛型特征的理解。

还需要注意的是在泛型类中若使用了T来表示泛型的类型,那么后续就不能使用其他的标记符如E,K,V,N等,理由是你已经使用T来表示不知道的类型,怎么还能用E,K,V,N来表示呢?这其实就是一旦确定就无法修改的事,生活中有很多这种情况,你一出生就是男孩,你能变成女孩吗???

 public E getT(E e){  //错误的方法
        return e;
    }

前面举的都是较为简单的泛型方法,接下来举一个较为复杂的例子。下面这段代码是没有任何问题,也就是说public与返回值之间可以存在多个泛型标识,且可以返回其中的任意一个:

public <T,K,V,N> K getSome(K k){
        return k;
    }

来看一张图片:

前面是普通方法,后面则是泛型方法,但是泛型方法抛异常了,说类型不一致,但是IDEA提示明明都是T泛型类型啊?怎么会抛异常呢?还记的前面说的那句话吗?泛型方法在被调用的时候才去指定它的类型,显然这个t是前面private T t;中的t,t是属性,是某个具体的东西(只是目前不知道它应该是什么,假设这里的T代表Fruit水果,那么这个t可以是一个梨,或者一个苹果)。因此你就不能传这个具体的东西了,你应该返回T本身,这样就没有问题。但是一般IDEA还是会提示一个Expression expected信息,为什么呢?还是那句话:泛型方法在被调用的时候才去指定它的类型。你现在方法中不传入任何参数,那么如何保证在调用的时候能指定它的类型?所以它会提示你最好采取下面这种方式:

 public <T> T getTest(T t){
        return t;
    }

通过传入的参数来控制泛型方法在被调用的时候的具体类型,而泛型类一般是通过属性来控制。所以建议大家在定义泛型类的时候定义泛型属性,定义泛型方法的时候传入泛型参数。

泛型类中的泛型方法

泛型方法可以出现在任何地方,但是当它出现在泛型类中就比较难以理解了。还是先举一个非常简单的泛型类中存在泛型方法的例子来感受一下:

package com.envy.parameter;

public class FiveClass<T> {
    private T t;
    
    public <T> void test(T t){
        System.out.println(t);
    }
}

这个没有任何问题。现在往里面新增一个hello方法,相应的代码为:

   public <E> void hello(E e){
        System.out.println(e);
    }

很奇怪编译器居然没有报错,当然不会报错了,这是正确的写法。你可能会感到困惑之前为什么就报错了呢?因为之前不是泛型方法:

public E getT(E e){  //错误的方法
        return e;
    }

在泛型类中定义了一个泛型方法,且使用了E来表示泛型,这个泛型E可以表示为任意类型。请注意,泛型方法中的泛型标识符可以与泛型类中的泛型标识符不一致。为什么可以不一致,因为你实例化泛型类的时候,不一定要求和调用泛型方法时中的参数类型保持一致。用上面的代码测试一下:

   public static void main(String[] args){
        FiveClass five = new FiveClass("envy");
        five.test(2018);
    }

看到没,你实例化对象的时候是String类型,但是调用的方法却是Integer类型。

既然这样,那么泛型方法在定义的时候如果使用了泛型,那么你这个类是不是泛型类就无所谓了,这也侧面说明了泛型方法不仅仅只存在于泛型类中。

回过头来看下面一段代码,这个也是没有问题的,因为你在声明stringList对象的时候,使用泛型确定了它存储的元素类型为String,因此可以使用增强for循环来遍历stringList对象。

   public static void main(String[] args){
        List<String> stringList = Arrays.asList("hello","world");
        for(String s:stringList){
            System.out.println(s);
        }
    }

java中有一个不定项参数T...args,这个类似于Python中的*args,它的意思是说args中的每一个对象都是T类型,因此它也是可以使用增强型for循环进行遍历输出(请注意这个不定项参数不一定非得在泛型方法中,普通方法中也是可以使用的):

   public void world(T...args){
        for(T a:args){
            System.out.println(a);
        }
    };

    public static void main(String[] args){
        FiveClass fiveClass = new FiveClass("envy");
        fiveClass.world("hello","world",2018);  // hello,world,2018
    }

其实这里还有一点需要注意,就是泛型类中的静态方法。前面说过在泛型类上定义的泛型,在类的方法中也能使用(普通静态方法除外)。现在告诉你为什么普通静态方法不能使用?往下看:

我们都知道静态方法是一个特殊的存在,它不依赖于对象而调用,因为它属于类本身,因此更多时候都是推荐你通过类名.静态方法名称的方法来调用。这里就是想告诉你,静态方法无法直接使用泛型类中的泛型

你必须将此静态方法定义为泛型方法,否则会出错。当然泛型标识符同样也可以与泛型类不一致:

 public static<E> void watch(E e){
    }

其实这个也很好理解,因为静态方法依赖于类,而你这个类都是泛型类,那你这静态方法如果想使用泛型,那么也必须给我变成泛型方法。如果你不使用泛型就没必要这样操作:

package com.envy.parameter;

public class SixClass<T> {
    private T t;

    public void see(T t){  //普通方法

    };

    public static<E> void watch(E e){  //使用泛型的静态方法
    }
    
    public static void good(){}  //不使用泛型的静态方法
}

泛型方法、泛型接口、泛型类小结

从上面的介绍你也看到了,泛型类的好处就是在泛型类上定义的泛型,在类的方法中也能使用(普通静态方法除外)。而泛型方法的最大优点就是能独立于类,不受类是否是泛型类的限制。因此当你考虑使用泛型的时候,优先考虑定义泛型方法。如果非要定义泛型类,个人建议通过使用泛型方法来将整个类泛型化,因为这样就不用担心静态方法的事,如果有静态方法那必然是泛型方法。这样就可以避免普通静态方法无法获取泛型类泛型的尴尬局面。

你以为这就把泛型介绍完了吗?不没有,这只是介绍了理由1和2,理由3还没介绍呢?

Java泛型中的通配符

通配符的由来

举一个常见的例子,这个例子会让你明白通配符存在的必要性。我们知道在Java中所有的数值型(byte,short,int,long,float,double)其对应的包装类都是继承自Number类(char和 boolean是继承Object类):

通过泛型只存在于编译阶段这一论据的介绍,你肯定知道下面一段代码输出的结果为true:

package com.envy.parameter;

public class SevenClass<T> {
    private T t;

    public T getT(){
        return t;
    }

    public SevenClass(T t){  //有参的构造方法
        this.t=t;
    }
    
    public static void main(String[] args){
        SevenClass<Long> longSeven = new SevenClass<>();

        SevenClass<Number> numberSeven = new SevenClass<>();

        Class longSevenClass = longSeven.getClass();
        Class numberSevenClass = numberSeven.getClass();

        System.out.println(longSevenClass==numberSevenClass); //true
    }
}

也就说在运行过程中SevenClass<Long>SevenClass<Number>其实是同一种数据类型。那么问题来了,在使用SevenClass<Number>作为形参的方法中,能否使用SevenClass<Long>作为实参进行传入呢?能否将SevenClass<Number>SevenClass<Long>可以看成具有继承关系的泛型类型呢?或者说泛型是否也存在继承关系呢?

可以发现编译器报错了,也就是说在使用SevenClass<Number>作为形参的方法中,不能使用SevenClass<Long>作为实参进行传入。泛型中的<Number><Long>并没有继承关系,它们两个是没有任何关联,就是不同的对象。

其实就是告诉你泛型不是协变的。在Java中,数组是协变的。我们知道Long继承于Number,那么你肯定知道Long是Number,但你可能并不知道其实Long[]也是Number[],这就是Java中的协变。如果某个地方要求你使用Number[],那么你完全可以使用Integer[]。但是在泛型中List<Number>List<Long>没有任何继承关系,也就是说要求你使用List<Number>的地方不能使用List<Long>,这是保护泛型类型安全的一种策略手段。

那么如何解决上面这个问题呢?此时我们多希望有一个机制,使用它后,使得这个方法既能传入SevenClass<Number>参数,也能传入SevenClass<Long>参数,这就是通配符的设计原则。你可以使用?来代替参数中的具体类型:

package com.envy.parameter;

public class SevenClass<T> {
    private T t;

    public T getT(){
        return t;
    }
    public SevenClass(T t){
        this.t=t;
    }

    public void watch(SevenClass<?> seven){
        System.out.println(seven.getT());
    }

    public static void main(String[] args){
        SevenClass<Number> numberSevenClass = new SevenClass<>(2018);
        SevenClass<Long> longSevenClass = new SevenClass<>(2019L);

        numberSevenClass.watch(longSevenClass);
    }
}

请注意,类型通配符一般是使用来代替具体的类型实参。是类型实参,不是类型形参 !上例中的就是指代Number、Long、Integer等具体的类型,这么看来可以认为是所有类型的父类,不过它是一种具体的类型。

由于这里的是所有类型的父类,虽说能表示一种具体的类型,但是具体哪种还需要你指定,因此你不能使用任何类型特有的方法,只能使用Object类提供的基础方法。

通配符的边界

在使用泛型的时候,你可以对传入的泛型类型实参进行边界限制。例如只允许类型实参为某种类型的父类或某种类型的子类。既然有父类和子类之分,那么就说明边界存在上下之分。

通配符上边界

所谓通配符的上边界,其实就是规定传入的类型实参必须是指定类型的子类型(包含本身),一般使用<? extend 指定类型>格式进行设置。

举个例子来说,假设某个List集合中只能存放Byte,Short,Integer,Long,Float,Double这几个类型中的任意一个,那么应该如何来限制呢?其实你只要限制它们必须为Number的子类型即可,可以使用通配符上边界<? extend Number>来进行设置:

    public static void book(List<? extends Number> list){
        System.out.println("envy");
    }

    public static void main(String[] args){
        List<Integer> integerList = new ArrayList<>();
        List<Long> longList = new ArrayList<>();
        List<Double> doubleList = new ArrayList<>();
        List<Number> numberList = new ArrayList<>();

        book(integerList);
        book(longList);
        book(doubleList);
        book(numberList);
    }

上述代码没有任何问题,且很好的实现了我们的目的。但是当你往声明List泛型为String时,程序就会抛异常,因为String不是Number的子类:

这是把泛型通配符使用在了book方法上(注意book方法不是泛型方法),同样你也可以将其定义在类上,此时就限制了泛型类实例化对象的类型:

package com.envy.parameter;

public class EightClass<T extends Number> {
    private T t;

    public T getT(){
        return t;
    }

    public EightClass(T t){
        this.t =t;
    }

    public static void main(String[] args){

        //String不是Number的子类,会抛异常
        EightClass<String> eightClass = new EightClass<String>(); 
    }
}

接下来就在其中定义一个泛型方法,很不幸它出错了:

泛型方法要求必须在访问修饰符和返回值类型之间的<T>内声明该泛型的上下边界。也就是说,泛型的上下边界必须与泛型声明在一起 。

    public <T extends Number> T show(EightClass<T> eightClass){
        T t = eightClass.getT();
        return t;
    };

通配符下边界

所谓通配符的下边界,其实就是规定传入的类型实参必须是指定类型的父类型(包含本身),一般使用<? super 指定类型>格式进行设置。其实通配符下边界的使用比上边界更为广泛,尤其是在Java源码中体现的更为全面。以最常见的List来进行说明,里面有一个sort方法:

它要求必须传入一个可比较的对象,因为是排序,只有可以比较的对象才能按照某种规则进行排序,这里的下边界就是可以比较的对象。

说到这里就不得不提PECS(Producer Extends Consumer Super)原则了。

PECS原则

在泛型中存在一个PECS(Producer Extends Consumer Super)原则。下面通过一个例子来告诉你,且该例子你肯定写过,只是没有想过这个问题罢了:

 public void foo(Map<String,String> map){
        //do something;
    };

这不就是在foo方法里传入一个map对象嘛,它的键和值类型都为String类型。是的大家都是这么认为,但是突然有一天,某个好奇心很重的童鞋提出了一种别出心裁的写法:

 public void foo(Map<? extends String, ? extends String> map){
        //do something;
    };

这两种写法有区别吗?有的,前面说过类型通配符一般是使用来代替具体的类型实参。上面那种写法就仅仅是特指String一种类型,但是下面这种写法表明你可以是String类型,还可以是它的子类型。


回过头来再来介绍一下? extends? super

? extends

先介绍? extends(通配符上边界,传入的类型实参必须是指定类型的子类型(包含本身)),还是通过实例来详细说明更好一些:

package com.envy.parameter;

import java.util.ArrayList;
import java.util.List;

public class PECS {
    static class Food{};
    static class Fruit extends Food{};
    static class Pear extends Fruit{};

    public static void main(String[] args){
        List<? extends Fruit> fruits = new ArrayList<>();
        fruits.add(new Pear());  //错误
        fruits.add(new Fruit());  //错误
        fruits.add(new Food());  //错误

        fruits = new ArrayList<Pear>();  //正确
        fruits = new ArrayList<Fruit>();  //正确
        fruits = new ArrayList<Food>();  //超出Fruit上边界
        fruits = new ArrayList<? extends Fruit>()  //通配符类型无法实例化 
        
        Fruit fruit = fruits.get(0);  //正确
    }
}

如果你仔细理解了这句话:“类型通配符一般是使用来代替具体的类型实参。”那么你就知道上面的代码出错在哪里。类型实参,这个fruits都已经是某个具体的类型,但是类型并不知道,所以怎么还会允许你往里面添加具体的类型对象呢?肯定是不行的。但是你可以在实例化的时候指定它的类型,只要不超过泛型设定的上边界(上限)Fruit,那都是允许的,所以你实例化Pear和Fruit对象都是可以的。后面一个是因为超出边界,另一个是非法实例化通配符类型而导致出错。但是需要说明的是,虽然不知道这个fruits是哪个具体的类型,但它肯定是Fruit的子类,因此你获取它是没有任何问题的。

读取数据:由于它必然是Fruit的子类型,因此总可以从中读取出Fruit对象。写入数据:很遗憾,你不可以往使用了? extends的数据结构中添加数据。

? super

接下来介绍? super(通配符下边界,传入的类型实参必须是指定类型的父类型(包含本身)),依然是通过实例来详细说明:

package com.envy.parameter;

import java.util.ArrayList;
import java.util.List;

public class PECS {
    static class Food{};
    static class Fruit extends Food{};
    static class Pear extends Fruit{};

    public static void main(String[] args){
        List<? super Fruit> fruits = new ArrayList<>();
        fruits.add(new Pear());  //正确
        fruits.add(new Fruit());  //正确
        fruits.add(new Food());  //错误

        fruits = new ArrayList<Pear>();  //错误
        fruits = new ArrayList<Fruit>();  //正确
        fruits = new ArrayList<Food>();  //正确
        fruits = new ArrayList<? extends Fruit>()  //通配符类型无法实例化

        Fruit fruit = fruits.get(0);  ///错误
    }
}

这里仅仅是将extends换成了super,但是结果却变化很大。首先你往里面添加Pear和Fruit对象是允许的,尽管这个fruits里面存放的是Fruit及其父类中的某一具体类型,但是由于类型是支持隐式转换(子类型默认向父类型转换),因此只要是Fruit类的子类都可以看成是Fruit或者其父类,所以就可以添加进去。但是你可以在实例化的时候指定它的类型,只要超过泛型设定的下边界(下限)Fruit,那都是允许的,所以你实例化Fruit和Food对象都是可以的。后面一个是因为不满足边界条件,另一个是非法实例化通配符类型而导致出错。

同时需要说明的是,由于不知道这个fruits是哪个具体的类型,但它肯定是Fruit的父类,因此你这样获取它肯定会出错的,需要强制类型转换才能实现,除非你改成返回Object类型。

读取数据:除非返回Object类型,否则取不出任何数据。写入数据:你可以往使用了? super的数据结构中添加其子类型对象。

PECS原则小结

通过前面的介绍,现在有必要对PECS原则进行一个小结,一共有三点:1、如果想从集合中读取类型T的数据,且不能写入时,推荐使用? extends 通配符形式(Producer Extends);2、如果想从集合中写入类型T的数据,且不需要读取时推荐使用? super 通配符形式(Consumer Super);3、如果你既想从集合中读取又要写入类型T的数据时,推荐不使用任何通配符的形式。

我该怎么选择使用

现在你可能要问了,前面介绍了泛型方法,这里又介绍了通配符,我应该怎样选择使用呢?

    //泛型方法
    public <T> void hey(List<T> list){
        //doSomething
    }
    //通配符
    public void hello(List<?> list){
        //doSomething
    }

个人建议参考这个标准来选择使用:1、如果参数类型之间存在依赖关系,又或者返回值与参数之间存在依赖关系时,优先使用泛型方法;2、如果不存在依赖关系,就优先考虑通配符,这样显得更灵活。(这就是为什么源码中通配符更常见的原因)

泛型的向下兼容

泛型这个概念是JDK5版本提出来的,前面的版本是没有的,因此需要考虑兼容JDK5以下的版本。这里以集合为例进行说明,当你把带有泛型信息的集合赋值给JDK5之前的版本的集合时,它会把泛型给擦除掉,取而代之的是类型参数的上边界。举个例子来说:

List<Number> list = new ArrayList<>();

//在JDK5以前版本会使用Number的上边界Object进行替换
List listOne = list;

也就是说listOne集合中存放的都是Object类型,类似于List<Object> listOne 所要表达的含义。那反过来呢?将没有类型参数的集合赋值给带有泛型信息的集合,那又会是怎样的呢?

List list = new ArrayList();

List<Number> listTwo = list;

可以发现程序没有抛异常,只是IDE会提示没有检查的转换而已。

泛型数组

这个是我后来补上的内容,因为新手基本上不会用到,所以可以做一个了解。

前面说过在Java中,数组是协变的,泛型不是协变的,那么能不能定义一个泛型数组呢?答案是可以的,先点击 这里,查看文档说明,其实下面这些内容都是参看该文档来的。文档指出,在java中无法创建一个具体泛型类型的数组。举个例子来说:

//无法创建一个具体泛型类型的数组
List<String>[] lsa = new List<String>[10];

上面的代码就是错的,因为你压根无法创建一个具体泛型类型的数组。继续往下看:

        // Not really allowed.
        List<String>[] lsa = new List<String>[10];
        Object o =lsa;
        Object[] oa = (Object[])o;
        List<Integer> li = new ArrayList<>();
        li.add(new Integer(3));
        // Unsound, but passes run time store check
        oa[1] = li;
        // Run-time error: ClassCastException.
        String s = lsa[1].get(0);

这里涉及到泛型擦除,前面说过泛型只存在于编译阶段,在运行时泛型已经被擦除,这里的oa[1]其实是指一个Object类型的对象,因此你当然可以将li赋值给它(相当于Object obj =li)。由于泛型擦除的存在,因此在运行的时候只知道lsa[1].get(0)得到的是一个Object类型的对象(因为String类的的上边界就是Object,或者说所有的类都是Object类的子类),这里你想直接获取String类型的对象其实是不可能的,需要进行一次强制类型转换,所以就出现了ClassCastException的异常。

还记得Java泛型设计的原则吗?只要程序在编译时期没有出现警告,那么运行时期就不会出现ClassCastException异常。

所以问题就来了,这里的程序在编译时没有出现问题,但是在运行时期却出现ClassCastException异常,说明此处声明泛型数组的方式有问题,这是错误的泛型数组声明打开方式。

正确的做法是使用通配符进行声明,通配符的方式要求你在读取数据时,需要进行一次强制类型转换,这样就符合泛型设计的原则:

List<?>[] lsa = new List<?>[10]; // OK, array of unbounded wildcard type.    
Object o = lsa;    
Object[] oa = (Object[]) o;    
List<Integer> li = new ArrayList<Integer>();    
li.add(new Integer(3));    
oa[1] = li; // Correct.    
Integer i = (Integer) lsa[1].get(0); // OK

同时请注意数组的元素类型不能是类型变量,这会引发编译时错误:

<T> T[] makeArray(T t) {
    return new T[100]; // Error.
}

当然了数组的元素类型也不能是参数,同样会引发编译时错误:

// Error.
List<String>[] lsa = new List<?>[10];

其实看到这里大家也就明白了,泛型数组的声明必须严格遵守泛型的设计原则,所以有时候你可能觉得写起来不怎么顺手,但是这也是出于程序的安全考虑。

写到这里泛型就告一段落了,但是对于泛型设计思想的体会还需要花一些时间去深思。

获取更多技术文章和信息,请关注我的个人微信公众号:余思博客,欢迎大家前来围观和玩耍。


原文链接:https://www.cnblogs.com/envythink/p/12880722.html
如有疑问请与原作者联系

标签:

版权申明:本站文章部分自网络,如有侵权,请联系:west999com@outlook.com
特别注意:本站所有转载文章言论不代表本站观点,本站所提供的摄影照片,插画,设计作品,如需使用,请与原作者联系,版权归原作者所有

上一篇:springboot swagger2注解使用

下一篇:volatile关键字解析~高级java必问