深入理解Java泛型机制

说来惭愧,虽然平时经常会使用到一些泛型类,但是却一直没有深入地去了解过泛型机制。今天开始学习记录泛型机制相关的知识点。
由于本人水平有限,如果有理解错误的地方,欢迎指出。
参考书籍《Java核心技术卷I 基础知识》

在开始学习泛型程序设计之前,我们首先需要弄清楚一个最基本的问题:Java语言设计者为什么要在Java中引入泛型机制?加入泛型机制解决了什么问题?

带着这两个疑问,开始泛型机制的学习之路。

一、为什么要使用泛型程序设计

泛型程序设计引入的初衷是为了使开发者编写的代码被不同对象的类型重用。大家可能会觉得,这种描述也太抽象了吧!没关系,接下来咱们分析一个简单例子。

ArrayList这个容器类对Java开发者来说都太熟悉了,我们不仅可以用它来存储String、Integer这些系统定义好的类型的对象,也可以存储自定义的类型的对象,那么ArrayList是如何做到的呢?

在泛型机制出现以前,泛型程序设计是利用继承来实现的。Object类是所有类的基类,ArrayList通过维护Object引用的数组来达到存储不同类型对象的目的,实际上就是利用对象的向上转型。看一个简单的例子:

/**
 * 伪代码
 */
public class MyList {
    private Object[] elements;

    public Object get(int i) {
        return elements[i];
    }

    public void add(Object o) {
        elements[elements.length] = o;
    }
}

这样做确实可以实现存储多种类型的对象,但是可能会有两个问题:

  1. 当你从MyList 中获取一个值,必须要强制转换成我们需要的类型对象
MyList list = new MyList();
list.add("ABCD");
String s = (String) list.get(0);  //必须强制转换为String类型
  1. 我们可以向MyList 添加任何类型的对象,这样可能会导致获取某个值时强制转换发生错误
MyList list = new MyList();
list.add(123);
String s = (String) list.get(0); //类型转换失败

对于上面这些问题,泛型为我们提供了一个很好的解决方案:类型参数。ArrayList会使用一个类型参数来指定它存储的对象类型。

ArrayList<String> arrayList = new ArrayList<>();
arrayList.add("ABC");
// arrayList.add(123); //无法通过编译
String s = arrayList.get(0);

使用了类型参数之后,当调用get方法的时候,不需要进行强制转换,编译器就知道你要返回的是什么类型的对象。当你向其中插入错误的参数类型的时候,是无法通过编译的。很显然,出现编译错误比在运行的时候出现类型转换异常代价要小得多。

类型参数的魅力在于:使程序具有更好的可读性和安全性。

到这里为止,基本上已经明白了为什么要使用泛型程序设计。实际上,想要设计一个完美的泛型类并不容易,因为编写一个泛型类通常需要尽可能地预测出泛型类未来可能出现的应用场景,这也是泛型技术的一个难点,此处暂时不做深入讨论,咱还是老老实实由浅入深地学习吧。

二、泛型类

Java核心技术对泛型类的定义:一个泛型类就是具有一个或多个类型变量的类。

这样描述可能有点抽象,还是先看一个具体的例子吧!依然还是以ArrayList为例,首先来看看它是如何定义的。这里只是为了说明如何定义一个泛型类,所以只摘取ArrayList的部分代码。

public class ArrayList<E> {
    public boolean add(E e) {
        ……
    }
    public E get(int index) {
        ……
    }
    public E remove(int index) {
        ……
    }
}

定义泛型类首先需要引入类型变量,并且用<>括起来直接放在类名后面ArrayList<E>类型变量通常使用比较短的大写字母表示,例如K和V,可以用来代表key和value。

泛型类也可以有多个类型变量,多个变量之间用","隔开,例如public class ClassName<T, K>。类型变量通常用于指定方法的返回类型或者变量的类型,例如上面代码中get()方法返回值以及add()方法参数变量类型都指定是E。

定义一个泛型类当然是供开发者使用的啦!使用方法也很简单,只需要用具体的类型替换类型变量即可。例如用String替换类型变量E

