Was ist das Konzept der Löschung in Generika in Java?

141

Was ist das Konzept der Löschung in Generika in Java?

Tushu
quelle

Antworten:

200

Es ist im Grunde die Art und Weise, wie Generika in Java über Compiler-Tricks implementiert werden. Der kompilierte generische Code wird eigentlich nur dort verwendet, java.lang.Objectwo Sie sprechen T(oder in einem anderen Typparameter) - und es gibt einige Metadaten, die dem Compiler mitteilen, dass es sich wirklich um einen generischen Typ handelt.

Wenn Sie Code gegen einen generischen Typ oder eine generische Methode kompilieren, ermittelt der Compiler, was Sie wirklich meinen (dh wofür das Typargument Tlautet), und überprüft beim Kompilieren, ob Sie das Richtige tun, aber der ausgegebene Code spricht erneut nur in Bezug auf java.lang.Object- der Compiler generiert bei Bedarf zusätzliche Casts. Zur Ausführungszeit sind a List<String> und a List<Date>genau gleich; Die zusätzlichen Typinformationen wurden vom Compiler gelöscht .

Vergleichen Sie dies beispielsweise mit C #, wo die Informationen zur Ausführungszeit beibehalten werden, sodass Code Ausdrücke enthalten kann, typeof(T)die beispielsweise äquivalent zu T.class- außer dass letzterer ungültig ist. (Es gibt wohlgemerkt weitere Unterschiede zwischen .NET-Generika und Java-Generika.) Das Löschen von Typen ist die Quelle vieler "ungerader" Warn- / Fehlermeldungen beim Umgang mit Java-Generika.

Andere Ressourcen:

Jon Skeet
quelle
6
@ Rogerio: Nein, die Objekte haben keine unterschiedlichen generischen Typen. Die Felder kennen die Typen, die Objekte jedoch nicht.
Jon Skeet
8
@ Rogerio: Absolut - es ist extrem einfach, zur Ausführungszeit herauszufinden, ob etwas, das nur als Object(in einem schwach typisierten Szenario) bereitgestellt wird, tatsächlich a List<String>) ist. In Java ist das einfach nicht machbar - Sie können feststellen, dass es sich um einen ArrayList, aber nicht um den ursprünglichen generischen Typ handelt. So etwas kann beispielsweise in Serialisierungs- / Deserialisierungssituationen auftreten. Ein anderes Beispiel ist, wenn ein Container in der Lage sein muss, Instanzen seines generischen Typs zu erstellen - Sie müssen diesen Typ separat in Java (as Class<T>) übergeben.
Jon Skeet
6
Ich habe nie behauptet, es sei immer oder fast immer ein Problem - aber meiner Erfahrung nach ist es zumindest einigermaßen häufig ein Problem. Es gibt verschiedene Stellen, an denen ich gezwungen bin Class<T>, einem Konstruktor (oder einer generischen Methode) einen Parameter hinzuzufügen, nur weil Java diese Informationen nicht speichert. Schauen Sie sich EnumSet.allOfzum Beispiel an - das generische Typargument für die Methode sollte ausreichen. Warum muss ich auch ein "normales" Argument angeben? Antwort: Geben Sie Löschen ein. Diese Art von Dingen verschmutzt eine API. Haben Sie aus Interesse häufig .NET-Generika verwendet? (Fortsetzung)
Jon Skeet
5
Bevor ich .NET-Generika verwendet habe, fand ich Java-Generika auf verschiedene Weise umständlich (und Wildcarding bereitet immer noch Kopfschmerzen, obwohl die "vom Anrufer angegebene" Varianzform definitiv Vorteile hat) - aber erst, nachdem ich .NET-Generika verwendet hatte Für eine Weile sah ich, wie viele Muster mit Java-Generika unangenehm oder unmöglich wurden. Es ist wieder das Blub-Paradoxon. Ich sage nicht, dass .NET-Generika übrigens auch keine Nachteile haben - es gibt verschiedene Typbeziehungen, die leider nicht ausgedrückt werden können -, aber ich ziehe sie Java-Generika bei weitem vor.
Jon Skeet
5
@ Rogerio: Es gibt viel, was man mit Reflexion anfangen kann - aber ich finde nicht, dass ich diese Dinge fast so oft tun möchte wie die Dinge, die ich mit Java-Generika nicht tun kann . Ich möchte das Typargument für ein Feld nicht fast so oft herausfinden, wie ich das Typargument eines tatsächlichen Objekts herausfinden möchte.
Jon Skeet
41

