Home > Informatik > Einführung in die OOP > 9. Vererbung > 9.4 Kleinigkeiten

9.4 Noch ein paar Kleinigkeiten

9.4.1 Das Schlüsselwort final

Man kann theoretisch von jeder existierenden Java-Klasse Unterklassen anlegen. Manchmal will man das aber aus bestimmten Gründen verhindern. In diesem Fall gibt es einen einfachen Trick, wie man das erreichen kann: Wir geben der Klasse bei der Deklaration das Attribut final.

Beispiel

public final class Lexikon extends Sachbuch

Dann versuchen wir eine Unterklasse Chemielexikon anzulegen:

public class Chemielexikon extends Lexikon
{
    public Chemielexikon(String titel, String autor, int jahr)
    {
       super(titel, autor, "Chemie", jahr);
    }
}

Der Compiler verweigert die Übersetzung mit der Fehlermeldung:

"Erben aus finalem Lexikon-Element nicht möglich".

Wann ist das sinnvoll?

Wenn eine Klasse, von der Objekte erzeugt werden können, bestimmte Regeln enthält, die auf keinen Fall geändert werden dürfen, dann kann Vererbung zum Problem werden: Man könnte eine Unterklasse anlegen, die diese in Methoden festgelegten Regeln einfach durch eigene Methoden mit gleicher Signatur und eigenen Regeln überschreibt.

Stellen Sie sich eine Klasse Konto vor, bei der die Methode abheben() prüft, ob der gewünschte Betrag größer ist als das Guthaben. Ein Entwickler könnte nun eine Unterklasse WunderKonto anlegen, in der abheben() überschrieben wird, sodass beliebig hohe Beträge abgehoben werden können - vielleicht sogar von einem anderen Konto.

Durch das Schlüsselwort final in der Deklaration von Konto wird ein solches Vorgehen verhindert, weil dann keine Unterklassen mehr definiert werden können.

Finale Methoden

Steht das Schlüsselwort final vor einer Methode, so kann diese Methode von Unterklassen nicht mehr überschrieben werden.

Finale Instanzvariablen

Wenn wir eine Instanzvariable mit final deklarieren, kann der einmal zugewiesene Wert nicht mehr verändert werden. Daher nutzt man das Schlüsselwort final beispielsweise, um Konstanten zu definieren.

9.4.2 Vererben von Konstruktoren

Konstruktoren werden in Java nicht vererbt. Das bedeutet: Auch wenn eine Oberklasse mehrere selbst definierte Konstruktoren besitzt, stehen diese der abgeleiteten Unterklasse nicht automatisch zur Verfügung. Stattdessen müssen alle benötigten Konstruktoren in der Unterklasse neu definiert werden – selbst dann, wenn sie lediglich aus dem Aufruf des Konstruktors der Oberklasse mittels super() bestehen.

"Konstruktoren werden nicht vererbt. Sie müssen alle benötigten Konstruktoren in einer abgeleiteten Klasse neu definieren, selbst wenn sie nur aus einem Aufruf des Superklassenkonstruktors bestehen."
(S. Dörn, Java lernen in abgeschlossenen Lerneinheiten)

Beispiel Konto/JugendKonto

Um dieses Prinzip zu veranschaulichen, betrachten wir eine Klasse Konto mit zwei Konstruktoren:

public class Konto
{
    private String inhaber;
    private int guthaben; // in Cent

    public Konto(String inhaber)
    {
        this(inhaber, 0);
    }

    public Konto(String inhaber, int startGuthaben)
    {
        if (inhaber == null)
            throw new IllegalArgumentException("Inhaber fehlt.");
        if (startGuthaben < 0)
            throw new IllegalArgumentException(
                  "Startguthaben darf nicht negativ sein.");

        this.inhaber = inhaber;
        guthaben = startGuthaben;
    }
}

Das ist eine typische Java-Klasse mit zwei Konstruktoren:

  1. einem Hauptkonstruktor mit zwei Parametern
  2. einem sogenannten Komfort-Konstruktor, der lediglich den Hauptkonstruktor mit dem Startguthaben 0 aufruft.

