Wir bleiben bei der Theorie und sehen uns an, wie Konstruktoren genau funktionieren und was es mit Polymorphie (Vielgestaltigkeit) auf sich hat.
Wir sehen außerdem, wie man mit abstrakten Klassen und Methoden hantiert, um den Code robuster gegen falsche Verwendung zu machen.
14.1 Punktnotation
Vorab noch eine kurze Wiederholung der Punktnotation in Form eines Videos. Bitte stellen Sie sicher, dass Sie die Punktnotation beherrschen.
Video: Punktnotation (7:03)
Zusammenfassung
Bei Punktnotation wertet Processing/Java von links nach rechts aus. Sobald ein Punkt vorkommt, wird die Methode ausgeführt und der Rückgabewert eingesetzt. Im Fall von Variablen wird der Variableninhalt eingesetzt. Dann wird der nächste Punkt ausgewertet, sofern vorhanden.
14.2 Konstruktoren, this und this()
Video: Konstruktoren und "this" (9:44)
Hier geht es zunächst um das Schlüsselwort
this
. Doch Vorsicht! - genauso
wie bei keyPressed oder mousePressed, gibt es
this
in zwei Varianten:
-
als Variable
this
-
als Funktion
this()
Variable this
Die Variable
this
ist ein Zeiger eines Objekts auf sich selbst.
Wozu man das braucht? Sie kennen sicher das folgende Problem beim Schreiben eines
Konstruktors:
class Foo { // Instanzvariablen String label; int num; // Parameter und Instanzvariablen heißen gleich Foo(String label, int num) { label = label; // Fehler! num = num; // Fehler! } }
Sie würden die Parameter für den Konstruktor gern genauso nennen wie die Instanzvariablen. Warum auch nicht? Warum sich einen zweiten Namen für jede Variable ausdenken? Problem ist nur, dass Processing dann nicht unterscheiden kann, welche Variable wo gemeint ist, wenn dort "label = label" steht. In diesem Fall muss Processing den Konstruktorparameter label nehmen und zwar für beide Seiten, so dass der Wert der Variable sich nicht ändern (selbst wenn: es ist eh die falsche, wir wollen schließlich die Instanzvariable setzen).
Die Lösung liefert
this
. Denn dank Punktnotation kann man schreiben
this.label
und es ist klar, dass hier die Instanzvariable gemeint ist.
class Foo { // Instanzvariablen String label; int num; // Parameter und Instanzvariablen heißen gleich Foo(String label, int num) { this.label = label; // Instanzvariable = Parameter this.num = num; // Instanzvariable = Parameter } }
Funktion this()
Die Funktion
this()
hat eine gänzlich andere Bedeutung.
Die Funktion ist nämlich ein Konstruktoraufruf der eigenen Klasse, ähnlich
wie super() den Konstruktor der Oberklasse aufruft.
Warum will man den eigenen Konstruktor aufrufen? Das tut man dann, wenn man mehrere Konstruktoren hat und miteinander verschränken will, um Code-Duplizierung zu vermeiden.
Nehmen wir an, Sie wollen für die Klasse Person einen "Basiskonstruktor" anbieten. Die Eigenschaften Alter und Größe werden hier automatisch auf zwei Grundwerte gesetzt.
class Person { String vorname; String nachname; int alter = 20; float groesse = 1.80; Person(String vorname, String nachname) { this.vorname = vorname; this.nachname = nachname; } }
Jetzt erweitern Sie die Klasse um verschiedene "Komfort-Konstruktoren", wo Sie wahlweise auch Alter und Größe übergeben können:
class Person { String vorname; String nachname; int alter = 20; float groesse = 1.80; Person(String vorname, String nachname) { this.vorname = vorname; this.nachname = nachname; } Person(String vorname, String nachname, int alter) { this.vorname = vorname; this.nachname = nachname; this.alter = alter; } Person(String vorname, String nachname, float groesse) { this.vorname = vorname; this.nachname = nachname; this.groesse = groesse; } }
Beachten Sie, dass Sie hier Code-Duplizierung vorliegen haben. In allen Konstruktoren führen Sie folgenden Code aus:
this.vorname = vorname; this.nachname = nachname;
Warum könnte das problematisch sein? Vielleicht dann, wenn Sie die übergebenen Namen in irgendeiner Form verarbeiten, bevor Sie sie in den Instanzvariablen ablegen.
In Frankreich werden die Nachnamen oft in Großbuchstaben geschrieben, so dass Sie in einer späteren Version den Nachnamen mit toUpperCase direkt im Konstruktor ändern wollen:
class Person { String vorname; String nachname; int alter = 20; float groesse = 1.80; Person(String vorname, String nachname) { this.vorname = vorname; this.nachname = nachname.toUpperCase(); } Person(String vorname, String nachname, int alter) { this.vorname = vorname; this.nachname = nachname.toUpperCase(); this.alter = alter; } Person(String vorname, String nachname, float groesse) { this.vorname = vorname; this.nachname = nachname; // Ups, vergessen! this.groesse = groesse; } }
Wie Sie sehen, können einem bei Code-Duplizierung Fehler unterlaufen. Stattdessen benutzen wir this(), um den Basiskonstruktor von den anderen Konstruktoren aufzurufen:
class Person { String vorname; String nachname; int alter = 20; float groesse = 1.80; Person(String vorname, String nachname) { this.vorname = vorname; this.nachname = nachname.toUpperCase(); } Person(String vorname, String nachname, int alter) { this(vorname, nachname); this.alter = alter; } Person(String vorname, String nachname, float groesse) { this(vorname, nachname); this.groesse = groesse; } }
Sie sehen, dass das ähnlich funktioniert wie super(), nur dass hier
der Konstruktor in der eigenen Klasse (nicht in der Oberklasse) gesucht
wird. In unserem Fall sucht Processing bei
this(vorname, nachname)
nach einem Konstruktor mit zwei String-Parametern; das ist offensichtlich
der erste. Dort werden die Namen gesetzt und auch das toUpperCase durchgeführt,
also an einer einzigen Stelle.
Übungsaufgaben
14.2 a) Anrede 1
Schreiben Sie eine Klasse Kunde mit der einzigen Instanzvariablen "name" (String).
Die Klasse soll drei Konstruktoren haben: (a) mit einem Namen, (b) mit Vornamen und Nachnamen, (c) mit Vornamen, Nachnamen, Titel. Der Konstruktor soll die Namen zu einenm einzigen String zusammensetzen und in "name" ablegen. Verwenden Sie unbedingt this().
Testen Sie Ihren Code mit
void setup() { Kunde k1 = new Kunde("Hans Meier"); Kunde k2 = new Kunde("Vera", "Schneider"); Kunde k3 = new Kunde("Dr.", "Heiko", "Rechthaber"); println(k1.name); println(k2.name); println(k3.name); }
Sie sollten sehen:
Hans Meier Vera Schneider Rechthaber Dr. Heiko
14.2 b) Anrede 2
Die Klasse Brief
speichert Briefe in Objekten. Die Anrede ("Liebe Frau Huber" etc.) wird dort als String gespeichert. Der Brieftext ist hier irrelevant.
class Brief { String anrede = "Lieber Herr Nobody,"; // Nur Platzhaltertext String text = "Bla bla bla bla bla bla bla bla bla."; String toString() { return anrede + "\n\n" + text; } }
Schreiben Sie Konstruktoren, in denen die Anrede "zusammengebaut" wird, d.h. die Instanzvariable anrede
wird neu gesetzt. (Es sind keine neuen Instanzvariablen nötig oder erwünscht.)
Es sollen drei Konstruktoren werden, so dass man Briefe mit
- nur dem Nachnamen oder
- Vor- und Nachnamen oder
- Vor-/Nachname und Titel erstellen kann.
Jeder Konstruktor bekommt außerdem einen boolschen Parameter, der das Geschlecht angibt (true = weiblich).
Überlegen Sie sich, wie Sie mit Hilfe von this()
auf einen Basiskonstruktor zurückzugreifen können. Hier sehen Sie Testfälle, die dann laufen sollten:
void setup() { // Testfälle Brief b = new Brief("Schmidt", false); Brief b2 = new Brief("Vera", "Schmidt", true); Brief b3 = new Brief("Lea", "Kraft", "Prof. Dr.", true); // Ausdrucken println(b); println("---"); println(b2); println("---"); println(b3); }
Hinweis: Sie schreiben zunächst den Basiskonstruktor und die beiden anderen Konstruktoren enthalten jeweils nur eine Zeile mit einem this()-Aufruf.
Auf Ihrer Konsole sollte erscheinen:
Lieber Herr Schmidt Bla bla bla bla bla bla bla bla bla. --- Liebe Frau Vera Schmidt Bla bla bla bla bla bla bla bla bla. --- Liebe Frau Prof. Dr. Lea Kraft Bla bla bla bla bla bla bla bla bla.
Zusammenfassung
-
Die Variable
this
zeigt auf das eigene Objekt, in der sich dieser Aufruf befindet. Wenn Sie sich in der KlasseFoo
befinden und eine Instanzvariable namensx
haben, können Sie mitthis.x
ausdrücken, dass die Instanzvariable dieser Klasse gemeint ist. Dies wird häufig im Konstruktor verwendet, um die Instanzvariable von einem gleichnamigen Parameter zu unterscheiden. Sie können auf gleiche Weise Methoden aufrufen, zum Beispiel:this.doSomething(0.25, "boo")
. -
Die Funktion
this()
ruft den Konstruktor der eigenen Klasse auf.- Die Parameterliste des Aufrufs bestimmt, welcher Konstruktor gewählt wird.
- Der this()-Aufruf darf nur in einem Konstruktor erfolgen und muss die erste Anweisung sein.
- wird z.B. dazu verwendet, um einen allgemeinen "Basiskonstruktor" zu schreiben, auf den andere Konstruktoren zurückgreifen.
14.3 Klassenhierarchie, Typen und Casting
Video: Statischer Typ vs. dynamischer Typ (8:12)
In einer Klassenhierarchie haben Sie Typen und Subtypen. Schauen
wir uns
die Klasse Tier
an, die Oberklasse der Klasse Vogel
ist:
class Tier { } class Vogel extends Tier { }
Dann ist folgendes korrekt:
Tier x = new Vogel(); // Ein Vogel IST EIN Tier
Umgekehrt können Sie nicht in einem Subtypen ein Objekt der Oberklasse speichern:
Vogel v = new Tier(); // Ein Tier IST NICHT immer ein Vogel
Wenn wir uns nochmal die korrekte Richtung anschauen:
Tier x = new Vogel();
Aber welchen Typ hat denn jetzt x? Ist x vom Typ Tier
oder vom Typ Vogel
? Wenn wir eine Methode aufrufen,
müssen wir schließlich wissen, aus welcher Klasse die Methode gezogen wird.
Betrachten wir folgendes konkrete Beispiel mit den drei Klassen
Tier
, Vogel
und Katze
,
die alle unterschiedliche Varianten der Methode sprich()
haben:
class Tier { void sprich() { println("brumm"); } } class Vogel extends Tier { void sprich() { println("fieeeeep"); } } class Katze extends Tier { void sprich() { println("miauuuuuu"); } }
Was passiert jetzt im folgenden Code: Wird die Methode
von Tier
oder von Katze
ausgeführt?
void setup() { Tier x = new Katze(); x.sprich(); }
Wenn Sie den Code laufen lassen, sehen Sie:
miauuuuuu
Dies entspricht hoffentlich Ihrer Intuition: Es wird immer die "speziellere"
Methode gewählt. Warum sollte ein Programmierer denn sonst auch die Methode sprich
in Katze
implementiert haben? Im nächsten Abschnitt erfahren wir,
auf welcher Regel dieses Verhalten basiert.
Statischer und dynamischer Typ
Die Anwort auf obige Frage ist: der statische Typ ist Tier
. Das ist also der Typ, den man in der Variablendeklaration angibt. Der dynamische Typ ist dagegen Vogel
, das ist der Typ, den das im Beispiel erzeugte Objekt eigentlich besitzt.
Tier x = new Vogel(); // statisch: Tier, dynamisch: Vogel
Der dynamische Typ ist also immer entweder gleich dem statischen Typ oder ein Subtyp des statischen Typs.
Warum nennt man das statisch und dynamisch? Nachdem Sie eine Variable definiert haben, hat diese Variable immer den gleichen Typ, der Variablentyp bleibt also statisch. Dagegen kann sich der Typ des enthaltenen Objekts auch ändern, zum Beispiel wenn man die Variable auf ein anderes Objekt zeigen lässt:
Tier x = new Vogel(); ... x = new Katze(); // OK, da Katze Unterklasse von Tier ... x = new Tier(); // natürlich auch OK
Daher nennt man den Typ des enthaltenen Objekts den dynamischen Typ, da dieser sich ändern kann.
In obigen Beispiel ändert sich der Typ des enthaltenen Objekts zwei Mal. Die Variable x bleibt dennoch immer vom Typ Tier. Deshalb nennt man den Typ, mit dem die Variable deklariert wurde (im Beispiel: Tier) also den statischen Typ. Der Typ des Objekts, das sich gerade in der Variable befindet nennt man den dynamischen Typ.
Tier x = new Vogel(); // stat. Typ = Tier, dyn. Typ = Vogel ... x = new Katze(); // stat. Typ = Tier, dyn. Typ = Katze ... x = new Tier(); // stat. Typ = Tier, dyn. Typ = Tier
Stellen wir uns nochmal die Frage, welche Methode im folgenden Code ausgeführt wird:
void setup() { Tier x = new Katze(); x.sprich(); }
Die Variable x
hat den statischen Typ Tier
und den dynamischen Typ Katze
. Jetzt können wir die
Regel formulieren, nach der Java eine Methode aus der Klassenhierarchie auswählt:
Es wird immer die Methode des dynamischen Typs ausgeführt, falls vorhanden. Falls die Methode dort nicht existiert, wird die Methode derjenigen Oberklasse gewählt, die dem dynamischen Typ am nächsten ist.
Casting
Nehmen wir an, wir haben folgende Klassenhierarchie:
Sie haben eine Variable für Medium und speichern dort einen Song:
Medium m = new Song("OMG", "Marteria");
Der statische Typ von m
ist in diesem Fall
Medium
, der dynamische Typ ist
Song
.
Wenn Sie auf die Variable artist
zugreifen wollen, können Sie
das nicht ohne weiteres tun, denn für den Zugriff auf Variablen
und Methoden ist für Java immer der statische Typ maßgeblich:
Medium m = new Song("OMG", "Marteria"); String x = m.artist; // Fehler! artist nicht in Medium
Wenn Sie als Programmierer sicher sind, dass die Variable
m
einen Song enthält, können Sie auf die Unterklasse Song Casten.
Das sieht dann so aus:
Medium m = new Song("OMG", "Marteria"); Song s = (Song)m; // Variable m wird auf Song gecastet String x = s.artist; // OK
Das geht auch kürzer:
Medium m = new Song("OMG", "Marteria"); String x = ((Song)m).artist; // Casting und Variablenzugriff
Die Klammerorgie ist leider notwendig, sonst wird der Casting-Operator (Song) erst nach dem Punkt ausgewertet, also zu spät.
instanceof
Manchmal möchten Sie prüfen, welchen dynamischen Typ eine Variable
gerade hat. Stellen Sie sich vor, Sie erzeugen Objekte aufgrund
einer Datei mit Song/Movie-Informationen und speichern sie in
einem Array. Dann wissen Sie nicht, an welcher Stelle ein Song und
an welcher Stelle ein Movie steht. Mit
instanceof
können
Sie prüfen, ob ein Objekt von einem bestimmten Typ ist.
Medium[] media = readMedia("file.txt"); // hier werden Songs und Movies eingelesen for (int i = 0; i < media.length; i++) { if (media[i] instanceof Song) { println("Artist: " + ((Song)media[i]).artist); } }
Der Operator instanceof
testet also, ob ein Objekt (linke Seite, im Beispiel media[i]) von einem
Typ (rechte Seite, im Beispiel "Song") ist. Wäre media[i] von einem Subtyp von Song, würde der
Operator auch true zurückgeben.
Übungsaufgaben
14.3 a) Klassenhierarchie und Casting
Schauen Sie sich folgenden Code an und zeichnen Sie die Klassenhierarchie als Diagramm.
class Kuenstler { } class BildenderKuenstler extends Kuenstler { } class Maler extends BildenderKuenstler { void male() { println("mal mal mal"); } } class Bildhauer extends BildenderKuenstler { void haue() { println("kracks"); } } class Musiker extends Kuenstler { void musiziere() { println("tralala"); } }
Verwenden Sie folgendes Hauptprogramm und rufen Sie auf jeder Variable die entsprechende Methode (male, haue, musiziere) auf, indem Sie Casting benutzen:
void setup() { Kuenstler k1 = new Maler(); Kuenstler k2 = new Bildhauer(); Kuenstler k3 = new Musiker(); // Ihr Code... }
14.3 b) instanceof
Erstellen Sie folgende Klassen (Wenn Sie möchten, können Sie auch zur besseren Strukturierung eine Zusatzklasse erstellen).
- Handy: Hat Eigenschaft preis (float)
- IPhone: Unterklasse von Handy
- AndroidPhone: Unterklasse von Handy, hat Eigenschaft hersteller (String)
- WindowsPhone: Unterklasse von Handy, hat Eigenschaft hersteller (String)
Schreiben Sie für jede Klasse genau einen Konstruktor, verwenden Sie dabei super(), und schreiben Sie die toString()-Methode.
Im Hauptprogramm erstellen Sie eine ArrayList von Handys, fügen Sie von jeder Klasse zwei Beispielobjekte hinzu. Anschließend schreiben Sie mit einer Foreach-Schleife jedes Objekt mit println() auf die Konsole. Hier sollen Sie allerdings bei allen iPhones und WindowsPhones "AUSVERKAUFT" hinter die Ausgabe schreiben. Nutzen Sie dabei instanceof
.
14.3 c) Casten
Erstellen Sie folgende Klassen:
- Animal: Hat Eigenschaft name (String)
- Fox: Unterklasse von Animal, hat Methode hunt(), die lediglich "(name) is hunting" auf der Konsole ausgibt (name durch Variableninhalt ersetzen)
- Rabbit: Unterklasse von Animal, hat Methode flee(), die lediglich "(name) is fleeing" auf der Konsole ausgibt (name durch Variableninhalt ersetzen)
Erstellen Sie eine Liste mit vier Beispielobjekten (2 Füchse, 2 Hasen). Durchlaufen Sie die Liste mit einer Foreach-Schleife und rufen Sie entweder hunt() oder flee() auf, je nachdem, welche Klasse Sie vor sich haben.
Zusammenfassung
- Wenn Sie eine Variable vom Typ A anlegen, ist die Variable immer vom statischen Typ A.
- Eine Variable vom Typ A kann auch Objekte von Typ B enthalten, sofern B Subtyp (Unterklasse) von A ist. Der konkrete Typ eines Objekts zur Laufzeit (also hier B) ist der dynamische Typ.
- Man darf nur Methoden und Variablen des statischen Typs nutzen.
-
Mit instanceof können Sie den dynamischen Typ prüfen. Genauer gesagt ist
x instanceof C
genau dann wahr, wennx
vom TypC
oder von irgendeinem Subtyp vonC
ist. -
Mit Casting können Sie den Compiler zwingen, eine Variable
als einem Subtyp zugehörig aufzufassen, so dass Sie die Methoden und Variablen des
entsprechenden (spezielleren) Subtyps verwenden können. Die Schreibweise ist
(B)x
, wenn Siex
aufB
casten wollen
Da ein- und dieselbe Variable Objekte unterschiedlichen Typs speichern kann, spricht man von Polymorphie, zu deutsch "Vielgestaltigkeit".
14.4 Methoden überschreiben
Video: Methoden überschreiben (override) (5:22)
In einer Klassenhierarchie sollen Unterklassen in bestimmten Fällen eine Methode der Oberklasse ersetzen. Stellen Sie sich vor, Sie programmieren ein Computerspiel und die meisten Spielobjekte bewegen sich mit Hilfe eines Speed-Vektors in der Methode move(). Eine bestimmte Klasse von Objekten soll sich aber zufällig bewegen. Dann müssen Sie dort die Methode move() neu definieren, man nennt das auch überschreiben (engl. override).
Wir schauen uns das anhand einer einfachen Print-Anweisung an:
class GameObject { void saySomething() { println("Hello!"); } } class Rocket extends GameObject { // überschreibt Methode von GameObject! void saySomething() { println("Wooosh!"); } } class Alien extends GameObject { }
Wenn Sie die Methode von drei verschiedenen Objekten aufrufen, bekommen Sie:
void setup() { GameObject g = new GameObject(); g.saySomething(); // im Folgenden nimmt Processing die Methode aus Rocket, // obwohl g vom Typ GameObject ist! g = new Rocket(); g.saySomething(); g = new Alien(); g.saySomething(); }
Hello! Wooosh! Hello!
Was ist passiert? Bei dem ersten saySomething() wird die Methode von GameObject genommen - alles normal hier. Beim zweiten saySomething() enthält g ein Objekt vom Typ Rocket und Processing nimmt auch die Methode saySomething() von Rocket. Denn diese Methode hat die von GameObject überschrieben. Im dritten Fall sucht Processing zuerst in Alien, findet dort keine Methode saySomething() und nimmt dann die der Oberklasse GameObject.
Sie sehen, dass Processing/Java den dynamischen Typ nimmt, um die Methode auszuwählen. Das ist auch sehr sinnvoll, denn Sie wollen natürlich immer den spezialisiertesten Code ausführen. Sie können sich also auch merken: Wenn Java eine Methode in einer Klassenhierarchie auswählen soll, wählt es immer die spezialisierteste!
Einschub: Wurzelklasse "Object"
Jede Klasse hat als "oberste" Oberklasse die Klasse Object
.
Hat Ihre Klasse keine Oberklasse, wird Object
automatisch als Oberklasse definiert. Damit ist diese Klasse
die Wurzel jeder Klassenhierarchie.
Dies hat den Vorteil, dass alle Objekte die
Methoden der Klasse Object erben. Ein
wichtiges Beispiel ist die Methode toString()
,
die Ihre Klasse automatisch erbt und die Sie überschreiben
können, um die Ausgabe mit println() zu steuern. Weitere interessante
Methoden sind equals()
und hashCode()
,
das soll hier aber nicht vertieft werden...
Eine weitere Folge ist, dass ein Test mit instanceof Object
bei jedem Objekt true
ist.
void setup() { Foo foo = new Foo(); println(foo instanceof Object); } class Foo { }
true
Eine Variable vom Typ Object
kann jedes
beliebige Objekt speichern, da alle Klassen automatisch Subklassen
von Object sind.
// Beispiele mit fiktiven Klassen: Object a = new Person(); Object b = new Flugzeug(); Object c = new Foo();
Animierte Objekte 2
Sie erinnern sich hoffentlich an unser Beispiel mit den animierten Bällen und Quadraten aus Kap. 18.4:
Unser Ziel ist es, viele Objekte mit einer Liste zu bewegen:
ArrayList<Mover> things = new ArrayList<Mover>(); for (Mover m: things) { m.update(); m.render(); // Fehler: Mover hat kein render }
Problem ist: Die Klasse Mover
hat keine Methode render(). Daher kann Java das render in der Schleife nicht aufrufen.
Die Lösung ist, eine "Dummy-Methode" in Mover einzuführen:
class Mover { PVector location; PVector speed; // Code zur Übersichtlichkeit ausgelassen void render() { } }
Unser Klassendiagramm sieht jetzt so aus:
Wir können also jetzt die Methode render()
in unserer Schleife aufrufen, da Mover ein render() besitzt:
ArrayList<Mover> things = new ArrayList<Mover>(); for (Mover m: things) { m.update(); m.render(); }
Da wir wissen, dass jedes Element auf jeden Fall entweder vom Typ Ball oder vom Typ Quad ist, wird immer eine der zwei speziellen render-Methoden aufgerufen. Die Methode von Mover wird nie verwendet!
Aber: Unsere Lösung ist noch nicht optimal! Eine "Dummy-Methode" könnte einen nicht gut eingeweihten Programmierer dazu verführen, Code dort hineinzuschreiben, der aber nie ausgeführt wird. Zweites Problem ist die Klasse Mover selbst. Derselbe Programmierer könnte auf die Idee kommen, eine Instanz von Mover anzulegen. Das aber ist nicht sinnvoll, da Mover aus rein "organisatorischen" Gründen angelegt wurde, aber nicht dazu da ist, um Mover-Objekte zu erzeugen. Beide Probleme werden im nächsten Kapitel behoben.
Zusammenfassung
Eine Unterklasse kann eine Methode der Oberklasse mit der gleichen Signatur neu definieren. Dies nennt man Überschreiben. (Im Englischen nennt man das method overriding und NICHT overwriting - to override bedeutet "außer Kraft setzen" oder "etwas überordnen").
Wird eine Methode mit einer Variablen x
aufgerufen, verwendet Java immer den dynamischen Typ von x
. Java wählt also immer die spezifischste Methode aus.
Polymorphie (Vielgestaltigkeit) bedeutet in diesem Zusammenhang, dass der Aufruf x.render()
unterschiedliche Ausprägungen annehmen kann, je nachdem, von welchem (dynamischen) Typ x
ist.
14.5 Abstrakte Klassen und Methoden
Video: Abstrakte Klassen und Methoden (6:29)
Animierte Objekte 3
Wir haben derzeit folgende Klassenstruktur:
Es gibt zwei Probleme:
- Man könnte Instanzen von
Mover
erzeugen, obwohl dies nicht erwünscht ist. - Die Methode
render()
der Klasse Mover ist leer und existiert nur zu dem Zweck, dass die Methoden der Unterklassen "bekannt gemacht" werden.
Um Problem Nr. 1 zu lösen, können wir Mover
zu einer abstrakten Klasse machen. Dazu müssen wir nur das Wort abstract
vor die Klassendefinition schreiben:
abstract class Mover { ... }
Wenn Sie jetzt versuchen, ein neues Objekt vom Typ Mover
herzustellen, bekommen Sie eine Fehlermeldung.
void setup() { Mover m = new Mover(); // FEHLER }
Genau das wollten wir erreichen! Eine abstrakte Klasse kann ansonsten Instanzvariablen und Methoden haben, die sie weitervererbt. Abstrakte Klassen sind also ideal, um gemeinsame Funktionalität "nach oben" zu verschieben.
Wie lösen wir Problem Nr. 2? Wir möchten einerseits Java signalisieren, dass alle Unterklasse die Methode render()
haben, möchten aber andererseits keine "Dummy-Methode" einbauen.
Zu diesem Zweck gibt es abstrakte Methoden. Eine abstrakte Methode hat keinen Code, sondern ist lediglich ein Versprechen, dass alle (nicht-abstrakten) Unterklassen diese Methode implementieren. Das ganze schreibt man - innerhalb der abstrakten Klasse - dann so:
abstract void render();
Abstrakte Methoden können natürlich auch Parameter und Rückgabetyp definieren. Sie dürfen logischerweise nur in abstrakten Klassen vorkommen.
Hier nochmal der komplette Code von Mover
:
abstract class Mover { PVector location; PVector speed; Mover() { location = new PVector(random(0, width), random(0, height)); speed = new PVector(random(-3, 3), random(-3, 3)); } void update() { location.add(speed); if (location.x > width || location.x < 0) { speed.x = -speed.x; } if (location.y > height || location.y < 0) { speed.y = -speed.y; } } abstract void render(); }
Im Klassendiagramm schreibt man bei abstakten Klassen "<<abstract>>" über den Klassennamen. Den Klassennamen schreibt man kursiv. Bei abstrakten Methoden schreibt man den Methodennamen kursiv.
Unser Code ist jetzt gut strukturiert und robust.
MyTunes 3
In unserem MyTunes-Beispiel haben wir eine neue Klasse Medium eingeführt, damit wir sowohl Filme als auch Songs in einem Array repräsentieren können.
Es fällt auf, dass wir die Klasse Medium nie instanziieren werden. Um zu verhindern, dass ein ahnungsloser Programmierer dennoch ein Medium-Objekt anlegt, können wir die Klasse als abstrakt deklarieren.
abstract class Medium { ... }
In abstrakten Klassen können wir bestimmte Methoden von allen Unterklassen einfordern, ohne dass wir bereits Code hinschreiben. Eine solche Methode ohne Code, eigentlich nur das "Versprechen einer Methode", nennen wir abstrakte Methode.
Im Beispiel würde es Sinn machen, eine abstrakte Methode play() zu definieren, die dann in den Klassen Movie und Song erst implementiert wird (also dort erst Code bekommt).
abstract class Medium { ... // alle Unterklassen müssen diese Methode implementieren: abstract void play(); }
Die Unterklassen Movie und Song müssen play() implementieren, also Code haben (es sei denn, diese Klassen sind auch abstract):
class Movie extends Medium { ... void play() { // konkreter Code, um Film abzuspielen } } class Song extends Medium { ... void play() { // konkreter Code, um Song abzuspielen } }
Dennoch könnte man im Hauptprogramm bei einer Variable vom Typ Medium play() aufrufen (Medium ist hier also der statische Typ). Dann würde eine der beiden play()-Methoden aufgerufen werden, je nachdem, ob der dynamische Typ Movie oder Song ist.
Nehmen wir an, Sie suchen in Ihrer Datenbank und bekommen ein Objekt vom Typ Medium zurück (statischer Typ). Jetzt dürfen Sie play() aufrufen, da es in Medium als abstrakte Methode definiert ist:
Medium m = searchMedia("OMG"); m.play(); // OK, da play() in Medium definiert
Wie wir oben gelernt haben, wird dabei das play() des aktuellen Typs genommen (dynamischer Typ), also von Song, wenn es ein Song ist, oder von Movie, wenn es ein Film ist.
Übungsaufgaben
14.5 a) Klassenhierarchie
Wir schauen uns die Klassen aus Aufgabe 18.3 (a) noch einmal an:
class Kuenstler { } class BildenderKuenstler extends Kuenstler { } class Maler extends BildenderKuenstler { void male() { println("mal mal mal"); } } class Bildhauer extends BildenderKuenstler { void haue() { println("kracks"); } } class Musiker extends Kuenstler { void musiziere() { println("tralala"); } }
Welche der Klassen können/sollten abstrakt sein? Zeichnen Sie dies korrekt in Ihrem Klassendiagramm ein.
14.5 b) Animierte Objekte
Wir sehen uns den neuesten Stand unserer animierten Objekte an:
ArrayList<Mover> things = new ArrayList<Mover>(); void setup() { size(200, 200); things.add(new Ball()); things.add(new Ball()); things.add(new Quad()); things.add(new Ball()); things.add(new Quad()); } void draw() { background(0); fill(255); noStroke(); for (Mover m: things) { m.update(); m.render(); } } abstract class Mover { PVector location; PVector speed; Mover() { location = new PVector(random(0, width), random(0, height)); speed = new PVector(random(-3, 3), random(-3, 3)); } abstract void render(); void update() { location.add(speed); if (location.x > width || location.x < 0) { speed.x = -speed.x; } if (location.y > height || location.y < 0) { speed.y = -speed.y; } } } class Ball extends Mover { void render() { ellipse(location.x, location.y, 20, 20); } } class Quad extends Mover { void render() { rectMode(CENTER); rect(location.x, location.y, 20, 20); } }
Nun sollen Sie den Quad so ändern, dass er rotiert und sich ansonsten nicht im Raum bewegt.
Ändern Sie dazu die Klassenhierachie wie folgt:
- Führen Sie eine neue Oberklasse
Thing
ein, die ab sofort die location enthält. - Führen Sie eine weitere Klasse
Rotator
ein, die zwei Eigenschaften enthält: angle und angularSpeed. Die Instanzvariable angularSpeed wird im Konstruktor zufällig gewählt (zwischen -0.1 und +0.1) und bestimmt, wie der Drehwinkel angepasst wird. - Zeichnen Sie eine sinnvolle Klassenhierarchie auf und bestimmen Sie, wo abstrakte Klassen/Methoden sinnvoll sind.
- Implementieren Sie die Klassen und ändern Sie insbesondere Quad so ab, dass der Kasten rotiert (Denken Sie an Transformationen, vielleicht hilft auch push/popMatrix).
Zusammenfassung
Eine abstrakte Klasse verwendet man, um zu verhindern, dass Instanzen der Klasse erzeugt werden. Dazu stellt man das Schlüsselwort abstract
vor die Klassendefinition.
Abstrakte Klassen können Instanzvariablen und Methoden enthalten und diese an die Unterklassen vererben.
Soll eine Methode einer abstrakten Klasse keinen Code enthalten, sondern lediglich als Versprechen dienen, dass alle nicht-abstrakten Unterklassen diese Methode enthalten, dann kann man eine abstrakte Methode definieren. Auch die abstrakte Methode wird mit dem Schlüsselwort abstract
markiert.
Eine abstrakte Methode enthält keinen Code. Die Kopfzeile wird mit einem Semicolon abgeschlossen. Jede (nicht-abstrakte) Unterklasse muss diese Methode erfüllen, sonst kommt eine Fehlermeldung.