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.