ArrayList<String> arrayList = new ArrayList<>();
arrayList.add("XXX");
String s = arrayList.get(0);
arrayList.remove("XXX");

从表面上看,Java的泛型类有点类似于C++的模板类,但是这两种机制是有着本质区别的。

三、泛型方法

现在,我们已经学会了如何定义一个泛型类,接下来继续学习泛型方法的定义和使用。在开始学习之前,我们需要明确的一点是:泛型方法不仅可以定义在泛型类中,在普通类中也是可以定义泛型方法的。

/**
 * 泛型方法
 * @param t
 * @param <T>
 * @return
 */
private static <T> T getT(T t) {
    return t;
}

上面是一个简单的泛型方法定义示例。定义一个泛型方法很简单,我们只需要把类型变量放在修饰符后面,返回类型的前面即可。调用泛型方法也很简单,我们只需要提前指定泛型变量的类型,除此之外与普通方法的调用并不差别,标准的调用方法如下:

Test.<String>getT("aaaa");

通常情况下,我们不需要提前指定泛型变量的类型,编译器拥有足够的信息自动判断出泛型变量的具体类型。也就是说编译器会通过参数类型为String来判断出T一定是String类型,因此我们可以直接这样使用方法:

Test.getT("aaaa");

但是,如果我们无法为编译器提供足够的信息判断出泛型变量的类型,还是需要主动指定泛型变量的具体类型。

四、泛型接口

定义一个泛型接口非常简单:

public interface Person<T> {
    T name();
}

然后在实现这个泛型接口的时候指定泛型T的具体类型即可。

public class Student implements Person<String> {

    @Override
    public String name() {
        return "I'm Jack!";
    }
}

五、类型变量的限定

有时候,类或方法需要对类型变量加以约束。举个例子,假如某家公司需要招聘一名Android开发工程师,那么招聘的流程可能是像下面这样的:

/**
 * @param android   Android开发人员
 * @param <ANDROID> 从事Android开发的人群
 */
private static <ANDROID> ANDROID getAndroidDeveloper(ANDROID android) {
    return android;
}

上面这段代码意味着只要你能够完成Android开发工作,你都符合这家公司的招聘条件。几天之后,技术主管告诉HR,公司需要的是能够熟练使用Kotlin语言进行开发的技术人员,因此HR需要在招聘信息上对职位增加一条约束信息,也就是对类型变量ANDROID设置一个限定。限定格式:<ANDROID extends UseKotlin>

/**
 * @param android   Android开发人员
 * @param <ANDROID> 从事Android开发的人群
 */
private static <ANDROID extends UseKotlin> ANDROID getAndroidDeveloper(ANDROID android) {
    return android;
}

/**
* 使用Kotlin
*/
public interface UseKotlin { }

现在,将ANDROID限制为必须是实现了UseKotlin接口的类型,这也就意味着应聘者必须是能够使用Kotlin进行Android开发的技术人员。当然,也可以给变量类型设置多个限定,多个限定类型之间用"&"分隔,例如公司还要求应聘者具有三年工作经验。多限定格式:<ANDROID extends UseKotlin & ThreeYearExperience>

/**
 * @param android   Android开发人员
 * @param <ANDROID> 从事Android开发的人群
 */
private static <ANDROID extends UseKotlin & ThreeYearExperience> ANDROID getAndroidDeveloper(ANDROID android) {
    return android;
}

/**
 * 使用Kotlin
 */
public interface UseKotlin {

}

/**
 * 三年开发经验
 */
public interface ThreeYearExperience {

}

需要注意的是,如果类型变量拥有多个限定,那么限定中最多只能有一个类,并且这个类必须是限定列表中的第一个。例如现在要求应聘者拥有本科学历,本科学历是一个类。

/**
 * 本科学历
 */
public class Benke {

}

那么限定格式必须是<ANDROID extends Benke & UseKotlin & ThreeYearExperience>,Benke这个限定必须放在最前面,否则将无法通过编译。

六、泛型擦除

在Java虚拟机中,是没有泛型类型对象的,所有对象都属于普通类。

