Rheinwerk Computing < openbook > Rheinwerk Computing - Professionelle Bücher. Auch für Einsteiger.
Professionelle Bücher. Auch für Einsteiger.

Inhaltsverzeichnis
1 Einleitung
2 Die Basis der Objektorientierung
3 Die Prinzipien des objektorientierten Entwurfs
4 Die Struktur objektorientierter Software
5 Vererbung und Polymorphie
6 Persistenz
7 Abläufe in einem objektorientierten System
8 Module und Architektur
9 Aspekte und Objektorientierung
10 Objektorientierung am Beispiel: Eine Web-Applikation mit PHP 5 und Ajax
A Verwendete Programmiersprachen
B Literaturverzeichnis
Stichwort
Ihre Meinung?

Spacer
 <<   zurück
Objektorientierte Programmierung von Bernhard Lahres, Gregor Rayman
Das umfassende Handbuch
Buch: Objektorientierte Programmierung

Objektorientierte Programmierung
2., aktualisierte und erweiterte Auflage, geb.
656 S., 49,90 Euro
Rheinwerk Computing
ISBN 978-3-8362-1401-8
Pfeil 5 Vererbung und Polymorphie
  Pfeil 5.1 Die Vererbung der Spezifikation
    Pfeil 5.1.1 Hierarchien von Klassen und Unterklassen
    Pfeil 5.1.2 Unterklassen erben die Spezifikation von Oberklassen
    Pfeil 5.1.3 Das Prinzip der Ersetzbarkeit
    Pfeil 5.1.4 Abstrakte Klassen, konkrete Klassen und Schnittstellen-Klassen
    Pfeil 5.1.5 Vererbung der Spezifikation und das Typsystem
    Pfeil 5.1.6 Sichtbarkeit im Rahmen der Vererbung
  Pfeil 5.2 Polymorphie und ihre Anwendungen
    Pfeil 5.2.1 Dynamische Polymorphie am Beispiel
    Pfeil 5.2.2 Methoden als Implementierung von Operationen
    Pfeil 5.2.3 Anonyme Klassen
    Pfeil 5.2.4 Single und Multiple Dispatch
    Pfeil 5.2.5 Die Tabelle für virtuelle Methoden
  Pfeil 5.3 Die Vererbung der Implementierung
    Pfeil 5.3.1 Überschreiben von Methoden
    Pfeil 5.3.2 Das Problem der instabilen Basisklassen
    Pfeil 5.3.3 Problem der Gleichheitsprüfung bei geerbter Implementierung
  Pfeil 5.4 Mehrfachvererbung
    Pfeil 5.4.1 Mehrfachvererbung: Möglichkeiten und Probleme
    Pfeil 5.4.2 Delegation statt Mehrfachvererbung
    Pfeil 5.4.3 Mixin-Module statt Mehrfachvererbung
    Pfeil 5.4.4 Die Problemstellungen der Mehrfachvererbung
  Pfeil 5.5 Statische und dynamische Klassifizierung
    Pfeil 5.5.1 Dynamische Änderung der Klassenzugehörigkeit
    Pfeil 5.5.2 Entwurfsmuster »Strategie« statt dynamischer Klassifizierung


Rheinwerk Computing - Zum Seitenanfang

5.3 Die Vererbung der Implementierung  Zur nächsten ÜberschriftZur vorigen Überschrift

In Abschnitt 4.2 haben Sie gesehen, wie die Vererbung der Spezifikation mit der Einteilung von Klassen in Unter- und Oberklassen zusammenhängt.

Wenn Sie eine solche Einteilung gefunden haben, die auch das Prinzip der Ersetzbarkeit nicht verletzt, können Sie eine weitere Möglichkeit der Objektorientierung nutzen: die Vererbung der Implementierung.


Icon Hinweis Vererbung der Implementierung

Unterklassen erben die in den Oberklassen bereits implementierte Funktionalität. Die Exemplare der Unterklassen erben damit neben den Verpflichtungen auch alle Methoden, alle Daten und alle Fähigkeiten ihrer Oberklassen, sofern diese zur Schnittstelle der Oberklasse gehören oder durch Sichtbarkeitsregeln für die Nutzung in Unterklassen freigegeben sind. Diese Funktionalität kann unverändert übernommen oder in Teilen von den Unterklassen überschrieben werden.


In Abschnitt 2.4, »Die Vererbung«, haben wir ein Beispiel vorgestellt, bei dem eine Hierarchie von Regelungen im Steuerrecht besteht und die Regelungen der höheren Ebene jeweils auch für die darunter liegenden Ebenen gelten, von diesen im Einzelfall aber überschrieben werden können. Diese Hierarchie können wir nun in Form der Klassen repräsentieren, die in Abbildung 5.44 dargestellt sind.

Hierarchie in Form der Klassen

Abbildung 5.44    Vererbung von Steuerregelungen

In der vorgestellten Hierarchie überschreibt das Steuerrecht der Bundesrepublik (die Klasse SteuerRegelungDesBundes) die Methode getKörperschaftssteuerSatz der EU. Dagegen wird die Methode getZinsabschlag direkt von der Klasse SteuerRegelungDerEU geerbt. Auch die Klassen SteuerRegelungDerLänder und SteuerRegelungDerKommunen erben alle von SteuerRegelungDesBundes umgesetzten Methoden. Sie fügen aber selbst auch wieder neue Operationen und Methoden hinzu.


Vererbung der Implementierung und die Programmiersprachen

In den objektorientierten Sprachen wird unterschiedlich konsequent zwischen Vererbung der Spezifikation und Vererbung der Implementierung getrennt. Wenn Sie von einer Klasse erben, die auch Implementierungen bereitstellt, haben Sie in den meisten Sprachen gar keine Wahl. Sie können nicht einfach die Spezifikation erben, Sie bekommen die Implementierung auf jeden Fall dazu, ob Sie wollen oder nicht. Allerdings bieten einige Sprachen sogenannte Schnittstellen-Klassen an, die gar keine Implementierungen zur Verfügung stellen können. Über diesen Mechanismus ist also auch eine reine Vererbung der Spezifikation möglich.


