1 Einführung in die OOP

1.1 Was sind Objekte?
1.2 Die erste eigene Klasse: Brüche
1.3 Vererbung: Gemischte Zahlen
1.4 Polymorphie: mal so, mal anders!

Zum Inhaltsverzeichnis Zum nächsten Kapitel


1.1 Was sind Objekte?

Das Wort "Objekt" wird im täglichen Sprachgebrauch meist in einem sehr allgemeinen Sinn verwendet, etwa wie "Ding" oder "Gegenstand". Im Gegensatz dazu ist das "informatische Objekt" ein wohldefinierter und sehr genau gefasster Begriff. Man versteht darunter eine Zusammenfassung von Daten und auf diesen Daten operierenden Methoden . Die Daten stellen dabei die Eigenschaften des Objekts dar, die Methoden seine Fähigkeiten. Methoden können Funktionen oder Prozeduren sein. Objekte haben...
Sie sind inzwischen längst an den täglichen Umgang mit Objekten gewöhnt: wann immer Sie in Delphi programmieren, manipulieren Sie Objekte. Wenn Sie aus der Werkzeugleiste einen Button auf das Formular ziehen, dann legt Delphi ein Objekt vom Typ TButton an, gibt ihm einen eindeutigen Namen (z.B. "Button1") und lädt dann dessen Eigenschaften in den Objektinspektor. Sie können diese Eigenschaften dort verändern, indem Sie den aufgelisteten Eigenschaftsvariablen neue Werte zuweisen. Um die Aufschrift auf dem Knopf zu ändern, haben Sie der Eigenschaft "Caption" des Objekts Button1 einen neuen Wert (z.B. "Suchen") gegeben. Zur Laufzeit des Programms würde dies durch die Zuweisung
       Button1.Caption := 'Suchen';
erledigt werden. Im Programmcode greift man also auf eine Eigenschaft eines Objektes zu, indem man dem Objektnamen einen Punkt und den Namen der Eigenschaftsvariablen nachstellt. Entsprechend können wir auch andere Eigenschaften des Knopfes Button1 einstellen, wie seine Größe oder seine Position im Fenster. Die zugehörigen Eigenschaften heißen Width, Height, Left und Top. Bei der konkreten Programmierung in Delphi werden die Werte dieser Eigenschaftsvariablen meist indirekt mit der Maus im Formular-Entwurfsfenster eingestellt. Vergewissern Sie sich aber, dass Delphi bei solchen Maus-Manipulationen die Werte der zugehörigen Eigenschaftsvariablen im Objektinspektor stets entsprechend ändert!

Allerdings sind nicht alle möglicherweise erwarteten Eigenschaften des Knopfes auf diese Art und Weise zugänglich. Zum Beispiel scheitert der Versuch, die Farbe des Knopfes zu ändern. Die Farbe ist nämlich keine Objekt-Eigenschaft des Objekttyps TButton: es gibt daher keine Eigenschaftsvariable Button1.Color. Andere Objekte (wie z.B. das Formular) haben eine solche Eigenschaft: mit
       Form1.Color := clRed;
können wir den Hintergrund unseres aktuellen Formulars rot einfärben. Beim Knopf ist dies so nicht möglich. Natürlich "hat" auch der Knopf eine Farbe, aber deren Wert wird automatisch vom Betriebssystem eingestellt und steht nicht über eine Eigenschaftsvariable zu unserer Verfügung. Welche Objekt-Eigenschaften eine bestimmter Objekttyp "veröffentlicht", darüber entscheidet der Entwickler dieses Typs.

