Métodos de sobrecarga o puente prohibidos en Java


La mayoría de mis entrevistas sobre puestos técnicos tienen una tarea en la que el candidato necesita implementar 2 interfaces muy similares en la misma clase:


Implemente ambas interfaces en una clase, si es posible. Explica por qué esto es posible o no.


interface WithPrimitiveInt { void m(int i); } interface WithInteger { void m(Integer i); } 

De un traductor: este artículo no lo alienta a hacer las mismas preguntas en una entrevista. Pero si desea estar completamente preparado cuando se le haga esta pregunta, entonces bienvenido a cat.


A veces, los solicitantes que no están muy seguros de la respuesta, prefieren resolver en lugar de este problema con la siguiente condición (más tarde, en cualquier caso, le pido que la resuelva):


 interface S { String m(int i); } interface V { void m(int i); } 

De hecho, la segunda tarea parece mucho más simple, y la mayoría de los candidatos responden que es imposible incluir ambos métodos en la misma clase, porque las firmas Sm(int) y Vm(int) mismas, mientras que el tipo del valor de retorno es diferente. Y esto es absolutamente cierto.


Sin embargo, a veces hago otra pregunta relacionada con este tema:


¿Crees que tiene sentido permitir la implementación de métodos con la misma firma pero diferentes tipos en la misma clase? Por ejemplo, en algún lenguaje hipotético basado en la JVM, o al menos en el nivel de la JVM?


Esta es una pregunta cuya respuesta es ambigua. Pero, a pesar del hecho de que no espero una respuesta, existe la respuesta correcta. Una persona que a menudo trata con la API de reflexión, manipula bytecode o está familiarizada con la especificación JVM podría responderla.


Firma del método Java y manejador del método JVM


La firma del método Java (es decir, el nombre del método y los tipos de parámetros) solo la utiliza el compilador Java en el momento de la compilación. A su vez, la JVM separa los métodos en la clase utilizando un nombre de método no calificado (es decir, solo un nombre de método) y un identificador de método , es decir, una lista de parámetros de descriptor y un descriptor de retorno.


Por ejemplo, si queremos llamar al método String m(int i) directamente en la clase foo.Bar , se requiere el siguiente foo.Bar :


 INVOKEVIRTUAL foo/Bar.m (I)Ljava/lang/String; 

y para nulo m(int i) siguiente:


 INVOKEVIRTUAL foo/Bar.m (I)V 

Por lo tanto, la JVM se siente bastante cómoda con String m(int i) y void m(int i) en la misma clase. Todo lo que se necesita es generar el bytecode correspondiente.


Kung fu con bytecode


Tenemos interfaces S y V, ahora crearemos una clase SV que incluye ambas interfaces. En Java, si se permitiera, debería verse así:


 public class SV implements S, V { public void m(int i) { System.out.println("void m(int i)"); } public String m(int i) { System.out.println("String m(int i)"); return null; } } 

Para generar el bytecode, usamos la biblioteca Objectweb ASM , una biblioteca de bajo nivel suficiente para tener una idea del bytecode JVM.


El código fuente completo se carga en GitHub, aquí daré y explicaré solo los fragmentos más importantes.


 ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES); // package edio.java.experiments // public class SV implements S, V cw.visit(V1_7, ACC_PUBLIC, "edio/java/experiments/SV", null, "java/lang/Object", new String[]{ "edio/java/experiments/S", "edio/java/experiments/V" }); // constructor MethodVisitor constructor = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null); constructor.visitCode(); constructor.visitVarInsn(Opcodes.ALOAD, 0); constructor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V"); constructor.visitInsn(Opcodes.RETURN); constructor.visitMaxs(1, 1); constructor.visitEnd(); // public String m(int i) MethodVisitor mString = cw.visitMethod(ACC_PUBLIC, "m", "(I)Ljava/lang/String;", null, null); mString.visitCode(); mString.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mString.visitLdcInsn("String"); mString.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V"); mString.visitInsn(Opcodes.ACONST_NULL); mString.visitInsn(Opcodes.ARETURN); mString.visitMaxs(2, 2); mString.visitEnd(); // public void m(int i) MethodVisitor mVoid = cw.visitMethod(ACC_PUBLIC, "m", "(I)V", null, null); mVoid.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mVoid.visitLdcInsn("void"); mVoid.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V"); mVoid.visitInsn(Opcodes.RETURN); mVoid.visitMaxs(2, 2); mVoid.visitEnd(); cw.visitEnd(); 

Comencemos creando un ClassWriter para generar bytecode.


Ahora declararemos una clase que incluye las interfaces S y V.


Aunque nuestro código de pseudo-java de referencia para SV no tiene constructores, aún necesitamos generar código para él. Si no describimos constructores en Java, el compilador genera implícitamente un constructor vacío.


En el cuerpo de los métodos, comenzamos obteniendo el campo System.out del tipo java.io.PrintStream y agregándolo a la pila de operandos. Luego cargamos la constante ( String o void ) en la pila y llamamos al comando println en la variable resultante con una constante de cadena como argumento.


Finalmente, para String m(int i) agregamos la constante del tipo de referencia con el valor null a la pila y usamos la return tipo correspondiente, es decir, ARETURN , para devolver el valor al iniciador de la llamada al método. Para void m(int i) debe usar RETURN tipo solo para volver al iniciador de la llamada al método sin devolver un valor. Para asegurarnos de que el código de bytes sea correcto (lo cual hago constantemente, corrigiendo errores muchas veces), escribimos la clase generada en el disco.


 Files.write(new File("/tmp/SV.class").toPath(), cw.toByteArray()); 