Es ergeben sich drei Varianten der Vererbung:

  • Reine Vererbung der Spezifikation Die Schnittstellen-Klassen (Interfaces) in Java oder C# stellen zum Beispiel einen Mechanismus zur Verfügung, mit dem alleine die Spezifikation einer Klasse geerbt werden kann. Da Schnittstellen-Klassen selbst keine Methoden implementieren können, können Sie deren Implementierung auch nicht vererben.
  • Vererbung von Spezifikation und Implementierung Dies ist der Normalfall, wenn eine abgeleitete Klasse von einer Klasse erbt, die Implementierungen bereitstellt. Wird zum Beispiel in Java über das Schlüsselwort extends eine abgeleitete Klasse definiert, so erbt diese Klasse von ihrer Basisklasse Spezifikation und Implementierung.
  • Reine Vererbung der Implementierung Eine reine Vererbung der Implementierung ist zum Beispiel in der Sprache C++ über die private Vererbung möglich, die wir in Abschnitt 5.1.6 vorgestellt haben. Dabei wird die Spezifikation der Basisklasse nicht geerbt, die abgeleitete Klasse übernimmt als nicht die Schnittstelle der Basisklasse, sondern nur deren Implementierungen zur internen Nutzung.

Im folgenden Abschnitt 5.3.1 gehen wir genauer darauf ein, wie geerbte Methoden helfen, Redundanzen in unserem Code zu vermeiden und wie das Überschreiben von Methoden Ihnen Mittel an die Hand gibt, die Zusammenarbeit zwischen Oberklassen und Unterklassen zu strukturieren.

Wir wollen aber auch nicht verschweigen, dass die Vererbung der Implementierung zu einer Reihe von konzeptionellen Problemen führen kann. Diese Probleme und mögliche Lösungen stellen wir anschließend in den Abschnitten 5.3.2 und 5.3.3 vor.


Rheinwerk Computing - Zum Seitenanfang

5.3.1 Überschreiben von Methoden  Zur nächsten ÜberschriftZur vorigen Überschrift

Die konkreten Unterklassen einer abstrakten Oberklasse müssen für alle ihre abstrakten Methoden eine Implementierung bereitstellen. Doch jede Unterklasse kann auch für die nicht abstrakten Methoden ihre eigene spezielle Implementierung bereitstellen.


Icon Hinweis Überschreiben von Methoden

Wenn eine Unterklassen für eine Operation eine Methode implementiert, für die es bereits in einer Oberklasse eine Methode gibt, so überschreibt die Unterklasse die Methode der Oberklasse. Wird die Operation auf einem Exemplar der Unterklasse aufgerufen, so wird die überschriebene Implementierung der Methode aufgerufen.

Das ist unabhängig davon, welchen Typ die Variable, über die das Objekt referenziert wird, hat. Entscheidend ist der Typ des Objekts selbst, nicht der Typ der Variablen. Die Unterstützung der dynamischen Polymorphie durch die objektorientierten Programmiersprachen ermöglicht dieses Verfahren.


Betrachten wir ein einfaches Beispiel für das Überschreiben einer Methode. In Abbildung 5.45 ist eine Generalisierungsbeziehung zwischen der Klasse FarbigerKreis und der Klasse Kreis dargestellt.

Abbildung 5.45    »FarbigerKreis« erbt von »Kreis«.

In unserem Beispiel repräsentieren die Exemplare der Klasse Kreis Kreise, die auf dem Bildschirm dargestellt werden können. Die Klasse Kreis kennt das Konzept der Farbe nicht, sie zeichnet die Kreise in der Standardfarbe. Die Spezialisierung der Klasse Kreis, die Unterklasse FarbigerKreis definiert für jedes ihrer Exemplare das Attribut myColor. Die Implementierung der Methode anzeigen muss für diese Klasse daher erweitert werden, so dass die Farbe beim Zeichnen des Kreises auf dem Bildschirm verwendet wird. In Listing 5.26 ist die Umsetzung der Methode anzeigen an einem Beispiel in Java dargestellt.

class FarbigerKreis extends Kreis { 
 
    protected Color myColor; 
 
    ... 
 
    public void anzeigen() { 
        Graphics context = getGraphics(); 
        Color oldColor = context.getColor(); 
        context.setColor(myColor); 
        super.anzeigen(); 
        context.setColor(oldColor); 
    } 
}

Listing 5.26    Überschriebene Methode der Klasse »FarbigerKreis« anzeigen

Aufruf weiterer Implementierungen

Eine überschreibende Methode kann ihre eigene selbstständige Implementierung haben, sie kann aber auch, wie in unserem Beispiel, die geerbte Implementierung aufrufen und sie nur erweitern. In unserem Beispiel sorgt der Aufruf von super.anzeigen() für diesen zusätzlichen Aufruf.

Mit welcher Syntax die überschriebene Methode aufgerufen wird, hängt von der Programmiersprache ab. In Java wird die geerbte überschriebene Methode mit dem Aufruf super.methodeName() aufgerufen. In C# verwendet man dazu das Schlüsselwort base. In C++ gibt es kein Schlüsselwort, mit dem man die überschriebene Methode aufrufen kann, stattdessen verwendet man den Klassennamen der Klasse, in der die Methode implementiert ist.

Wenn es für C++ eine grafische Bibliothek mit Klassen mit gleichen Namen wie in Javas AWT gäbe, sähe die Methode anzeigen der Klasse FarbigerKreis in C++ so aus:

    void anzeigen() { 
        Graphics* context = GetGraphics(); 
        Color* oldColor = context->getColor(); 
        context->setColor(myColor); 
        Kreis::anzeigen(); 
        context->setColor(oldColor); 
    }

Ein Schlüsselwort wie super kann es in C++ nicht geben, weil C++ die Mehrfachvererbung der implementierenden Klassen unterstützt, und es wäre nicht eindeutig, welche der Oberklassen gemeint ist. Viele Entwickler, die in C++ nur die einfache Vererbung verwenden, deklarieren für jede Klasse ein Makro, das die Funktion eines solchen Schlüsselwortes übernimmt.

Vererbung der Implementierung: Probleme und Alternativen


