java中的泛型及泛型应用

1. 泛型基础

1.1. 泛型的概念

泛型是在JDK 5中引入的一个新特性,其本质是参数化类型,也就是将具体的类型形参化,参数化的类型(可以称之为类型形参)在使用或者调用时传入具体的类型(类型实参),类似于调用方法时传入实参才确定方法形参的具体值。泛型的声明由一对尖括号和类型形参组成,类型形参定义在尖括号中间,定义类、接口和方法时使用泛型声明,定义出的类、接口和方法分别称为泛型类、泛型接口和泛型方法。

1.2. 泛型的定义

使用泛型编程,会在使用或者调用时传入具体的类型时才确定最终的数据类型,所以集合需要存储什么类型的数据,在创建集合时传入对应的类型即可。

定义泛型时类型形参由一对尖括号(<>)包含在中间,使用或者调用泛型时,需要将类型实参写在尖括号(<>)之间。

JDK 5之后的类库中很多重要的类和接口都引入了泛型,例如集合体系中的类和接口。下面分别演示未引入泛型和使用泛型编程的区别,体验泛型具体有什么好处。

(1)未引入泛型前

public class TestDemo {
    
    @Test
    public void test(){
        // 创建一个只保存Integer类型的List集合
        List intList = new ArrayList();
        intList.add(1);
        intList.add(2);
        //因为失误存放了Integer类型之外的字符串数据
        intList.add("3");
        for (int i = 0; i < intList.size(); i++) {
            /*因为List里面默认取出的全部Object对象,所以使用之前需要进行强
             * 制类型转换。集合内最后一个元素进行转换时候将出现类型转换异常
             * */
            Integer num=(Integer)intList.get(i);
        }
    }
}

(2)引入泛型后

public class TestDemo {
    @Test
    public void test(){
        // 创建一个只保存Integer类型的List集合
        List<Integer> intList = new ArrayList<Integer>();
        intList.add(1);
        intList.add(2);
        //下面代码将出现编译时异常
        intList.add("3");
        for (int i = 0; i < intList.size(); i++) {
            //下面的代码无需强制类型转换
            Integer num=intList.get(i);
        }
    }
}

1.3. 泛型的好处

使用泛型的好处如下:

  • 提高类型的安全性

使用泛型后,将类型的检查从运行期提前到编译期,编译期的类型检查,可以更早、更容易的找出因为类型限制而导致的类型转换异常,从而提高程序的可靠性。

  • 消除强制类型转换

使用泛型后,程序会记住当前的类型形参,从而无需对传入的实参值进行强制类型转换。使得代码更加清晰和筒洁,可读性更高。

  • 提高代码复用性

使用泛型后,可以更好的将程序中通用的代码提取出来,在使用时传入不同类型的参数,避免了多次编写相同功能的代码,以提高代码的复用性。

  • 拥有更高的运行效率

使用泛型之前,传入的实际参数值作为Object类型传递时,需要进行封箱和拆箱操作,会消耗程序的一定的开销。使用泛型后,类型形参中都需要使用引用数据类型,即传入的实际参数的类型都是对应引用数据类型,避免了封箱和拆箱操作,降低了程序运行的开销,提高了程序运行的效率。

2. 泛型类

2.1. 泛型类的语法格式

定义类时,在类名后加上尖括号包含类型形参,定义的这个类就是泛型类。创建泛型类的实例对象时传入不同的类型实参,从而可以动态生成无数个该泛型类的子类。在JDK类包中泛型类的最典型应用就是各种容器类,如ArrayList、HashMap等。定义泛型类的格式具体如下。

public class 类名<类型形参变量>{
    
}

