Schauen Sie sich die Kraft des parametrischen Polymorphismus an

Angenommen, Sie möchten eine Listenklasse in Java implementieren. Sie beginnen mit einer abstrakten Klasse Listund zwei Unterklassen Emptyund Cons, die leere bzw. nicht leere Listen darstellen. Da Sie die Funktionalität dieser Listen erweitern möchten, entwerfen Sie eine ListVisitorSchnittstelle und stellen accept(...)Hooks für ListVisitors in jeder Ihrer Unterklassen bereit . Darüber hinaus verfügt Ihre ConsKlasse über zwei Felder firstund restentsprechende Accessor-Methoden.

Welche Arten dieser Felder gibt es? Klar, restsollte vom Typ sein List. Wenn Sie im Voraus wissen, dass Ihre Listen immer Elemente einer bestimmten Klasse enthalten, wird die Codierung an dieser Stelle erheblich einfacher. Wenn Sie beispielsweise wissen, dass Ihre Listenelemente alle integers sind, können Sie firstsie vom Typ zuweisen integer.

Wenn Sie diese Informationen jedoch, wie dies häufig der Fall ist, nicht im Voraus kennen, müssen Sie sich mit der am wenigsten verbreiteten Oberklasse zufrieden geben, die alle möglichen Elemente in Ihren Listen enthält. Dies ist normalerweise der universelle Referenztyp Object. Daher hat Ihr Code für Listen mit Elementen unterschiedlichen Typs die folgende Form:

abstrakte Klasse List {public abstract Object accept (ListVisitor that); } interface ListVisitor {public Object _case (Leere das); public Object _case (Nachteile); } class Empty erweitert List {public Object accept (ListVisitor that) {return that._case (this); }} class Cons erweitert List {private Object first; private Liste Rest; Nachteile (Object _first, List _rest) {first = _first; rest = _rest; } public Object first () {return first;} public List rest () {return rest;} public Object accept (ListVisitor that) {return that._case (this); }}

Obwohl Java-Programmierer auf diese Weise häufig die am wenigsten verbreitete Oberklasse für ein Feld verwenden, hat der Ansatz seine Nachteile. Angenommen, Sie erstellen eine ListVisitor, die alle Elemente einer Liste von Integers hinzufügt und das Ergebnis zurückgibt, wie unten dargestellt:

Klasse AddVisitor implementiert ListVisitor {private Integer zero = new Integer (0); public Object _case (Leere das) {return zero;} public Object _case (Cons that) {return new Integer (((Integer) that.first ()). intValue () + ((Integer) that.rest (). accept (this)). intValue ()); }}

Beachten Sie die expliziten Umwandlungen Integerin der zweiten _case(...)Methode. Sie führen wiederholt Laufzeittests durch, um die Eigenschaften der Daten zu überprüfen. Idealerweise sollte der Compiler diese Tests im Rahmen der Programmtypprüfung für Sie durchführen. Da jedoch nicht garantiert ist, dass dies AddVisitornur auf Lists von Integers angewendet wird , kann die Java-Typprüfung nicht bestätigen, dass Sie tatsächlich zwei Integers hinzufügen, es sei denn, die Casts sind vorhanden.

Sie könnten möglicherweise eine genauere Typprüfung erhalten, jedoch nur, indem Sie den Polymorphismus opfern und den Code duplizieren. Sie können beispielsweise für jede Elementklasse, die Sie in einem speichern , eine spezielle ListKlasse (mit entsprechenden Klassen Consund EmptyUnterklassen sowie einer speziellen VisitorSchnittstelle) erstellen List. Im obigen Beispiel würden Sie eine IntegerListKlasse erstellen , deren Elemente alle Integers sind. Wenn Sie beispielsweise Booleans an einer anderen Stelle im Programm speichern möchten, müssen Sie eine BooleanListKlasse erstellen .

Es ist klar, dass die Größe eines Programms, das mit dieser Technik geschrieben wurde, schnell zunehmen würde. Es gibt auch weitere stilistische Probleme; Eines der wesentlichen Prinzipien einer guten Softwareentwicklung besteht darin, für jedes Funktionselement des Programms einen einzigen Kontrollpunkt zu haben, und das Duplizieren von Code auf diese Weise durch Kopieren und Einfügen verstößt gegen dieses Prinzip. Dies führt häufig zu hohen Kosten für Softwareentwicklung und -wartung. Um zu sehen, warum, überlegen Sie, was passiert, wenn ein Fehler gefunden wird: Der Programmierer müsste zurückgehen und diesen Fehler in jeder erstellten Kopie separat korrigieren. Wenn der Programmierer vergisst, alle duplizierten Sites zu identifizieren, wird ein neuer Fehler eingeführt!

Wie das obige Beispiel zeigt, fällt es Ihnen jedoch schwer, gleichzeitig einen einzigen Kontrollpunkt beizubehalten und statische Typprüfungen zu verwenden, um sicherzustellen, dass bestimmte Fehler bei der Ausführung des Programms niemals auftreten. In Java, wie es heute existiert, haben Sie oft keine andere Wahl, als Code zu duplizieren, wenn Sie eine genaue statische Typprüfung wünschen. Natürlich können Sie diesen Aspekt von Java niemals vollständig beseitigen. Bestimmte Postulate der Automatentheorie, die zu ihrer logischen Schlussfolgerung gezogen werden, implizieren, dass kein Soundtypsystem den Satz gültiger Eingaben (oder Ausgaben) für alle Methoden in einem Programm genau bestimmen kann. Folglich muss jedes Typensystem ein Gleichgewicht zwischen seiner eigenen Einfachheit und der Ausdruckskraft der resultierenden Sprache finden. Das Java-Typ-System neigt der Einfachheit halber etwas zu sehr. Im ersten BeispielMit einem etwas ausdrucksstärkeren Typsystem hätten Sie eine präzise Typprüfung durchführen können, ohne Code duplizieren zu müssen.

Ein solches ausdrucksstarkes Typensystem würde der Sprache generische Typen hinzufügen . Generische Typen sind Typvariablen, die mit einem entsprechend spezifischen Typ für jede Instanz einer Klasse instanziiert werden können. Für die Zwecke dieses Artikels werde ich Typvariablen in spitzen Klammern über Klassen- oder Schnittstellendefinitionen deklarieren. Der Gültigkeitsbereich einer Typvariablen besteht dann aus dem Hauptteil der Definition, in der sie deklariert wurde (ohne die extendsKlausel). In diesem Bereich können Sie die Typvariable überall dort verwenden, wo Sie einen normalen Typ verwenden können.

Bei generischen Typen können Sie Ihre ListKlasse beispielsweise wie folgt umschreiben :

abstrakte Klasse List {public abstract T accept (ListVisitor that); } interface ListVisitor {public T _case (Leere das); public T _case (Nachteile); } class Empty erweitert List {public T accept (ListVisitor that) {return that._case (this); }} Klasse Cons erweitert List {private T first; private Liste Rest; Nachteile (T _first, List _rest) {first = _first; rest = _rest; } public T first () {return first;} public List rest () {return rest;} public T accept (ListVisitor that) {return that._case (this); }}

Jetzt können Sie neu schreiben AddVisitor, um die generischen Typen zu nutzen:

Klasse AddVisitor implementiert ListVisitor {private Integer zero = new Integer (0); public Integer _case (Leere das) {return zero;} public Integer _case (Cons that) {return new Integer ((that.first ()). intValue () + (that.rest (). accept (this)). intValue ()); }}

Beachten Sie, dass die expliziten Besetzungen Integernicht mehr benötigt werden. Das Argument thatfür die zweite _case(...)Methode wird als deklariert Consund instanziiert die Typvariable für die ConsKlasse mit Integer. Daher kann die statische Typprüfung nachweisen, dass that.first()sie vom Typ Integerund that.rest()vom Typ ist List. Ähnliche Instanziierungen würden jedes Mal vorgenommen, wenn eine neue Instanz von Emptyoder Consdeklariert wird.

Im obigen Beispiel können die Typvariablen mit any instanziiert werden Object. Sie können auch eine spezifischere Obergrenze für eine Typvariable angeben. In solchen Fällen können Sie diese Grenze am Deklarationspunkt der Typvariablen mit der folgenden Syntax angeben:

  erweitert  

Wenn Sie beispielsweise möchten, dass Ihr Lists nur ComparableObjekte enthält, können Sie Ihre drei Klassen wie folgt definieren:

Klassenliste {...} Klasse Nachteile {...} Klasse Leer {...} 

Das Hinzufügen parametrisierter Typen zu Java würde Ihnen zwar die oben gezeigten Vorteile bringen, dies wäre jedoch nicht sinnvoll, wenn dabei die Kompatibilität mit Legacy-Code beeinträchtigt würde. Glücklicherweise ist ein solches Opfer nicht notwendig. Es ist möglich, Code, der in einer Java-Erweiterung mit generischen Typen geschrieben wurde, automatisch in Bytecode für die vorhandene JVM zu übersetzen. Einige Compiler tun dies bereits - die von Martin Odersky geschriebenen Pizza- und GJ-Compiler sind besonders gute Beispiele. Pizza war eine experimentelle Sprache, die Java einige neue Funktionen hinzufügte, von denen einige in Java 1.2 integriert waren. GJ ist ein Nachfolger von Pizza, der nur generische Typen hinzufügt. Da dies die einzige hinzugefügte Funktion ist, kann der GJ-Compiler Bytecode erzeugen, der reibungslos mit Legacy-Code zusammenarbeitet. Es kompiliert Quelle zu Bytecode mittelsTyplöschung, die jede Instanz jeder Typvariablen durch die Obergrenze dieser Variablen ersetzt. Außerdem können Typvariablen für bestimmte Methoden und nicht für ganze Klassen deklariert werden. GJ verwendet dieselbe Syntax für generische Typen, die ich in diesem Artikel verwende.

In Arbeit

An der Rice University implementiert die Technologiegruppe für Programmiersprachen, in der ich arbeite, einen Compiler für eine aufwärtskompatible Version von GJ namens NextGen. Die NextGen-Sprache wurde gemeinsam von Professor Robert Cartwright von Rices Informatikabteilung und Guy Steele von Sun Microsystems entwickelt. Es bietet GJ die Möglichkeit, Laufzeitprüfungen von Typvariablen durchzuführen.

Eine weitere mögliche Lösung für dieses Problem, PolyJ, wurde am MIT entwickelt. Es wird in Cornell erweitert. PolyJ verwendet eine etwas andere Syntax als GJ / NextGen. Es unterscheidet sich auch geringfügig in der Verwendung von generischen Typen. Beispielsweise wird die Typparametrisierung einzelner Methoden nicht unterstützt, und derzeit werden keine inneren Klassen unterstützt. Im Gegensatz zu GJ oder NextGen können Typvariablen jedoch mit primitiven Typen instanziiert werden. Ebenso wie NextGen unterstützt PolyJ Laufzeitoperationen für generische Typen.

Sun hat eine Java Specification Request (JSR) zum Hinzufügen generischer Typen zur Sprache veröffentlicht. Es überrascht nicht, dass eines der Hauptziele für jede Einreichung die Aufrechterhaltung der Kompatibilität mit vorhandenen Klassenbibliotheken ist. Wenn generische Typen zu Java hinzugefügt werden, ist es wahrscheinlich, dass einer der oben diskutierten Vorschläge als Prototyp dient.

Es gibt einige Programmierer, die es trotz ihrer Vorteile ablehnen, generische Typen in irgendeiner Form hinzuzufügen. Ich beziehe mich auf zwei häufige Argumente solcher Gegner, wie das Argument "Vorlagen sind böse" und das Argument "Es ist nicht objektorientiert", und gehe nacheinander auf jedes dieser Argumente ein.

