Polymorphie durch virtuelle Funktionen

Willemers Informatik-Ecke

Polymorphie bezeichnet in der Chemie die Vielgestaltigkeit von Kristallen und in der Biologie die Vielgestaltigkeit von Tierstaaten wie Ameisen bezüglich ihrer Aufgabenverteilung. Bei einer Programmiersprache, in der das Vererben ein wichtiges Konzept ist, ahnt man schon, dass wohl die biologische Sicht des Wortes dem Ganzen am nächsten kommt. Statt Polymorphie könnte man auch von der »Selbstständigkeit des Objekts« sprechen, aber dafür gibt es keinen so schönen griechischen Ausdruck.

Überdecken

Abgeleitete Klassen erben die Funktionen ihrer Basisklasse. Sollte eine Funktion der Basisklasse nicht die gewünschte Funktionalität für die abgeleitete Klasse liefern, können Sie sie überschreiben. Dann wird die Funktion der Basisklasse für alle Instanzen der abgeleiteten Klasse überdeckt. Damit werden Instanzen der Basisklasse unter dem gleichen Namen eine andere Funktion aufrufen als Instanzen der abgeleiteten Klasse. Am folgenden Beispiel wird das anhand dröhnender Musikinstrumente gezeigt.

[Beispiel Musik]

#include <iostream>
using namespace std;

class Bass
{
public:
    void droehn() { cout << "Bass" << endl; }
};

class Tuba : public Bass
{
public:
    void droehn() { cout << "Tuba" << endl; }
};

int main()
{
    Tuba tuba.droehn();
    Bass bass.droehn();
}

Es ist keine Überraschung, dass die Tuba anders dröhnt als der Bass. Immerhin gehört ja die Funktion zum Objekt. Jedes Objekt weiß, welche Funktionen zu ihm gehören. Was passiert aber, wenn man ein Orchester von Instrumenten zusammenstellt und jedes auffordert, einmal zu dröhnen?

Das erste Problem ist, Bässe und Tubas unter der gleichen Kategorie abzustellen. Dabei kommt uns zu Hilfe, dass bei einer öffentlichen Vererbung die abgeleitete Klasse kompatibel zu ihrer Basisklasse ist. So kann das Objekt tuba als Argument an Funktionen übergeben werden, die als Parameter einen Typ Bass erwarten, denn die Tuba ist ja ein Bass. Allerdings darf es nicht zu einer Kopie oder Zuweisung kommen, weil dann die Tuba alle Besonderheiten verliert, die sie vom Bass unterscheiden. Wenn aber der Parameter ein Zeiger ist, bleibt die Tuba eine Tuba und kann dennoch einem Zeiger auf einen Bass zugewiesen werden. Wird über den übergebenen Zeiger die Elementfunktion droehn() aufgerufen, sollte man intuitiv annehmen, dass jedes Objekt, die ihm zugehörige Funktion aufruft.

Testfall

Wir erweitern das Beispiel um die Funktion MachMalTut(). Als Parameter verwendet sie einen Zeiger auf Bass. Dann ruft sie über das Objekt die Elementfunktion droehn() auf.

[Beispiel Musik]

#include <iostream>
using namespace std;

class Bass 
{
public:
    virtual void droehn() { cout << "Bass" << endl; }
};

class Tuba : public Bass 
{
public:
    void droehn() { cout << "Tuba" << endl; }
};

void MachMalTut(Bass *tute)
{
    tute->droehn();
}

int main()
{
    Tuba tuba;
    Bass bass;

    MachMalTut(&bass);
    MachMalTut(&tuba);
}

Frühe und späte Bindung