上述语法格式中,类名<类型形参变量>是一个整体的数据类型,通常称为泛型类型;类型形参变量,没有特定的意义,可以是任意一个字母,但是为了提高可读性,建议使用有意义的字母。一般情况下使用较多的字母及意义如下所示。

  • E:表示Element(元素),常用在java Collection里使用,如 List<E>,Iterator<E>,Set<E>。
  • K,V:表示Key,Value(Map的键值对)。
  • N:表示Number(数字)。
  • T:表示Type(类型),如String,Integer等。

2.2. 泛型类的定义与创建

定义:定义泛型类时,类的构造方法名称还是类的名称,类型形参变量可以用于属性的类型、方法的返回值类型和方法的参数类型。

创建:创建泛型类的对象时,不强制要求传入类型实参,如果传入类型实参,类型形参会根据传入的类型实参做相应的限制,此时泛型才会起到本应起到的限制作用。如果不传入类型实参的话,在泛型类中使用类型形参的方法或成员变量定义的类型可以为任何的类型。

(1)定义泛型类Goods,声明私有变量info,定义构造方法,与getter/setter方法。

/**
 * 定义泛型类Goods
 * @param <T>
 */
public class Goods<T> {
    // 类型形参变量作用于属性的类型
    private T info ;
    //无参构造方法
    public Goods(){

    }
    // 类型形参变量作用于构造方法的参数类型
    public Goods(T info) {
        this.info = info;
    }
    // 类型形参变量作用于方法的参数类型
    public void setInfo(T info){
        this.info = info ;
    }
    // 类型形参变量作用于方法的返回值类型
    public T getInfo(){
        return this.info ;
    }
}

(2)定义测试类,创建Goods对象,分别调用setInfo()方法和getInfo()方法。

public class TestDemo{
    @Test
    public void test(){
        Goods goods = new Goods();
        goods.setInfo("电脑");
        System.out.println(goods.getInfo() + ":" + goods.getInfo().getClass());
        goods.setInfo(200);
        System.out.println(goods.getInfo() + ":" + goods.getInfo().getClass());
    }
    @Test
    public void test2(){
        Goods<String> goods = new Goods<>();
        goods.setInfo("电脑");
        System.out.println(goods.getInfo() + ":" + goods.getInfo().getClass());
    }
    @Test
    public void test3(){
        Goods<Integer> goods= new Goods<>();
        goods.setInfo(200);
        System.out.println(goods.getInfo() + ":" + goods.getInfo().getClass());
    }
}

2.3. 泛型类的练习

定义一个泛型类Point<T>,其中包含x和y两个类型为T的成员,定义类的带参构造方法,为x和y定义setter和getter,定义show方法输出坐标。

编写测试方法,创建Point<Integer>对象和Point<Double>对象。

  • Point<T>类
public class Point<T> {
    //泛型成员
    private T x;
    private T y;
    //构造方法
    public Point(T x,T y){
        this.x = x;
        this.y = y;
    }

    public T getX() {
        return x;
    }
    public void setX(T x) {
        this.x = x;
    }
    public T getY() {
        return y;
    }
    public void setY(T y) {
        this.y = y;
    }
    //输出坐标
    public void show(){
        System.out.println("x坐标是:" + x + ",y坐标是:" + y);
    }
}
  • 测试类
public class Test {
    public static void main(String[] args) {
        //Integer型
        Point<Integer> p1 = new Point(1,2);
        p1.show();
        //Double型
        Point<Double> p2 = new Point(1.1,2.2);
        p2.show();
    }
}

3. 泛型接口

3.1. 泛型接口的语法格式

定义泛型接口和定义泛型类的语法格式类似,在接口名称后面加上尖括号包含类型形参即可。集合相关的接口中很多接口也都是泛型接口,如Collection、List等。定义泛型接口的基本语法格式如下所示:

public interface 接口名称<类型形参变量>{
    
}

3.2. 泛型接口的应用

泛型接口可以有两种类方式实现,第一种是使用非泛型类实现泛型接口,第二种是使用泛型类实现泛型接口。

(1)使用非泛型类实现泛型接口