Neben Eigenschaften haben Objekte aber auch noch Methoden. Diese implementieren die Fähigkeiten des Objekts durch entsprechende Prozeduren oder Funktionen. Bei unserem Knopf Button1 ist die wichtigste Methode seine Klick-Prozedur Button1Click: der Inhalt dieser Prozedur legt fest, was der Knopf "kann", also: was passieren soll, wenn der Benutzer auf diesen Knopf klickt. Weitere Methoden eines Knopfes sind uns bisher kaum untergekommen, aber es gibt eine ganze Menge davon. So sorgt z.B. die Prozedur Repaint dafür, dass der Knopf sich selbst auf der grafischen Oberfläche unseres Programms darstellen kann. Und die Funktion ScreenToClient kann z.B. eine in Bildschirmkoordinaten gegebene Mausposition in das lokale Koordiantensystem des Knopfes umrechnen. Ein Blick in die Delphi-Hilfe zeigt, dass unserer Knopf über Dutzende weiterer Methoden verfügt, die wir aber glücklicherweise allesamt nicht kennen müssen, um den Knopf sinnvoll in unserem Programm einsetzen zu können. Der Knopf wird von Delphi (und Windows!) weitgehend selbständig verwaltet; wir müssen eigentlich nur festlegen, was passieren soll, wenn der Benutzer den Knopf anklickt, und das machen wir in seiner Klick-Prozedur!

Vielleicht ist Ihnen aufgefallen, dass im obigen Text an einigen Stellen nicht von "Objekten" sondern von "Objekt-Typen" die Rede ist. Worin genau besteht da der Unterschied? Betrachten wir zur Klärung die folgende Situation:


Alle drei Knöpfe sind vom selben Typ TButton. Man nennt den Objekt-Typ auch die Klasse: die Klasse ist sozusagen der Konstruktionsplan, nach dem solche Objekte herzustellen sind. Andererseits unterscheiden sie sich die drei Knöpfe in ihren Eigenschaften und Methoden voneinander: jeder Knopf hat z.B. eine eigene Aufschrift und eine eigene Klick-Prozedur. Damit sind die Knöpfe drei individuelle Instanzen der Klasse TButton. Im Folgenden soll der Begriff "Objekt" immer eine Instanz einer Klasse bezeichnen, also ein "konkretes", individuelles Objekt; ist hingegen der Objekt-Typ gemeint, dann reden wir von der "Klasse".

Auch wenn wir also schon längst an den täglichen Umgang mit (vorgefertigten) Objekten gewöhnt sind, sind uns die Details des Klassen-Konzepts noch fremd. Dies wird sich erst ändern, wenn wir eigene Klassen erstellen, und das wollen wir nun auch gleich in Angriff nehmen!




1.2 Die erste eigene Klasse: Brüche

Delphi kennt ganze Zahlen und Fließkommazahlen, jeweils in verschiedenen Größen- bzw. Genauigkeitsbereichen. Was fehlt, ist eine Darstellung von rationalen Zahlen in Bruchform. Wir wollen nun eine Klasse TBruch konstruieren, die sozusagen die Bruchdarstellung in Delphi implementiert.

Welche Eigenschaften soll ein Objekt vom Typ TBruch haben? Ein Bruch besteht zunächst mal aus einem Zähler und einem Nenner. Damit sind dann aber auch schon alle seine Bestandteile aufgezählt. Wir brauchen also offenbar lediglich zwei Datenfelder vom Typ Integer, und sinnvollerweise werden wir sie zaehler und nenner nennen.

Etwas schwieriger zu beantworten ist die Frage nach den Fähigkeiten eines Bruch-Objekts. Statt zu fragen, was ein Bruch alles können muss, ist es sinnvoller, sich zu überlegen, was wir alles mit Brüchen tun können. Nun, wir können mit Brüchen rechnen: Brüche kann man kürzen und erweitern, addieren und subtrahieren, multiplizieren und dividieren... aber im Augenblick wollen wir uns darauf beschränken, dass wir Brüche kürzen können. Warum dies? Weil man zum Kürzen nur einen Bruch braucht, für die anderen Rechenoperationen aber immer (mindestens) zwei! Zunächst müssen wir aber mal den korrekten Umgang mit einem Bruch-Objekt erlernen; die möglichen Verknüpfungen zwischen mehreren Brüchen heben wir uns für einen späteren Abschnitt auf.

