Die meisten meiner Interviews zu technischen Positionen haben eine Aufgabe, bei der der Kandidat zwei sehr ähnliche Schnittstellen in einer Klasse implementieren muss:
Implementieren Sie nach Möglichkeit beide Schnittstellen in einer Klasse. Erklären Sie, warum dies möglich ist oder nicht.
interface WithPrimitiveInt { void m(int i); } interface WithInteger { void m(Integer i); }
Von einem Übersetzer: Dieser Artikel ermutigt Sie nicht, dieselben Fragen in einem Interview zu stellen. Aber wenn Sie vollständig vorbereitet sein möchten, wenn Ihnen diese Frage gestellt wird, dann sind Sie bei cat willkommen.
Manchmal ziehen es Bewerber, die sich der Antwort nicht sehr sicher sind, vor, dieses Problem anstelle der folgenden Bedingung zu lösen (später bitte ich Sie auf jeden Fall, es zu lösen):
interface S { String m(int i); } interface V { void m(int i); }
In der Tat scheint die zweite Aufgabe viel einfacher zu sein, und die meisten Kandidaten antworten, dass es unmöglich ist, beide Methoden in dieselbe Klasse aufzunehmen, da die Signaturen Sm(int)
und Vm(int)
, während der Typ des Rückgabewerts unterschiedlich ist. Und das ist absolut wahr.
Manchmal stelle ich jedoch eine andere Frage zu diesem Thema:
Halten Sie es für sinnvoll, Methoden mit derselben Signatur, aber unterschiedlichen Typen in derselben Klasse zu implementieren? Zum Beispiel in einer hypothetischen Sprache, die auf der JVM basiert, oder zumindest auf der JVM-Ebene?
Dies ist eine Frage, deren Antwort nicht eindeutig ist. Aber trotz der Tatsache, dass ich keine Antwort darauf erwarte, existiert die richtige Antwort. Eine Person, die sich häufig mit der Reflection-API befasst, den Bytecode manipuliert oder mit der JVM-Spezifikation vertraut ist, könnte darauf antworten.
Java-Methodensignatur und JVM-Methodenhandle
Die Java-Methodensignatur (d. H. Methodenname und Parametertypen) wird nur vom Java-Compiler zur Kompilierungszeit verwendet. Die JVM trennt die Methoden in der Klasse wiederum mithilfe eines nicht qualifizierten Methodennamens ( dh nur eines Methodennamens) und eines Methodenhandles, dh einer Liste von Deskriptorparametern und eines Rückgabedeskriptors.
Wenn wir beispielsweise die String m(int i)
-Methode direkt in der foo.Bar
Klasse foo.Bar
, ist der folgende Bytecode erforderlich:
INVOKEVIRTUAL foo/Bar.m (I)Ljava/lang/String;
und für void m(int i)
folgendes:
INVOKEVIRTUAL foo/Bar.m (I)V
Daher ist die JVM mit String m(int i)
und void m(int i)
in derselben Klasse recht vertraut. Sie müssen lediglich den entsprechenden Bytecode generieren.
Kung Fu mit Bytecode
Wir haben S- und V-Schnittstellen, jetzt erstellen wir eine SV-Klasse, die beide Schnittstellen enthält. Wenn es in Java erlaubt wäre, sollte es so aussehen:
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; } }
Um den Bytecode zu generieren, verwenden wir die Objectweb ASM-Bibliothek , eine Bibliothek auf niedriger Ebene, die ausreicht, um sich ein Bild von der Bytecode-JVM zu machen.
Der vollständige Quellcode wird auf GitHub hochgeladen, hier werde ich nur die wichtigsten Fragmente geben und erklären.
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
Beginnen wir mit der Erstellung eines ClassWriter
zum Generieren von Bytecode.
Jetzt werden wir eine Klasse deklarieren, die die Schnittstellen S und V enthält.
Obwohl unser Referenz-Pseudo-Java-Code für SV keine Konstruktoren enthält, müssen wir dennoch Code dafür generieren. Wenn wir Konstruktoren in Java nicht beschreiben, generiert der Compiler implizit einen leeren Konstruktor.
Im Hauptteil der Methoden java.io.PrintStream
wir zunächst das Feld System.out
vom Typ java.io.PrintStream
und fügen es dem Operandenstapel hinzu. Dann laden wir die Konstante ( String
oder void
) auf den Stapel und rufen den Befehl println
in der resultierenden out
Variablen mit einer String-Konstante als Argument auf.
Schließlich fügen String m(int i)
für String m(int i)
die Konstante des Referenztyps mit dem Wert null
zum Stapel hinzu und verwenden die return
entsprechenden Typs, d. H. ARETURN
, um den Wert an den Initiator des Methodenaufrufs zurückzugeben. Für void m(int i)
Sie untyped RETURN
nur verwenden, um zum Initiator des Methodenaufrufs zurückzukehren, ohne einen Wert zurückzugeben. Um sicherzustellen, dass der Bytecode korrekt ist (was ich ständig mache und Fehler viele Male korrigiere), schreiben wir die generierte Klasse auf die Festplatte.
Files.write(new File("/tmp/SV.class").toPath(), cw.toByteArray());
und verwenden Sie jad
(Java-Dekompiler), um den Bytecode zurück in den Java-Quellcode zu übersetzen:
$ 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;
Meiner Meinung nach nicht schlecht.
Verwenden der generierten Klasse
Eine erfolgreiche Dekompilierung von jad
garantiert uns im Wesentlichen nichts. Das Dienstprogramm jad
weist Sie nur auf häufig auftretende Probleme im Bytecode hin, von der Frame-Größe bis hin zu Fehlanpassungen lokaler Variablen oder fehlenden return-Anweisungen.
Um die generierte Klasse zur Laufzeit zu verwenden, müssen wir sie irgendwie in die JVM laden und dann instanziieren.
Lassen Sie uns unseren eigenen AsmClassLoader
. Dies ist nur ein praktischer Wrapper für 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); } }
Verwenden Sie nun diesen Klassenlader und instanziieren Sie die Klasse:
ClassWriter cw = SVGenerator.generateClass(); AsmClassLoader classLoader = new AsmClassLoader(); Class<?> generatedClazz = classLoader.defineAsmClass(SVGenerator.SV_FQCN, cw); Object o = generatedClazz.newInstance();
Da unsere Klasse zur Laufzeit generiert wird, können wir sie nicht im Quellcode verwenden. Aber wir können seinen Typ auf implementierte Schnittstellen übertragen. Ein Anruf ohne Reflexion kann folgendermaßen erfolgen:
((S)o).m(1); ((V)o).m(1);
Bei der Ausführung des Codes erhalten wir folgende Ausgabe:
String void
Für einige mag diese Schlussfolgerung unerwartet erscheinen: Wir verweisen in der Klasse auf dieselbe (aus Java-Sicht) Methode, aber die Ergebnisse unterscheiden sich je nach der Schnittstelle, zu der wir das Objekt gebracht haben. Atemberaubend, richtig?
Alles wird klar, wenn wir den zugrunde liegenden Bytecode berücksichtigen. Für unseren Aufruf generiert der Compiler eine INVOKEINTERFACE-Anweisung, und das Methodenhandle stammt nicht von der Klasse, sondern von der Schnittstelle.
Somit erhalten wir den ersten Anruf:
INVOKEINTERFACE edio/java/experiments/Sm (I)Ljava/lang/String
und im zweiten:
INVOKEINTERFACE edio/java/experiments/Vm (I)V
Das Objekt, für das wir den Aufruf getätigt haben, kann aus dem Stapel abgerufen werden. Dies ist die Kraft des Polymorphismus, die Java innewohnt.
Sein Name ist die Brückenmethode
Jemand wird fragen: "Also, worum geht es bei all dem? Wird es jemals nützlich sein?"
Der Punkt ist, dass wir beim Schreiben von normalem Java-Code (implizit) dasselbe verwenden. Beispielsweise werden kovariante Rückgabetypen, Generika und der Zugriff auf private Felder aus inneren Klassen mit derselben Magie des Bytecodes implementiert.
Schauen Sie sich diese Oberfläche an:
public interface ZeroProvider { Number getZero(); }
und seine Implementierung mit der Rückkehr des kovarianten Typs:
public class IntegerZero implements ZeroProvider { public Integer getZero() { return 0; } }
Lassen Sie uns nun über diesen Code nachdenken:
IntegerZero iz = new IntegerZero(); iz.getZero(); ZeroProvider zp = iz; zp.getZero();
Für iz.getZero()
generiert der Aufruf-Compiler INVOKEVIRTUAL
mit der handle-Methode ()Ljava/lang/Integer;
, während für zp.getZero()
INVOKEINTERFACE mit dem Methodendeskriptor ()Ljava/lang/Number;
. Wir wissen bereits, dass die JVM einen Objektaufruf unter Verwendung des Namens- und Methodendeskriptors auslöst. Da die Deskriptoren unterschiedlich sind, können diese beiden Aufrufe in einer IntegerZero
Instanz nicht an dieselbe Methode IntegerZero
werden.
Tatsächlich generiert der Compiler eine zusätzliche Methode, die als Brücke zwischen der in der Klasse angegebenen realen Methode und der beim Aufrufen über die Schnittstelle verwendeten Methode fungiert. Daher ist der Name die Brückenmethode. Wenn dies in Java möglich wäre, würde der endgültige Code folgendermaßen aussehen:
public class IntegerZero implements ZeroProvider { public Integer getZero() { return 0; }
Nachwort
Die Java-Programmiersprache und die Java Virtual Machine sind nicht dasselbe: Obwohl der Name ein gemeinsames Wort enthält und Java die Hauptsprache für die JVM ist, sind ihre Funktionen und Einschränkungen bei weitem nicht immer gleich. Wenn Sie die JVM kennen, können Sie Java oder eine andere JVM-basierte Sprache besser verstehen. Wenn Sie jedoch Java und seinen Verlauf kennen, können Sie bestimmte Entscheidungen im JVM-Design besser verstehen.
Vom Übersetzer
Kompatibilitätsprobleme machen Entwicklern früher oder später Sorgen. Im Originalartikel wird eine wichtige Frage zum impliziten Verhalten des Java-Compilers und zur Auswirkung seiner Magie auf Anwendungen aufgeworfen, die uns als Entwickler des CUBA Platform-Frameworks sehr am Herzen liegen. Dies wirkt sich direkt auf die Kompatibilität von Bibliotheken aus. Zuletzt haben wir bei JUG in Jekaterinburg im Bericht „APIs ändern sich an der Kreuzung nicht - wie man eine stabile API erstellt“ über die Kompatibilität in realen Anwendungen gesprochen. Das Video des Meetings finden Sie hier.