java son Comportamiento genérico diferente cuando se utiliza lambda en lugar de una clase interna anónima explícita




que son las clases internas java (3)

El contexto

Estoy trabajando en un proyecto que depende en gran medida de los tipos genéricos. Uno de sus componentes clave es el llamado TypeToken , que proporciona una manera de representar tipos genéricos en tiempo de ejecución y aplicar algunas funciones de utilidad en ellos. Para evitar el borrado de tipo de Java, estoy usando la notación de corchetes ( {} ) para crear una subclase generada automáticamente, ya que esto hace que el tipo sea confiable.

Lo que TypeToken básicamente hace

Esta es una versión fuertemente simplificada de TypeToken que es mucho más indulgente que la implementación original. Sin embargo, estoy usando este enfoque para asegurarme de que el problema real no se encuentra en una de esas funciones de utilidad.

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

        // ...
    } 

Cuando funciona

Básicamente, esta implementación funciona perfectamente en casi todas las situaciones. No tiene ningún problema con el manejo de la mayoría de los tipos. Los siguientes ejemplos funcionan perfectamente:

TypeToken<List<String>> token = new TypeToken<List<String>>() {};
TypeToken<List<? extends CharSequence>> token = new TypeToken<List<? extends CharSequence>>() {};

Como no verifica los tipos, la implementación anterior permite todos los tipos que permite el compilador, incluidas las variables de tipo.

<T> void test() {
    TypeToken<T[]> token = new TypeToken<T[]>() {};
}

En este caso, type es un GenericArrayType tiene una TypeVariable como su tipo de componente. Esto está perfectamente bien.

La extraña situación al usar las lambdas.

Sin embargo, cuando inicializa un TypeToken dentro de una expresión lambda, las cosas comienzan a cambiar. (La variable de tipo viene de la función de test arriba)

Supplier<TypeToken<T[]>> sup = () -> new TypeToken<T[]>() {};

En este caso, el type sigue siendo un GenericArrayType , pero se mantiene null como su tipo de componente.

Pero si estás creando una clase interna anónima, las cosas comienzan a cambiar nuevamente:

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

En este caso, el tipo de componente contiene nuevamente el valor correcto (TypeVariable)

Las preguntas resultantes

  1. ¿Qué sucede con la TypeVariable en el ejemplo de lambda? ¿Por qué la inferencia de tipo no respeta el tipo genérico?
  2. ¿Cuál es la diferencia entre el ejemplo declarado explícitamente y el ejemplo declarado implícitamente? ¿Es la inferencia de tipos la única diferencia?
  3. ¿Cómo puedo solucionar esto sin usar la declaración explícita repetitiva? Esto es especialmente importante en las pruebas unitarias, ya que quiero verificar si el constructor lanza excepciones o no.

Para aclararlo un poco: este no es un problema que sea "relevante" para el programa, ya que NO permito en absoluto los tipos que no se pueden resolver, pero es un fenómeno interesante que me gustaría entender.

Mi investigación

Actualización 1

Mientras tanto, he hecho algunas investigaciones sobre este tema. En la Especificación del lenguaje Java §15.12.2.2 , he encontrado una expresión que podría tener algo que ver con eso: "pertinente a la aplicabilidad", mencionando "expresión lambda tipificada implícitamente" como una excepción. Obviamente, es el capítulo incorrecto, pero la expresión se usa en otros lugares, incluido el capítulo sobre inferencia de tipos.

Pero, para ser honesto, aún no he Fi0 qué les gusta a todos esos operadores := o Fi0 significa lo que hace que sea muy difícil entenderlo en detalle. Me alegraría si alguien pudiera aclarar esto un poco y si esta pudiera ser la explicación del extraño comportamiento.

Actualización 2

He pensado en ese enfoque de nuevo y llegué a la conclusión de que incluso si el compilador eliminaría el tipo ya que no es "pertinente a la aplicabilidad", no justifica establecer el tipo de componente en null lugar del tipo más generoso. , Objeto. No puedo pensar en una sola razón por la que los diseñadores de idiomas decidieron hacerlo.

Actualización 3

Acabo de volver a probar el mismo código con la última versión de Java (usé 8u191 antes). Lamentablemente, esto no ha cambiado nada, aunque se ha mejorado la inferencia de tipos de Java ...

Actualización 4

He solicitado una entrada en la base de datos / rastreador de errores de Java oficial hace unos días y acaba de ser aceptada. Dado que los desarrolladores que revisaron mi informe asignaron la prioridad P4 al error, puede tardar un tiempo hasta que se solucione. Puedes encontrar el informe here .

Un gran agradecimiento a Tom Hawtin - táctica por mencionar que esto podría ser un error esencial en la propia Java SE. Sin embargo, un informe de Mike Strobel probablemente sería mucho más detallado que el mío debido a su impresionante conocimiento de fondo. Sin embargo, cuando escribí el informe, la respuesta de Strobel aún no estaba disponible.