Nur als Randnotiz ist es eine interessante Übung, tatsächlich zu sehen, was der Compiler tut, wenn er das Löschen durchführt - was das gesamte Konzept ein wenig verständlicher macht. Es gibt ein spezielles Flag, mit dem Sie den Compiler übergeben können, um Java-Dateien auszugeben, bei denen die Generika gelöscht und Casts eingefügt wurden. Ein Beispiel:

javac -XD-printflat -d output_dir SomeFile.java

Dies -printflatist das Flag, das an den Compiler übergeben wird, der die Dateien generiert. (Der -XDTeil sagt javac, dass es an die ausführbare JAR-Datei übergeben werden soll, die das Kompilieren tatsächlich durchführt und nicht nur javac, aber ich schweife ab ...) -d output_dirDies ist erforderlich, da der Compiler einen Platz zum Ablegen der neuen Java-Dateien benötigt.

Dies bedeutet natürlich mehr als nur Löschen. Alle automatischen Aufgaben des Compilers werden hier erledigt. Beispielsweise werden auch Standardkonstruktoren eingefügt, die neuen foreach- forSchleifen werden zu regulären forSchleifen erweitert usw. Es ist schön zu sehen, welche kleinen Dinge automatisch ablaufen.

Jigawot
quelle
29

Löschen bedeutet wörtlich, dass die im Quellcode vorhandenen Typinformationen aus dem kompilierten Bytecode gelöscht werden. Lassen Sie uns dies mit etwas Code verstehen.

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class GenericsErasure {
    public static void main(String args[]) {
        List<String> list = new ArrayList<String>();
        list.add("Hello");
        Iterator<String> iter = list.iterator();
        while(iter.hasNext()) {
            String s = iter.next();
            System.out.println(s);
        }
    }
}

Wenn Sie diesen Code kompilieren und dann mit einem Java-Dekompiler dekompilieren, erhalten Sie so etwas. Beachten Sie, dass der dekompilierte Code keine Spur der Typinformationen enthält, die im ursprünglichen Quellcode vorhanden sind.

import java.io.PrintStream;
import java.util.*;

public class GenericsErasure
{

    public GenericsErasure()
    {
    }

    public static void main(String args[])
    {
        List list = new ArrayList();
        list.add("Hello");
        String s;
        for(Iterator iter = list.iterator(); iter.hasNext(); System.out.println(s))
            s = (String)iter.next();

    }
} 
Parag
quelle
Ich habe versucht, den Java-Dekompiler zu verwenden, um den Code nach dem Löschen des Typs aus der .class-Datei anzuzeigen, aber die .class-Datei enthält weiterhin Typinformationen. Ich habe versucht jigawotgesagt, es funktioniert.
Frank
25

Um die bereits sehr vollständige Antwort von Jon Skeet zu vervollständigen, müssen Sie das Konzept der Typlöschung erkennen, das sich aus der Notwendigkeit der Kompatibilität mit früheren Java-Versionen ergibt .

Die Kompatibilität wurde ursprünglich auf der EclipseCon 2007 vorgestellt (nicht mehr verfügbar) und umfasste folgende Punkte:

  • Quellkompatibilität (Schön zu haben ...)
  • Binärkompatibilität (Muss haben!)
  • Migrationskompatibilität
    • Bestehende Programme müssen weiter funktionieren
    • Bestehende Bibliotheken müssen generische Typen verwenden können
    • Haben müssen!

Ursprüngliche Antwort:

Daher:

new ArrayList<String>() => new ArrayList()

Es gibt Vorschläge für eine größere Verdinglichung . Reify ist "Betrachten Sie ein abstraktes Konzept als real", wo Sprachkonstrukte Konzepte sein sollten, nicht nur syntaktischer Zucker.

