深入理解java泛型-来龙去脉版

2,160 阅读12分钟

泛型是java很重要的语法糖。本文将由浅到深,对其进行介绍。首先介绍java中泛型的基本概念,实现方式和用法,最后会将其与其他语言的泛型实现进行对比,讨论其优缺点。

简介

泛型的本质是参数化类型,是可以将操作的数据类型只定位方法签名的一种特殊参数,可用于类,接口和方法中。

泛型是 JDK1.5 的一个新特性,其实就是一个『语法糖』(即编译器为了提供更好的可读性而提供的一种小「手段」),虚拟机层面是不存在所谓『泛型』的概念的。

为什么一定要有泛型呢?请看下面代码:

//无泛型
ArrayList list = new ArrayList();
list.add("hello world");
list.add(123);
list.add(new Zi());
System.out.println(list);

假如没有泛型,放入集合中的每个元素的类型显然应该是Object,只有这样,才能使得所有的类型都被包含在内。这样会导致两个问题:
1.操作集合会变得很不方便,需要对Object进行类型强转。
2.进行强转时,遇到不同数据类型会出现异常。加入类型判断,会增加代码复杂度。但是别人又不知道这个集合里是什么,因为这个集合就是不安全的。
其实以上是通用型和独特性的矛盾,泛型就是统一这两个矛盾体的方法。

那么泛型的好处有哪些呢?其主要表现为两点: 一,通过泛型的语法定义,编译器可以在编译期提供一定的类型安全检查,过滤掉大部分因为类型不符而导致的运行时异常,例如:

ArrayList<Integer> list = new ArrayList<>();
list.add("ddddd"); //编译失败

由于我们的 ArrayList 是符合泛型语法定义的容器,所以你可以在实例化的时候指定一个类型,限定该容器只能容纳 Integer 类型的元素。而如果你强行添加其他类型的元素进入,那么编译器是不会通过的。

二,泛型可以让程序代码的可读性更高,并且由于本身只是一个语法糖,所以对于 JVM 运行时的性能是没有任何影响的。

实现原理

java中的泛型只在源码程序中存在,在编译后的字节码中,全部泛型都被替换为原来的裸类型。也就是说,泛型信息不会进入到运行时阶段。

裸类型:例如ArrayList<>的裸类型为ArrayList。
首先下面代码和其输出

List<String> list = new ArrayList<>();  //java.util.ArrayList
List<Integer> list1 = new ArrayList<>(); //java.util.ArrayList
System.out.println(list.getClass().equals(list1.getClass()));
输出:true

经过编译后,他们的类型都变成裸类型ArrayList,因此最后输出是true,进一步验证了我们的观点。
我们为了进一步验证,对上诉代码进行编译,并javap反编译class文件后,得到的代码如下:

ArrayList var1 = new ArrayList();
ArrayList var2 = new ArrayList();
System.out.println(var1.getClass().equals(var2.getClass()));

可以看到,验证无误,确实进行了类型擦除。细心的读者可能会发现华点?咦,你把类型擦除了,怎么判定加入的类是什么类型呢,调用的时候不会报错吗?这不是回到了没有泛型以前的时候了吗?确实,这里java编译器进行了处理,例如下面的代码:

List<String> iList= List.of("hello","world");
System.out.println(iList.get(0));

反编译后代码:

List var1 = List.of("hello", "world");
System.out.println((String)var1.get(0));

可以看到,其实java在访问元素的时候,进行了一个类型的强制转换。其实从这里也可以看出为什么泛型要求都是包装类,因为假如这里是int的话,无法进行与Obiect之间的强转。这会导致后续无数的自动装箱和开箱,也是java泛型慢的很重要的原因。

总之,java通过『泛型擦除』来实现泛型。在访问其中元素时,必要时会进行类型强转。

用法

文章刚开始说过,泛型可以在类,接口,方法中使用。他们使用到泛型的用法大致如下:

