【联想实习笔记】克服在流中使用 Lambda 表达式排序时编译器类型推断的弱点
Motivation
Highlights
使用 Lambda 表达式排序遇到的一个陷阱。在原有升序输出的流中新增.reversed()
方法、试图将流中的元素降序输出时,必须显式指定对象的类型。然而不使用.reversed()
方法时却不需要这么做,为什么?
1 | .sorted((m1, m2) -> { return Integer.valueOf(m1.get("seq").toString()).compareTo(Integer.valueOf(m2.get("seq").toString()));}) // ok |
1 | .sorted(Comparator.comparingInt(m -> Integer.valueOf(m.get("seq").toString()))) // ok |
1 | .sorted(Comparator.comparingInt(m -> Integer.valueOf(m.get("seq").toString())).reversed()) // error |
1 | .sorted(Comparator.<Map>comparingInt(m -> Integer.valueOf(m.get("seq").toString())).reversed()) // ok |
Full code snippet
1 | public List<Map<String, Object>> getFilterData(List<ControlTowerFilterConf> allByMenuIdAndUserIdOrderBySeq, boolean dataFlag, long menuId) { |
直接原因:类型接收者的参数中含有 Lambda 表达式
English Version
Case
Sort the User objects list. Only works using method reference, with lambda expression the compiler gives an error:
1 | List<User> userList = Arrays.asList(u1, u2, u3); |
Error:
1 | com\java8\collectionapi\CollectionTest.java:35: error: cannot find symbol |
Reason
This is a weakness in the compiler’s type inferencing mechanism. In order to infer the type of u
in the lambda, the target type for the lambda needs to be established. This is accomplished as follows. userList.sort()
is expecting an argument of type Comparator<User>
. In the first line, Comparator.comparing()
needs to return Comparator<User>
. This implies that Comparator.comparing()
needs a Function
that takes a User
argument. Thus in the lambda on the first line, u
must be of type User
and everything works.
In the second and third lines, the target typing is disrupted by the presence of the call to reversed()
. Both the receiver and the return type of reversed()
are Comparator<T>
so it seems like the target type should be propagated back to the receiver, but it isn’t. (Like I said, it’s a weakness.)
Lambdas are divided into implicitly-typed (no manifest types for parameters) and explicitly-typed; method references are divided into exact (no overloads) and inexact. When a generic method call in a receiver position has lambda arguments, and the type parameters cannot be fully inferred from the other arguments, you need to provide either an explicit lambda, an exact method ref, a target type cast, or explicit type witnesses for the generic method call to provide the additional type information needed to proceed.
In the second line, the method reference provides additional type information that fills this gap. This information is absent from the third line, so the compiler infers u
to be Object
(the inference fallback of last resort), which fails.
Usage
This would work fine if you have not chained the comparators:
1 | userList.sort(Comparator.comparing(u -> u.getName()); |
However, when comparators are chained, the type of the objects being compared need to be specified explicitly. Obviously if you can use a method reference, do that and it’ll work. Sometimes you can’t use a method reference, for example when you reference non-static methods from static contexts.
If you want to pass an additional parameter, so you have to use a lambda expression. In that case you’d provide type in comparing
:
1 | userList.sort(Comparator.<User>comparing(u -> u.getName()).reversed()); |
or specify an explicit parameter type in the lambda:
1 | userList.sort(Comparator.comparing((User u) -> u.getName()).reversed()); |
中文版
示例
对用户对象列表进行排序。只能使用方法引用,使用 lambda 表达式编译器会报错:
1 | List<User> userList = Arrays.asList(u1, u2, u3); |
错误信息:
1 | com\java8\collectionapi\CollectionTest.java:35: error: cannot find symbol |
解释
这是编译器的类型推断机制的一个弱点。为了推断 lambda 中 u
的类型,需要建立 lambda 的目标类型。具体过程是,userList.sort()
期望一个类型为 Comparator<User>
的参数。在第一行中,Comparator.comparing()
需要返回 Comparator<User>
。这意味着 Comparator.comparing()
需要一个以 User
为参数的 Function
。因此,在第一行的 lambda 表达式中,u
必须是 User
类型。
在第二行和第三行中,调用 reversed()
破坏了目标类型的推断。reversed()
的接收参数和返回类型都是 Comparator<T>
,所以目标类型应该传播回接收者,但实际上没有传播。(弱点就在于此)
Lambda 分为隐式类型(参数没有明确的类型)和显式类型;方法引用分为精确(无重载)和不精确。当接收者位置的泛型方法调用具有 lambda 参数时,如果类型参数无法从其他参数中完全推断出来,就需要指定显式的 lambda、精确的方法引用、目标类型转换或用显式的泛型方法来调用类型见证 (type witnesses),来为编译器提供所需的额外类型信息。
在第二行中,方法引用提供了填补这个间隙的额外类型信息。而第三行中缺少了这些信息,因此编译器将 u
推断为 Object
(最后的推断回退父类),而 Object
并没有 getName()
方法,导致报错。
正确用法
如果你没有连续使用 Comparator,这样写就够了:
1 | userList.sort(Comparator.comparing(u -> u.getName()); |
然而,当 Comparator 被连续拼接使用时,就需要明确指定被比较对象的类型。如果你可以用方法引用,那么这是最简洁的写法。不过有时并不能使用方法引用,比如在 static 上下文中引用非 static 方法。
如果你想传递额外的参数,那么必须使用 lambda 表达式。在这种情况下,你需要在 comparing
中显式提供类型:
1 | userList.sort(Comparator.<User>comparing(u -> u.getName()).reversed()); |
或者在 lambda 中指定一个显式参数类型:
1 | userList.sort(Comparator.comparing((User u) -> u.getName()).reversed()); |
间接原因:泛型类型擦除
Java 泛型的实现方法:类型擦除
大家都知道,Java 的泛型是伪泛型,这是因为 Java 在编译期间,所有的泛型信息都会被擦掉,正确理解泛型概念的首要前提是理解类型擦除。Java 的泛型基本上都是在编译器这个层次上实现的,在生成的字节码中是不包含泛型中的类型信息的,使用泛型的时候加上类型参数,在编译器编译的时候会去掉,这个过程成为类型擦除。
如在代码中定义 List<Object>
和 List<String>
等类型,在编译后都会变成 List
,JVM 看到的只是 List
,而由泛型附加的类型信息对 JVM 是看不到的。Java 编译器会在编译时尽可能的发现可能出错的地方,但是仍然无法在运行时刻出现的类型转换异常的情况,类型擦除也是 Java 的泛型与 C++ 模板机制实现方式之间的重要区别。
通过两个例子证明 Java 类型的类型擦除
原始类型相等
1 | public class Test { |
在这个例子中,我们定义了两个 ArrayList
数组,不过一个是 ArrayList<String>
泛型类型的,只能存储字符串;一个是 ArrayList<Integer>
泛型类型的,只能存储整数,最后,我们通过 list1
对象和 list2
对象的 getClass()
方法获取他们的类的信息,最后发现结果为 true
。说明泛型类型 String
和 Integer
都被擦除掉了,只剩下原始类型。
通过反射添加其它类型元素
1 | public class Test { |
在程序中定义了一个 ArrayList
泛型类型实例化为 Integer
对象,如果直接调用 add()
方法,那么只能存储整数数据,不过当我们利用反射调用 add()
方法的时候,却可以存储字符串,这说明了 Integer
泛型实例在编译之后被擦除掉了,只保留了原始类型。
类型擦除后保留的原始类型
在上面,两次提到了原始类型,什么是原始类型?
原始类型 就是擦除去了泛型信息,最后在字节码中的类型变量的真正类型,无论何时定义一个泛型,相应的原始类型都会被自动提供,类型变量擦除,并使用其限定类型(无限定的变量用 Object)替换。
原始类型 Object
1 | public class Pair<T> { |
Pair 的原始类型为:
1 | public class Pair { |
因为在 Pair<T>
中,T 是一个无限定的类型变量,所以用 Object
替换,其结果就是一个普通的类,如同泛型加入 Java 语言之前的已经实现的样子。在程序中可以包含不同类型的 Pair
,如 Pair<String>
或 Pair<Integer>
,但是擦除类型后他们的就成为原始的 Pair
类型了,原始类型都是 Object
。
从上面的” 一 - 2” 中,我们也可以明白 ArrayList<Integer>
被擦除类型后,原始类型也变为 Object
,所以通过反射我们就可以存储字符串了。
如果类型变量有限定,那么原始类型就用第一个边界的类型变量类替换。
比如: Pair 这样声明的话
1 | public class Pair<T extends Comparable> {} |
那么原始类型就是 Comparable
。
要区分原始类型和泛型变量的类型。
在调用泛型方法时,可以指定泛型,也可以不指定泛型。
- 在不指定泛型的情况下,泛型变量的类型为该方法中的几种类型的同一父类的最小级,直到 Object。
- 在指定泛型的情况下,该方法的几种类型必须是该泛型的实例的类型或者其子类。
1 | public class Test { |
其实在泛型类中,不指定泛型的时候,也差不多,只不过这个时候的泛型为 Object
,就比如 ArrayList
中,如果不指定泛型,那么这个 ArrayList
可以存储任意的对象。
Object 泛型
1 | public static void main(String[] args) { |
类型擦除引起的问题及解决方法
因为种种原因,Java 不能实现真正的泛型,只能使用类型擦除来实现伪泛型,这样虽然不会有类型膨胀问题,但是也引起来许多新问题,所以,SUN 对这些问题做出了种种限制,避免我们发生各种错误。
先检查再编译以及编译的对象和引用传递问题
Q: 既然说类型变量会在编译的时候擦除掉,那为什么我们往 ArrayList 创建的对象中添加整数会报错呢?不是说泛型变量 String 会在编译的时候变为 Object 类型吗?为什么不能存别的类型呢?既然类型擦除了,如何保证我们只能使用泛型变量限定的类型呢?
A: Java 编译器是通过先检查代码中泛型的类型,然后在进行类型擦除,再进行编译。
例如:
1 | public static void main(String[] args) { |
在上面的程序中,使用 add
方法添加一个整型,在 IDE 中,直接会报错,说明这就是在编译之前的检查,因为如果是在编译之后检查,类型擦除后,原始类型为 Object
,是应该允许任意引用类型添加的。可实际上却不是这样的,这恰恰说明了关于泛型变量的使用,是会在编译之前检查的。
那么,这个类型检查是针对谁的呢?我们先看看参数化类型和原始类型的兼容。
以 ArrayList 举例子,以前的写法:
1 | ArrayList list = new ArrayList(); |
现在的写法:
1 | ArrayList<String> list = new ArrayList<String>(); |
如果是与以前的代码兼容,各种引用传值之间,必然会出现如下的情况:
1 | ArrayList<String> list1 = new ArrayList(); //第一种 情况 |
这样是没有错误的,不过会有个编译时警告。
不过在第一种情况,可以实现与完全使用泛型参数一样的效果,第二种则没有效果。
因为类型检查就是编译时完成的,new ArrayList()
只是在内存中开辟了一个存储空间,可以存储任何类型对象,而真正设计类型检查的是它的引用,因为我们是使用它引用 list1
来调用它的方法,比如说调用 add
方法,所以 list1
引用能完成泛型类型的检查。而引用 list2
没有使用泛型,所以不行。
举例子:
1 | public class Test { |
通过上面的例子,我们可以明白,类型检查就是针对引用的,谁是一个引用,用这个引用调用泛型方法,就会对这个引用调用的方法进行类型检测,而无关它真正引用的对象。
泛型中参数话类型为什么不考虑继承关系?
在 Java 中,像下面形式的引用传递是不允许的:
1 | ArrayList<String> list1 = new ArrayList<Object>(); //编译错误 |
我们先看第一种情况,将第一种情况拓展成下面的形式:
1 | ArrayList<Object> list1 = new ArrayList<Object>(); |
实际上,在第 4 行代码的时候,就会有编译错误。那么,我们先假设它编译没错。那么当我们使用 list2
引用用 get()
方法取值的时候,返回的都是 String
类型的对象(上面提到了,类型检测是根据引用来决定的),可是它里面实际上已经被我们存放了 Object
类型的对象,这样就会有 ClassCastException
了。所以为了避免这种极易出现的错误,Java 不允许进行这样的引用传递。(这也是泛型出现的原因,就是为了解决类型转换的问题,我们不能违背它的初衷)。
再看第二种情况,将第二种情况拓展成下面的形式:
1 | ArrayList<String> list1 = new ArrayList<String>(); |
没错,这样的情况比第一种情况好的多,最起码,在我们用 list2
取值的时候不会出现 ClassCastException
,因为是从 String
转换为 Object
。可是,这样做有什么意义呢,泛型出现的原因,就是为了解决类型转换的问题。我们使用了泛型,到头来,还是要自己强转,违背了泛型设计的初衷。所以 java 不允许这么干。再说,你如果又用 list2
往里面 add()
新的对象,那么到时候取得时候,我怎么知道我取出来的到底是 String
类型的,还是 Object
类型的呢?
所以,要格外注意,泛型中的引用传递的问题。
自动类型转换
因为类型擦除的问题,所以所有的泛型类型变量最后都会被替换为原始类型。
既然都被替换为原始类型,那么为什么我们在获取的时候,不需要进行强制类型转换呢?
看下 ArrayList.get()
方法:
1 | public E get(int index) { |
可以看到,在 return
之前,会根据泛型变量进行强转。假设泛型类型变量为 Date
,虽然泛型信息会被擦除掉,但是会将 (E) elementData[index]
,编译为 (Date) elementData[index]
。所以我们不用自己进行强转。当存取一个泛型域时也会自动插入强制类型转换。假设 Pair
类的 value
域是 public
的,那么表达式:
1 | Date date = pair.value; |
也会自动地在结果字节码中插入强制类型转换。
类型擦除与多态的冲突和解决方法
现在有这样一个泛型类:
1 | class Pair<T> { |
然后我们想要一个子类继承它。
1 | class DateInter extends Pair<Date> { |
在这个子类中,我们设定父类的泛型类型为 Pair<Date>
,在子类中,我们覆盖了父类的两个方法,我们的原意是这样的:将父类的泛型类型限定为 Date
,那么父类里面的两个方法的参数都为 Date
类型。
1 | public Date getValue() { |
所以,我们在子类中重写这两个方法一点问题也没有,实际上,从他们的 @Override
标签中也可以看到,一点问题也没有,实际上是这样的吗?
分析:实际上,类型擦除后,父类的的泛型类型全部变为了原始类型 Object
,所以父类编译之后会变成下面的样子:
1 | class Pair { |
再看子类的两个重写的方法的类型:
1 |
|
先来分析 setValue
方法,父类的类型是 Object
,而子类的类型是 Date
,参数类型不一样,这如果实在普通的继承关系中,根本就不会是重写,而是重载。
我们在一个 main 方法测试一下:
1 | public static void main(String[] args) throws ClassNotFoundException { |
如果是重载,那么子类中两个 setValue
方法,一个是参数 Object
类型,一个是 Date
类型,可是我们发现,根本就没有这样的一个子类继承自父类的 Object 类型参数的方法。所以说,却是是重写了,而不是重载了。
为什么会这样呢?
原因是这样的,我们传入父类的泛型类型是 Date,Pair<Date>
,我们的本意是将泛型类变为如下:
1 | class Pair { |
然后再子类中重写参数类型为 Date 的那两个方法,实现继承中的多态。
可是由于种种原因,虚拟机并不能将泛型类型变为 Date
,只能将类型擦除掉,变为原始类型 Object
。这样,我们的本意是进行重写,实现多态。可是类型擦除后,只能变为了重载。这样,类型擦除就和多态有了冲突。JVM 知道你的本意吗?知道!!!可是它能直接实现吗,不能!!!如果真的不能的话,那我们怎么去重写我们想要的 Date
类型参数的方法啊。
于是 JVM 采用了一个特殊的方法,来完成这项功能,那就是桥方法。
首先,我们用 javap -c className
的方式反编译下 DateInter
子类的字节码,结果如下:
1 | class com.tao.test.DateInter extends com.tao.test.Pair<java.util.Date> { |
从编译的结果来看,我们本意重写 setValue
和 getValue
方法的子类,竟然有 4 个方法,其实不用惊奇,最后的两个方法,就是编译器自己生成的桥方法。可以看到桥方法的参数类型都是 Object,也就是说,子类中真正覆盖父类两个方法的就是这两个我们看不到的桥方法。而在我们自己定义的 setvalue
和 getValue
方法上面的 @Oveerride
只不过是假象。而桥方法的内部实现,就只是去调用我们自己重写的那两个方法。
所以,虚拟机巧妙的使用了桥方法,来解决了类型擦除和多态的冲突。
不过,要提到一点,这里面的 setValue
和 getValue
这两个桥方法的意义又有不同。
setValue
方法是为了解决类型擦除与多态之间的冲突。
而 getValue
却有普遍的意义,怎么说呢,如果这是一个普通的继承关系:
那么父类的 getValue
方法如下:
1 | public Object getValue() { |
而子类重写的方法是:
1 | public Date getValue() { |
其实这在普通的类继承中也是普遍存在的重写,这就是协变。
关于协变:。。。。。。
并且,还有一点也许会有疑问,子类中的桥方法 Object getValue()
和 Date getValue()
是同时存在的,可是如果是常规的两个方法,他们的方法签名是一样的,也就是说虚拟机根本不能分别这两个方法。如果是我们自己编写 Java 代码,这样的代码是无法通过编译器的检查的,但是虚拟机却是允许这样做的,因为虚拟机通过参数类型和返回类型来确定一个方法,所以编译器为了实现泛型的多态允许自己做这个看起来 “不合法” 的事情,然后交给虚拟器去区别。
泛型类型变量不能是基本数据类型
不能用类型参数替换基本类型。就比如,没有 ArrayList<double>
,只有 ArrayList<Double>
。因为当类型擦除后,ArrayList
的原始类型变为 Object
,但是 Object
类型不能存储 double
值,只能引用 Double
的值。
编译时集合的 instanceof
1 | ArrayList<String> arrayList = new ArrayList<String>(); |
因为类型擦除之后,ArrayList<String>
只剩下原始类型,泛型信息 String
不存在了。
那么,编译时进行类型查询的时候使用下面的方法是错误的
1 | if( arrayList instanceof ArrayList<String>) |
泛型在静态方法和静态类中的问题
泛型类中的静态方法和静态变量不可以使用泛型类所声明的泛型类型参数
举例说明:
1 | public class Test2<T> { |
因为泛型类中的泛型参数的实例化是在定义对象的时候指定的,而静态变量和静态方法不需要使用对象来调用。对象都没有创建,如何确定这个泛型参数是何种类型,所以当然是错误的。
但是要注意区分下面的一种情况:
1 | public class Test2<T> { |
因为这是一个泛型方法,在泛型方法中使用的 T 是自己在方法中定义的 T,而不是泛型类中的 T。
Reference
java - Comparator.reversed() does not compile using lambda - Stack Overflow
How to use Comparator in java with lambda expression? - Stack Overflow