Ich sollte auch die checkCollectionMethode von Java 6 erwähnen , die eine dynamisch typsichere Ansicht der angegebenen Sammlung zurückgibt. Jeder Versuch, ein Element des falschen Typs einzufügen, führt sofort zu einem ClassCastException.

Der generische Mechanismus in der Sprache bietet eine (statische) Typprüfung zur Kompilierungszeit. Es ist jedoch möglich, diesen Mechanismus mit ungeprüften Casts zu umgehen .

Normalerweise ist dies kein Problem, da der Compiler bei all diesen ungeprüften Vorgängen Warnungen ausgibt.

Es gibt jedoch Zeiten, in denen die statische Typprüfung allein nicht ausreicht, z.

  • Wenn eine Sammlung an eine Bibliothek eines Drittanbieters übergeben wird und der Bibliothekscode die Sammlung nicht durch Einfügen eines Elements des falschen Typs beschädigt.
  • Ein Programm schlägt mit a fehl ClassCastException, was darauf hinweist, dass ein falsch eingegebenes Element in eine parametrisierte Sammlung aufgenommen wurde. Leider kann die Ausnahme jederzeit nach dem Einfügen des fehlerhaften Elements auftreten, sodass in der Regel nur wenige oder gar keine Informationen zur tatsächlichen Ursache des Problems vorliegen.

Update Juli 2012, fast vier Jahre später:

Es ist jetzt (2012) in " API-Migrationskompatibilitätsregeln (Signaturtest) " beschrieben.

Die Programmiersprache Java implementiert Generika mithilfe von Erasure, wodurch sichergestellt wird, dass ältere und generische Versionen normalerweise identische Klassendateien generieren, mit Ausnahme einiger zusätzlicher Informationen zu Typen. Die Binärkompatibilität wird nicht beeinträchtigt, da eine ältere Klassendatei durch eine generische Klassendatei ersetzt werden kann, ohne dass Clientcode geändert oder neu kompiliert werden muss.

Um die Anbindung an nicht generischen Legacy-Code zu erleichtern, kann auch das Löschen eines parametrisierten Typs als Typ verwendet werden. Ein solcher Typ wird als Rohtyp bezeichnet ( Java Language Specification 3 / 4.8 ). Durch das Zulassen des Rohtyps wird auch die Abwärtskompatibilität für den Quellcode sichergestellt.

Demnach sind die folgenden Versionen der java.util.IteratorKlasse sowohl Binär- als auch Quellcode abwärtskompatibel:

Class java.util.Iterator as it is defined in Java SE version 1.4:

public interface Iterator {
    boolean hasNext();
    Object next();
    void remove();
}

Class java.util.Iterator as it is defined in Java SE version 5.0:

public interface Iterator<E> {
    boolean hasNext();
    E next();
    void remove();
}
VonC
quelle
2
Beachten Sie, dass die Abwärtskompatibilität ohne Löschen von Typen hätte erreicht werden können, jedoch nicht, ohne dass Java-Programmierer neue Sammlungen gelernt hätten. Genau diesen Weg hat .NET gegangen. Mit anderen Worten, es ist diese dritte Kugel, die wichtig ist. (Fortsetzung)
Jon Skeet
15
Persönlich denke ich, dass dies ein kurzsichtiger Fehler war - es gab einen kurzfristigen Vorteil und einen langfristigen Nachteil.
Jon Skeet
8

Ergänzung der bereits ergänzten Antwort von Jon Skeet ...

Es wurde erwähnt, dass die Implementierung von Generika durch Löschen zu einigen störenden Einschränkungen führt (z new T[42]. B. nein ). Es wurde auch erwähnt, dass der Hauptgrund für diese Vorgehensweise die Abwärtskompatibilität im Bytecode war. Dies ist auch (meistens) wahr. Der generierte Bytecode -Ziel 1.5 unterscheidet sich etwas von dem einfach zuckerfreien Casting -Ziel 1.4. Technisch gesehen ist es sogar möglich (durch immense Tricks), zur Laufzeit auf generische Typinstanziierungen zuzugreifen , was beweist, dass der Bytecode wirklich etwas enthält.

