Java核心 -- 泛型

导论

从Java程序设计语言1.0版发布以来,变化最大的部分就是泛型。专家组花费了5年左右的时间来定义规范和测试实现。

使用泛型机制编写的程序代码要比那些杂乱的使用Object变量,然后再进行强制类型转换的代码具有更好的安全性和可读性。

泛型对于集合类尤其有用。例如,ArrayList就是一个无处不在的集合类。

 

从一个例子说起

import java.util.ArrayList;
import java.util.List;

public class Test {

    public static void main (String[] args) {

        List arrayList = new ArrayList();
        arrayList.add("hello");
        arrayList.add(123);
        for (int i = 0; i<arrayList.size(); i++){
            String item = (String) arrayList.get(i);
            System.out.println("item: " + item);
        }

    }
}


运行结果:
item: hello
Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String
	at Test.main(Test.java:12)

从运行结果可以看出,由于arraList数组中有两个元素,第一个是String型,第二个是个整型,而该段程序不仅运行起来了,而且还把第一个字符串“hello”正确输出了,只是在碰到第二个int转String语句时,报java.lang.ClassCastException错误,即类型转换错误。

因此可以说,类似于这种类型转换错误在程序编译期,编译器没法排查错误,只能等到运行期导致程序崩溃。为了让程序更安全,我们的目标是希望这样的错误,在编译期就能被排查到,便于及时修正。

>>> 于是乎,便有了泛型技术的引进

在解释是什么是泛型,泛型怎么使用,有哪些规则和特性之前,我们先看看对比上述问题,使用泛型的效果是什么样的。

import java.util.ArrayList;
import java.util.List;

public class Test {

    public static void main (String[] args) {

        // 仅仅在List和ArrayList后面加上了<String>
        List<String> arrayList = new ArrayList<String>();
        arrayList.add("hello");
        arrayList.add(123); // IDE错误提示:add(java.lang.String) in List cannot be applied to (int)
        for (int i = 0; i<arrayList.size(); i++){
            String item = (String) arrayList.get(i);
            System.out.println("item: " + item);
        }

    }
}

代码中,仅需要在arrayList声明和初始化时,指明了其类型参数为String,IDE就能检测出arrayList.add(123)有语法错误,将这段代码强行编译,也会报错如下,即编译就已经无法通过,更不用等到运行才发现错误

上面通过在一个“需要类型转换且转换会出错”的案例,对比了没有泛型使用泛型的不同之处;前者在编译期正常,运行期会报错,在生产环境下,会影响整个程序的可靠性,带来无法预测的意外,而后者在编译期就能及时发现错误,提醒修正,减少代码的风险。这就是引入泛型的好处,使得代码编写更安全。当然泛型的作用也不会仅仅如此。下面就开始正式介绍泛型是什么,如何使用,有哪些规则和特性。

泛型类和接口

Generic类引入了一个类型变量T,用尖括号(< >)括起来,并放在类名的后面。泛型类可以有多个类型变量。

类型变量使用大写形式,且比较短,这是很常见的。在Java库中,使用

  • 变量E表示集合的元素类型;
  • K和V分别表示表的关键字与值得类型;
  • T(需要时还可以用邻近的U和S)表示“任意类型”;

用具体的类型 替换 类型变量(T 、K、V、E、U、S等)就可以实例化泛型类型。

可以将实例化的结果想象成带有构造器的普通类,换句话说说,泛型类可以看作普通类的工厂。

// 定义Generic类
public class Generic<T> {
    private T mType;
    
    Generic(T t){
        this.mType = t;
    }

    void say(){
        System.out.println("Input is: " + mType.toString());
    }
}

// 定义Test类
public class Test {

    public static void main(String[] args){
        Generic<Number> numberGeneric = new Generic<Number>(12);
        Generic<Integer> integerGeneric = new Generic<Integer>(123);
        Generic<String> stringGeneric = new Generic<String>("hello");

        Test.testNumber(integerGeneric); // compile error
        Test.testInteger(numberGeneric); // compile error
        Test.testInteger(stringGeneric); // compile error
        Test.testString(integerGeneric); // compile error
    }

    static void testNumber(Generic<Number> generic){
        generic.say();
    }