//访问修饰符 class/interface 类名或接口名<限定类型变量名>
public class ArrayList<E>{}
public interface List<E>{}
//方法
public <E> E test(E e)

下面将分别对上诉用法进行详解。

泛型类

泛型类型用于类的定义中,被称为泛型类。通过泛型可以完成对一组类的操作对外开放相同的接口。最典型的就是各种容器类,如:List、Set、Map。

class 类名称 <泛型标识:可以随便写任意标识号,标识指定的泛型的类型>{
  private 泛型标识 /*(成员变量类型)*/ var; 
  .....

  }
}

例如:

//此处T可以随便写为任意标识,常见的如T、E、K、V等形式的参数常用于表示泛型
//在实例化泛型类时,必须指定T的具体类型
public class Generic<T>{ 
    //key这个成员变量的类型为T,T的类型由外部指定  
    private T key;

    public Generic(T key) { //泛型构造方法形参key的类型也为T,T的类型由外部指定
        this.key = key;
    }

    public T getKey(){ //泛型方法getKey的返回值类型为T,T的类型由外部指定
        return key;
    }
}

定义的泛型类,就一定要传入泛型类型实参么?并不是这样,在使用泛型的时候如果传入泛型实参,则会根据传入的泛型实参做相应的限制,此时泛型才会起到本应起到的限制作用。如果不传入泛型类型实参的话,在泛型类中使用泛型的方法或成员变量定义的类型可以为任何的类型。

//没有传入泛型的参数下的情况
List list = new ArrayList();
list.add("hello world");
list.add(123);
list.add(false);
list.add(802.11);
System.out.println(list);
输出:
[hello world, 123, false, 802.11]

值得注意的是,泛型的类型参数只能是类类型,不能是简单类型。

泛型接口

泛型接口与泛型类的定义及使用基本相同。泛型接口常被用在各种类的生产器中,可以看一个例子:

//定义一个泛型接口
public interface Generator<T> {
    public T next();
}

当实现泛型接口的类,未传入泛型实参时:

/**
 * 未传入泛型实参时,与泛型类的定义相同,在声明类的时候,需将泛型的声明也一起加到类中
 * 即:class FruitGenerator<T> implements Generator<T>{
 * 如果不声明泛型,如:class FruitGenerator implements Generator<T>,编译器会报错:"Unknown class"
 */
class FruitGenerator<T> implements Generator<T>{
    @Override
    public T next() {
        return null;
    }
}

当实现泛型接口的类,传入泛型实参时:

/**
 * 传入泛型实参时:
 * 定义一个生产器实现这个接口,虽然我们只创建了一个泛型接口Generator<T>
 * 但是我们可以为T传入无数个实参,形成无数种类型的Generator接口。
 * 在实现类实现泛型接口时,如已将泛型类型传入实参类型,则所有使用泛型的地方都要替换成传入的实参类型
 * 即:Generator<T>,public T next();中的的T都要替换成传入的String类型。
 */
public class FruitGenerator implements Generator<String> {

    private String[] fruits = new String[]{"Apple", "Banana", "Pear"};

    @Override
    public String next() {
        Random rand = new Random();
        return fruits[rand.nextInt(3)];
    }
}

泛型方法

泛型方法,是在调用方法的时候指明泛型的具体类型 。

修饰符 <代表泛型的变量> 返回值类型 方法名(参数){ }

例如:

/**
     *
     * @param t 传入泛型的参数
     * @param <T> 泛型的类型
     * @return T 返回值为T类型
     * 说明:
     *   1)public 与 返回值中间<T>非常重要,可以理解为声明此方法为泛型方法。
     *   2)只有声明了<T>的方法才是泛型方法,泛型类中的使用了泛型的成员方法并不是泛型方法。
     *   3)<T>表明该方法将使用泛型类型T,此时才可以在方法中使用泛型类型T。
     *   4)与泛型类的定义一样,此处T可以随便写为任意标识,常见的如T、E等形式的参数常用于表示泛型。
     */
    public <T> T genercMethod(T t){
        System.out.println(t.getClass());
        System.out.println(t);
        return t;
    }
 
 