Vermeidung von Redundanzen durch Vererbung

Ein Vorteil der Vererbung von Implementierungen ist es, dass Methoden in Unterklassen nicht neu umgesetzt werden müssen. Dadurch werden Redundanzen im Quellcode vermieden. Allerdings legen Sie sich durch die Vererbungsbeziehung bereits auf eine recht starre Struktur fest. Spätere Erweiterungen sind nur mit größeren Eingriffen möglich. Wenn Sie zum Beispiel feststellen, dass ihr Kreis nicht nur Farben, sondern auch eine Schraffierung beinhaltet und diese nicht nur für Kreise, sondern auch für andere geometrische Formen gelten soll, wird die Klassenhierarchie bereits sehr komplex, obwohl sie eigentlich zwei ganz einfache zusätzliche Attribute modelliert haben.


Redundanzen können auch auf andere Arten vermieden werden, indem die gemeinsam genutzte Funktionalität in ein separates Modul ausgelagert wird. Im weiteren Verlauf werden Sie mehrere Alternativen zur Vererbung der Implementierung kennen lernen. In Abschnitt 5.3.2 werden Sie an einem Beispiel sehen, wie eine Vererbungsbeziehung in eine Delegationsbeziehung umgewandelt werden kann.

Die Alternative der Delegation greifen wir auch in Abschnitt 5.4.2 auf. In Abschnitt 5.5.2, »Entwurfsmuster ›Strategie‹ statt dynamischer Klassifizierung«, werden Sie außerdem eine weitere Alternative zur Vererbung der Implementierung kennen lernen. Das Entwurfsmuster »Strategie« basiert auf der Delegation und erlaubt größere Flexibilität als Vererbungsbeziehungen.

Implementierung von geschützten Methoden

Eine weitere Aufgabe erfüllt die Vererbung der Implementierung bei Daten und Methoden von Klassen, die nicht zur Schnittstelle der Klasse gehören. Diese werden in der Regel für die Umsetzung von Operationen genutzt, die selbst zur Schnittstelle gehören. In Abschnitt 5.1.6 haben wir die Sichtbarkeitsstufe »Geschützt« vorgestellt. Methoden, die mit dieser Sichtbarkeit markiert sind, gehören nicht zur Schnittstelle einer Klasse. Sie sind aber explizit dafür vorgesehen, dass ihre Implementierung von Unterklassen geerbt und möglicherweise überschrieben werden kann.

Überschreiben von Methoden verhindern

Durch die Vererbung der Implementierung können Sie auch erzwingen, dass Unterklassen in Teilen die Funktionalität ihrer Oberklassen nutzen müssen. Dabei kann eine Oberklasse verbieten, dass ihre Unterklassen bestimmte Teile der Implementierung verändern. Diese Fähigkeit kann wichtig sein, um Verträge zwischen Klassen und ihren potenziellen Unterklassen zu definieren.

Im nächsten Abschnitt werden wir ein Beispiel für eine solche Situation vorstellen und den Mechanismus der Schablonenmethode einführen, mit dem die Interaktion zwischen Ober- und Unterklasse strukturiert werden kann.

Das Entwurfsmuster »Schablonenmethode«

In manchen Situationen ist es vorhersehbar, dass das Überschreiben einer Methode durch eine Unterklasse dazu führt, dass die Operationen der Oberklasse nicht mehr korrekt arbeiten. Dies kann vor allem dann passieren, wenn die ursprüngliche Implementierung durch eine Unterklasse komplett ersetzt wird. Damit sind möglicherweise Konsistenzbedingungen der Oberklasse gefährdet.

Methoden paarweise aufrufen

Nehmen Sie an, dass eine Klasse FileController zwei Methoden start und end implementiert, die immer paarweise aufgerufen werden müssen. In Abbildung 5.46 ist die Klasse dargestellt.

Abbildung 5.46    Methoden zum paarweisen Aufruf

Die Klasse FileController implementiert eine Methode output, die Daten in eine Datei schreibt. Die Ausgabe auf diese Datei geschieht in der Methode output(), diese wiederum ruft die Methoden start() und end() auf. Die Methode start() öffnet die Datei, und die Methode end() schließt sie wieder. Zwischen den Aufrufen von start() und end() werden Daten in diese Datei geschrieben.

Die Methode output() sieht dann so aus:

public void work() { 
    start(); 
    ... // hier geschieht die Bearbeitung der Datei 
    end(); 
}

Nehmen wir an, wir möchten in der Unterklasse ExtendedFileController das Verhalten der Exemplare so abändern, dass Sie an den Anfang der Datei eine Kopfzeile und an das Ende der Datei eine Fußzeile hinzufügen.

Die überschriebenen Methoden würden in Java dann wie in Listing 5.27 aussehen.

... 
public void start() { 
    super.start(); 
    write("Kopfzeile"); 
} 
 
public void end() { 
    write("Fußzeile"); 
    super.end(); 
}

Listing 5.27    Überschriebene Methoden

Dies wäre eine korrekte Erweiterung der Methoden der Oberklasse und würde Ihren Absichten entsprechen. Doch was würde passieren, wenn wir in der überschreibenden Methode end() den Aufruf der geerbten Methode end() vergessen hätten? Die Methode start() würde die Datei öffnen, die Methode end() würde sie aber nicht schließen. Dies wäre ein Fehler, der vom Compiler unentdeckt bliebe.

In Situationen wie diesen stehen Sie vor einem Dilemma. Einerseits müssen Sie sicherstellen, dass die ursprüngliche Implementierung der Methoden start() und end() aufgerufen wird, andererseits möchten Sie das Verhalten der Objekte beim Öffnen und Schließen der Datei erweiterbar machen, abgeleiteten Klassen also erlauben, dass sie die Methoden überschreiben.

Einen Ausweg aus diesem Dilemma bietet das Entwurfsmuster »Schablonenmethode«.

In unserem Beispiel können wir also die Methode output() zu einer Schablonenmethode machen und sie um die benötigten Erweiterungspunkte erweitern. In Abbildung 5.47 ist der Aufbau der Methode dargestellt.


Icon Hinweis Entwurfsmuster »Schablonenmethode« (engl. Template Method)