y use jad (descompilador de Java) para traducir el bytecode nuevamente al código fuente de Java:


 $ jad -p /tmp/SV.class The class file version is 51.0 (only 45.3, 46.0 and 47.0 are supported) // Decompiled by Jad v1.5.8e. Copyright 2001 Pavel Kouznetsov. // Jad home page: http://www.geocities.com/kpdus/jad.html // Decompiler options: packimports(3) package edio.java.experiments; import java.io.PrintStream; // Referenced classes of package edio.java.experiments: // S, V public class SV implements S, V { public SV() { } public String m(int i) { System.out.println("String"); return null; } public void m(int i) { System.out.println("void"); } } 

No está mal en mi opinión.


Usando la clase generada


La descompilación exitosa de jad esencialmente no nos garantiza nada. La utilidad jad solo le alerta sobre problemas comunes en el código de bytes, desde el tamaño de trama hasta los desajustes de las variables locales o las declaraciones de retorno faltantes.


Para usar la clase generada en tiempo de ejecución, necesitamos cargarla de alguna manera en la JVM y luego instanciarla.


Implementemos nuestro propio AsmClassLoader . Esto es solo un útil contenedor para ClassLoader.defineClass :


 public class AsmClassLoader extends ClassLoader { public Class defineAsmClass(String name, ClassWriter classWriter) { byte[] bytes = classWriter.toByteArray(); return defineClass(name, bytes, 0, bytes.length); } } 

Ahora use este cargador de clases e instancia la clase:


 ClassWriter cw = SVGenerator.generateClass(); AsmClassLoader classLoader = new AsmClassLoader(); Class<?> generatedClazz = classLoader.defineAsmClass(SVGenerator.SV_FQCN, cw); Object o = generatedClazz.newInstance(); 

Como nuestra clase se genera en tiempo de ejecución, no podemos usarla en el código fuente. Pero podemos emitir su tipo a interfaces implementadas. Una llamada sin reflexión se puede hacer así:


 ((S)o).m(1); ((V)o).m(1); 

Al ejecutar el código, obtenemos el siguiente resultado:


 String void 

Para algunos, esta conclusión puede parecer inesperada: nos referimos al mismo método (desde el punto de vista de Java) en la clase, pero los resultados difieren dependiendo de la interfaz a la que llevamos el objeto. Impresionante, ¿verdad?


Todo quedará claro si tenemos en cuenta el bytecode subyacente. Para nuestra llamada, el compilador genera una instrucción INVOKEINTERFACE, y el identificador del método no proviene de la clase, sino de la interfaz.


Por lo tanto, la primera llamada que recibimos:


 INVOKEINTERFACE edio/java/experiments/Sm (I)Ljava/lang/String; 

y en el segundo:


 INVOKEINTERFACE edio/java/experiments/Vm (I)V 

El objeto en el que realizamos la llamada se puede obtener de la pila. Este es el poder del polimorfismo inherente a Java.


Su nombre es el método del puente.


Alguien preguntará: "Entonces, ¿cuál es el punto de todo esto? ¿Será útil alguna vez?"


El punto es que usamos lo mismo (implícitamente) cuando escribimos código Java normal. Por ejemplo, los tipos de retorno covariantes, los genéricos y el acceso a campos privados desde clases internas se implementan utilizando la misma magia del código de bytes.


Echa un vistazo a esta interfaz:


 public interface ZeroProvider { Number getZero(); } 

y su implementación con el retorno del tipo covariante:


 public class IntegerZero implements ZeroProvider { public Integer getZero() { return 0; } } 

Ahora pensemos en este código:


 IntegerZero iz = new IntegerZero(); iz.getZero(); ZeroProvider zp = iz; zp.getZero(); 

Para iz.getZero() , el compilador de llamadas generará INVOKEVIRTUAL con el método de manejo ()Ljava/lang/Integer; , mientras que para zp.getZero() generará INVOKEINTERFACE con el descriptor de método ()Ljava/lang/Number; . Ya sabemos que la JVM despacha una llamada a un objeto utilizando el nombre y el descriptor del método. Como los descriptores son diferentes, estas 2 llamadas no se pueden enrutar al mismo método en una instancia de IntegerZero .


De hecho, el compilador genera un método adicional que actúa como un puente entre el método real especificado en la clase y el método utilizado al llamar a través de la interfaz. Por lo tanto, el nombre es el método del puente. Si esto fuera posible en Java, el código final se vería así:


 public class IntegerZero implements ZeroProvider { public Integer getZero() { return 0; } // This is a synthetic bridge method, which is present only in bytecode. // Java compiler wouldn't permit it. public Number getZero() { return this.getZero(); } } 

Epílogo


El lenguaje de programación Java y la máquina virtual Java no son lo mismo: aunque tienen una palabra común en el nombre y Java es el lenguaje principal para la JVM, sus capacidades y limitaciones distan mucho de ser siempre las mismas. Conocer JVM le ayuda a comprender mejor Java o cualquier otro lenguaje basado en JVM, pero, por otro lado, conocer Java y su historia ayuda a comprender ciertas decisiones en el diseño de JVM.


Del traductor


Los problemas de compatibilidad tarde o temprano comienzan a preocupar a cualquier desarrollador. El artículo original tocaba la importante cuestión del comportamiento implícito del compilador de Java y el efecto de su magia en las aplicaciones, lo que nos preocupa, como desarrolladores del marco de la plataforma CUBA, esto afecta directamente la compatibilidad de las bibliotecas. Más recientemente, hablamos sobre la compatibilidad en aplicaciones de la vida real en JUG en Ekaterimburgo en el informe "Las API no cambian en el cruce: cómo construir una API estable", el video de la reunión se puede encontrar aquí.


Source: https://habr.com/ru/post/es426419/


All Articles