Sind Vorlagen böse?

C ++ verwendet Vorlageneine Form von generischen Typen bereitzustellen. Vorlagen haben bei einigen C ++ - Entwicklern einen schlechten Ruf erlangt, da ihre Definitionen nicht in parametrisierter Form typgeprüft werden. Stattdessen wird der Code bei jeder Instanziierung repliziert und jede Replikation wird separat typgeprüft. Das Problem bei diesem Ansatz besteht darin, dass im ursprünglichen Code möglicherweise Typfehler vorhanden sind, die in keiner der anfänglichen Instanziierungen angezeigt werden. Diese Fehler können sich später manifestieren, wenn Programmrevisionen oder -erweiterungen neue Instanziierungen einführen. Stellen Sie sich die Frustration eines Entwicklers vor, der vorhandene Klassen verwendet, die beim Kompilieren selbst eine Typprüfung durchführen, jedoch nicht, nachdem er eine neue, absolut legitime Unterklasse hinzugefügt hat! Schlimmer noch, wenn die Vorlage nicht zusammen mit den neuen Klassen neu kompiliert wird, werden solche Fehler nicht erkannt, sondern beschädigen das ausführende Programm.

Aufgrund dieser Probleme runzeln einige Leute die Stirn, wenn sie Vorlagen zurückbringen, und erwarten, dass die Nachteile von Vorlagen in C ++ auf ein generisches Typsystem in Java zutreffen. Diese Analogie ist irreführend, da sich die semantischen Grundlagen von Java und C ++ grundlegend unterscheiden. C ++ ist eine unsichere Sprache, in der die statische Typprüfung ein heuristischer Prozess ohne mathematische Grundlage ist. Im Gegensatz dazu ist Java eine sichere Sprache, in der die statische Typprüfung buchstäblich beweist, dass bestimmte Fehler bei der Ausführung des Codes nicht auftreten können. Infolgedessen leiden C ++ - Programme mit Vorlagen unter unzähligen Sicherheitsproblemen, die in Java nicht auftreten können.

Darüber hinaus führen alle bekannten Vorschläge für ein generisches Java eine explizite statische Typprüfung der parametrisierten Klassen durch, anstatt dies nur bei jeder Instanziierung der Klasse zu tun. Wenn Sie befürchten, dass eine solche explizite Überprüfung die Typprüfung verlangsamen würde, können Sie sicher sein, dass tatsächlich das Gegenteil der Fall ist: Da die Typprüfung nur einen Durchgang über den parametrisierten Code durchführt, im Gegensatz zu einem Durchgang für jede Instanziierung des Bei parametrisierten Typen wird der Typprüfungsprozess beschleunigt. Aus diesen Gründen gelten die zahlreichen Einwände gegen C ++ - Vorlagen nicht für die generischen Typvorschläge für Java. Wenn Sie über das hinausblicken, was in der Branche weit verbreitet ist, gibt es viele weniger beliebte, aber sehr gut gestaltete Sprachen wie Objective Caml und Eiffel, die parametrisierte Typen mit großem Vorteil unterstützen.

Sind generische Typsysteme objektorientiert?

Schließlich lehnen einige Programmierer ein generisches Typsystem mit der Begründung ab, dass solche Systeme, da sie ursprünglich für funktionale Sprachen entwickelt wurden, nicht objektorientiert sind. Dieser Einwand ist falsch. Generische Typen passen sehr natürlich in ein objektorientiertes Framework, wie die obigen Beispiele und Diskussionen zeigen. Ich vermute jedoch, dass dieser Einwand auf einem Unverständnis darüber beruht, wie generische Typen in den Vererbungspolymorphismus von Java integriert werden können. Tatsächlich ist eine solche Integration möglich und bildet die Grundlage für unsere Implementierung von NextGen.