使用lambda而不是显式匿名内部类时的不同通用行为

Qua*_*fel 54 java generics lambda java-8

上下文

我正在开发一个严重依赖泛型类型的项目.它的一个关键组件是所谓的TypeToken,它提供了一种在运行时表示泛型类型并在其上应用一些实用函数的方法.为了避免Java的类型擦除,我使用大括号notation({})来创建一个自动生成的子类,因为这使得类型可以恢复.

什么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];

        // ...
    } 
Run Code Online (Sandbox Code Playgroud)

当它工作

基本上,这种实现几乎适用于所有情况.处理大多数类型没有问题.以下示例完美运行:

TypeToken<List<String>> token = new TypeToken<List<String>>() {};
TypeToken<List<? extends CharSequence>> token = new TypeToken<List<? extends CharSequence>>() {};
Run Code Online (Sandbox Code Playgroud)

由于它不检查类型,上面的实现允许编译器允许的每种类型,包括TypeVariables.

<T> void test() {
    TypeToken<T[]> token = new TypeToken<T[]>() {};
}
Run Code Online (Sandbox Code Playgroud)

在这种情况下,typeGenericArrayType将a TypeVariable作为其组件类型.这很好.

使用lambdas时的奇怪情况

但是,在初始化TypeTokenlambda表达式内部时,事情开始发生变化.(类型变量来自test上面的函数)

Supplier<TypeToken<T[]>> sup = () -> new TypeToken<T[]>() {};
Run Code Online (Sandbox Code Playgroud)

在这种情况下,type仍然是一个GenericArrayType,但它null作为其组件类型.

但是如果你正在创建一个匿名内部类,事情就会再次发生变化:

Supplier<TypeToken<T[]>> sup = new Supplier<TypeToken<T[]>>() {
        @Override
        public TypeToken<T[]> get() {
            return new TypeToken<T[]>() {};
        }
    };
Run Code Online (Sandbox Code Playgroud)

在这种情况下,组件类型再次保持正确的值(TypeVariable)

由此产生的问题

  1. lambda示例中的TypeVariable会发生什么?为什么类型推断不遵循泛型类型?
  2. 显式声明和隐式声明的示例之间有什么区别?类型推断是唯一的区别吗?
  3. 如何在不使用样板明确声明的情况下解决此问题?这在单元测试中变得尤为重要,因为我想检查构造函数是否抛出异常.

稍微澄清一点:这不是一个与程序"相关"的问题,因为我根本不允许不可解析的类型,但它仍然是一个我想要了解的有趣现象.

我的研究

更新1

同时,我对这个话题做了一些研究.在Java语言规范§15.12.2.2中,我发现了一个可能与它有关的表达式 - "与适用性相关",提到"隐式类型的lambda表达式"作为例外.显然,这是不正确的章节,但表达式在其他地方使用,包括关于类型推断的章节.

但说实话:我还没有真正弄清楚所有这些操作员喜欢:=或者Fi0意味着什么使得它很难理解它.如果有人能够澄清这一点,我会很高兴,如果这可能是对奇怪行为的解释.

更新2

我再次考虑了这种方法并得出结论,即使编译器会删除该类型,因为它不是"与适用性相关",因此将组件类型设置为null而不是最慷慨的类型是不合理的,宾语.我想不出语言设计师决定这么做的一个原因.

更新3

我刚刚使用最新版本的Java(8u191之前使用过的)重新测试了相同的代码.令我遗憾的是,尽管Java的类型推断得到了改进,但这并没有改变任何东西......

更新4

几天前我已经要求在官方Java Bug数据库/跟踪器中输入一个条目,它就被接受了.由于审阅我的报告的开发人员将优先级P4分配给该错误,因此可能需要一段时间才能修复.你可以在这里找到报告.

汤姆·霍金(Tom Hawtin)的巨大呐喊 - 提到这可能是Java SE本身的一个重要错误.然而,由于他令人印象深刻的背景知识,Mike Strobel的报告可能比我的更详细.但是,当我撰写报告时,斯特罗贝尔的答案尚未公布.

Mik*_*bel 14

tldr:

  1. 有一个错误javac记录了lambda嵌入内部类的错误封闭方法.因此,这些内部类无法解析实际封闭方法上的类型变量.
  2. java.lang.reflectAPI实现中可能存在两组错误:
    • 有些方法被记录为在遇到不存在的类型时抛出异常,但它们永远不会.相反,它们允许空引用传播.
    • 当无法解析类型时,各种Type::toString()覆盖当前抛出或传播NullPointerException.

