Polymorphie in Java erklärt

Das Konzept der Polymorphie entfaltet auch in Java magische Wirkung.
KRIACHKO OLEKSII | shutterstock.com



Der Begriff der Polymorphie bezeichnet im Tierreich die Fähigkeit bestimmter Lebewesen, mehrere verschiedene Gestalten annehmen zu können. Das Konzept existiert jedoch auch im Bereich der Programmiersprachen. Hier bezeichnet es eine Modellierungstechnik, die es ermöglicht, eine einzige Schnittstelle zu verschiedenen Operanden, Argumenten und Objekten zu schaffen. Mit Blick auf Java sorgt das für prägnanteren Code, der außerdem leichter zu warten ist.



In diesem Tutorial lesen Sie:




welche Arten von Java-Polymorphie existieren,



warum die Subtyp-Polymorphie besonders wichtig ist,



wie Upcasting und Late Binding funktionieren,



wie Sie abstrakte Klassen und Methoden händeln,



warum Downcasting und Runtime Type Identification (RTTI) wichtig sind und



was es mit kovarianten Rückgabetypen auf sich hat.




Den Quellcode zu diesem Tutorial können Sie hier herunterladen.



Polymorphie-Arten in Java



In Java existieren vier verschiedene Arten von Polymorphie, die Sie kennen sollten.




Coercion bezeichnet eine Operation, die mehrere Typen durch implizite Type Conversion bedient. Ein Beispiel hierfür: Sie dividieren eine Ganzzahl durch eine andere Ganzzahl oder eine Fließkommazahl durch eine andere Fließkommazahl. Wenn ein Operand eine Ganzzahl und der andere eine Fließkommazahl ist, konvertiert der Compiler die Ganzzahl implizit in eine Fließkommazahl, um einen Typfehler zu vermeiden. Ein weiteres Beispiel wäre die Übergabe einer Subklassen-Objektreferenz an den Superklassen-Parameter einer Methode. Der Compiler „zwingt“ den Typ der Subklasse in den der Superklasse, um die Operationen auf letztgenannte zu beschränken.



Unter Overloading (Überladung) versteht man die Verwendung desselben Operator-Symbols oder Methodennamens in verschiedenen Kontexten. Sie könnten etwa mit + eine Ganzzahl- oder Fließkommazahladdition oder eine String-Verkettung durchführen – je nach den Typen der Operanden. Darüber hinaus können auch mehrere Methoden mit demselben Namen in einer Klasse auftauchen (durch Deklaration und/oder Vererbung).



Die parametrische Polymorphie besagt, dass innerhalb einer Klassendeklaration ein Feldname mit verschiedenen Typen und ein Methodenname mit verschiedenen Parameter- und Rückgabetypen assoziiert werden kann. Feld und Methode können dann in jeder Klasseninstanz (Objekt) einen anderen Typ annehmen. So kann beispielsweise ein Feld den Typ Double aufweisen (ein Element der Standard-Klassenbibliothek von Java, das einen Double-Value verpackt) und eine Methode in einem Objekt einen Double-Wert zurückgeben – während das gleiche Feld in einem anderen Objekt vom Typ String sein kann und die gleiche Methode einen String-Wert zurückgeben kann. Java unterstützt parametrischen Polymorphismus über Generics.



Subtyp-Polymorphie bedeutet, dass ein Typ als Subtyp eines anderen Typs dienen kann. Wenn eine Subtyp-Instanz in einem Supertyp-Kontext auftaucht, führt die Ausführung einer Supertyp-Operation auf der Subtyp-Instanz dazu, dass die Subtyp-Version der Operation ausgeführt wird. Angenommen Sie haben ein Codefragment, das beliebige „Shapes“ aufwirft. Diesen Code könnten Sie prägnanter ausdrücken, indem Sie: mit einer draw()-Methode eine Shape-Klasse einführen; Circle, Rectangle und andere Subklassen einführen, die draw() überschreiben; ein Array des Shape-Typs einführen, dessen Elemente Referenzen auf Instanzen der Shape-Subklassen speichern; die draw()-Methode von Shape für jede Instanz callen.




Ad-hoc- vs. universelle Polymorphie



Wie die meisten anderen Entwickler ordne ich Coercion und Overloading der Ad-hoc-Polymorphie zu – parametrische und Subtyp-Polymorphie der universellen. Obwohl die beiden erstgenannten wertvolle Techniken darstellen, bilden sie meiner Auffassung nach keine „echte“ Polymorphie ab. Sie sind vielmehr Type Conversions und syntaktisches „Zuckergebäck“. Unser Fokus liegt in diesem Tutorial auf der Subtyp-Polymorphie.



