面试重灾区-泛型攻克

2,649 阅读15分钟

目录介绍

  • 1.使用泛型的意义
  • 1.1 例一
  • 1.2 例二
  • 2.泛型擦除
  • 3.使用泛型带来的问题
  • 4.泛型的通配符super和extends

泛型可以说是面试中的重灾区了,一直以来大家对于泛型的认识可能并不是非常的清晰,在泛型的使用上可能就更疑惑了,这篇文章将带大家攻克这一知识点,泛型这块其实还是要大家多敲敲,看看什么情况下泛型是会报错的什么情况下不会,这样才能真正的了解泛型。

1. 使用泛型的意义

1.泛型的创造者让泛型的使用者可以在使用泛型的时候根据传入泛型类型的不同而使用对应类型的API。

2.使用泛型可以解决不必要的类型转换错误。

针对第一点:举系统用到泛型的两个例子:

例子一:findViewById()

@Nullable
public <T extends View> T findViewById(@IdRes int id) {
    return getWindow().findViewById(id);
}

这个方法应该是我们日常开发中最常用的方法了,可以看到根据我们传入的id最终解析找到返回与之对应的一个View。 层层深入最终找到源头:

protected <T extends View> T findViewTraversal(@IdRes int id) {
    if (id == mID) {
        return (T) this;
    }
    return null;
}

调用时:

TextView textView= findViewById(R.id.title);
textView.setText("123");

ImageView imageView= findViewById(R.id.image);
imageView.setImageResource(R.drawable.ic_smoll_android);

可以看到我们并没有显示的获取或者创建对应的TextView或者ImageView实例就直接获取到了对应的类型从而调用到了对应类型的API,比如TextView的setText方法和ImageView的setImageResource方法。

这里的泛型创造者也就是谷歌写View的这个程序员,泛型的使用者自然就是我们自身调用findViewById这个方法的人了,由于这是一个泛型方法,我们最终不用new TextView就可以获取对应的TextView实例,因为泛型的创造者已经帮我们将新建的逻辑写好了,我们只需要根据需要用不用的View类型去接收就可以获取到对应类型的实例,这无疑是相当方便的。 这就是根据我们传入类型不同而获取调用到对应类型API的一个最好的例子。

例子二:系统的Comparable接口

public interface Comparable<T> {
    public int compareTo(T o);
}
public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
...
    public native int compareTo(String anotherString);

}
public final class Integer extends Number implements Comparable<Integer> {
...
    public int compareTo(Integer anotherInteger) {
        return compare(this.value, anotherInteger.value);
    }

}

可以看到String和Integer类实现Comparable接口后通过传入不同的泛型类型String,Integer从而在复写compareTo方法时便可以调用到传入的类型String,Integer的API。

第一点的总结:

  1. 我们使用泛型时判断是否需要使用泛型最简单的方法其实就是判断当前这个类中的方法是否需要在返回值中使用泛型,如果需要则可以使用泛型,反则不需要。
  2. 可以看到例子二其实是违背了我上边所说的第一点的,但是如果你仔细看就会发现这个用法其实最终也是基于总体原则: 使用泛型的时候根据传入的不同类型而使用对应类型的API 而去使用的,所以也是满足这个原则的。
  3. 如果你的类型确定的话是完全不需要使用泛型的。

针对第二点:

List list = new ArrayList();
list.add("123");
int a = (int) list.get(0);//需要做类型强转,在运行时,ClassCastException

可以看到如果没有泛型在运行时有可能会出现这样不必要的类型转换错误,使用泛型以后在编译时就将类型约束好了从而解决了这种问题的产生。

2.泛型擦除

Java中的泛型类型在代码编译时会被擦除掉(一般情况下会被擦成Object类型,如果使用了上限通配符的话会被擦成extends右边的类型,如T extends View则最终会被擦成View类型)。

Class c1 = new ArrayList<Integer>().getClass();
Class c2 = new ArrayList<String>().getClass();
System.out.println(c1 == c2); true

可以看到最后结果是true的,因为泛型类型在运行时都会被擦除掉,也就是说其实c1和c2是由相同的class字节码文件加载出来的,他们是相同的Class,new ArrayList()和new ArrayList()最后都会被擦成new ArrayList()。

为什么要将泛型类型在编译时擦除?

最主要还是出于兼容性的考虑,泛型是JDK1.5以后才引入的,为了兼容之前的JDK版本所以在运行时将泛型类型都擦掉以保证和之前JDK版本的java字节码相同。