Wenn Sie das Programm laufen lassen, erscheint tatsächlich erst das Wort »Bass« und dann das Wort »Tuba«. Wenn Sie das Listing genauer ansehen, werden Sie entdecken, dass vor der Elementfunktion droehn() der Klasse Bass das Schlüsselwort virtual eingefügt wurde. Wenn Sie das Wort virtual entfernen, werden Sie feststellen, dass als Ausgabe zweimal das Wort »Bass« auf dem Bildschirm erscheint. Offensichtlich koppelt der Compiler normalerweise beim Übersetzen bereits die Dröhnfunktion des Basses an jede übergebene Tute. Man spricht hier von »früher Bindung«. Der Compiler erkennt innerhalb der Funktion nur ein Objekt vom Typ Zeiger auf Bass. Entsprechend wird die Verbindung zur Elementfunktion von Bass beim Übersetzen festgelegt. Durch die Deklaration der Elementfunktion droehn() in der Basisklasse als virtual wird offensichtlich erreicht, dass jedes übergebene Objekt selbst prüft, welche Dröhnfunktion zu ihm gehört. Es ist aber erst zur Laufzeit des Programms feststellbar, welches Objekt sich hinter dem Zeiger befindet. Und letztlich muss das Objekt selbst die Funktion benennen, die ausgeführt werden soll. Diese Festlegung zur Laufzeit wird als »späte Bindung« bezeichnet. In C++ signalisieren Sie dem Compiler, dass Funktionen spät gebunden werden sollen, indem Sie in der Basisklasse das Schlüsselwort virtual vor die Funktion droehn() stellen.

Das Objekt ruft die Funktion auf

Mit dem Schlüsselwort virtual wird dem Compiler signalisiert, dass die Verantwortung, welche Funktion aufgerufen wird, an das Objekt übergeht. Wenn nun in der Funktion MachMalTut() die Dröhnfunktion aufgerufen wird, übernimmt das jeweils übergebene Objekt den Aufruf. Beim Ablauf des Programms wird sich also die Tuba als solche melden. Um die späte Bindung möglich zu machen, muss das Objekt selbst zur Laufzeit erkennen können, zu welcher Klasse es gehört. Diese Information wird in Form von Zeigern gespeichert. Dadurch wird ein Objekt einer Klasse mit einer virtuellen Funktion mehr Speicher belegen als die Summe seiner Elementvariablen.

Beispiel: Firma

Kommen wir noch einmal auf die Klasse Person und
ihre abgeleiteten Klassen zurück.

Virtuelle Zahlungen

Als Basisklasse dient Person. Davon werden Kunde, Mitarbeiter und Lieferant abgeleitet. Auch hier gibt es gleiche Vorgänge, die klassenspezifisch unterschiedlich behandelt werden. So sind Zahlungen immer ähnlich: Es muss eine Banküberweisung veranlasst werden. Hinzu kommen aber klassenspezifische Abläufe. Beispielsweise ist eine Zahlung an einen Mitarbeiter ein Vorgang, den dieser jeden Monat in einer gewissen Höhe erwartet. Diese Zahlung muss versteuert werden, und es müssen dafür Sozialabgaben abgeführt werden. Erhält dagegen ein Lieferant eine Zahlung, muss das Lieferantenkonto um diesen Betrag verändert werden. Eine Zahlung an einen Kunden kann beispielsweise ein Rabatt oder eine Reklamation sein. Da jede dieser Zahlungen programmtechnisch anders zu behandeln ist, verwendet jede abgeleitete Klasse eine eigene Funktion Zahlung().

[Zahlung bei Personen]

class Person
{
public:
    virtual void Zahlung(float Geld);
};

class Mitarbeiter : public Person
{
public:
    virtual void Zahlung(float Geld);
};

class Kunde : public Person
{
public:
    virtual void Zahlung(float Geld);
};

class Lieferant : public Person
{
public:
    virtual void Zahlung(float Geld);
};

void Auszahlung(Person &Mensch, float Summe)
{
    Mensch.Zahlung(Summe);
}

