Objektorientierte Sprachansätze in Freebasic


  • Klassen, Eigenschaften, Methoden
  • Dynamisch reservierte Klassen
  • Konstruktoren und Destruktoren
  • Überladung
  • Rechte
  • 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, Eigenschaften, Methoden

    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.

    Dynamisch reservierte Klassen

    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
    

    Konstruktoren und Destruktoren

    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 mit Parametern

    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.

    Copy-Konstruktoren, deep and shallow copies

    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
    
    

    Überladung

    Überladung so nebenbei...

    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

    Ü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.

    Rechte

    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.