Surcharge interdite ou méthodes de pontage en Java


La plupart de mes entretiens sur des postes techniques ont une tâche dans laquelle le candidat doit implémenter 2 interfaces très similaires dans la même classe:


Implémentez les deux interfaces dans une seule classe, si possible. Expliquez pourquoi cela est possible ou non.


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

De la part d'un traducteur: cet article ne vous encourage pas à poser les mêmes questions lors d'un entretien. Mais si vous voulez être bien préparé lorsque cette question vous est posée, alors bienvenue au chat.


Parfois, les candidats qui ne sont pas très sûrs de la réponse, préfèrent résoudre plutôt que ce problème avec la condition suivante (plus tard, en tout cas, je vous demande de la résoudre):


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

En effet, la deuxième tâche semble beaucoup plus simple, et la plupart des candidats répondent qu'il est impossible d'inclure les deux méthodes dans la même classe, car les signatures Sm(int) et Vm(int) mêmes, tandis que le type de la valeur de retour est différent. Et c'est absolument vrai.


Cependant, parfois je pose une autre question liée à ce sujet:


Pensez-vous qu'il est logique de permettre l'implémentation de méthodes avec la même signature mais différents types dans la même classe? Par exemple, dans un langage hypothétique basé sur la JVM, ou au moins au niveau de la JVM?


C'est une question dont la réponse est ambiguë. Mais, malgré le fait que je n'attends pas de réponse, la bonne réponse existe. Une personne qui traite souvent de l'API de réflexion, manipule le bytecode ou est familière avec la spécification JVM pourrait y répondre.


Signature de méthode Java et descripteur de méthode JVM


La signature de la méthode Java (c'est-à-dire le nom de la méthode et les types de paramètres) est utilisée uniquement par le compilateur Java au moment de la compilation. À son tour, la machine virtuelle Java sépare les méthodes de la classe en utilisant le nom de méthode non qualifié (c'est-à-dire uniquement le nom de la méthode) et le descripteur de méthode , c'est-à-dire une liste de paramètres de descripteur et un descripteur de retour.


Par exemple, si nous voulons appeler la méthode String m(int i) directement sur la classe foo.Bar , le bytecode suivant est requis:


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

et pour void m(int i) suit:


 INVOKEVIRTUAL foo/Bar.m (I)V 

Ainsi, la JVM est assez à l'aise avec String m(int i) et void m(int i) dans la même classe. Il suffit de générer le bytecode correspondant.


Kung Fu avec bytecode


Nous avons des interfaces S et V, nous allons maintenant créer une classe SV qui comprend les deux interfaces. En Java, s'il était autorisé, il devrait ressembler à ceci:


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

Pour générer le bytecode, nous utilisons la bibliothèque Objectweb ASM , une bibliothèque de bas niveau suffisante pour avoir une idée de la JVM du bytecode.


Le code source complet est téléchargé sur GitHub, ici je vais donner et expliquer seulement les fragments les plus importants.


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

Commençons par créer un ClassWriter pour générer du bytecode.


Nous allons maintenant déclarer une classe qui comprend les interfaces S et V.


Bien que notre code pseudo-java de référence pour SV n'ait pas de constructeurs, nous devons quand même générer du code pour celui-ci. Si nous ne décrivons pas les constructeurs en Java, le compilateur génère implicitement un constructeur vide.


Dans le corps des méthodes, nous commençons par obtenir le champ System.out de type java.io.PrintStream et en l'ajoutant à la pile d'opérandes. Ensuite, nous chargeons la constante ( String ou void ) sur la pile et appelons la commande println dans la variable de out résultante avec une constante de chaîne comme argument.


Enfin, pour String m(int i) ajoutons la constante du type de référence avec la valeur null ARETURN pile et utilisons l' return type correspondant, c'est-à-dire ARETURN , pour renvoyer la valeur à l'initiateur de l'appel de méthode. Pour void m(int i) vous devez utiliser RETURN non typé uniquement pour revenir à l'initiateur de l'appel de méthode sans renvoyer de valeur. Pour nous assurer que le bytecode est correct (ce que je fais constamment, corrigeant les erreurs plusieurs fois), nous écrivons la classe générée sur le disque.


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