Eine weitere wünschenswerte Fähigkeit eines TBruch-Objekts wäre, dass es Auskunft geben kann über seinen aktuellen Wert. Der Einfachheit halber soll das Objekt einen String der Form "Zähler/Nenner" produzieren können, was zum Beispiel in einer Funktion namens AsString geschehen könnte. Den Ergebnis-String dieser Funktion können wir dann in jedem beliebigen Editierfeld anzeigen.

Jede Klasse muss darüberhinaus zwei spezielle Methoden implementieren, die den Lebenszyklus der zugehörigen Objekte einrahmen: ein "Konstruktor" (üblicherweise mit Namen Create) stellt ein neues Objekt im Speicher her, während ein "Destruktor" (üblicherweise mit Namen Destroy) das Objekt wieder abbaut und aus dem Speicher entfernt. Konstruktoren werden wie Funktionen verwendet: sie geben ein Objekt vom angegebenen Typ zurück, welches dann in einer passenden Objektvariablen abgespeichert wird. Destruktoren werden wie gewöhnliche Prozeduren verwendet.

Wir erhalten damit die folgende Klassen-Definition:
       Type TBruch = class(TObject)
                       zaehler : Integer;
                       nenner  : Integer;
                       constructor Create(i_zaehler, i_nenner: Integer);
                       destructor Destroy;
                       function AsString: String;
                       procedure Kuerze;
                     end;
Ein Klassen-Definition beginnt also mit dem Schlüsselwort class, listet dann die Datenfelder (mit Typ-Angaben) und Methoden auf und endet mit dem Schlüsselwort end. Hinter class taucht allerdings noch ein Parameter auf: er gibt die Eltern-Klasse an, von der die neue Klasse abgeleitet werden soll. Die hier angegebene Eltern-Klasse ist TObject, der "Urahn aller Klassen": er implementiert vor allen Dingen die Speicherverwaltung für Objekte. TBruch "erbt" damit alle Eigenschaften und Fähigkeiten von TObject. Durch diesen Mechanismus der Vererbung müssen wir nicht bei jeder neuen Klasse wieder "von vorne" anfangen, sondern können die Vorarbeit nutzen, die in einer anderen Klasse schon geleistet wurde. Mithin ist also die Definition einer neuen Klasse stets eine Erweiterung einer schon existierenden!
Nun müssen wir "nur noch" die Methoden unserer neuen Klasse implementieren. Für den Konstruktor zum Beispiel könnte das folgendermaßen aussehen:
       constructor TBruch.Create(i_zaehler, i_nenner: Integer);
         begin
         Inherited Create;
         zaehler := i_zaehler;
         nenner  := i_nenner;
         end;
Der Aufruf von Inherited Create ruft den vom Vorfahren geerbten Konstruktor auf. Dies sollte stets als allererste Aktion in jedem Konstruktor einer neuen Klasse sein, damit die entsprechenden nötigen Initialisierungsarbeiten getan werden können. Die folgenden beiden Zeilen setzen Zähler und Nenner unseres Bruches auf die dem Konstruktor übergebenen Initialisierungswerte.

Für Destruktoren gilt entsprechend: jeder Destruktor einer neuen Klasse sollte eventuell nötige Aufräumarbeiten durchführen und danach den ererbten Destruktor aufrufen. In unserem Fall sind gar keine Aufräumarbeiten nötig, so dass der Destruktor recht einfach ausfällt:
       destructor TBruch.Destroy;
         begin
         Inherited Destroy;
         end;
Schließlich bleibt nur noch zu klären, wie nun eigentlich eine Instanz der Klasse TBruch hergestellt wird. Dazu deklarieren wir im Formular eine (private) Variable br1 vom Typ TBruch. Damit diese Variable dann zur Laufzeit auch ein "echtes" TBruch-Objekt enthält, muss ein solches beim Programmstart erzeugt werden. Dies erledigen wir vorteilhaft in der OnCreate-Methode des Formulars, deren Deklaration wir zu diesem Zwecke zunächst von Delphi erzeugen lassen:
       TForm1.FormCreate(Sender: TObject);
         begin
         br1 := TBruch.Create(3, 4);
         end;