Eine Schablonenmethode implementiert einen vorgegebenen groben Ablauf und bietet definierte Erweiterungspunkte für bestimmte Schritte dieses Ablaufes. Die Schablonenmethode basiert darauf, dass nur ein definierter Teil von Methoden durch eine Unterklasse überschrieben werden kann. Dadurch kann eine Unterklasse gezwungen werden, eine Implementierung von der Oberklasse unverändert zu übernehmen. Diese Möglichkeit stellt ein wichtiges Mittel zur Strukturierung von objektorientierten Anwendungen dar.


Abbildung 5.47    Umsetzung einer Schablonenmethode

Der Gesamtablauf der Schablonenmethode output() (siehe Markierung ) kann nicht geändert werden, da die Methode nicht polymorph ist. Die Methoden afterStart() und beforeEnde() sind polymorph und können überschrieben werden. Somit unterscheiden sich Details des Ablaufes der Methode output() für die Exemplare der Klasse AbgeleiteteKlasse von dem Ablauf für die Exemplare der Klasse Basisklasse.

Unsere neue Methode output() sieht im Quellcode jetzt so aus:

public void work() { 
    start(); 
    afterStart(); 
    ... // hier geschieht die Bearbeitung der Datei 
    beforeEnd(); 
    end(); 
}

Wir haben den Ablauf der Methode output() um die Aufrufe der neuen Methoden afterStart() und beforeEnd() erweitert. In der Basisklasse machen diese Methoden in unserem Beispiel nichts, sie sind nicht abstrakt, sie haben bloß eine leere Implementierung.

In der abgeleiteten Klasse können wir jetzt darauf verzichten, die Methoden start() und end() zu überschreiben. Stattdessen überschreiben wir die Methoden afterStart() und beforeEnd().

public void afterStart() { 
    write("Kopfzeile"); 
} 
 
public void beforeEnd() { 
    write("Fußzeile"); 
}

Methoden für Überschreiben sperren

Die Methoden start() und end() müssen also nicht mehr überschrieben werden. Um jedoch sicherzustellen, dass die ursprünglichen Implementierungen dieser Methoden in der Methode output() aufgerufen werden, müssen wir dafür sorgen, dass die Methoden nicht überschrieben werden können.

In dynamisch typisierten Programmiersprachen haben Sie hier wenige Chancen, doch statisch typisierte Programmiersprachen bieten uns vielfältige Möglichkeiten, die Überschreibbarkeit der Methoden zu gestalten. Schauen wir uns die Möglichkeiten in den verschiedenen Programmiersprachen im Folgenden kurz an.

C++

In C++ sind Methoden normalerweise nicht überschreibbar, und sie werden auch nicht dynamisch polymorph aufgerufen. Eine Methode wird erst dann überschreibbar und dynamisch polymorph, wenn sie mit dem Schlüsselwort virtual bezeichnet wird. Damit sind Methoden automatisch für das Überschreiben gesperrt, wenn das Schlüsselwort virtual nicht angegeben wird.

Damit lässt sich die beschriebene Schablonenmethode in C++ einfach umsetzen, indem die Methoden output, start und end nicht mit dem Schlüsselwort virtual markiert werden.

Java

Im Gegensatz zu C++ sind in Java Methoden, die wir nicht anders markieren, von vornherein überschreibbar. Wir können allerdings die Methoden, deren Überschreibbarkeit wir verhindern möchten, mit dem Schlüsselwort final markieren. Solche Methoden können in den Unterklassen nicht mehr überschrieben werden. Enthält eine Unterklasse eine Methode mit der gleichen Signatur wie eine finale Methode in der Oberklasse, kommt es zu einem Übersetzungsfehler. [Eine Ausnahme bilden die privaten Methoden. ] Für die Umsetzung der Template-Methode aus unserem Beispiel würden Sie also die Methoden output, start und end mit dem Schlüsselwort final markieren.

In Java können auch ganze Klassen so markiert werden, dass überhaupt keine Unterklassen von ihnen existieren können.


Icon Hinweis Finale Klassen

Finale Klassen sind Klassen, die keine Unterklassen haben können. Finale Klassen können deshalb auch nicht abstrakt sein, sie wären sonst völlig nutzlos: Man kann keine Exemplare der Klasse erstellen, aber auch keine Unterklassen bilden. Abstrakte Klassen können aber finale Methoden haben.


C#

In C# müssen Methoden, die überschrieben werden dürfen, ähnlich wie in C++, als virtuell bezeichnet werden. Dazu dient auch hier das Schlüsselwort virtual. Abstrakte Methoden werden mit dem Schlüsselwort abstract markiert.

Während in C++ eine einmal als virtual markierte Methode in allen weiteren Unterklassen überschreibbar bleibt, können Sie in C# das weitere Überschreiben einer virtuellen Methode mit dem Schlüsselwort sealed verhindern. Wird das Schlüsselwort sealed auf eine Klasse angewandt, darf diese Klasse keine Unterklassen haben.


Rheinwerk Computing - Zum Seitenanfang

5.3.2 Das Problem der instabilen Basisklassen  Zur nächsten ÜberschriftZur vorigen Überschrift

Leider lässt sich die Vererbung der Implementierung durch die Programmiersprachen auch nutzen, wenn keine korrekte Vererbung der Spezifikation vorliegt. Deshalb soll an dieser Stelle noch ein Wort der Warnung ausgesprochen werden.

Vererbung und Alternativen


Vererbung und alternative Nutzungsbeziehungen

Wenn eine Klasse die Funktionalität einer anderen Klasse nutzen soll, können wir dies durch Vererbung oder eine Beziehung (Assoziation, Aggregation oder Komposition) zwischen den Exemplaren dieser Klassen erreichen. Die Vererbung kann in bestimmten Fällen zwar einfacher scheinen, doch Sie sollten immer auf das Prinzip der Ersetzbarkeit achten und sich fragen: Sind die Exemplare der Unterklasse fachlich auch in allen Fällen Exemplare der Oberklasse? Und wir müssen auch die Frage stellen, ob wir erwarten, dass dies auch in Zukunft so bleibt. Änderungen an einer Oberklasse können nämlich dazu führen, dass nicht mehr alle abgeleiteten Klassen echte Unterklassen im Sinn des Prinzips der Ersetzbarkeit sind.

