一文详解Java泛型设计

简介: 本文主要介绍泛型诞生的前世今生,特性,以及著名PECS原则的由来。

泛型的诞生


背景

在没有泛型之前,必须使用Object编写适用于多种类型的代码,想想就令人头疼,并且非常的不安全。同时由于数组的存在,设计者为了让其可以比较通用的进行处理,也让数组允许协变,这又为程序添加了一些天然的不安全因素。为了解决这些情况,Java的设计者终于在Java5中引入泛型,然而,正是因为引入泛型的时机较晚,为了兼容先前的代码,设计者也不得不做出一些限制,来让使用者(也就是我们)以难受换来一些安全。


优点

简单来说,泛型的引入有以下好处:

  • 程序更加易读
  • 安全性有所保证

以ArrayList举例,在增加泛型类之前,其通用性是用继承来实现的,ArrayList类只维护一个Object引用的数组,当我们使用这个工具类时,想要获取指定类型的对象必须经过强转:

import java.util.ArrayList;
import java.util.Date;

public class Main {
    public static void main(String[] args) {
        ArrayList list = new ArrayList();
        //强制类型转换
        String res = (String) list.get(0);
        //十分不安全的行为
        list.add(new Date());
    }
}

这种写法在编译类型时不会报错,但一旦使用get获取结果并试图将Date转换为其他类型时,很有可能出现类型转换异常,为了解决这种情况,类型参数应用而生。


类型参数

类型参数(Type parameter)使得ArrayList以及其他可能用到的集合类能够方便的指示虚拟机其包含元素的类型:

import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        ArrayList<String> objects = new ArrayList<>();
        objects.add("Hello");
    }
}

这使得代码具有更好的可读性,并且在调用get()的时候,无需进行强转,最重要的是,编译器终于可以检查一个插入操作是否符合要求,运行时可能出现的各种类型转换错误得以在编译阶段就被阻止。

import java.util.ArrayList;
import java.util.Date;

public class Main {
    public static void main(String[] args) {
        ArrayList<String> objects = new ArrayList<>();
        //we can do it like that
        objects.add("Hello");
        //wrong example
        objects.add(new Date());
    }
}

基本用法

一般来说,使用泛型工具类很容易,但是自己编写会相对困难很多,设计者必须考虑的相当周全才能使自己的泛型类库比较完善。


泛型类

泛型类是有一个或者多个类型变量的类,泛型类中的属性可以全都不是泛型,不过一般不会这样做,毕竟类型变量在整个类上定义就是用于指定方法的返回类型以及字段的类型,定义代码如下:

public class Animal<T> {
    private String name;
    private T mouth;
    
    public T getMouth(){
        return mouth;
    }
}

泛型类可以有多个类型变量:

public class Animal<T,U> {
    private String name;
    private T mouth;
    private U eyes;

    public T getMouth(){
        return mouth;
    }
}


泛型方法

泛型方法可以在普通类中定义,也可以在泛型类中定义,例如:

public class Animal<T,U> {
    private T value;
    public static <T> T get(T... a){
        return a[a.length-1];
    }
    public T getFirst(){
        return value;
    }
}

类型擦除

虚拟机没有泛型类型对象,也就是说,所有对象在虚拟机中都属于普通类,这意味着在程序编译并运行后我们的类型变量会被擦除(erased)并替换为限定类型,擦掉类型参数后的类型就叫做原始类型(raw type),正是因为有类型参数,所以下面的比较结果会为true:

image.png

这里的替换规则我个人理解为:“替换最近上界”,也就是无限定符修饰,则为顶级父类Object,如果有,则会替换为其指定的类型。最直观的示例如下,这就是类型擦除的体现:

image.png 

前面说过,泛型是在1.5才提出的,因此类型擦除的目的就是为了保证已有的代码和类文件依然合法,也就是向低版本兼容。这样做会带来几个问题:


1.类型参数不支持基本类型,只支持引用类型,这是因为泛型会被擦除为具体类型,而Object不能存储基本类型的值。

运行时你只能对原始类型进行类型检测:

image.png

2.不能实例化类型参数

不能实例化泛型数组,因为类型擦除会将数组变为Object数组,如果允许实例化,极易造成类型转换异常。


强制转换

在编写泛型方法调用时,如果擦出了返回类型,编译器会插入强制类型转换。例如下面的代码:

public class Main {
    public static void main(String[] args) {
        Animal<Integer,Double> pair = new Animal<>();
        Integer first = pair.getFirst();
    }
}