Diese eine Zeile erzeugt also das TBruch-Objekt br1 und initialisiert es mit dem Wert (3/4). Beim Programmende müssen wir dann dieses Objekt auch wieder entsorgen. Dazu eignet sich die OnClose-Methode des Formulars:
       TForm1.FormClose(Sender: TObject);
         begin
         br1.Destroy;
         end;
Die noch ausstehende Implementierung der restlichen Methoden von TBruch braucht nun keine weiteren Tricks mehr, so dass Sie das auch selbst machen können:



Aufgabe:

  1. Die Klasse TBruch

    Öffnen Sie das Archiv bruch_0.zip und packen Sie das darin enthaltene Delphi-Projekt in ein neues Verzeichnis auf Ihrem persönlichen Laufwerk aus. Öffnen Sie dann das Projekt "bruch_0.dpr". Es enthält die oben angegebene Deklaration der Klasse TBruch und eine unvollständige Implementierung, die Sie nun fertigstellen sollen:

    1. Beim Klick auf den Knopf Button1 soll das Bruchobjekt seinen Wert in das Editfeld Edit1 ausgeben. Die dafür zuständige Methode AsString müssen Sie aber noch mit Leben füllen.

    2. Ändern Sie nun die Initialisierung von br1 so, dass der Bruch zunächst den Wert 75/100 hat. Implementieren Sie dann die Methode kuerze und testen Sie sie! (Möglicherweise können Sie dabei auch von früherer Arbeit profitieren: haben Sie nicht irgendwann den Euklidischen Algorithmus zur Berechnung des ggT implementiert? Diese Funktion könnte man nun hier recht gut gebrauchen...)

    [Lösungsvorschlag]




1.3 Vererbung: Gemischte Zahlen

Wenn der Zähler größer wird als der Nenner, dann stellen wir Brüche häufig als "gemischte Zahlen" dar. Eine solche gemischte Zahl hat zwar auch einen Zähler und einen Nenner, aber sie hat darüberhinaus auch noch einen "ganzzahligen Anteil". Der Begriff "Gemischte Zahl" ist somit eine Erweiterung des Begriffes "Bruch". Wenn wir nun auch für gemischte Zahlen eine Klasse (z.B. "TGemZahl") modellieren wollen, dann liegt es nahe, diese Klasse von TBruch abzuleiten:
       Type TGemZahl = class(TBruch)
                         gz : Integer;
                         constructor Create(i_gz, i_z, i_n : Integer);
                         function AsString: String;
                       end;
Beachten Sie, dass als einziges Datenfeld gz aufgeführt ist; über zaehler und nenner verfügt die Klasse TGemZahl ja schon, weil sie von TBruch abgeleitet wurde! Sicher braucht die neue Klasse einen eigenen Konstruktor, da ja nun eine zusätzliche Zahl initialisiert werden muss:
       constructor TGemZahl.Create(i_gz, i_z, i_n : Integer);
         begin
         Inherited Create(i_z, i_n);
         gz := i_gz;
         end;
Der ererbte Konstruktor ist hier derjenige der Klasse TBruch, also können wir bei diesem Aufruf die Initialisierungswerte für zaehler und nenner übergeben. Einen eigenen Destruktor brauchen wir nicht: schon der Destruktor von TBruch rief ja nur den ererbten Destruktor von TObject auf. Aber die Funktion AsString muss sicher neu geschrieben werden, weil nun dem Bruch noch der ganzzahlige Anteil vorangestellt werden muss, sinnvollerweise z.B. durch ein Leerzeichen (oder ein Semikolon) getrennt.


Aufgabe:

  1. Die Klasse TGemZahl

    Kopieren Sie das Projekt aus der vorangehenden Aufgabe. Ergänzen Sie es dann um die Deklaration der Klasse TGemZahl, und implementieren Sie die fehlenden Methoden. Ändern Sie den Typ von br1 in TGemZahl, und testen Sie, ob Ihr Programm nun korrekt mit gemischten Zahlen umgeht! Speziell: Funktioniert das Kürzen?
    [Lösungsvorschlag]




1.4 Polymorphie: mal so, mal anders!