int main()
{
    Lieferant Hansen, Meier;
    Kunde Mueller;
    Mitarbeiter Gaston;
    Auszahlung(Hansen, 100);
    Auszahlung(Meier, 100);
    Auszahlung(Mueller, 100);
    Auszahlung(Gaston, 100);
}

Virtuelle Zahlung

Die Funktion Zahlung() wird sowohl in der Basisklasse als auch in den abgeleiteten Klassen mit dem Schlüsselwort virtual deklariert, weil sie in den abgeleiteten Klassen anders implementiert wird. Es reicht eigentlich aus, nur die Funktionen der Basisklassen mit dem Attribut virtual zu versehen. Um es aber zu ermöglichen, auch weitere Klassen abzuleiten, ist es nicht falsch, auch in den abgeleiteten Klassen die Funktionen als virtuell zu kennzeichnen. Eine Person, die ein Lieferant ist, wird die Zahlung als Lieferant abwickeln. Das Objekt verwendet also die Art der Zahlung, die am besten zum eigenen Typ passt. Nur wenn eine abgeleitete Klasse keine eigene Funktion Zahlung() hat, würde auf die Funktion Zahlung() der Klasse zurückgegriffen, die in der Vererbungskette als Nächstes eine solche Funktion anbietet. So würde eine Zahlung an einen Außendienstmitarbeiter über die Funktion Zahlung() der Klasse Mitarbeiter laufen.

Testpersonen

In der Hauptfunktion main() werden nun vier Personen definiert: zwei Lieferanten, ein Kunde und ein Mitarbeiter. Da sie alle Objekte abgeleiteter Klassen von Person sind, können Sie als Referenzparameter der Funktion Auszahlung() übergeben werden. Innerhalb der Funktion werden die Objekte als Person behandelt. Es ist innerhalb der Funktion nicht zu erkennen, ob die Person ein Lieferant oder ein Kunde ist. Die Funktion ruft die Elementfunktion Zahlung() auf. Da diese bei Person als virtual deklariert ist, verwendet das Objekt die Funktion, die zum eigenen Typ am besten passt. Da jedes Objekt die eigene Klassenzugehörigkeit kennt, ruft es die jeweils eigene Funktion Zahlung() auf.

Parameter Auszahlung

Es ist wichtig, dass die Übergabe der Person als Referenz erfolgt. Würde die Person als Wert übergeben, würde beim Aufruf der Funktion eine Kopie in die Parametervariable Mensch erzeugt. Diese Kopie wäre eine echte Person und hätte keine Informationen über ihr Vorleben. Die Funktion muss also mit dem Original arbeiten, weil nur das Original »weiß«, welch ein Mensch das ist. Also muss der Parameter entweder als Referenz oder als Zeiger übergeben werden.

Qualitätssicherung

Die Polymorphie ermöglicht es, Funktionen wie Auszahlung() unverändert zu lassen, auch wenn weitere Arten von Personen hinzukommen. In den prozeduralen Sprachen würde eine solche Funktion, die mehrere ähnliche Typen behandelt, durch eine Fallunterscheidung realisiert. Das Kriterium, nach dem unterschieden würde, wäre der Personentyp. Jedes Mal, wenn ein neuer Typ hinzukommt, müsste diese Funktion ergänzt werden. Durch den Einsatz der Polymorphie muss der bestehende Code der Funktion Auszahlung() oder einer der bisher implementierten Elementfunktionen Zahlung() nicht geändert werden. Eine solche Erweiterung führt bei Verwendung der Polymorphie dazu, dass Klassen besonders behandelt werden können, ohne den bestehenden Code der Funktion Auszahlung() oder einer der bisher implementierten Elementfunktionen Zahlung() zu ändern. Es ist nicht einmal erforderlich, dass der Programmierer überhaupt den Quelltext besitzt. Jede Änderung einer geprüften Funktion bringt das Risiko mit sich, dass bestehende Programmteile destabilisiert werden. Wenn per Polymorphie erweitert werden kann, so ist das ein wichtiger Beitrag zur Qualitätssicherung eines Programms.