Die treibende Kraft bei der Modellierung von Vererbungsbeziehungen sollte deshalb nie die Vererbung der Implementierung sein, sondern die Vererbung der Spezifikation.


Ein konkretes Problem, das bei der Verwendung von Vererbung der Implementierung auftreten kann, ist eine sehr enge Kopplung zwischen Basisklassen und den davon abgeleiteten Klassen. Dieses Problem wird auch als Fragile Base Class Problem bezeichnet: das Problem der instabilen Basisklasse. [Hin und wieder wird unter der Bezeichnung Fragile Base Class Problem auch verstanden, dass Änderungen an Basisklassen, die im Source-Code unproblematisch sind, zu Problemen mit bereits in Bibliotheken vorliegenden und ausgelieferten Unterklassen führen. Dieses Problem sehen wir eher als Teil des sogenannten Problems der instabilen binären Schnittstellen (Fragile Binary Interface Problem), das wir bereits im Abschnitt 5.2.5, »Die Tabelle für virtuelle Methoden«, vorgestellt haben. ]

Fragile Base Class Problem


Fragile Base Class Problem (Problem der instabilen Basisklassen)

Mit Fragile Base Class Problem17 wird ein Problem bei der Nutzung von Vererbung der Implementierung bezeichnet. Dabei kann der problematische Fall auftreten, dass Anpassungen an einer Basisklasse zu unerwartetem Verhalten von abgeleiteten Klassen führen.

Anpassungen an Basisklassen können häufig nicht vorgenommen werden, ohne den Kontext der abgeleiteten Klassen mit einzubeziehen. Damit wird eine Wartung von objektorientierten Systemen, die in größerem Maß die Vererbung der Implementierung nutzen, stark erschwert. Deshalb muss zur Entwurfszeit darauf geachtet werden, dass die Vererbung der Implementierung nicht eingesetzt wird, wenn spätere Änderungen an den Basisklassen wahrscheinlich sind, die Auswirkungen auf abgeleitete Klassen haben. In der Praxis ist das allerdings meist schwer vorauszusehen. Im Zweifelsfall sollten Sie deshalb eine reine Vererbung der Spezifikation vorziehen. Die Vermeidung von Redundanzen, die von der Vererbung der Implementierung ermöglicht wird, kann auch über Delegationsbeziehungen erreicht werden, wie Sie im Beispiel weiter unten sehen werden.


Das Problem der instabilen Basisklassen tritt dann auf, wenn sich abgeleitete Klassen auf Implementierungen der Basisklassen verlassen. Es könnte nun der Einwand kommen, dass das dann eben eine inkorrekte Nutzung von abgeleiteten Klassen ist, weil diese sich ausschließlich auf die Spezifikation der Basisklasse verlassen sollten. Das ist in der Theorie richtig. In der Praxis können diese Fälle aber sehr subtil sein, und wenn die Spezifikation der Basisklassen nicht sehr exakt ist, gibt es ausreichend Spielraum für abgeleitete Klassen, inkorrekte Annahmen zu machen.

Betrachten wir das Ganze am besten an einem Beispiel. In Abbildung 5.48 sind eine Basisklasse Well (Brunnen) und die davon abgeleitete Klasse LoggingWell zu sehen.

Abbildung 5.48    Instabile Basisklasse »Well«

Well modelliert einen Brunnen, der durch die Operation rain() aufgefüllt wird. Durch die Operation pump() wird dem Brunnen Wasser entnommen. Außerdem gibt es noch eine Operation empty(), die den Brunnen komplett leert.

Da der Wasserstand des Brunnens für die Versorgung der umliegenden Bevölkerung wichtig ist, hat die Dorfverwaltung eine Klasse LoggingWell in Auftrag gegeben. Diese fügt Funktionalität hinzu, die alle Änderungen am Wasserstand des Brunnens mitprotokollieren soll. Dazu überschreibt die Klasse die Methoden rain und pump. In Listing 5.28 ist die Umsetzung der beiden Klassen in Java zu sehen.

class Well { 
    private int level; 
    public int getLevel() { 
      return level; 
    } 
    public void rain(int days) { 
      level += days / 3; 
    } 
    public void pump(int buckets) { 
      level -= Math.min(level, buckets); 
    } 
 
    public void empty() {  
        pump(level); 
    } 
} 
 
class LoggingWell extends Well { 
 
    public void rain(int days) {      
      System.out.println("it is raining " 
              + days + " days ..."); 
      super.rain(days); 
    } 
 
    public void pump(int buckets) {    
      System.out.println("taking " 
              + buckets + " buckets from well"); 
      super.pump(buckets); 
    } 
}

Listing 5.28    Erweiterung von »Well« durch »LoggingWell«

Da die Operation empty() in der Klasse Well in Zeile darüber umgesetzt ist, dass pump mit dem aktuellen Pegelstand aufgerufen wird, ist das Verhalten von LoggingWell auch korrekt: Es werden alle Änderungen des Wasserstands über die Methoden rain und pump (Zeilen und ) protokolliert.

Nun könnte aber jemand eine interne Optimierung der Klasse Well vornehmen. Die Methode empty() muss ja lediglich dazu führen, dass der Wasserstand auf 0 sinkt. Diese Optimierung könnte dann einfach so aussehen:

    public void empty() { 
        level = 0; 
    }

Die Klasse Well wird nach wie vor ohne Probleme funktionieren. Allerdings hat nun LoggingWell ein Problem: Das Leeren des Brunnens wird komplett an der Protokollierung vorbeigehen. LoggingWell hält damit seine Spezifikation nicht mehr ein, die ja genau alle Änderungen des Wasserstands beobachten soll.

Hat sich nun LoggingWell unzulässig auf die konkrete Implementierung der Operation empty() in der Basisklasse verlassen? Mag sein, aber das gleiche Problem würde auch dann auftreten, wenn die Klasse Well eine völlig neue Operation erhalten würde, die ebenfalls den Wasserstand verändert.