    static void testInteger(Generic<Integer> generic){
        generic.say();
    }

    static void testString(Generic<String> generic){
        generic.say();
    }
}

上述代码分析可知,虽然Interger是Number的子类,Generic<Number> 、Generic<Integer> 、Generic<String>在类型擦除后都变成Generic类,但是 Generic<Integer> 也不能看做是Generic<Number>的子类,Generic<Integer>和Generic<String>不能相互转换。

再分析上段中出现的问题,Interger是Number的子类,但不能将Generic<Integer>理解成Generic<Number>的子类,因为二者其实只是Generic类的不同泛型版本,而泛型的不同版本是不兼容的,不会因为泛型的类型存在继承关系而可以相互转换。 

如果想要达到像普通类一样,让泛型类也存在继承关系, 我们需要一个在逻辑上可以表示同时Generic<Integer>Generic<Number>父类的引用类型。

>>>  由此,类型通配符应运而生,可以把 “?” 看成所有类型的父类

public class Test {

    public static void main(String[] args){
        Generic<Number> numberGeneric = new Generic<Number>(12);
        Generic<Integer> integerGeneric = new Generic<Integer>(123);
        Generic<String> stringGeneric = new Generic<String>("hello");

        Test.testType(numberGeneric);
        Test.testType(integerGeneric);
        Test.testType(stringGeneric);
    }

    // 将testType方法的的具体的泛型类型参数改为通配符参数
    static void testType(Generic<?> generic){
        generic.say();
    }

}

输出结果:
Input is: 12
Input is: 123
Input is: hello

类型通配符一般是使用 “?” 代替具体的类型实参,注意,此处’?’是类型实参,而不是类型形参 。

再直白点的意思就是,此处的?和Number、String、Integer一样都是一种实际的类型,可以把 “?” 看成所有类型的父类。是一种真实的类型。

泛型方法

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

// Generic
public class Generic<T> {
    private T mType;
   
    /**
     * 虽然在方法中使用了泛型,但是这并不是一个泛型方法。
     * 这只是类中一个普通的成员方法,只不过他的返回值是在声明泛型类已经声明过的泛型。
     * 所以在这个方法中才可以继续使用 T 这个泛型。
     */
    public T getmType(){
        return mType;
    }

    Generic(T t){
        this.mType = t;
    }

    void say(){
        System.out.println("Input is: " + mType.toString());
    }
}

// Test
public class Test {

    public static void main(String[] args){
        Generic<Number> numberGeneric = new Generic<Number>(12);
        Generic<Integer> integerGeneric = new Generic<Integer>(123);
        Generic<String> stringGeneric = new Generic<String>("hello");

        Test.testType(numberGeneric);
        Test.testType(integerGeneric);
        Test.testType(stringGeneric);
    }


    /**
     * 泛型方法的基本介绍
     * @param generic传入的泛型实参
     * @return T 返回值为T类型
     * 说明:
     *     1)static与 返回值中间<T>非常重要,可以理解为声明此方法为泛型方法。
     *     2)只有声明了<T>的方法才是泛型方法,泛型类中的使用了泛型的成员方法并不是泛型方法。
     *     3)<T>表明该方法将使用泛型类型T,此时才可以在方法中使用泛型类型T。
     *     4)与泛型类的定义一样,此处T可以随便写为任意标识,常见的如T、E、K、V等形式的参数常用            于表示泛型。
     */
    static <T> void testType(Generic<T> generic){
        generic.say();
    }

}


输出:
Input is: 12
Input is: 123
Input is: hello

// JD-GUI 反编译Test.class
public class Test
{
  public static void main(String[] paramArrayOfString)
  {
    Generic localGeneric1 = new Generic(Integer.valueOf(12));
    Generic localGeneric2 = new Generic(Integer.valueOf(123));
    Generic localGeneric3 = new Generic("hello");
    
    testType(localGeneric1);
    testType(localGeneric2);
    testType(localGeneric3);
  }
  
  static <T> void testType(Generic<T> paramGeneric)
  {
    paramGeneric.say();
  }
}
// 定义Generic泛型类,使用T表示类型
public class Generic<T> {

    // 定义say泛型方法(因为方法头中有<T>泛型标志),使用T表示类型,注意这个T是一种全新的类型,注意不同于Generic类名中的T
    <T> void say(T word){
        System.out.println("say what: " + word.toString());
    }