et utilisez jad (décompilateur Java) pour traduire le bytecode en code source 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"); } } 

Pas mal à mon avis.


Utilisation de la classe générée


Une décompilation réussie de jad ne nous garantit essentiellement rien. L'utilitaire jad vous avertit uniquement des problèmes courants dans le bytecode, de la taille de la trame aux incompatibilités de variables locales ou aux instructions de retour manquantes.


Pour utiliser la classe générée lors de l'exécution, nous devons en quelque sorte la charger dans la JVM puis l'instancier.


AsmClassLoader notre propre AsmClassLoader . Ceci est juste un emballage pratique pour 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); } } 

Utilisez maintenant ce chargeur de classe et instanciez la classe:


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

Puisque notre classe est générée lors de l'exécution, nous ne pouvons pas l'utiliser dans le code source. Mais nous pouvons convertir son type en interfaces implémentées. Un appel sans réflexion peut se faire comme ceci:


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

Lors de l'exécution du code, nous obtenons la sortie suivante:


 String void 

Pour certains, cette conclusion peut sembler inattendue: nous nous référons à la même méthode (du point de vue Java) dans la classe, mais les résultats diffèrent selon l'interface à laquelle nous avons apporté l'objet. Superbe, non?


Tout deviendra clair si nous prenons en compte le bytecode sous-jacent. Pour notre appel, le compilateur génère une instruction INVOKEINTERFACE et le descripteur de méthode ne provient pas de la classe, mais de l'interface.


Ainsi, le premier appel que nous recevons:


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

et dans le second:


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

L'objet sur lequel nous avons fait l'appel peut être obtenu à partir de la pile. C'est le pouvoir du polymorphisme inhérent à Java.


Son nom est la méthode du pont


Quelqu'un demandera: "Alors quel est l'intérêt de tout cela? Est-ce que cela sera utile?"


Le fait est que nous utilisons la même chose (implicitement) lors de l'écriture de code Java normal. Par exemple, les types de retour covariants, les génériques et l'accès aux champs privés des classes internes sont implémentés en utilisant la même magie que le bytecode.


Jetez un œil à cette interface:


 public interface ZeroProvider { Number getZero(); } 

et sa mise en œuvre avec le retour du type covariant:


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

Maintenant, réfléchissons à ce code:


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

Pour iz.getZero() , le compilateur d'appels générera INVOKEVIRTUAL avec la méthode handle ()Ljava/lang/Integer; , tandis que pour zp.getZero() il générera INVOKEINTERFACE avec le descripteur de méthode ()Ljava/lang/Number; . Nous savons déjà que la JVM envoie un appel d'objet à l'aide du nom et du descripteur de méthode. Étant donné que les descripteurs sont différents, ces 2 appels ne peuvent pas être acheminés vers la même méthode dans une instance IntegerZero .


En fait, le compilateur génère une méthode supplémentaire qui sert de pont entre la méthode réelle spécifiée dans la classe et la méthode utilisée lors de l'appel via l'interface. Par conséquent, le nom est la méthode du pont. Si cela était possible en Java, le code final ressemblerait à ceci:


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

Postface


Le langage de programmation Java et la machine virtuelle Java ne sont pas la même chose: bien qu'ils aient un mot commun dans le nom et que Java soit le langage principal de la JVM, leurs capacités et limitations sont loin d'être toujours les mêmes. La connaissance de la JVM vous aide à mieux comprendre Java ou tout autre langage basé sur JVM, mais d'un autre côté, connaître Java et son historique permet de comprendre certaines décisions dans la conception de la JVM.


Du traducteur


Les problèmes de compatibilité commencent tôt ou tard à inquiéter tout développeur. L'article original a abordé la question importante du comportement implicite du compilateur Java et de l'effet de sa magie sur les applications, dont nous, en tant que développeurs du framework CUBA Platform, nous soucions beaucoup, cela affecte directement la compatibilité des bibliothèques. Plus récemment, nous avons parlé de compatibilité dans les applications réelles au JUG d'Ekaterinbourg dans le rapport «Les API ne changent pas au ferry - comment construire une API stable», la vidéo de la réunion peut être trouvée ici.


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


All Articles