Zeigen Sie die Magie hinter dem Subtyp-Polymorphismus auf

Das Wort Polymorphismus kommt aus dem Griechischen für "viele Formen". Die meisten Java-Entwickler verknüpfen den Begriff mit der Fähigkeit eines Objekts, das richtige Methodenverhalten an geeigneten Stellen in einem Programm auf magische Weise auszuführen. Diese implementierungsorientierte Sichtweise führt jedoch eher zu Bildern der Zauberei als zu einem Verständnis grundlegender Konzepte.

Polymorphismus in Java ist ausnahmslos ein Subtyp-Polymorphismus. Um die Mechanismen, die diese Vielfalt polymorphen Verhaltens erzeugen, genau zu untersuchen, müssen wir unsere üblichen Implementierungsbedenken verwerfen und in Bezug auf den Typ denken. Dieser Artikel untersucht eine typorientierte Perspektive von Objekten und wie diese Perspektive trennt, welches Verhalten ein Objekt ausdrücken kann und wie das Objekt dieses Verhalten tatsächlich ausdrückt. Indem wir unser Konzept des Polymorphismus von der Implementierungshierarchie befreien, entdecken wir auch, wie Java-Schnittstellen das polymorphe Verhalten über Gruppen von Objekten hinweg ermöglichen, die überhaupt keinen Implementierungscode gemeinsam haben.

Quattro polymorphi

Polymorphismus ist ein weit gefasster objektorientierter Begriff. Obwohl wir normalerweise das allgemeine Konzept mit der Subtyp-Varietät gleichsetzen, gibt es tatsächlich vier verschiedene Arten von Polymorphismus. Bevor wir den Subtyp-Polymorphismus im Detail untersuchen, bietet der folgende Abschnitt einen allgemeinen Überblick über den Polymorphismus in objektorientierten Sprachen.

Luca Cardelli und Peter Wegner, Autoren von "Über das Verständnis von Typen, Datenabstraktion und Polymorphismus" (siehe Ressourcen für den Link zum Artikel), unterteilen den Polymorphismus in zwei Hauptkategorien - ad hoc und universell - und vier Varianten: Zwang, Überladung, parametrisch und Einbeziehung. Die Klassifizierungsstruktur lautet:

| - Zwang | - ad hoc - | Überladung des Polymorphismus | - parametrisch | - universell - | Aufnahme

In diesem allgemeinen Schema repräsentiert Polymorphismus die Fähigkeit einer Entität, mehrere Formen zu haben. Universeller Polymorphismus bezieht sich auf eine Einheitlichkeit der Typstruktur, bei der der Polymorphismus über eine unendliche Anzahl von Typen wirkt, die ein gemeinsames Merkmal haben. Der weniger strukturierte Ad-hoc-Polymorphismus wirkt auf eine endliche Anzahl von möglicherweise nicht verwandten Typen. Die vier Sorten können beschrieben werden als:

  • Zwang: Eine einzelne Abstraktion dient mehreren Typen durch implizite Typkonvertierung
  • Überladung: Ein einzelner Bezeichner kennzeichnet mehrere Abstraktionen
  • Parametrisch: Eine Abstraktion funktioniert über verschiedene Typen hinweg einheitlich
  • Inklusion: Eine Abstraktion funktioniert über eine Inklusionsbeziehung

Ich werde jede Sorte kurz diskutieren, bevor ich mich speziell dem Subtyp-Polymorphismus zuwende.

Zwang

Zwang stellt eine implizite Konvertierung des Parametertyps in den von einer Methode oder einem Operator erwarteten Typ dar, wodurch Typfehler vermieden werden. Für die folgenden Ausdrücke muss der Compiler bestimmen, ob +für die Operandentypen ein geeigneter binärer Operator vorhanden ist:

 2,0 + 2,0 2,0 + 2 2,0 + "2" 

Der erste Ausdruck fügt zwei doubleOperanden hinzu. Die Java-Sprache definiert speziell einen solchen Operator.

Der zweite Ausdruck fügt jedoch a doubleund an hinzu int; Java definiert keinen Operator, der diese Operandentypen akzeptiert. Glücklicherweise konvertiert der Compiler den zweiten Operanden implizit in den doublefür zwei doubleOperanden definierten Operator und verwendet ihn . Das ist für den Entwickler äußerst praktisch. ohne die implizite Konvertierung, ein Fehler Compile-Zeit führen würde oder der Programmierer würde die explizit werfen müssen intzu double.

Der dritte Ausdruck fügt a doubleund a hinzu String. Auch hier definiert die Java-Sprache einen solchen Operator nicht. Der Compiler zwingt den doubleOperanden also zu a String, und der Plus-Operator führt eine Zeichenfolgenverkettung durch.

