java - son - que es una interfaz interna
Comportamiento genérico diferente cuando se utiliza lambda en lugar de una clase interna anónima explícita (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
- ¿Qué sucede con la TypeVariable en el ejemplo de lambda? ¿Por qué la inferencia de tipo no respeta el tipo genérico?
- ¿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?
- ¿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:
- 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.- 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 unaNullPointerException
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 demethod_index
léxico de la clase que contiene este atributoEnclosingMethod
. (é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.
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();
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
GenericArrayType
(¡
GenericArrayType
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.