写点什么

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

用户头像
Sakura
关注
发布于: 8 小时前

面试题:说说你对泛型的理解?面试考察点 #考察目的:了解求职者对于 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)这种类型转换错误,相信大家在开发中有遇到过,总的来说,在没有泛型的情况下,会有两个比较严重的问题


需要对类型进行强制转换使用不方便,容易出错怎么解决上面这个问题呢?要解决这个问题,就得思考这个问题背后的需求是什么?


我简单总结两点:


要能支持不同类型的数据存储还需要保证存储数据类型的统一性基于这两个点不难发现,对于一个数据容器中要存储什么类型的数据,其实是由开发者自己决定的。因此,为了解决这个问题,在 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 类型,否则会提示如下的语法错误。


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


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


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


类型变量的表示标记,常用的是: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 关于泛型方法的定义规则,简单总结如下:


所有泛型方法的定义,都有一个用<>表示的类型参数声明,这个类型参数声明部分在方法返回类型之前。每一个类型参数声明部分包含一个或多个类型参数,参数间用逗号隔开。一个泛型参数,也被称为一个类型变量,是用于指定一个泛型类型名称的标识符。类型参数能被用来声明返回值类型,并且能作为泛型方法得到的实际参数类型的占位符泛型方法体的声明和其他方法一样。注意类型参数只能代表引用型类型,不能是原始类型(像 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){ //修改,增加extends 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 方法中的参数 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 泛型是程序员最基础的必备技能,虽然面试很卷,但是实力仍然很重要。

用户头像

Sakura

关注

还未添加个人签名 2021.11.02 加入

还未添加个人简介

评论

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