V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
rqxiao
V2EX  ›  Java

对 Java 之泛型通配符 ?extends T 的认知 和 ? super T 一些疑惑

  •  
  •   rqxiao · 119 天前 · 2689 次点击
    这是一个创建于 119 天前的主题,其中的信息可能已经有所发展或是发生改变。
    首先是 <? extends T>

    可以是
    List<? extends Fruit> flist =new ArrayList<Apple>();
    也可以是
    List<? extends Fruit> flist2 =new ArrayList<Banana>();

    List<? extends Fruit> list 并不是说能添加 Fruit 子类的任意类型到 list 中。而是说<?extends Fruit>通配符实际上代表一种特定的类型,它只是表示 “某种特定的类型,但是 flist 没有指定“。就比如 flist2 里面声明的是 Banana 泛型,flist 声明的是 Apple 泛型 。
    所以说 List<? extends Fruit> list 不能添加。因为其实从 List<? extends Fruit> 来看,这个集合会存什么样的 Fruit 子类它自己是未知的

    但是 Apple a = (Apple)flist.get(0) 是可以的。在这个 List 中,不管它实际的类型到底是什么,但肯定能转型为 Fruit ,都是继承了 Fruit 的类,所以编译器允许返回 Fruit 。





    其次是 ? super T 的疑惑

    a5 继承了 a4 ,a4 继承了 a3 ,a3 继承了 a2. a2 继承了 a1
    ? super T 可以按照下面这么写
    List<? super a5> a5List = new ArrayList<a5>();
    List<? super a5> a4List = new ArrayList<a4>();
    List<? super a5> a3List = new ArrayList<a3>();
    List<? super a5> a2List = new ArrayList<a2>();
    List<? super a5> a1List = new ArrayList<a1>();

    我们可以知道向这些 List 添加一个 a5 或者其子类型的对象是安全的,这些对象都可以向上转型为 Apple 。

    但是不能添加 a5 的父类。




    不能添加 a5 的父类原因是这样吗?

    List<? super a5> a3List ,里面实际存放的 都是 a3 类型。
    a3List 不能添加 a2 的 ,因为 a2 类 并不能向下转成 a2 的子类 a3 ,
    所以说 List<? super a5> 并不能添加 a5 的父类??
    22 条回复    2022-06-13 23:25:15 +08:00
    ianEros
        1
    ianEros  
       119 天前
    虽然没看懂问题。。但是我知道的事同一个 list 是可以反射放入任意类,因为会范型擦除,jvm 并不关注里面放了什么
    chendy
        2
    chendy  
       119 天前   ❤️ 1
    理解没问题
    extends 和 super 其实都不知道具体的类型是什么
    extends T 不知道子类是什么,于是不能写入,但是可以用 T 读取
    super T 不知道父类是什么,于是不能读(只能用 Object 读),但是可以写入 T ,因为 T 肯定是兼容的类型
    rqxiao
        3
    rqxiao  
    OP
       119 天前
    List<? super a5> 可以引用 ArrayList ( 或者其它 List 的 子类 ) 的对象,这个对象包含的元素类型是 a5 的父类型 ( 包含 C 本身)的一种。


    但是不能添加 a5 分类。



    不能添加 a5 分类 原因,其实也可以用这个例子说明?
    List<a3> a3_List = new ArrayList<a3>();
    a3_List .add(new a4());
    a3_List .add(new a3());
    a3_List .add(new a2());// error

    所以最终说 List<? super T> 并不能添加 T 的父类?
    Mistwave
        4
    Mistwave  
       119 天前 via iPhone   ❤️ 1
    都是 type bound
    一个协变( covariance ) 一个逆变( contravariance )
    Leviathann
        5
    Leviathann  
       119 天前
    协变的容器,元素的子类型关系和容器的子类型关系是相同的
    逆变是相反的
    这里的容器是指类型容器

    而直接的 T 则是不变,容器类型的子类型关系和元素类型无关
    nothingistrue
        6
    nothingistrue  
       119 天前
    你这个疑问,起始不止跟泛型有关,还跟 Base base = new AnyChild() 多态有关。

    如果没有泛型(或者说完美的泛型),应该是这样的多态,List<Fruit> fruits = new ArrayList(); ruits.add(new Apple()); fruits.add(new Banana);。但是 Java 的泛型不是完美的泛型,用得是编译擦除,所以实现不了上面的效果。因为泛型仅存在于编译时,运行时被擦除了,运行时的 fruits 就是个 List<?>,你给他 add 啥都可以。
    nothingistrue
        7
    nothingistrue  
       119 天前
    ? extends T 跟 ? super T 都是编译擦除法下实现泛型的折衷方法,并不完美。另外它们的实际含义不是 T 的子类、T 的父类。
    nothingistrue
        8
    nothingistrue  
       119 天前
    关于通配符,我去拔了下我的笔记,发现了这句话:
    如果你想从一个数据类型里获取数据,使用 ? extends 通配符
    如果你想把对象写入一个数据结构里,使用 ? super 通配符
    如果你既想存,又想取,那就别用通配符。

    ? extends 、? super 这里是通配符,不是继承关系,前者是向上造型通配符,后者是向下造型通配符。
    nothingistrue
        9
    nothingistrue  
       119 天前
    List<? extends Fruit> upper , 向上造型通配符,可以使用其“方法返回类型限定”的方法(例如 get 方法),不能使用其“方法参数类型限定”的方法(例如 add 、set 方法)。可以这样使用 upper.get(),得到的一个可以向上造型到 Fruit 的对象——即 Fruit 或者它的子类的对象。不能使用 upper.add (someObjectInstanceFruit)。

    List<? super Apple> downer ,向下造型通配符,可以使用其“方法返回类型限定”的方法(例如 get 方法), >>>但是只能得到 Object 类型的返回类型<<< ;可以使用其“方法参数类型限定”的方法(例如 add 、set 方法),但参数类型只能是类型参数(或者其 >>>子类<<< )。downer.get() 的返回类型只能是 Object 。 可以使用 downer.add ( someObjectInstanceApple )。

    另外需要注意一点,通配符是用来限定声明变量赋值的,不是用来限定声明完成之后的使用的,上面说的两点其实是被动的的使用限制,而不是主动的类型限定。这正的限定是:
    List<? extends Fruit> 限定了只能将 new ArrayList<Fruit 的子类>() 赋值给它。
    List< ? super Apple> 限定了只能将 new ArrayList<Apple 的父类>() 赋值给它。
    bonjourcs
        10
    bonjourcs  
       119 天前
    可以这样理解,不妨用 a1 > a2 表示 a1 是 a2 父类,在你的描述里,应该有 a1 > a2 > a3 > a4 > a5 。

    你定义一个 List<? super a5> 的容器,本质上想有一个“存放 a5 和 a5 父类的容器”,Java 里有:List<? super a1> 是 List<a5> 的子类,这种现象叫做逆变。那么就有:
    List<? super a5> > List<a5>
    List<? super a5> > List<a4>
    ...
    List<? super a5> > List<a1>

    结合上面的定义这个 List<? super a5> 可能是 List<a1>、List<a2>、List<a3>、List<a4>、List<a5> 中的任意一种,编译器不能替你决定到底是哪一个,因此它只允许 List<? super a5> 存入 a5 类型或者 a5 的子类。
    bonjourcs
        11
    bonjourcs  
       119 天前
    typo:

    "List<? super a1> 是 List<a5> 的子类" -> "List<? super a5> 是 List<a1> 的父类"。
    unco020511
        12
    unco020511  
       119 天前
    你把?理解为一个确定的类型你就能想明白了.假设 T extends A extends B, 那么 List<? super T> 可以是 List<B>或者 List<A>,如果是 List<A>,你能往里面装 B 的对象吗,并不能,但你绝对可以往里装 T
    acidsweet
        13
    acidsweet  
       119 天前
    我觉得上面有几个人说的比较触及原理了,主要是要理解协变和逆变,其实 java 上这个不太直观,你看看 kotlin 上泛型的设计,用关键字`in`和`out`,这个描述更加精准
    GuuJiang
        14
    GuuJiang  
       119 天前
    这可以算得上是一个月经问题了,和其他另外几个问题一样,属于里面有一个弯转过来了就一切迎刃而解,转不过来就始终似懂非懂
    GuuJiang
        15
    GuuJiang  
       119 天前   ❤️ 5
    @GuuJiang 不小心按错发出来了,接着写
    参见我在 https://v2ex.com/t/790199#reply32 里的回答,这里我多花点篇幅争取把这个问题一次性讲清楚
    首先准备点开胃菜,当我们有一个类型为 A 的引用 a 和一个类型为 B 的对象 b ,什么情况下 a = b 这个语句是合法的?答案就是当 b instanceof A 的时候,这就是 OOP 的几大基石之一的里氏代换原则,也就是说当需要一个类型 A 的场合,类型 A 及其子类都是可以接受的,这也就是为什么 instanceof 运算符的反射版本名字叫作 isAssignableFrom
    对于简单类型来说,任给两个类型 A 和 B ,判断 A 和 B 之间是否具有 instanceof 关系是很简单的,直接看两个类的定义就行,如果类的定义里有直接或者间接的 extends 或者 implement 关系,则具有 instanceof 关系,否则不具有
    但是到了范型的世界里,问题就开始变得复杂起来了,如果 B 是 A 的子类,那 List<A>和 List<B>这两个类型里,谁是谁的子类?答案是不具有 instanceof 关系,这就叫做 invariant ,但是 invariant 的类型系统功能比较弱,很多想要的功能实现不了,这就需要引入另外两种关系,即协变(covariant)、逆变(contravariant)
    回到我最开始说的,在关于范型约束的这个问题上,很多人存在一个误解,或者曾经存在这个误解,导致总是觉得有哪里不对,怎么也圆不回来,再听完别人说什么协变逆变、PECS 等以后就更懵了,而只要这个误解一消除,范型约束瞬间就变得异常简单,没有任何不理解的地方,这个误解就是“认为 List<? extends T>和 List<? super T>里的 extends 和 super 约束的是类型 T 和这个 list 里能够放的对象类型之间的 instanceof 关系”,而这个误解的正确答案是,把 List<? extends T>这个类型整体视为类型 A ,把 List<? super T>这个类型整体视为 B ,然后现在有若干的类型 C 、D 、E……,都是 List<X>的形式,其中的 X 为某个具体类型,extends 和 super 真正约束的是 A 与 B 和 C 、D 、E 之间的 instanceof 关系,换句话说就是,有一个 List<? extends T>类型的引用,这个引用能接受什么类型的 list 对象,有一个 List<? super T>类型的引用,这个引用能接受什么类型的 list 对象,这才是范型约束的真正含义
    其实我个人并不喜欢 Java 的这个语法,应该说这个误解这么普遍,可能跟 Java 选择的这个语法有一定的关系,而相比起来 c#和 scala 在这方面的语法就会稍好一点,因为 Java 的这个语法给人一种错觉,就是 List<T>、List<? super T>、List<? extends T>是三个不同的类型,当然严格说起来它们确实是三个不同的类型,但是从某种意义上来说,可以认为它们都是 List<T>,但是具有不同的 variance 属性,换句话说,如果换成下面的伪代码可能会更好理解
    GuuJiang
        16
    GuuJiang  
       119 天前   ❤️ 9
    @GuuJiang 幹,想换行时老是习惯性按 cmd+enter ,又发出来了,书接上文。。。
    把这三个类型写成下面的形式可能会更容易理解
    List<T>

    @Covariant
    List<T>

    @Contravariant
    List<T>

    也就是说给 List<T>这个类型额外指定一个 variance 属性,variance 可以有三种取值,分别是 Invariant(默认)、Covariant 和 Contravariant ,下面给出这三种关系的正式定义
    如果 B 是 A 的子类,则 List<B>是 List<A>的子类,那么这样的类型系统称为协变(covariant)
    如果 B 是 A 的子类,则 List<A>是 List<B>的子类,那么这样的类型系统称为逆变(contravariant)
    如果不管 A 和 B 之间是否有子类关系,List<A>和 List<B>之间都没有子类关系,那么这样的类型系统称为不变(invariant)
    所以? extends 和? super 语法的本质是,在 Java 的类型系统默认为 invariant 的前提下,人为指定某个类型的 variance 属性,使其变成 covariant 的或者 contravariant 的
    到此为止,我们搞清了第一个问题,即为什么要有协变和逆变,在默认情况下,如果有一个方法的一个参数类型为 List<Number>,那么在使用这个方法时它只接受 List<Number>,而如果将其指定为协变的,它就可以接受 List<Integer>,如果将其指定为逆变的,它就可以接受 List<Object>,所以就好像范型的出现扩展了方法的适用范围一样,协变和逆变的出现进一步扩展了方法的适用范围
    下面就到了第二个问题,这个方法的参数可以接收的范围广了以后,对这个参数的值的使用上就和原来有区别了,需要受到一些限制,而这个限制就是被无数人提起过的 PECS 原则,话说 PECS 是第二个我个人不太喜欢的概念,确实,它是一个非常精妙的总结,使得看过它的人能够很容易地记忆对于协变或者逆变后的类型在使用上的限制,但是它同时也带来了另一个误解,PECS 里的“生产”、“消费”等概念假定了范型类型一定是某种“容器”,诚然,用到范型最多的场景确实就是容器,就好像我写的这段话一样,提到范型的时候第一反应也是拿 List 来举例,但是事实上,范型和容器之间没有任何必然联系,对于编译器来说也不存在“生产”、“消费”等业务层面的概念,那么“生产”和“消费”的本质到底是什么呢?
    写到这里回头补充下前面的一段,先从简单类型入手了解下类型约束的本质是什么,前面提到的著名误解是误认为范型约束约束的是容器类型和容器内对象的类型,那实际上后者到底是由谁来约束的呢,事实上,如果有一个 List<T>,那这个 List 里能放什么类型的对象呢,答案是 T 及 T 的子类,这个相信每个人都知道,但是深入想想,类型系统是编译器关心的事情,而“一个类型为 List<T>的 list 里面能放什么类型的对象”这个问题明显是个业务层面的问题,对于编译器来说,“容器”、“容器里元素的类型”等这些概念都是不存在的,那我们天天挂在嘴上的“List<T>里能够存放 T 及其子类”这个结论到底是由谁来保证的呢,真正的答案是,List<T>里方法 add 的签名为 add(T),那根据里氏代换原则,这个参数自然接受 T 及其子类都是合法的,这才是这个约束的本质,编译器只负责校验方法的签名,保证了 add 方法只能接受 T 及其子类的参数,最终产生的效果才是我们说的“List<T>里能够存放什么”这个问题
    回到 PECS 这边来,所以说,对于编译器而言,“生产”的本质是“调用返回值为 T 的方法”,“消费”的本质是“调用参数为 T 的方法”,理解了这一点,PECS 自然就是顺理成章的了,而且根本不用去记,随便推导一下就能得出答案,下面试验一下按照上述的方法从头推导 PECS 原则,假如现在有一个引用 List<? extends Number> l ,那么下面这个语句是否是合法的
    Number n = l.get(0)
    答案是是,因为协变原则保证了 l 的实际类型可能为 List<Number>、List<Integer>、List<Float>等,无论是那种,其 get 方法的返回值肯定是 Number 或 Number 的子类,因此将这个返回值赋值给 Number 是完全没有问题的,反之,如果 l 的类型是 List<? super Number>,那么它的实际类型可能是 List<Number>、List<Object>等,因此是无法保证 add 的返回值一定能够赋值给 Number 的
    反之,对于如下的语句
    Number n = ...
    l.add(n)
    如果 l 是 List<? super Number>,那么不管它的实际类型是什么,其 add 方法一定能够接收 Number 类型的参数,而如果 l 是 List<? extends Number>,就无法保证其 add 方法一定能够接收 Number 类型的参数
    以上才是 PECS 原则的本质

    想到哪写到哪,一不小心写了这么多,可能有点啰嗦,其实相信对于很多人来说,看到讲误解的那一段应该就能自己想通后面的这些内容了,权当是自己的一份笔记吧,希望这篇回答可以就此终结所有关于范型约束的月经问题
    最后再补充一点,凡是提到 Java 的范型相关的问题总有人要提到擦除法,实际上对于今天的这个问题来说,和擦除法没有任何的关系,因为这些都是类型系统相关的问题,是编译阶段处理的问题,而擦除法是运行阶段的问题,二者之间没有任何联系,不管 Java 采不采用擦除法,今天讨论的这个问题的结论都不会有任何变化
    kaedea
        17
    kaedea  
       119 天前 via Android
    无脑记住 PRCS:Producer 用 extends ,Consumer 用 super 。
    Kaiv2
        18
    Kaiv2  
       119 天前
    import java.util.ArrayList;
    import java.util.List;

    public class App {

    class A {

    }

    class B extends A {

    }
    class C extends B {

    }
    class D extends C {

    }


    public static void main(String[] args) {
    // 编译支持, 使用继承关系
    List<A> al = new ArrayList<A>();
    al.add(new A());
    al.add(new B());
    al.add(new C());
    A a = al.get(0);

    // 声明 List<? extends A> , 实现可能是 继承 A 的任意类型
    List<? extends A> al1 = new ArrayList<A>();
    List<? extends A> al2 = new ArrayList<B>();
    List<? extends A> al3 = new ArrayList<C>();
    // 虽然编译器知道放入数据一定是继承 A ,但是用户指定的实现不确定 比如 al3 无法放入 B 类型,会有类型转换错误
    al1.add(new A()); // 编译错误
    al2.add(new B()); // 编译错误
    // 应为声明的 List<? extends A>, 编译器确定类型一定是 A
    A x = al1.get(0);




    // 声明 List<? super A> , 实现可能是 A 的任意父类, 兼容放入 A 以及子类
    // 编译器支持放入任意 A oo X extends A
    List<? super A> bl1 = new ArrayList<Object>();
    List<? super B> bl2 = new ArrayList<A>();
    List<? super C> bl3 = new ArrayList<B>();

    bl1.add(new B());
    bl1.add(new C());

    bl2.add(new C());
    bl2.add(new D());
    // 编译器无法获取确定实现类支持的数据类型, 因为 bl1 可以是 A or Object 的 List 装载数据
    A b = bl1.get(0); // 编译错误
    Object o = bl1.get(0); // 所有类型的父类是 Object 所以支持
    // 需要人为强制转换
    B b1 = (B)bl1.get(0);

    }
    }
    Kaiv2
        19
    Kaiv2  
       119 天前
    https://www.v2ex.com/t/858827
    这里写代码太费劲了,创建了一个帖子回复
    kran
        20
    kran  
       118 天前 via Android
    @GuuJiang 写的太好了
    NeroKamin
        21
    NeroKamin  
       116 天前
    @GuuJiang 写的真通透,我在理解 PECS 的时候对于也有种怪怪的感觉,再看大佬写的一下子就明白了本质
    mmdsun
        22
    mmdsun  
       116 天前
    @acidsweet 建议直接看 C sharp 的文档更全,kotlin 的逆变协变和关键字都是借鉴 C#,还有?空判断和扩展函数委托。设计思想几乎一模一样。
    关于   ·   帮助文档   ·   API   ·   FAQ   ·   我们的愿景   ·   广告投放   ·   感谢   ·   实用小工具   ·   1662 人在线   最高记录 5497   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 43ms · UTC 16:44 · PVG 00:44 · LAX 09:44 · JFK 12:44
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.