Subtyp-Polymorphie: Upcasting und Late Binding



Die Subtyp-Polymorphie basiert auf Upcasting und Late Binding.



Upcasting und Late Binding



Hierbei handelt es sich um eine Casting-Form, bei der Sie innerhalb der Vererbungshierarchie von einem Subtyp zu einem Supertyp „hochcasten“. Weil der Subtyp dabei eine Spezialisierung des Supertyps darstellt, ist kein Cast-Operator beteiligt. Shape s = new Circle(); sorgt beispielsweise für einen Upcast von Circle zu Shape. Das macht Sinn, weil ein „Circle“ eine Art von „Shape“ ist.



Da Circle-spezifische Methoden nicht Teil des Shape-Interface sind, können Sie nach dem Upcast von Circle zu Shape keine Circle-spezifischen Methoden wie etwa getRadius() (gibt den Kreisradius zurück) callen. Den Zugriff auf Subtyp-Features nach der Eingrenzung einer Subklasse auf ihre Superklasse scheint sinnlos, ist aber notwendig, um Subtyp-Polymorphie zu erreichen.



Angenommen, Shape deklariert eine draw()-Methode, seine Circle-Subklasse überschreibt diese Methode, Shape s = new Circle(); wurde gerade ausgeführt und die nächste Zeile spezifiziert s.draw();. Der Compiler weiß in diesem Fall nicht, ob die draw()-Methode von Shape oder von Circle aufgerufen werden soll. Er kann lediglich prüfen, ob:




eine Methode in der Superklasse existiert und



die Liste der Argumente und der Rückgabetyp des Methoden-Calls mit der Methodendeklaration der Superklasse übereinstimmen.




Der Compiler fügt jedoch auch eine Anweisung in den kompilierten Code ein, die zur Laufzeit die in s vorliegende Referenz nutzt, um die korrekte draw()-Methode aufzurufen. Dieser Task wird als Late Binding bezeichnet.



Late Binding vs. Early Binding



Für Instanzmethoden, die nicht final sind, kommt Late Binding zum Einsatz. Bei allen anderen Methoden-Calls weiß der Compiler, was zu tun ist. Er fügt eine Anweisung in den kompilierten Code ein, die die Methode aufruft, die mit dem Typ der Variablen (nicht mit ihrem Wert) verbunden ist. Diese Technik wird als Early Binding bezeichnet.



Ich habe eine Anwendung erstellt, die Subtyp-Polymorphie in Form von Upcasting und Late Binding demonstriert. Diese Anwendung besteht aus den Klassen Shape, Circle, Rectangle und Shapes, wobei jede Klasse in ihrer eigenen Quelldatei gespeichert ist. Das folgende Listing zeigt die ersten drei Klassen.



Listing 1



class Shape



{



   void draw()



   {



   }



}



class Circle extends Shape



{



   private int x, y, r;



   Circle(int x, int y, int r)



   {



      this.x = x;



      this.y = y;



      this.r = r;



   }



   // For brevity, I've omitted getX(), getY(), and getRadius() methods.



   @Override



   void draw()



   {



      System.out.println("Drawing circle (" + x + ", "+ y + ", " + r + ")");



   }



}



class Rectangle extends Shape



{



   private int x, y, w, h;



   Rectangle(int x, int y, int w, int h)



   {



      this.x = x;



      this.y = y;



      this.w = w;



      this.h = h;



   }



   // For brevity, I've omitted getX(), getY(), getWidth(), and getHeight()



   // methods.



   @Override



   void draw()



   {



      System.out.println("Drawing rectangle (" + x + ", "+ y + ", " + w + "," +



                         h + ")");



   }



}



Das folgende Listing zeigt die Anwendungsklasse Shapes, deren main()-Methode die Anwendung steuert.



Listing 2



class Shapes



{



   public static void main(String[] args)



   {



      Shape[] shapes = { new Circle(10, 20, 30),



                         new Rectangle(20, 30, 40, 50) };



      for (int i = 0; i < shapes.length; i++)



         shapes[i].draw();



   }



}



Die Deklaration des shapes-Array demonstriert das Upcasting. Die Referenzen Circle und Rectangle werden in shapes[0] und shapes[1] gespeichert und in den Shape-Typ „hochgecastet“. Dabei wird sowohl shapes[0] als auch shapes[1] jeweils als Shape-Instanz betrachtet: shapes[0] wird nicht als Circle, shapes[1] nicht als Rectangle betrachtet.