    // 定义play泛型方法,使用E表示类型
    <E> void play(E sport){
        System.out.println("play what: " + sport.toString());

    }

    // 定义普通成员方法,和say方法相比,少了<T>泛型方法标志,两者都有泛型入参
    void like(T thing){
        System.out.println("like what: " + thing.toString());
    }
}

// Test主类
public class Test {

    public static void main(String[] args){
        // 实例Generic泛型类,使用Double作为类型
        Generic<Double> generic = new Generic<Double>();
        // 调用泛型方法,使用不同于Double的Integer作为类型
        generic.say(12);
        // 调用泛型方法,使用不同于Double的String作为类型
        generic.play("hello");
        // 调用普通成员方法,使用不同于Double的Integer作为类型
        generic.like(12);
    }
}

Generic类中有三个方法,其中say和play是泛型方法,like是普通成员方法,并且like方法的入参也是泛型T。

在主类中调用like方法时,传入的是Integer类型,会报错(因为generic对象在创建时,使用的Double类型):

而其他两个泛型方法的入参都不是Double类型,却则没有报错,说明泛型方法不受泛型类的类型影响。

另一方面,在将Generic类中like方法的入参改成E时(不同于类名中的T),则也会报错,说明泛型类的非泛型方法在使用泛型时要严格和泛型类的定义统一。

泛型方法和可变参数

public class Generic<T> {

    <T> void display(T... args){
        for (T a: args){
            System.out.println(a.toString());
        }
    }

    public static void main(String[] args){
        Generic generic = new Generic<String>();
        generic.display(12, "hello", 5D, 6f);
    }
}

输出结果:
12
hello
5.0
6.0

静态方法与泛型

静态方法上不能直接使用泛型,会报错,需要把静态方法修改成静态泛型方法。

即:如果静态方法要使用泛型的话,必须将静态方法也定义成泛型方法 。

将静态方法也定义成泛型方法后,就没有编译错误:

泛型方法总结:

泛型方法能使方法独立于类而产生变化,以下是一个基本的指导原则:

无论何时,如果你能做到,你就该尽量使用泛型方法。也就是说,如果使用泛型方法将整个类泛型化,那么就应该使用泛型方法。

另外对于一个static的方法而已,无法访问泛型类型的参数。所以如果static方法要使用泛型能力,就必须使其成为泛型方法。

类型变量的限定

import java.io.Serializable;

public class Generic<T> {

    Generic(T t){
    }

    private static  <T extends Number & Comparable & Serializable> void supBound(Generic<T> generic){
        System.out.println(generic.getClass());
    }


    public static void main(String[] args){

        Generic.supBound(new Generic<Integer>(12));

    }
}

extends关键字能限定变量类型的父类类型,即<T extends BoundingType>表示T应该是边界类型BoundingType的子类型。如下所示,当T不满足指定类型的子类型要求时,会编译报错。

一个类型变量或通配符可以有多个限定,例如:T extends Comparable & Serializable,限定类型之间用“&”分隔,而逗号用来分隔类型变量

在Java的继承中,可以根据需要拥有多个接口超类型,但限定中至多有一个类,如果用一类作为限定,它必须是限定列表中的第一个

如下图所示,限定表的第二项是String,属于类而不是接口,所以会报错。 

将限定表第二项集后面的项都改成接口,则不会报错,如下图

 

泛型代码与虚拟机

虚拟机没有泛型类型对象 --- 所有对象都属于普通类。

类型擦除(泛型只作用于编译期)

注:方法体中的泛型都被擦除,但是类名称或者方法头中的泛型并没有被擦除。

// Test.java 类源码
import java.util.ArrayList;
import java.util.List;

public class Test {

    public static void main (String[] args) {

        List<String> strGenerics = new ArrayList<String>();
        List<Integer> intGenerics = new ArrayList<Integer>();
        strGenerics.add("hello");
        intGenerics.add(123);

    }
}

// 使用 JD-GUI 对Test.class字节码反编译后的类代码
import java.util.ArrayList;
import java.util.List;

