泛型边界的问题

作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO

联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬

我们花了两篇文章讲述了泛型是什么以及有什么用:

  • 作用于编译期,由编译器解析,是一种兼具类型约束和自动转型的代码模板
  • 存入:约束存入的元素类型,将可能的类型错误提前到编译期
  • 取出:编译自动转型,消除手动强转,极大降低ClassCastException的风险

泛型只是程序员和编译器的约定。

我们可以通过泛型告诉编译器自己的意图

呐,我现在假定这个List只能存String,你帮我盯着点,后面如果不小心放错类型,在编译期报错提醒我。

当然,要想编译器帮我们约束类型,就必须按人家的规矩办事。就好比Spring明明告诉你默认读取resources/application.yml,你非要把配置文件命名为resources/config.yml当然就报错啦。

而泛型也有一套自己的规则,我们必须遵守这些规则才能让编译器按我们的意愿做出约束。

这些规则是谁定的呢?当然是JDK的那群秃子咯。

今天我们来学习泛型通配符。

在讲述通配符的语法规则时,我会尽量给出自己的理解,让大家更容易接受它们。另外需要说明的是,在泛型相关的文章里我们总是以List元素存入、取出举例子,是因为容器类是我们接触最多的,这样更好理解。实际上对于泛型类、泛型方法都是适用的,并不一定要是容器类。

简单泛型

JDK1.5以后,我们全面跨入泛型时代。

假设现在有一个需求:设计一个print方法打印任意类型的List。

你想显摆一下刚学的泛型,于是这样设计:

public class GenericClassDemo {

    public static void main(String[] args) {
        List<Integer> integerList = new ArrayList<>();
        print(integerList);
    }

    public static void print(List<Integer> list) {
		// 打印...
    }
}

咋一看没问题,但需求是打印任意类型的List。目前的print()只能接收List<Integer>,你传List<String>会报错:

你想了想,Object是所有对象的父类,我改成List<Object>吧:

悲剧,这下连List<Integer>都不行了。这是为什么呢?我们来分析一下原因。

实际编码时,常见的错误写法如下:

// 错误写法1:间接传递(通常发生在方法传参,比如将stringList传给print(List<Object> list))
List<String> stringList = new ArrayList<>();
List<Object> list = stringList;
// 错误写法2:直接赋值
List<Object> list = new ArrayList<String>();

总之,list引用和实际指向的List容器类型必须一致(赋值操作左右两边的类型必须一致)。

JDK推荐的写法:

// 比较啰嗦的写法
List<String> list = new ArrayList<String>();
List<Object> list = new ArrayList<Object>();
// 省略写法,默认左右类型一致
List<String> list = new ArrayList<>();
List<Object> list = new ArrayList<>();

我们在前面已经了解到,泛型底层其实还是Object/Object[],所以上面的几种写法归根到底都是Object[]赋值给Object[],理论上是没有问题的。

那么我们不禁要问:既然底层都支持了,为什么编译器要禁止这种写法呢?

我们从一正一反两个角度来思考这个问题。

正向思考

首先,Object和String之间确实有继承关系,但List<Object>和List<String>没有,不能用多态的思维考虑这个问题(List和ArrayList才是继承/实现关系)。

其次,讨论泛型时,大家应该尽量从语法角度分析。

对于:

List<Object> list = new ArrayList<String>();

左边List<Object>的意思是希望编译器帮它约束存入的元素类型为Object,而右边new ArrayList<String>()则希望约束存入的类型为String,此时就会出现两个约束标准,而它们却是对同一个List的约束,是自相矛盾的。

反向思考

如果上面的论述还是缺乏说服力,那么我们干脆假设List<Object> list = new ArrayList<String>()是合法的,又会发生什么呢?

先来看看数组是怎么处理类似问题的:

数组底层和泛型不同,泛型底层都是Object/Object[],而数组是真的分别创建了Object[]和String[],而且允许String[]赋值给Object[]。但这不是它骄傲的资本,反而是它的弱点,给了异常可趁之机:

public static void main(String[] args) throws Exception {
    // 直接往String[]存Integer会编译错误
    String[] strings = new String[3];
    strings[0] = "a";
    strings[1] = "b";
    strings[2] = 100; // COMPILE ERROR!

    // 但数组允许String[]赋值给Object[]
    Object[] objects = strings;
    // 这样就能通过编译了,但运行期会抛异常:ArrayStoreException
    objects[2] = 100;
}

数组允许String[]赋值给Object[],但却把错误被拖到了运行期,不容易定位。

同样的,如果泛型也允许这样的语法,那就和数组没区别了:

  • 首先,ls.add(new Object())成功了,那就意味着之前List<String>所做的约束都白费了,因为StringList中混入了别的类型
  • 其次,编译器仍会按String自动转型,会发生ClassCastException

这么看来,泛型强制要求左右两边类型参数一致真是明智的举措,直接把错误扼杀在编译期。

泛型的指向与存取

在之前介绍泛型时,我们观察的维度只有存入和取出,实际上泛型还有一个很重要的约束:指向。为什么之前不提这个概念呢?因为之前接触的泛型都太简单了,比如List<String>只能指向List<String>,也就是泛型左右两边类型必须一致,没什么好讲的。

另外,千万别以为List<Number>只能存Number类型的元素,只要是Number的子类型都是可以的。因为对于List<Number>来说,反正取出时会统一转向上转型为Number,很安全。

至此,我们完善了泛型最重要的两个概念:指向、存取。

对于简单泛型而言:

  • List<Number>指向:只能指向List<Number>,左右两边泛型必须一致(所以简单泛型解决不了print(List<???> list)的通用性问题)
  • List<Number>存入:可以存入Integer/Long/BigDecimal...等Number子类元素
  • List<Number>取出:自动按Number转(存在多态,不会报错)

后面学习通配符时,也请大家时刻保持清醒,多想想当前list可以指向什么类型的List,可以存取什么类型的元素。如果你觉得上面的推演太绕了,那么就记住:简单泛型的左右两边类型必须一致。

通配符

既然泛型强制要求左右两边类型参数必须一致,是否意味着永远无法封装一个方法打印任意类型的List?如何既能享受泛型的约束(防止出错),又能保留一定的通用性呢?

答案是:通配符。

我把List<T>、BaseDao<T>这样的称为简单泛型,把extends、super、?称为通配符。而简单泛型和通配符组合后又可以得到更为复杂的泛型,比如? extends T、? super T、?等。简而言之,通配符可以用来调节泛型的指向和存取之间的矛盾。

比如,有时我们需要list能指向不同类型的List(希望print()方法能接收更多类型的List)、有时我们又希望泛型能约束元素的存入和取出。但指向和存取往往不可兼得,具体要选用哪种泛型,需要根据实际情况做决定。

extends:上边界通配符

通配符所谓的上边界、下边界其实是对“指向”来说的。比如

List<? extends Number> list = new ArrayList<Integer>();

extends是上边界通配符,所以对于List<? extends Number>,元素类型的天花板就是Number,右边List的元素类型只能比Number“低”。换句话说,List<? extends Number>只能指向List<Integer>、List<Long>等子类型List,不能指向List<Object>、List<String>。

记忆方法: List<? extends Number> list = ...,把?看做右边List的元素(暂不确定,用?代替),? extends Number表示右边元素必须是Number的子类。

你可能会问:

之前简单泛型List<Object>不能指向List<String>,怎么到了extends这就可以了。这不扯淡吗?

其实换个角度就是,Java规定简单泛型左右类型必须一致,但有些情况又要考虑通用性,所以又搞出了extends,允许List<? extends Number>指向子类型List。

之前我们假设过,如果允许简单泛型指向指向子类型List,那么存取会出问题:

现在extends通配符放宽了指向限制(List<? extends Human>允许指向List<Chinese>),是否意味着extends通配符也会发生强转错误呢?

卧槽,我以为有什么高招,结果用了extends后直接不让存了。不过想想,确实是无奈之举。

public static void main(String[] args) {

    List<Integer> integerList = new ArrayList<>();
    integerList.add(1);

    List<Long> longList = new ArrayList<>();
    longList.add(1L);

    List<? extends Number> numberList = new ArrayList<>();
    numberList = 随机指向integerList或longList等子类型List;
    numberList.add(1);  // 由于无法确定numberList指向哪个List,所以干脆禁止add(万一指向integerList,那么add(1L)就不合适了,取出时可能转型错误)
}

