![]() |
![]() |
|
In Abbildung 5.57 ist eine Lösung für unser Problem des beobachtbaren HTML-Dokuments vorgestellt, die auf Delegation basiert. Bei diesem Design deklarieren wir die Klasse Beobachtbares als eine explizite Schnittstelle und lassen die Klasse BeobachtbaresHtmlDokument 2 diese Schnittstelle implementieren, welche zudem die Funktionalität von HTMLDokument 1 erbt. Die wirkliche Umsetzung der Methoden der Klasse Beobachtbares, die ja nicht nur für die HTML-Dokumente gebraucht werden kann, stellt die Klasse BeobachtbaresImplementierung 4 bereit. Jedes Exemplar der Klasse BeobachtbaresHtmlDokument besitzt ein Exemplar der Klasse BeobachtbaresImplementierung, auf das es alle Aufrufe der in der Schnittstelle Beobachtbares deklarierten Methoden weiterleitet – delegiert (siehe Markierung 3). Dieses Hilfsobjekt übernimmt also die komplette Verwaltung und Benachrichtigung der Beobachter.
Die Vorgehensweise, die Implementierung bestimmter Methoden einem Delegaten zu überlassen, löst das Problem der fehlenden Vererbungsmöglichkeit und hat auch noch andere Vorteile. Eine Implementierung einer Methode einer Klasse ist in Java und C# nicht während der Laufzeit der Anwendung änderbar, alle direkten Exemplare einer implementierenden Klasse haben für eine Operation dieselbe Methode. Diese Einschränkung gilt nicht für die Delegation. Austausch von Delegaten Verschiedene Objekte, auch wenn sie direkt zu einer und derselben Klasse gehören, können die Aufrufe an unterschiedliche Hilfsobjekte delegieren und diese sogar zur Laufzeit ändern. Diese Tatsache macht man sich auch beim Entwurfsmuster Strategie zunutze, dem wir uns in Abschnitt 5.5.2 widmen werden. Ein Nachteil der Delegation besteht darin, dass die Klassen, die sie verwenden, um bestimmte Schnittstellen zu implementieren, immer noch eine eigene Rumpfimplementierung der Schnittstelle besitzen müssen. Auch wenn jede der Methoden nur aus einem einzigen Aufruf der entsprechenden Methode des Delegaten besteht, sie muss vorhanden sein. Einige Programmiersprachen bieten hier allerdings weitergehende Unterstützung. In Ruby ist es zum Beispiel möglich, alle Aufrufe von Operationen an ein Hilfsobjekt weiterzuleiten, wenn diese von einem Objekt nicht selbst umgesetzt werden. Sofern die von Ihnen verwendete Programmiersprache den Mechanismus von Mixins unterstützt, bieten diese eine Alternative, die in vielen Fällen mit weniger Quelltext umzusetzen ist. 5.4.3 Mixin-Module statt Mehrfachvererbung
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Mixin |
|
Mixins werden Module genannt, die existierende Klassen um Datenelemente und Methoden erweitern, ohne dass eine Subklasse dieser existierenden Klasse erstellt werden muss. Beispiele für Programmiersprachen, die eine solche Erweiterung zulassen, sind Ruby, Python oder CLOS (Common Lisp Object System). Allerdings werden Mixins von den verbreiteten objektorientierten Sprachen wie Java und C# zumindest in den aktuellen Versionen nicht unterstützt. In C# sind die Mixins für die Version 3 vorgesehen, für Folgeversionen von Java ist eine bessere Unterstützung der Delegation geplant. Wenn ein Mixin-Modul eine existierende Klasse erweitert, werden wir das in Ermangelung eines besseren deutschen Wortes als das Reinmischen des Moduls bezeichnen. |
Bei dem Problem der fehlenden Vererbungsmöglichkeit können Ihnen die Mixins helfen. Statt der Delegation an eine Hilfsklasse können Sie unter Verwendung von Mixins ein Modul implementieren, das die Methoden für die Operationen der Schnittstelle implementiert.
Wenn Sie dieses Modul als ein Mixin zu den implementierenden Klassen hinzufügen, ist Ihre Delegationsabsicht klar und explizit formuliert. Und wenn sich die Schnittstelle ändern sollte, müssen Sie auch nur das Mixin-Modul anpassen.
In der Sprache Ruby gehört das Konzept der Mixins zu einem der Schlüsselfeatures der Programmiersprache. Wir stellen deshalb die Verwendung von Mixins anhand von Ruby vor. In Ruby kann ein Modul in eine Klasse mit dem Statement include eingefügt werden. Somit werden alle Routinen, die in diesem Modul definiert werden, zu Methoden der Klasse, in die wir das Modul reinmischen. Die reingemischten Methoden enthalten dabei den Zugriff auf alle anderen Methoden der Klasse, seien es Methoden, die in der Klasse direkt definiert worden sind, von der Oberklasse geerbt oder von anderen Modulen reingemischt wurden.
Mehrere Mixins mit gleichen Methoden
In Ruby erhält eine Klasse dabei pro Methodennamen immer nur eine Methode. Die zuletzt »reingemischte« Methode ersetzt die vorher reingemischte gleichnamige Methode. In Abbildung 5.58 ist dargestellt, wie Ruby auch in Mixin-Modulen nach Methoden sucht, wenn eine Operation auf einem Objekt aufgerufen wird.

Hier klicken, um das Bild zu vergrößern
In Ruby ist es kein Fehler, in einer Klasse mehrere Methoden mit dem gleichen Namen zu deklarieren. Die zuletzt deklarierte Methode ersetzt immer die zuvor deklarierte Methode des gleichen Namens. Mixins können aber existierende Methoden von Klassen nicht überschreiben, sondern lediglich neue Methoden hinzufügen. Klassen werden deshalb durch Mixin-Module erweitert. Dies unterscheidet den Mechanismus klar von der Vererbung.
Wenn in Ruby eine Klasse oder ein Modul erweitert wird, wird automatisch die Funktionalität aller ihrer Exemplare erweitert! Dies funktioniert auch mit Mixins. Wenn Sie also nachträglich einem Mixin-Modul eine neue Methode hinzufügen, erhalten alle Klassen, in die Sie dieses Mixin importieren, diese Methode ebenfalls. Somit kann die Methode sofort von allen ihren Exemplaren verwendet werden.
Aspektorientierte Fähigkeiten von Ruby
Verzeihen Sie den Enthusiasmus und die etwas angestrengt jugendliche Sprache an dieser Stelle, aber dieses Feature ist richtig cool. Nicht nur weil sich dynamisch selbst modifizierender Code nützlich sein kann, sondern weil die Fähigkeit, bereits vorhandene Klassen zu erweitern oder sie zu ändern, eine wichtige Fähigkeit der aspektorientierten Programmierung ist. Weitere Anwendungen dafür werden wir deshalb auch in Kapitel 9, Aspekte und Objektorientierung, vorstellen.
Ein den Mixins ähnlicher Mechanismus kann auch in der Sprache C++ umgesetzt werden.
Den Mixins kommt nämlich in C++ die private Ableitung von einer Oberklasse sehr nahe. Durch die private Vererbung erhält die Klasse alle öffentlichen und geschützten Methoden und Datenelemente der als Mixin agierenden Oberklasse, ohne jedoch »von außen betrachtet« ihre Unterklasse zu werden. Den Mechanismus der privaten Vererbung in C++ haben Sie bereits in Abschnitt 5.1.6, Sichtbarkeit im Rahmen der Vererbung, kennen gelernt.
Wenden wir uns nun im folgenden Abschnitt noch einem anderen Aspekt der Mehrfachvererbung zu.
Eine Programmiersprache, die Mehrfachvererbung unterstützt, muss drei Fragen dazu beantworten können. Es können sich verschiedene Situationen ergeben, in denen der Umgang mit Datenstrukturen, Operationen und Methoden nicht eindeutig geklärt ist. Eine Programmiersprache, die Mehrfachvererbung unterstützt, muss für diese Fragen eine Strategie vorliegen haben.
In Abbildung 5.59 sind die folgenden drei Fragen an einem Beispiel illustriert.
| Frage 1: Wie werden Operationen mit gleicher Signatur behandelt, die in verschiedenen Oberklassen deklariert werden (siehe Markierung 3)? |
| Frage 2: Wenn mehrere Oberklassen dieselbe Operation mit unterschiedlichen Methoden umsetzen, welche Methode wird beim Aufruf der Operation an einem Exemplar der gemeinsamen Unterklasse aufgerufen (siehe Markierung 3)? |
| Frage 3: Wie werden Datenstrukturen kombiniert? An Markierung 1 werden die geerbeten Operationen und Methoden dargestellt. Wenn mehrere Oberklassen die Datenstruktur der Exemplare beschreiben, wie sieht die kombinierte Datenstruktur der Exemplare der gemeinsamen Unterklasse aus (siehe Markierung 2)? |

Hier klicken, um das Bild zu vergrößern
In Tabelle 1 ist in der Übersicht dargestellt, wie die als Beispiel gewählten Sprachen Java, C#, Python und C++ bei diesen Fragen vorgehen. Java und C# umgehen die letzten beiden Fragen dadurch, dass sie nur die Mehrfachvererbung der Spezifikation erlauben. Damit müssen die beiden Sprachen lediglich eine Antwort auf die erste Frage liefern.
| Java | C# | Python | C++ | |
| Frage 1 | Verschmelzung von Operationen | Mehrere Operationen | Eine Operation | Verschmelzung von Operationen |
| Frage 2 | Nicht relevant | Nicht relevant | Diamantenregel | Expliziter Klassenname |
| Frage 3 | Nicht relevant | Nicht relevant | Nicht relevant | Wahlweise |
In den folgenden Abschnitten schauen wir uns das Vorgehen der Sprachen bei der Beantwortung der Fragen etwas genauer an. Anhand von Beispielen werden wir dabei illustrieren, welche Probleme bei der Mehrfachvererbung entstehen können und welche Mittel uns die verschiedenen Sprachen bieten, diese zu lösen.
Die Mehrfachvererbung der Spezifikation ist sowohl konzeptionell als auch in der Umsetzung einfacher als die Mehrfachvererbung der Implementierung. Aus diesem Grund verzichten die Programmiersprachen Java und C# zum Beispiel komplett auf die Mehrfachvererbung der Implementierung.
Da Java und C# statisch typisiert sind, muss jede Klasse, auch wenn sie selbst keine Implementierung bereitstellt, deklariert werden. Aus diesem Grund unterscheidet man in Java und C# zwischen zwei Arten von Klassen:
| Klassen, die eine Implementierung ihrer Spezifikation enthalten können, die sowohl in Java als auch in C# mit dem Schlüsselwort class deklariert und daher einfach nur »Klasse« genannt werden. |
| Klassen, die nur die Spezifikation ihrer Schnittstelle enthalten dürfen. Sie werden einfach nur »Schnittstellen« genannt und mit dem Schlüsselwort interface deklariert. |
Implizite und explizite Schnittstellen
Auch wenn man in der Umgangssprache in Java und C# vereinfacht zwischen Klassen und Schnittstellen unterscheidet, sollten Sie sich immer der Tatsache bewusst sein, dass auch die explizit als solche deklarierten Schnittstellen aus der Sicht der Objektorientierung Klassen sind und die Klassen auch eine implizite Schnittstelle deklarieren, die aus allen ihren öffentlichen Operationen besteht.
In Java und C# ist die Mehrfachvererbung der expliziten Schnittstellen erlaubt, die Mehrfachvererbung der implementierenden Klassen jedoch nicht. Eine explizite Schnittstelle kann von mehreren anderen expliziten Schnittstellen erben, und eine implementierende Klasse kann mehrere Schnittstellen implementieren. Eine implementierende Klasse kann jedoch nur von einer implementierenden Klasse erben.
Mehrere Oberklassen deklarieren gleiche Operation.
Wenn mehrere Schnittstellen in Java eine Operation mit der gleichen Signatur deklarieren, gelten alle diese Deklarationen in der gemeinsamen Ableitung als eine Deklaration derselben Operation. In Java kann also eine Klasse nicht mehrere Methoden mit derselben Signatur implementieren.3
Da der Rückgabetyp einer Operation nicht zu deren Signatur gehört, kann in Java eine Klasse nicht ohne weiteres zwei Schnittstellen implementieren, die eine Operation mit derselben Signatur aber unterschiedlichen Rückgabetypen deklarieren.
In den alten Java-Versionen bis 1.4 ist es immer ein Fehler, wenn eine Klasse zwei Schnittstellen mit einer Operation mit derselben Signatur, aber unterschiedlichen Rückgabetypen zu implementieren versucht. Denn bis zur Version 1.4 kann eine Methode den Rückgabewert der Methode, die sie überschreibt, beziehungsweise der Operation, die sie implementiert, nicht ändern.
Abbildung 5.60 zeigt ein Beispiel, in dem die Datenstrukturen nur von einer Klasse geerbt werden (siehe Markierung 1). Die Implementierung von operationA wird nur von BasisklasseA geerbt (siehe Markierung 2). Es existiert zudem nur eine operationY 3, ihr Rückgabetyp ist mit beiden geerbten Operationen kompatibel (kovariante Rückgabetypen werden ab Java 5 unterstützt). Die Klasse AbgeleiteteKlasse 4 hat drei verschiedene inkompatible Rückgabewertspezifikationen für operationZ geerbt. Dies ist in Java nicht erlaubt.
Ab Java 5: kovariante Rückgabetypen
Ab der Version 5 kann eine Methode einen Rückgabetyp deklarieren, der mit dem ursprünglichen Rückgabetyp kovariant ist.

Hier klicken, um das Bild zu vergrößern
| Kovariante Typen |
|
Der Typ T2 ist dem Typ T1 kovariant, wenn alle Exemplare von T2 gleichzeitig Exemplare von T1 sind. Einfacher gesagt, T2 muss entweder T1 oder sein Untertyp sein. |
Obwohl in unserem Beispiel die AbgeleiteteKlasse einen anderen Rückgabetyp der operationY deklariert als den, der in der SchnittstelleB beziehungsweise in der SchnittstelleC deklariert ist, handelt es sich nicht um eine Verletzung des Prinzips der Ersetzbarkeit. Denn die Deklaration der SchnittstelleB besagt, dass die operationY angewendet auf jedes Exemplar der SchnittstelleB ein Exemplar der Klasse/Schnittstelle SchnittstelleB oder null zurückgibt. Die Implementierung in der Klasse AbgeleiteteKlasse sorgt dafür, dass operationY angewendet auf jedes ihrer Exemplare ein Exemplar der Klasse AbgeleiteteKlasse zurückgibt. Da die AbgeleiteteKlasse aber eine Unterklasse der SchnittstelleB ist, ist das Prinzip der Ersetzbarkeit nicht verletzt.
Da in unserem Beispiel die AbgeleiteteKlasse sowohl die SchnittstelleB als auch die SchnittstelleC implementiert, ist der Typ AbgeleiteteKlasse sowohl mit dem Typ SchnittstelleB als auch mit dem Typ SchnittstelleC kovariant.
Wenn in C# mehrere geerbte Schnittstellen eine Operation mit der gleichen Signatur deklarieren, enthält in C# die erbende Schnittstelle mehrere Operationen mit gleichem Namen und gleicher Signatur. Dies ist ein Unterschied zu Java.
Gleiche Signatur
Unabhängig davon ob diese Operationen gleiche oder unterschiedliche, kovariante oder nicht kovariante Rückgabewerttypen haben, es sind unterschiedliche Operationen.
Beim Aufruf meldet C# einen Mehrdeutigkeitsfehler, wenn die Signatur des Aufrufes die aufzurufende Methode nicht eindeutig bestimmt. Man kann diese Mehrdeutigkeit des Aufrufes auch durch explizite Typumwandlung des Objekts auf eine der Oberschnittstellen beseitigen.
Wenn eine Schnittstelle in C# eine Operation deklariert, welche die gleiche Signatur hat wie eine geerbte Operation, wird eine neue Operation deklariert, welche die geerbte Operation verdeckt. Eine solche verdeckende Deklaration sollte mit dem Schlüsselwort new gekennzeichnet werden.
Schnittstellen in C#
In Abbildung 5.61 wird die Implementierung von operationA() nur von BasisklasseA geerbt. Für die operationA aus der SchnittstelleB kann, muss aber nicht eine eigene Methode bereitgestellt werden (siehe Markierung 2). Jeder der geerbten Operationen mit unterschiedlichen Rückgabetypen an Markierung 3 muss in einer konkreten Klasse eine eigene Methode zugewiesen werden. Welche Methode aufgerufen wird, hängt vom Typ der Variablen ab, mit der auf das Objekt zugegriffen wird.

Hier klicken, um das Bild zu vergrößern
Die Datenstrukturen (siehe Markierung 1) in diesem Beispiel werden hingegen nur von einer Klasse geerbt.
new und override
Genauso wie die Schnittstellen mehrere Operationen mit derselben Signatur haben können, können Klassen mehrere Methoden mit derselben Signatur haben. Wie bereits oben beschrieben, kann eine Klasse in C# mit dem Schlüsselwort new durch eine neue Methode mit gleicher Signatur eine geerbte Methode verdecken, oder sie kann sie mit dem Schlüsselwort override überschreiben.
Wie sieht es aber mit der Implementierung der Schnittstellen aus, wenn diese mehrere Operationen mit der gleichen Signatur enthalten? Hier bietet C# zwei Möglichkeiten an:
Angabe der Schnittstelle
Gibt man vor dem Namen der Methode den Namen der Schnittstelle an, in der sie deklariert wurde, implementiert die Methode nur diese Operation. Zusätzlich können wir aber auch eine allgemeine Methode ohne Angabe der konkreten Schnittstelle umsetzen.4 Diese wird dann in allen Fällen aufgerufen, in denen es keine anwendbare schnittstellenspezifische Methode gibt.
Mehrere Klassen implementieren dieselbe Operation.
C++ verhält sich sehr pragmatisch bei der Frage, was bei Mehrdeutigkeiten in Bezug auf den Aufruf von Methoden zu tun ist. Entweder ist der Aufruf einer Methode eindeutig, oder es handelt sich um einen Uneindeutigkeitsfehler. Wenn eine Klasse von mehreren Oberklassen Methoden mit derselben Signatur erbt, muss man beim Aufruf einer der Methoden den gemeinten Typ des aufgerufenen Objekts explizit bestimmen.
Die aufzurufende Methode lässt sich also in den betrachteten Fällen immer bestimmen.

Hier klicken, um das Bild zu vergrößern
In der Abbildung 5.62 sind an Markierung 1 nur neu hinzugefügte Datenelemente dargestellt. An Markierung 2 sehen Sie, dass nur die überschriebenen und neuen Methoden dargestellt werden. In dem abgebildeten Beispiel enthält ein Exemplar der Klasse abgeleiteteKlasse alle geerbten Datenelemente, zwei davon heißen datumC (siehe Markierung 3 und 4). Bei einem Zugriff auf datumC muss eindeutig klar sein, welches Element gemeint ist, sonst ist es ein Fehler.
Werden zwei Operationen mit gleicher Signatur von zwei verschiedenen Klassen geerbt, so gelten ähnliche Regeln wie in Java. Es muss nur eine Methode für die Implementierung beider Operationen umgesetzt werden.
Rückgabetypen kovariant?
Ob die Rückgabetypen der Methoden gleich oder nur kovariant sein müssen, hängt hier von dem verwendeten Compiler ab. Der aktuelle C++-Standard erlaubt zwar kovariante Rückgabetypen, nicht alle Compiler halten sich allerdings an diese Vorgabe. Manche unterstützen sie überhaupt nicht, manche nur teilweise.
In Python wird eine Operation ausschließlich über ihren Namen identifiziert. Wenn eine Klasse von mehreren Klassen mehrere Methoden mit demselben Namen geerbt hat, unterstützt sie trotzdem nur eine Operation mit diesem Namen.
Die spannende Frage ist, welche der geerbten Methoden ausgeführt wird, wenn die Operation auf einem Exemplar der abgeleiteten Klasse aufgerufen wird.
Suchreihenfolge für Methoden
In den älteren Versionen von Python (bis zur Version 2.1) gilt für die Bestimmung der implementierenden Methode die Regel »Tiefensuche, von links nach rechts«. Das bedeutet, dass die Methode in den geerbten Oberklassen von links nach rechts gesucht wird, wobei bei jeder Klasse auch deren Oberklassen untersucht werden.
Im folgenden Bild illustrieren wir die Reihenfolge, in der eine Methode bei einem Aufruf einer Operation gesucht wird:

Hier klicken, um das Bild zu vergrößern
Prinzip der kleinstmöglichen Überraschung
Python verfolgt löblicherweise das Prinzip der kleinstmöglichen Überraschung. Wäre es also nicht angebracht, statt einer Tiefensuche eher die Breitensuche zu verwenden? Das heißt, zuerst alle direkt geerbten Klassen zu untersuchen, dann erst deren direkte Oberklassen und so weiter? Wäre die Suchreihenfolge AbgleiteteKlasse, BasisklasseB, BasisklasseE nicht weniger überraschend? Immerhin ist die Klasse BasisklasseE in der Vererbungshierarchie näher an der AbgleiteteKlasse als die Klasse BasisklasseA.
Doch wenn wir das Prinzip der Kapselung beachten, muss für uns unwichtig sein, ob die Klasse BasisklasseB eine Methode selbst implementiert oder ob sie die Methode von einer ihrer Oberklassen geerbt hat. Aus diesem Grund ist die von Python gewählte Suchstrategie verständlich.
In Python 2.2 wurde die Suchstrategie jedoch geändert.
Die Zulassung der Mehrfachvererbung führt automatisch dazu, dass eine Klasse in der Vererbungshierarchie mehrfach auftreten kann. Was passiert, wenn eine Klasse zwei Oberklassen hat, die von derselben Klasse eine Methode erben, und eine der Oberklasse diese Methode überschreibt?
Diamantenregel
Wenn eine Klasse in der Vererbungshierarchie mehrfach auftritt, kann man das Klassendiagramm in Form einer Raute (englisch auch Diamond) darstellen. Daher der Name der neuen Suchvorgehensweise in Python 2.2: die Diamantenregel.5
Nach der Diamantenregel wird die Suchreihenfolge genau wie in den alten Versionen bestimmt. Doch anstatt sofort mit der Suche anzufangen, werden aus der Suchreihenfolge zuerst die Duplikate entfernt. Erhalten bleibt immer nur der letzte Eintrag einer Klasse. Wird also eine geerbte Methode durch eine der Oberklassen überschrieben, wird diese überschriebene Variante gefunden, auch wenn die ursprüngliche Implementierung in einer Basisklasse »weiter links« in der Vererbungshierarchie zu finden wäre.

Hier klicken, um das Bild zu vergrößern
Sollten Sie mit der Suchmethode von Python nicht zufrieden sein, haben Sie immer die Möglichkeit, die Methode in der abgeleiteten Klasse selbst zu implementieren und den Aufruf auf die gewünschte Implementierung umzuleiten.
Klassen erben von ihren Oberklassen nicht nur die Spezifikation der Operationen und die Implementierung der Methoden, sondern auch die Definition der Datenelemente der Oberklasse. Wie sieht die Datenstruktur einer solchen von mehreren Oberklassen abgeleiteten Klasse in Phython aus?
In Python werden die Datenstrukturen der Exemplare einer Klasse nicht explizit durch die Klasse vorgegeben.
Zusammenführen der Datenelemente
Die Daten jedes Objekts werden in Python dem Objekt dynamisch zur Laufzeit zugeordnet, und unterschiedliche Exemplare derselben Klasse können durchaus unterschiedliche Attribute besitzen. Wenn mehrere Oberklassen ein Datenelement mit demselben Namen initialisieren, besitzen die Exemplare der abgeleiteten Klasse nur genau ein Datenelement mit diesem Namen. Das Datenelement enthält den letzten ihm zugewiesenen Wert. Da die Sprachen Python dynamisch typisiert ist und Variablen und Datenelemente keine deklarierten Typen haben, kann man jedem Datenelement jeden beliebigen Wert zuordnen.
Initialisierung von Daten
Wenn also die Klasse A in ihrer Initialisierungsroutine die Datenelemente x und y dem initialisierten Exemplar zuordnet, und die Klasse B die Datenelemente y und z, und wenn die Klasse C von A und B abgeleitet ist, können wir keine Aussage über die Attribute der initialisierten Exemplare von C treffen, ohne uns die Initialisierungsroutine der Klasse C selbst anzuschauen – denn hier gilt die einfache Regel – das Datenelement wird den ihm zuletzt zugewiesenen Wert haben.
Namenskonflikte
Um eventuelle Namenskonflikte zu vermeiden, kann man in Python den Namen eines Datenexemplars mit einem doppelten Unterstrich anfangen lassen. Dies veranlasst Python, intern dem Namen des Datenattributes noch den Namen der deklarierenden Klasse hinzuzufügen. Damit wird ein Namenskonflikt zwar nicht ganz ausgeschlossen, aber die Gefahr wird erheblich reduziert.
Kombination von Datenstrukturen
Die Klassen in C++ enthalten die Deklaration aller ihrer Datenelemente und bestimmen so die für die Speicherung der Daten benötigten Datenstrukturen. Historisch betrachtet sind die Klassen in C++ eine Weiterentwicklung der Strukturen von C.
Ähnlich wie bei der einfachen Vererbung setzt sich die Datenstruktur der Exemplare der abgeleiteten Klasse aus den Datenstrukturen der Oberklassen und den Einträgen, welche die abgeleitete Klasse selbst deklariert, zusammen. Die Datenstrukturen der verschiedenen Klassen in der Vererbungshierarchie werden im Speicher einfach hintereinander gehängt.
Besitzen also mehrere Oberklassen einen Dateneintrag mit dem gleichen Namen, so werden die Exemplare von deren gemeinsamen Ableitungen mehrere Dateneinträge mit denselben Namen besitzen. Die Datentypen der Exemplare brauchen dabei nicht gleich zu sein.
Doch wenn die unterschiedlichen Dateneinträge denselben Namen haben, wie wird auf diese Dateneinträge zugegriffen? Welcher der Dateneinträge wird verwendet, wenn man den Namen benutzt?
Wenn es bei einem Zugriff nicht eindeutig ist, welcher Dateneintrag gemeint ist, meldet ein C++-Compiler einen Fehler. Als Programmierer haben wir aber die Möglichkeit, die Eindeutigkeit wieder herzustellen, indem explizit die Klasse angegeben wird, aus welcher der Eintrag verwendet werden soll.
Illustrieren wir diesen etwas trockenen Sachverhalt am besten an einem Beispiel. In Abbildung 5.65 wird der Dateneintrag x von zwei Basisklassen geerbt.

Hier klicken, um das Bild zu vergrößern
Zuordnung eines Datenelements
Dadurch hat jedes Exemplar der Klasse C drei Dateneinträge mit dem Namen x. Dennoch besteht hier keine Gefahr der Uneindeutigkeit, weil der Eintrag x in der Klasse C die geerbten Einträge verdeckt. Daher kann man in den Methoden der Klasse C einfach den Bezeichner x oder in Methoden anderer Klassen den Ausdruck pC->x verwenden. Die Elemente, die in den Klassen A und B deklariert sind, können über den Typ C gar nicht referenziert werden. Um sie verwenden zu können, müssen wir einen Zeiger vom Typ A* bzw. B* verwenden.
Uneindeutigkeit
Wenn die Klasse C aber keinen eigenen Eintrag mit dem Namen x hätte, wie in Abbildung 5.66, so wäre der Aufruf pC->x nicht eindeutig und somit nicht zulässig.

Hier klicken, um das Bild zu vergrößern
Namenskonflikte werden also vom Compiler entdeckt und müssen vom Programmierer durch eine explizite Typkonvertierung aufgelöst werden.
Es gibt allerdings noch eine weitere Situation, in der die Entscheidung über ein Datenelement Fragen aufwirft: Wenn eine Oberklasse in der Vererbungshierarchie einer Unterklasse mehrfach vorkommt, enthalten die Exemplare der Unterklasse die Datenstruktur der Oberklasse mehrfach oder nur einmal? In Abbildung 5.67 ist diese Situation dargestellt: Die Klasse AbgeleiteteKlasse erbt den Dateneintrag datumX gleich zweimal von der Klasse BasisklasseA.

Hier klicken, um das Bild zu vergrößern
In diesem Beispiel ist jedes Exemplar der Klasse BasisklasseB gleichzeitig ein Exemplar der Klasse BasisklasseA und hat also die Daten eines Exemplars von der BasisklasseA. Das Gleiche gilt für die BasisklasseC. Da ein Exemplar der Klasse AbgeleiteteKlasse gleichzeitig ein Exemplar der BasisklasseB und der BasisklasseC ist, also deren Daten besitzt, besitzt es zwei Datensätze der Klasse BasisklasseA.
Das Object4 ist ein Exemplar der Klasse AbgeleiteteKlasse. Da die AbgeleiteteKlasse indirekt eine Unterklasse der BasisklasseA ist, ist das Object4 gleichzeitig ein Exemplar der BasisklasseA. Doch welcher der zwei Datensätze datumX ist gemeint, wenn man das Objekt als ein Exemplar der BasisklasseA betrachten möchte?
Diese Frage ist nicht eindeutig zu beantworten, und aus diesem Grund würde der Compiler einen Mehrdeutigkeitsfehler melden, wenn wir das Object4 einer Variablen des Typs BasisklasseA zuweisen wollten.
Eindeutigkeit über static_cast
Um dem Compiler zu helfen, müssen wir bei der Typumwandlung von AbgeleiteteKlasse* zu BasisklasseA* immer den Weg in der Vererbungshierarchie eindeutig spezifizieren und den Typ des Zeigers immer zuerst explizit zu BasisklasseB* oder BasisklasseC* umwandeln. Über den Aufruf von static_cast können wir diese Zuordnung eindeutig herstellen.
Problematisch ist hier einzig die Tatsache, dass hier die Konzepte der Vererbung (der Ist-Beziehung) und der Komposition (der Besteht-aus-Beziehung) durcheinander gebracht sind. Doch was schert sich der Compiler um konzeptionelle Schwierigkeiten von Softwareentwicklern?
| Ersetzung der Mehrfachvererbung durch Komposition |
|
Eine alternative und unproblematische Umsetzung des Beispiels aus Abbildung 5.67 können Sie erstellen, indem Sie statt der Vererbung explizit die Komposition verwenden. struct ZusammengesetzteKlasse { BasisklasseB b; BasisklasseC c; };In diesem Beispiel ist ein Exemplar der Klasse ZusammengesetzteKlasse kein Exemplar der BasisklasseB oder der BasisklasseC, sondern ein Exemplar der Klasse ZusammengesetzteKlasse. Sie besteht aus einem Exemplar der BasisklasseB und einem Exemplar der BasisklasseC, die jeweils ein Exemplar der BasisklasseA sind. |
Manchmal kann es für uns aber wichtig sein, dass jedes Exemplar der abgeleiteten Klasse sich eindeutig als ein Exemplar der in der Klassenhierarchie mehrfach vorkommenden Oberklasse betrachten lässt. Dies bietet in C++ die so genannte virtuelle Vererbung.
| Virtuelle Vererbung |
|
Wenn eine BasisklasseB von der BasisklasseA virtuell abgeleitet ist, bedeutet das, dass die Exemplare der BasisklasseB zwar alle Dateneinträge der Oberklasse BasisklasseA besitzen, allerdings nicht exklusiv. Wenn auch die BasisklasseC virtuell von der BasisklasseA abgeleitet ist und die AbgeleiteteKlasse sowohl von der BasisklasseB als auch von der BasisklasseC abgeleitet ist, so enthalten die Datenstrukturen der Exemplare der Klasse AbgeleiteteKlasse nur einen Satz der Einträge der BasisklasseA. Die in der BasisklasseB und in der BasisklasseC implementierten Methoden teilen sich diesen Datensatz. |
Abbildung 5.68 zeigt den Effekt von virtueller Vererbung in der Übersicht.
Exemplare der Klasse AbgeleiteteKlasse enthalten nun aufgrund der virtuellen Vererbung das Datenelement datumX nur einmal.
Dies entspricht grob dem Verhalten von Python: Zwar hat jede Klasse der Klassenhierarchie eigene Methoden, sie teilen aber eine gemeinsame Datenstruktur.
In diesem Beispiel enthält ein Exemplar der Klasse AbgeleiteteKlasse nur einen Datensatz der BasisklasseA, obwohl die Klasse BasisklasseA über zwei Vererbungspfade erreichbar ist. Daher kann das Object4 es eindeutig als ein Exemplar der BasisklasseA betrachtet werden, und man kann seinen Dateneintrag datumX direkt verwenden, weil der Name datumX diesen Dateneintrag eindeutig bestimmt.