当使用非泛型类实现接口时,需要明确接口的泛型类型,也就是需要将类型实参传入到接口中。此时实现类重写接口中使用泛型的地方,都需要将类型形参替换成传入的类型实参,这样可以直接使用泛型接口的类型实参,具体代码如下所示。

  • 定义一个泛型接口
public interface Student<T> {
    public abstract void show(T t);
}
  • 定义泛型接口的实现类,在泛型接口后指定类型实参以明确接口的泛型类型。
public class StudentImpl implements Student<String>{
    @Override
    public void show(String s) {
        System.out.println(s);
    }
}
  • 定义测试类,创建Student对象时,传入的类型实参必须是String类型,否则编译异常。
public class TestDemo {

    @Test
    public void test(){
        Student<String> stu = new StudentImpl();
        stu.show("你好,我是张三");
    }

}

(2)使用泛型类实现泛型接口

当使用泛型类实现泛型接口时,需要将泛型的声明加在实现类中,并且泛型类和泛型接口使用的都是同一个类型形参变量,否则会出现编译异常。具体代码如下所示。

  • 定义一个泛型接口
public interface Student<T> {
    public abstract void show(T t);
}
  • 定义泛型接口的实现类,使用泛型类实现泛型接口
public class StudentImpl<T> implements Student<T> {

    @Override
    public void show(T t) {
        System.out.println(t);
    }
}
  • 定义测试类,创建Student对象时,传入不同的类型实参,并分别调用show()方法进行输出验证。
public class TestDemo {

    @Test
    public void test(){
        Student<String> stu1 = new StudentImpl<>();
        stu1.show("你好,我是张三");
        Student<Integer> stu2 = new StudentImpl<>();
        stu2.show(20);
    }

}

4. 泛型方法

4.1. 泛型方法的语法格式

泛型方法是将类型形参的声明放在修饰符和返回类型之间的方法。在Java程序中,定义泛型方法常用的格式如下所示:

public [static] [final] <类型形参> 返回值类型 方法名 (形式参数列表){
     
}

定义泛型方法注意事项如下所示:

  • 访问权限修饰符(包括private、public、protected)、static和 final都必须写在类型形参列表的前面。
  • 返回值类型必须写在类型形参列表的后面。
  • 泛型方法可以在泛型类中,也可以在普通类中。
  • 泛型类中的任何方法本质上都是泛型方法,所以在实际使用中很少会在泛型类中再用上面的形式来定义泛型方法。
  • 类型形参可以用在方法体中修饰局部变量,也可以修饰方法的返回值。
  • 泛型方法可以是实例方法(没有用static修饰,也叫非静态方法)也可以是静态方法。

4.2. 泛型方法的应用

如果泛型方法是实例方法,则需要使用对象名进行调用;如果泛型方法是静态方法,可以使用类名进行调用,泛型方法的两种使用方式。

  • 方式一
对象名|类名.<类型实参> 方法名(类型实参列表);
  • 方式二
对象名|类名.方法名(类型实参列表);

两种调用泛型方法的差别在于,方法名之前是否显式地指定了类型实参。调用时是否需要显式地指定了类型实参,要根据泛型方法的声明形式,以及调用时编译器能否从实际参数表中获得足够的类型信息决定,如果编译器能够根据实际参数推断出参数类型,就可以不指定类型实参,反之则需要指定类型实参。

4.3. 泛型方法的练习

下面通过一个案例,演示泛型方法的定义与使用,具体代码如下所示。

(1)定义Student类,在类中定义一个静态泛型方法和一个普通泛型方法。

public class Student {
    // 静态泛型方法
    public static <T> void show(T t) {
        System.out.println(t + ":" + t.getClass());
    }
    // 普通泛型方法
    public <T> void study(T t) {
        System.out.println(t + ":" + t.getClass());
    }
}

(2)定义测试方法,调用方法测试结果。

public class TestDemo {
    @Test
    public void test(){
        //静态方法
        Student.show("你好,我是张三");            // 使用方式一调用静态的泛型方法
        Student.<String>show("你好,我是张三");    // 使用方式二调用静态的泛型方法

        //普通方法
        Student stu = new Student();
        stu.study("好好学习");          // 使用方式一调用普通的泛型方法
        stu.<String>study("好好学习");  // 使用方式二调用普通的泛型方法
    }
}

从运行结果可以得出,泛型方法可以在非泛型类中定义,并且在调用泛型方法的时候确定泛型的具体类型 。上述结果中虽然使用方式一和方式二的输出结果一致,但是方式一隐式的传入类型实参,不能直观的查看到调用的方法是泛型方法,不利于代码的阅读和维护,通常建议使用第二种方式调用泛型方法。

5. 类型通配符

类型通配符使用一个问号(?)表示,类型通配符可以匹配任何类型的类型实参。

下面使用一个案例演示类型通配符的使用。

(1)定义泛型类Student,声明私有变量info,定义有参构造方法和getter方法。

/**
 * 定义泛型类Student
 * @param <T>
 */
public class Student<T> {
    private T info;

    public Student(T info) {
        this.info = info;
    }
    public T getInfo() {
        return info;
    }
}

(2)定义测试方法,创建Student对象,分别传入String类型和Integer类型的类型实参,进行测试。

public class TestDemo {
    @Test
    public void test(){
        // 创建student对象,传入String类型的类型实参
        Student<?> student = new Student<String>("张三");
        System.out.println( student.getInfo()+":"+student.getInfo().getClass());
        // 创建student对象,传入Integer类型的类型实参
        student =new Student<Integer>(20);
        System.out.println( student.getInfo()+":"+student.getInfo().getClass());
    }
}
  • 不使用通配符的情况一

如果创建Student对象时,不使用类型通配符,而是使用指定的类型实参,会出现编译异常,具体如下图所示。

  • 不使用通配符的情况二

使用Object代替类型通配符?接收所有的类型,也会出现编译异常,

作为一个 Java 程序员,日常编程早就离不开泛型。泛型自从 JDK1.5 引进之后,真的非常提高生产力。一个简单的泛型 T,寥寥几行代码, 就可以让我们在使用过程中动态替换成任何想要的类型,再也不用实现繁琐的类型转换方法。

虽然我们每天都在用,但是还有很多同学可能并不了解其中的实现原理。今天这篇我们从以下几点聊聊 Java 泛型:

  • Java 泛型实现方式
  • 类型擦除带来的缺陷
  • Java 泛型发展史

Java 泛型实现方式

Java 采用**类型擦除(Type erasure generics)**的方式实现泛型。用大白话讲就是这个泛型只存在源码中,编译器将源码编译成字节码之时,就会把泛型『擦除』,所以字节码中并不存在泛型。

对于下面这段代码,编译之后,我们使用 javap -s class 查看字节码。

观察setParam 部分的字节码,从 descriptor 可以看到,泛型 T 已被擦除,最终替换成了 Object

“ps:并不是每一个泛型参数被擦除类型后都会变成 Object 类,如果泛型类型为 T extends String 这种方式,最终泛型擦除之后将会变成 String。

同理getParam 方法,泛型返回值也被替换成了 Object

为了保证 String param = genericType.getParam(); 代码的正确性,编译器还得在这里插入类型转换。

除此之外,编译器还会对泛型安全性防御,如果我们往 ArrayList<String> 添加 Integer,程序编译期间就会报错。

最终类型擦除后的代码等同与如下:

类型擦除带来的缺陷

作为对比,我们再来简单聊下 C# 泛型的实现方式。

**C#**泛型实现方式为「具现化式泛型(Reifiable generics)」,不熟悉的 C#小伙伴可以不用纠结具现化技术概念,我也不了解这些特性--!

