AI智能
改变未来

面试题系列:用了这么多年的 Java 泛型,我竟然只知道它的皮毛

面试题:说说你对泛型的理解?

面试考察点

考察目的:了解求职者对于Java基础知识的掌握程度。

考察范围:工作1-3年的Java程序员。

背景知识

Java中的泛型,是JDK5引入的一个新特性。

它主要提供的是编译时期类型的安全检测机制。这个机制允许程序在编译时检测到非法的类型,从而进行错误提示。

这样做的好处,一方面是告诉开发者当前方法接收或返回的参数类型,另一方面是避免程序运行时的类型转换错误。

泛型的设计推演

举一个比较简单的例子,首先我们来看一下

ArrayList

这个集合,部分代码定义如下。

public class ArrayList{transient Object[] elementData; // non-private to simplify nested class access}

在ArrayList中,存储元素所使用的结构是一个

Object[]

对象数组。意味着可以存储任何类型的数据。

当我们使用这个ArrayList来做下面这个操作时。

public class ArrayExample {public static void main(String[] args) {ArrayList al=new ArrayList();al.add("Hello World");al.add(1001);String str=(String)al.get(1);System.out.println(str);}}

运行程序后,会得到如下的执行结果

Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.Stringat org.example.cl06.ArrayExample.main(ArrayExample.java:11)

这种类型转换错误,相信大家在开发中有遇到过,总的来说,在没有泛型的情况下,会有两个比较严重的问题

  1. 需要对类型进行强制转换
  2. 使用不方便,容易出错

怎么解决上面这个问题呢?要解决这个问题,就得思考这个问题背后的需求是什么?

我简单总结两点:

  1. 要能支持不同类型的数据存储
  2. 还需要保证存储数据类型的统一性

基于这两个点不难发现,对于一个数据容器中要存储什么类型的数据,其实是由开发者自己决定的。因此,为了解决这个问题,在JDK5中就引入了泛型的机制。

其定义形式是:

ArrayList<E>

,它相当于给

ArrayList

提供了一个类型输入的模板

E

E

可以是任意类型的对象,它的定义方式如下。

public class ArrayList<E>{transient E[] elementData; // non-private to simplify nested class access}

在ArrayList这个类的定义中,使用

<>

语法,并传入一个用来表示任意类型的对象

E

,这个

E

可以随便定义,你可以定义成

A

B

C

都可以。

接着,把用来存储元素的数组

elementData

的类型,设置为

E

类型。

有了这个配置之后,

ArrayList

这个容器中,你想存储什么类型的数据,是由使用者自己决定,比如我希望

ArrayList

只存储

String

类型,那么它可以这么实现

public class ArrayExample {public static void main(String[] args) {ArrayList<String> al=new ArrayList();al.add("Hello World");al.add(1001);String str=(String)al.get(1);System.out.println(str);}}

在定义

ArrayList

时,传入一个

String

类型,这样写意味着后续往

ArrayList

这个实例对象

al

中添加元素,必须是

String

类型,否则会提示如下的语法错误。

同理,如果需要保存其他类型的数据,可以这么写:

  1. ArrayList
  2. ArrayList

总结:所谓泛型定义,其实本质上就是一种类型模板,在实际开发中,我们把一个容器或者一个对象中需要保存的属性的类型,通过模板定义的方式,给到调用者来决定,从而保证了类型的安全性。

泛型的定义

泛型定义可以从两个维度来说明:

