Java 泛型通配符实战:? extends 与 ? super 到底怎么选(PECS 原则)
Java 泛型通配符实战:? extends 与 ? super 到底怎么选(PECS 原则)
写 Java 的人几乎都被List<? extends Number>和List<? super Integer>绕晕过。明明都是通配符,一个只能读不能写,一个能写不能安全读,IDE 报错还看不懂。这篇不背概念,直接从「你写的方法怎么才能同时收List<Integer>和List<Double>」这个真实需求出发,把 PECS 讲成肌肉记忆。
先看没有通配符时的死路
假设你要写一个把 List 里的数字求和的方法:
staticdoublesum(List<Number>list){doubles=0;for(Numbern:list)s+=n.doubleValue();returns;}看着没问题,但你调用时会发现:
List<Integer>ints=List.of(1,2,3);sum(ints);// 编译错误!List<Integer> 不是 List<Number>很多人这里第一反应是「Integer 是 Number 的子类,List 当然是 List」——错。Java 泛型是不变的(invariant):List<Integer>和List<Number>没有继承关系。如果允许它们互相赋值,你就能往List<Integer>里塞一个Double,类型安全就崩了。
上界通配符 ? extends:生产者,只读
要让sum收下任意「元素是 Number 或其子类」的 List,用? extends Number:
staticdoublesum(List<?extendsNumber>list){doubles=0;for(Numbern:list)s+=n.doubleValue();// 读出来当 Number 用,安全returns;}List<Integer>ints=List.of(1,2,3);List<Double>doubles=List.of(1.5,2.5);sum(ints);// 编译通过sum(doubles);// 编译通过? extends Number的含义是:「某个具体但未知的、是 Number 子类型的类型」。因为编译器不知道确切类型,所以:
staticvoiddemo(List<?extendsNumber>list){Numbern=list.get(0);// 读:一定是 Number,允许list.add(1);// 写:编译错误!list.add(null);// 只有 null 例外}为什么不能写?因为 list 实际可能是List<Double>,你往里 add 一个Integer就破坏了它。编译器无法确认你要加的东西是不是「那个未知子类型」,索性一律禁止。所以? extends的容器只能当数据来源(生产者 Producer),从里面往外拿。
下界通配符 ? super:消费者,可写
反过来,如果你要写一个「往 List 里塞 Integer」的方法,希望它能接受List<Integer>、List<Number>、List<Object>,用? super Integer:
staticvoidaddNumbers(List<?superInteger>list){for(inti=1;i<=3;i++){list.add(i);// 写:一定能装下 Integer,安全}}List<Number>nums=newArrayList<>();List<Object>objs=newArrayList<>();addNumbers(nums);// 通过addNumbers(objs);// 通过? super Integer是「某个未知的、是 Integer 父类型的类型」。因为无论那个未知父类型是谁,Integer都能放进去,所以写是安全的。但读呢?
staticvoiddemo(List<?superInteger>list){list.add(42);// 写:允许Integerx=list.get(0);// 读:编译错误!Objecto=list.get(0);// 只能当 Object 读}读出来的东西编译器只知道「是 Integer 的某个父类」,最保守就是Object,拿不到具体类型。所以? super的容器当数据接收方(消费者 Consumer),只往里塞。
PECS:一句话记死
Effective Java 里的口诀PECS = Producer-Extends, Consumer-Super:
- 参数是生产数据给你用的(你从里面读)→ 用
? extends - 参数是消费你给的数据的(你往里面写)→ 用
? super
经典案例是Collections.copy,一个读一个写,两个通配符一起上:
publicstatic<T>voidcopy(List<?superT>dest,List<?extendsT>src){for(inti=0;i<src.size();i++){dest.set(i,src.get(i));// 从 src 读(生产者 extends),往 dest 写(消费者 super)}}List<Object>dest=newArrayList<>(List.of(0,0,0));List<Integer>src=List.of(1,2,3);copy(dest,src);// Integer 能读、能塞进 Object 列表src是数据来源用extends,dest是数据去处用super,完美对应。
一个真实踩坑:自己的接口忘了加通配符
假设你写了个事件处理器接口:
interfaceHandler<T>{voidhandle(Tevent);}// 想注册:一个能处理 Object 的 handler 应该也能处理 String 事件staticvoidregister(Handler<String>h){/* ... */}Handler<Object>universal=e->System.out.println(e);register(universal);// 编译错误!Handler<Object> 不是 Handler<String>universal明明能处理任何 Object(包括 String),却传不进去。因为handle是消费event 的(消费者),按 PECS 参数该用super:
staticvoidregister(Handler<?superString>h){h.handle("hello");}register(universal);// 现在通过了判断依据永远是看方法内部对这个泛型参数是读还是写:handle往里塞(消费)String,就用super。
小结
- Java 泛型不变:
List<Integer>不是List<Number>,别指望自动向上转。 ? extends X:生产者,只能读(读出来是 X),不能写(null 除外)。数据从它流出。? super X:消费者,能写(写 X 安全),读只能当 Object。数据往它流入。- PECS:你读它就
extends,你写它就super;既读又写(如copy)两个一起用。 - 设计自己的泛型方法时,盯住「方法体里对这个参数是 get 还是 add」来决定用哪个。
一句话记忆点:Producer-Extends,Consumer-Super——从哪读用 extends,往哪写用 super。