public class Test
{
  public static void main(String[] paramArrayOfString)
  {
    ArrayList localArrayList1 = new ArrayList();
    ArrayList localArrayList2 = new ArrayList();
    localArrayList1.add("hello");
    localArrayList2.add(Integer.valueOf(123));
  }
}

对比Test源码和反编译后的代码,发现:

  • 泛型在编译成字节码的过程中,被编译器擦除掉了,泛型类型都会擦除成它们的“原生”类型,如泛型类型ArrayList<String>、ArrayList<Integer>被编译器擦除后,都变成原生类型ArrayList;
  • ArrayList的add函数的入参是Object的子类,即必须是类对象,而不是基本类型,所以这里需要把int整型装箱成Integer类实例;
import java.util.ArrayList;
import java.util.List;

public class Test {

    public static void main (String[] args) {

        List<String> strGenerics = new ArrayList<String>();
        List<Integer> intGenerics = new ArrayList<Integer>();
        Class strCl = strGenerics.getClass();
        Class intCl = intGenerics.getClass();

        System.out.println("使用equals对比两者的Class对象引用: " + strCl.equals(intCl));
        System.out.println("打印strCl的hashCode: " + strCl.hashCode());
        System.out.println("打印intCl的hashCode: " + intCl.hashCode());

    }
}


输出结果:
使用equals对比两者的Class对象引用: true
打印strCl的hashCode: 1163157884
打印intCl的hashCode: 1163157884

由于泛型只作用于源码的编译期,而对于生成的字节码、以及往后JVM对字节码的类加载、内存分配、垃圾回收等整个运行期,都跟泛型没有关系。在类型擦除后,由于都变成同样的原生类型,所以上述代码中,才有对Class类对象的获取(不管是三种获取方式中的哪种),都是对ArrayList类的同一个Class对象的引用。

在编译之后程序会采取去泛型化的措施。也就是说Java中的泛型,只在编译阶段有效。在编译过程中,正确检验泛型结果后,会将泛型的相关信息擦出,并且在对象进入和离开方法的边界处添加类型检查和类型转换的方法。也就是说,泛型信息不会进入到运行时阶段。
--------------------- 
作者:VieLei 
来源:CSDN 
原文:https://blog.csdn.net/s10461/article/details/53941091 
版权声明:本文为博主原创文章,转载请附上博文链接!

对此总结成一句话:

编译期之前:泛型类型在编译期之前可以看成是多个不同的类型

编译期之后(运行期):擦除泛型,变成相同的原生类型。

 

泛型的类型擦除规则

// Test.java 类源码
public class Test <T> {

    private T key;

    Test(T key){
        this.key = key;
    }

    public T getKey() {
        return key;
    }

    public static void main (String[] args) {
	
        Test test = new Test<Double>(123D);
        System.out.println(test.getKey());
    }
}


// JD-GUI反编译
import java.io.PrintStream;

public class Test<T>
{
  private T key;
  
  Test(T paramT)
  {
    this.key = paramT;
  }
  
  public T getKey()
  {
    return (T)this.key;
  }
  
  public static void main(String[] paramArrayOfString)
  {
    Test localTest = new Test(Double.valueOf(123.0D));  // 基本类型自动装箱,泛型擦除成原生类型
    System.out.println(localTest.getKey());
  }
}

 

总结

泛型只作用于源码的编译期,也就是说在字节码生成后,其内容中就不在含有泛型的踪迹,所以才有所谓的JVM并没有因为泛型技术的引入而有什么不一样的改变(因为JVM是作用于字节码运行期的平台)。既然在运行期有没有泛型对于程序的运行没有影响,而编写代码时使用泛型,编译代码时又擦除了泛型,这个过程不仅使得编译器的编译过程更复杂,对程序编写也增添了很多语法细节和编程规则,而且泛型的引入对整个Java类库尤其是容器类的重构,带来极繁重的任务量。 那么这么看似“多此一举”而且影响颇大的泛型到底有多大的必要性,难道仅仅是为了能提前在编译期对类型匹配进行检测吗?引入泛型需要付出代价,Java核心开发团队能在10年时间里权衡利弊,直到java1.5才完成整体Java类库的泛型化,我们只能慢慢体验并欣赏泛型的艺术。

猜你喜欢

转载自blog.csdn.net/WalleIT/article/details/87545037