public static void main(String[] args) {
    GenericsClassDemo<String> genericString  = new GenericsClassDemo("helloGeneric"); //这里的泛型跟下面调用的泛型方法可以不一样。
    String str = genericString.genercMethod("hello");//传入的是String类型,返回的也是String类型
    Integer i = genericString.genercMethod(123);//传入的是Integer类型,返回的也是Integer类型
}
 
 
class java.lang.String
hello
 
 
class java.lang.Integer
123

泛型通配符

Java泛型的通配符是用于解决泛型之间引用传递问题的特殊语法, 主要有以下三类:

/*
1. 无边界的通配符(Unbounded Wildcards), 就是<?> , 比如List<?>,Class<?>
无边界的通配符的主要作用就是让泛型能够接受未知类型的数据.
 */
 //表示类型参数可以是任何类型
public class Apple<?>{}

/*
2. 固定上边界的通配符(Upper Bounded Wildcards),采用<? extends E>的形式
使用固定上边界的通配符的泛型, 就能够接受指定类及其子类类型的数据。
要声明使用该类通配符, 采用<? extends E>的形式, 这里的E就是该泛型的上边界。
注意: 这里虽然用的是extends关键字, 却不仅限于继承了父类E的子类, 也可以代指显现了接口E的类
 */
//表示类型参数必须是A或者是A的子类
public class Apple<T extends A>{}
/*
3. 固定下边界的通配符(Lower Bounded Wildcards),采用<? super E>的形式
使用固定下边界的通配符的泛型, 就能够接受指定类及其父类类型的数据.
要声明使用该类通配符, 采用<? super E>的形式, 这里的E就是该泛型的下边界.。
注意: 你可以为一个泛型指定上边界或下边界, 但是不能同时指定上下边界。
*/
//表示类型参数必须是A或者是A的超类型
public class Apple<T supers A>{}

泛型中KTVE的含义

常见泛型参数名称有如下:

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

java的『伪泛型』

在 Java 中的 泛型,常常被称之为 伪泛型,究其原因是因为在实际代码的运行中,将实际类型参数的信息擦除掉了[(Type Erasure)](前面原理部分说过)。所谓的泛型其实只在编译前存在。 那是什么原因导致了 Java 做出这种妥协的呢?下面本文将从泛型的设计入手,对这一问题进行讨论。

什么是真泛型

C#中使用了"真泛型",在了解 Java "伪泛型" 之前,我们先简单讲一讲"真泛型"与“伪泛型”的区别。

  • 真泛型:泛型中的类型是真实存在的。
  • 伪泛型:仅于编译时类型检查,在运行时擦除类型信息。

其实很简单,不擦除就是真泛型,擦除掉就是伪泛型。

编程语言实现“真泛型”的一般思路

一般编程语言引入泛型的思路,都是通过编译时膨胀法。即编译的时候通过增加字段,区分带泛型的和原有类型。

以 Java 语言实现"真泛型"为例,首先,对泛型类型(泛型类、泛型接口)泛型方法的名字使用特别的编码,例如将 Factory<T> 类生成为一个名为 “Factory@@T” 的类,这种特别的编码后的名字将被编译器识别,作为判断是否为泛型的依据。方法中使用占位符 T 的地方可以临时生成为 Object ,并带上特别的 Annotation 让 Java 编译器能得知这个是占位符。然后,如果编译时发现有对 Factory<String> 的使用,则将 “Factory@@T” 的所有逻辑复制一份,新建 “Factory@String@” 类,将原本的占位符 T 替换为 String 。然后在编译 new Factory<String>() 时生成 new Factory@String@() 即可。

Factory<String>Factory<Integer> 为例,其生成的代码为:

//替换前的代码
Factory<String>() f1 = new Factory<String>();
Factory<Integer>() f2 = new Factory<Integer>();