tldr:

  1. Hay un error en javac que registra el método de cierre incorrecto para las clases internas embebidas en lambda. Como resultado, las variables de tipo en el método de encierre real no pueden ser resueltas por esas clases internas.
  2. Podría decirse que hay dos conjuntos de errores en la implementación de la API java.lang.reflect :
    • Algunos métodos se documentan como excepciones de lanzamiento cuando se encuentran tipos no existentes, pero nunca lo hacen. En su lugar, permiten que las referencias nulas se propaguen.
    • Las diversas sustituciones de Type::toString() actualmente lanzan o propagan una NullPointerException cuando no se puede resolver un tipo.

La respuesta tiene que ver con las firmas genéricas que normalmente se emiten en archivos de clase que hacen uso de genéricos.

Normalmente, cuando escribe una clase que tiene uno o más supertipos genéricos, el compilador de Java emitirá un atributo de Signature contiene las firmas genéricas totalmente parametrizadas de los supertipos de la clase. He escrito sobre esto antes , pero la breve explicación es la siguiente: sin ellos, no sería posible consumir tipos genéricos como tipos genéricos a menos que tenga el código fuente. Debido al borrado de tipo, la información sobre las variables de tipo se pierde en el momento de la compilación. Si esa información no se incluyera como metadatos adicionales, ni el IDE ni su compilador sabrían que un tipo era genérico, y no podría usarlo como tal. El compilador tampoco podría emitir las comprobaciones de tiempo de ejecución necesarias para hacer cumplir la seguridad de tipos.

javac emitirá metadatos de firma genéricos para cualquier tipo o método cuya firma contenga variables de tipo o un tipo parametrizado, por lo que puede obtener la información de supertipo genérica original para sus tipos anónimos. Por ejemplo, el tipo anónimo creado aquí:

TypeToken<?> token = new TypeToken<List<? extends CharSequence>>() {};

... contiene esta Signature :

LTypeToken<Ljava/util/List<+Ljava/lang/CharSequence;>;>;

A partir de esto, las API java.lang.reflection pueden analizar la información genérica de supertipo sobre su clase (anónima).

Pero ya sabemos que esto funciona bien cuando el TypeToken está parametrizado con tipos concretos. Veamos un ejemplo más relevante, donde su parámetro de tipo incluye una variable de tipo :

static <F> void test() {
    TypeToken sup = new TypeToken<F[]>() {};
}

Aquí, obtenemos la siguiente firma:

LTypeToken<[TF;>;

Tiene sentido, ¿verdad? Ahora, veamos cómo las API java.lang.reflect pueden extraer información de supertipo genérica de estas firmas. Si miramos en Class::getGenericSuperclass() , vemos que lo primero que hace es llamar a getGenericInfo() . Si no hemos llamado antes a este método, se ClassRepository una instancia de 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;
}

La pieza crítica aquí es la llamada a getFactory() , que se expande a:

CoreReflectionFactory.make(this, ClassScope.make(this))

ClassScope es lo que nos importa: proporciona un alcance de resolución para las variables de tipo. Dado un nombre de variable de tipo, el ámbito busca una variable de tipo coincidente. Si no se encuentra uno, se busca el ámbito 'externo' o de cierre :

public TypeVariable<?> lookup(String name) {
    TypeVariable<?>[] tas = getRecvr().getTypeParameters();
    for (TypeVariable<?> tv : tas) {
        if (tv.getName().equals(name)) {return tv;}
    }
    return getEnclosingScope().lookup(name);
}

Y, finalmente, la clave de todo (de 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);

    // ...
}

Si una variable de tipo (por ejemplo, F ) no se encuentra en la clase en sí (por ejemplo, el TypeToken<F[]> anónimo), el siguiente paso es buscar el método de inclusión . Si observamos la clase anónima desensamblada, vemos este atributo:

EnclosingMethod: LambdaTest.test()V

La presencia de este atributo significa que computeEnclosingScope producirá un MethodScope para el método genérico static <F> void test() . Como la test declara la variable de tipo W , la encontramos cuando buscamos en el ámbito que la contiene.

Entonces, ¿por qué no funciona dentro de un lambda?

Para responder esto, debemos entender cómo se compilan las lambdas. El cuerpo de la lambda se traslada a un método estático sintético. En el punto en el que declaramos nuestro lambda, se emite una instrucción invokedynamic , lo que hace que se TypeToken una clase de implementación TypeToken la primera vez que TypeToken esa instrucción.

En este ejemplo, el método estático generado para el cuerpo lambda tendría un aspecto similar al siguiente (si se descompila):

private static /* synthetic */ Object lambda$test$0() {
    return new LambdaTest$1();
}

... donde LambdaTest$1 es tu clase anónima. Vamos a desmontar eso e inspeccionar nuestros atributos:

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

Al igual que en el caso en el que creamos una instancia de un tipo anónimo fuera de un lambda, la firma contiene la variable de tipo W Pero EnclosingMethod refiere al método sintético .

El método sintético lambda$test$0() no declara la variable de tipo W Además, lambda$test$0() no está incluida en la test() , por lo que la declaración de W no es visible en su interior. Su clase anónima tiene un supertipo que contiene una variable de tipo que su clase no conoce porque está fuera de alcance.

