《面试王者系列》Java泛型

69 阅读7分钟

泛型面试题大全

说一下你对泛型的理解

泛型可以实现,在使用时才具体指定参数的类型,它可以使用在类、接口、方法中。

泛型和Object相比,有什么优势?

使用 Object 有两个痛点:1、得强制转换;2、没有编译器级别的安全校验;

使用泛型后,不需要强制转换,如果我们指定了类型之后,如果设置的类型不正确,会报错。这是泛型的优势。

介绍一下泛型擦除

Java的泛型其实是伪泛型,仅在语法上支持泛型,在编译的阶段,会进行泛型擦除。简单来说,就是编译时,如果识别到泛型的语法(<>尖括号及其内容),就将其替换成具体的类型。

替换的规则是这样的,如果有设置上下边界,就按上下边界设置具体类型,没有就设置成Object。

泛型为什么不支持基本类型

因为泛型在没有定义边界的时,进行类型擦除后,原始类型会变成Object,而基本类型不是对象,更不会继承于Object,没有多态性(说白了就是不能隐式转成使用时的目标类型),这就是不支持基本类型的根本原因。

说一下泛型的桥接方法(低概率会问到)

桥接方法只出现在子类重写了父类的泛型方法后。

因为父类的泛型方法,在泛型擦除后,会变成一个具体的类型(比如Object)。而子类覆盖父类这个泛型方法后,可能会指定一个具体类型(比如String)。这会导致父类原本 Object 的那个泛型方法没有被实现,这会让父类没了多态性。

具体的影响,就是当我们使用父类API进行统一调用的时候,可能会出现运行时错误。因为入参或者返回的数据,和我们写代码时候预期的不一样,就会出现类型转换的错误。

Java给出的解决方案是,在子类重写了父类的泛型方法后,编译器会自动生成对应的桥接方法。桥接方法的名称、入参、返回值和父类一致,方法内容就是去调用我们重写的方法。

知识点

在JDK5的时候,Java引入了泛型这个特性。

泛型让我们可以实现,在使用时才具体指定参数的类型。

泛型可以使用在类、接口、方法中。

泛型的意义

其实使用 Object ,也可以实现泛型的所有工作。

那为什么还要引入泛型呢?

因为Object 有两个痛点:1、得强制转换。2、没有编译器级别的安全校验。

例子如下:

没有引入泛型前,Java都是使用 Object 来实现类似泛型功能的

在实际开发中,我们经常会定义一个通用的返回结果类,如下:

传统使用 Object

public class CommonResult{
    public boolean success = true;
    public Object data;
}

使用泛型

public class CommonResult<T>{
    public boolean success = true;
    public T data;
}

消除转换

使用传统的 Object 的 CommonResult

如下例子可以看到,当我们需要取出 result.data 来使用的时候,是需要进行强制类型转换的。

public static void main(String[] args){
    CommonResult result = new CommonResult();
    
    // 1、给data赋值,
    result.data = "呵呵哒";
    
    // 2、把 result.data的值放到 data 变量里
    String data = (String) result.data;
}

使用泛型的 CommonResult

我们给 CommonResult 指定类型,后续使用的时候,无需强制转换。

相对传统 Object 来说,会比较优雅一些。

public static void main(String[] args){
    CommonResult<String> result = new CommonResult();
    
    // 1、给data赋值,
    result.data = "呵呵哒";
    
    // 2、把 result.data的值放到 data 变量里
    String data = result.data;
}

安全性

使用传统的 Object 的 CommonResult

由于使用时需要进行类型的强制转换,但Object里面可能装载着任意类型,并非强制转换的目标类型,就有可能发生运行时异常。

第一步:给 result.data 赋值为字符串的“1”

第二步:由于第一步赋值的是字符串的“1”,强制转换成 int 会报一个类型转换的异常。

public static void main(String[] args){
    CommonResult result = new CommonResult();
    
    // 1、给data赋值,
    result.data = "1";
    
    // 2、把 result.data的值放到 data 变量里
    int data = (int) result.data;
}

使用泛型的 CommonResult 就可以避免这个问题

首先,我们给 CommonResult 指定类型

第一步:我们给 result.data 赋值为字符串的“1”,此时编译器会报错,因为赋的值的不是指定的类型。

public static void main(String[] args){
    CommonResult<Integer> result = new CommonResult();
    
    // 1、给data赋值,
    result.data = "1";
    
    // 2、把 result.data的值放到 data 变量里
    Integer data = result.data;
}

泛型标记符

  • E - Element (在集合中使用,因为集合中存放的是元素)
  • T - Type(Java 类)
  • K - Key(键)
  • V - Value(值)
  • N - Number(数值类型)
  • - 表示不确定的 java 类型

上下边界设定

定义上边界,以下实例代码标识,泛型的类型只能是 A 或者 A的子孙类

public class Test<T extends A>{}

定义下边界,以下实例代码表示,泛型的类型只能是 A 或者 A的超类

public class Test<T super A>{}

不设定边界 ,在不是E,K 这类业务的情况下,使用 T、? 都行

public class Test<T>{} public class Test<?>{}

泛型定义

泛型类

public class CommonResult<T> {}

泛型方法

public class CommonResult {
    // 入参是泛型
    public <T> void setData(T data){}
    
    // 返回值是泛型
    public <T> T setData(Object data){}
    
    // 入参和返回值都是泛型
    public <T> T setData(T data){}
}

泛型的原理

Java的泛型,实际上是使用伪泛型的策略,仅在Java的语法上支持泛型,在编译的阶段会进行 泛型擦除 。简单来说,就是在编译时,如果识别到泛型的语法(<>尖括号及其内容),就将其替换成具体的类型。

泛型擦除的原则

  • 泛型擦除发生在编译期间;
  • 当识别到类型参数声明,就删除<>包围及其内容;
  • 如果设置了上下边界,则按设定的上下边界进行替换。如果没有设定上下边界,则替换为Object;
  • 自动产生“桥接方法”以保证擦除类型后的代码仍然具有泛型的“多态性”;

具体是怎么擦除的?

无边界的情况

还是以上面的 CommonResult 为例

public class CommonResult<T>{
    public boolean success = true;
    public T data;
}

擦除后

public class CommonResult{
    public boolean success = true;
    public Object data;
}

有边界的情况

public class CommonResult<T extends A>{
    public boolean success = true;
    public T data;
}

擦除后

public class CommonResult{
    public boolean success = true;
    public A data;
}

泛型的桥接方法

当子类重写了父类的泛型方法后,编译器会在该子类中自动生成桥接方法。

以 CommonResult 举一个例子

public class CommonResult<T>{
    private T data;
    
    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }
}

假设有个需求,要在CommonResult的基础上,写一个只能返回和设置 String 类型的子类 StringResult ,如下

public class StringResult extends CommonResult<String>{
    @Override 
    public String getData() {
        return data;
    }

    @Override 
    public void setData(String data) {
        this.data = data;
    }
}

继承时指定了类型为CommonResult ,并且重写了 getData 和 setData方法,让其只能返回和接受 String 类型。看起来没啥问题哈,重写是可以的,编译器也没报错。

可是,从前面说的【具体是怎么擦除的? 】案例来看,父类 CommonResult 在进行类型擦除后,会变成Object,如下:

public class CommonResult<T>{
    private Object data;
    
    public Object getData() {
        return data;
    }

    public void setData(Object data) {
        this.data = data;
    }
}

由于父类的 返回值 和 入参 在类型擦除后,是 Object 。

而 StringResult 重写了父类的 getData() setData() 方法,并且把 返回值 和 入参 改成了 String ,导致父类原本的 返回值 和 入参 等于Object 的方法没有被实现。

当我们在使用父类的方法进行统一调用的时候,会出错的。

举个例子:

public static void main(String[] args) {
    // 当变量类型是父类CommonResult,而实际指向的对象是子类StringResult时
    CommonResult result = new StringResult();
    // 由于 StringResult 重写 getData() 后,返回的是 String 对象,编译时没问题,运行时这行代码会出错
    Object data = result.getData();
}

为了解决这个问题,Java采用了桥接方法的方案,具体的实现,看下面例子:

首先使用 javap -c StringResult.class 命令查看编译后的类信息,如下:

Compiled from "StringResult.java"
public class StringResult extends CommonResultN<java.lang.String> {
  public StringResult();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method com/mianshiking/common/model/CommonResultN."<init>":()V
       4: return

  public java.lang.String getData();
    Code:
       0: aload_0
       1: invokespecial #2                  // Method com/mianshiking/common/model/CommonResultN.getData:()Ljava/lang/Object;
       4: checkcast     #3                  // class java/lang/String
       7: areturn

  public void setData(java.lang.String);
    Code:
       0: aload_0
       1: aload_1
       2: invokespecial #4                  // Method com/mianshiking/common/model/CommonResultN.setData:(Ljava/lang/Object;)V
       5: return

  public void setData(java.lang.Object);
    Code:
       0: aload_0
       1: aload_1
       2: checkcast     #3                  // class java/lang/String
       5: invokevirtual #5                  // Method setData:(Ljava/lang/String;)V
       8: return

  public java.lang.Object getData();
    Code:
       0: aload_0
       1: invokevirtual #6                  // Method getData:()Ljava/lang/String;
       4: areturn
}

可以看到,编译过后,编译器会自动生成对应的桥接方法,由桥接方法调用我们重写的方法。

有了桥接方法后,以上问题就解决了。

桥接方法对于开发人员来说是不感知的。

但是看编译后的这些代码,感觉有些奇奇怪怪,我把这些代码翻译成Java代码:

果然,编辑器报错了。

java: 已在类 com..StringResult中定义了方法 getData()
java: 名称冲突: com..StringResult 中的 setData(java.lang.String) 覆盖的方法的疑符与另一个方法的相同, 但两者均不覆盖对方
  第一个方法:  com..StringResult 中的 setData(java.lang.Object)
  第二个方法: com..CommonResultN 中的 setData(T)

因为这些桥接方法,既不符合重载的要求,又不符合重写的语法标准。

对于编译器来说,它无法识别方法名和入参一样,但返回数据不一样的多个方法,所以编译器会报错。

但是对于JVM来说,它是根据返回参数类型 + 方法名 + 入参个数和类型来判断方法唯一性的。

所以Java为了实现自动桥接,允许了这个在编译器看起来不合法的事情,然后交给JVM去进行区别。

泛型为什么不支持基本类型

因为泛型在没有定义边界的时,进行类型擦除后,原始类型会变成Object,而基本类型不是对象,更不会继承于Object,没有多态性(说白了就是不能隐式转成使用时的目标类型),这就是不支持基本类型的根本原因。

public static void main(String[] args) {
    // 这样会报错
    List<int> list1 = new ArrayList();
    
    List list2 = new ArrayList();
    // 这里 add int 却可以 ,是因为Java提供自动装箱的能力,会自动把 int 装箱成 Integer
    list1.add(1);
}