![]() |
![]() |
Seit Ver. 0.17 werden in Freebasic auch Syntaxelemente der objektorientierten Programmierung eingeführt. Diese sollen hier ein wenig angerissen werden. Es besteht schon deswegen kein Anspruch auf Vollständigkeit, weil die Sprachweiterentwicklung hier in vollem Gange ist. Ich werde mich auch kurz fassen, da an sich die Sprachelemente auf dem Dokumenten-Wiki von Freebasic besonders im Bereich "Programming Reference", aber auch bei den Schlüsselwörtern TYPE, CONSTRUCTOR, DESTRUCTOR usw. recht gut dokumentiert ist.
Die Begriffe der OVP werden hier nicht mehr erklärt - siehe das Vorkapitel
Klassen werden - wie sollte es anders sein - mit TYPE deklariert. Bei der Deklaration der Eigenschaften unterscheidet sich zunächst nichts von normalen UDT's, es kommen lediglich zwei Möglichkeiten hinzu: Methodendeklaration mittels DECLARE SUB, DECLARE FUNCTION, DECLARE CONSTRUCTOR und DECLARE DESTRUCTOR. Und die Möglichkeit, mit dem Schlüsselwort PRIVAT den direkten Zugriff auf Eigenschaften zu verbieten.
TYPE tklasse1 x1 AS INTEGER s1 AS STRING*80 DECLARE SUB fill_s1(s AS string) DECLARE FUNCTION give_result() AS STRING END TYPE ' Das ist eine Methodendefinition: SUB tklasse1.fill_s1(s AS string) s1=STR(s) (...) END SUB ' Das ist eine Methodendefinition: FUNCTION tklasse1.give_result() AS STRING (...) give_result=s1 END FUNCTION 'Das ist eine Routine ausserhalb einer Klasse: SUB main DIM AS tklasse1 objekt1 objekt1.fill_s1("Hallo") ? objekt1.give_result() SLEEP END SUB main
So sieht das Grundgerüst einer Klassenprogrammierung aus. Innerhalb der Methoden kann ohne explizite Klassenangabe auf die Eigenschaften zugegriffen werden.
Statisch deklarierte Klassen sind zunächst der Standardfall wie im obigen Abschnitt. Wir können die Klassen aber auch als Zeiger deklarieren und den Speicherplatz dynamisch anfordern. Das machen wir allerdings nicht mit ALLOCATE wie früher, sondern mit dem Operator NEW. Den Unterschied werden wir gleich im nächsten Abschnitt kennenlernen: NEW tut etwas mehr als nur den Speicherplatz zu deklarieren. Das Gegenstück zu NEW ist DELETE. Auch DELETE ist etwas mächtiger als DEALLOCATE.
TYPE tklasse1 (...) alles wie im Vorbeispiel 'Das ist eine Routine ausserhalb einer Klasse: SUB main DIM AS tklasse1 objekt1 DIM AS tklasse1 PTR objekt2 objekt2=NEW tklasse1 objekt1.fill_s1("Hallo") objekt2->fill_s1("Hallo") ? "Objekt1-Inhalt: ";objekt1.give_result() ? "Objekt2-Inhalt: ";objekt2->give_result() SLEEP DELETE objekt2 END SUB main
Bis jetzt haben wir fast nichts kennengelernt, was sich mit der alten Syntax nicht auch hätte abbilden lassen können. Jetzt kommt etwas Neues. Ein Konstuktor ist eine Methode, die bei jeder Klasse automatisch mit dabei ist und automatisch aufgerufen wird, sobald die Variable gebildet wird. Entsprechend ist ein Destruktor ist eine Methode, die bei jeder Klasse automatisch mit dabei ist und automatisch aufgerufen wird, sobald die Variable ihre Gültigkeit verliert (statisch reserviert) oder mit DELETE zerstört wird (dynamische Reservierung). Falls die Kon/Destruktoren nicht explizit definiert wurden, werden sie vom Compiler automatisch gebildet, sind aber leer.
Die Sache wird interessant, wenn man Kon/Destruktoren befüllt. Das macht man so:
TYPE tklasse1 x1 AS INTEGER s1 AS ZSTRING PTR DECLARE CONSTRUCTOR() DECLARE DESTRUCTOR() DECLARE SUB fill_s1(s AS string) DECLARE FUNCTION give_result() AS STRING END TYPE CONSTRUCTOR tklasse1() s1=ALLOCATE(1000) ? "String wurde reserviert" END CONSTRUCTOR DESTRUCTOR tklasse1() DEALLOCATE(s1) s1=0 ? "String wurde freigegeben" END DESTRUCTOR SUB tklasse1.fill_s1(s AS string) IF (s1=0) THEN PRINT "tklasse1.fill_s1() error: Buffer not allocated" END END IF *s1=s '(...) END SUB ' Das ist eine Methodendefinition: FUNCTION tklasse1.give_result() AS STRING '(...) give_result=*s1 END FUNCTION 'Das ist eine Routine ausserhalb einer Klasse: SUB main DIM AS tklasse1 objekt1 objekt1.fill_s1("Hallo") ? objekt1.give_result() SLEEP END SUB main
Hier sehen wir gleich den Vorteil von Konstruktoren: Es ist viel schwerer, Initialisierungen beim Programmieren zu vergessen. Im Beispiel könnte es schnell passieren, zu denken, mit "DIM AS ..." sei auch schon der String da. Ohne Konstruktoren kracht's dann bei Aufruf von fill_s1(). Mit Konstruktoren ist die Annahme richtig: Mit "DIM AS ..." ist alles zum Start des Objekts erledigt - vorausgesetzt, der Konstruktor wurde korrekt definiert.
Bei den Destruktoren sieht's genauso aus. Wir können z.B. durchaus drandenken, mit DEALLOCATE den Speicherplatz freizugeben - aber die freigegebenen Zeiger falsch verwalten. Mit dem Ergebnis, dass wir irgendwann später auf den Speicherplatz eines freigegebenen Zeigers zugreifen. Wem wäre das nicht schon passiert? Unter Verwendung von Destruktoren wie im obigen Beispiel kann dies fast unmöglich gemacht werden. Der Trick liegt hier nicht darin, dass Destruktoren automatisch aufgerufen würden - bei dynamischer Reservierung ist das ja nicht der Fall. Sondern darin, dass wir innerhalb des Destruktors den String-Zeiger auf Null setzen. Wenn wir nun noch eine Zugriffsmethode schreiben, dort vor dem Zugriff den Zeiger auf Null prüfen und im restlichen Programm ausschliesslich diese Zugriffsmethode benutzen, dann haben wir das Management in beliebig komplexen Fällen im Griff - ohne uns um die Nullsetzerei und -Prüferei explizit kümmern zu müssen.
Konstruktoren, die keine Parameter beim Aufruf benötigen, sind diejenigen, die automatisch aufgerufen werden. Man nennt sie "default-Konstruktoren". Logischerweise dürfen für einen automatischen Aufruf keine Parameter notwendig sein. Was nicht heisst, dass der Konstruktor nicht Parameter haben darf. Sie müssen nur optional sein, das heisst, es müssen default-Werte für die Parameter vorhanden sein.
Konstruktoren mit Parametern sind oft notwendig - aber nicht richtig eingesetzt sind sie auch ein zweischneidiges Schwert. Sie initialisieren Objekte automatisch - und genau das war es, was wir bei einfachen Variablen mit der Anweisung Option explicit ausgeschaltet haben - aus gutem Grund!
Man sollte bei parametrisierten Konstruktoren also immer genau überlegen, ob man damit eine automatische Bug-Zucht aufmacht oder nicht. Im Zweifelsfall ist die Verwendung einer manuellen init-Routine im Zusammenhang mit Option explicit weitaus effektiver: Der Compiler baut eine Laufzeitfehlermeldung ein und man weiss, woran man ist. Vorsichtig eingesetzt, d.h. unter Prüfung, ob hier eine versehentliche Initialisierung passiert oder nicht, können parametrisierte Konstruktoren allerdings ganz hilfreich sein.
Im Exkurs weiter unten geht es z.B. um die Reservierung von Text-Speicherplatz. Das muss flexibel geschehen, da Text heutzutage in Absätzen gespeichert wird und diese nahezu beliebig lang sein können. Daher muss für jede Zeile der Speicherplatz individuell reserviert werden. Der Konstruktor für diesen Zeilenspeicher muss demnach die Buffergrösse enthalten. Auf der anderen Seite kann es gut einmal sein, dass diese Zeilenspeicherklasse als Tochter- oder Enkel-Element in einer anderen Klasse eingesetzt wird und durch die verzwickten Aufrufe bei manueller Initialisierung nicht ganz sichergestellt werden kann, dass auch die Zeilenspeicher immer richtig alloziiert und initialisiert werden. Hier hilft ein parametrisierter Konstruktor sehr:
TYPE tklasse1 x1 AS INTEGER s1 AS ZSTRING PTR 'Hier der parametrisierte Konstruktor mit default-Wert: DECLARE CONSTRUCTOR(buffersize AS INTEGER= -1) DECLARE DESTRUCTOR() DECLARE SUB fill_s1(s AS string) DECLARE FUNCTION give_result() AS STRING END TYPE CONSTRUCTOR tklasse1(buffersize AS INTEGER= -1) 'Wir benutzen einen negativen default-Wert, um default-Initialisierungen 'abzufangen: IF (buffersize<0) THEN ? "error: object of tklasse1 not initialized correctly" END END IF s1=ALLOCATE(buffersize) ? "String wurde reserviert" END CONSTRUCTOR
Wie rufen wir aber so einen Konstruktor auf? In Version 0.20b ist ein Aufruf des Konstruktors mit Parametern korrekt nur für dynamisch reservierte Objekte möglich. Das geht dann so:
SUB main DIM AS tklasse1 PTR objekt1 'Hier wird der Konstruktor mit Argument aufgerufen: objekt1=NEW tklasse1(1000) objekt1->fill_s1("Hallo") ? objekt1->give_result() SLEEP DELETE objekt1 END SUB main
Ist die Klasse lokal reserviert, kenne ich zur Zeit noch keine korrekte Methode, den Konstruktor mit Argumenten aufzurufen. Man sollte sich jedenfalls hüten, das so zu versuchen:
SUB main DIM AS tklasse1 objekt1 'Hier wird zwar der Konstruktor aufgerufen: objekt1=tklasse1(1000) 'Aber über ein s.g. temporäres Objekt. Dieses wird rechts von 'Zuweisungszeichen erzeugt, dabei wird für das temporäre Objekt der 'Konstruktor aufgerufen. Anschliessend wird das temporäre Objekt mit dem 'default- 'Konstruktor ins eigentliche Objekt objekt1 kopiert. Und anschliessend 'wird für das temporäre Objekt der destruktor aufgerufen. Das ist fatal, 'falls dort Zeiger freigegeben werden (was meistens der Fall ist). objekt1.fill_s1("Hallo") ' Hier wird's knallen, da s1 nicht mehr 'alloziiert ist. '(...) END SUB main
Einstweilen sollte man parametrisierte Konstruktoren bei lokal definierten so implementieren, dass man extra init-Routinen schreibt und diese vom Konstruktor aufrufen lässt. Deklariert man das Objekt dynamisch, kann man den Konstruktor verwenden, definiert man es lokal, verwendet man die manuelle init-Routine.
Eine Objekt-Initialisierung ist wenigstens zum Teil nichts anderes als die Zuweisung von Anfangswerten für alle Eigenschaften. Das Problem hatten wir schon bei zusammengesetzten, benutzerdefinierten Datentypen mit TYPE. Wir können so eine Initialisierung nicht auseinanderhalten von einem normalen Kopiervorgang: Objekt 1 wird nach Objekt 2 kopiert - nichts anderes ist ja eine Zuweisung. Und wir hätten natürlich gerne, dass das so einfach funktioniert wie bei einfachen Variablen: x=y. Sowohl für einfache Types wie auch für richtige Klassen mit Methoden ist das allerdings nicht so einfach, wenn sie Zeiger enthalten. Denn bei Zeigern gibt es immer zwei Möglichkeiten: Man kopiert die Zeiger oder man kopiert den Inhalt, auf den die Zeiger zeigen. Und beides kann sehr sinnvoll sein. Besteht ein Objekt z.B. aus Verweisen auf eine zentrale Datenbank, so wäre es schlecht, immer die ganzen Datenbankinhalte hin- und herzukopieren. Hier genügen shallow copies - es werden nur die Zeiger kopiert. Ansonsten wird man oft deep copies machen wollen - auch die Inhalte werden kopiert. In der Praxis wird allerdings eine von Klasse zu Klasse verschiedene Mischung aus beiden Kopiertypen relevant sein. Wie also ein korrekter Kopiervorgang aussieht, muss für jede Klasse separat festgelegt werden. Und genau das tun Copy-Konstruktoren.
Copy-Konstruktoren sind an sich ganz normale Methoden, die wir auch mit Sub deklarieren könnten.
TYPE tklasse1 x1 AS INTEGER s1 AS ZSTRING PTR DECLARE CONSTRUCTOR() DECLARE SUB copy(BYREF obj AS tklasse1) DECLARE DESTRUCTOR() DECLARE SUB fill_s1(s AS string) DECLARE FUNCTION give_result() AS STRING END TYPE CONSTRUCTOR tklasse1() s1=ALLOCATE(1000) ? "String wurde reserviert" END CONSTRUCTOR DESTRUCTOR tklasse1() DEALLOCATE(s1) s1=0 ? "String wurde freigegeben" END DESTRUCTOR SUB tklasse1.copy(BYREF obj AS tklasse1) THIS.x1=obj.x1 *THIS.s1=*obj.s1 END SUB SUB tklasse1.fill_s1(s AS string) IF (s1=0) THEN PRINT "tklasse1.fill_s1() error: Buffer not allocated" END END IF *s1=s '(...) END SUB ' Das ist eine Methodendefinition: FUNCTION tklasse1.give_result() AS STRING '(...) give_result=*s1 END FUNCTION 'Das ist eine Routine ausserhalb einer Klasse: SUB main DIM AS tklasse1 objekt1, objekt2 objekt1.fill_s1("Hallo") objekt2.copy(objekt1) ? objekt2.give_result() SLEEP END SUB main
Neu ist hier die Verwendung von "this". Damit bezeichnen wir die Klasse, in der wir uns zur Zeit befinden. Normalerweise ist das nicht notwendig, aber falls Argumente oder andere Variablen genauso heissen wie die Elemente der eigenen Klasse, braucht man eine Möglichkeit, fremde Variablen und eigene Eigenschaften auseinanderzuhalten.
Unsere copy-Methode ist allerdings auf diese Weise noch kein copy-Konstruktor. Dieser Schritt ist allerdings ziemlich einfach:
TYPE tklasse1 x1 AS INTEGER s1 AS ZSTRING PTR DECLARE CONSTRUCTOR() DECLARE CONSTRUCTOR(BYREF obj AS tklasse1) DECLARE DESTRUCTOR() DECLARE SUB fill_s1(s AS string) DECLARE FUNCTION give_result() AS STRING END TYPE CONSTRUCTOR tklasse1(BYREF obj AS tklasse1) THIS.x1=obj.x1 *THIS.s1=*obj.s1 END CONSTRUCTOR '(...) SUB main DIM AS tklasse1 objekt1, objekt2 objekt1.fill_s1("Hallo") 'Bei einem Aufruf des Zuweisungsoperators "=" mit einem Objekt der Klasse 'tklasse1 wird der copy-Konstruktor aufgerufen. objekt2=objekt1 ? objekt2.give_result() SLEEP END SUB main
Hier sehen wir allerdings Merkwürdiges: Der Konstruktor wurde zweimal deklariert. Das sollte eigentlich eine Fehlermeldung hervorrufen. Tut es aber nicht. Jedenfalls nicht mehr. Zur OOP gehört es dazu, dass man Methoden mit verschiedenen Headern mehrfach deklarieren kann. Der Compiler sucht sich dann beim Aufruf die Methode mit dem passenden Header raus. Das nennt man "Überladung". Das funktioniert nicht nur mit Konstruktoren, sondern mit allen Methoden.
Überladene Operatoren erhöhen nicht den expliziten Sprachraum. Sie sind nur ein Weg, bequemer zu Programmieren, bzw. Programme zu lesen. Sie sind allerdings auch ein Weg, den Leser des Programms etwas in die Irre zu führen, falls er mit dem Konzept überladener Operatoren nicht vertraut ist.
Wie beim Copy-Konstruktor mit dem Zuweisungsoperator haben wir auch bei anderen Operatoren, z.B. den Grundrechenarten, die Möglichkeit, diese für unsere Klasse explizit zu definieren. Das sähe dann z.B. so aus:
TYPE tklasse1 x1 AS INTEGER s1 AS ZSTRING PTR DECLARE CONSTRUCTOR() DECLARE CONSTRUCTOR(BYREF obj AS tklasse1) DECLARE DESTRUCTOR() DECLARE SUB fill_s1(s AS string) DECLARE FUNCTION give_result() AS STRING END TYPE OPERATOR + (BYREF x AS tklasse1, BYREF y AS tklasse1) AS tklasse1 DIM AS tklasse1 retval retval.x1=x.x1+y.x1 'Beachte: Hier werden die Strings aneinandergehängt! *retval.s1=*x.s1+*y.s1 'Rückgabe RETURN retval END OPERATOR CONSTRUCTOR tklasse1() s1=ALLOCATE(1000) ? "String wurde reserviert" END CONSTRUCTOR DESTRUCTOR tklasse1() DEALLOCATE(s1) s1=0 ? "String wurde freigegeben" END DESTRUCTOR CONSTRUCTOR tklasse1(BYREF obj AS tklasse1) THIS.x1=obj.x1 *THIS.s1=*obj.s1 END CONSTRUCTOR SUB tklasse1.fill_s1(s AS string) IF (s1=0) THEN PRINT "tklasse1.fill_s1() error: Buffer not allocated" END END IF *s1=s '(...) END SUB ' Das ist eine Methodendefinition: FUNCTION tklasse1.give_result() AS STRING '(...) give_result=*s1 END FUNCTION 'Das ist eine Routine ausserhalb einer Klasse: SUB main DIM AS tklasse1 objekt1, objekt2, objekt3 objekt1.fill_s1("Hallo") objekt2=objekt1 objekt3=objekt1+objekt2 ? objekt2.give_result() SLEEP END SUB main
Wir sehen, dass der Additionsoperator nicht klassenspezifisch definiert wurde, sondern global. Das schreibt Freebasic zwingend so vor. Macht aber keinen Unterschied, denn wegen der Überladung und der Spezifikation auf die Argumente des Typs tklasse1 kann man den Operator für jede Klasse individuell festlegen. Allerdings wird keineswegs jeder Operator auf diese Art und Weise festgelegt. Mehr Details sollte man sich im Freebasic-Wiki - Programming Guide anlesen.
Man beachte: Um Operatoren zu definieren, ist die Rückgabe des gesamten Objekts notwendig. Das kann heikel sein - es sei denn, man hat vorher schon sorgfältig den korrekten Copy-Konstruktor definiert. Der wird nämlich bei "Return" aufgerufen.
In sauberer OOP sind Zugriffe auf Eigenschaften von aussen nicht erlaubt. Wie wir an unserem String-Beispiel sehen können: Ein einfaches Insert a la objekt1.s1=left(objekt1.s1,pos)+s2+right(objekt1.s1,len(objekt1.s1)-pos+1) wäre eine Katastrophe, da ein individuell reserviertes s1 (nur soviel Zeichen, wie das Argument bei .fill() lang ist) keinen Platz für den Einfügestring haben wird. Es ist also notwendig, dass alle Eigenschaftszugriffe unter Kontrolle der Klasse stehen. Das sichert man über das Schlüsselwort Private ab:
TYPE tklasse1 DECLARE CONSTRUCTOR() DECLARE CONSTRUCTOR(BYREF obj AS tklasse1) DECLARE DESTRUCTOR() DECLARE SUB fill_s1(s AS string) DECLARE FUNCTION get_x1 AS INTEGER DECLARE FUNCTION get_s1 AS STRING DECLARE SUB set_x1(x1 AS integer) DECLARE SUB set_s1(s1 AS string) DECLARE FUNCTION give_result() AS STRING private: x1 AS INTEGER s1 AS ZSTRING PTR END TYPE FUNCTION tklasse1.get_x1 AS INTEGER RETURN x1 END FUNCTION FUNCTION tklasse1.get_s1 AS STRING RETURN *s1 END FUNCTION SUB tklasse1.set_x1(x1 AS integer) THIS.x1=x1 END SUB SUB tklasse1.set_s1(s1 AS string) *THIS.s1=s1 END SUB OPERATOR + (BYREF x AS tklasse1, BYREF y AS tklasse1) AS tklasse1 DIM AS tklasse1 retval retval.set_x1(x.get_x1+y.get_x1) 'Beachte: Hier werden die Strings aneinandergehängt! retval.set_s1(x.get_s1+y.get_s1) 'Rückgabe RETURN retval END OPERATOR '(...)
Alle Eigenschaften hinter "private" sind nur noch für die eigene Klasse lesend und schreibend zugänglich. Resultat: Erstmal viel Schreibarbeit, falls wir Operatoren überladen. Dadurch, dass die Operatoren global definiert werden, müssen wir zwangsweise von aussen auf die Eigenschaften zugreifen, was heisst, dass wir erstmal einen Haufen s.g. "Setter"- und "Getter"-Methoden schreiben müssen. Und für jede neue Eigenschaft braucht's meist eine neue Setter- und Getter-Methode. Der konsequente Weg über private-Members macht sich daher erst dann bezahlt, wenn man sich so gar nicht diszplinieren kann beim Umgang mit seinen eigenen Objekten - oder aber wenn Klassen so heikel sind, dass man sich den Zugriff auf ihre Eigenschaften gleich ganz verbieten muss.
Im nächsten Kapitel werden wir ein bisschen OOP anwenden. Nicht alles, aber so das ein oder andere. Vielleicht werden aus diesem Praxisbezug die Konzepte verständlicher.