public class Test<T> {
    private T b;
    public void setB(T b) {
        this.b = b;
    }
    public T getB() {
        return b;
    }
}


Test<Integer> a=new Test<Integer>();
a.setB(1);
int b=a.getB();//不需要做类型强转,自动完成
//定义处已经被擦除成Object,无法进行强转,不知道强转成什么public T getB();
   Code:
      0: aload_0
      1: getfield      #23                 // Field b:Ljava/lang/Object;
      4: areturn
//调用处利用checkcast进行强转
L5 {
            aload1
            invokevirtual com/ljj/A getB()Ljava.lang.Object);
            checkcast java/lang/Integer
            invokevirtual java/lang/Integer intValue(()I);
            istore2
    }

可以看到如果使用了泛型,在运行期间是会自动进行类型强转的而不用我们主动去调用类型强转。

Java 泛型类、泛型接口、泛型方法有什么区别?

  1. 泛型类是在实例化类的对象时才能确定的类型,其定义譬如 class Test {},在实例化该类时必须指明泛型 T 的具体类型。

  2. 泛型接口与泛型类一样,其定义譬如 interface Generator { E dunc(E e); }。

  3. 泛型方法是独立的,它可以不依附与你当前类中的泛型,当前类没有泛型也可以使用泛型方法使用的时候将泛型类型定义到方法返回值的左边,之后就可以使用了。

<T> void func(T val) {}
<T> T func(Fruit val) {}
public static <T> void func(T val) {}

下边举一个例子

public class Test{
    public static <T> T add(T x, T y){
        return y;
    }

    public static void main(String[] args) {

        int t0 = Test.add(10, 20.8);
        int t1 = Test.add(10, 20);    
        Number t2 = Test.add(100, 22.2);

        Object t3 = Test.add(121, "abc");
        int t4 = Test.<Integer>add(10, 20);
        int t5 = Test.<Integer>add(100, 22.2);
        Number t6 = Test.<Number>add(121, 22.2);

    }
}
  1. t0 编译直接报错,add 的两个参数一个是 Integer,一个是 Float,所以取同一父类的最小级为 Number,故 T 为 Number 类型,而 t0 类型为 int,所以类型错误。
  2. t1 执行赋值成功,add 的两个参数都是 Integer,所以 T 为 Integer 类型。
  3. t2 执行赋值成功,add 的两个参数一个是 Integer,一个是 Float,所以取同一父类的最小级为 Number,故 T 为 Number 类型。
  4. t3 执行赋值成功,add 的两个参数一个是 Integer,一个是 Float,所以取同一父类的最小级为 Object,故 T 为 Object 类型。
  5. t4 执行赋值成功,add 指定了泛型类型为 Integer,所以只能 add 为 Integer 类型或者其子类的参数。
  6. t5 编译直接报错,add 指定了泛型类型为 Integer,所以只能 add 为 Integer 类型或者其子类的参数,不能为 Float。
  7. t6 执行赋值成功,add 指定了泛型类型为 Number,所以只能 add 为 Number 类型或者其子类的参数,Integer 和 Float 均为其子类,所以可以 add 成功。

数组不支持泛型

Fruit<String>[] i=new Fruit<String>[10]; //Errot
Fruit<?>[] i=new Fruit<?>[10]; /可以通过,但是没有意义

再看一个例子

//Part1
List<Object> list=new ArrayList<String>();//Error
list.add("123");

//Part2
Object[] objects=new Long[10];
objects[0]="123"; //Runtime 异常

上面 Part 1 编译出错,Part 2 编译 OK,运行出错。 因为 List 和 ArrayList 没有继承关系,而 Java 的数组是在运行时类型检查的。

3.使用泛型带来的问题

1.自动拆箱装箱带来的性能损耗

泛型不支持传入基本数据类型,只支持引用数据类型,例如我们直接使用new ArrayList()是不合法的,因为类型擦除后会替换成Object(如果通过extends设置了上限,则替换成上限类型),int显然无法替换成Object,所以泛型参数必须是引用类型。

2.泛型类型无法当做真实类型去使用

所以下列方法都是错误的

static <T> void test(T t){ //全部ERROR
    T newInstance=new T();  
    T[] array=new T[0]; 
    Class c=T.class;
    List<T> list=new ArrayList<T>();
    if(list instanceof List<String>){}  
}

3.泛型会自动将类型进行强转,类型转换时也会有性能的开销。