还不是很明白?那就再举个例子:

但是对于取出,extends可不含糊:

public static void main(String[] args) {

    List<Integer> integerList = new ArrayList<>();
    integerList.add(1);

    List<Long> longList = new ArrayList<>();
    longList.add(1L);

    List<? extends Number> numberList = integerList; // 不管numberList指向integerList还是longList
    Number number = numberList.get(0);  // 取出来的元素都可以转Number,因为Long/Integer都是它子类
}

看到这,我们应该有所体会:对于泛型而言,指向和存取是两个不同的方向,很难同时兼顾。要么指向放宽,存取收紧;要么指向收紧,存取放宽。

extends小结:

  • List<? extends Number>指向:Java允许extends指向子类型List,比如List<? extends Number>允许指向List<Integer>
  • List<? extends Number>存入:禁止存入(防止出错)
  • List<? extends Number>取出:由于指向的都是子类型List,所以按Number转肯定是正确的

相比简单泛型,extends虽然能大大提高指向的通用性,但为了防止出错,不得不禁止存入元素,也算是一种取舍。换句话说,print(List<? extends Number> list)对于传入的list只能做读操作,不能做写操作。

super:下边界通配符

super是下边界通配符,所以对于List<? super Integer>,元素类型的地板就是Integer,右边List的元素类型只能比Integer“高”。换句话说,List<? super Integer>只能指向List<Number>、List<Object>等父类型List。

记忆方法: List<? super Integer> list = ...,把?看做右边List的元素(暂不确定,用?代替),? super Integer表示右边元素必须是Integer的父类。

super的特点是:

  • List<? super Integer>指向:只能指向父类型List,比如List<Number>、List<Object>
  • List<? super Integer>存入:只能存Integer及其子类型元素
  • List<? super Integer>取出:只能转Object

至此,我们发现Java同时满足了:

  • extends:指向子类型List
  • 简单泛型T:指向同类型List
  • super:指向父类型List

说完指向问题,我们再来探讨一下存取问题。思路还是一样,既然Java允许List<? super Integer>指向List<Number>等父类型,那么如何防止存取出错呢?

假设存在class Human implement Swimming, Speaking,那么Swimming和Speaking都是Human的父类/父接口。由于List<? super Human>可以指向父类型List,要么指向SwimmingList,要么指向SpeakingList。

public static void main(String[] args) {
    List<Swimming> swimmingList = new ArrayList<>();
    // 假设加入了很多实现了Swimming接口的元素,比如Dolphin(海豚)
    // swimmingList.add(dolphin)...

    List<Speaking> speakingList = new ArrayList<>();
    // 假设加入了很多实现了Speaking接口的元素,比如Parrot(鹦鹉)
    // speakingList.add(parrot)...

    List<? super Human> humanList = swimmingList / speakingList; // 指向随机的List
    
    humanList.add(...) // 是否应该允许存入 Parrot(鹦鹉)?

}

此时对于List<? super Human>,是否应该允许加入 Parrot(鹦鹉)呢?答案是最好不要。因为humanList的指向是不确定的,如果刚好指向的是swimmingList,那么list.add(parrot)显然是不合适的。

只有存入Human及其子类才是安全的:

介绍完super的存入,最后聊聊super的取出。由于List<? super Human>可以指向任意Human父类型的List,可能是SwimmingList,也可能是SpeakingList。这意味取出的元素可能是Swimming,也可能是Speaking,是不确定的,所以用Swimming或Speaking都不太合适。

那能不能强转为Human呢?答案是不行。假设humanList指向的是swimmingList,而swimmingList里存的是Shark、Dolphin、Human,此时list.get(0)得到的是 Shark implements Swimming,强转为Human显然不合适。

super小结

  • List<? super Human>指向:只能指向父类型List,比如List<Speaking>、List<Swimming>
  • List<? super Human>存入:只能存Human及其子类型元素
  • List<? super Human>取出:只能转Object

?:无界通配符

讲完最难的两个通配符,?就很简单了。它类似于List<? extends Object>,允许指向任意类型的List。