简单点来讲,**C#**实现的泛型,无论是在程序源码,还是在编译之后的,甚至是运行期间都是切实存在的。

相对比与 C# 泛型,Java 泛型看起来就像是个「」泛型。Java 泛型只存在程序源码中,编译之后就被擦除,这种缺陷相应的会带来一些问题。

不支持基本数据类型

泛型参数被擦除之后,强制变成了 Object 类型。这么做对于引用类型来说没有什么问题,毕竟 Object 是所有类型的父类型。但是对于 int/long 等八个基本数据类型说,这就难办了。因为 Java 没办法做到int/longObject 的强制转换。

如果要实现这种转换,需要进行一系列改造,改动难度还不小。所以当时 Java 给出一个简单粗暴的解决方案:既然没办法做到转换,那就索性不支持原始类型泛型了。

如果需要使用,那就规定使用相关包装类的泛型,比如 ArrayList<Integer>。另外为了开发人员方便,顺便增加了原生数据类型的自动拆箱/装箱的特性。

正是这种「偷懒」的做法,导致现在我们没办法使用原始类型泛型,又要忍受包装类装箱/拆箱带来的开销,从而又带来运行效率的问题。

运行效率

上面字节码例子我们已经看到,泛型擦除之后类型将会变成 Object。当泛型出现在方法输入位置的时候,由于 Java 是可以向上转型的,这里并不需要强制类型转换,所以没有什么问题。

但是当泛型参数出现在方法的输出位置(返回值)的时候,调用该方法的地方就需要进行向下转换,将 Object 强制转换成所需类型,所以编译器会插入一句 checkcast 字节码

除了这个,上面我们还说到原始基本数据类型,编译器还需帮助我们进行装箱/拆箱。

所以对于下面这段代码来说:

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

对于①处,编译器要做就是增加基本类型的装箱即可。但是对于第二步来说,编译器首先需要将 Object 强制转换成 Integer,接着编译器还需要进行拆箱。

类型擦除之后,上面代码等同于:

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

如果上面泛型代码在 C# 实现,就不会有这么多额外步骤。所以 Java 这种类型擦除式泛型实现方式无论使用效果与运行效率,还是全面落后于 C# 的具现化式泛型

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

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

下面这段代码,从源码上两个 List 看起来是不同类型的集合,但是经过泛型擦除之后,集合都变为 ArrayList。所以 if语句中代码将会被执行。

ArrayList<Integer> li = new ArrayList<Integer>();
ArrayList<Float> lf = new ArrayList<Float>();
if (li.getClass() == lf.getClass()) { // 泛型擦除,两个 List 类型是一样的
    System.out.println("6666");
}

这样代码看起来就有点反直觉,这对新手来说不是很友好。

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

最后再举个例子,比如说我们需要实现一个泛型 List 转换成数组的方法,我们就没办法直接从 List 去获取泛型实际类型,所以我们不得不额外再传入一个 Class 类型,指定数组的类型:

public static <E> E[] convert(List<E> list, Class<E> componentType) {
    E[] array = (E[]) Array.newInstance(componentType, list.size());
    ....
}

从上面的例子我们可以看到,Java 采用类型擦除式实现泛型,缺陷很多。那为什么 Java 不采用 C# 的那种泛型实现方式?或者说采用一种更好实现方式?

这个问题等我们了解 Java 泛型机制的历史,以及当时 Java 语言的现状,我们才能切身体会到当时 Java 采用这种泛型实现方式的原因。

Java 泛型历史背景

Java 泛型最早是在 JDK5 的时候才被引入,但是泛型思想最早来自来自 C++ 模板(template)。1996 年 Martin Odersky(Scala 语言缔造者) 在刚发布的 Java 的基础上扩展了泛型、函数式编程等功能,形成一门新的语言-「Pizza」。

