Behandelte Konzepte/Konstrukte: this, this(), instanceof, Casten, Object, Überschreiben (override)

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 Klasse Foo befinden und eine Instanzvariable namens x haben, können Sie mit this.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, wenn x vom Typ C oder von irgendeinem Subtyp von C 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 Sie x auf B 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:

  1. Man könnte Instanzen von Mover erzeugen, obwohl dies nicht erwünscht ist.
  2. 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.