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

时间:2018-10-29 06:35:52

标签: java generics lambda java-8

上下文

我正在从事一个严重依赖泛型类型的项目。它的关键组件之一就是所谓的TypeToken,它提供了一种在运行时表示泛型类型并在其上应用一些实用程序功能的方法。为了避免Java的Type Erasure,我使用大括号表示法({})创建一个自动生成的子类,因为这使类型可以更改。

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[]>() {};
}

在这种情况下,typeGenericArrayType,它以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)

由此产生的问题

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

澄清一下:对于我的程序,这不是一个“相关的”问题,因为我根本不允许使用非可解析的类型,但这仍然是我想理解的有趣现象。

我的研究

更新1

与此同时,我已经对该主题进行了一些研究。在Java Language Specification §15.12.2.2中,我发现了一个可能与之相关的表达式-“与适用性有关”,其中提到了“隐式类型的lambda表达式”作为例外。显然,这是不正确的一章,但是该表达式在其他地方使用,包括有关类型推断的一章。

但是,老实说:我还没有真正弄清楚所有:=Fi0之类的运算符的含义,这使得很难真正理解它。如果有人可以澄清一下,或者这可能是怪异行为的解释,我会很高兴。

更新2

我再次考虑了这种方法并得出结论,即使编译器会删除类型,因为它与“适用性无关”,也没有理由将组件类型设置为{{1}而不是最慷慨的类型Object。我想不出语言设计师决定这样做的一个单一原因。

更新3

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

更新4

几天前,我已请求在官方Java Bug数据库/跟踪器中输入一个条目,但该条目刚刚被接受。由于审阅我的报告的开发人员将错误的优先级P4分配给该错误,因此可能需要一段时间才能解决该错误。您可以找到报告here

对Tom Hawtin的巨大呼喊-提到这可能是Java SE本身中的重要错误的提示。但是,由于Mike Strobel令人印象深刻的背景知识,他的报告可能比我的报告更为详尽。但是,当我撰写报告时,Strobel的答案尚不可用。

3 个答案:

答案 0 :(得分:13)

  

tldr:

     
      
  1. javac中存在一个错误,该错误记录了嵌入lambda的内部类的错误的包围方法。结果,这些内部类无法解析 actual 封闭方法上的类型变量。
  2.   
  3. java.lang.reflect API实现中可以说存在两组错误:      
        
    • 当遇到不存在的类型时,某些方法被记录为抛出异常,但是从不发生。相反,它们允许传播空引用。
    •   
    • 当类型无法解析时,各种Type::toString()覆盖当前抛出或传播NullPointerException
    •   
  4.   

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

通常,当您编写具有一个或多个通用超类型的类时,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用具体类型进行参数化时,这很好用。让我们看一个更相关的示例,其类型参数包含一个 type变量

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);

    // ...
}

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

EnclosingMethod: LambdaTest.test()V

此属性的存在意味着computeEnclosingScope将为通用方法MethodScope产生一个static <F> void test()。由于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的匿名类型 outside 的情况一样,签名包含类型变量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报告了 actual 词法包围方法,则该方法的类型变量可以由lambda嵌入的类解析,并且您的代码将产生预期的结果。

可以说这也是一个错误,签名解析器/修复程序默默地允许将null类型的参数传播到ParameterizedType中(正如@ tom-hawtin-tackline指出的那样,该参数具有辅助toString()这样的效果会引发NPE)。

我的EnclosingMethod问题的bug report现在在线。

(ii)java.lang.reflect及其支持的API中可能存在多个错误。

当“任何实际类型参数都引用不存在的类型声明”时,方法ParameterizedType::getActualTypeArguments()被记录为抛出TypeNotPresentException。该描述可以说涵盖了类型变量不在范围内的情况。当“基础数组类型的类型引用不存在的类型声明”时,GenericArrayType::getGenericComponentType()应该引发类似的异常。目前,在任何情况下都没有一个抛出TypeNotPresentException

我还要指出,各种Type::toString覆盖应该只填写任何未解析类型的规范名称,而不是抛出NPE或任何其他异常。

我已针对这些与反射相关的问题提交了错误报告,一旦链接公开可见,我将发布该链接。

解决方法?

如果您需要能够引用封闭方法声明的类型变量,则不能使用lambda来实现;您将不得不使用更长的匿名类型语法。但是,lambda版本在大多数其他情况下也可以使用。您甚至应该能够引用由封闭的 class 声明的类型变量。例如,这些应该始终有效:

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中,假设它完全固定了。

答案 1 :(得分:1)

作为一种解决方法,您可以将TypeToken的创建从lambda移到单独的方法,并且仍然使用lambda而不是完全声明的类:

static<T> TypeToken<T[]> createTypeToken() {
    return new TypeToken<T[]>() {};
}

Supplier<TypeToken<T[]>> sup = () -> createTypeToken();

答案 2 :(得分:1)

我还没有找到规范的相关部分,但这是部分答案。

组件类型为null的确存在错误。需要明确的是,这是通过调用方法TypeToken.type从上方强制转换为GenericArrayType的{​​{1}}(真糟糕!)。 API文档未明确提及返回的getGenericComponentType是否有效。但是,null方法会抛出toString,因此肯定存在一个错误(至少在我使用的Java的随机版本中)。

我没有 bugs.java.com 帐户,因此无法举报。有人应该。

让我们看一下生成的类文件。

NullPointerException

这将产生一个包含以下内容的清单:

javap -private YourClass

请注意,我们的显式static <T> void test(); private static TypeToken lambda$test$0(); 方法具有类型参数,但合成的lambda方法则没有。您可能会期望像这样:

test

为什么不会发生这种情况。大概是因为在需要类型类型参数的情况下,这将是方法类型参数,并且它们是令人惊讶的不同。对lambda的另一个限制是不允许它们具有方法类型的参数,这显然是因为没有显式的符号(某些人可能认为这似乎是一个不好的借口)。

结论:这里至少有一个未报告的JDK错误。 static <T> void test(); private static <T> TypeToken<T[]> lambda$test$0(); /*** DOES NOT HAPPEN ***/ // ^ name copied from `test` // ^^^ `Object[]` would not make sense API和该语言的lambda + generics部分并不符合我的口味。