Wir legen nun eine Unterklasse JugendKonto an:

public class JugendKonto extends Konto
{
    private int limit; // in Cent

    public JugendKonto(String inhaber)
    {
        super(inhaber);          // ruft Konto(String) auf
        limit = 2000;            // z.B. 20,00 Euro
    }

    public JugendKonto(String inhaber, int startGuthaben)
    {
        super(inhaber, startGuthaben);  // ruft Konto(String, int) auf
        limitCent = 2000;
    }
}

Beide Konstruktoren der Unterklasse beginnen mit einem super(...)-Aufruf, der den jeweils passenden Konstruktor der Oberklasse anspricht. Das ist nicht nur guter Stil, sondern in Java zwingend erforderlich: Der super(...)-Aufruf muss, sofern er explizit (also vom Entwickler) angegeben wird, stets die erste Anweisung im Konstruktorrumpf sein.

Erst dann wird das klasseneigene Attribut limit initialisiert - ein Schritt, den die Oberklasse naturgemäß nicht übernehmen kann, da sie von limit keine Kenntnis hat.

Was würde passieren, wenn die Unterklasse Jugendkonto keine eigenen Konstruktoren hätte?

Der Compiler würde so vorgehen:

1. Er stellt fest, dass die Unterklasse Jugendkonto keinen Konstruktor besitzt.

2. Er erzeugt daraufhin automatisch einen parameterlosen Standardkonstruktor:

  public class JugendKonto extends Konto
  {
      private int limit = 2000; // in Cent

      public JugendKonto()// parameterloser Standardkonstruktor
      {
          super();
      }
  }

3. Dieser implizit (also automatisch vom Compiler erzeugt und für den Entwickler nicht sichtbar) eingefügte super()-Aufruf setzt voraus, dass die Oberklasse ebenfalls einen parameterlosen Konstruktor besitzt.

4. Die Oberklasse Konto besitzt jedoch keinen parameterlosen Konstruktor, da bereits zwei eigene Konstruktoren mit Parametern definiert wurden. Sobald eine Klasse mindestens einen Konstruktor explizit definiert (also durch den Entwickler erstellt), erzeugt der Compiler keinen parameterlosen Standardkonstruktor mehr.

5. Der Aufruf super() kann daher nicht aufgelöst werden, und so meldet der Compiler einen Fehler. Das Programm lässt sich nicht übersetzen.

Merke:

Oberklassen ohne parameterlosen Konstruktor

Sobald eine Oberklasse keinen parameterlosen Konstruktor besitzt, muss jede Unterklasse mindestens einen eigenen Konstruktor definieren, der mit super(...) einen der vorhandenen Oberklassen-Konstruktoren explizit aufruft. Andernfalls verweigert der Compiler die Übersetzung.

9.4.3 Polymorphie zum Zweiten

In den vorherigen Abschnitten haben wir Polymorphie anhand einer konkreten Klassenhierarchie (Buch als Oberklasse, Roman und Fachbuch als Unterklassen) kennengelernt.

Wir wollen diesen wichtigen Mechanismus hier nun noch einmal verallgemeinern und vertiefen. Was zunächst als "Trick" eingeführt wurde, um unterschiedliche Objekte in einer ArrayList zu speichern, entpuppt sich bei näherer Betrachtung als ein grundlegendes Prinzip, das den Entwurf von Programmen ermöglicht, die leicht zu warten und zu erweitern sind.

1. Statischer Typ und dynamischer Typ

Eine zentrale Unterscheidung ist die zwischen statischem Typ und dynamischem Typ. Der statische Typ einer Variablen ist der Typ, den der Compiler aus der Deklaration ableitet. Der dynamische Typ ist der tatsächliche Typ des Objekts, auf das die Referenz zur Laufzeit zeigt.

Buch b;
b = new Roman("Der Prozess","Franz Kafka","Roman",1925);