后来,Java 核心开发团队对 Pizza泛型设计深感兴趣,与 Martin 合作,一起合作开发的一个新的项目「Generic Java」。这个项目的目的是为了给 Java 增加泛型支持,但是不引入函数式编程等功能。最终成功在 Java5 中正式引入泛型支持

泛型移植过程,一开始并不是朝着类型擦除的方向前进,事实 Pizza 中泛型更加类似于 C# 中的泛型。

但是由于 Java 自身特性,自带严格的约束,让 Martin 在Generic Java 开发过程中,不得不放弃了 Pizza 中泛型设计。

这个特性就是,Java 需要做到严格的向后兼容性。也就是说一个在 JDK1.2 编译出来 Class 文件,不仅能在 JDK 1.2 能正常运行,还得必须保证在后续 JDK,比如 JDK12 中也能保证正常的运行。

这种特性是明确写入 Java 语言规范的,这是一个对 Java 使用者的一个严肃承诺。

“这里强调一下,这里的 向后兼容性指的是二进制兼容性,并不是源码兼容性。也不保证高版本的 Class 文件能够运行在低版本的 JDK 上。

现在困难点在于,Java 1.4.2 之前都没有支持泛型,而 Java5 之后突然要支持泛型,还要让 JDK1.4 之前编译的程序能在新版本中正常运行,这就意味着以前没有的限制,就不能突然增加。

举个例子:

ArrayList arrayList=new ArrayList();
arrayList.add("6666");
arrayList.add(Integer.valueOf(666));

没有泛型之前, List 集合是可以存储不同类型的数据,那么引入泛型之后,这段代码必须的能正确运行。

为了保证这些旧的 Clas 文件能在 Java5 之后正常运行,设计者基本有两条路:

  1. 需要泛型化的容器(主要是容器类型),以前有的保持不变,平行增加一套新的泛型化的版本。
  2. 直接把已有的类型原地泛型化,不增加任何新的已有类型的泛型版本。

如果 Java 采用第一条路实现方式,那么现在我们可能就会有两套集合类型。以 ArrayList 为例,一套为普通的 java.util.ArrayList,一套可能为 java.util.generic.ArrayList<T>

采用这种方案之后,如果开发中需要使用泛型特性,那么直接使用新的类型。另外旧的代码不改动,也可以直接运行在新版本 JDK 中。

这套方案看起来没什么问题,实际上C# 就是采用这套方案。但是为什么 Java 却没有使用这套方案那?

这是因为当时 C# 才发布两年,历史代码并不多,如果旧代码需要使用泛型特性,改造起来也很快。但是 Java 不一样,当时 Java 已经发布十年了,已经有很多程序已经运行部署在生产环境,可以想象历史代码非常多。

如果这些应用在新版本 Java 需要使用泛型,那就需要做大量源码改动,可以想象这个开发工作量。

另外 Java 5 之前,其实我们就已经有了两套集合容器,一套为 Vector/Hashtable 等容器,一套为 ArrayList/ HashMap。这两套容器的存在,其实已经引来一些不便,对于新接触的 Java 的开发人员来说,还得学习这两者的区别。

如果此时为了泛型再引入新类型,那么就会有四套容器同时并存。想想这个画面,一个新接触开发人员,面对四套容器,完全不知道如何下手选择。如何 Java 真的这么实现了,想必会有更多人吐槽 Java。

所以 Java 选择第二条路,采用类型擦除,只需要改动 Javac 编译器,不需要改动字节码,不需要改动虚拟机,也保证了之前历史没有泛型的代码还可以在新的 JDK 中运行。

但是第二条路,并不代表一定需要使用类型擦除实现,如果有足够时间好好设计,也许会有更好的方案。

当年留下的技术债,现在只能靠 Valhalla 项目来还了。这个项目从2014 年开始立项,原本计划在 JDK10 中解决现有语言的各种缺陷。但是结果我们也知道了,现在都 JDK14 了,还只是完成少部分目标,并没有解决核心目标,可见这个改动的难度啊。

  • 18
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值