Zwang tritt auch beim Methodenaufruf auf. Angenommen, die Klasse Derivederweitert die Klasse Baseund die Klasse Cverfügt über eine Methode mit Signatur m(Base). Für den Methodenaufruf im folgenden Code konvertiert der Compiler implizit die derivedReferenzvariable mit Typ Derivedin den Basedurch die Methodensignatur vorgegebenen Typ. Diese implizite Konvertierung ermöglicht es dem m(Base)Implementierungscode der Methode, nur die Typoperationen zu verwenden, die definiert sind durch Base:

C c = neues C (); Abgeleitet abgeleitet = neu Abgeleitet (); cm (abgeleitet);

Wiederum vermeidet impliziter Zwang während des Methodenaufrufs eine umständliche Typumwandlung oder einen unnötigen Fehler bei der Kompilierung. Natürlich überprüft der Compiler weiterhin, ob alle Typkonvertierungen der definierten Typhierarchie entsprechen.

Überlastung

Durch Überladen kann derselbe Operator- oder Methodenname verwendet werden, um mehrere unterschiedliche Programmbedeutungen zu kennzeichnen. Der +im vorherigen Abschnitt verwendete Operator zeigte zwei Formen: eine zum Hinzufügen von doubleOperanden und eine zum Verketten von StringObjekten. Es gibt andere Formen zum Hinzufügen von zwei Ganzzahlen, zwei Longs usw. Wir nennen den Operator überladen und verlassen uns darauf, dass der Compiler die entsprechende Funktionalität basierend auf dem Programmkontext auswählt. Wie bereits erwähnt, konvertiert der Compiler bei Bedarf die Operandentypen implizit so, dass sie mit der genauen Signatur des Operators übereinstimmen. Obwohl Java bestimmte überladene Operatoren angibt, unterstützt es keine benutzerdefinierte Überladung von Operatoren.

Java erlaubt das benutzerdefinierte Überladen von Methodennamen. Eine Klasse kann mehrere Methoden mit demselben Namen besitzen, vorausgesetzt, die Methodensignaturen sind unterschiedlich. Das bedeutet, dass entweder die Anzahl der Parameter unterschiedlich sein muss oder mindestens eine Parameterposition einen anderen Typ haben muss. Mit eindeutigen Signaturen kann der Compiler zwischen Methoden mit demselben Namen unterscheiden. Der Compiler zerlegt die Methodennamen mithilfe der eindeutigen Signaturen und erstellt so effektiv eindeutige Namen. In Anbetracht dessen verdunstet jedes scheinbare polymorphe Verhalten bei näherer Betrachtung.

Sowohl Zwang als auch Überlastung werden als Ad-hoc klassifiziert, da jedes nur in einem begrenzten Sinne polymorphes Verhalten liefert. Obwohl sie unter eine breite Definition des Polymorphismus fallen, sind diese Sorten in erster Linie Entwicklerkomfort. Durch Zwang werden umständliche explizite Typumwandlungen oder unnötige Compilertypfehler vermieden. Überladen hingegen liefert syntaktischen Zucker, sodass ein Entwickler denselben Namen für verschiedene Methoden verwenden kann.

Parametrisch

Der parametrische Polymorphismus ermöglicht die Verwendung einer einzelnen Abstraktion für viele Typen. Beispielsweise könnte eine ListAbstraktion, die eine Liste homogener Objekte darstellt, als generisches Modul bereitgestellt werden. Sie würden die Abstraktion wiederverwenden, indem Sie die in der Liste enthaltenen Objekttypen angeben. Da der parametrisierte Typ ein beliebiger benutzerdefinierter Datentyp sein kann, gibt es eine potenziell unendliche Anzahl von Verwendungsmöglichkeiten für die generische Abstraktion, was diesen Typ wohl zum mächtigsten Typ des Polymorphismus macht.

Auf den ersten Blick Listscheint die obige Abstraktion der Nutzen der Klasse zu sein java.util.List. Allerdings ist Java nicht wahr parametrischer Polymorphismus in einem typsichere Art und Weise unterstützen, weshalb java.util.Listund java.utilandere Sammlungsklassen s‘sind in Bezug auf die ursprünglichen Java - Klasse geschrieben java.lang.Object. (Weitere Informationen finden Sie in meinem Artikel "A Primordial Interface?".) Javas Single-Rooted-Implementierungsvererbung bietet eine Teillösung, jedoch nicht die wahre Kraft des parametrischen Polymorphismus. Eric Allens ausgezeichneter Artikel "Siehe die Kraft des parametrischen Polymorphismus" beschreibt die Notwendigkeit generischer Typen in Java und die Vorschläge zur Beantwortung der Java-Spezifikationsanforderung Nr. 000014 von Sun, "Hinzufügen generischer Typen zur Java-Programmiersprache". (Einen Link finden Sie unter Ressourcen.)

Aufnahme

Inclusion polymorphism achieves polymorphic behavior through an inclusion relation between types or sets of values. For many object-oriented languages, including Java, the inclusion relation is a subtype relation. So in Java, inclusion polymorphism is subtype polymorphism.