LoggingWell ist also ein Opfer der instabilen Basisklasse Well geworden. Und das Unschöne daran ist: Durch Tests hatten Sie keine Chance, das Problem zu erkennen, da vor der Anpassung der Basisklasse alle Operationen gemäß Spezifikation gearbeitet haben.

In Abbildung 5.49 ist ein alternatives Klassendesign aufgeführt, das die beschriebenen Probleme ausschließt.

Abbildung 5.49    Design ohne instabile Basisklasse

Dabei setzen nun die zwei Klassen Well und LoggingWell beide die Schnittstelle WellInterface um. Die Klasse LoggingWell verwendet ein Exemplar der Klasse Well und delegiert die Aufrufe der Operationen an dieses. Zusätzlich zur Delegation protokolliert die Klasse aber auch noch die Aufrufe. In Listing 5.29 ist wieder die Java-Umsetzung dargestellt, allerdings ohne die Klasse Well, die sich nicht verändert, abgesehen davon, dass sie nun die Schnittstelle WellInterface implementiert, die in Zeile definiert ist.

interface WellInterface {     
    public void rain(int days); 
    public void pump(int buckets); 
    public void empty(); 
} 
 
class LoggingWell implements WellInterface { 
    Well well = new Well();     
 
    public void rain(int days) { 
      System.out.println("it is raining " 
                   + days + " days ..."); 
      well.rain(days);     
    } 
    public void pump(int buckets) { 
      System.out.println("getting " 
                   + buckets + " buckets from well"); 
      well.pump(buckets);   
    } 
 
    public void empty() { 
      System.out.println("well is getting emptied"); 
      well.empty();   
    } 
}

Listing 5.29    Vererbung durch Delegation ersetzt

In Zeile wird das Exemplar von Well angelegt, an das die Methodenaufrufe delegiert werden. In den mit markierten Zeilen erfolgt dann die Delegation.

Dieses Design vermeidet das Problem der instabilen Basisklasse, ohne wesentlich höhere Aufwände in der Umsetzung zu erfordern. Eine Änderung der Schnittstelle würde nun auch LoggingWell dazu zwingen, eine neue Operation umzusetzen. Und eine Änderung an der Klasse Well ohne Änderung der Schnittstelle WellInterface würde LoggingWell nicht betreffen, da seine eigene Außenschnittstelle damit nicht erweitert wird.

Sie können aus diesem Beispiel nicht schließen, dass Vererbung der Implementierung grundsätzlich problematisch ist und vermieden werden sollte. Es gibt durchaus Fälle, in denen durch Vererbung der Implementierung Quelltextredundanzen vermieden werden können. Wenn sich allerdings wie in unserem vorgestellten Beispiel eine Lösung auf Basis von reiner Vererbung der Spezifikation und Delegation einfach umsetzen lässt, dann sollten Sie das auch tun.


Rheinwerk Computing - Zum Seitenanfang

5.3.3 Problem der Gleichheitsprüfung bei geerbter Implementierung  topZur vorigen Überschrift

Die Prüfung auf Gleichheit ist etwas, was intuitiv sehr einfach ist. Wenn Sie prüfen wollen, ob zwei Datumswerte gleich sind, müssen Sie die Bestandteile des Datumsobjekts einzeln vergleichen. Damit können Sie zum Beispiel die Frage beantworten, ob zwei Termine am gleichen Tag stattfinden.

Wenn allerdings Hierarchien von Klassen ins Spiel kommen, die ihre Implementierung voneinander erben, ist dieser Vergleich auf einmal gar nicht mehr so einfach anzustellen, und es werden ganz besondere Anforderungen an eine korrekte Umsetzung eines Vergleichs gestellt. Wir werden das gleich an einem Beispiel vorstellen, vorher wollen wir aber noch die Erwartungshaltung an eine Prüfung auf Gleichheit formulieren.

Formale Eigenschaften der Gleichheitsprüfung


Formale Kriterien für Gleichheitsprüfung

Der Kontrakt, den eine Prüfung auf Gleichheit einhalten muss, lässt sich aus den mathematischen Bedingungen für die Gleichheitsprüfung heraus formulieren.

Die Gleichheitsprüfung ist reflexiv:

A ist gleich A.

Die Gleichheitsprüfung ist symmetrisch:

A ist gleich B ® B ist gleich A.

Die Gleichheitsprüfung ist transitiv:

A ist gleich B und B ist gleich C ® A ist gleich C.

Eine Umsetzung der Gleichheitsprüfung muss diese Bedingungen einhalten, damit sie korrekt arbeitet.


Gleichheitsprüfung bei Vererbungsbeziehung

Problematisch wird diese Prüfung im Bereich der Objektorientierung, wenn wir Objekte prüfen, die zu unterschiedlichen Klassen gehören, die aber in einer Vererbungsbeziehung zueinander stehen.

Greifen wir zur Illustration unser Beispiel aus Abschnitt 5.3.1 wieder auf. In Abbildung 5.50 ist ein angepasste Version der Klassen Kreis und FarbigerKreis zu sehen.

Abbildung 5.50    Gleichheitsprüfung für Kreise

Beide Klassen haben jeweils eine Methode equals zugeordnet, die Exemplare der Klasse auf Gleichheit prüfen soll. Im Folgenden illustrieren wir das Vorgehen und mögliche Probleme an Beispielen in Java.

Methode equals

Da in Java bereits die Klasse Object eine Methode equals umsetzt, überschreiben wir diese Methode für die Klassen Kreis und FarbigerKreis. Zwei Kreise sollen aufgrund des Radius verglichen werden, zwei farbige Kreise aufgrund von Radius und Farbe.

In Abbildung 5.51 sind drei Objekte dargestellt, die wir testweise für einen Vergleich heranziehen wollen.

Abbildung 5.51    Objekte zum Vergleich

Die gezeigten Objekte haben alle denselben Radius 10, die zwei Exemplare der Klasse FarbigerKreis unterscheiden sich allerdings in der Farbe.

Gleichheitsprüfung durchführen