Hier ist der statische Typ von b gleich Buch. Im Quelltext wurde das Objekt b von der Klasse Buch abgeleitet.

Der dynamische Typ ist dagegen Roman. Beim Erzeugen des Objekts b wurde der Konstruktor der Unterklasse Roman aufgerufen.

Das Objekt b ist also ein Roman. Wegen der Vererbungsbeziehung ist b aber auch gleichzeitig ein Objekt der Klasse Buch. Überspitzt formuliert: b IST ein Buch.

Wenn diese beiden Code-Zeilen übersetzt werden, prüft der Compiler ausschließlich, ob der Methodenaufruf mit dem statischen Typ (also Buch) kompatibel ist.

Die Entscheidung, welche Implementierung dann tatsächlich ausgeführt wird (die von Buch oder die von Roman), fällt erst zur Laufzeit anhand des dynamischen Typs (hier also Roman).

2. Dynamisches Binden

Wenn Unterklassen eine Methode der Oberklasse überschreiben, wird beim Methodenaufruf zur Laufzeit automatisch die passende Implementierung gewählt. Dieser Mechanismus heißt dynamisches Binden. Er ist in Java die technische Grundlage der Polymorphie.

for (Buch b : buchliste)
{
   b.zeige();  // Auswahl der Implementierung zur Laufzeit
}

Wichtig ist: Der Quelltext enthält nur den Aufruf b.zeige(). Dennoch kann zur Laufzeit je nach Objekttyp die Methode aus Buch, Roman oder Fachbuch ausgeführt werden. Damit wird "gleiches Senden einer Nachricht" (Methodenaufruf) zu "unterschiedlichem Verhalten" (Implementierung).

3. Polymorphie und Subtypbeziehung (Liskov-Prinzip)

Polymorphie ist eng mit der Subtypbeziehung verbunden: Wenn Roman eine Unterklasse von Buch ist, dann muss jedes Roman-Objekt in Kontexten verwendbar sein, in denen ein Buch erwartet wird. Auf Entwurfsniveau wird diese Forderung durch das Liskovsche Substitutionsprinzip (LSP) präzisiert. Im Grunde haben wir dieses Prinzip bereits kennengelernt, nämlichn als Prinzip der Ersetzbarkeit.

Praktisch bedeutet das: Überschriebene Methoden sollten die Erwartungen, die durch die Oberklasse gesetzt werden, nicht verletzen. Insbesondere sollten Vorbedingungen nicht verschärft und Nachbedingungen nicht abgeschwächt werden. Andernfalls funktioniert Polymorphie zwar technisch, aber die Software wird konzeptionell fehlerhaft und schwer wartbar.

4. Polymorphie über abstrakte Klassen und Interfaces

In realen Projekten werden Oberklassen häufig als abstrakte Klassen oder als Interfaces modelliert. Der entscheidende Gedanke ist dabei: Man programmiert gegen einen Vertrag (Interface), nicht gegen eine konkrete Implementierung. Dadurch wird der Code entkoppelt und leichter erweiterbar. Auf abstrakte Klassen und Interfaces werden wir auf den nächsten Seiten näher eingehen.

5. Polymorphie und Entwurf: Offen-geschlossenes Prinzip

Ein wesentlicher Nutzen von Polymorphie zeigt sich beim Software-Entwurf: Gut entworfene Klassen sind häufig offen für Erweiterungen, aber geschlossen gegenüber Änderungen (Open-Closed-Principle). Wenn eine neue Unterklasse hinzukommt (zum Beispiel Hoerbuch), sollte bestehender Code in der Regel nicht angepasst werden müssen, solange er nur den Vertrag (die Spezifikation) der Oberklasse bzw. des Interfaces verwendet.

Merke:

Polymorphie in Java

Der Compiler prüft Methodenaufrufe anhand des statischen Typs. Welche überschriebene Implementierung tatsächlich ausgeführt wird, entscheidet Java zur Laufzeit anhand des dynamischen Typs. Polymorphie ist damit die Grundlage für entkoppelte, erweiterbare Programmentwürfe.

Seitenanfang