Der interessantere Punkt (der nicht angesprochen wurde) ist, dass die Implementierung von Generika mithilfe der Löschung einiges an Flexibilität bietet, was das System auf hoher Ebene leisten kann. Ein gutes Beispiel hierfür wäre die JVM-Implementierung von Scala im Vergleich zu CLR. In der JVM ist es möglich, höhere Arten direkt zu implementieren, da die JVM selbst keine Einschränkungen für generische Typen auferlegt (da diese "Typen" effektiv fehlen). Dies steht im Gegensatz zur CLR, die über Laufzeitkenntnisse zu Parameterinstanziierungen verfügt. Aus diesem Grund muss die CLR selbst ein Konzept für die Verwendung von Generika haben, wodurch Versuche, das System mit unerwarteten Regeln zu erweitern, zunichte gemacht werden. Infolgedessen werden Scalas höhere Arten in der CLR mithilfe einer seltsamen Form der Löschung implementiert, die im Compiler selbst emuliert wird.

Das Löschen kann unpraktisch sein, wenn Sie zur Laufzeit ungezogene Dinge tun möchten, bietet jedoch den Compiler-Autoren die größte Flexibilität. Ich vermute, das ist ein Teil dessen, warum es nicht so schnell verschwindet.

Daniel Spiewak
quelle
6
Die Unannehmlichkeit ist nicht, wenn Sie zur Ausführungszeit "ungezogene" Dinge tun möchten. Es ist, wenn Sie zur Ausführungszeit völlig vernünftige Dinge tun möchten. Tatsächlich können Sie durch Löschen des Typs weitaus ungezogenere Dinge tun, z. B. eine Liste <String> in Liste und dann in Liste <Datum> mit nur Warnungen umwandeln.
Jon Skeet
5

Soweit ich weiß (als .NET- Typ), hat die JVM kein Konzept für Generika, daher ersetzt der Compiler die Typparameter durch Object und führt das gesamte Casting für Sie durch.

Dies bedeutet, dass Java-Generika nichts anderes als Syntaxzucker sind und keine Leistungsverbesserung für Werttypen bieten, die Boxing / Unboxing erfordern, wenn sie als Referenz übergeben werden.

Andrew Kennan
quelle
3
Java-Generika können ohnehin keine Werttypen darstellen - es gibt keine List <int>. In Java gibt es jedoch überhaupt keine Referenzübergabe - es handelt sich ausschließlich um eine Wertübergabe (wobei dieser Wert möglicherweise eine Referenz ist).
Jon Skeet,
2

Es gibt gute Erklärungen. Ich füge nur ein Beispiel hinzu, um zu zeigen, wie die Typlöschung mit einem Dekompiler funktioniert.

Ursprüngliche Klasse,

import java.util.ArrayList;
import java.util.List;


public class S<T> {

    T obj; 

    S(T o) {
        obj = o;
    }

    T getob() {
        return obj;
    }

    public static void main(String args[]) {
        List<String> list = new ArrayList<>();
        list.add("Hello");

        // for-each
        for(String s : list) {
            String temp = s;
            System.out.println(temp);
        }

        // stream
        list.forEach(System.out::println);
    }
}

Dekompilierter Code aus seinem Bytecode,

import java.io.PrintStream;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.Objects;
import java.util.function.Consumer;

public class S {

   Object obj;


   S(Object var1) {
      this.obj = var1;
   }

   Object getob() {
      return this.obj;
   }

   public static void main(String[] var0) {

   ArrayList var1 = new ArrayList();
   var1.add("Hello");


   // for-each
   Iterator iterator = var1.iterator();

   while (iterator.hasNext()) {
         String string;
         String string2 = string = (String)iterator.next();
         System.out.println(string2);
   }


   // stream
   PrintStream printStream = System.out;
   Objects.requireNonNull(printStream);
   var1.forEach(printStream::println);


   }
}
snr
quelle
2

Warum Generices verwenden?

Kurz gesagt, Generika ermöglichen es Typen (Klassen und Schnittstellen), Parameter bei der Definition von Klassen, Schnittstellen und Methoden zu sein. Ähnlich wie die bekannteren formalen Parameter, die in Methodendeklarationen verwendet werden, bieten Typparameter eine Möglichkeit, denselben Code mit unterschiedlichen Eingaben wiederzuverwenden. Der Unterschied besteht darin, dass die Eingaben für formale Parameter Werte sind, während die Eingaben für Typparameter Typen sind. Eine Ode, die Generika verwendet, hat viele Vorteile gegenüber nicht generischem Code:

  • Stärkere Typprüfungen zur Kompilierungszeit.
  • Beseitigung von Abgüssen.
  • Programmierer können generische Algorithmen implementieren.