  1. 泛型类
  2. 泛型方法

泛型类

泛型类指的是在类名后面添加一个或多个类型参数,一个泛型参数,也被称为一个类型变量,是用于指定一个泛型类型名称的标识符。因为他们接受一个或多个参数,这些类被称为参数化的类或参数化的类型。

类型变量的表示标记,常用的是:

E(element)

T(type)

K(key)

V(value)

N(number)

等,这只是一个表示符号,可以是任何字符,没有强制要求。

下面的代码是关于

泛型类

的定义。

该类接收一个

T

标记符的类型参数,该类中有一个成员变量,使用

T

类型。

public class Response <T>{private T data;public T getData() {return data;}public void setData(T data) {this.data = data;}}

使用方式如下:

public static void main(String[] args) {Response<String> res=new Response<>();res.setData("Hello World");}

泛型方法

泛型方法是指指定方法级别的类型参数,这个方法在调用时可以接收不同的参数类型,根据传递给泛型方法的参数类型,编译器适当地处理每一个方法调用。

下面的代码表示泛型方法的定义,用到了JDK提供的反射机制,来生成动态代理类。

public interface IHelloWorld {String say();}

定义

getProxy

方法,它用来生成动态代理对象,但是传递的参数类型是

T

,也就是说,这个方法可以完成任意接口的动态代理实例的构建。

在这里,我们针对

IHelloWorld

这个接口,构建了动态代理实例,代码如下。

public class ArrayExample implements InvocationHandler {public <T> T getProxy(Class<T> clazz){// clazz 不是接口不能使用JDK动态代理return (T) Proxy.newProxyInstance(clazz.getClassLoader(), new Class<?>[]{ clazz }, ArrayExample.this);}@Overridepublic Object invoke(Object proxy, Method method, Object[] args) throws Throwable {return "Hello World";}public static void main(String[] args) {IHelloWorld hw=new ArrayExample().getProxy(IHelloWorld.class);System.out.println(hw.say());}}

运行结果:

Hello World

关于泛型方法的定义规则,简单总结如下:

  1. 所有泛型方法的定义,都有一个用
    <>

    表示的类型参数声明,这个类型参数声明部分在方法返回类型之前。

