使用 lambda 而不是显式匿名内部类时的不同泛型行为
Posted
技术标签:
【中文标题】使用 lambda 而不是显式匿名内部类时的不同泛型行为【英文标题】:Different generic behaviour when using lambda instead of explicit anonymous inner class 【发布时间】:2019-04-02 01:25:06 【问题描述】:上下文
我正在从事一个严重依赖泛型类型的项目。它的一个关键组件是所谓的TypeToken
,它提供了一种在运行时表示泛型类型并在其上应用一些实用函数的方法。为了避免 Java 的类型擦除,我使用大括号表示法 () 来创建一个自动生成的子类,因为这使得类型可具体化。
TypeToken
的基本作用
这是TypeToken
的一个高度简化的版本,比原来的实现要宽松得多。不过,我使用这种方法是为了确保真正的问题不在于这些实用程序函数之一。
public class TypeToken<T>
private final Type type;
private final Class<T> rawType;
private final int hashCode;
/* ==== Constructor ==== */
@SuppressWarnings("unchecked")
protected TypeToken()
ParameterizedType paramType = (ParameterizedType) this.getClass().getGenericSuperclass();
this.type = paramType.getActualTypeArguments()[0];
// ...
什么时候起作用
基本上,这种实现几乎适用于所有情况。 处理大多数类型没有问题。以下示例完美运行:
TypeToken<List<String>> token = new TypeToken<List<String>>() ;
TypeToken<List<? extends CharSequence>> token = new TypeToken<List<? extends CharSequence>>() ;
由于它不检查类型,因此上面的实现允许编译器允许的每种类型,包括 TypeVariables。
<T> void test()
TypeToken<T[]> token = new TypeToken<T[]>() ;
在这种情况下,type
是一个 GenericArrayType
,将 TypeVariable
作为其组件类型。这完全没问题。
使用 lambda 时的奇怪情况
但是,当您在 lambda 表达式中初始化 TypeToken
时,情况开始发生变化。 (类型变量来源于上面的test
函数)
Supplier<TypeToken<T[]>> sup = () -> new TypeToken<T[]>() ;
在这种情况下,type
仍然是 GenericArrayType
,但它持有 null
作为其组件类型。
但是,如果您正在创建一个匿名内部类,事情又开始发生变化:
Supplier<TypeToken<T[]>> sup = new Supplier<TypeToken<T[]>>()
@Override
public TypeToken<T[]> get()
return new TypeToken<T[]>() ;
;
在这种情况下,组件类型再次保持正确的值(TypeVariable)
产生的问题
-
lambda 示例中的 TypeVariable 会发生什么情况?为什么类型推断不尊重泛型类型?
显式声明的示例和隐式声明的示例有什么区别?类型推断是唯一的区别吗?
如何在不使用样板显式声明的情况下解决此问题?这在单元测试中变得尤为重要,因为我想检查构造函数是否抛出异常。
澄清一下:这不是与程序“相关”的问题,因为我根本不允许不可解析的类型,但它仍然是我想了解的有趣现象。
我的研究
更新 1
与此同时,我对这个主题进行了一些研究。在Java Language Specification §15.12.2.2 中,我发现了一个可能与它有关的表达式——“与适用性相关”,提到“隐式类型的 lambda 表达式”是一个例外。很明显,这是错误的章节,但是这个表达式在其他地方使用,包括关于类型推断的章节。
但老实说:我还没有真正弄清楚像 :=
或 Fi0
这样的所有这些运算符的含义是什么让我很难详细理解它。如果有人能澄清一下,如果这可能是对奇怪行为的解释,我会很高兴。
更新 2
我再次考虑了这种方法并得出结论,即使编译器会删除该类型,因为它与“适用性无关”,也没有理由将组件类型设置为 null
最慷慨的类型,对象。我想不出语言设计者决定这样做的单一原因。
更新 3
我刚刚使用最新版本的 Java 重新测试了相同的代码(我之前使用过 8u191
)。令我遗憾的是,这并没有改变任何东西,尽管 Java 的类型推断得到了改进......
更新 4
几天前我在官方的 Java Bug Database/Tracker 中申请了一个条目,它刚刚被接受。由于审查我的报告的开发人员将优先级 P4 分配给了该错误,因此可能需要一段时间才能修复它。您可以找到报告here。
对 Tom Hawtin 大喊大叫 - 提到这可能是 Java SE 本身的一个重要错误。但是,由于 Mike Strobel 令人印象深刻的背景知识,他的报告可能会比我的更详细。然而,当我写报告时,Strobel 的答案还没有出现。
【问题讨论】:
您不能泛泛地声明类型标记并期望它们做任何有用的事情。如果可以,就不需要类型标记。如果你想要一个泛型类型标记,你必须传入一个用非泛型代码构造的实例。 我完全知道上面的构造函数没有解析类型变量,但这不是TypeToken
的意图。它应该保存不同的通用定义类型信息并对其进行一些操作。显然,不可能知道 T 是哪种类型,我只是想知道为什么在从 lambda 调用时无法访问 TypeVariable 信息,但是当我从显式声明的匿名内部类调用相同的构造函数时可以这样做。
TypeVarialbe 信息是指 java.lang.reflect 类的表示,而不是实际的类型信息。
@TT。 docs.oracle.com/javase/tutorial/java/generics/…
这个问题比我预期的要有趣得多,它暴露了编译器和核心反射 API 中的一系列明显的长期存在的错误。运气好的话,我们至少会看到它们在 JDK 11 中得到修复,甚至有望向后移植到 JDK 8。感谢发帖!
【参考方案1】:
tldr:
javac
中有一个错误,它记录了错误的 lambda 嵌入内部类的封闭方法。因此,这些内部类无法解析 actual 封闭方法上的类型变量。java.lang.reflect
API 实现中可能存在两组错误: 当遇到不存在的类型时,某些方法被记录为抛出异常,但它们从不抛出异常。相反,它们允许传播空引用。 当前,当无法解析类型时,各种Type::toString()
覆盖会抛出或传播NullPointerException
。
答案与通常在使用泛型的类文件中发出的泛型签名有关。
通常,当您编写具有一个或多个泛型超类型的类时,Java 编译器将发出一个 Signature
属性,其中包含该类超类型的完全参数化的泛型签名。我有written about these before,但简短的解释是:没有它们,就不可能使用泛型类型作为泛型类型,除非你碰巧有源代码。由于类型擦除,有关类型变量的信息会在编译时丢失。如果该信息没有作为额外的元数据包含在内,那么 IDE 和您的编译器都不会知道某个类型是泛型的,并且您不能这样使用它。编译器也不能发出必要的运行时检查来强制类型安全。
javac
将为签名包含类型变量或参数化类型的任何类型或方法发出泛型签名元数据,这就是为什么您能够获取匿名类型的原始泛型超类型信息的原因。比如这里创建的匿名类型:
TypeToken<?> token = new TypeToken<List<? extends CharSequence>>() ;
...包含这个Signature
:
LTypeToken<Ljava/util/List<+Ljava/lang/CharSequence;>;>;
由此,java.lang.reflection
API 可以解析关于您的(匿名)类的通用超类型信息。
但我们已经知道,当TypeToken
使用具体类型参数化时,它就可以正常工作。让我们看一个更相关的例子,它的类型参数包含一个类型变量:
static <F> void test()
TypeToken sup = new TypeToken<F[]>() ;
在这里,我们得到以下签名:
LTypeToken<[TF;>;
有道理,对吧?现在,让我们看看java.lang.reflect
API 是如何从这些签名中提取通用超类型信息的。如果我们查看Class::getGenericSuperclass()
,我们会看到它所做的第一件事就是调用getGenericInfo()
。如果我们之前没有调用过这个方法,ClassRepository
会被实例化:
private ClassRepository getGenericInfo()
ClassRepository genericInfo = this.genericInfo;
if (genericInfo == null)
String signature = getGenericSignature0();
if (signature == null)
genericInfo = ClassRepository.NONE;
else
// !!! RELEVANT LINE HERE: !!!
genericInfo = ClassRepository.make(signature, getFactory());
this.genericInfo = genericInfo;
return (genericInfo != ClassRepository.NONE) ? genericInfo : null;
这里的关键部分是对getFactory()
的调用,它扩展为:
CoreReflectionFactory.make(this, ClassScope.make(this))
ClassScope
是我们关心的位:它为类型变量提供了解析范围。给定类型变量名称,在范围内搜索匹配的类型变量。如果没有找到,则搜索“外部”或封闭范围:
public TypeVariable<?> lookup(String name)
TypeVariable<?>[] tas = getRecvr().getTypeParameters();
for (TypeVariable<?> tv : tas)
if (tv.getName().equals(name)) return tv;
return getEnclosingScope().lookup(name);
最后,这一切的关键(来自ClassScope
):
protected Scope computeEnclosingScope()
Class<?> receiver = getRecvr();
Method m = receiver.getEnclosingMethod();
if (m != null)
// Receiver is a local or anonymous class enclosed in a method.
return MethodScope.make(m);
// ...
如果在类本身(例如匿名TypeToken<F[]>
)上找不到类型变量(例如F
),那么下一步是搜索封闭方法。如果我们查看反汇编的匿名类,我们会看到这个属性:
EnclosingMethod: LambdaTest.test()V
此属性的存在意味着computeEnclosingScope
将为泛型方法static <F> void test()
生成MethodScope
。由于test
声明了类型变量W
,所以我们在搜索封闭范围时会找到它。
那么,为什么它不能在 lambda 中工作?
要回答这个问题,我们必须了解 lambda 是如何编译的。将 lambda gets moved 的主体转化为合成静态方法。在我们声明 lambda 时,会发出一个 invokedynamic
指令,这会导致在我们第一次点击该指令时生成一个 TypeToken
实现类。
在此示例中,为 lambda 主体生成的静态方法将如下所示(如果已反编译):
private static /* synthetic */ Object lambda$test$0()
return new LambdaTest$1();
...LambdaTest$1
是你的匿名类。让我们反汇编它并检查我们的属性:
Signature: LTypeToken<TW;>;
EnclosingMethod: LambdaTest.lambda$test$0()Ljava/lang/Object;
就像我们在 lambda 之外实例化匿名类型 的情况一样,签名包含类型变量 W
。 但EnclosingMethod
指的是合成方法。
合成方法lambda$test$0()
没有声明类型变量W
。而且lambda$test$0()
没有被test()
包围,所以W
的声明在里面是不可见的。您的匿名类有一个超类型,其中包含您的类不知道的类型变量,因为它超出了范围。
当我们调用getGenericSuperclass()
时,LambdaTest$1
的作用域层次结构中不包含W
,因此解析器无法解析它。由于代码的编写方式,这个未解析的类型变量导致null
被放置在泛型超类型的类型参数中。
请注意,如果您的 lambda 实例化了一个不引用任何类型变量的类型(例如,TypeToken<String>
),那么您就不会遇到这个问题。
结论
(i) javac
中有一个错误。 Java 虚拟机规范 §4.7.7(“EnclosingMethod
属性”)指出:
Java 编译器有责任确保通过
method_index
标识的方法确实是包含此EnclosingMethod
属性的类的最接近的词法封闭 方法。 (强调我的)
目前,javac
似乎在 lambda 重写器运行其过程之后确定封闭方法,因此,EnclosingMethod
属性指的是一种甚至从未存在于词法中的方法范围。如果EnclosingMethod
报告了 实际 词法封闭方法,则该方法上的类型变量可以由 lambda 嵌入类解析,并且您的代码将产生预期的结果。
签名解析器/解析器静默允许将null
类型参数传播到ParameterizedType
中(正如@tom-hawtin-tackline 指出的那样,具有类似@987654383 的辅助效果),这也是一个可以说的错误@抛出一个NPE)。
我的bug reportEnclosingMethod
问题现已上线。
(ii) java.lang.reflect
及其支持 API 中可能存在多个错误。
当“任何实际类型参数引用不存在的类型声明”时,方法ParameterizedType::getActualTypeArguments()
被记录为抛出TypeNotPresentException
。该描述可以说涵盖了类型变量不在范围内的情况。当“底层数组类型的类型引用不存在的类型声明”时,GenericArrayType::getGenericComponentType()
应该抛出类似的异常。目前,在任何情况下似乎都不会抛出TypeNotPresentException
。
我还认为,各种 Type::toString
覆盖应该只填写任何未解析类型的规范名称,而不是抛出 NPE 或任何其他异常。
我已针对这些与反射相关的问题提交了错误报告,一旦该链接公开可见,我将发布该链接。
解决方法?
如果您需要能够引用由封闭方法声明的类型变量,那么您不能使用 lambda 来执行此操作;您将不得不回退到更长的匿名类型语法。但是,lambda 版本应该适用于大多数其他情况。你甚至应该能够引用由封闭的类声明的类型变量。例如,这些应该始终有效:
class Test<X>
void test()
Supplier<TypeToken<X>> s1 = () -> new TypeToken<X>() ;
Supplier<TypeToken<String>> s2 = () -> new TypeToken<String>() ;
Supplier<TypeToken<List<String>>> s3 = () -> new TypeToken<List<String>>() ;
不幸的是,鉴于此错误自 lambda 首次引入以来显然已存在,并且尚未在最新的 LTS 版本中修复,您可能不得不假设该错误在修复后很长时间仍保留在客户的 JDK 中,假设它得到了修复。
【讨论】:
答案已更新,并提供了javac
和 java.lang.reflect
API 中存在错误的支持证据。
你成就了我的一天
有一个问题,“编译器也不能发出必要的运行时检查来强制类型安全”是什么意思?编译器发出的那些运行时检查是什么?【参考方案2】:
作为一种解决方法,您可以将 TypeToken 的创建从 lambda 移到一个单独的方法中,并且仍然使用 lambda 而不是完全声明的类:
static<T> TypeToken<T[]> createTypeToken()
return new TypeToken<T[]>() ;
Supplier<TypeToken<T[]>> sup = () -> createTypeToken();
【讨论】:
这并没有真正让他得到相同的行为:生成的TypeToken
将具有与使用匿名类的版本不同的类型参数。具体来说,结果将始终具有TypeToken<T[]>
的通用超类,其中T
是createTypeToken
声明的类型变量。大概 OP 想要使用特定类型参数实例化 TypeToken
并能够在运行时解析相同的类型。【参考方案3】:
我没有找到规范的相关部分,但这里是部分答案。
组件类型为null
肯定存在错误。需要明确的是,这是 TypeToken.type
从上面的转换到 GenericArrayType
(糟糕!)并调用了 getGenericComponentType
方法。 API 文档没有明确提及返回的null
是否有效。但是toString
方法抛出NullPointerException
,所以肯定有bug(至少在我使用的随机Java版本中)。
我没有 bugs.java.com 帐户,因此无法报告。应该有人。
让我们看看生成的类文件。
javap -private YourClass
这应该会产生一个包含以下内容的列表:
static <T> void test();
private static TypeToken lambda$test$0();
请注意,我们的显式 test
方法有它的类型参数,但合成 lambda 方法没有。你可能会期待这样的事情:
static <T> void test();
private static <T> TypeToken<T[]> lambda$test$0(); /*** DOES NOT HAPPEN ***/
// ^ name copied from `test`
// ^^^ `Object[]` would not make sense
为什么不会发生这种情况。大概是因为这将是在需要类型类型参数的上下文中的方法类型参数,并且它们是令人惊讶的不同事物。 lambda 也有一个限制,不允许它们具有方法类型参数,显然是因为没有明确的表示法(有些人可能会认为这似乎是一个糟糕的借口)。
结论:这里有至少一个未报告的 JDK 错误。 reflect
API 和该语言的这个 lambda+泛型部分不合我的口味。
【讨论】:
在我看来,合成方法不应该获取类型参数。毕竟,此类类型参数与test
的类型参数无关,因此报告的TypeToken
参数化会造成更多混乱。正确的行为应该是本地类将 test()
报告为其封闭方法,因为 lambda 表达式的行为应该像在其周围的上下文中一样,即不应反映它们的编译方式。
@Holger 同意。我倾向于将“封闭在”解释为“声明在”,即由编写代码的开发人员声明。不能在合成方法中声明一个类,因为该方法从未以源代码形式存在。
@MikeStrobel java.lang.Class
同时拥有getEnclosingClass
和getDeclaringClass
。封闭与直接封闭的类有关(也许它本身就是一个嵌套类。声明与成员类的封闭相同,但为本地/内部/匿名/***类报告null
。所以我们可以确定这是关于内部类的虚构,而不是真正的 JVM 类或合成方法。/我猜在稍微不同的情况下,test
的类型参数可能是不可表示的(也许,我认为,不确定)。
@TomHawtin-tackline 请允许我重新表述:封闭方法或类是最近的包含声明站点的外部方法或类范围。这是指源代码范围(预编译)。在m()
中声明的匿名内部类的EnclosingMethod
是m()
,即使编译的内部类位于m()
及其封闭类之外。对于在 m()
内部的 lambda 中声明的匿名类,将 m()
作为其 EnclosingMethod
是一致的。编译器移动其实例化并不重要。
根据JVMS §4.7.7,这确实是一个bug。 EnclosingMethod
明确引用了包含该属性的类的“最接近的lexically enclosure 方法”(根据定义,它不能是源代码中不存在的合成方法)。此外,各种java.lang.reflect
API 的 javadocs 表明,当无法解析类型时,当前实现无法引发适当的异常。我已经相应地更新了我的答案,并向 Oracle 提交了错误报告。以上是关于使用 lambda 而不是显式匿名内部类时的不同泛型行为的主要内容,如果未能解决你的问题,请参考以下文章