如何通过反射获取泛型类型?

既然泛型类型在运行时会被擦除那么我们怎么获取到泛型类型呢?

其实在泛型擦除时并不会将所有的泛型类型都擦除掉,它只会擦除运行时的泛型类型,编译时类中定义的泛型类型是不会被擦除的,对应的泛型类型会被保存在Signature中。 我们如果想获取对应对象中的泛型类型只需将动态创建的对象改为匿名内部类即可获取,因为内部类实在编译时创建的,泛型类型是会保存下来的。 对应API getGeneric...都是获取泛型类型的。 下边举两个例子:

List<Integer> list = new ArrayList<>();
list.getClass().getGenericSuperclass(); //获取不到泛型信息
List<Integer> list1 = new ArrayList() {};
list1.getClass().getGenericSuperclass(); //可以获取到泛型信息
  1. 可以看到第一个list由于是在运行时创建的对象所以由于泛型擦除是无法获取泛型信息的,因为运行时对象本质是方法的调用(真正调用了以后才会创建),在运行时创建的对象是没有办法通过反射获取其中的类型的。
  2. 第二个是可以获取的,因为后边加了{},这就使得这个list成为了一个匿名内部类且父类是List,子类是可以调用父类的构造方法的,加了之后这个list1就不是运行时创建的对象了而是编译时创建的,所以是可以获取泛型类型的。

下边以一道题为例:

public class Demo {

        public static void main(String[] args) throws Exception {

                ParameterizedType type = (ParameterizedType) Bar.class.getGenericSuperclass();
                System.out.println(type.getActualTypeArguments()[0]);

                ParameterizedType fieldType = (ParameterizedType) Foo.class.getField("children").getGenericType();
                System.out.println(fieldType.getActualTypeArguments()[0]);

                ParameterizedType paramType = (ParameterizedType) Foo.class.getMethod("foo", List.class).getGenericParameterTypes()[0];
                System.out.println(paramType.getActualTypeArguments()[0])
    
                System.out.println(Foo.class.getTypeParameters()[0].getBounds()[0]);
        }

        class Foo<T extends CharSequence> {

                public List<Bar> children = new ArrayList<Bar>();

                public List<StringBuilder> foo(List<String> foo) {return null}

                public void bar(List<? extends String> param) {}
         }

        class Bar extends Foo<String> {}
}

运行结果如下。

class java.lang.String

class Demo$Bar

class java.lang.String

interface java.lang.CharSequence

通过上面例子会发现泛型类型的每一个类型参数都被保留了,而且在运行期可以通过反射机制获取到,因为泛型的擦除机制实际上擦除的是除结构化信息外的所有东西(结构化信息指与类结构相关的信息,而不是与程序执行流程有关的,即与类及其字段和方法的类型参数相关的元数据都会被保留下来通过反射获取到)。

4.泛型的通配符super和extends

由于泛型不是协变的,它不支持继承,比如在使用 List< Number> 的地方不能传递 List< Integer>,所以引入了通配符去解决对应的问题,可以理解通配符是对泛型功能的扩展和增强。

  • extends 上限通配符 可以接收extend后的类型及子类

  • super 下限通配符 可以接收super后的类型及父类

向下边这种写法都是被禁止的

List<Number> list=new ArrayList<Integer>() //Error

List<Object> list;
List<String> strlist=new ArrayList<>();
list=strlist;							   //Error

首先明确两个概念:

形参和实参

  • 形参 Type parameter

public class Shop< T>的那个T

表示我要创建一个 Shop 类,它的内部会用到一个统一的类型,这个类型姑且称他为 T 。

  • 实参 Type argument

其它地方尖括号里的全是 Type argument,比如 Shop < Apple> appleShop;的 Apple ;

表示「那个统一代号,在这里的类型我决定是这个」

其实用大白话来说形参就是定义泛型的地方,实参是传入具体泛型类型的地方。

下边以几个例子来看看用法

Vector<? extends Number> x1 = new Vector<Integer>();    //正确
Vector<? extends Number> x2 = new Vector<String>();    //编译错误
Vector<? super Integer> y1 = new Vector<Number>();    //正确
Vector<? super Integer> y2 = new Vector<Byte>();    //编译错误
  1. x1使用了上限统配符,所以可以接收Interger类型。
  2. x2中的String类型并不属于Number及其子类,所以接收失败报错。
  3. y1使用了下限统配符,所以可以接收Number类型。
  4. y2中的Byte类型并不属于Integer及其父类,所以接收失败报错。