Um die oben formulierten Kriterien für eine Gleichheitsprüfung einzuhalten, müssen Sie die Vergleichsoperation sehr restriktiv umsetzen. Wir stellen zunächst die beiden Möglichkeiten vor, welche die oben genannten Kriterien einhalten, bevor wir noch einige Varianten auflisten, die intuitiv besser scheinen, aber eben nicht korrekt sind.


Erste Option: Exemplare verschiedener Klassen sind ungleich.

Vergleiche zwischen Exemplaren verschiedener Klassen werden grundsätzlich mit ungleich beantwortet, auch wenn diese Klassen in einer Generalisierungsbeziehung stehen, also eine Klasse von der anderen erbt.

Möglichkeit 1: Verschiedene Klassen => dann ungleich

Eine Möglichkeit für die Umsetzung ist es, die Exemplare von unterschiedlichen Klassen grundsätzlich als ungleich zu betrachten. In Listing 5.30 ist eine mögliche Umsetzung dieses Vorgehens in Java aufgeführt.

class Kreis { 
    public boolean equals(Object obj) { 
        if (obj.getClass() == this.getClass()) {   
            Kreis andererKreis = (Kreis) obj; 
            return this.radius == andererKreis.radius; 
        } else { 
            return false; 
        } 
        // ... 
 
class FarbigerKreis { 
  public boolean equals(Object obj) { 
    if (super.equals(obj)) {     
        FarbigerKreis andererKreis = (FarbigerKreis) obj; 
        return this.farbe == andererKreis.farbe; 
    } 
    return false; 
}

Listing 5.30    Vergleich nur o.k. bei gleicher Klassenzugehörigkeit

Dies führt allerdings in unserem Beispiel dazu, dass alle drei Kreise FarbigA, FarbigB und KreisA jeweils als ungleich von den anderen betrachtet werden, da ein Vergleich eines Exemplars von Kreis mit einem Exemplar von FarbigerKreis und auch umgekehrt grundsätzlich das Ergebnis false liefert.

In Zeile wird die Klassenzugehörigkeit der Objekte verglichen, und dieser Vergleich schlägt in diesen Fällen fehl. Dadurch dass die Prüfung für einen farbigen Kreis in Zeile an die Oberklasse delegiert wird, greift der Klassenvergleich auch hier. Bei einem Vergleich mit gleicher Klassenzugehörigkeit wie zwischen FarbigA und FarbigB kommen dann die inhaltlichen Kriterien zum Tragen, und die unterschiedlichen Farben führen korrekt zum Ergebnis false.

Beim Vergleich zwischen einem Kreis und einem farbigen Kreis würden wir zwar eher die Aussage erwarten: kann ich nicht sagen, weil mir Informationen fehlen. Die Definition der Gleichheitsprüfung erlaubt aber eine solche Rückmeldung nicht, so dass wir im Fall von unterschiedlichen Klassen die Aussage ungleich treffen.


Zweite Option: Kriterium der Basisklasse ist alleine gültig.

Die Umsetzung der Vergleichsoperation der Basisklasse wird für alle Unterklassen verbindlich gemacht. Damit gibt es eindeutig definierte Kriterien für den Vergleich, die unabhängig von der Klassenzugehörigkeit der verglichenen Objekte sind.

Wenn Sie die zweite Option für unser Beispiel umsetzen, wird das Vergleichskriterium der Basisklasse, in diesem Fall der Radius, das einzige Kriterium für den Vergleich bleiben.

In Java können Sie das sicherstellen, indem Sie die Methode equals in der Klasse Kreis als final deklarieren. Damit entfällt für die Unterklasse die Möglichkeit, überhaupt eine Vergleichsoperation equals zu definieren.

    final public boolean equals(Object obj) { 
        // ... Prüfung auf null und Identität weggelassen 
        if (obj instanceof Kreis) { 
            Kreis andererKreis = (Kreis) obj; 
            return this.radius == andererKreis.radius; 
        } else { 
            return false; 
        } 
    }

Damit ist die Vergleichsoperation für die Klasse Kreis und alle ihre Unterklassen klar spezifiziert und anwendbar. Die Methode wird jedes Exemplar genau dann als gleich zu einem anderen betrachten, wenn beide den gleichen Radius haben. Wenn Sie diese Variante wählen, sind die in unserem Beispiel ausgewerteten Kreise FarbigA, FarbigB und KreisA alle gleich, da sie alle denselben Radius aufweisen.

Ob diese Variante sinnvoll ist, hängt vom Kontext der Anwendung ab. Wenn die Vergleichsoperation so sinnvoll einsetzbar ist, bietet diese Umsetzung eine praktikable Lösung.


Warum kein inhaltlicher Vergleich von Exemplaren verschiedener Klassen?

Die bisher aufgeführten Lösungen arbeiten zwar korrekt, sind aber nicht völlig intuitiv. Warum sollten wir nicht auch KreisA, der den Radius 10 hat, mit FarbigA vergleichen, der ebenfalls den Radius 10 hat, und ein O.k. bekommen? Und trotzdem beim Vergleich eines farbigen Kreises mit einem Kreis ebenfalls möglicherweise eine Gleichheit feststellen?

Es gibt verschiedene Umsetzungsmöglichkeiten, die solche Vergleiche auch zulassen und in der Praxis auch zu finden sind. Aber alle arbeiten nicht korrekt im Sinne der Kriterien für die Gleichheitsoperation. Im Folgenden stellen wir deshalb einige gängige Umsetzungen der Prüfung auf Gleichheit vor und erläutern, warum diese nicht korrekt arbeiten. 18


Gehen wir also die in Listing 5.31 aufgeführte Variante an, die einen Vergleich auch inhaltlich zwischen Exemplaren von Kreis und FarbigerKreis zulässt.

Erste fehlerhafte Umsetzung

class Kreis { 
    public boolean equals(Object obj) { 
        if (obj instanceof Kreis) {    
            Kreis andererKreis = (Kreis) obj; 
            return this.radius == andererKreis.radius; 
        } else { 
            return false; 
        } 
    } 
} 
class FarbigerKreis { 
  public boolean equals(Object obj) { 
    if (obj instanceof FarbigerKreis) {    
        FarbigerKreis andererKreis = (FarbigerKreis) obj; 
        return (super.equals(andererKreis)   
                && this.farbe == andererKreis.farbe);   
    } 
    return false; 
  } 
}

Listing 5.31    Inkorrekte Umsetzung: Vergleich auch auf Farbe

Wir prüfen zunächst in Zeile , ob das zu vergleichende Objekt ebenfalls ein Exemplar der Klasse Kreis ist. Nur in diesem Fall führen wir den Vergleich über den Radius durch, sonst betrachten wir die beiden Objekte als ungleich.

Zwei farbige Kreise können nur gleich sein, wenn es sich bei beiden um farbige Kreise handelt (Zeile ). Außerdem müssen sie den gleichen Radius haben, geprüft über den Aufruf der Basisklasse in Zeile , und die gleiche Farbe, geprüft in Zeile .

Aber wenn Sie nun zwischen Kreisen unsere Vergleiche durchführen, sehen Sie, dass die Symmetriebedingung verletzt wird.

Die Ausgabe einer Prüfung auf die Bedingung der Symmetrie ergibt Folgendes:

KreisA und FarbigA sind gleich 
Symmetrie verletzt: FarbigA ist aber nicht gleich KreisA

Zwar klappt der Vergleich, wenn Sie die Operation equals auf einem Exemplar der Klasse Kreis aufrufen. Die verwendete Methode equals der Klasse Kreis stellt fest, dass es sich auch beim übergebenen Objekt A um ein Exemplar der Klasse Kreis handelt, die resultierende Prüfung auf Gleichheit des Radius ist erfolgreich. Wenn aber nun umgekehrt die Operation auf einem Exemplar der Klasse FarbigerKreis aufgerufen wird, wird die Methode der Klasse FarbigerKreis zum Einsatz kommen. Und bei dieser führt die Prüfung, ob es sich beim übergebenen Objekt A um ein Exemplar von FarbigerKreis handelt, zu einem negativen Ergebnis.

Zweite fehlerhafte Umsetzung

So geht es also nicht. Aber warum soll denn die Methode von FarbigerKreis gleich aufgeben, wenn ihr ein Exemplar von Kreis übergeben wird? Sie könnte in diesem Fall die Prüfung doch an die Methode der Klasse Kreis weiterreichen. In Zeile des unten stehenden Listings ist die Anpassung vorgenommen.

public boolean equals(Object obj) { 
    if (obj instanceof FarbigerKreis) { 
        FarbigerKreis andererKreis = (FarbigerKreis) obj; 
        return (super.equals(andererKreis)   
                && this.farbe == andererKreis.farbe); 
    } 
    return super.equals(obj); 
}

Listing 5.32    Vermeintliche Korrektur: nicht viel besser

Nun klappt das auch mit der Symmetriebedingung, weil die Methode equals der Klasse FarbigerKreis den Aufruf einfach an die Methode der Oberklasse Kreis weitergibt. Da dann nur noch der Radius verglichen wird, klappt dann auch der Vergleich Aber etwas anderes scheint schief zu gehen.

KreisA und FarbigA sind gleich 
FarbigA und KreisA sind gleich 
KreisA und FarbigA sind gleich, 
              KreisA und FarbigB sind gleich 
Transitivität verletzt: 
              FarbigA und FarbigB sind nicht gleich

Wir haben ein anderes Problem eingebaut. Jetzt gilt zwar unsere Symmetriebedingung. Nun liefert aber jeder Vergleich eines Kreises mit jedem beliebigen anderen farbigen Kreis true, sofern dieser den gleichen Radius hat. Wenn die betreffenden farbigen Kreise aber unterschiedliche Farben haben, wie in unserem Beispiel, sind diese natürlich nicht gleich. Die Transitivitätsbedingung ist ganz klar verletzt.

Zurück auf den Anfang

Offensichtlich führt unser Versuch, Kreise und farbige Kreise in allen Situationen vergleichbar zu halten, zu dem Problem. Deshalb sind diese Umsetzungen nicht korrekt. Damit sind die am Anfang des Abschnitts vorgestellten beiden Varianten vorzuziehen, da diese die Semantik der Gleichheitsoperation korrekt umsetzen.



Ihre Meinung

Wie hat Ihnen das Openbook gefallen? Wir freuen uns immer über Ihre Rückmeldung. Schreiben Sie uns gerne Ihr Feedback als E-Mail an kommunikation@rheinwerk-verlag.de.

 <<   zurück
  Zum Rheinwerk-Shop
Neuauflage: Objektorientierte Programmierung






Neuauflage:
Objektorientierte Programmierung

Jetzt Buch bestellen


 Ihre Meinung?
Wie hat Ihnen das Openbook gefallen?
Ihre Meinung

 Buchempfehlungen
Zum Rheinwerk-Shop: Java ist auch eine Insel






 Java ist auch
 eine Insel


Zum Rheinwerk-Shop: Schrödinger programmiert C++






 Schrödinger
 programmiert C++


Zum Rheinwerk-Shop: C++ Handbuch






 C++ Handbuch


Zum Rheinwerk-Shop: Einstieg in Python






 Einstieg in Python


Zum Rheinwerk-Shop: IT-Handbuch für Fachinformatiker






 IT-Handbuch für
 Fachinformatiker


 Lieferung
Versandkostenfrei bestellen in Deutschland, Österreich und der Schweiz
InfoInfo




Copyright © Rheinwerk Verlag GmbH 2009
Für Ihren privaten Gebrauch dürfen Sie die Online-Version natürlich ausdrucken. Ansonsten unterliegt das Openbook denselben Bestimmungen, wie die gebundene Ausgabe: Das Werk einschließlich aller seiner Teile ist urheberrechtlich geschützt.
Alle Rechte vorbehalten einschließlich der Vervielfältigung, Übersetzung, Mikroverfilmung sowie Einspeicherung und Verarbeitung in elektronischen Systemen.


Nutzungsbestimmungen | Datenschutz | Impressum

Rheinwerk Verlag GmbH, Rheinwerkallee 4, 53227 Bonn, Tel.: 0228.42150.0, Fax 0228.42150.77, service@rheinwerk-verlag.de

Cookie-Einstellungen ändern