VTable

Hintergrund

Die virtuellen Funktionen werden in den Compilern meist durch ein Array realisiert, das einen Zeiger auf alle virtuellen Funktionen enthält. Dieses Array wird wird oft als VTable oder vtbl bezeichnet. Für jede Klasse mit virtuellen Funktionen gibt es genau eine solche Tabelle. Jedes Objekt dieser Klasse erhält als zusätzliche Information einen Zeiger auf diese Tabelle. Wird die Klasse abgeleitet, erhält die neue Klasse eine Kopie der VTable. Für alle überschriebenen Funktionen wird der Zeiger auf die eigenen Funktionen gesetzt. Wird eine virtuelle Funktion eines polymorph abgeleiteten Objekts aufgerufen, wird über den Zeiger auf die VTable seiner Klasse zugegriffen. Damit »weiß« das Objekt, welche Funktionen zu ihm gehören. Die Funktion wird dann über den in der VTable hinterlegten Funktionszeiger aufgerufen. Das hört sich zunächst aufwändig an. In der Praxis ist der Laufzeitunterschied kaum spürbar. So zeigt Kaiser, (Kaiser, Richard: C++ mit dem Borland C++ Builder. Springer, Berlin-Heidelberg, 2002. S. 827.) dass beim Aufruf von 100 Millionen leerer Funktionen virtuelle Funktionen 3,69 Sekunden benötigen gegenüber 3,03 Sekunden, die normale Funktionen brauchen. Damit sind die virtuellen Funktionen also insgesamt sechs Millisekunden pro Millionen Aufrufe langsamer. Das dürfte in der Praxis kaum relevant sein. Da der Zeiger auf die VTable am Objekt hängt, sind virtuelle Funktionen nur bei nicht-statischen Elementen sinnvoll und erlaubt.

Virtueller Destruktor

Es gibt eine Faustregel, die besagt, dass jede Klasse mit virtuellen Funktionen auch einen virtuellen Destruktor haben soll. Wie Sie oben gesehen haben, wird die abgeleitete Klasse Tuba auch durch einen Zeiger auf Bass angesprochen. Wird über einen solchen Zeiger der Befehl delete aufgerufen, würden bei einem nicht virtuellen Destruktor nur die Bestandteile des Bass, aber nicht der Tuba angesprochen. In diesem einfachen Beispiel ist das nicht relevant. Würde die Tuba aber externe Speicherbereiche verwenden, die über Zeiger angesprochen werden, dann würden sie über einen nicht-virtuellen Destruktor nicht freigegeben.

Abstrakte Basisklasse

Im Tuba-Beispiel können Sie von der Basisklasse Bass ein Objekt erzeugen. Dabei gibt es so etwas wie einen Bass eigentlich gar nicht. Es gibt eine Tuba, eine Bassgitarre oder eine Bassstimme. Dagegen bezeichnet der abstrakte Begriff Bass eigentlich nur die Eigenschaft all dieser Instrumente, tiefe Töne erzeugen zu können. Insofern macht eine Instanz eines Basses keinen Sinn. Es gibt nur Instanzen realer Instrumente, die aber alle von Bass abgeleitet sind, weil sie tiefe Töne erzeugen.

Keine Objekte, bitte!

Sie können nun einen großen Kommentar neben die Klasse Bass setzen, dass bitte niemand ein Objekt dieser Klasse anlegen soll. Sie können das aber auch durch den Compiler überwachen lassen. Das erreichen Sie dadurch, dass Sie eine virtuelle Funktion 0 setzen. Das bedeutet, dass diese Funktion nicht implementiert werden kann. Dann kann auch keine Instanz dieser Klasse angelegt werden.

[Abstrakte Basisklasse]

class Bass 
{
public:
    virtual void droehn() = 0;
};