Zum Einstieg in einen nicht ganz einfachen Problemkreis gibt's nun einen kleinen

Forschungsauftrag:

  1. Ist eine gemischte Zahl auch nur ein Bruch?

    Setzen Sie im Programm der vorigen Aufgabe den Typ von br1 auf TBruch zurück. Testen Sie dann, ob das Erzeugen, das Kürzen und das Ausgeben einer gemischten Zahl funktionieren, indem Sie mit dem Delphi-Debugger Haltepunkte auf die entsprechenden Programmstellen setzen!

Das Ergebnis ist wohl etwas enttäuschend: sobald die Variable br1 vom Typ TBruch ist, wird beim Aufruf der Methode AsString stets die Version aus der Klasse TBruch verwendet, auch wenn das Objekt "eigentlich" eine gemischte Zahl ist! Offenbar handelt es sich bei dem durch br1 referenzierten Objekt wirklich um eine gemischte Zahl, denn es wurde durch einen Aufruf von TGemZahl.Create erzeugt. Aber in der Klick-Prozedur des "Ausgabe"-Knopfes wird mit br1.AsString immer nur die TBruch-Variante dieser Funktion aufgerufen.

Dies ist wohl darauf zurückzuführen, dass br1 als TBruch-Objekt gar nicht "daran denkt", dass es auch ein TGemZahl-Objekt sein könnte. Interessanterweise genügen aber einige wenige kleine Änderungen im Quelltext, um diesen Notstand zu beheben und das Programm so funktionieren zu lassen, wie wir es erwarten:


Aufgabe:

  1. Eine "virtuelle" Lösung

    Entpacken Sie das Projekt aus dem Archiv gemzahl_2.zip in ein eigenes Verzeichnis, laden Sie es dann in Delphi, kompilieren und testen Sie es! Überzeugen Sie sich davon, dass nun auch das durch die TBruch-Variable br1 referenzierte Objekt sich in allen Belangen verhält, wie es sich für eine gemischte Zahl gehört!

    Suchen Sie dann im Quelltext die Unterschiede zur vorigen Version heraus!


Es sind offenbar nur zwei Stellen, an denen der Quelltext des Programms ergänzt wurde, und beide befinden sich in den Deklarationen unserer Klassen:
       TBruch = class(TObject)
                  zaehler : Integer;
                  nenner  : Integer;
                  constructor Create(i_zaehler, i_nenner: Integer);
                  destructor Destroy;
                  function   ggT(a, b: Integer): Integer;
                  function   AsString: String; virtual;
                  procedure  Kuerze;
                end;

       TGemZahl = class(TBruch)
                    gz : Integer;
                    constructor Create(i_gz, i_z, i_n : Integer);
                    function AsString: String; override;
                  end;
Die Funktion TBruch.AsString erhielt den Zusatz "virtual", ihr Gegenstück TGemZahl.AsString den Zusatz "override". Dies genügt offenbar, um das gewünschte Verhalten zu erzielen, dass nämlich der Aufruf von br1.AsString auch dann die Funktion TGemZahl.AsString aufruft, obwohl br1 "nur" als TBruch deklariert ist, wenn nur das referenzierte Objekt eine Instanz von TGemZahl ist.

Wie wird dies erreicht? Nun, die Details sind etwas schwierig, aber ein einfaches Modell sollte uns eine Anschauung davon vermitteln, was da eigentlich passiert. Die Deklaration einer Methode als "virtuell" veranlasst die Klasse, diese Methode in eine Tabelle einzutragen, die sogenannte "Virtuelle-Methoden-Tabelle" oder kurz "VMT". Wenn eine von dieser Klasse abgeleitete Klasse eine eigene Version dieser Methode deklariert, welche dann dort als "override" deklariert ist, dann wird diese eigene Methode in die VMT dieser abgeleiteten Klasse eingetragen und "überschreibt"(!) dort den geerbten Eintrag. Soll schließlich eine Instanz einer solchen Klasse einen Aufruf dieser Methode durchführen, dann schaut sie zunächst mal in Ihrer Liste der virtuellen Methoden nach, ob da eventuell eine eigene Version vorhanden wäre. Das Objekt "weiß" also aufgrund der Informationen in seiner VMT selbst, welche Methode die passende ist!