在C++模板中,编译器使用提供的类型参数来扩充模板,因此List<A>和 List<B>实际上会生成两套不同的代码。而 Java 中的泛型以不同的方式实现,编译器会对这些类型参数进行擦除和替换,因此类型 ArrayList<Integer> 和 ArrayList<String> 的对象共享相同的类ArrayList。

在C++中每个模板的实例化都会产生不同的类型,这一现象被称为“模板代码膨胀”,而java则不存在这个问题的困扰。Java虚拟机中没有泛型,只有基本类型和类类型,泛型会被擦除,一般会被修改为Object,如果有限制,例如<T extends Comparable> 会被修改为Comparable。

在C++中不能对模板参数的类型加以限制,如果程序员用一个不适当的类型实例化一个模板,将会在模板代码中报告一个错误信息。

这样描述可能还是有点抽象,还是举个简单的例子吧!

public class ArrayList<E> {
    public boolean add(E e) {
        ……
    }
    public E get(int index) {
        ……
    }
    public E remove(int index) {
        ……
    }
}

对于ArrayList类而言,它在虚拟机中以原始类型的形式存在。由于E是一个无限定的类型变量,所以在原始类型中直接用Object替换E。因此ArrayList<E> 的原始类型如下:

public class ArrayList {
    public boolean add(Object e) {
        ……
    }
    public Object get(int index) {
        ……
    }
    public Object remove(int index) {
        ……
    }
}

翻译泛型表达式

当程序调用泛型方法时,如果擦除返回类型,那么编译器会插入强制类型转换

继续分析下面这个例子:

ArrayList<String> arrayList = new ArrayList<>();
……
String s = arrayList.get(0);

我们现在已经知道,编译器在擦除get()方法后返回的是Object类型对象,然后自动插入String 的强制类型转换,也就是把这个方法调用分成两个步骤执行:

  1. 擦除方法返回类型并返回Object类型对象
  2. 将返回的Object对象强制转换成String类型

翻译泛型方法

与泛型表达式类似,类型擦除也会发生在泛型方法中。

我们来看一个简单的泛型方法:

/**
 * 泛型方法
 */
private static <T> T getT(T t) {
    return t;
}

擦除类型之后:

/**
 * 擦除类型后的泛型方法
 */
private static Object getT(Object t) {
    return t;
}

根据擦除规则,由于类型变量T无任何限定类型,因此直接被Object替换。

方法的类型擦除会带来两个复杂的问题。

1. 类型擦除与多态发生冲突
public static class Person<T> {

    private T name;

    public void setName(T name) {
        this.name = name;
    }

    public T getName() {
        return name;
    }
}

public class Student extends Person<String> {

    @Override
    public void setName(String name) {
        super.setName(name);
    }
}

对于上面这段代码,当Person类被擦除后,会存在另一个从Person类继承的setName方法,即

 public void setName(Object name)

这个时候Student类相当于拥有两个不同的setName方法,因为它们有不同类型的参数,Object和String。考虑下面这段代码:

Student student = new Student();
Person<String> person = student;
person.setName("Bob");

我们希望对setName的调用具有多态性,并调用最合适的那个方法。由于person引用Student对象,所以应该调用Student.setName,问题在于类型擦除与多态就发生了冲突。为了解决这个问题,需要编译器在Student 类中生成一个桥方法:

public void setName(Object name) {
    setName((String)name);
}

要想了解它的工作过程,还得从person.setName("Bob")语句的执行说起:
变量person已经声明为类型Person<String>,这个类型只有一个简单的方法setName(Object name),虚拟机用person引用的对象调用setName方法,也就是调用Student.setName(Object name),这就是我们上面所说的桥方法,桥方法内部又会调用Student.setName(String name)方法。

这样person最终调用的就是Student.setName(String name)方法,这正是我们所希望的结果。

2. 可能会产生参数类型相同、返回类型不同的方法

还是以上面的代码为例,如果Student类中也覆盖了getName方法,那么擦除类型中会存在两个getName方法:

public String getName() { }
public Object getName() { }

在Java代码中,具有相同参数类型的两个方法是不合法的,但是在虚拟机中,用参数类型和返回类型确定一个方法,因此虚拟机可以正确地处理上面这种情况。