Man nennt eine Elementfunktion wie droehn() eine »rein virtuelle Funktion« und die Klasse Bass »abstrakte Basisklasse«. Von dieser Klasse können keine Instanzen mehr gebildet werden. Sie kann nur noch zur Ableitung anderer Klassen verwendet werden. Damit beschreibt die Klasse Bass eine Kategorie von Instrumenten, die gemeinsame Eigenschaften haben. Eine von einer abstrakten Basisklasse abgeleitete Klasse ist so lange ebenfalls abstrakt, so lange sie nicht alle rein virtuellen Funktionen überschreibt. Abstrakte Basisklassen werden angelegt, um verbindliche Absprachen über bestimmte Klassen zu treffen. In unserem Beispiel mit der Klasse Bass würde die abstrakte Basisklasse vorschreiben, dass jede abgeleitete Klasse eine Elementfunktion droehn() implementieren muss. Dabei kann es durchaus sein, dass die abgeleiteten Klassen nur wenig gemeinsam haben. Es ist denkbar, dass Sie für Auto-Shampoo, Möhren und Toilettenpapier eine gemeinsame Basisklasse benötigen, weil dies die Waren sind, die ein Kaufhaus anbietet. In solch einem Fall werden Sie eine Klasse Ware anlegen, die Funktionen wie eine Wertermittlung anbietet. Wenn die Inventur ansteht, werden Sie diese nicht für jede Warengruppe getrennt durchführen wollen. Schließlich möchten Sie wissen, was Ihr Lagerbestand insgesamt wert ist. Sie wollen also erreichen, dass alle Waren, die jemals angelegt werden, eine Elementfunktion besitzen, die den Wert liefert. Denn diese Funktion werden Sie polymorph bei der Inventur aufrufen wollen. Andererseits wollen Sie verhindern, dass irgendjemand aus purer Faulheit Waren nicht näher bezeichnet. Es soll also keine Instanzen von der abstrakten Klasse Waren geben, sondern nur von Auto-Shampoo, Möhren und Toilettenpapier. Sobald eine neue Warengruppe angelegt wird, soll es auch eine neue Klasse geben.

Bedeutung

Kaiser erklärt, dass Vererbung ohne virtuelle Funktionen nur selten sinnvoll sei. (vgl. Kaiser, Richard: C++ mit dem C++ Builder. Springer, Heidelberg-Berlin, 2002. S. 847.) Einige Autoren vertreten den Standpunkt, dass eine Verwendung der Klassen ohne Polymorphie keine objektorientierte, sondern objektbasierte Programmierung sei. Andere Autoren bezeichnen die Verwendung von Klassen ohne Einsatz der Vererbung als objektbasiert. Es entsteht der Eindruck, dass die objektorientierte Programmierung durch gewisse ideologische Kämpfe geprägt ist. In der Praxis wird ein professioneller Programmierer selten nach solchen Ideologien fragen, sondern alles einsetzen, was zu einer Verbesserung seines Programms oder zur Effizienzsteigerung seiner Arbeitszeit führt. Das ist allerdings keine Ausrede, sich mit diesen Themen nicht zu beschäftigen. Solche Regeln oder Ideologien sind oft als Folge von Erfahrungen entstanden, und Sie sollten sie nicht ohne vorherige Prüfung verwerfen. Die objektorientierte Programmierung hat sich nicht ohne Grund durchgesetzt.

Polymorphie in der Praxis

Beim Studium der Polymorphie gewinnt mancher den Eindruck, dass sie nur für wenige, sehr konstruierte Beispiele einsetzbar wäre. Das ist keineswegs richtig. Die meisten Klassenbibliotheken wären ohne Polymorphie gar nicht denkbar. Insbesondere bei den grafischen Oberflächen stellen die Klassenbibliotheken Klassen für Applikationen, Fenster und Dialoge zur Verfügung. Um eigene Applikationen, Fenster oder Dialoge zu erstellen, leiten Sie von diesen Basisklassen Ihre eigenen Klassen ab und fügen die Elemente hinzu, die Ihr Programm von der Standardvorgabe unterscheiden.