再分析一下存和取:

  • 由于指向的List不确定,并且这些List没有共同的子类,所以找不到一种类型的元素,能保证add()时百分百不出错,所以禁止存入。
  • 由于指向的List不确定,并且这些List没有共同的父类(除了Object),所以只能用Object接收。

通配符的使用场景

泛型本身比较复杂,能把简单的T用熟练的已经不多,更别说用上通配符了。但从语法本身来说,通配符就是为了让赋值更具通用性。原先泛型赋值只能是同类型之间赋值,不利于抽取通用方法。而使用通配符后,就可以在一定程度上开放赋值限制。

?是开放限度最大的,可指向任意类型List,但在对List的方法调用上也是限制最大的,具体表现在:

  • 入参和泛型相关的都不能使用(禁止存入)
  • 返回值和泛型相关的都只能用Object接收(只能强转为Object)

extends和super指向性各砍了一半,分别指向子类型List和父类型List,但方法使用上又相对开放了一部分:

  • extends不允许存入,但取出时类型稍微精确些,可以往边界类型转
  • super允许存入子类型元素,但取出时只能转为Object

所以如果要用到通配符,需要结合业务考虑,如果你只是希望造一个方法,接收任意类型的List,且方法内不调用List的特定方法,那就用?。而对于extends和super的取舍,《Effective Java》提出了所谓的:PECS(Producer Extends Consumer Super)

  • 频繁往外读取内容的(向外提供内容,所以是Producer),适合用<? extends T>:extends返回值稍微精确些,对调用者友好
  • 经常往里插入的(消耗数据,所以是Consumer),适合用<? super T>:super允许存入子类型元素

给大家举一个JDK对通配符的使用案例:

ArrayList中定义了一个addAll(Collection<? extends E> c)方法,我单独把这个方法拿出来:

class ArrayList<E> extends ... {
    
    ...

    public boolean addAll(Collection<? extends E> c) {
        Object[] a = c.toArray();
        int numNew = a.length;
        ensureCapacityInternal(size + numNew);  // Increments modCount
        System.arraycopy(a, 0, elementData, size, numNew);
        size += numNew;
        return numNew != 0;
    }
}

以Person为例,假设是List<Person> list = new ArrayList<>(),那么这个方法就变成了:

public boolean addAll(Collection<? extends Person> c) {
    Object[] a = c.toArray();
    int numNew = a.length;
    ensureCapacityInternal(size + numNew);  // Increments modCount
    System.arraycopy(a, 0, elementData, size, numNew);
    size += numNew;
    return numNew != 0;
}

此时,addAll()只能接收Person集合或者它的Person子类的集合,比如Student extends Person:

List<Person> personList = new ArrayList<>();

List<Student> studentList = new ArrayList();
personList.addAll(studentList)

为什么会选择extends呢?还是PECS原则,因为allAll()很显然是消费者场景,我更关心对参数的具体操作,而不怎么关心返回值(就是boolean提示操作成功与否)。这也是我日常使用通配符时的一个思路,PECS确实很实用。

最后,很多人会以为?等同于T,其实两者是有区别的。我们本质还是通过给T“赋值”来确定类型,只不过此时赋值给T的不再是某个具体的类型,而是某个“匹配规则”,帮助编译器确定向上、向下可以指向的List类型范围以及存取的元素类型限定。

强调

当你使用简单泛型时,首要考虑你想把元素规定为何种类型,顺便考虑子类型的存入是否会有影响(一般不会)。而如果要使用通配符,应该先考虑接收的范围,再考虑存取操作如何取舍(PECS原则)。

个人愚见是,通配符的出发点本来是为了解决指向问题,但开放指向后为了避免ClassCastException,不得已又对存取加了限制,实际开发时要灵活利用边界限制并结合实际需求选择合适的泛型。

提问:

  1. List list = new ArrayList(); 能添加各种类型的数据吗?
  2. List<Object> list = new Array<Integer>()会报错吗?为什么?
  3. 什么时候用?、extends、super?
  4. PECS是什么?
  5. List<?>和List<Object>的区别?

作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO

进群,大家一起学习,一起进步,一起对抗互联网寒冬

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/175105.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

40、Flink 的Apache Kafka connector(kafka source的介绍及使用示例)-1

Flink 系列文章 1、Flink 部署、概念介绍、source、transformation、sink使用示例、四大基石介绍和示例等系列综合文章链接 13、Flink 的table api与sql的基本概念、通用api介绍及入门示例 14、Flink 的table api与sql之数据类型: 内置数据类型以及它们的属性 15、Flink 的ta…

【开源】基于Vue和SpringBoot的智能教学资源库系统

项目编号&#xff1a; S 050 &#xff0c;文末获取源码。 \color{red}{项目编号&#xff1a;S050&#xff0c;文末获取源码。} 项目编号&#xff1a;S050&#xff0c;文末获取源码。 目录 一、摘要1.1 项目介绍1.2 项目录屏 二、功能模块2.1 数据中心模块2.2 课程档案模块2.3 课…

Rockchip平台rk3588源码下载编译(基于Android13)

Rockchip平台rk3588源码下载编译(基于Android13) 源码下载 下载地址 repo init --repo-url https://gerrit.rock-chips.com:8443/repo-release/tools/repo -u https://gerrit.rock-chips.com:8443/Android_T/manifests.git -m Android13.xml服务器镜像下载 repo init --rep…

PCS7中如何实现DB块变量的自动上传

问题:如何实现PCS7中DB块中变量的自动上传? 解答:PCS7下,所有CFC中的变量都通过编译的方式自动上传的OS项目中,针对自定义的DB块同样也可以通过设置相关属性自动上传的OS中,具体操作如下: 插入一个全局数据块。 注意:数据块号必须符合要求,可以参考PCS7中定义的预留DB…

5-2计算pi

#include<stdio.h> #include<math.h>int main(){int sign1;//数值的符号int count0;//累计计算循环的次数double pi0.0;double n1;//分母double term1.0;//当前项的数while(fabs(term)>1e-6){//fabs(trem)|term|pipiterm;nn2;sign-sign;termsign/n;count;}pipi*…

vue3的单组件的编写(二)--通过对比vue2来讲解

&#x1f42f; 单组件的编写(二) 主要讲了 &#x1f308; 响应式数据的变化 响应式数据是MVVM数据变驱动编程的特色&#xff0c; VUE的设计也是受 MVVM模型的启发&#xff0c;大部分开发者选择MVVM框架都是因为数据驱动编程比传统的事件驱动编程来的方便。而选择vue&#xff…

“index“ should always be multi-word

vue报错&#xff1a;Component name “index” should always be multi-word 分析&#xff1a;组件名要以驼峰格式命名&#xff0c;自定义的要以loginIndex.vue等这种方式命名&#xff0c;防止和html标签冲突&#xff0c;所以命名index.vue 会报错 解决&#xff1a;在.eslint…

PG数据中DBeaver上传csv文件作为数据表

DBeaver 是一个开源的数据库工具&#xff0c;还是蛮好用的&#xff0c;有时候需要我们上传数据做表&#xff0c;数据为CSV格式的&#xff0c;DBeaver本身自带有功能实现的。 可打开连着的数据库&#xff0c;找到模式&#xff0c;点到下面的表里&#xff0c;选择一个表直接导入…

已完结7个,再启动1个新项目,嘎嘎强!

作者&#xff1a;小傅哥 博客&#xff1a;https://bugstack.cn 沉淀、分享、成长&#xff0c;让自己和他人都能有所收获&#xff01;&#x1f604; 大家好&#xff0c;我是技术UP主小傅哥。 &#x1f490;又到了启动新项目的时候&#xff0c;死鬼开心嘛。小傅哥的星球&#xf…

Rust可空类型Option

文章目录 Option基础模式匹配unwrap Rust基础教程&#xff1a;初步⚙所有权⚙结构体和枚举类⚙函数进阶⚙泛型和特征⚙并发和线程通信⚙cargo包管理 Rust进阶教程&#xff1a;用宏实现参数可变的函数⚙类函数宏 Option基础 在一些编程语言中&#xff0c;允许存在空值&#xf…

Linux环境下安装部署单机RabbitMQ(离线)