总之,我们需要记住有关Java泛型转换的几个结论:

  • 虚拟机中没有泛型,只有普通的类和方法
  • 在擦除的类型中,所有的类型参数都用它们的限定类型替换
  • 使用桥方法来保持多态的正确性
  • 为保证类型安全,必要时插入强制类型转换

七、通配符类型

在实例化对象的时候,不确定泛型参数的具体类型时,可以使用通配符 ? 进行对象定义

  • 上边界限定通配符<? extends Object>
  • 下边界限定通配符<? super Object>

接下来通过几个简单的例子分析通配符具体的使用场景。

1、上边界限定通配符<? extends Object>

假设现在有两个类,EmployeeManager extends Employee,他们具有继承关系:

public class Employee {
    public String jobName() {
        return "Employee";
    }
}
public class Manager extends Employee{
    @Override
    public String jobName() {
        return "Manager";
    }
}

现在需要编写一个打印雇员信息的方法:

public static void printJob(List<Employee> employeeList){
    for (int i = 0; i < employeeList.size(); i++) {
        Employee employee =  employeeList.get(i);
        System.out.println("employee name = "+employee.jobName());
    }
}

这个方法可以成功打印出雇员信息,我们显然不能把List<Manager>参数传递给这个方法,这也就意味着如果我们想打印Manager相关的信息,需要再编写一个方法printJob(List<Manager> managerList)。通配符类型很好地解决了这个问题:

public static void printJob(List<? extends Employee> employeeList) { 
    …… 
}

这样printJob()方法不仅能够接收 List<Employee> 类型的参数,也能够接收 List<Manager> 类型的参数,因为Manager extends Employee。这就是上边界限定通配符的作用。

上边界限定通配符不允许对List<? extends Employee> employeeList进行任何更改操作,这是因为编译器只知道需要某个Employee的子类型,但不知道具体是什么类型。这也是引入有限定的通配符的关键之处,现在已经有办法区分安全的访问器方法和不安全的更改器方法。

2、下边界限定通配符<? super Employee>

下边界限定通配符与上边界限定通配符原理类似,只不过它表达的是相反的概念。

public class Person {
    public String jobName() {
        return "Person";
    }
}
public class Employee extends Person {
    public String jobName() {
        return "Employee";
    }
}

public static void printJob(List<? super Employee> employeeList) { 
    …… 
}

下边界限定通配符允许对List<? super Employee> employeeList进行更改操作,只要我们操作的是Employee的基类对象。但往外取元素只能使用Object对象接收,因为编译器只知道需要Employee的父类型,但不知道具体是什么类型,这样元素的类型信息就全部丢失。

3、限定通配符总结

带有超类型限定的通配符可以向泛型对象写入,带有子类型限定的通配符可以从泛型对象读取。

Joshua Bloch 称那些你只能从中读取的对象为生产者,并称那些你只能写入的对象为消费者。他建议:“为了灵活性最大化,在表示生产者或消费者的输入参数上使用通配符类型”,并提出了以下助记符:
PECS 代表生产者-Extends,消费者-Super(Producer-Extends, Consumer-Super)。

注意:如果你使用一个生产者对象,如 List<? extends Foo>,在该对象上不允许调用 add() 或 set()。但这并不意味着该对象是不可变的:例如,没有什么阻止你调用 clear()从列表中删除所有项目,因为 clear() 根本无需任何参数。通配符(或其他类型的型变)保证的唯一的事情是类型安全。不可变性完全是另一回事。

  • 如果你想从列表中读取T类型的元素,你需要把这个列表声明成<? extends T>,例如List<? extends Employee> employeeList。但是不能往该列表中添加任何元素,因为编译器只知道需要某个Employee的子类型,但不知道具体是什么类型。
  • 如果你想把T类型的元素加入到列表中,你需要把这个列表声明成<? super T>,比如List<? super Employee>。由于编译器无法保证从中读取到的元素的具体类型,所以只能用Object对象接收读取到的元素,这样会导致一定程度上的信息丢失。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,393评论 5 467
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,790评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,391评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,703评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,613评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,003评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,507评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,158评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,300评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,256评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,274评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,984评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,569评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,662评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,899评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,268评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,840评论 2 339

推荐阅读更多精彩内容