Late Binding wird durch den Ausdruck shapes[i].draw(); repräsentiert. Dabei gilt:




Wenn i gleich 0 ist, bewirkt die vom Compiler erzeugte Anweisung, dass die draw()-Methode von Circle aufgerufen wird.



Wenn i gleich 1 ist, bewirkt diese Anweisung, dass die draw()-Methode von Rectangle aufgerufen wird.




Das ist die Grundessenz der Subtyp-Polymorphie.



Unter der Annahme, dass sich alle vier Quelldateien (Shapes.java, Shape.java, Rectangle.java und Circle.java) im aktuellen Verzeichnis befinden, kompilieren Sie sie mit einer der folgenden Befehlszeilen:



javac *.java



javac Shapes.java



Anschließend führen Sie die resultierende Anwendung aus mit:



java Shapes



Nun sollten Sie folgenden Output sehen:



Drawing circle (10, 20, 30)



Drawing rectangle (20, 30, 40, 50)



Abstrakte Klassen und Methoden



Geht es um das Design von Klassenhierarchien, werden Sie feststellen, dass Klassen die in dieser weiter oben stehen, generischer sind als die, die weiter unten liegen. So ist die Superklasse Vehicle generischer als die Subklasse Truck. Ähnlich verhält es sich mit der Superklasse Shape, die generischer ist als die Subklassen Circle oder Rectangle.



Es macht keinen Sinn, generische Klassen zu instanziieren. Was würde ein Vehicle-Objekt beschreiben, welche Art von Form ein Shape-Objekt darstellen? Statt eine leere draw()-Methode in Shape zu coden, lässt es sich verhindern, dass diese Methode gecallt und die Klasse instanziiert wird – indem beide Entitäten als abstrakt deklariert werden.



Um eine Klasse zu deklarieren, die nicht instanziiert werden kann, ist in Java das Keyword abstract vorgesehen. Es wird auch verwendet, um eine Methode ohne Body zu deklarieren. Die draw()-Methode benötigt keinen, da sie nicht in der Lage ist, eine abstrakte Form zu zeichnen – wie das nachfolgende Listing demonstriert.



Listing 3



abstract class Shape



{



   abstract void draw(); // semicolon is required



}



Abstract mit Vorsicht genießen



Wenn Sie versuchen, eine Klasse als abstrakt und final zu deklarieren, meldet der Compiler einen Fehler. Er beanstandet zum Beispiel abstract final class Shape, weil eine abstrakte Klasse nicht instanziiert und eine finale Klasse nicht erweitert werden kann. Einen Compiler-Fehler erzeugt außerdem eine Methode als abstract zu deklarieren, die zugehörige Klasse jedoch nicht.



Das Keyword abstract jedoch aus dem Header der Shape-Klasse zu entfernen, würde ebenfalls zu einem Fehler führen, weil eine nicht abstrakte (konkrete) Klasse nicht instanziiert werden kann, wenn sie eine abstrakte Methode enthält. Wenn Sie eine abstrakte Klasse erweitern wollen, muss die erweiternde Klasse alle abstrakten Methoden außer Kraft setzen – oder die erweiternde Klasse selbst als abstrakt deklariert werden.



Eine abstrakte Klasse kann Felder, Konstruktoren und nicht-abstrakte Methoden zusätzlich zu – oder anstelle von – abstrakten Methoden deklarieren. Eine abstrakte Vehicle-Klasse könnte zum Beispiel Felder deklarieren, die die Marke, das Modell und das Baujahr beschreiben. Außerdem könnte sie einen Konstruktor deklarieren, um diese Felder zu initialisieren sowie konkrete Methoden, um deren Werte zurückzugeben – wie im folgenden Listing.



Listing 4



abstract class Vehicle



{



   private String make, model;



   private int year;



   Vehicle(String make, String model, int year)



   {



      this.make = make;



      this.model = model;



      this.year = year;



   }



   String getMake()



   {



      return make;



   }



   String getModel()



   {



      return model;



   }



   int getYear()



   {



      return year;



   }



   abstract void move();



}



In diesem Beispiel deklariert Vehicle eine abstrakte move()-Methode, um die Bewegung eines Fahrzeugs zu beschreiben: Ein Auto rollt auf Asphalt, ein Boot segelt über das Wasser und ein Flugzeug fliegt durch die Luft. Die Subklassen von Vehicle würden move() außer Kraft setzen und eine entsprechende Beschreibung liefern. Sie würden auch die Methoden erben und ihre Konstruktoren den Konstruktor von Vehicle aufrufen.



Downcasting und RTTI