摘要 本文档适用于在Linux系统下部署单体RabbitMQ&#xff0c;是在无网的情况下部署的。涉及的任何操作都是通过手动下载安装包然后上传到服务器上进行安装&#xff0c;因此也遇到一些问题&#xff0c;并在此文档中记录。 实际操作环境&#xff1a;Kylin V10&#xff0c;实际…

java疫情期间社区出入管理系统-计算机毕业设计源码21295

摘 要 信息化社会内需要与之针对性的信息获取途径&#xff0c;但是途径的扩展基本上为人们所努力的方向&#xff0c;由于站在的角度存在偏差&#xff0c;人们经常能够获得不同类型信息&#xff0c;这也是技术最为难以攻克的课题。针对疫情期间社区出入管理等问题&#xff0c;对…

并行与分布式计算 第8章 并行计算模型

文章目录 并行与分布式计算 第8章 并行计算模型8.1 并行算法基础8.1.1 并行算法的定义8.1.2并行算法的分类8.1.3算法的复杂度 8.2 并行计算模型8.2.1 PRAM (SIMD-SM)模型8.2.3 BSP (MIMD-DM)模型8.2.4LogP&#xff08;MIMD-DM&#xff09;模型 并行与分布式计算 第8章 并行计算…

单链表OJ题——11.随机链表的复制

11.随机链表的复制 138. 随机链表的复制 - 力扣&#xff08;LeetCode&#xff09; /* 解题思路&#xff1a; 此题可以分三步进行&#xff1a; 1.拷贝链表的每一个节点&#xff0c;拷贝的节点先链接到被拷贝节点的后面 2.复制随机指针的链接&#xff1a;拷贝节点的随机指针指向…

单链表OJ题——10.环形链表2

10.环形链表2 142. 环形链表 II - 力扣&#xff08;LeetCode&#xff09; /* 解题思路&#xff1a; 如果链表存在环&#xff0c;则fast和slow会在环内相遇&#xff0c;定义相遇点到入口点的距离为X,定义环的长度为C,定义头到入口的距离为L,fast在slow进入环之后一圈内追上slow…

webpack环境变量的设置

现在虽然vite比较流行&#xff0c;但对于用node写后端来说&#xff0c;webpack倒是成了一个很好的打包工具&#xff0c;可以很好的保护后端的代码。所以这块的学习还是不能停下来&#xff0c;接下来我们来针对不同的环境做不同的设置写好笔记。 引用场景主要是针对服务器的各种…

小米集团收入增长失速已久:穿越寒冬,雷军的路走对了吗?

撰稿|行星 来源|贝多财经 11月20日&#xff0c;小米集团&#xff08;HK:01810&#xff0c;下称“小米”&#xff09;发布了截至2023年9月30日的第三季度业绩公告。 财报显示&#xff0c;在智能手机出货量下行、平均售价下跌的背景下&#xff0c;小米逆势而上&#xff0c;实现…

向量数据库—加速大模型训练推理

目录 前言什么是向量数据库&#xff1f;向量数据库在大模型中扮演什么角色&#xff1f;Amazon OpenSearch Serverless向量引擎使用场景 其他向量数据库FaissMilvusChromaelasticsearchTencent Cloud VectorDB 向量数据库的应用场景图像和视频处理自然语言处理推荐系统搜索引擎人…

《程序员考公指南》:零基础到上岸的完整攻略 | 开源日报 No.82

mastodon/mastodon Stars: 44.2k License: AGPL-3.0 Mastodon 是一个免费、开源的社交网络服务器&#xff0c;基于 ActivityPub。用户可以在 Mastodon 上关注朋友并发现新朋友&#xff0c;并且可以发布链接、图片、文字和视频等内容。所有的 Mastodon 服务器都能互操作成为联邦…

Open3D (C++) 计算两点云之间的最小距离

目录 一、 算法原理二、代码实现三、结果展示本文由CSDN点云侠原创,原文链接。如果你不是在点云侠的博客中看到该文章,那么此处便是不要脸的爬虫与GPT。 一、 算法原理 Open3D中ComputePointCloudDistance函数提供了计算从源点云到目标点云的距离的方法,计算点云的距离。也…