List<? extends Fruit> list = new ArrayList<>();
list.add(new Apple());//Error
list.get(0);//不报错

List<? super Fruit> list2 = new ArrayList<>();
list2.add(new Apple());//不报错
list2.get(0);//Error

当我们使用上限通配符时对应方法参数中使用到泛型的方法将都无法调用,因为我们不能确定具体传入的是哪种类型。

当我们使用下限通配符时对应方法返回值中使用到泛型的方法将都无法调用,因为我们不能确定具体返回的是哪种类型。

这时我们就有疑问了,既然使用通配符后对应的对象都处于报废状态,那么这东西有啥用? 其实 ? 以及通配符通常都是用在方法参数中的,比如:

extends的例子

public int getTotalWeight(List<Fruit> list) {
    float totalWeight = 0;
    for (int i=0;i<list.size();i++) {
        Fruit fruit=list.get(i);
        totalWeight += fruit.getWeight();
    }
    return totalWeight;
}

List<Apple> listApple = new ArrayList<>();
listApple.add(new Apple());

List<Banana> listBanana = new ArrayList<>();
listBanana.add(new Banana());
int totalPrice=getTotalWeight(listApple)+getTotalWeight(listBanana);//编译报错

这种情况是错误的,因为泛型不支持继承,我们是无法直接传入的。但我们只要修改一下即可

public int getTotalWeight(List<? extends Fruit> list) {
    float totalWeight = 0;
    for (int i=0;i<list.size();i++) {
        Fruit fruit=list.get(i);
        totalWeight += fruit.getWeight();
    }
    return totalWeight;
}

这种情况就OK了,因为这代表着我们传入的类型是可知的,上限通配符extends可以接受extends右边的类型及其子类。

super的例子

定义一个方法去添加自身

public class Apple extends Fruit{

    void addMeToList(List<Apple> list){
        list.add(this);
    }
}
List<Fruit> fruits = new ArrayList<>();
Apple apple=new Apple();
apple.addMeToList(fruits);//报错

可以看到当调用方法时会出现报错,因为泛型不支持继承。我们修改一下

public class Apple implements Fruit{

    void addMeToList(List<? super Apple> list){
        list.add(this);
    }
}

这样调用时就不会出现任何问题了。因为下限通配符可以接收super类型后的父类,自然Apple的父类Fruit是肯定可以接收的。

进一步加深理解和认知

< T extends E> 和 <? extends E> 有什么区别?

它们用的地方不一样,< T extends E>只能用于形参(也就是泛型定义的时候),<? extends E>只能用于实参(也就是传入具体泛型类型的时候)。 比如:

public void addAll(Bean<? extends E> bean;
public <T extends E> void addAll(Bean<T> bean;

下面程序合法吗?

class Bean<T super Student> { //TODO }

编译时报错,因为super只能用作实参不能用于形参,extends实参形参都可以

下面两个方法有什么区别?为什么?

public static <T> T get1(T t1, T t2) {
    if(t1.compareTo(t2) >= 0);
    return t1;
}

public static <T extends Comparable> T get2(T t1, T t2){
    if(t1.compareTo(t2) >= 0);
    return t1;
}
  1. get1 方法直接编译错误,因为编译器在编译前首先进行了泛型检查和泛型擦除才编译,所以等到真正编译时 T 由于没有类型限定自动擦除为 Object 类型,所以只能调用 Object 的方法,而 Object 没有 compareTo 方法。
  2. get2 方法添加了泛型类型限定可以正常使用,因为限定类型为 Comparable 接口,其存在 compareTo 方法,所以 t1、t2 擦除后被强转成功。所以类型限定在泛型类、泛型接口和泛型方法中都可以使用,不过不管该限定是类还是接口都使用 extends 和 & 符号,如果限定类型既有接口也有类则类必须只有一个且放在首位,如果泛型类型变量有多个限定则原始类型就用第一个边界的类型变量来替换。

总结通配符

  1. extends 方法参数中用到了泛型的方法都失效,返回值返回泛型的可以调用。
  2. super 方法参数中用到了泛型的方法可以调用,返回值返回泛型的都失效。 最直观的例子就是List集合中使用了 <? extend 类型> 后对应的add方法都无法调用,get方法可以调用。super与之相反,List集合中使用了 <? super 类型> 后对应的get方法可以调用,add方法都无法调用。