Was ist Typ Löschung?

Generika wurden in die Java-Sprache eingeführt, um beim Kompilieren strengere Typprüfungen zu ermöglichen und die generische Programmierung zu unterstützen. Um Generika zu implementieren, wendet der Java-Compiler das Löschen des Typs an:

  • Ersetzen Sie alle Typparameter in generischen Typen durch ihre Grenzen oder Objekte, wenn die Typparameter unbegrenzt sind. Der erzeugte Bytecode enthält daher nur gewöhnliche Klassen, Schnittstellen und Methoden.
  • Fügen Sie bei Bedarf Gussteile ein, um die Sicherheit des Typs zu gewährleisten.
  • Generieren Sie Brückenmethoden, um den Polymorphismus in erweiterten generischen Typen zu erhalten.

[NB] -Was ist die Brückenmethode? Bei einer parametrisierten Schnittstelle wie z. B. Comparable<T>kann dies dazu führen, dass der Compiler zusätzliche Methoden einfügt. Diese zusätzlichen Methoden werden als Brücken bezeichnet.

So funktioniert das Löschen

Das Löschen eines Typs ist wie folgt definiert: Löschen Sie alle Typparameter aus parametrisierten Typen und ersetzen Sie jede Typvariable durch das Löschen ihrer Bindung oder durch Object, wenn es keine Bindung hat, oder durch das Löschen der Grenze ganz links, wenn es eine Grenze hat mehrere Grenzen. Hier sind einige Beispiele:

  • Die Löschung List<Integer>, List<String>und List<List<String>>ist List.
  • Das Löschen von List<Integer>[]ist List[].
  • Das Löschen von Listist selbst, ähnlich für jeden rohen Typ.
  • Das Löschen von int ist selbst, ähnlich für jeden primitiven Typ.
  • Das Löschen von Integerist selbst, ähnlich für jeden Typ ohne Typparameter.
  • Das Löschen Tin der Definition von asListist Object, weil T keine Grenze hat.
  • Das Löschen Tin der Definition von maxist Comparable, weil T gebunden hat Comparable<? super T>.
  • Die Löschung von Tin der endgültigen Definition von maxist Object, weil That Object& gebunden hat Comparable<T>und wir die Löschung der am weitesten links liegenden Grenze nehmen.

Seien Sie vorsichtig, wenn Sie Generika verwenden

In Java können zwei unterschiedliche Methoden nicht dieselbe Signatur haben. Da Generika durch Löschen implementiert werden, folgt auch, dass zwei unterschiedliche Methoden keine Signaturen mit derselben Löschung haben können. Eine Klasse kann nicht zwei Methoden überladen, deren Signaturen dieselbe Löschung aufweisen, und eine Klasse kann keine zwei Schnittstellen implementieren, die dieselbe Löschung aufweisen.

    class Overloaded2 {
        // compile-time error, cannot overload two methods with same erasure
        public static boolean allZero(List<Integer> ints) {
            for (int i : ints) if (i != 0) return false;
            return true;
        }
        public static boolean allZero(List<String> strings) {
            for (String s : strings) if (s.length() != 0) return false;
            return true;
        }
    }

Wir beabsichtigen, dass dieser Code wie folgt funktioniert:

assert allZero(Arrays.asList(0,0,0));
assert allZero(Arrays.asList("","",""));

In diesem Fall sind die Löschungen der Signaturen beider Methoden jedoch identisch:

boolean allZero(List)

Daher wird beim Kompilieren ein Namenskonflikt gemeldet. Es ist nicht möglich, beide Methoden gleich zu benennen und durch Überladen zwischen ihnen zu unterscheiden, da es nach dem Löschen unmöglich ist, einen Methodenaufruf vom anderen zu unterscheiden.

Hoffentlich wird der Leser genießen :)

Atif
quelle