一个没有泛型的Java世界,是什么样的?你从ArrayList里取出一只Dog,编译器却只肯保证它是Object。你小心翼翼地强转,运行到一半,ClassCastException砸在你脸上。更糟糕的是,这种错误发生在毫秒之间,发生在千万行代码的深处,让你无从查起。Java泛型的诞生,正是为了把这种运行时痛苦,提前到编译期变成一声轻响。
编译期里的红线
泛型最直接的好处,是消灭了强制类型转换。没有泛型时,集合里装的都是Object,取出来必须手动转型;转错了,运行时报错。有了泛型,你写List ,编译器就在程序里埋下了看不见的检查哨。往里面塞一只Cat?编译直接失败。泛型让错误在编译期现形,而不是在运行期爆炸。这种“早失败”哲学,是软件工程的黄金法则。越早发现缺陷,修复成本越低。运行期的ClassCastException可能在你交付给用户三个月后才出现,而编译期的错误,在你敲下回车的那一毫秒就被钉死。
更重要的是,泛型带来的并不只是安全。它让程序读起来更干净。你看到List ,立刻知道里面装有字符串,而不是一堆需要心算的Object。代码的自文档化程度提升了,协作时心智负担大幅降低。泛型不只是一种语法糖,它是写给下一个开发者的情书。当你的同事打开这段代码,不用翻遍所有调用点就能明白你的意图,这比一百行注释更有效。
擦除——一份被刻意遗忘的记忆
不过,泛型有一条要命的规定:Java虚拟机里从来不存在泛型。编译器把List 、List 统统擦成原始类型List,再偷偷帮你插入类型转换。类型擦除意味着,JVM根本不知道泛型的存在。它对List的内容只关心它是不是Object。所以运行时你无法判断一个List到底属于什么类型:getClass()返回的都是ArrayList.class,泛型参数被抹得一干二净。
擦除带来很多连锁反应。你无法创建一个泛型数组,比如T[] array = new T[10];,因为运行时不知道该创建什么类型。你无法直接实例化泛型参数:T t = new T();也是非法的。这些限制让很多初学者一头雾水,但背后逻辑是自洽的:泛型是编译期的工具,运行时必须知道自己是什么类型。泛型是编译器的纪律,不是JVM的壁垒。理解了这层关系,你才能接受Java泛型的种种别扭。
有人会问,为什么Java不采用C#那样的真泛型,让类型在运行时保留?答案是兼容性。Java泛型在JDK 5.0引入时,已有海量代码运行在JVM上。如果改变运行时类型系统,所有旧代码都会面临失效。擦除泛型是唯一能保持二进制兼容的选择。泛型是安全的加法,不是激进的革命。这种选择在理论上并不完美,却在工程上拯救了整个Java生态。
通配符与PECS——泛型的灵魂
真正让Java泛型变得难以驾驭的,是通配符。List<? extends Animal>和List<? super Dog>看起来差不多,用起来却一个像刀,一个像盾。如果你只读不写,用extends;如果你只写不读,用super。这句话被缩写为PECS,即Producer Extends,Consumer Super。PECS是一条用直觉无法猜到的规则,它是泛型的灵魂。生产者负责产出口数据,所以它的上界可以收窄;消费者负责接收写入,所以它的下界可以放宽。
这么说依然抽象。想象一个方法void feedAll(List<? extends Animal> animals),它从列表中拿出Animal喂食。因为上界是Animal,编译器允许读取,但不允许添加任何元素——你只确定它是Animal的某种子类,却不敢放入一个Dog,因为它也可能是Cat。反过来,void addCats(List<? super Cat> cats),你只知道这个列表可以容纳Cat或它的父类,于是你可以往里放Cat,但读取时只能得到Object。不加通配符的泛型是死板的,加了通配符的泛型是危险的。危险在于它把“只读”和“只写”的边界划得极其鲜明,而程序员最容易踩入越界陷阱。
泛型与数组——两种信任体系
数组和泛型,在Java中像两个性格完全相反的双胞胎。数组是协变的:String[]是Object[]的子类型,所以你可以把一个String[]赋给Object[]的引用。泛型却是不协变的:List 不是List