• 泛型边界的问题


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

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

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

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

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

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

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

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

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

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

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

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

    简单泛型

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

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

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

    1. public class GenericClassDemo {
    2. public static void main(String[] args) {
    3. List<Integer> integerList = new ArrayList<>();
    4. print(integerList);
    5. }
    6. public static void print(List list) {
    7. // 打印...
    8. }
    9. }

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

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

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

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

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

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

    JDK推荐的写法:

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

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

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

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

    正向思考

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

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

    对于:

    List list = new ArrayList();

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

    反向思考

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

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

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

    1. public static void main(String[] args) throws Exception {
    2. // 直接往String[]存Integer会编译错误
    3. String[] strings = new String[3];
    4. strings[0] = "a";
    5. strings[1] = "b";
    6. strings[2] = 100; // COMPILE ERROR!
    7. // 但数组允许String[]赋值给Object[]
    8. Object[] objects = strings;
    9. // 这样就能通过编译了,但运行期会抛异常:ArrayStoreException
    10. objects[2] = 100;
    11. }

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

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

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

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

    泛型的指向与存取

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

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

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

    对于简单泛型而言:

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

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

    通配符

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

    答案是:通配符。

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

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

    extends:上边界通配符

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

    List list = new ArrayList();

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

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

    你可能会问:

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

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

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

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

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

    1. public static void main(String[] args) {
    2. List<Integer> integerList = new ArrayList<>();
    3. integerList.add(1);
    4. List<Long> longList = new ArrayList<>();
    5. longList.add(1L);
    6. List<? extends Number> numberList = new ArrayList<>();
    7. numberList = 随机指向integerList或longList等子类型List;
    8. numberList.add(1); // 由于无法确定numberList指向哪个List,所以干脆禁止add(万一指向integerList,那么add(1L)就不合适了,取出时可能转型错误)
    9. }

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

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

    1. public static void main(String[] args) {
    2. List<Integer> integerList = new ArrayList<>();
    3. integerList.add(1);
    4. List<Long> longList = new ArrayList<>();
    5. longList.add(1L);
    6. List<? extends Number> numberList = integerList; // 不管numberList指向integerList还是longList
    7. Number number = numberList.get(0); // 取出来的元素都可以转Number,因为Long/Integer都是它子类
    8. }

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

    extends小结:

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

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

    super:下边界通配符

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

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

    super的特点是: