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.2 Polymorphie und ihre Anwendungen  Zur nächsten ÜberschriftZur vorigen Überschrift

Polymorphie ist eine der wichtigsten Fähigkeiten, die Sie bei der Umsetzung von objektorientierten Systemen nutzen können. Wörtlich bedeutet der Begriff ins Deutsche übersetzt »Vielgestaltigkeit«.

Im Bereich der Objektorientierung bezieht sich Polymorphie darauf, dass verschiedene Objekte bei Aufruf derselben Operation unterschiedliches Verhalten an den Tag legen können.


Icon Hinweis Dynamische Polymorphie (oder Laufzeitpolymorphie)

Objektorientierte Systeme, die dynamische Polymorphie unterstützen, sind in der Lage, einer Variablen Objekte unterschiedlichen Typs zuzuordnen. Dabei beschreibt der Typ der Variablen selbst lediglich eine Schnittstelle. Der Variablen können dann aber alle Objekte zugewiesen werden, deren Klasse diese Schnittstelle implementiert. Welche Methode beim Aufruf einer Operation aufgerufen wird, hängt davon ab, welche Klassenzugehörigkeit das Objekt hat, das der Variablen zugeordnet ist. Der Typ der Variablen ist nicht entscheidend. Der Aufruf der Operation erfolgt damit polymorph, also abhängig vom konkreten Objekt. Wenn der Variablen während der Laufzeit des Programms Objekte mit unterschiedlicher Klassenzugehörigkeit zugewiesen werden, so werden jeweils andere Methoden aufgrund des Aufrufs derselben Operation ausgeführt. Im Folgenden werden wir die dynamische Polymorphie einfach kurz als Polymorphie bezeichnen.


Diese Fähigkeit hört sich zunächst nicht wirklich spektakulär an. Sie bildet aber die Grundlage dafür, dass objektorientierte Systeme so entwickelt werden können, dass sie innerhalb von Grenzen flexibel auf Änderungen von Anforderungen reagieren können.

In den meisten Fällen ist es allerdings zur Übersetzungszeit eines Programms noch gar nicht klar, mit welchen Objekten eine Variable konkret belegt sein wird. Deshalb kann in der Regel erst zur Laufzeit eines Programms entschieden werden, welche Methode beim Aufruf einer Operation ausgeführt werden soll. Der Mechanismus der späten Bindung erlaubt es, die Zuordnung auch erst zu diesem Zeitpunkt zu treffen.


Icon Hinweis Späte Bindung

Objektorientierte Systeme sind in der Regel in der Lage, die Zuordnung einer konkreten Methode zum Aufruf einer Operation erst zur Laufzeit eines Programms vorzunehmen. Dabei wird abhängig von der Klassenzugehörigkeit des Objekts, auf dem die Operation aufgerufen wird, entschieden, welche Methode verwendet wird.

Diese Fähigkeit, eine Methode dem Aufruf einer Operation erst zur Laufzeit zuzuordnen, wird späte Bindung genannt. Dies rührt daher, dass für die Zuordnung der Methode der spätest mögliche Zeitpunkt gewählt wird, um die Methode an den Aufruf einer Operation zu binden.


Die Polymorphie ist die technische Voraussetzung dafür, dass die Konzepte der Vererbung der Spezifikation und das Prinzip der Ersetzbarkeit in Ihren Programmen auch effektiv genutzt werden können.

Bevor wir die dynamische Polymorphie an einem Beispiel erläutern, stellen wir noch kurz ihren kleinen Bruder vor, die sogenannte Überladung.


Icon Hinweis Überladung (statische Polymorphie)

Von Überladung sprechen wir, wenn der Aufruf einer Operation anhand des konkreten Typs von Variablen oder Konstanten auf eine Methode abgebildet wird. Im Gegensatz zur dynamischen Polymorphie spielen die Inhalte der Variablen bei der Entscheidung, welche konkrete Methode aufgerufen wird, keine Rolle. Überladung kann nur von Sprachen mit statischem Typsystem unterstützt werden. Zu Sprachen, die Überladung unterstützen, gehören unter anderem C++ und Java. Überladung ist so etwas wie der kleine Bruder der dynamischen Polymorphie. Sie hilft dabei, Programme lesbarer und überschaubarer zu gestalten. Sie reicht aber nicht aus, um das Prinzip der Ersetzbarkeit für Unterklassen in unseren Programmen praktisch nutzbar zu machen.



Rheinwerk Computing - Zum Seitenanfang

5.2.1 Dynamische Polymorphie am Beispiel  Zur nächsten ÜberschriftZur vorigen Überschrift

Betrachten wir im Folgenden ein sehr einfaches Beispiel, bei dem dynamische Polymorphie zum Einsatz kommt. Daran anschließend werden wir zeigen, wie die Umsetzung ohne Polymorphie aussieht und welche Nachteile wir uns damit einhandeln würden. Wir verwenden für das erste Beispiel eine Umsetzung in C++, für das zweite die Sprache C, um deren Restriktionen in Bezug auf dynamische Polymorphie auszunutzen.

Umsetzung mit Polymorphie

In Abbildung 5.21 ist eine minimale Klassenhierarchie dargestellt, die wir für unser Beispiel verwenden werden.

Abbildung 5.21    Umsetzung einer Operation print

Die Abbildung zeigt die Umsetzung der Hierarchie in der Sprache C++ und die Nutzung der polymorphen Operation print().

class Data { 
    virtual void print() = 0;  
}; 
 
class Text: public Data { 
    virtual void print() {  
        printf("Druck eines einfachen Texts\n"); 
    } 
}; 
// Umsetzungen für BitmapImage und PdfData entsprechend 
// ... 
 
void printList(Data** dataList) { 
    Data** element; 
    Data* data;     
    for (element=dataList; *element != NULL; ++element) {  
        data = *element;  
        data->print();  
    } 
}

Listing 5.12    Verteilung von Methodenaufrufen durch Polymorphie

In Zeile deklarieren Sie, dass alle Exemplare der Klasse Data die Operation print unterstützen. Durch das Schlüsselwort virtual wird die Operation auch als polymorph markiert.

In den mit markierten Zeilen wird diese Operation von der Unterklasse TextData umgesetzt. Die Umsetzungen für BitmapData und PdfData sind nicht dargestellt.

In Zeile wird die Variable data deklariert. Ihr Typ ist ein Zeiger auf ein Exemplar der Klasse Data, die hierfür als Schnittstelle verwendet wird. Weil die dynamische Polymorphie unterstützt wird, kann die Variable aber auch Zeiger auf Exemplare aller Unterklassen von Data aufnehmen.

In Zeile wird über eine Liste von Exemplaren der Klasse Data iteriert. Das jeweils aktuelle Element wird in Zeile der Variablen data zugewiesen. Diese zeigt nun auf ein Exemplar einer Unterklasse von Data.

In Zeile wird die Operation print() auf der Variablen data aufgerufen. Der Aufruf wird jeweils einer Methode zugeordnet. Wenn data aktuell auf ein Exemplar von TextData zeigt, wird die Methode der Klasse TextData aufgerufen. Wenn data auf ein Exemplar von BitmapImage zeigt, wird die Methode print() von BitmapImage aufgerufen.

Die Umsetzung und Verwendung der Operation print() sieht also schon sehr kompakt aus.

Umsetzung ohne Polymorphie

Betrachten wir nun, was wir uns einhandeln würden, wenn wir die Polymorphie in diesem Fall nicht nutzen könnten. In Listing 5.13 versuchen wir, eine Version in der Sprache C umzusetzen, welche die gleiche Funktionalität realisiert. Dabei können wir nicht mehr auf Klassen zurückgreifen, sondern definieren lediglich Datenstrukturen über das Schlüsselwort struct. Die Datenstruktur Data enthält dabei einen Zeiger auf beliebige andere Strukturen, so dass sie die Daten unserer Unterklassen (nun Datenstrukturen) TextData, BitmapData und PdfData referenzieren kann.

#define STOP 0              
#define PLAIN_TEXT 1        
#define RASTER_IMAGE 2      
 
struct Data { 
    int type;   
    void * data; // Zeiger auf konkrete Daten 
}; 
typedef struct Data Data; 
 
struct TextData { 
    // hier stehen die eigentlichen Daten 
};
// entsprechende Strukturen für BitmapImage und PdfData 
// ... 
 
void printPlainText(TextData* text) { 
    printf("Druck eines einfachen Texts\n"); 
} 
// entsprechende Umsetzungen für BitmapImage und PdfData 
// ...

Verteilung nach Typ

void dispatchPrint(Data* data) {      
    switch (data->type) {             
        case PLAIN_TEXT: 
            printPlainText((char*)data->data); 
            break; 
        case RASTER_IMAGE: 
            printRasterImage((RasterImage*)data->data); 
            break; 
        default:                       
            printf("Fehler: Unbekannter Typ der Daten\n"); 
    } 
} 
 
void printList(Data* dataList) { 
    Data* d; 
    for (d = dataList; d->type != STOP; ++d) { 
        dispatchPrint(d); 
    } 
}

Listing 5.13    Aufruf der Operation print ohne dynamische Polymorphie

Einige Unterschiede, die diese Lösung komplexer machen, sind bereits klar aus dem Listing ersichtlich.

  • In den mit markierten Zeilen ist zu sehen, dass explizite Typinformation benötigt wird, um die verschiedenen Datenstrukturen zu unterscheiden. Das war in unserer Variante mit Polymorphie nicht der Fall.
  • Es ist eine explizite Verteilung der Aufrufe der verschiedenen print-Routinen durch eine Methode dispatchPrint() in Zeile notwendig. Dies ist in der Variante mit Polymorphie nicht notwendig.
  • Die Zuordnung der korrekten Routine muss in Zeile anhand des Typs erfolgen, der in der allgemeinen Datenstruktur Data mitgeführt wird. Auch dies ist in der Variante mit Polymorphie nicht notwendig.
  • Es kann zu Fehlerfällen kommen, in denen eine Datenstruktur an die Routine dispatchPrint() übergeben wird, für die in der Verteilung keine Routine vorhanden ist (). In der Variante mit Polymorphie ist sichergestellt, dass zu einem Objekt auch eine Methode print() vorhanden ist, da diese dem Objekt selbst zugeordnet ist.

Neben den direkt aus den Listings zu entnehmenden Vorteilen der Polymorphie gibt es einen weiteren Vorteil, der mit späteren Änderungen am Programm zu tun hat.

Erweiterbarkeit

Wenn Sie das Programm um die Fähigkeit erweitern wollen, auch Vektorgrafiken zu drucken, müssen Sie in der Variante mit Polymorphie nichts weiter tun, als eine neue Unterklasse VectorImage der Klasse Data zu erstellen, die eine Methode print() realisiert, um eine Vektorgrafik zu drucken.

Wenn Sie die Polymorphie nicht zur Verfügung haben, müssen Sie nicht nur eine Routine printVectorGraphics implementieren, Sie müssen auch die Prozedur dispatchPrint anpassen und einen neuen Typ dafür definieren, der dann mit der Datenstruktur Data zusammen verwendet wird. Sie müssen also stark in den vermeintlich bereits fertig gestellten Quelltext eingreifen.

Verteilung durch Programmiersprache

Mit Hilfe der dynamischen Polymorphie können Sie also die beschriebene Aufgabenstellung mit weniger Quelltext umsetzen, und Sie halten den Änderungsaufwand im Fall von Anpassungen geringer.

Zuordnung beim Aufruf einer Operation

Die Zuordnung beim Aufruf einer Operation zu einer konkreten Methode übernimmt ein von der Programmiersprache (oder deren Laufzeitsystem) bereitgestellter Mechanismus, ein sogenannter Dispatcher.

Wenn Sie im Beispiel aus Listing 5.12 das Programm um weitere druckbare Dateitypen erweitern, werden diese automatisch in die Entscheidungslogik des Programms integriert. Der Dispatcher wird die Zuordnung der Methode für Exemplare von neu erstellten Klassen mit übernehmen.


Icon Hinweis Dispatcher in objektorientierten Programmiersprachen

Ein Dispatcher ist ein durch eine objektorientierte Programmiersprache zur Verfügung gestellter Mechanismus, der die Verantwortung dafür hat, für jede aufgerufene Operation die korrekte Methode auszuführen. Die Entscheidung darüber, welche Methode ausgeführt wird, hängt in der Regel von der Klassenzugehörigkeit des Objekts ab, auf dem eine Operation aufgerufen wird. Die konkrete auszuführende Methode kann erst bestimmt werden, wenn der tatsächliche Typ des Objekts, auf dem die Operation durchgeführt wird, bekannt ist. In den meisten Fällen ist dies erst zur Laufzeit möglich, so dass ein Dispatcher auch zur Laufzeit zur Verfügung stehen muss.


Diskussion: Polymorphie ohne Objekte?

Bernhard: Ist denn eigentlich dynamische Polymorphie etwas, was nur im Kontext von objektorientierten Sprachen funktioniert? [In bestimmten Fällen lässt sich der tatsächliche Typ bereits zur Kompilierungszeit bestimmen. Das bietet eine Möglichkeit zur Optimierung der Performance des Programms, denn die dynamische Bestimmung der aufzurufenden Routine (Dynamic Dispatch) ist nicht ohne Laufzeitkosten. ]

Gregor: Nein, man kann dynamische Polymorphie in praktisch jeder Programmiersprache anwenden. Eines der bekanntesten Beispiele der Anwendung von dynamischer Polymorphie hängt sogar sehr eng mit der Sprache C zusammen.

Bernhard: Welches Beispiel meinst du?

Gregor: Die Sprache C und das Betriebssystem Unix entstanden sozusagen Hand in Hand. In Unix (und ähnlichen Systemen wie z. B. Linux) betrachtet man alle an den Rechner angeschlossenen Geräte als Dateien. Man benutzt die gleichen C-Funktionen, um auf tatsächliche Dateien, den Drucker, die Maus, das Netzwerk und so weiter zuzugreifen. Ähnlich, wenn auch nicht so konsequent, macht man es unter Windows. Ein anderes Beispiel ist, dass man auf eine Datei zugreifen kann, als ob deren Inhalt einfach im Speicher liegen würde. Das alles sind Beispiele von dynamisch polymorpher Funktionalität, die in diesem Fall vom Betriebssystem bereitgestellt wird.

Bernhard: Die dynamische Polymorphie wird also nicht von der Objektorientierung ermöglicht?

Gregor: Nein, sie wird aber erheblich einfacher gemacht.

Die Polymorphie gibt Ihnen einen sehr breit nutzbaren Abstraktionsmechanismus an die Hand. Polymorphie bildet damit eine ganz zentrale Voraussetzung für die objektorientierte Vorgehensweise.

In den folgenden Abschnitten werden Sie einige Anwendungen für Polymorphie kennen lernen. Zunächst stellen wir Ihnen an einem Beispiel vor, wie unterschiedliche Methoden auf Grundlage der Polymorphie dieselbe Operation umsetzen und welche Mechanismen zum Einsatz kommen, um die richtige Methode zu finden. In Abschnitt 5.2.3 gehen wir auf die sogenannten anonymen Klassen ein, für deren Nutzung die Polymorphie zentral ist. In Abschnitt 5.2.4 werden wir dann Aufrufe von Operationen zeigen, die eine komplexere Verteilung auf der Grundlage von Polymorphie erfordern, und diese am konkreten Beispiel des Entwurfsmusters »Besucher« erläutern. Schließlich werden wir in Abschnitt 5.2.5 auch noch auf die technische Realisierung von Polymorphie eingehen und als Beispiel die sogenannte Tabelle für virtuelle Methoden vorstellen.


Rheinwerk Computing - Zum Seitenanfang

5.2.2 Methoden als Implementierung von Operationen  Zur nächsten ÜberschriftZur vorigen Überschrift

Klassen deklarieren in der Regel eine Reihe von Operationen, die sich auf den Exemplaren dieser Klassen aufrufen lassen. In Abschnitt 4.2.4 haben Sie gesehen, dass diese Operationen auch innerhalb der Klassen umgesetzt werden können, und zwar in Form von Methoden.

Operationen bilden auf Methoden ab.

Eine Klasse, die eine Operation über eine Methode umsetzt, implementiert diese Operation. Allerdings muss die Klasse, die eine Methode umsetzt, nicht unbedingt diejenige sein, die auch die Operation spezifiziert. In so einem Fall spricht man davon, dass eine Klasse die Schnittstelle einer anderen Klasse ganz oder teilweise implementiert.

In Abbildung 5.22 implementieren drei Klassen die von der Schnittstellen-Klasse ProtocolHandler spezifizierte Operation getContent() über unterschiedliche Methoden.

Im Beispiel sind die Methoden in der statisch typisierten Sprache Java umgesetzt. Bei Sprachen mit statischem Typsystem muss die Referenz auf ein Objekt (also eine Variable oder ein Parameter) den Typ der Schnittstelle deklarieren, damit die Operation überhaupt aufgerufen werden kann. Zur Laufzeit wird der Dispatcher der Programmiersprache dann für eine Variable vom Typ der Schnittstelle entscheiden, welche der Methoden aufgerufen wird.

In Listing 5.14 ist der Parameter handler der statischen Methode getText in Zeile vom Typ der Schnittstelle ProtocolHandler. Der Aufruf der Operation getContent in Zeile wird eine der drei Methoden aus Abbildung 5.22 aufrufen, abhängig davon, ob zur Laufzeit des Programms ein Exemplar von HttpProtocolHandler, FtpProtocolHandler oder SecureHttpProtocolHandler vorliegt.

Abbildung 5.22    Verschiedene Java-Methoden für die Operation »getContent«

Statisch typisierte Sprachen

static String getText(ProtocolHandler handler,    
                      String location) { 
   String description = "Presenting " + location + ": \n" ; 
   description += handler.getContent(location);  
   return description; 
}
public static void main(String[] args) { 
  ProtocolHandler handler = new HttpProtocolHandler();    
  System.out.println(getText(handler,"http://www.mopo.de")); 
  handler = new FtpProtocolHandler();  
  System.out.println(getText(handler,"ftp://ftp.share.de")); 
  handler = new SecureHttpProtocolHandler(); 
  System.out.println(getText(handler,"https://www.bank.de")); 
}

Listing 5.14    Verschiedene Methoden werden für die Operation »getContent« aufgerufen.

In den Zeile , und werden jeweils Exemplare der unterschiedlichen Klassen konstruiert und an getText()übergeben. Die Ausgaben unterscheiden sich dann auch, da jeweils unterschiedliche Methoden aufgerufen werden:

Presenting http://www.mopo.de: 
content of Http-Connection 
Presenting ftp://ftp.share.de: 
content of Ftp-Connection 
Presenting https://www.bank.de: 
content of Http-Connection (secure)

Die Verteilung einer Operation auf verschiedene Methoden ist in den statisch typisierten Sprachen also nur möglich, wenn alle beteiligten Objekte eine gemeinsame, explizit deklarierte Schnittstelle implementieren.

Dynamisch typisierte Sprachen

Anders in den dynamisch typisierten Sprachen. Dort ist es durchaus möglich, eine Operation erfolgreich auf verschiedenen Objekten aufzurufen, deren Klassen in keiner Beziehung zueinander stehen. Die Objekte müssen also nicht unbedingt eine explizite gemeinsame Schnittstelle implementieren.

Betrachten wir diese Situation an einem Beispiel in der dynamisch typisierten Sprache Python. In Abbildung 5.23 ist das Klassenmodell so angepasst, dass es technisch die Situation in dynamisch typisierten Sprachen darstellt. Die beiden Klassen unterstützen zwar beide die Operation getContent(), dies wird aber nicht über eine explizite gemeinsame Schnittstelle modelliert.

Abbildung 5.23    Unabhängige Klassen mit der gleichen Schnittstelle

In Listing 5.15 ist die Verwendung der Operation getContent() am Beispiel in Python dargestellt.

class HttpProtocolHandler:    
   def getContent(self, location):  
     print "content of Http-Connection " 
 
class FtpProtocolHandler:     
   def getContent(self, location):  
     print "content of Ftp-Connection" 
 
if random.random() < 0.5:     
   handler = HttpProtocolHandler() 
else: 
   handler = FtpProtocolHandler() 
handler.getContent("http://www.mopo.de")    
# was wird hier wohl ausgegeben?

Listing 5.15    Abwechselnder Aufruf von zwei Methoden

  • In den mit markierten Zeilen werden die beiden beteiligten Klassen konstruiert. Diese stehen in keiner Beziehung zu anderen Klassen.
  • In den mit markierten Zeilen werden dann die Methoden umgesetzt, welche die Operation getContent() implementieren.
  • In Zeile wird eher zufällig entschieden, ob die Variable handler ein Exemplar von HttpProtocolHandler oder von FtpProtocolHandler zugewiesen bekommt.
  • Da beide Klassen die Operation getContent() unterstützen, ist der Aufruf in Zeile unabhängig von der konkreten Klassenzugehörigkeit erfolgreich. Beim Aufruf einer Operation wird einfach für das betroffene Objekt überprüft, ob das Objekt selbst oder eine der Klassen, zu denen es gehört, für eine Operation dieses Namens eine Methode umgesetzt haben.

In manchen Sprachen lässt sich aber in die Suche nach der Methode zu einer Operation eingreifen. In Python und Ruby werden solche Modifikationen des Suchverfahrens unterstützt. Auf diese Weise können Sie zum Beispiel erreichen, dass ein Objekt den Aufruf von Operationen, die es nicht selbst unterstützt, an ein anderes Objekt weiterleitet, also delegiert.

Umsetzung eines Proxy in Ruby

Betrachten wir diese Möglichkeit ebenfalls wieder an einem Beispiel. Nehmen Sie an, Sie möchten in Ruby ein Objekt als sogenannten Proxy [Proxy lässt sich mit »Stellvertreter« übersetzen. Ein Proxy ist meist ein Objekt (oder ein Dienst), das stellvertretend für andere Objekte Nachrichten entgegennimmt und diese falls notwendig in möglicherweise modifizierter Form an die anderen Objekte weiterleitet. Ein klassisches Beispiel für einen Proxy ist ein Dienst, der Aufrufe von Webseiten empfängt und selbst entscheidet, ob diese aus einem Zwischenspeicher bedient werden können oder an den Zielserver weitergeleitet werden müssen. ] für andere Objekte einsetzen. Ihr Proxy soll alle Aufrufe von Operationen protokollieren und dann an das eigentliche Objekt weiterreichen.

Die Modellierung in Abbildung 5.24, die auch in Listing 5.16 umgesetzt wird, nutzt die Möglichkeit von Ruby, die Methode method_missing zu überschreiben.

Abbildung 5.24    Eine Proxy-Klasse in Ruby

Die Modellierung sieht vor, dass beliebige Operationen auf Exemplaren der Klasse LoggingProxy aufgerufen werden können. Diese halten selbst eine Referenz auf das Objekt, das eigentlich den Aufruf erhalten sollte, und leiten den Aufruf weiter, nachdem ein Protokolleintrag geschrieben wurde.

In Zeile von Listing 5.16 ist dargestellt, wie die Methode method_missing überschrieben wird. Durch dieses Überschreiben können alle Aufrufe von Operationen an ein anderes Objekt weitergeleitet werden.

class EigentlicheKlasse 
  def sinnvolleOperation  
    print "Ich führe eine sinnvolle Aktion aus\n" 
  end 
end 
 
class LoggingProxy 
  def initialize(ziel)   
    @ziel = ziel 
  end 
  def method_missing(symbol, *args)   
    print "Aufruf von " + symbol.to_s 
    @ziel.send(symbol, *args)    
  end 
end 
 
eigentlichesObjekt = EigentlicheKlasse.new 
proxy = LoggingProxy.new(eigentlichesObjekt) 
 
proxy.sinnvolleOperation 
# Ausgegeben wird: 
#  Aufruf von beispielMethode 
#  Ich führe eine sinnvolle Aktion aus

Listing 5.16    Umsetzung eines Proxy in Ruby

Fallback in Ruby: method_missing

Wenn ein Objekt in Ruby keine Methode mit dem Namen der aufgerufenen Operation besitzt, wird die Methode method_missing mit dem Namen der fehlenden Methode und den Parametern des ursprünglichen Aufrufes aufgerufen. Im Beispiel protokollieren wir den Aufruf, der an das Objekt proxy adressiert ist, und leiten ihn zum Objekt eigentlichesObjekt weiter, das bei der Initialisierung in Zeile übergeben wurde. In Zeile wird dann über die Operation send der Aufruf an das eigentliche Objekt weitergeleitet. Dies ist nützlich, wenn man den Namen, wie in unserem Falle, nicht von vornherein kennt, sondern einfach alle Aufrufe weiterleiten möchte. Neben allen anderen Aufrufen wird so auch die Operation sinnvolleOperation weitergeleitet, für die in Zeile die entsprechende Methode umgesetzt ist.

Diskussion: Verwendung von method_missing

Gregor: Macht die Verwendung eines Konstrukts wie method_missing unsere Anwendung nicht sehr unübersichtlich? Wenn wir das zum Extrem treiben, können doch beliebige Methoden auf unserem Objekt aufgerufen werden, die dann alle über method_missing behandelt werden.

Bernhard: Wir müssen tatsächlich aufpassen, dass wir durch die Weiterleitung von Methodenaufrufen keine unübersichtliche Struktur schaffen. Das Verfahren ist auch nicht generell geeignet, um Delegationsbeziehungen umzusetzen. Speziell für die Umsetzung von Proxys, also reinen Stellvertreter-Objekten, ist die Möglichkeit aber dennoch nützlich. Es geht dabei ja gar nicht um eine inhaltliche Auswertung, sondern um das reine Weiterleiten eines Aufrufs. Dies lässt sich mit den gezeigten Verfahren einfach und elegant umsetzen.

Bei der Umsetzung von Methoden werden Objekte in der Regel auch wieder Operationen auf weiteren Objekten aufrufen. Zu diesen Objekten bestehen dann Abhängigkeiten. Da Sie in der Regel Abhängigkeiten minimieren sollten, ist in diesen Fällen zu prüfen, ob die Abhängigkeit kritisch ist und ob es Alternativen dazu gibt. Das Demeter-Prinzip formuliert einen Anhaltspunkt, welche Abhängigkeiten unbedenklich sind und welche überprüft werden sollten. [Wir haben hier den gängigen englischen Begriff Law of Demeter ganz bewusst mit »Das Demeter-Prinzip« übersetzt. Dies soll deutlich machen, dass es sich auch und speziell bei diesem Prinzip eben nur um eine Leitlinie handelt, von der Sie in bestimmten Fällen abweichen können und auch sollten. ]


Icon Hinweis Das Demeter-Prinzip (Law of Demeter)

Eine Methode, die zu einem Objekt gehört, darf Operationen auf anderen Objekten nur aufrufen, wenn diese auf einem Objekt aus der folgenden Liste aufgerufen werden:

  • das Objekt selbst
  • ein Objekt, das vom Objekt selbst referenziert wird
  • ein Objekt, das als Parameterwert an die Methode übergeben wurde
  • ein Objekt, das innerhalb der Methode selbst angelegt wurde

Das Prinzip hat seinen Namen nach dem Demeter-Projekt an der Northeastern University in Boston erhalten.12  Ein etwas eingängigerer Name ist »Sprich nur mit deinen Freunden«.


Wenn Sie das Demeter-Prinzip vollständig umsetzen, dann sind innerhalb von Methoden keine sogenannten verketteten Aufrufe mehr erlaubt. [Das Projekt beschäftigte sich mit Mechanismen, die Zugriffe auf Objekte über Zugriffsketten durch Traversierungsobjekte kapseln sollten. Der Name des Projekts wiederum gründet auf die griechische Göttin Demeter. Diese war unter anderem für die Fruchtbarkeit der Felder zuständig. Die Analogie dabei sollte sein, dass auch Software nach und nach in kleinen Schritten wachsen sollte. ]

Beispiel für Demeter-Prinzip

Betrachten wir am besten an einem Beispiel, was denn nun verkettete Aufrufe sind. Nehmen Sie an, Sie wollen eine Steuerung für einen Roboter umsetzen. Die Steuerung prüft regelmäßig, ob der Batteriezustand des Roboters noch in Ordnung ist. Falls nein, weist die Steuerung den Roboter an, zur Aufladestation zu fahren, und gibt der Batterie des Roboters das Signal, dass sie sich dort aufladen kann. In Abbildung 5.25 ist eine Modellierung dieses Szenarios aufgeführt, die das Demeter-Prinzip verletzt.

Abbildung 5.25    Verletzung des Demeter-Prinzips

Um zu sehen, warum das so ist, müssen Sie einen Blick auf den resultierenden Quelltext werfen. In Listing 5.17 ist zu sehen, dass das Prinzip verletzt wird, weil in Zeile ein verketteter Aufruf enthalten ist. Der Zugriff auf eine Operation istAufgeladen() einer Roboterbatterie über die Methode batterie() in Zeile ist nach dem Demeter-Prinzip nicht erlaubt.

class RoboterSteuerung { 
... 
public void pruefeZustand(RasenmaeherRoboter roboter) 
{ 
    if (!roboter.batterie().istAufgeladen())  
    { 
       roboter.fahreZuAufladeStation(); 
       roboter.batterie().aufladen(); 
    } 
} 
// ...

Listing 5.17    Verletzung des Demeter-Prinzips

Das Batterie-Objekt, das vom Aufruf roboter.batterie() geliefert wird, ist kein Objekt aus der Kriterienliste der Demeter-Prinzips: Es ist nicht das Objekt selbst (ein Exemplar von RoboterSteuerung), es wird aber auch nicht direkt von der Steuerung referenziert. Ebenso wenig ist die Batterie aber als Parameterwert an die Methode pruefeZustand() übergeben worden, und sie wurde auch nicht innerhalb der Methode angelegt.

Nutzen des Demeter-Prinzips

Wenn wir dem Prinzip folgen, reduzieren wir die Abhängigkeiten in unserem Code. Eine Methode kennt bei Einhaltung des Prinzips nur die ihr ohnehin bekannten Objekte. Wenn Sie über diese bereits bekannten Objekte hinaus auf weitere Objekte zugreifen, um mit diesen zu arbeiten, haben Sie neue Abhängigkeiten geschaffen, da Sie nun die Schnittstelle dieser Objekte kennen müssen. Die Kopplung zwischen Modulen wird dadurch verstärkt. Das Demeter-Prinzip wirkt dem entgegen.

In Abbildung 5.26 ist eine angepasste Modellierung zu sehen, die das Demeter-Prinzip nicht verletzt. Die Aufrufe der Steuerung erfolgen nun alle gegenüber dem Roboter, der diejenigen Operationen, die sich auf die Batterie beziehen, an diese weiterleitet.

Abbildung 5.26    Korrigierte Version ohne Verletzung des Demeter-Prinzips

Nachteile des Prinzips

Das Demeter-Prinzip komplett einzuhalten, ist in der Praxis meist nicht möglich und kann durchaus auch relevante Nachteile haben. Sie erhalten bei kompletter Einhaltung des Prinzips in vielen Anwendungen für diesen Fall eine große Anzahl von Methoden, die Aufrufe einfach weiterreichen.

Sie sollten sich aber bei einer Verletzung des Demeter-Prinzips bewusst sein, dass Sie Abhängigkeiten geschaffen haben, und prüfen, ob diese für Ihre Anwendung nachteilig sind.


Rheinwerk Computing - Zum Seitenanfang

5.2.3 Anonyme Klassen  Zur nächsten ÜberschriftZur vorigen Überschrift

In der Regel haben Klassen einen Namen zugeordnet und können somit zur Konstruktion von beliebig vielen Objekten verwendet werden. Es gibt allerdings auch Situationen, in denen einfach genau ein Objekt erstellt werden soll, das ganz spezifische Eigenschaften hat. Um so ein Objekt zu erstellen, können anonyme Klassen verwendet werden.


Icon Hinweis Anonyme Klassen

Anonyme Klassen sind Klassen, die keine Namen haben. Da anonyme Klassen keinen Namen haben, können Sie auch keine Variable mit dieser Klasse als Typ deklarieren. Sie können allerdings Variablen deklarieren, die den Typ einer Oberklasse haben.


Beispiel Java

Betrachten wir im Folgenden ein Beispiel in der Sprache Java, in dem wir ein Objekt (nicht eine Klasse) mit einer modifizierten Methode ausstatten.

In Abschnitt 5.1.6, »Sichtbarkeit im Rahmen der Vererbung«, haben wir ein Beispiel zum Umgang mit Sammlungen vorgestellt. Dabei haben wir zwei abstrakte Klassen Collection und Iterator spezifiziert. Stellen Sie sich jetzt eine Situation vor, in der Sie eine Methode aufrufen wollen, die eine Sammlung als Parameter verlangt. Sie möchten eine Sammlung herstellen, die genau einen Eintrag enthält. Um dies zu erreichen, können Sie anonyme Klassen verwenden. Sie erstellen dabei eine anonyme Unterklasse der Klasse Collection, wie im folgenden Java-Beispiel dargestellt.

final Object element = null; // Der Eintrag in der Sammlung 
 
// anonyme Unterklasse von Collection 
Collection myCollection = new Collection() {  
  public Iterator iterator() {  
    // anonyme Unterklasse von Iterator 
    return new Iterator() {  
      private boolean first = true; 
      public boolean hasNext() {  
        if (first) { 
          first = false; 
          return true; 
        } 
        return false; 
      } 
 
      public Object next() {  
        return element; 
      } 
    }; 
  } 
};

Durch den Aufruf in Zeile wird ein Exemplar einer anonymen Klasse erstellt, die als Unterklasse von Collection definiert ist. Dieses Exemplar ist der Variablen myCollection zugewiesen. Das Exemplar besitzt außerdem die Methode iterator(), die ab Zeile definiert wird. Der Aufruf der Methode wiederum wird ein Exemplar einer anonymen Unterklasse der Klasse Iterator liefern, da in Zeile solch ein Exemplar erzeugt wird. Dieses Exemplar der anonymen Klasse hat nun wiederum modifizierte Methoden hasNext und next zugeordnet, wie sie in der anonymen Klasse in den mit markierten Zeilen definiert wurden.

Anonyme Klassen und Ereignisse

Eine andere, häufig anzutreffende Anwendung von anonymen Klassen findet man bei der Behandlung von Ereignissen. Bei deren Bearbeitung wird häufig eine spezielle Form von anonymen Klassen verwendet. Wir gehen auf diese Anwendung in Abschnitt 7.4.4, »Funktionsobjekte und ihr Einsatz als Eventhandler«, ein.


Rheinwerk Computing - Zum Seitenanfang

5.2.4 Single und Multiple Dispatch  Zur nächsten ÜberschriftZur vorigen Überschrift

Sie haben in den vorangegangenen Abschnitten den Begriff der Polymorphie kennen gelernt. Dabei wird für den Aufruf einer Operationen zur Laufzeit eines Programms anhand der Klassenzugehörigkeit eines Objekts entschieden, welche Methode wirklich aufgerufen wird.

Sie werden in diesem Abschnitt erfahren, wie die Polymorphie dazu verwendet werden kann, die konkrete aufzurufende Methode aufgrund des Typs aller beteiligten Objekte zu bestimmen. Außerdem werden Sie mit dem Entwurfsmuster des Besuchers (Visitor) einen Anwendungsfall für eine solche Verteilung kennen lernen. Dabei werden Sie auch sehen, wie dieser Anwendungsfall in einer Programmiersprache wie Java umgesetzt werden kann, die eigentlich die aufzurufende Methode nicht anhand mehrerer Objekte auswählen kann.

In Abschnitt 5.2 haben wir beschrieben, wie ein in eine Sprache integrierter Dispatcher dafür sorgt, dass Aufrufe von Operationen den entsprechenden Methoden zugeordnet werden. Abhängig davon, ob diese Zuordnung nur ein Objekt oder mehrere Objekte als Grundlage für die Verteilung heranzieht, unterscheiden wir zwischen Single Dispatch und Multiple Dispatch.


Icon Hinweis Single Dispatch

Der technische Mechanismus des Dispatchers sorgt in einem objektorientierten System dafür, dass Aufrufe von Operationen zur Laufzeit konkreten Methoden zugeordnet werden. Verwendet ein Dispatcher für die Zuordnung einer Methode zum Aufruf einer Operation ausschließlich Informationen über ein Objekt und dessen Klassenzugehörigkeit, wird diese Verteilung Single Dispatch genannt.


Ein Beispiel für Single Dispatch haben wir bereits in Abschnitt 5.2 gegeben. Im Beispiel von Listing 5.12 erfolgt eine Verteilung auf der Basis eines Objekts: das Objekt, das unsere Daten repräsentiert und entweder zur Klasse BitmapImage, TextData oder PdfData gehört.

Dass eine Operation zu einem Objekt gehört, ist aber in manchen Fällen eine zu stark vereinfachte Sicht der Dinge. Wenn Sie eine spezielle Grafik über die Operation ausgeben auf ein spezielles Ausgabemedium ausgeben wollen, kann die notwendige Aktion von beiden beteiligten Objekten abhängen: von der Art der Grafik und von der Art des Ausgabemediums. Bitmapgrafiken lassen sich einfach auf dem Bildschirm darstellen, Vektorgrafiken einfach in ein PDF-Format bringen. Eine Bitmapgrafik im PDF-Format auszugeben erfordert aber eine ganze andere Aktion, und Vektorgrafiken auf dem Bildschirm sind wieder separat zu behandeln. Abbildung 5.27 stellt die Beispielhierarchie vor, in der jeweils zwei verschiedene Arten von Grafiken und Ausgabemedien aufgeführt sind.

Abbildung 5.27    Hierarchie von Grafiktypen und Ausgabemedien

In so einem Fall ist es also notwendig, zwei oder mehr Objekte mit einzubeziehen, wenn eine Methode ausgewählt werden soll.


Icon Hinweis Multiple Dispatch

Verwendet ein Dispatcher für die Zuordnung einer Methode zum Aufruf einer Operation Information über mehrere Objekte und deren Klassenzugehörigkeit, wird diese Verteilung Multiple Dispatch genannt.


Möglicherweise erscheint es Ihnen nicht intuitiv, dass eine Operation nicht einem Objekt zugeordnet ist, sondern mehreren. Tatsächlich legen die gängigen Programmiersprachen nahe, dass eine Operation immer genau einem Objekt zugeordnet ist. Das wird schon durch die Syntax nahe gelegt, die z. B. in Java object.operation(param1, param2 ...) lautet. Aber das ist einfach eine Festlegung der Programmiersprache. Wir werden später in diesem Abschnitt kurz auf die Programmiersprache CLOS (Common Lisp Object System) eingehen, in der Multiple Dispatch direkt unterstützt wird.

Da Sie aber sehr wahrscheinlich in der Praxis eher mit einer Sprache arbeiten, die direkt nur Single Dispatch unterstützt, erläutern wir hier zunächst, wie sie eine vergleichbare Verteilung auch in Sprachen wie Java umsetzen können.

Multiple Dispatch ohne Unterstützung durch die Programmiersprache

Bei Programmiersprachen wie Java, deren Dispatcher nur anhand eines Objekts entscheidet, müssen Sie selbst dafür sorgen, dass bei mehreren Objekten die Verteilung korrekt durchgeführt wird. Hier gibt es die Möglichkeit, eine Verteilungskette aufzubauen. Dabei bekommt eine Operation ein Objekt als Parameterwert übergeben und ruft auf diesem Objekt wiederum eine Operation auf, so dass dabei erneut der Dispatcher zum Einsatz kommt.

Betrachten Sie also noch einmal das Beispiel, in dem wir unterschiedliche Grafiken auf unterschiedliche Medien ausgeben wollen. In Abbildung 5.28 sind die beteiligten Klassen und die benötigten Operationen aufgeführt, damit wir auch hier eine korrekte Zuordnung einer Operation ausgeben erreichen.

Abbildung 5.28    Klassenhierarchie für Multiple Dispatch in Java

Wie Sie der Abbildung entnehmen können, ist die eigentliche Ausgabe-Operation den Medien zugeordnet. Auf einem Exemplar der Klasse Bildschirm könnten wir also die Operation ausgeben aufrufen und ein Exemplar der Klasse Grafik als Parameterwert übergeben:

public class ChainOfDispatch { 
    static void print(Grafik grafik, AusgabeMedium medium) 
    { 
        medium.ausgeben(grafik); 
    }

Damit das funktioniert, müssen aber nun die Methoden, welche die Operation ausgeben umsetzen, dafür sorgen, dass eine Weiterverteilung erfolgt. Abbildung 5.29 zeigt den Ablauf bei dieser Weiterverteilung.

Abbildung 5.29    Aufruffolge bei Verteilung über eine Kette von Objekten

Wenn Sie den dazugehörigen Source-Code aus Listing 5.18 betrachten, sehen Sie, dass ein Exemplar der Klasse PdfDatei den Aufruf in Form einer Operation ausgebenAlsPdfDatei weiterleitet, ein Exemplar der Klasse Bildschirm aber in Form der Operation ausgebenAufBildschirm. Als Parameterwert übergibt das Objekt durch die Angabe von this sich selbst.

class PdfDatei extends AusgabeMedium { 
    void ausgeben(Grafik grafik) 
    { 
        grafik.ausgebenAlsPdfDatei(this); 
    } 
} 
class Bildschirm extends AusgabeMmedium { 
    void ausgeben(Grafik grafik) 
    { 
        grafik.ausgebenAufBildschirm(this); 
    } 
}

Listing 5.18    Umsetzung von Dispatch-Methoden

Die Operationen ausgebenAufBildschirm und ausgebenAlsPdfDatei werden nun von den abgeleiteten Klassen BitmapGrafik und VektorGrafik unterschiedlich umgesetzt, wie in Listing 5.19 dargestellt. Da die Operationen auf einem Exemplar der Klasse Grafik aufgerufen werden, erfolgt die konkrete Methodenzuordnung bei Aufruf einer der beiden Operationen wiederum über den Dispatcher.

class BitmapGrafik extends Grafik { 
    void ausgebenAlsPdfDatei(AusgabeMedium medium) 
    { 
        System.out.println("Bitmapgrafik als PDF-Datei"); 
    } 
    void ausgebenAufBildschirm(AusgabeMedium medium) 
    { 
       System.out.println("Bitmapgrafik auf Bildschirm"); 
    } 
} 
class VektorGrafik extends Grafik { 
    void ausgebenAlsPdfDatei(AusgabeMedium medium) 
    { 
        System.out.println("Vektorgrafik als PDF-Datei"); 
    } 
    void ausgebenAufBildschirm(AusgabeMedium medium) 
    { 
        System.out.println("Vektorgrafik auf Bildschirm"); 
    } 
}

Listing 5.19    Umsetzung der weiteren Verteilung

Als Beleg dafür, dass die Verteilung funktioniert, erstellen wir nun Exemplare der jeweiligen konkreten Klassen und prüfen, ob die Zuordnung korrekt erfolgt.

    public static void main(String[] args) 
    { 
        PdfDatei pdfdatei = new PdfDatei(); 
        Bildschirm bildschirm = new Bildschirm(); 
        VektorGrafik vektorgrafik = new VektorGrafik(); 
        BitmapGrafik bitmapgrafik = new BitmapGrafik(); 
 
        graphik_auf_medium(vektorgrafik, pdfdatei); 
        graphik_auf_medium(bitmapgrafik, pdfdatei); 
        graphik_auf_medium(vektorgrafik, bildschirm); 
        graphik_auf_medium(bitmapgrafik, bildschirm); 
    }

Wie erwartet erhalten wir die jeweils zugeordneten Ausgaben:

Vektorgrafik als PDF-Datei 
Bitmapgrafik als PDF-Datei 
Vektorgrafik auf Bildschirm 
Bitmapgrafik auf Bildschirm

Diskussion: Wo ist die Praxisrelevanz?

Gregor: Hm, das scheint mir aber doch eher ein praxisfernes Beispiel zu sein. Wann wollen wir schon eine Verteilung auf der Basis von mehreren Objekten vornehmen? In aller Regel reicht uns doch ein Objekt für die Verteilung.

Bernhard: In Abschnitt 5.2.4, »Das Entwurfsmuster ›Besucher‹: Multiple Dispatch im Einsatz«, werden wir sehen, dass dieses Verfahren die technische Grundlage für das Entwurfsmuster »Besucher« bildet. Und dafür gibt es eine ganze Reihe von praktischen Anwendungen.

Multiple Dispatch mit Unterstützung durch die Programmiersprache

CLOS und Multiple Dispatch

Einige Programmiersprachen unterstützen das Konzept des Multiple Dispatch direkt. Dadurch lässt sich die beschriebene Aufgabenstellung wesentlich kompakter lösen. Am Beispiel der Programmiersprache CLOS sehen Sie in diesem Abschnitt einen kurzen Überblick über die wesentlich kompaktere Umsetzung unseres Beispiels aus dem vorigen Abschnitt unter Verwendung von CLOS.

Eine direkte Darstellung in UML ist nicht möglich, da die UML davon ausgeht, dass eine Operation auch immer einem konkreten Objekt und damit einer Klasse zugeordnet werden kann. Somit müssen Sie für diesen Fall auf ein UML-Diagramm verzichten. Die Hierarchie der beteiligten Klassen bleibt aber auch für das Beispiel in CLOS diejenige aus Abbildung 5.28.

Polymorphe Methoden in CLOS

Operationen auf Objekten, also polymorphe Methoden, werden in CLOS über das Makro defmethod umgesetzt. Dabei wird die Methode nicht einer konkreten Klasse zugeordnet. Vielmehr werden alle Parameter, die Klassen zugeordnet sind, für die Methodenzuordnung herangezogen. In Listing 5.20 sehen Sie, dass die Umsetzung in CLOS wesentlich direkter möglich ist, auch wenn die Syntax möglicherweise etwas gewöhnungsbedürftig ist.

;; Definition der Methoden, die über zwei Parameter verteilen 
(defmethod ausgeben((bitmap BitmapGrafik)(pdf PdfDatei)) 
    (print "Ausgabe von Bitmap als PDF") 
) 
(defmethod ausgeben((bitmap BitmapGrafik)(bs Bildschirm)) 
    (print "Ausgabe von Bitmap auf Bildschirm") 
) 
(defmethod ausgeben ((vektor VektorGrafik)(pdf PdfDatei)) 
    (print "Ausgabe von Vektorgrafik als PDF") 
) 
 
(defmethod ausgeben((vektor VektorGrafik)(bs Bildschirm)) 
    (print "Ausgabe von Vektorgraphik auf Bildschirm") 
)

Listing 5.20    Multiple Dispatch in CLOS

Im aufgeführten Beispiel haben wir vier verschiedene Umsetzungen der Operation ausgeben. Welche davon im konkreten Fall aufgerufen wird, ist abhängig von den Objekten, die für die beiden Parameter jeweils übergeben werden.

CLOS flexibler als andere OO-Sprachen

Damit bietet CLOS mehr Möglichkeiten für polymorphe Methoden als andere objektorientierte Sprachen. Allerdings geht hier auch die intuitive Vorstellung verloren, dass eine Methode immer genau zu einer Klasse beziehungsweise eine Operation immer zu genau einem Objekt gehört. Wir können hier nicht mehr davon sprechen, dass wir eine Operation auf einem Objekt durchführen.

Diskussion: Methode und Objekt

Gregor: Das ist aber doch gar nicht intuitiv. Hier sind ja Operationen und Methoden überhaupt nicht mehr einer Klasse zugeordnet. Wir sehen hier gar nicht mehr, dass eine Operation auf einem Objekt ausgeführt wird. Je nachdem, welche Parameter ich übergebe, passieren ganz unterschiedliche Dinge.

Bernhard: Du hast Recht, das läuft der gängigen Vorgehensweise der objektorientierten Programmierung entgegen und ist tatsächlich zunächst nicht intuitiv. Wie wir gesehen haben, bietet der Ansatz auf der anderen Seite aber auch mehr Flexibilität. In der Praxis haben sich aber doch eher Sprachen durchgesetzt, die eine Operation genau einem Objekt zuordnen.

Das Entwurfsmuster »Besucher«: Multiple Dispatch im Einsatz

Im letzten Abschnitt haben Sie die Möglichkeiten des Multiple Dispatch, also der Polymorphie, die sich auf mehrere Objekte bezieht, kennen gelernt. Jetzt werden Sie eine praktische Anwendung dafür kennen lernen. Zunächst stellen wir dazu das Entwurfsmuster »Besucher« vor, bei dem Multiple Dispatch die Basis bildet.

Bevor Sie das Muster gleich an einem Beispiel kennen lernen, finden Sie in Abbildung 5.30 zunächst die Sicht auf die Klassenstruktur des Entwurfsmusters.


Icon Hinweis Entwurfsmuster »Besucher« (engl. Visitor)

Das Entwurfsmuster »Besucher« kapselt eine Operation als ein Objekt. Dieses Objekt wird durch eine bestehende Struktur von anderen Objekten, zum Beispiel eine Baumstruktur, hindurchgereicht. Es besucht also jedes Objekt in der Struktur. Dabei führt es die gekapselte Operation auf jedem der Objekte aus. Der grundlegende Vorteil des Entwurfsmusters ist es, dass neue Operationen definiert werden können, die auf der gesamten Struktur ausgeführt werden, ohne dass die in der Struktur enthaltenen Objekte oder deren Klassen selbst angepasst werden müssen.


Abbildung 5.30    Klassenstruktur des Entwurfsmusters »Besucher«

Wenn Sie Abbildung 5.30 mit Abbildung 5.28 vergleichen, in der unser Beispiel zu Multiple Dispatch aufgeführt ist, werden Sie bereits eine starke Ähnlichkeit feststellen. Ein Aufruf der Operation willkommen auf einem Element der Struktur führt dazu, dass unterschiedliche Operationen auf dem übergebenen Besucherobjekt aufgerufen werden, je nachdem, zu welcher Klasse das Element gehört.

So richtig interessant wird dieses Verfahren allerdings erst dann, wenn die Elemente wirklich in einer Struktur angeordnet sind, zum Beispiel in der Form eines Baums. Dies ist zum Beispiel dann der Fall, wenn eine Art der Elemente wiederum andere Elemente enthalten kann. Betrachten wir diese Situation an einem konkreten Beispiel.

Icon Beispiel Preisberechnung

Nehmen Sie an, Sie sind Besitzer des kleinen Computerladens aus Abbildung 5.31.

Abbildung 5.31    Ein kleiner Computerladen

Da Ihr kleines Startup-Unternehmen noch im Aufbau ist, verkauft es genau drei Arten von Produkten:

1. Einzelne Computerteile. Dazu gehören zum Beispiel Grafikkarten, Festplatten oder Tastaturen.
       
2. Dienstleistungen. Dazu gehören die Installation eines Betriebssystems auf dem Rechner oder die Einbindung des Rechners in ein lokales Netzwerk.
       
3. Von Ihnen zu einem sinnvollen Ganzen zusammengesetzte oder zusammengestellte Teile und Dienstleistungen. Dazu gehört zum Beispiel ein von Ihnen zusammengeschraubter Rechner oder auch ein aufeinander abgestimmter Satz von Peripheriegeräten. Ihr Rundum-sorglos-Premium-Paket besteht zum Beispiel aus komplett montiertem Rechner mit allen notwendigen Peripheriegeräten wie Drucker, Scanner und der kompletten Installation und Inbetriebnahme vor Ort.
       

Um diese verschiedenen Produkte abzubilden, wählen Sie die Klassenstruktur aus Abbildung 5.32.

Abbildung 5.32    Klassenstruktur für Produkte eines Computerladens

Zusammengesetzte Produkte können also beliebig viele weitere Produkte enthalten, die selbst wieder zusammengesetzt sein können.

Das Preismodell, das Sie für Ihre Produkte ansetzen, ist ebenfalls ein sehr einfaches. Jedes Hardwareteil hat seinen Preis direkt zugeordnet, so dass bei einem Einzelverkauf natürlich unmittelbar klar ist, was zum Beispiel eine Grafikkarte kostet. Ihre Dienstleistung rechnen Sie mit einem Stundensatz von 40 Euro ab, dabei setzen Sie für Standardaufgaben aber eine feste Stundenzahl an. Wenn Sie ein zusammengesetztes Produkt verkaufen, also zum Beispiel einen kompletten Rechner, setzen Sie zusätzlich zu den Einzelpreisen noch einmal einen festen Betrag für jeden Bestandteil an, um damit Ihren Aufwand für das Zusammenbauen einzurechnen. Bei einem Rechner sind das zum Beispiel 5 Euro pro eingebautem Teil. Ein sehr simples Preismodell, zugegeben, aber für einen Kunden auch einfach nachvollziehbar. Jedenfalls einfacher als die meisten gängigen Mobilfunktarife.

Nun möchten Sie natürlich für jedes verkaufte Paket auch den Preis berechnen können. Und nicht nur das: Sie möchten auch für jedes verkaufte Teil in Ihrem Lagerbestand vermerken, dass nun ein Exemplar weniger davon auf Lager ist.

Hier bietet sich das Entwurfsmuster »Besucher« für die Umsetzung an. In Abbildung 5.33 ist die Klassenhierarchie der Produkte angepasst, so dass die Produkte Besucher empfangen können.

Abbildung 5.33    Produkte mit der Fähigkeit, Besucher zu empfangen

Wenn Sie auf Basis dieser Struktur eine Preisberechnung durchführen wollen, rufen Sie einfach die Operation willkommen auf dem obersten Element der Struktur auf, also zum Beispiel auf Ihrem Rundum-sorglos-Paket, das ja ein Exemplar der Klasse ZusammengesetztesProdukt ist. Als Wert für den Parameter besucher geben Sie eine Umsetzung eines Besuchers an, welche die Preise der einzelnen Elemente addiert. Wie diese Umsetzung aussieht, werden Sie gleich noch sehen. Die Produktstruktur selbst sorgt dafür, dass der Besucher von einem Produkt zum nächsten weitergereicht wird. Der dazu notwendige Code am Beispiel einer Implementierung in Java ist in Listing 5.21 zu sehen. Bei einem zusammengesetzten Produkt wird die Operation willkommen auch auf allen enthaltenen Produkten aufgerufen.

class ZusammengesetztesProdukt extends Produkt { 
... 
    void willkommen(ProduktBesucher besucher) { 
        besucher.besucheZusammengesetztesProdukt(this); 
        Iterator<Produkt> iter = produkte.iterator(); 
        while (iter.hasNext()) { 
            Produkt produkt = iter.next(); 
            produkt.willkommen(besucher); 
        } 
    } 
}

Listing 5.21    Weiterreichen eines Besuchers

Für Hardwareteile und Dienstleistungen erfolgt dagegen einfach ein Aufruf der Operation zum Besuch eines spezifischen Produkts, Listing 5.22 zeigt das am Beispiel.

Hardwareteil extends Produkt {
    ...
    void willkommen(ProduktBesucher besucher) {
        besucher.besucheHardwareteil(this); 
    } 
}

Listing 5.22    Besuch eines Stücks Hardware

Multiple Dispatch im Einsatz

Hier sehen Sie auch den Auftritt für das Verfahren des Multiple Dispatch, das Sie in Abschnitt 5.2.4 kennen gelernt haben. Die erste Verteilung auf Basis der Polymorphie ist nämlich nun bereits erfolgt: Es ist aufgrund der konkreten Klassenzugehörigkeit eines Produkts eine konkrete Umsetzung der Operation willkommen gewählt worden. Mit dem nun dargestellten Aufruf von besucheHardwareteil wird wiederum eine Verteilung vorgenommen, die aufgrund der Klassenzugehörigkeit des Besuchers eine Zuordnung vornimmt.

In Abbildung 5.34 ist die Darstellung um die Klassenstruktur der Besucher erweitert.

Eine Preisberechnung auf einem zusammengesetzten Produkt sieht anders aus als auf einem Hardwareteil oder einer Dienstleistung. Deshalb sind für den PreisBesucher jeweils spezifische Berechnungen umgesetzt, die in Listing 5.23 aufgeführt sind.

Abbildung 5.34    Klassenstruktur von Produkten und deren Besuchern

class PreisBesucher implements ProduktBesucher { 
 
    double gesamtpreis; 
    ... 
 
    public void besucheZusammengesetztesProdukt( 
                ZusammengesetztesProdukt gruppe) { 
        gesamtpreis += gruppe.anzahlElemente() 
                * gruppe.zusatzPreisProElement(); 
    } 
 
    public void besucheHardwareteil(Hardwareteil hardware) { 
        gesamtpreis += hardware.preis(); 
    } 
 
    public void besucheDienstleistung(Dienstleistung 
                                 dienstleistung) { 
        gesamtpreis += 
                  dienstleistung.erwarteterZeitaufwand() 
                * dienstleistung.stundensatz(); 
    } 
...

Listing 5.23    Unterschiedliche Varianten der Preisberechnung

Vorteil des Entwurfsmusters »Besucher«

In Abbildung 5.34 sehen Sie außer dem PreisBesucher auch gleich noch eine weitere Klasse LagerAktualisierungsBesucher, die ebenfalls die Schnittstelle von ProduktBesucher implementiert. Weitere ließen sich hinzufügen, ohne dass Sie irgendeine Änderung an der Struktur Ihrer Produktklassen vornehmen müssten. Es ist der wichtigste Vorteil des Entwurfsmusters »Besucher«, dass neue Operationen auf der bestehenden Produktstruktur hinzugefügt werden können, ohne dass diese selbst angepasst werden muss.

Wie läuft aber nun so ein kompletter Besuch ab? In Abbildung 5.35 ist ein Beispiel dargestellt, wie denn nun so eine konkrete Rechnerkonfiguration zusammengesetzt sein könnte.

Das Komplettpaket, das Sie verkauft haben, besteht in diesem Fall aus der Installation (einer Dienstleistung) und zwei weiteren Paketen: den internen Komponenten des Rechners und der Peripherie. Ein PreisBesucher durchläuft den entstandenen Baum und klappert dabei die jeweiligen Knoten ab. Dabei wird er an den Knoten die jeweils spezifische Preisberechnung durchführen.

Abbildung 5.35    Beispiel für zusammengesetzte Produkte

Der Besucher, der über den Aufruf rechnerpaket.willkommen (besucher) seine Arbeit aufnimmt, wird in diesem Fall einen Gesamtpreis von 1135 Euro ermitteln:

  • 1075 Euro Einzelpreise der Bauteile
  • 20 Euro für die internen Komponenten (jeweils 5 Euro für die vier enthaltenen Elemente)
  • 40 Euro für die Installation. Diese Dienstleistung wird mit einer Stunde Arbeitszeit bei einem Stundensatz von 40 Euro berechnet.

Wenn Sie die Rechnung überprüfen wollen, können Sie dies tun, indem Sie den Produktbaum selbst durchgehen und die Berechnungen durchführen. Alternativ und einfacher können Sie sich aber auch das ausführbare Beispiel von der Webseite zum Buch (www.objektorientierte-programmierung.de) laden und die Berechnung darüber durchführen lassen.

In Listing 5.24 ist zur Ergänzung der Source-Code in Java aufgeführt, mit dem ein Rechnerpaket zusammengestellt und die Preisberechnung darauf durchgeführt wird.

Source-Code- Beispiel

public static void main(String[] args) { 
    // Konfiguration eines einfachen Rechners, 
    // Hardware mit ihrem Preis in Euro angelegt. 
    Hardwareteil festplatte = new Hardwareteil(200); 
    Hardwareteil cpu = new Hardwareteil(300); 
    Hardwareteil cdbrenner = new Hardwareteil(120); 
    Hardwareteil platine = new Hardwareteil(100); 
 
    Hardwareteil bildschirm = new Hardwareteil(250); 
    Hardwareteil tastatur = new Hardwareteil(25); 
    Hardwareteil gehäuse = new Hardwareteil(80);

Interne Komponenten als Zusammensetzung

     // Zusammengesetzte Produkte werden mit einem 
    // Zusatzpreis in Euro pro enthaltenem Produkt angelegt. 
    ZusammengesetztesProdukt interneKomponenten = 
                new ZusammengesetztesProdukt(5); 
    interneKomponenten.add(festplatte); 
    interneKomponenten.add(cpu); 
    interneKomponenten.add(platine); 
    interneKomponenten.add(cdbrenner); 
 
    ZusammengesetztesProdukt peripherie = 
                new ZusammengesetztesProdukt(0); 
    peripherie.add(tastatur); 
    peripherie.add(bildschirm); 
    // Eine Dienstleistung wird mit einem Stundensatz 
    // in Euro und der erwarteten Arbeitszeit in Stunden 
    // angelegt 
    Dienstleistung installation = 
               new Dienstleistung(40,1); 
 
    ZusammengesetztesProdukt rechner = 
               new ZusammengesetztesProdukt(0); 
    rechner.add(interneKomponenten); 
    rechner.add(peripherie); 
    rechner.add(gehäuse); 
    rechner.add(installation);

Besucher wird losgeschickt.

    PreisBesucher besucher = new PreisBesucher(); 
    rechner.willkommen(besucher); 
 
    System.out.println( 
             "Gesamtpreis für Rechnerkonfiguration: " 
           + besucher.gesamtpreis()); 
}

Listing 5.24    Source-Code für Zusammenstellung eines Rechners

Bestandteile des Entwurfsmusters des Besuchers

Das Entwurfsmuster des Besuchers besteht, wie Sie gesehen haben, aus zwei Bestandteilen.

  • Zum einen beschreibt es eine Navigation über eine zusammengesetzte Struktur.
  • Der Kern ist aber der andere Bestandteil, die Umsetzung des Double Dispatch. Dieser erlaubt es uns, Methoden abhängig von der Klasse des Besuchers und des besuchten Elements aufzurufen.

Die Prinzipien von Besuchern


Das Entwurfsmuster und die Prinzipien

Die Verwendung eines Besuchers hilft Ihnen dabei, zwei Prinzipien der Objektorientierung besser umzusetzen. Das Prinzip einer einzigen Verantwortung wird dadurch unterstützt, dass eine Besucherklasse sich auf eine ganz konkrete Aufgabe beschränken kann, zum Beispiel einen Preis berechnen. Sie ist nicht dafür zuständig, über eine Struktur von Elementen zu navigieren, und sie muss über diese Elementstruktur auch nur ein begrenztes Wissen haben.

Das Prinzip Offen für Erweiterung, geschlossen für Änderung wird dadurch unterstützt, dass weitere Arten von Besuchern für eine Struktur von Elementen hinzugefügt werden können, ohne dass in diese Struktur eingegriffen werden muss. Damit ist das System offen für Erweiterung durch neue Besucher, aber geschlossen für Änderungen an der Repräsentation der Struktur von Elementen.

Allerdings kann das Muster auch zu einer engen Kopplung zwischen Besuchern und den besuchten Objekten führen. Die besuchten Objekte müssen nämlich ihre Schnittstelle so gestalten, dass ein Besucher auf die benötigten Informationen zugreifen kann. Damit kann es passieren, dass die Datenkapselung zum Teil aufgeweicht wird. Ein Beispiel dafür ist die Berechnung der Zusatzkosten für zusammengesetzte Produkte im Beispiel dieses Abschnitts: Die Information über die Anzahl der enthaltenen Produkte wurde nur offen gelegt, weil der Preisbesucher diese Information zur Preisberechnung benötigt. Die Verwendung des Musters sollte also wie bei allen Entwurfsmustern das Ergebnis einer bewussten Abwägung sein. Die Vorteile kommen vor allem dann zur Geltung, wenn zu erwarten ist, dass nachträglich häufig neue Operationen hinzukommen, die ebenfalls auf der gesamten Elementstruktur ausgeführt werden sollen.


Bei der Vorstellung des Entwurfsmusters »Besucher« haben Sie ja nun auch einiges über zusammengesetzte Strukturen von Elementen erfahren und wie diese genutzt werden. Dadurch haben Sie neben dem Entwurfsmuster »Besucher« auch gleich noch ein weiteres Entwurfsmuster mit kennen gelernt, ohne dass dieses namentlich genannt wurde: das Entwurfsmuster »Kompositum«. Wir stellen es nun auch offiziell vor.


Icon Hinweis Entwurfsmuster »Kompositum« (Composite)

Das Kompositum-Muster wird angewendet, um eine einheitliche Behandlung von Elementen in Strukturen zu ermöglichen, die aus zusammengesetzten Elementen bestehen können. Durch eine gemeinsame Oberklasse oder Schnittstelle wird eine einheitliche Sicht auf die beteiligten Elemente ermöglicht. Sowohl die zusammengesetzten Elemente also auch die atomaren Elemente, aus denen sie sich zusammensetzen, erben die Spezifikation dieser Oberklasse. Damit ist eine einheitliche Behandlung dieser Elemente möglich. Insbesondere wird es möglich, alle Elemente einer zusammengesetzten Struktur zu durchlaufen und definierte Operationen darauf auszuführen. Das Durchlaufen der Struktur von Elementen wird auch als Traversieren der Elemente bezeichnet.


Ein Beispiel für eine solche zusammengesetzte Struktur sind hierarchische Dateisysteme. Es gibt eine ganze Reihe von Aspekten, unter denen eine Datei und ein Verzeichnis, das Dateien enthält, gleich behandelt werden können. In Abbildung 5.36 ist die Klassenstruktur des Musters dargestellt. Sie kennen die Ausprägung dieser Struktur auch schon aus unserem Beispiel in Abbildung 5.32.

Abbildung 5.36    Klassenstruktur des Entwurfsmusters »Kompositum«

Einsatz

Das Entwurfsmuster »Kompositum« ist vor allem dann sinnvoll anwendbar, wenn Elemente und zusammengesetzte Elemente gleich behandelt werden sollen und können. In der Regel wird ein Kompositum bei Aufruf einer Operation diesen Aufruf auch an alle in ihm enthaltenen Elemente weiterleiten. Im Fall eines Dateisystems könnte zum Beispiel die Operation loeschen() für ein Verzeichnis so implementiert sein, dass sie die Operation zunächst an alle enthaltenen Verzeichnisse und Dateien delegiert, um dann erst das Verzeichnis selbst zu löschen.


Rheinwerk Computing - Zum Seitenanfang

5.2.5 Die Tabelle für virtuelle Methoden  topZur vorigen Überschrift

In den vorhergehenden Abschnitten haben Sie die Konzepte der Polymorphie und ihrer Anwendungen kennen gelernt. Damit diese Konzepte in der Praxis funktionieren, müssen objektorientierte Sprachen und deren Laufzeitsysteme einen technischen Mechanismus bereitstellen, der die Umsetzung der späten Bindung konkret realisiert.

In diesem Abschnitt erfahren Sie deshalb am Beispiel von C++ einiges über die sogenannte Virtuelle-Methoden-Tabelle. Diese dient der Realisierung der späten Bindung in C++. In der Praxis ist es nützlich, die Grundlagen der technischen Umsetzung zu kennen. Die Überraschung über einige in der Praxis auftretende Phänomene hält sich dann eher in Grenzen.

Späte Bindung

Rein abstrakt ist der Mechanismus der späten Bindung recht einfach zu beschreiben: Welche Methode eines Objekts aufgerufen wird, entscheidet sich erst zur Laufzeit eines Programms, abhängig davon, welchen Typ das Objekt hat, auf dem die Methode aufgerufen wird.

Beispiel C++

Am Beispiel der Sprache C++ stellen wir im Folgenden vor, was die praktischen Konsequenzen sein können. Wir werden feststellen, dass C++ das Prinzip Offen für Erweiterung, geschlossen für Änderung für das Thema »späte Bindung« nur sehr begrenzt unterstützt. In der Praxis greift hier das Gesetz der lückenhaften Abstraktion (Law of leaky Abstractions), das von Joel Spolsky formuliert wurde: Jede nichttriviale Abstraktion ist zu einem bestimmten Grad lückenhaft. [Joel Spolsky: http://www.joelonsoftware.com/articles/LeakyAbstractions.html ]

Fragile Binary Interface Problem

Ein bestimmtes Problem, dass Änderungen an Software erschwert werden, hat folgenden Grund: Änderungen an Basisklassen können dazu führen, dass davon abhängige Bibliotheken nicht mehr korrekt arbeiten. Es wird auch als Problem der zerbrechlichen binären Schnittstellen (Fragile Binary Interfaces) bezeichnet.

Das beschriebene Problem tritt auf, weil Bibliotheken und ausführbare Module so konstruiert sind, dass eine Änderung an Teilen, die im Bereich der Quelltexte ohne Probleme möglich ist, zu Problemen führt, wenn nur ein Teil der genutzten Bibliotheken neu erstellt und ausgeliefert wird. Dieses Problem besteht nicht nur bei objektorientierten Sprachen. Dort kommen allerdings einige spezifische neue Probleme hinzu, die sich zum großen Teil im Bereich der Abbildung von Operationen auf Methoden bewegen.

Fragile Binary Interface Problem


Das Problem der zerbrechlichen binären Schnittstellen (engl. Fragile Binary Interface Problem)

In vielen objektorientierten Sprachen führen Änderungen an Basisklassen oder Schnittstellen dazu, dass davon abhängige, bereits kompilierte Klassen (insbesondere abgeleitete Klassen) nicht mehr wie erwartet arbeiten. Der Grund liegt darin, dass durch Änderungen an Basisklassen das Layout der konstruierten Objekte im Speicher geändert wird. Wenn man die abhängigen Klassen selbst nicht neu kompiliert, können Zugriffe auf ihre Exemplare oder aus ihren Exemplaren zu Fehlern führen. Diese kann man durch eine Neukompilierung des gesamten Programmes beseitigen. Das Problem ist zum Beispiel in den gängigen objektorientierten Sprachen C++, Java und C# präsent.


Da für diese Abbildung in der Regel die sogenannte Virtuelle-Methoden-Tabelle (VMT) verwendet wird, können die resultierenden Probleme nur verstanden werden, wenn die Grundstruktur dieser Tabelle bekannt ist. Wir stellen deshalb im Folgenden die Umsetzung dieser VMT und ihr Verhalten bei Änderungen vor.

Die Virtuelle-Methoden-Tabelle (VMT)

Um späte Bindung zu realisieren, wird zur Laufzeit eines Programms für einen Methodenaufruf eine zusätzliche Abstraktionsebene benötigt, mittels der aufgrund des konkreten Typs eines Objekts entschieden wird, welche Methode denn nun aufgerufen werden soll.

Bei Sprachen, die nicht interpretiert, sondern kompiliert werden, wird zur Umsetzung dieser Abstraktionsebene in der Regel ein Konstrukt eingeführt, das als Virtuelle-Methoden-Tabelle bezeichnet wird (kurz auch vtable oder VMT). Polymorphe Methoden, also solche, die in Ableitungen überschrieben werden können, werden in C++ als virtuelle Methoden bezeichnet, daher auch der Name Virtuelle-Methoden-Tabelle.

Statische Polymorphie

Machen wir keinen Gebrauch von später Bindung, so kann für ein Programm bereits vor der Laufzeit (also z. B. beim Durchlauf eines Compilers) eine direkte Zuordnung von Methodenaufrufen vorgenommen werden. Für jede Aufrufstelle einer Operation ist völlig klar, welche Methode damit genau gemeint ist. Wir haben nur statische Polymorphie vorliegen.

Bei der Verwendung von später Bindung ist das nicht so: Eine Variable, die als Referenz auf den Typ einer Basisklasse definiert ist, kann zur Laufzeit auch auf ein Exemplar einer abgeleiteten Klasse verweisen. Erst zur Laufzeit können wir also entscheiden, welche Methode aufgerufen werden muss.

Objekterstellung und VMT

Um das leisten zu können, wird in C++ jeder Klasse, die mindestens eine virtuelle Methode besitzt, eine eigene VMT zugeordnet. In dieser Tabelle ist verzeichnet, welche Operation für genau dieses Objekt auf welche Methode abgebildet wird. Bei seiner Erstellung über einen Konstruktor wird jedem Objekt mitgegeben, welche VMT das Objekt verwenden soll. Bei der Konstruktion eines Objekts ist bekannt, von welcher Klasse dieses Objekt ein Exemplar ist. Also kann zu diesem Zeitpunkt auch entschieden werden, welche VMT das Objekt verwenden muss. Die VMT ist also die zusätzliche Zwischentabelle, über die zur Laufzeit dann entschieden wird, welche konkrete Methode bei Aufruf einer Operation angesteuert wird. Es wird damit zu einer Eigenschaft des Objekts (nicht mehr der Klasse), welche Methode aufgerufen wird.

Aber Moment: Ein Objekt kann ja ein Exemplar von mehreren Klassen sein, da wir möglicherweise eine Hierarchie von Klassen vorliegen haben. Welche VMT greift denn in diesem Fall?

In Abbildung 5.37 ist eine sehr einfache Hierarchie dargestellt, die zwei virtuelle Methoden nutzt: print und save.

Abbildung 5.37    Einfache Hierarchie mit virtuellen Methoden

Die Zuordnung der VMTs zu konkreten Exemplaren ist in Abbildung 5.38 dargestellt. Dabei ist zu sehen, dass jedes Exemplar einer Klasse einen Verweis auf deren VMT erhält. Die VMT selbst ist hauptsächlich eine Tabelle von Zeigern auf Funktionen. Da die abgeleiteten Klassen nur jeweils eine der virtuellen Methoden überschreiben, enthält ihre VMT jeweils auch einen Funktionszeiger auf eine Methode der jeweiligen Superklasse.

Abbildung 5.38    Abstrakte Darstellung der Zuordnung von Methoden über VMT

Compiler generiert Funktionsaufruf.

Der Compiler generiert, wenn er den Aufruf einer virtuellen Methode findet, einen Aufruf der Funktion an einer bestimmten Position der VMT. Welche Methode zur Laufzeit aufgerufen wird, bestimmt sich also durch zwei Kriterien:

  • den Zeiger auf die VMT der Klasse
  • die Position der Methode in der Liste von virtuellen Methoden der Klasse

Reihenfolge virtueller Methoden

In Abbildung 5.38 wird ersichtlich, dass die virtuellen Methoden in einer bestimmten Reihenfolge in den jeweiligen VMTs abgelegt sind. In der Regel ist das die Reihenfolge, in der die Methoden in der Quelldatei definiert werden, ein Compiler kann sich aber auch für andere Reihenfolgen entscheiden. Führen abgeleitete Klassen neue virtuelle Methoden ein (in unserem Beispiel die Methode rotate der Klasse Image), so werden diese am Ende der Tabelle angefügt.

Umsetzung von VMT: Das Open-Closed-Prinzip wird nicht unterstützt

Mögliche Fehlersituationen

Betrachten wir nun die möglichen Fehlersituationen, die entstehen können, wenn wir Anpassungen an den virtuellen Methoden einer Klasse vornehmen. Grundsätzlich entstehen hier keine Probleme, wenn wir in allen Situationen alle beteiligten Source-Dateien komplett neu übersetzen. Dies ist in der Praxis aber oft nicht möglich und auch nicht erwünscht. Wir wollen oft durch eine Anpassung von virtuellen Methoden eine lokale Änderung vornehmen, die zu einer Erweiterung des Programms führt, ohne dass wir alle betroffenen Module kennen und neu übersetzen müssen.

Virtuelle Methode überschreiben

Was passiert also, wenn wir eine virtuelle Methode in einer abgeleiteten Klasse überschreiben?

Die Antwort ist hier: Es hängt davon ab. Und zwar von zwei Randbedingungen: Hatte die Klasse bereits vorher andere virtuelle Methoden überschrieben? Und nutzt der Compiler diese Information zu Optimierungen aus?

Nur wenn eine Klasse selbst virtuelle Methoden einführt oder überschreibt, ist es auch notwendig, dass die Klasse eine eigene VMT erhält. Im anderen Fall wird diese nämlich genau gleich der VMT der direkten Oberklasse sein. Ein Compiler kann diese Information ausnutzen und sich die VMT für diese Klasse sparen, auch wenn eine von deren Superklassen bereits virtuelle Methoden eingeführt hat. Exemplare dieser Klasse bekommen dann bei der Konstruktion einen Verweis auf die VMT der Oberklasse zugewiesen. Eine Teilhierarchie für einen solchen Fall ist in Abbildung 5.39 dargestellt, Abbildung 5.40 zeigt, wie die VMT der Klasse InterpolatedImage einfach eingespart wird.

Abbildung 5.39    Abgeleitete Klasse ohne eigene virtuelle Methoden

So weit, so optimiert. Aber was passiert, wenn wir nun einer solchen Klasse selbst eine virtuelle Methode geben und die Code-Stellen, an denen ein Exemplar der Klasse konstruiert wird, nicht neu kompilieren? Ganz klar: Es wird eine neue VMT für diese Klasse erstellt, aber kein Objekt wird darauf verweisen, weil ja dort immer noch die alte VMT (diejenige der direkten Oberklasse) referenziert wird.

Abbildung 5.40    Verweis auf VMT der Superklasse

Keine Verhaltensänderung

Der Effekt ist, dass das Verhalten unseres Programms sich durch die neue virtuelle Methode nicht ändert, was nicht unbedingt intuitiv erwartet würde.

Abbildung 5.41    Neue virtuelle Methode ohne Neukompilation der Objektkonstruktion

Abbildung 5.41 stellt die Situation dar, nachdem die Klasse InterpolatedImage die virtuelle Methode rotate überschrieben hat. Die Stellen, an denen ein Exemplar von InterpolatedImage konstruiert wird, wurden aber nicht neu kompiliert. Wir haben zwar eine schöne neue VMT für die Klasse erhalten, aber nirgends im ganzen Programm wird diese verwendet werden, da keine Verweise darauf existieren.

OK, aber es ist ja nun eher die Ausnahme, dass eine Klasse noch keine virtuelle Methode hat. Zumindest einen virtuellen Destruktor sollten wir ihr verpasst haben.

Existierende virtuelle Methode überschreiben

Was passiert also, wenn wir in einer solchen Klasse eine weitere virtuelle Methode überschreiben? Auch hier ändert sich die Virtuelle-Methoden-Tabelle der Klasse. Allerdings verschieben sich keine Einsprungpunkte für den Methodenaufruf, sondern es wird lediglich ein Zeiger in der VMT umgesetzt. Relevant ist das für die Klasse, die wir gerade modifiziert haben, und für alle davon abgeleiteten Klassen. Diese müssen wir also neu übersetzen. Falls wir das nicht tun, werden diese einfach dabei bleiben, die ihnen vorher bekannte Methode der betreffenden Basisklasse aufzurufen. Das ist nicht das, was wir erreichen wollen. Also müssen alle abgeleiteten Klassen, welche die neu überschriebene Methode nutzen sollen, auch neu kompiliert werden.

Komplett neue virtuelle Methode

Nun zu einer anderen Fehlersituation: Was passiert, wenn wir eine komplett neue virtuelle Methode zu einer Klasse hinzufügen?

Zunächst einmal haben wir das bereits vorher beschriebene Problem, falls die Klasse vorher noch überhaupt keine virtuellen Methoden hatte. Aber es kommen zusätzliche Komplikationen dazu.

Abbildung 5.42    Aufruf von Methoden über Offset in VMT

Abbildung 5.42 illustriert noch einmal, dass der Aufruf einer virtuellen Methode über eine ganz konkrete Position in der VMT gemappt wird.

Funktionszeiger verschoben

Aber: Die VMT der betroffenen Klasse hat sich durch die Anpassung so verändert, dass die Funktionszeiger sich nun an anderer Stelle befinden. Bei Aufrufen von virtuellen Methoden der Klasse und von deren Ableitungen hat der Compiler aber bereits die Position der Methode in der VMT eingetragen. Wenn wir die Aufrufstellen aller virtuellen Methoden dieser und abgeleiteter Klassen nicht neu kompilieren, werden die veränderten Positionen in der VMT nicht berücksichtigt, und es wird ganz einfach die falsche Methode aufgerufen. Die Ursachensuche für die resultierenden Fehler gestaltet sich dann oft schwierig.

Abbildung 5.43 illustriert das Problem. Wir nehmen dabei an, dass zu der Klasse Data eine neue virtuelle Methode load hinzugefügt wurde, ohne dass die Aufrufstellen von Methoden für die abgeleitete Klasse Image neu kompiliert wurden.

Abbildung 5.43    Aufruf der falschen Funktion durch Veränderung der VMT

Bei einem Aufruf der Methode rotate auf einem Exemplar der Klasse Image wird nach wie vor die dritte Methode in deren VMT aufgerufen. Aber: Das ist gar nicht mehr rotate, sondern an dieser Stelle steht jetzt die Methode load der Basisklasse Data.

Technische Konsequenzen

Zusammenfassend können wir also sagen: Bei der Objektkonstruktion entscheidet der Compiler, welche VMT verwendet wird, beim Aufruf einer virtuellen Methode, an welcher Stelle der VMT diese Methode erwartet wird.

Ändert sich also die Information, welche VMT für eine Klasse verwendet wird, müssen die Stellen neu kompiliert werden, an denen Exemplare dieser Klasse konstruiert werden.

Ändert sich die Position einer Methode in der VMT, so müssen alle Aufrufstellen aller virtuellen Methoden der betroffenen und abgeleiteten Klassen neu kompiliert werden.

Diskussion: Warum so technisch?

Gregor: Müssen wir uns denn wirklich auf dieser technischen Ebene mit dem Thema Polymorphie beschäftigen? Eigentlich würde ich mir ja von einer echten Programmiersprache erwarten, dass sie mich von diesen Details abschirmt.

Bernhard: Ja, schön wäre es. Aber wir leben nicht in einer idealen Welt, schon gar nicht, wenn es um die Entwicklung von Software geht. Das Beispiel von C++ zeigt ja bereits, dass wir schon eine ganze Menge technischer Restriktionen haben, die unsere Möglichkeiten für Änderungen in der Praxis einschränken.

Gregor: O.k., aber C++ ist ja auch eine etwas ältere Sprache. Bei Java zum Beispiel haben wir doch sicher weniger Probleme.

Bernhard: Bei Java sieht es zwar etwas besser aus, aber auch dort gibt es eine große Zahl von Situationen, bei denen wir durch technische Effekte mit Änderungen eine Anwendung in einen fehlerhaften Zustand bringen können. Die Situation ist zum Teil sogar noch etwas komplizierter, weil sich Änderungen an Klassen und Interfaces unterschiedlich verhalten. Wir haben auch hier eine lückenhafte Abstraktion vorliegen und müssen uns mit den Details der Umsetzung beschäftigen.

Konstruktion und Destruktion von Objekten mit polymorphen Methoden

Einen besonderen Status haben polymorphe Methoden während der Konstruktion und Destruktion von Objekten.

Ein Objekt durchläuft während der Konstruktion verschiedene Stufen, da die Konstruktoren der jeweiligen Klassenhierarchie sukzessive aufgerufen werden. Dabei wird der Konstruktor der Basisklasse zuerst aufgerufen, dann absteigend in der Klassenhierarchie die jeweils folgenden.

Objekt entsteht nach und nach.

Das Objekt wird also erst nach und nach zu dem speziellen Objekt, das es sein soll. Dadurch ergeben sich für den Aufruf von polymorphen Methoden in einem Konstruktor spezielle Randbedingungen, die zum Teil technisch bedingt sind und mit der gewählten Programmiersprache in Zusammenhang stehen.

Am besten zeigen wir das anhand von Beispielen in C++ und Java, die sich an dieser Stelle unterschiedlich verhalten. Bei Java verhalten sich Methoden auch bei einem Aufruf im Konstruktor polymorph. Das heißt, wenn wir gerade ein Exemplar einer Subklasse konstruieren, wird bei polymorphen Methoden auch im Konstruktor der Basisklasse die Methode der Subklasse aufgerufen.

Polymorphie im Konstruktor in Java

public class ClassA { 
    protected Integer value1 = 1; 
 
    ClassA() { 
        polymorphicMethod("ClassA"); 
    } 
    void polymorphicMethod(String myclass) { 
    System.out.println("ClassA aus Konstr. von " + myclass); 
    } 
} 
public class ClassB extends ClassA { 
 
    protected String valueB = "initial"; 
    ClassB() { 
        valueB = "set"; 
        polymorphicMethod("ClassB"); 
    } 
    void polymorphicMethod(String myclass) { 
        System.out.println("ClassB aus Konstr. von " 
                + myclass + " valueB: " + valueB ); 
    } 
} 
public class ClassC extends ClassB { 
 
    protected String valueC = "initial"; 
    ClassC() { 
        valueC = "set"; 
        polymorphicMethod("ClassC"); 
    } 
    void polymorphicMethod(String myclass) { 
        System.out.println("ClassC aus Konstr. von " 
    + myclass + " valueB/valueC: " + valueB + "/" + valueC); 
    } 
 
} 
public static void main(String[] args) { 
    ClassA a = new ClassA(); 
    System.out.println(); 
    ClassB b = new ClassB(); 
    System.out.println(); 
    ClassC c = new ClassC(); 
}

Listing 5.25    Polymorphe Methoden im Konstruktor bei Java

In Listing 5.25 ist ein Beispiel für die Verwendung von polymorphen Methoden in einem Konstruktor in Java aufgeführt. Das gelistete Programm liefert die unten stehende Ausgabe:

ClassA aus Konstr. von ClassA 
 
ClassB aus Konstr. von ClassA valueB: null 
ClassB aus Konstr. von ClassB valueB: set 
 
ClassC aus Konstr. von ClassA valueB/valueC: null/null 
ClassC aus Konstr. von ClassB valueB/valueC: set/null 
ClassC aus Konstr. von ClassC valueB/valueC: set/set

Methodenaufrufe sind polymorph.

Erwartungsgemäß werden die Konstruktoren jeweils beginnend mit der Basisklasse aufgerufen. Beim Aufruf einer polymorphen Methode wird immer die Methode der speziellsten Klasse aufgerufen, Aufrufe werden also im Konstruktor nicht anders gehandhabt als in normalen Methoden. Allerdings wird hier auch das Problem dieser Aufrufe deutlich: Initialisierungen von Daten, die in abgeleiteten Klassen vorgenommen werden, sind beim Aufruf in einer Basisklasse noch nicht durchgeführt. [Die Behandlung der finalen Datenelemente ist hier einer andere. Diese werden bereits vor dem Aufruf des Konstruktors der obersten Klasse Object initialisiert. ] Deshalb kann da schon der ein oder andere Nullzeiger-Zugriff passieren.

Keine Polymorphie in C++-Konstruktoren

Im Fall von C++ ist das Verhalten dann ganz anders. Hier verhalten sich Methoden im Konstruktor nicht völlig polymorph. Da die Virtuelle-Methoden-Tabelle erst nach und nach aufgebaut wird, kann im Konstruktor einer Basisklasse noch gar keine Methode von Subklassen aufgerufen werden. Wird jedoch in der Klasse, deren Konstruktor gerade aufgerufen wird, eine Methode einer Oberklasse überschrieben, so wird die überschriebene Variante aufgerufen. [Man hat hier eigentlich ein Beispiel einer dynamischen Klassifizierung. In C++ ändert sich während seiner Konstruktion die Klassenzugehörigkeit eines Objekts. Während wir in unserem Java-Beispiel bei dem Aufruf new C() sofort ein Exemplar der Klasse C bekommen, das durch die Konstruktoren nacheinander lediglich initialisiert wird, erhalten wir in C++ zuerst ein Exemplar der Klasse A, das dann zu einem Exemplar der Klasse B mutiert, um schließlich zu einem Exemplar der Klasse C zu werden. ]

Hier die Ausgabe des dem obigen Java-Programm entsprechenden C++-Programms:

ClassA aus Konstr. von ClassA 
 
ClassA aus Konstr. von ClassA 
ClassB aus Konstr. von ClassB valueB: set 
 
ClassA aus Konstr. von ClassA 
ClassB aus Konstr. von ClassB valueB: set 
ClassC aus Konstr. von ClassC valueB/valueC: set/set

Wir sehen dabei, dass hier immer die Methode der Klasse aufgerufen wird, in deren Konstruktor wir uns grade befinden. Das ist zumindest sicherer in Bezug auf die Initialisierung von Daten, wir sehen, dass die betroffenen Daten immer korrekt initialisiert sind.



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