Java中List<T>赋值给Collection<? extends T>为何可编译通过?
你觉得反直觉,本质是误解了? extends T的语义,以及Java泛型设计要解决的核心问题。
先从泛型要堵的历史坑说起
Java在泛型出现之前,数组是支持协变的:如果SubT是T的子类,那么SubT[]会被当成T[]的子类,可以直接赋值:
Number[] numbers = new Integer[10]; // 编译能过 numbers[0] = 3.14; // 编译不报错,运行直接抛ArrayStoreException
这个设计的缺陷非常明显:类型错误要到运行时才能暴露,完全违背了静态类型检查提前发现问题的初衷。
泛型上线之后,默认采用了不变性规则:就算SubT是T的子类,List<SubT>也不是List<T>的子类,直接赋值会编译失败,从根源上避免了数组的这个坑。
但完全的不变性太影响灵活性了——比如你要写一个通用方法计算任意数字集合的总和,总不能给List<Integer>、List<Double>、List<Long>各写一个重载版本吧?所以Java才引入了通配符机制,其中? extends T就是专门用来实现安全协变的。
为什么List<T>能赋值给Collection<? extends T>?
你首先要把? extends T的语义搞对:它不是说“这个集合只能存T的、排除T自身的严格子类实例”,而是声明了一个类型范围承诺:
这个引用指向的集合,它的泛型实际类型一定是T或者T的子类,我作为编译器可以保证,从里面读出来的元素一定能安全转成T类型。
那List<T>的泛型实际类型就是T本身,当然完全符合这个承诺,能赋值是完全合理的——就像你说“我要一个能产出水果的盒子”,别人给你一个“装水果的盒子”,你总不能说不符合要求吧?
你觉得反直觉,只是把日常语境里“extends(继承)必须是父子类、不能是自身”的认知套到了通配符上,实际上Java类型系统里的上界定义本来就包含类型自身,比如你写泛型约束<T extends Comparable<T>>,T本身实现了Comparable就能正常用,从来不会因为T是自身就判定不符合约束。
为什么不能往这个引用里add元素?
这个限制恰恰是泛型比数组安全的核心原因。
你要知道,Collection<? extends T>只是个引用类型,它实际指向的对象可能是List<T>,也可能是List<SubT1>,还可能是Set<SubT2>(这里SubT1、SubT2都是T的互不相关的子类)。编译器只知道这个集合的泛型参数是T的某个子类型,但根本不知道具体是哪一个。
拿具体类型举个例子,T取Number:
Collection<? extends Number> numbers; List<Integer> intList = new ArrayList<>(); List<Double> doubleList = new ArrayList<>(); // 下面两个赋值都能编译通过,因为Integer、Double都是Number的子类 numbers = intList; numbers = doubleList;
这时候你尝试往numbers里加元素,编译器根本没法判断操作是否安全:
- 加
1(Integer类型)不行,万一numbers现在指向的是doubleList呢?Integer放不进Double类型的集合; - 加
3.14(Double类型)也不行,万一numbers现在指向的是intList呢?Double放不进Integer类型的集合; - 就算强转成Number类型加,还是不行,父类实例不管啥情况都不能放进子类泛型的集合里。
唯一能安全添加的只有null,因为null属于所有引用类型,但这个操作没有实际业务意义,所以Java干脆在编译期直接禁止了除null外的所有add操作,把所有类型风险拦在编译阶段,根本不给你运行时抛类型转换异常的机会。
这个设计的实际价值
你别觉得这个“能读不能写”的引用没用,它刚好适配了绝大多数“只需要读取集合元素、不需要修改集合”的场景:比如遍历集合做统计、做元素查找,用? extends T类型的引用就可以接收所有泛型参数为T子类的集合实例,既实现了通用的逻辑复用,又完全保证了类型安全,比数组那种运行时才炸的协变靠谱得多。
内容的提问来源于stack exchange,提问作者Noah Carter