  2. 每一个类型参数声明部分包含一个或多个类型参数,参数间用逗号隔开。一个泛型参数,也被称为一个类型变量,是用于指定一个泛型类型名称的标识符。
  3. 类型参数能被用来声明返回值类型,并且能作为泛型方法得到的实际参数类型的占位符
  4. 泛型方法体的声明和其他方法一样。注意类型参数只能代表引用型类型,不能是原始类型(像 int、double、char 等)。##

多类型变量定义

上在我们只定义了一个泛型变量T,那如果我们需要传进去多个泛型要怎么办呢?

我们可以这么写:

public class Response <T,K,V>{}

每一个参数声明符号代表一种类型。

注意,在多变量类型定义中,泛型变量最好是定义成能够简单理解具有含义的字符,否则类型太多,调用者比较容易搞混。

有界类型参数

在有些场景中,我们希望传递的参数类型属于某种类型范围,比如,一个操作数字的方法可能只希望接受Number或者Number子类的实例,怎么实现呢?

泛型通配符上边界

上边界,代表类型变量的范围有限,只能传入某种类型,或者它的子类。

我们可以在泛型参数上,增加一个

extends

关键字,表示该泛型参数类型,必须是派生自某个实现类,示例代码如下。

public class TypeExample<T extends Number> {private T t;public T getT() {return t;}public void setT(T t) {this.t = t;}public static void main(String[] args) {TypeExample<String> t=new TypeExample<>();}}

上述代码,声明了一个泛型参数

T

,该泛型参数必须是继承

Number

这个类,表示后续实例化

TypeExample

时,传入的泛型类型应该是

Number

的子类。

所以,有了这个规则后,上面这个测试代码,会提示

java: 类型参数java.lang.String不在类型变量T的范围内

错误。

泛型通配符下边界

下边界,代表类型变量的范围有限,只能传入某种类型,或者它的父类。

我们可以在泛型参数上,增加一个

super

关键字,可以设定泛型通配符的上边界。实例代码如下。

public class TypeExample<T> {private T t;public T getT() {return t;}public void setT(T t) {this.t = t;}public static void say(TypeExample<? super Number> te){System.out.println("say: "+te.getT());}public static void main(String[] args) {TypeExample<Number> te=new TypeExample<>();TypeExample<Integer> te2=new TypeExample<>();say(te);say(te2);}}

say

方法上声明

TypeExample<? super Number> te

,表示传入的

TypeExample

的泛型类型,必须是

Number

以及

Number

的父类类型。

在上述代码中,运行时会得到如下错误:

java: 不兼容的类型: org.example.cl06.TypeExample<java.lang.Integer>无法转换为org.example.cl06.TypeExample<? super java.lang.Number>

如下图所示,表示

Number

这个类的类关系图,通过

super

关键字限定后,只能传递

Number

以及父类

Serializable

类型通配符?

类型通配符一般是使用 ? 代替具体的类型参数。例如 List<?> 在逻辑上是 List,List 等所有 List<具体类型实参> 的父类。

来看下面这段代码的定义,在

say

方法中,接受一个

TypeExample

类型的参数,并且泛型类型是

<?>

,代表接收任何类型的泛型类型参数。

public class TypeExample<T> {private T t;public T getT() {return t;}public void setT(T t) {this.t = t;}public static void say(TypeExample<?> te){System.out.println("say: "+te.getT());}public static void main(String[] args) {TypeExample<Integer> te1=new TypeExample<>();te1.setT(1111);TypeExample<String> te2=new TypeExample<>();te2.setT("Hello World");say(te1);say(te2);}}

运行结果如下

say: 1111say: Hello World

同样,类型通配符的参数,也可以通过

extends

来做限定,比如:

public class TypeExample<T> {private T t;public T getT() {return t;}public void setT(T t) {this.t = t;}public static void say(TypeExample<? extends Number> te){ //修改,增加extendsSystem.out.println("say: "+te.getT());}public static void main(String[] args) {TypeExample<Integer> te1=new TypeExample<>();te1.setT(1111);TypeExample<String> te2=new TypeExample<>();te2.setT("Hello World");say(te1);say(te2);}}

由于

say

方法中的参数

TypeExample

,在泛型类型定义中使用了

<? extends Number>

,所以后续在传递参数时,泛型类型必须是

Number

的子类型。

因此上述代码运行时,会提示如下错误:

java: 不兼容的类型: org.example.cl06.TypeExample<java.lang.String>无法转换为org.example.cl06.TypeExample<? extends java.lang.Number>

注意: 构建泛型实例时,如果省略了泛型类型,则默认是通配符类型,意味着可以接受任意类型的参数。

泛型的继承

泛型类型参数的定义,是允许被继承的,比如下面这种写法。

表示子类

SayResponse

和父类

Response

使用同一种泛型类型。

public class SayResponse<T> extends Response<T>{private T ox;}

JVM是如何实现泛型的?

在JVM中,采用了

类型擦除Type erasure generics)

的方式来实现泛型,简单来说,就是泛型只存在.java源码文件中,一旦编译后就会把泛型擦除.

我们来看ArrayExample这个类,编译之后的字节指令。

public class ArrayExample implements InvocationHandler {public <T> T getProxy(Class<T> clazz){// clazz 不是接口不能使用JDK动态代理return (T) Proxy.newProxyInstance(clazz.getClassLoader(), new Class<?>[]{ clazz }, ArrayExample.this);}@Overridepublic Object invoke(Object proxy, Method method, Object[] args) throws Throwable {return "Hello World";}public static void main(String[] args) {IHelloWorld hw=new ArrayExample().getProxy(IHelloWorld.class);System.out.println(hw.say());}}

通过

javap -v ArrayExample.class

查看字节指令如下。

public <T extends java.lang.Object> T getProxy(java.lang.Class<T>);descriptor: (Ljava/lang/Class;)Ljava/lang/Object;flags: ACC_PUBLICCode:stack=5, locals=2, args_size=20: aload_11: invokevirtual #2                  // Method java/lang/Class.getClassLoader:()Ljava/lang/ClassLoader;

可以看到,

getProxy

在编译之后,泛型

T

已经被擦除了,参数类型替换成了java.lang.Object.

并不是所有类型都会转换为java.lang.Object,比如如果是,则参数类型是java.lang.String。

同时,为了保证

IHelloWorld hw=new ArrayExample().getProxy(IHelloWorld.class);

这段代码的准确性,编译器还会在这里插入一个类型转换的机制。

下面这个代码是

ArrayExample.class

反编译之后的呈现。

IHelloWorld hw = (IHelloWorld)(new ArrayExample()).getProxy(IHelloWorld.class);System.out.println(hw.say());

泛型类型擦除实现带来的缺陷

擦除方式实现泛型,还是会存在一些缺陷的,简单举几个案例说明。

不支持基本类型

由于泛型类型擦除后,变成了java.lang.Object类型,这种方式对于基本类型如int/long/float等八种基本类型来说,就比较麻烦,因为Java无法实现基本类型到Object类型的强制转换。

ArrayList<int> list=new ArrayList<int>();

如果这么写,会得到如下错误

java: 意外的类型需要: 引用找到:    int

所以,在泛型定义中,只能使用引用类型。

但是作为引用类型,如果保存基本类型的数据时,又会涉及到装箱和拆箱的过程。比如

List<Integer> list = new ArrayList<Integer>();list.add(10); // 1int num = list.get(0); // 2

在上述代码中,声明了一个

List<Integer>

泛型类型的集合,

在标记

1

的位置,添加了一个

int

类型的数字10,这个过程中,会涉及到装箱操作,也就是把基本类型

int

转换为

Integer

.

在标记

2

的位置,编译器首先要把Object转换为Integer类型,接着再进行拆箱,把

Integer

转换为

int

。因此上述代码等同于

List list = new ArrayList();list.add(Integer.valueOf(10));int num = ((Integer) list.get(0)).intValue();

增加了一些执行步骤,对于执行效率来说还是会有一些影响。

运行期间无法获取泛型实际类型

由于编译之后,泛型就被擦除,所以在代码运行期间,Java 虚拟机无法获取泛型的实际类型。

下面这段代码,从源码上两个 List 看起来是不同类型的集合,但是经过泛型擦除之后,集合都变为

ArrayList

。所以

if

语句中代码将会被执行。

public static void main(String[] args) {ArrayList<Integer> li = new ArrayList<>();ArrayList<Float> lf = new ArrayList<>();if (li.getClass() == lf.getClass()) { // 泛型擦除,两个 List 类型是一样的System.out.println("类型相同");}}

运行结果:

类型相同

这就使得,我们在做方法重载时,无法根据泛型类型来定义重写方法。

也就是说下面这种方式无法实现重写。

public void say(List<Integer> a){}public void say(List<String> b){}

另外还会给我们在实际使用中带来一些限制,比如说我们没办法直接实现以下代码

public <T> void say(T a){if(a instanceof T){}T t=new T();}

上述代码会存在编译错误。

既然通过擦除的方式实现泛型有这么多缺陷,那为什么要这么设计呢?

要回答这个问题,需要知道泛型的历史,Java的泛型是在Jdk 1.5 引入的,在此之前Jdk中的容器类等都是用Object来保证框架的灵活性,然后在读取时强转。但是这样做有个很大的问题,那就是类型不安全,编译器不能帮我们提前发现类型转换错误,会将这个风险带到运行时。 引入泛型,也就是为解决类型不安全的问题,但是由于当时java已经被广泛使用,保证版本的向前兼容是必须的,所以为了兼容老版本jdk,泛型的设计者选择了基于擦除的实现。

问题解答

面试题:说说你对泛型的理解?

回答: 泛型是JDK5提供的一个新特性。它主要提供的是编译时期类型的安全检测机制。这个机制允许程序在编译时检测到非法的类型,从而进行错误提示。

问题总结

深入理解Java泛型是程序员最基础的必备技能,虽然面试很卷,但是实力仍然很重要。

关注[跟着Mic学架构]公众号,获取更多精品原创

赞(0) 打赏
未经允许不得转载:爱站程序员基地 » 面试题系列:用了这么多年的 Java 泛型,我竟然只知道它的皮毛