Upcasting hat zur Folge, dass der Zugriff auf Subtyp-Features verlorengeht. Weisen Sie beispielsweise ein Circle-Objekt der Shape-Variablen s zu, können Sie diese nicht verwenden, um die getRadius()-Methode von Circle zu callen.



Downcasting



Es ist jedoch möglich, wieder auf diese zuzugreifen, indem Sie eine explizite Cast-Operation wie Circle c = (Circle) s; durchführen. Diese Zuweisung wird als Downcasting bezeichnet, weil Sie in der Vererbungshierarchie von einem Supertyp (Shape) auf einen Subtyp (Circle) zurückgreift. Obwohl Upcasting immer sicher ist (die Schnittstelle der Superklasse ist eine Teilmenge derer der Unterklasse), ist das beim Downcasting nicht immer der Fall. Das nachfolgende Listing demonstriert, welche Probleme auftreten können diese Technik falsch eingesetzt wird.



Listing 5



class Superclass



{



}



class Subclass extends Superclass



{



   void method()



   {



   }



}



public class BadDowncast



{



   public static void main(String[] args)



   {



      Superclass superclass = new Superclass();



      Subclass subclass = (Subclass) superclass;



      subclass.method();



   }



}



Hier sehen Sie eine Klassenhierarchie, bestehend aus Superclass und Subclass, die Superclass erweitert. Außerdem deklariert Subclass eine method(). Eine dritte Klasse namens BadDowncast bietet eine main()-Methode, die Superclass instanziiert. BadDowncast versucht dann, dieses Objekt auf Subclass herunterzurechnen und das Ergebnis der Subclass-Variablen zuzuweisen.



In diesem Fall wird sich der Compiler nicht beschweren, da ein Downcasting von einer Superklasse zu einer Subklasse in derselben Typenhierarchie zulässig ist. Wäre die Zuweisung erlaubt, würde die Anwendung abstürzen, sobald sie versucht, subclass.method(); auszuführen. In diesem Fall würde die JVM versuchen, eine nicht existierende Methode aufzurufen, da die Superklasse keine method() deklariert. Glücklicherweise prüft die JVM, ob ein Cast zulässig ist, bevor sie eine entsprechende Operation durchführt. Stellt sie dabei fest, dass Superclass keine method() deklariert, würde sie ein ClassCastException-Objekt auslösen.



Kompilieren Sie Listing 5 wie folgt:



javac BadDowncast.java



Die resultierende Anwendung führen Sie aus mit:



java BadDowncast



Das sollte in folgendem Output resultieren:



Exception in thread "main" java.lang.ClassCastException: class Superclass cannot be cast to class Subclass (Superclass and Subclass are in unnamed module of loader 'app')



    at BadDowncast.main(BadDowncast.java:17)



Runtime Type Identification (RTTI)



Die Cast-Verifizierung der Java Virtual Machine (JVM) in Listing 5 veranschaulicht RTTI. Das wird initiiert, indem der Operanden-Typ des Cast-Operators untersucht wird, um festzustellen, ob der Cast zulässig ist oder nicht. In diesem Szenario sollte er es nicht sein.



Eine andere Form von RTTI betrifft den instanceof-Operator. Dieser Operator prüft den linken Operanden darauf, ob er eine Instanz des rechten Operanden ist. Ist das der Fall, gibt er true zurück. Im folgenden Beispiel wird instanceof in Listing 5 eingeführt, um die ClassCastException zu verhindern:



if (superclass instanceof Subclass)



{



   Subclass subclass = (Subclass) superclass;



   subclass.method();



}



Der instanceof-Operator erkennt, dass die Instanz der variablen Superklasse nicht von der Subklasse erstellt wurde. Um das anzuzeigen, gibt er false zurück. Infolgedessen wird der Code, der den unzulässigen Cast durchführt, nicht ausgeführt.



Da ein Subtyp eine Art Supertyp ist, gibt instanceof true zurück, wenn sein linker Operand eine Subtyp- oder eine Supertyp-Instanz des rechten Supertyp-Operanden darstellt. Das demonstriert das folgende Beispiel:



Superclass superclass = new Superclass();



Subclass subclass = new Subclass();



System.out.println(subclass instanceof Superclass); // Output: true



System.out.println(superclass instanceof Superclass); // Output: true



Dieses Beispiel geht von der in Listing 5 gezeigten Klassenstruktur aus und instanziiert Superclass und Subclass. Der erste Call der Methode System.out.println() gibt true aus, weil die Referenz von Subclass eine Instanz einer Unterklasse von Superclass identifiziert. Der zweite Aufruf der System.out.println()-Methode gibt true aus, weil die Referenz von Superclass eine Instanz von Superclass identifiziert.



