深入浅出 Java 泛型之(一):前生今世

1,078 阅读7分钟

本文出自伯特的《LoulanPlan》,转载务必注明作者及出处。

对于 Java 开发者而言,泛型是必须掌握的知识点。泛型本身并不复杂,但由于涉及的概念、用法较多,所以打算通过系列文章去讲解,旨在全面、通俗的介绍泛型及其使用。如果你是初学者,可以通过本文了解泛型,并满足企业级开发的需求;如果你对泛型已有一定的了解,可以通过本文进行巩固,加深对泛型的理解。

作为系列文章的第一篇,本文将带你了解 Java 泛型的前生今世,看看泛型的诞生之于开发者的意义。

1. 泛型之前:通用数据类型

对于集合框架中的 List 及其实现类,想必大家都不陌生。同时,泛型诞生之后即被广泛运用于 Java 集合框架。所以,我们就以 List 作为观察对象,看看在泛型诞生之前,Oracel 的工程师们是如何进行设计的。

摘自 JDK 1.4 的 List.java 源码:

public interface List extends Collection {
    //添加元素
    boolean add(Object o);
    //查询元素
    Object get(int index);
}

可以看出 List 是通过 Object 类型管理的数据,如此设计的好处显而易见:

具备通用性,因为所有的类都是 Object 的直接或间接子类,所以适用于任意类型的对象

同时,弊端也是不可忽视的。下面就通过使用 List 存、取数据来看看都有哪些问题:

//构造对象
List list = new ArrayList();
//存
list.add(1);
list.add("2");//①
//取
int num1 = (int)list.get(0);
int num2 = (int)list.get(1);//②

由于使用 Object,编译器无法判断存、取数据的实际类型,导致上述几行代码暴露出许多问题:

  1. 无法限制存储数据类型,不够健壮:在 ① 处可以添加 String 类型数据,显然是脏数据;
  2. 取出时强转代码冗余,可读性差:取出数据时必须显示强转为 int 类型;
  3. 由于 ① 处在编译时无法检查出错误,导致 ② 处的强转在运行时引发 ClassCastException,安全性低;

问题还真不少!

2. 泛型萌芽:数据类型的包装

上述问题究其根本,是无法限制数据类型引起的。也就是说,如果我们基于 List 包装出相应类型的 XxxList,就可以解决问题。

举个例子,包装用于存储 Integer 数据类型的 IntegerList

public class IntegerList {
    List list = new ArrayList();

    //限制外部只能添加整型数据
    public boolean add(Integer data) {
        return list.add(data);
    }

    //内部进行强转,调用者可以直接赋值为整型
    public Integer get(int index) {
        return (Intrger)list.get(index);
    }
}

包装内依然使用 List 管理数据,但我们对外暴露的接口限制了数据类型,规避了直接访问 List 的接口可能引发的问题。

下面一起来看看如何使用包装类:

//构造对象
IntegerList list = new IntegerList();
//存
list.add(1);
list.add("2");//①
//取
int num1 = list.get(0);

怎么样,一个包装类轻松解决问题:

  1. 在 ① 处试图添加 String 类型数据,会在编译期进行类型检查时报错,导致编译失败;
  2. 在取出数据时,无需重复强转,直接赋值给 int 类型的数据;
  3. 因为限制了 add() 方法的参数类型,所以不用担心在 get() 时内部强转会引发异常。

简直完美。同理,可以包装出一系列 StringList, LongList,以及自定义数据的集合包装类 PeopleList, DataList 等。

但人无完人,类亦无完类啊。包装类虽解决了编码上的数据类型问题,可在工程效率方面却捉襟见肘:

  • 复用性低:每一个包装类只适用于一种数据类型,无法复用核心逻辑;
  • 维护成本高:复用性低必然会增加后期维护的成本。

仍需努力!

3. 泛型登场:参数化类型

虽然包装类存在缺陷,但其对于理解泛型思想是很有意义的。不知 Oracle 的工程师们,是否受此启发设计出的泛型呢?

如果你试着多写几个数据类型的包装类,就会发现各包装类之间的区别和联系:

  1. 区别:数据类型不同;
  2. 联系:操作数据的方法相同,即核心算法逻辑是一致的。

既然如此,如果我们能够弱化数据类型,使其不再受具体的业务场景限制,就可以做到专注于通用的算法逻辑,从而提升复用性。

那么,如何弱化数据类型呢?有人说了,使用 Object 就很弱化啊。咳,麻烦你从头开始看。。。

JDK 5(即 JDK 1.4 之后的 1.5) 引入了 泛型(Generic Type) 的概念,其通过“参数化类型”实现数据类型的弱化,使得程序内部不需要关心具体的数据类型,而是让业务在调用时作为参数传入。泛型将传入的数据类型传递给编译器,这样编译器就可以在编译期间进行类型检查,确保程序的安全性,并且可以插入相应的强转以避免开发人员显示强转。

上面这段话值得多读几遍,尤其是“参数化类型”可以说是泛型的核心所在。如果还有点蒙没关系,继续往下看。

Java 中方法的声明大家都不陌生,如果某个方法需要对整数进行加法运算,我们可以在声明方法时添加整数类型的参数,外部调用时必须传入相应的整数数据。这里,将数据抽象为参数的过程,可以理解为“参数化实参”。

那么,“参数化类型”可以理解为是“参数化数据”的进一步抽象:将数据类型抽象为参数,即类型形参。如此一来,数据类型可以像形参一样,在调用时动态指定。如此,就达到了使用通用逻辑动态处理不同数据类型的目的。

下面,我们通过 JDK 源码中有关泛型的运用来巩固这一概念。

4. 泛型的简单运用

泛型诞生后,即对 Java 集合框架进行了大刀阔斧的修改,引入了泛型。下面仍然以 List 作为观察对象,看看泛型带来了哪些改变。

//摘自 JDK 5 版本的 List 源码
public interface List<E> extends Collection<E> {
    //添加元素
    boolean add(E e);
    //指定下标查询元素
    E get(int index);
    //指定下标移除元素
    E remove(int index);
}

可以看出,List<E> 通过在类 List 后追加 <> 标识其为泛型类,包含的元素 E 即“类型形参“,以支持开发者在使用时指定实际类型。下面看看在代码中如何使用泛型 List

//构造对象
List<Integer> list = new ArrayList();
//存
list.add(1);
list.add("2");//①
//取
int num1 = list.get(0);
int num2 = list.get(1);

首先,我们构造了 List<Integer> 类型的对象,所以在运行时 List<E> 中的形参会被当做 Integer 去出处理,我们可以想象出一个虚拟的 List 类:

public interface List extends Collection<E> {
    boolean add(Integer e);
    Integer get(int index);
    Integer remove(int index);
}

接下来,和文章开头一样,我们对集合进行了相关操作,可以看出使用泛型解决了我们之前遇到的所有问题:

  1. ① 处的代码在编译期间会出错:由于声明的是 Integer 类型的 List,显然无法接收 String 类型的数据。
  2. 从虚拟 List 可以知道,取出元素时不需要显示强转,自然也不会在运行时抛出异常。

通过对泛型 List 的简单运用,可以看出引入泛型后集合不失普适性,依然可以针对各种类型对象进行操作。同时,泛型为集合框架增加了编译时类型安全性,并避免了在使用过程中的强转操作。

5. 总结

有关泛型的前生今世就介绍到这儿了。至此,我们通过相关示例一步步引出了泛型,了解了泛型诞生前后在一些编码场景下的差异。最后还通过实例简单使用了泛型,但泛型的运用远不止如此...

下一篇将进一步介绍泛型的各种运用场景,掌握泛型的用武之地。