getFirst擦除类型后的返回类型是Object,编译器自动插入转换到Integer的强制类型转换,也就是说,编译器把这个方法调用转换为两条虚拟机指令:

  • 对原始方法的调用。
  • 将返回的Object类型强制转换为Integer类型。


方法桥接

子类重写父类方法时,必须和父类保持相同的方法名称,参数列表和返回类型。那么问题来了,如果按照之前的思路来讲,当泛型父类或接口的类型参数被擦除了,那么子类岂不是不构成重写条件?(参数类型很可能变化):

擦除前:

image.png

擦除后:

image.png

为了解决这个事情,Java引入了桥接方法,为每个继承/实现泛型类/接口的子类服务,以此保持多态性,字节码如下:

image.png

(图片来源:RudeCrab)

其实现原理,就是重写擦除后的父类方法,并在其内部委托了原始的子类方法,巧妙绕过了擦除带来的影响。不仅如此,就算不是泛型类,当子类方法重写父类方法的返回类型是父类返回类型的子类时,编译器也会生成桥接方法来满足重写的规则。


总结

Java核心技术中总结的非常到位:

  • 虚拟机中没有泛型,只有普通的类和方法。
  • 所有的类型参数都会替换为他们的限定类型。
  • 会合成桥接方法来保持多态。
  • 为保持类型安全性,必要时会插入强制类型转换。


变型(Variant)与数组

变型是类型系统中很重要的概念,主要有三个规则协变,逆变,和不变:

image.png

这三个类型可以解释为:假设有一个类型构造器f,它可以将已知类型转换为另一种类型,那么,有Animal父类和Dog子类。

  • 则f(Dog)是f(Animal)的子类,称为协变;
  • 则f(Dog)是f(Animal)的父类,成为逆变;
  • 则f(Dog)和f(Animal)没有任何关系;

而这个f(),可以是泛型,可以是数组,也可以是方法。


知道了以上概念,我们需要直接指出,泛型默认是不支持协变的,原因很简单,类型安全:如果允许协变,可能会造成类型转换异常。而数组支持协变,正如文章开头所说,就是设计者希望可以对数组进行比较通用的处理,防止方法为每一种类型编写重复逻辑,这样做也确实导致为数组赋值元素时可能会抛出运行时异常ArrayStoreException,这是一个很危险的坑。Effective Java中直接指出允许数组协变是Java的缺陷,我想这也是要多用列表而不用数组的原因之一。


泛型协变—PECS原则

为了让泛型也支持多态,让其支持协变是很必要的,最常用的场景:我们想让一个方法接受一个集合,并做统一的逻辑处理,如果泛型不支持协变,这种很基本的需求都会成为奢望。


上界

让泛型支持协变很简单,只需要使用? extends的组合即可实现,?称为通配符,这种组合方式声明了类型的上界,标识泛型可接受的类型只能是指定类型或是其子类。在这里,ElectricVehicle和Diesel均是继承自Car。

image.png


为了杜绝可协变后出现类似于数组一样的安全隐患,泛型设计采用了“一刀切”的方式,即:只要声明了上界,除了null之外,一律不准传入给泛型。说白了,就是只读不写,这样当然可以保证安全性。

image.png

到这里可以顺便说一下集合的设计,可以注意到集合中只有add方法是泛型参数,而其余方法并不是,为何要这样设计,为何不把其余方法的参数类型也改为E?其原因就是在于,如果将contains和remove改为E,那么声明上界之后,调用这两个方法会引发编译错误,然而这两个方法均为类型安全方法,自然不可声明为E,add作为很明显的写方法,自然也需要用E作为参数类型,到这里,不得不感叹类库设计者的想法独到。

image.png


下界

对应协变的上界,自然有逆变的下界,很自然的,我们使用? super的组合来声明一个泛型的下界,来表示可以接收本类型或者其父类型。

image.png

而且相对应的,正是由于最多只能接收父类型泛型,所以不会有类型转换失败的风险,因此逆变可以添加元素,不过添加的元素类型只能是指定类型和其子类,切记不要把添加元素和接收泛型类参数给弄混了。


有利有弊,虽然逆变没有了协变只读不写的限制,但是读取元素时将不能确定具体的类型,只能用Object来接收:

image.png


PECS

正如上面对上下界的描述,我们已经明白了大致的应用场景,当我们需要只读不写时,就用协变,只写不读,就用逆变。又想读又想写,我们应该指明准确的泛型类型。