//替换后的代码
Factory<String>() f1 = new Factory@String@()
Factory<Integer>() f2 = new Factory@Integer@();
  • 其中 Factory@String@ 类中的 T 已经被替换为 String。
  • 其中 Factory@Integer@ 类中的 T 已经被替换为 Integer。

Factory<String>Factory<Integer> 为例,其生成的代码为:

//替换前的代码
Factory<String>() f1 = new Factory<String>();
Factory<Integer>() f2 = new Factory<Integer>();

//替换后的代码
Factory<String>() f1 = new Factory@String@()
Factory<Integer>() f2 = new Factory@Integer@();
  • 其中 Factory@String@ 类中的 T 已经被替换为 String。
  • 其中 Factory@Integer@ 类中的 T 已经被替换为 Integer。

因为含有不同的 实际类型参数泛型类型 都被替换为了不同的类,且泛型类型中的类型也都得到了确认。所以我们在程序中可以这么做:

class Factory<T> {
    T data;
    public static void f(Object arg){
        if(arg instanceof T){...}//success
        T var = new T();//success
        T[] array = new T[10];//success
    }
}

Java 直接使用 “真泛型” 带来的问题

单从技术来说,Java 是完全能实现我们所说的 ”真泛型”。但是要考虑很重要的一点是:java一开始是没有泛型的,在后续的版本中加入泛型。《Java语言规范》中说到要保证『二进制向后兼容性』。例如JDK1.2中编译的class文件,要保证在JDK12和以后的版本中都可以运行。这就意味着以前没有的限制不能突然冒出来。

为了保证JDK1.2编译出来的class文件可以在以后的版本中继续运行,设计者大概有两条路可以选择:
1.原有的类型保持不变,平行的加入一套泛型化版本的新类型。(保持ArrayList不变,加入ArrayList<>。) 2.直接把原有的类型泛型化,不添加新类型。(将ArrayList变为加入ArrayList。)

C#选择了第一条路,我们以好理解的java为例,做出了如下修改。即增加了新的java.util.generic.ArrayList<T>

  • java.util.ArrayList 👈 Java 老版本
  • java.util.generic.ArrayList<T> 👈 Java5 新增泛型版本

而java选择了第二条路,这是为什么呢?考虑AndyJennifer文章中所提的一种情况。
如果我有一个 Java 5 之下 的 A 项目与第三方的 B1 lib,其中有 A 项目中引用了 B1 lib 中的某个 ArrayList ,随着 Java 的升级,B1 lib 的开发者为了使用 Java 新特性--泛型,故将代码迁移到了 Java 5,并重新生成了 B2 lib,那么 A 项目要兼容 B2 lib,那么 A 项目中必须升级到 Java 5 并同时修改代码。

A 项目为什么必须要升级 Java 5?

在 Java 中不支持高版本的 Java 编译生成的 class 文件在低版本的 JRE 上运行,如果尝试这么做,就会得到 UnsupportedClassVersionError 错误。如下图所示:

版本说明.png

故 A 项目要适配 B2 lib,必要要把 Java 升级到 Java 5。

因为当时java已经有快十年的历史了,Java 版本迭代都从 1.0 到 5.0了,有多少的开源框架,有多少项目,如果为了引入泛型,强行让开发者修改代码,工作量是十分巨大的。而C#才刚刚问世两年,遗留的代码较少。因此,两种语言选择了完全不同的道路。

因此,java通过类型擦除这一编译器的”魔法“,解决了处理泛型兼容老版本的问题。正如我们在本文泛型的实现一章所说,经过编译器处理,新版本去掉了泛型,从而与老版本无泛型进行了兼容。

因此,即使伪泛型有这这样那样的问题,但它实现了不同版本的兼容,是一件值得尊重的事情。

参考资料:
1.juejin.cn/post/684490…
2.www.cnblogs.com/Blue-Keroro…
3.juejin.cn/post/684490…