java泛型逆变与协变-泛型的协变和逆变
上图中,行1中定义了一个泛型类 GenericType,其类型变量是T、 S extends List & Serializable,S 有 List 和 Serializable 这两个类型上界,即 S 必须是这两个接口的实现类,编译后 S 擦除为第一个参数类型,即 List。行 2 定义了一个构造方法,其使用了类型参数 T、S,并引入了新的类型参数 U,注意如果范型参数未在类范围定义过,需要在方法名前添加声明,因此构造方法前需添加。行 4~6 定义了一个 consumeNumber 方法,该方法新增一个类型参数 Njava泛型逆变与协变,继承自 Number,参数列表中包括一个 List 对象 numbers ,下界为 Number。该方法的一个调用示例见图 5-4:
图 5-4 GenericType 类调用示例
consumeNumber 调用中,第一个参数是 Long 类型,而 Long 是 Number 的子类;参数 2 是泛型类 Class,泛型变量是ArrayList;参数 3 是一个 ArrayList,默认类型变量是 Object,Object 是 Number 的超类,因此方法调用说合法的。
类型擦除
上文已经提到 Java 采用类型擦除实现泛型,相比 C# 在运行时,生成泛型对应的类型,类型擦除产生的字节码只包含非泛型化的类,接口和方法,从而保证运行时无需添加新的类型,减少运行时负载,并保证旧版本的二进制兼容性;然后这样也会带来一个和多态之间的冲突,参见图 6-1:
图 6-1 类型擦除与多态
首先,父类的类型变量,子类都需要继承,同时,子类可以添加自己的类型变量,再次子类有泛型参数,父类可以没有。
例子中,Child 继承了带有日期 Date 的类型参数的泛型类 Parent。如果不带类型变量,这样定义子类:public static class Child extends Parent{...},Child 会自动继承父类的类型变量,由于类型擦除,子类的 get/set 操作的都是 Object 对象。
另外,子类可以添加自己的泛型参数,见行 10,Child 添加了一个类型变量 Date,注意这里 Date 是一个类型变量名称,和日期类没任何关系。因为类型变量的名称和 Date 类名相同,因此在创建日期类型的对象时,需要带上完整的包名、类名,以区别是类型变量还是日期类。
重点看行 12、17。这里的 Override 都不是真正的重写。对于 get 方法,父类返回的是 Object,子类返回 Date,子类的适用场景更广,发生了协变(covariance,什么是协变后面再详细介绍),即方法的返回值是协变的,符合里氏替换原则(Liskov Substitution Principle),也就是 OOP 里的重写,暂时没有问题;那么再看 set 方法,父类中参数类型是 Object,子类为 Date,这个在不使用泛型时,事实上是重载,编译器并不会提示添加 Override 注解,允许它们共存,但在使用泛型时,编译器会提示添加 Override,也就是说编译器认为它就是重写。编译器到底做了什么?我们看下该类的字节码,见图 6-2:
图6-2 桥方法
在 Child 类中,除了行 10、16,行 22、29 分别增加一个 get 和 set 方法,返回值和入参类型都是 Object,这两个方法就是编译器自动生成的桥方法java泛型逆变与协变,编译器为解决类型擦除对多态的影响,自动在字节码中添加的两个方法。对于 get 方法,由于只有返回值不同时,编译器会认为是重复方法(Java 语法中两个只有返回值不同,不是重载,编译器会认为是重复定义的方法),但对于 JVM 来讲,只要参数列表和返回值有一个不同即认为是不同方法,所以运行时没问题。由此可见,子类中真正覆盖父类的是桥方法,由桥方法再去调用子类方法,参见行 27、32,因此桥方法起到了代理的作用。
尽管桥方法能解决由于类型擦除导致多态冲突的问题,但避免不了堆污染(Heap pollution)。所谓堆污染是指泛型的类型参数指向了与其类型不匹配的变量,编译时会有 unchecked warning 信息,运行时抛出 ClassCastException 。例如图 6-1 中的行 35,parent 指向一个 child 对象,child 的 data 类型是 Date,而 parent 声明时是 raw type,因此 data 类型为 Object,所以可以 setData 一个字符串,在编译时有一个告警信息,参见 6-3,运行时导致 ClassCastException 。通常在编译时,我们需求检查这一类告警信息,确保运行时正常,关键是要注意原始类型对泛型对象的引用。
图 6-3 heap pollution
协变(Covariance)和逆变(Contravariance)
泛型是与 OOP 强关联的技术,而协变和逆变也是关于继承关系的理论,因此两者在很多方面是贯通的。
上文提到了里氏替换原则,它指出如果一个类型 S 是类型 T 的子类,那么程序中任何 T 的对象出现的地方都可以用 S 的对象来替换,因为 S 的对象包含了 T 的对象的所有信息。这里的类型不一定就是类,也可以是接口、函数或方法 。里氏替换原则是协变和逆变的基础,如果有人说的“协变”或“逆变”违反了里氏原则,那肯定是错误的推断。
首先,协变和逆变这两个概念在数学和物理界已经应用很广泛,它强调的是当变量发生变化时,所研究对象变化的方向与该变量变化的方向是否一致,如果一致就是协变,否则就是逆变。例如函数,当我们改变自变量,使得,则,此时,取任意自变量的值,变化后的值都比对应的旧值减小了 1,因此与的变化方向是相反的,它们之间的关系就是逆变。同理,当我们改变,使得,那么与的变化方向一致,它们之间就是协变。
回到编程语言领域。假设有 P、C 两个类,C 继承自 P,在 P 出现的地方,都可用 C 来替换,即 C 的适用范围更广,我们可以用来表示这种继承关系,因为在树型结构中,P 在 C 的上方(当然也可以用表示,因为 C 的应用范围广,但大家都习惯用来表示继承关系)。
假设有两个函数,在出现的地方可代替它。因为出现的地方肯定存在可替换,因此可得上述结论,反之则不然。与类的继承关系相似,我们可以说是的子类,即。由此得出,函数和其参数之间的关系是逆变,即如果两个类存在继承关系,则以这两个类为参数的函数之间存在相反的继承关系。 上述考察的是以 P,C 作为参数的函数的变化,我们再来分析以 P,C 作为响应的函数之间的关系。假设是两个以 P,C 为返回值的函数,在出现的地方,可用替换,因为会生成,而可以替换。反之则不成立,因此可以说是的子类,即。可见,返回值和函数之间是协变关系。
Java 中数组是协变的。如果 S 是 T 的子类,那么S[]也是T[]的子类,S[]可赋值给T[],并且T[]可添加 S 类型元素。例如Interger[]是Number[]的子类。因为数组在运行时类型是可识别的 。而泛型由于类型擦除,没有协变关系。例如List不是List的子类,因为它们都是List类型。如果不支持协变和逆变,那么泛型的功能就大打折扣,因此 Java 引入了通配符以支持这种复杂结构的变换关系。
通配符
Java 利用通配符实现了泛型的协变和逆变,通配符用?号表示,代表未知类型。 代表 Type 及其所有子类型,又叫上界通配符; 代表 Type 及其所有父类型,又叫下界通配符。因为上界通配符代表的泛型可以加入父类及所有子类,因此是协变的;同理,下界通配符是逆变的。
Java 中不能用于定义泛型的类型变量,即它不能出现在类和方法的类型参数声明中,例如都是非法的。这是因为使用一个类的超类作为类型变量和使用 Object 作为类型变量作用相同,基本上没什么价值。
通配符的使用需遵循PECS原则,即producer extends and consumer super,也称为get and put principle,在读的情况下用 extends,写时用 super,如果即想读,又想写,就不要用泛型。一个使用 extends 上界限定符只读数据的例子见图 8-1:
图 8-1 只读泛型
图中行 1 定义的 sum 方法的参数 ns 是一个包含 Number 或其子类元素的 Collection 泛型,用来计算所有元素的和。ns 事实上是一种 in 类型(除了 in 类型还有 out,in/out 类型,熟悉 C# 的程序员会比较熟悉这种概念)的参数,只能用于读取 ns 中的数据。 因为 Integer、Double 都是其子类,所以行 11、13、15 分别定义的 ints、dobles、nums 都可以传入 sum 方法。行 5 注释的代码说明带有上界通配符的泛型容器除了 null,任何元素都不允许插入。同时,这个例子也演示了基于泛型实现的算法变得更通用了。一个使用 super 下界限定符约束只写数据的例子见图 8-2:
图 8-2 只写泛型
上图行 1 定义的 insert 方法实现往 out 型参数 ns 中插入整数。ns 是一个 Collection 泛型,类型变量是 Integer 或其超类。因此行 9、10 定义的 ints、nums 可以传入该方法。在 insert 方法中并不会读取 ns 的内容,即使读取也只能返回 Object 类型的元素。再看一个同时带有 in、out 参数的例子如 8-3 所示:
图 8-3 同时带有读、写型参数的方法
上图行 1 定义的 copy 方法是 JDK 里 Collections 中的方法,它从 src 容器 copy 元素到 dest;src 为 T 或 T 子类元素的 List,因为需要读取 src 中元素,所以使用上界限定符;dest 是 T 或其超类的 List,因为需要往里写入元素,所以使用下界限定符。行 6~7 说明程序从 src 读取元素写入到 dest 中,因为 dest 中元素类型是 src 中元素类型的超类,超类可被子类替换,所以符合里氏原则,行 12~15 给出了调用 copy 方法的几个例子。
从协变、逆变一节可知,子类比父类适用范围更广,因为父类出现的地方,子类都可以出现,反之,父类比子类适用范围更窄,要求更严格,所以 PECS 原则也可以说是“严于律己 宽以待人”9,已就是指返回值,要写入的数据,人是指入参,要读取的数据。
泛型在函数式编程中的应用
JDK8 支持函数式编程(FP,functional programming,FP 是 declarative programming 的一种),Oracle 此举把 FP 融入了 Java 这样一种命令式编程语言(imperative programming)中。虽然目前 Java 中的 FP 不是纯粹的 FP(pure FP),但它为 Java 注入了新的编程风格,新的编程思路,新的编程框架。
命令式编程语言包括过程式编程语言(Procedure programming)和面向对象编程语言(Object oriented programming),它们关注如何解决问题,而 FP 关注如何描述问题,问题描述清楚了,问题也就解决了。使用命令式编程语言,程序的逻辑由一条条语句,一个个方法,对象以及它们之间的调用、共享数据来完成, 程序流程分支庞杂,测试困难,分支覆盖不全,很容易就导致程序崩溃,异常处理不当,也会埋藏隐患。JDK8 支持 FP 的代码主要位于 java.util.function 包,FP 的编程思想及思维习惯和 OOP 差别很大,一个程序员在实际代码中使用过 FP 并不代表其熟悉 FP 的思想,由于本文主要讨论的是泛型,因此回归主题,重点讨论一下 java.util.function 包中一些泛型的使用。
FP中,纯粹的函数可以看作一个黑盒子,相同的输入必定产生相同的输出,不会有副作用,即不受 context 影响,或影响 context。高阶函数(higher-order functions)是 FP 重要的特征,所谓高阶函数是指满足一个或多个函数作为入参,或返回结果是一个函数的函数,例如图 9-1 所示:
图 9-1 高阶函数
上图中行 2 声明的 Function 是 JDK 中的函数接口,该接口代表了所有的有一个入参,一个出参的方法;T 表示入参,R 为出参。compose 是该接口中的一个 default 方法,它的入参和出参都是一个函数,因此它是一个高阶函数。根据行 2 和行 6 可知该高阶函数(内层函数和外层函数组合后的函数)的功能为输入一个 V 类型的参数,输出一个 R 类型的值。内层函数 before 接收 V 或其超类,输出 T 类型或其子类,参见行 4,从 before 函数的声明可看到泛型 PECS 原则的应用:输入使用 super 下界,返回使用 extends 上界。外层函数的入参为 T,正是 before 的输出 T 类型或其子类,符号里氏原则,外层函数的输出 R 就是高阶组合函数的输出。
总之,针对 Java 中的 FP 函数定义,运用上文提到的泛型的语法与语义的解析就轻松地理解。