Dialog

Als Beispiel soll die Klasse CDialog der MFC (Microsoft Foundation Classes) herhalten. Die Dialogklasse ist selbst bereits ein Spezialfall eines Fensters und damit von der Klasse CWnd hergeleitet. Wenn Sie für Ihr Programm einen eigenen Dialog erstellen wollen, werden Sie vom Betriebssystem über alle Ereignisse informiert, die im Zusammenhang mit der Dialogbox stehen. Die meisten dieser Ereignisse werden Sie gar nicht interessieren. Sie können von der Klasse CDialog abgehandelt werden. Vor allem drei Ereignisse werden Sie wahrscheinlich interessieren. Das erste Ereignis ist der Start der Dialogbox. Sobald die Dialogbox startet, muss das Programm die Kontrollelemente initialisieren, beispielsweise eine Listbox füllen. Das zweite Ereignis ist, wenn der Benutzer den Ok-Button angeklickt hat und damit den Inhalt des Dialog für gültig erklärt. In dem Moment müssen Sie die Inhalte der Kontrollelemente auslesen und deren Inhalt den Programmvariablen zuführen. Das dritte Ereignis tritt ein, wenn der Benutzer den Dialog durch die ESC-Taste beendet. Meist ist da längst nicht so viel zu tun, aber das Programm will ja wissen, dass der Benutzer es sich anders überlegt hat.

Ereignisbehandlung

Um an diese drei Ereignisse zu gelangen, stellt die Klasse CDialog die Elementfunktion OnInitDialog() für den Start der Dialogbox und die Elementfunktionen OnOK() für den Ok-Button und OnCancel() für den Abbruch-Button zur Verfügung. Diese Funktionen werden polymorph aufgerufen, wenn eines der Ereignisse eintritt. Um eine eigene Dialogbox zu realisieren, erstellen Sie eine eigene Klasse, die Sie von CDialog öffentlich ableiten. Für diejenigen Ereignisse, die Sie verarbeiten wollen, überschreiben Sie die Ereignisfunktionen neu. Ihre Klasse sieht etwa so aus:

[Dialogableitung]

class tMeinDialog : public CDialog
{
    ...
    BOOL OnInitDialog();
    void OnOK();
    void OnCancel();
    ...
};

Überschriebene Initialisierung

Die entsprechenden Funktionen der Klasse CDialog realisieren die jeweiligen Funktionalitäten im Standardfall. So wird die Funktion OnInitDialog() der Klasse CDialog den grundlegenden Aufbau der Dialogbox durchführen. Da Sie die Funktion überschrieben haben, um Ihre Kontrollelemente zu initialisieren, wird durch den polymorphen Aufruf die Funktion OnInitDialog() Ihrer Klasse aufgerufen und nicht mehr die Funktion OnInitDialog() der Klasse CDialog. Um zu gewährleisten, dass die notwendigen Standardinitialisierungen einer Dialogbox ablaufen, müssen Sie als Erstes die Funktion der Basisklasse aufrufen.

[Dialoginitialisierung]

BOOL tMeinDialog::OnInitDialog()
{
    CDialog::OnInitDialog();
    // richte die eigenen Dialoge ein
    ...
}

Dieser Mechanismus zur Weiterleitung von Ereignissen an den Benutzerdialog durch Verwendung der Polymorphie ist keine Spezialität der MFC, sondern auch in anderen Klassenbibliotheken üblich.

BOOL?

Sie werden sich vielleicht wundern, warum hier plötzlich bool groß geschrieben wird. Hier hat Microsoft seinen eigenen Typ BOOL geschaffen, weil bool zu diesem Zeitpunkt noch nicht als Standard existierte.


Informatik-Ecke Einstieg in C++ (C) Copyright 2005 Arnold Willemer