Cuando llamamos a getGenericSuperclass() , la jerarquía de alcance para LambdaTest$1 no contiene W , por lo que el analizador no puede resolverlo. Debido a la forma en que se escribe el código, esta variable de tipo no resuelta hace que no se coloque en los parámetros de tipo del supertipo genérico.

Tenga en cuenta que, si su lambda hubiera instanciado un tipo que no se refería a ninguna variable de tipo (por ejemplo, TypeToken<String> ), entonces no se encontraría con este problema.

Conclusiones

(i) Hay un error en javac . La Especificación de la máquina virtual de Java §4.7.7 ("El atributo de §4.7.7 ") establece:

Es responsabilidad de un compilador de Java asegurarse de que el método identificado a través de method_index sea, de hecho, el más cercano método de method_index léxico de la clase que contiene este atributo EnclosingMethod . (énfasis mío)

Actualmente, javac parece determinar el método de cierre después de que la reescritura lambda siga su curso y, como resultado, el atributo EnclosingMethod refiere a un método que nunca existió en el ámbito léxico. Si EnclosingMethod informara el método de inclusión léxica real , las variables de tipo en ese método podrían ser resueltas por las clases integradas en lambda, y su código produciría los resultados esperados.

Podría decirse que también es un error que el analizador / reificador de firmas permita silenciosamente que un argumento de tipo null se propague en un Tipo ParameterizedType (que, como señala @ tom-hawtin-tackline, tiene efectos secundarios como toString() lanzando un NPE).

Mi informe de error para el problema EnclosingMethod ahora está en línea.

(ii) Podría decirse que hay varios errores en java.lang.reflect y sus API de soporte.

El método ParameterizedType::getActualTypeArguments() se documenta como lanzar una TypeNotPresentException cuando "cualquiera de los argumentos de tipo reales se refiere a una declaración de tipo no existente". Esa descripción cubre posiblemente el caso en el que una variable de tipo no está dentro del alcance. GenericArrayType::getGenericComponentType() debería lanzar una excepción similar cuando "el tipo del tipo de matriz subyacente se refiere a una declaración de tipo no existente". Actualmente, ninguno de los dos parece lanzar una TypeNotPresentException bajo ninguna circunstancia.

También argumentaría que las diferentes anulaciones de Type::toString deberían simplemente completar el nombre canónico de cualquier tipo no resuelto en lugar de lanzar un NPE o cualquier otra excepción.

He enviado un informe de error para estos temas relacionados con la reflexión, y publicaré el enlace una vez que esté públicamente visible.

Soluciones?

Si necesita poder hacer referencia a una variable de tipo declarada por el método adjunto, entonces no puede hacer eso con una lambda; Tendrá que recurrir a la sintaxis de tipo anónima más larga. Sin embargo, la versión lambda debería funcionar en la mayoría de los otros casos. Incluso debería poder hacer referencia a las variables de tipo declaradas por la clase envolvente. Por ejemplo, estos siempre deberían funcionar:

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

Desafortunadamente, dado que este error aparentemente ha existido desde que se introdujeron las lambdas por primera vez, y no se ha corregido en la versión más reciente de LTS, es posible que tenga que asumir que el error permanece en los JDK de sus clientes mucho después de que se solucione, suponiendo que se solucione. arreglado en absoluto.


No he encontrado la parte relevante de la especificación, pero aquí hay una respuesta parcial.

Ciertamente hay un error con el tipo de componente que es null . Para que quede claro, este es TypeToken.type desde el TypeToken.type anterior al GenericArrayTypeGenericArrayType asco!) Con el método getGenericComponentType invocado. Los documentos de la API no mencionan explícitamente si el null devuelto es válido o no. Sin embargo, el método toString lanza NullPointerException , por lo que definitivamente hay un error (al menos en la versión aleatoria de Java que estoy usando).

No tengo una cuenta de bugs.java.com , así que no puedo informar de esto. Alguien debería.

Echemos un vistazo a los archivos de clase generados.

javap -private YourClass

Esto debería producir un listado que contenga algo como:

static <T> void test();
private static TypeToken lambda$test$0();

Observe que nuestro método de test explícito tiene su parámetro de tipo, pero el método lambda sintético no. Usted podría esperar algo como:

static <T> void test();
private static <T> TypeToken<T[]> lambda$test$0(); /*** DOES NOT HAPPEN ***/
             // ^ name copied from `test`
                          // ^^^ `Object[]` would not make sense

¿Por qué no sucede esto? Presumiblemente porque este sería un parámetro de tipo de método en un contexto donde se requiere un parámetro de tipo de tipo, y son cosas sorprendentemente diferentes. También existe una restricción sobre las lambdas que no les permiten tener parámetros de tipo de método, aparentemente porque no hay una notación explícita (algunas personas pueden sugerir que esto parece una mala excusa).

Conclusión: hay al menos un error JDK no reportado aquí. El API de reflect y esta parte del lenguaje de lambda + genéricos no es de mi gusto.


Como solución alternativa, puede mover la creación de TypeToken fuera de lambda a un método separado y seguir utilizando lambda en lugar de la clase totalmente declarada:

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

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




java-8