As noted earlier, when Java developers generically refer to polymorphism, they invariably mean subtype polymorphism. Gaining a solid appreciation of subtype polymorphism's power requires viewing the mechanisms yielding polymorphic behavior from a type-oriented perspective. The rest of this article examines that perspective closely. For brevity and clarity, I use the term polymorphism to mean subtype polymorphism.

Type-oriented view

The UML class diagram in Figure 1 shows the simple type and class hierarchy used to illustrate the mechanics of polymorphism. The model depicts five types, four classes, and one interface. Although the model is called a class diagram, I think of it as a type diagram. As detailed in "Thanks Type and Gentle Class," every Java class and interface declares a user-defined data type. So from an implementation-independent view (i.e., a type-oriented view) each of the five rectangles in the figure represents a type. From an implementation point of view, four of those types are defined using class constructs, and one is defined using an interface.

The following code defines and implements each user-defined data type. I purposely keep the implementation as simple as possible:

/* Base.java */ public class Base { public String m1() { return "Base.m1()"; } public String m2( String s ) { return "Base.m2( " + s + " )"; } } /* IType.java */ interface IType { String m2( String s ); String m3(); } /* Derived.java */ public class Derived extends Base implements IType { public String m1() { return "Derived.m1()"; } public String m3() { return "Derived.m3()"; } } /* Derived2.java */ public class Derived2 extends Derived { public String m2( String s ) { return "Derived2.m2( " + s + " )"; } public String m4() { return "Derived2.m4()"; } } /* Separate.java */ public class Separate implements IType { public String m1() { return "Separate.m1()"; } public String m2( String s ) { return "Separate.m2( " + s + " )"; } public String m3() { return "Separate.m3()"; } } 

Using these type declarations and class definitions, Figure 2 depicts a conceptual view of the Java statement:

Derived2 derived2 = new Derived2(); 

The above statement declares an explicitly typed reference variable, derived2, and attaches that reference to a newly created Derived2 class object. The top panel in Figure 2 depicts the Derived2 reference as a set of portholes, through which the underlying Derived2 object can be viewed. There is one hole for each Derived2 type operation. The actual Derived2 object maps each Derived2 operation to appropriate implementation code, as prescribed by the implementation hierarchy defined in the above code. For example, the Derived2 object maps m1() to implementation code defined in class Derived. Furthermore, that implementation code overrides the m1() method in class Base. A Derived2 reference variable cannot access the overridden m1() implementation in class Base. That does not mean that the actual implementation code in class Derived can't use the Base class implementation via super.m1(). But as far as the reference variable derived2 is concerned, that code is inaccessible. The mappings of the other Derived2 operations similarly show the implementation code executed for each type operation.

Now that you have a Derived2 object, you can reference it with any variable that conforms to type Derived2. The type hierarchy in Figure 1's UML diagram reveals that Derived, Base, and IType are all super types of Derived2. So, for example, a Base reference can be attached to the object. Figure 3 depicts the conceptual view of the following Java statement:

Base base = derived2; 

There is absolutely no change to the underlying Derived2 object or any of the operation mappings, though methods m3() and m4() are no longer accessible through the Base reference. Calling m1() or m2(String) using either variable derived2 or base results in execution of the same implementation code:

String tmp; // Derived2 reference (Figure 2) tmp = derived2.m1(); // tmp is "Derived.m1()" tmp = derived2.m2( "Hello" ); // tmp is "Derived2.m2( Hello )" // Base reference (Figure 3) tmp = base.m1(); // tmp is "Derived.m1()" tmp = base.m2( "Hello" ); // tmp is "Derived2.m2( Hello )" 

Realizing identical behavior through both references makes sense because the Derived2 object does not know what calls each method. The object only knows that when called upon, it follows the marching orders defined by the implementation hierarchy. Those orders stipulate that for method m1(), the Derived2 object executes the code in class Derived, and for method m2(String), it executes the code in class Derived2. The action performed by the underlying object does not depend on the reference variable's type.

Wenn Sie die Referenzvariablen derived2und verwenden , ist jedoch nicht alles gleich base. Wie in Abbildung 3 dargestellt, kann eine BaseTypreferenz nur die BaseTypoperationen des zugrunde liegenden Objekts anzeigen. Obwohl Derived2es Zuordnungen für Methoden m3()und hat m4(), basekann Variable nicht auf diese Methoden zugreifen:

String tmp; // Derived2-Referenz (Abbildung 2) tmp = derivative2.m3 (); // tmp ist "Derived.m3 ()" tmp = derivative2.m4 (); // tmp ist "Derived2.m4 ()" // Basisreferenz (Abbildung 3) tmp = base.m3 (); // Kompilierungsfehler tmp = base.m4 (); // Kompilierungsfehler

Die Laufzeit

Derived2

Objekt bleibt voll in der Lage, entweder die zu akzeptieren

m3()

oder

m4()

Methodenaufrufe. Die Typeinschränkungen, die diese versuchten Anrufe über das nicht zulassen

Base