答案与通常在使用泛型的类文件中发出的通用签名有关.

通常,当您编写具有一个或多个通用超类型的类时,Java编译器将发出Signature包含类的超类型的完全参数化通用签名的属性.我以前写过这些,但简短的解释是:如果没有它们,除非你碰巧有源代码,否则不可能将泛型类型作为泛型类型使用.由于类型擦除,有关类型变量的信息在编译时会丢失.如果该信息未作为额外元数据包含在内,则IDE和编译器都不会知道某个类型是通用的,并且您无法使用它.编译器也不能发出必要的运行时检查来强制类型安全.

javac将为其签名包含类型变量或参数化类型的任何类型或方法发出通用签名元数据,这就是您能够获取匿名类型的原始通用超类型信息的原因.例如,此处创建的匿名类型:

TypeToken<?> token = new TypeToken<List<? extends CharSequence>>() {};
Run Code Online (Sandbox Code Playgroud)

...包含这个Signature:

LTypeToken<Ljava/util/List<+Ljava/lang/CharSequence;>;>;
Run Code Online (Sandbox Code Playgroud)

由此,java.lang.reflectionAPI可以解析有关您的(匿名)类的通用超类型信息.

但是我们已经知道,当TypeToken使用具体类型进行参数化时,这很好用.让我们看一个更相关的例子,其类型参数包括一个类型变量:

static <F> void test() {
    TypeToken sup = new TypeToken<F[]>() {};
}
Run Code Online (Sandbox Code Playgroud)

在这里,我们得到以下签名:

LTypeToken<[TF;>;
Run Code Online (Sandbox Code Playgroud)

有道理,对吗?现在,让我们看看java.lang.reflectAPI 如何从这些签名中提取通用超类型信息.如果我们进入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;
}
Run Code Online (Sandbox Code Playgroud)

这里的关键部分是呼叫getFactory(),扩展到:

CoreReflectionFactory.make(this, ClassScope.make(this))
Run Code Online (Sandbox Code Playgroud)

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);
}
Run Code Online (Sandbox Code Playgroud)

最后,这一切的关键(来自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);

    // ...
}
Run Code Online (Sandbox Code Playgroud)

如果F在类本身(例如,匿名TypeToken<F[]>)上找不到类型变量(例如),则下一步是搜索封闭方法.如果我们查看反汇编的匿名类,我们会看到这个属性:

EnclosingMethod: LambdaTest.test()V
Run Code Online (Sandbox Code Playgroud)

此属性的存在意味着computeEnclosingScope将为MethodScope泛型方法生成a static <F> void test().由于test声明了类型变量W,我们在搜索封闭范围时会找到它.

那么,为什么它不能在lambda中运行?

要回答这个问题,我们必须了解如何编译lambdas.lambda的主体被移动到合成静态方法中.在我们声明我们的lambda时,invokedynamic会发出一条指令,这会导致在TypeToken我们第一次触发该指令时生成一个实现类.

在这个例子中,为lambda体生成的静态方法看起来像这样(如果反编译):

private static /* synthetic */ Object lambda$test$0() {
    return new LambdaTest$1();
}
Run Code Online (Sandbox Code Playgroud)

...... LambdaTest$1你的匿名课在哪里 让我们解散它并检查我们的属性:

Signature: LTypeToken<TW;>;
EnclosingMethod: LambdaTest.lambda$test$0()Ljava/lang/Object;
Run Code Online (Sandbox Code Playgroud)

就像我们在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嵌入类来解析,并且您的代码将产生预期的结果.

这可能也是一个错误,签名解析器/ reifier默默地允许null类型参数传播到a ParameterizedType(正如@ tom-hawtin-tackline所指出的那样,具有像toString()投掷NPE 这样的辅助效果).

我的问题的错误报告EnclosingMethod现已在线.

(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>>() {};
    }
}
Run Code Online (Sandbox Code Playgroud)

不幸的是,鉴于自从lambdas首次引入以来已经存在这个bug,并且在最近的LTS版本中没有修复它,你可能不得不假设它在修复后很长时间内仍然存在于客户端的JDK中,假设它得到了完全固定.