Mit instanceof nicht übertreiben



Den instanceof-Operator übermäßig zu verwenden, kann ein Hinweis für unzureichendes Softwaredesign sein. Nehmen wir zum Beispiel an, Sie entscheiden sich, mehrere instanceof-Ausdrücke zu verwenden, um festzustellen, ob ein Shape-Objekt ein Quadrat, ein Kreis oder ein anderer Subtyp ist. Wenn Sie einen neuen Shape-Subtyp einführen, vergessen Sie vielleicht, einen instanceof-Test einzuschließen, um festzustellen, ob Shape eine Instanz dieses Typs ist. Das würde in einen Fehler münden. Sie sollten instanceof deshalb nur in speziellen Fällen nutzen. Ansonsten empfiehlt es sich, auf Subtyp-Polymorphie zu setzen.



Kovariante Rückgabetypen



Bei einem kovarianten Rückgabetyp handelt es sich um einen Methodenrückgabetyp, der in der Methodendeklaration der Superklasse den Supertyp des Rückgabetyps in der überschreibenden Methodendeklaration der Subklasse darstellt.



Die Anwendung in folgendem Listing veranschaulicht das.



Listing 6



class BaseReturnType



{



   @Override



   public String toString()



   {



      return "base class return type";



   }



}



class DerivedReturnType extends BaseReturnType



{



   @Override



   public String toString()



   {



      return "derived class return type";



   }



}



class BaseClass



{



   BaseReturnType createReturnType()



   {



      return new BaseReturnType();



   }



}



class DerivedClass extends BaseClass



{



   @Override



   DerivedReturnType createReturnType()



   {



      return new DerivedReturnType();



   }



}



public class CRTDemo



{



   public static void main(String[] args)



   {



      BaseReturnType brt = new BaseClass().createReturnType();



      System.out.println(brt);



      DerivedReturnType drt = new DerivedClass().createReturnType();



      System.out.println(drt);



   }



}



Dieses Listing deklariert die Superklassen BaseReturnType und BaseClass sowie die Subklassen DerivedReturnType und DerivedClass. BaseClass und DerivedClass deklarieren jeweils eine createReturnType()-Methode. Der Rückgabetyp der BaseClass-Methode ist auf BaseReturnType gesetzt, während der Rückgabetyp der überschreibenden Methode von DerivedClass auf DerivedReturnType (eine Subklasse von BaseReturnType) gesetzt ist.



Kovariante Rückgabetypen minimieren Upcasting und Downcasting. Zum Beispiel muss die createReturnType()-Methode von DerivedClass ihre DerivedReturnType-Instanz nicht auf ihren Rückgabetyp DerivedReturnType „hochcasten“. Außerdem muss diese Instanz bei der Zuweisung an die Variable drt nicht auf DerivedReturnType ge-„downcastet“ werden.



Kompilieren Sie Listing 6 wie folgt:



javac CRTDemo.java



Die resultierende Anwendung führen Sie aus mit:



java CRTDemo



Folgender Output sollte Sie erwarten:



base class return type



derived class return type



Würden kovariante Rückgabetypen nicht existieren, würde das Ergebnis wie im nachfiolgenden, finalen Listing aussehen.



Listing 7



class BaseReturnType



{



   @Override



   public String toString()



   {



      return "base class return type";



   }



}



class DerivedReturnType extends BaseReturnType



{



   @Override



   public String toString()



   {



      return "derived class return type";



   }



}



class BaseClass



{



   BaseReturnType createReturnType()



   {



      return new BaseReturnType();



   }



}



class DerivedClass extends BaseClass



{



   @Override



   BaseReturnType createReturnType()



   {



      return new DerivedReturnType();



   }



}



public class CRTDemo



{



   public static void main(String[] args)



   {



      BaseReturnType brt = new BaseClass().createReturnType();



      System.out.println(brt);



      DerivedReturnType drt =



         (DerivedReturnType) new DerivedClass().createReturnType();



      System.out.println(drt);



   }



}



In Listing 7 zeigt erfolgt ein Upcast von DerivedReturnType zu BaseReturnType. Zudem wird der erforderliche Cast Operator (DerivedReturnType) verwendet, um vor der Zuweisung an drt einen Downcast von BaseReturnType zu DerivedReturnType durchzuführen. (fm)



Sie wollen weitere interessante Beiträge zu diversen Themen aus der IT-Welt lesen? Unsere kostenlosen Newsletter liefern Ihnen alles, was IT-Profis wissen sollten – direkt in Ihre Inbox!