Gibt es keinen Eintrag in der VMT, dann ist die Methode "statisch": dies bedeutet, dass schon beim Kompilieren z.B. der Anweisung Edit1.Text := br1.AsString festgeschrieben, welche AsString-Methode genommen wird. Weil aber br1 vom Typ TBruch ist, kommt hier nur TBruch.AsString in Frage. Dass zur Laufzeit hinter br1 ein TGemZahl-Objekt stecken könnte, davon kann der Compiler nichts wissen, solange die Methode AsString nicht als virtuell deklariert und deshalb in der VMT aufgeführt ist!

Wenn die TBruch-Variablen br1 ein TBruch-Objekt referenziert, welches sich in allen Belangen als Bruchobjekt verhält, wird das niemanden wundern. Die Tatsache aber, dass sich hinter br1 gelegentlich auch ein TGemZahl-Objekt verstecken kann, welches sich dann auch komplett als TGemZahl-Objekt verhält, zeigt, dass br1 sozusagen verschiedene Gestalten annehmen kann. Diese Eigenschaft von Objekten nennt man Polymorphie. Virtuelle Methoden ermöglichen also die Polymorphie von Objekt-Variablen.

Vererbung und Polymorphie sind die beiden Charakterzüge, die die objekt-orientierte Programmierung zu einem der erfolgreichsten Konzepte der modernen Informatik machen. Erst mit Hilfe dieser beiden Konstruktionsmerkmale wird der Aufbau hocheffizienter Klassenhierarchien möglich, wie sie für die Erstellung komplexer Programme benötigt werden. Die "Visual Component Library" von Delphi (kurz "VCL") ist ein schönes Beispiel für eine solche Klassenhierarchie - und von deren Leistungsfähigkeit profitieren Sie schon, seit Ihren ersten Gehversuchen in Delphi!


Aufgabe:

  1. Mal so, mal anders

    Stellen Sie eine Kopie des Projekts aus der vorigen Aufgabe her. Ergänzen Sie das Programm dann um zwei Knöpfe "Erzeuge Bruch" und "Erzeuge Gemischte Zahl" mit folgenden Eigenschaften:
    • Ein Klick auf den Knopf "Erzeuge Bruch" soll das durch br1 referenzierte Objekt freigeben und dann ein neues TBruch-Objekt (mit Wert 15/25) erzeugen und in br1 ablegen.
    • Ein Klick auf den Knopf "Erzeuge gemischte Zahl" soll ebenfalls das durch br1 referenzierte Objekt freigeben und dann ein TGemZahl-Objekt (mit Wert 3 8/12) erzeugen und in br1 ablegen.
    Überzeugen Sie sich davon, dass die virtuelle Methode "AsString" nun in allen Betriebslagen korrekt aufgerufen wird!
    [Lösungsvorschlag]


Ein kleines Problem sollte zum Schluss noch angesprochen werden: selbst in der letzten Version unseres Programms gibt der Compiler beim Übersetzen des Quelltextes immer eine "Warnung" aus, nämlich:
       Methode 'Destroy' verbirgt virtuelle Methode vom Basistyp 'TObject'.
Dies deutet zunächst einmal darauf hin, dass in der Klasse TObject die Methode Destroy als "virtual" deklariert ist. Nach der oben dargestellten Logik müsste dann unsere TBruch-Version von Destroy den Zusatz "override" erhalten. Und tatsächlich: wenn Sie dies hinzufügen, gibt's keine Compiler-Warnung mehr!

Wenn also eine Methode einnmal als "virtual" deklariertist, müssen alle abgeleiteten Klassen diese Methode mit dem Zusatz "override" überschreiben; ansonsten geht die Information über die "Virtualität" der Methode verloren.
Oder kurz und knapp: einmal virtuell, immer virtuell!





Zum Inhaltsverzeichnis Zum nächsten Kapitel