注明的PECS原则就总结了这一点,PECS(Prodcuer extends Consumer super),也就是说,作为元素的生产者Prodcuer,要用协变,支持元素的读取,而作为消费者Consumer,要支持逆变,支持元素的写入。

image.png

Collections的copy方法就非常好的印证了这一点:

image.png





来源  |  阿里云开发者公众号
作者  |  江归


相关文章
信道建模流程 | 带你读《大规模天线波束赋形技术原理与设计 》之二十八
本节将详细介绍衰落信道的整体建模流程,内容上与 3D 信道模 型 3GPP TR36.873 7.3 节和 3GPP TR38.901 的 7.5 节对应。两者在内容上大体相同,前者的目标为6GHz以下的信道建模(记为模型1),后者为0.5~100GHz 的信道建模(记为模型 2)。对于 6GHz 以下的信道建模,两者均可以使用, 在下文的描述中,两者不同的地方均会列出。
信道建模流程  | 带你读《大规模天线波束赋形技术原理与设计 》之二十八
|
2月前
|
机器学习/深度学习 数据采集 算法
大模型应用:K-Means/LDA + 千问大模型:无监督文本自动打标完整方案.85
本文介绍“聚类算法+大模型”无监督自动打标方案:先用K-Means/LDA对海量无标签文本(如电商评论、客服工单)自动分组,再由大模型为每簇生成可理解的业务标签与语义解释,实现从“类1/类2”到“物流慢”“价格争议”等高价值洞察的跃迁,显著降本增效。
382 6
采用zookeeper的EPHEMERAL节点机制实现服务集群的陷阱
在集群管理中使用Zookeeper的EPHEMERAL节点机制存在很多的陷阱,毛估估,第一次使用zk来实现集群管理的人应该有80%以上会掉坑,有些坑比较隐蔽,在网络问题或者异常的场景时才会出现,可能很长一段时间才会暴露出来。
15119 1
|
存储 人工智能 算法
图与树的遍历:探索广度优先、深度优先及其他遍历算法的原理与实现
图与树的遍历:探索广度优先、深度优先及其他遍历算法的原理与实现
1111 0
|
前端开发 开发者
HarmonyOS实战:自定义时间选择器
在鸿蒙开发中,官方提供的默认时间选择器可能无法满足特定需求。本文分享了自定义时间选择器的实现过程:通过 TextPicker 控件实现年月日及时分的选择,支持默认选中当前时间、精确到时分,并注意闰年计算与日期格式处理。代码中使用 Promise 处理耗时的日期计算,确保显示和逻辑正确。总结指出,尽管看似简单,但需关注时间计算、格式化等细节。快动手试试吧!
476 1
|
开发者
鸿蒙next版开发:ArkTS组件通用属性(图形变换)
在HarmonyOS 5.0中,ArkTS提供了强大的图形变换功能,支持组件的旋转、缩放和平移操作,增强用户界面的视觉效果和交互体验。本文详细解读了ArkTS中图形变换的通用属性,并提供了示例代码,包括基础变换、组合变换和动画效果的应用。通过这些示例,开发者可以轻松实现复杂的视觉效果和动态用户界面。
944 1
|
Cloud Native 前端开发 Java
技术人生第5篇——浅谈如何成为技术一号位?
认清每个人自己在日常工作中的思维定式非常重要,有助于转变自己对很多事情的认知,而这种转变也会从根本上带来行为上的变化。也就是说,可以通过理论分析和实践,来共同完成对个人实际生活的影响。今天这篇文章,我们会先讨论业务研发同学,或者说大多数的业务研发同学的自我认知是什么,再看下这种普遍的自我认知之内,是否已经存在着大家视而不见的思维定式;然后再讨论思维定式产生的原因是什么,如何突破这种由认知不到位而导致的自我束缚;最后再探讨业务研发同学应该存在什么样的认知,如何通过实践完成自己从普通开发到技术一号位的角色转变。
9021 84
技术人生第5篇——浅谈如何成为技术一号位?
|
Java API Spring
【异常】Feign 调用api模块直接进入fallback的问题解决办法
【异常】Feign 调用api模块直接进入fallback的问题解决办法
904 0
|
机器学习/深度学习 PyTorch 算法框架/工具
深度学习实践篇 第五章:模型保存与加载
简要介绍pytorch中模型的保存与加载。
757 0