Hier klicken, um das Bild zu vergrößern
Diskussion: Virtuelle Vererbung
Bernhard: Hör mal, Du machst hier so trockene Beispiele mit ABC, kannst du dir nicht etwas Realistischeres ausdenken?
Gregor: O.k., ich versuche es. Stellen wir uns die Klasse der Transportmittel vor. Jedes Transportmittel wird eine Bezeichnung haben. Bei Schiffen wird es der Name des Schiffes sein, bei Autos das Kennzeichen, bei Fahrrädern die Seriennummer des Rahmens.
Jetzt unterteilen wir die Klasse Transportmittel anhand des Kriteriums »Motorisierung« in zwei Unterklassen: Motorisierte Transportmittel (mit dem Dateneintrag »Leistung«) und unmotorisierte Transportmittel. Anhand des Kriteriums »Bereifung« unterteilen wir die Transportmittel in bereifte Transportmittel (mit dem Dateneintrag »Reifendruck«) und reifenlose Transportmittel. Schließlich leiten wir von den Oberklassen motorisierte Transportmittel und bereifte Transportmittel die Klasse Auto ab. Ich habe dir das Szenario übrigens in Abbildung 5.69 aufgezeichnet.

Hier klicken, um das Bild zu vergrößern
Ein Auto hat also einen Dateneintrag über die Leistung des Motors, definiert in der Klasse motorisierte Transportmittel. Es hat auch einen Dateneintrag für den Reifendruck, definiert in der Klasse bereifte Transportmittel. Außerdem hat ein Auto, da es ein Transportmittel ist, auch einen Namen. Einen oder zwei? Da wir hier nur einen Namen brauchen, würden wir in diesem Falle in C++ die virtuelle Vererbung verwenden.
Bernhard: Hör mal, dieses Beispiel ist zwar nicht so trocken, aber es ist schon ziemlich … Wie soll ich es sagen, ohne Dich zu beleidigen …?
Gregor: Ja gut, du hast schon Recht. Das Beispiel ist nicht besonders realistisch. Ich muss zugeben, dass ich noch nie in der Praxis die virtuelle Vererbung in C++ gebraucht habe, weil die Klassenhierarchien nie tief und komplex genug waren und ich in vielen Fällen die Komposition gegenüber der Vererbung bevorzuge.
Wenn wir überhaupt überlegen müssen, ob eine Klasse virtuell oder nicht virtuell erben soll, sollten wir stattdessen zunächst überlegen, ob die Klassenhierarchie nicht zu kompliziert ist.
1 Im Abschnitt 8.2, Die Präsentationsschicht: Model, View, Controller (MVC), werden wir das MVC-Muster vorstellen, durch das die Interaktion zwischen den dargestellten und den darstellenden Komponenten weiter strukturiert wird.
2 Klassenerweiterungen (Mixins) erläutern wir in Abschnitt 5.4.3, Mixin-Module statt Mehrfachvererbung.
3 Zumindest nicht in der Programmiersprache Java. In dem, vom Compiler generierten, Bytecode kann in der Tat eine Klasse mehrere Methoden mit derselben Signatur besitzen. Dies ist allerdings eher ein Implementierungsdetail der Generics in der Version 5 von Java als ein Feature der Programmiersprache.
4 Diese Methode muss allerdings mit Sichtbarkeitsstufe public deklariert werden.
5 Um die Kompatibilität mit älteren Versionen zu gewährleisten, unterscheidet man in Python ab der Version 2.2 zwischen zwei Arten von Klassen. Für die »alten« Klassen werden die geerbten Methoden auch in den neueren Python-Versionen mit der Tiefensuche gefunden. Die Diamantenregel gilt nur für die »neuen« Klassen, die explizit von der Klasse object erben müssen.
| << zurück |
|
||||||||||||||
|
||||||||||||||
|
||||||||||||||
|
||||||||||||||
Copyright © Galileo Press 2006
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.