Während die letzten Kapitel eher die technischen Gestaltungsmöglichkeiten mit Freebasic illustriert haben, kamen wir ganz nebenbei in immer komplizierter anmutende Gefilde. Da waren Sprite- und Fensterobjekte und von diesen wiederum gab es gleich ganze Arrays voll und irgendwie mussten die einen Objekte von anderen etwas wissen und wir mussten ganze Wagenladungen an Argumenten übergeben. Und da waren Routinen, die wir öfters brauchten, aber eben dann doch nicht exakt in der gleichen Form, sondern leicht abgewandelt, weswegen es dann Version 2,3 und 4 davon gab. Und noch einiges mehr von diesen Problemen. Insgesamt wuchs der Wunsch, mehr Gestaltungsmittel zu haben, diese Aufgaben besser strukturieren zu können.
Das Wort "Strukturierte Programmierung" darf man heute eigentlich schon gar nicht mehr in den Mund nehmen. Alles ist heute objektorientiert. "Strukturiert" - das ist ein Schlagwort von vor 20, 30 Jahren. Bei dem ganzen Tamtam um Objektorientierung und funktionale Programmierung ging m.E. der Blick ziemlich gründlich für die Kunst verloren, eine Aufgabe ersteinmal prozedural zu strukturieren. Schon mit den bescheidenen Mitteln, die wir bisher zur Verfügung hatten, konnten wir mittels einer solchen Strukturierung ganz erstaunliche Dinge realisieren. Und prozedurale Strukturierung ist die allerste Voraussetzung, innerhalb der OOP (objektorientierten Programmierung) eine geeignete Klassenstruktur der Aufgabenstellung freizulegen.
Was verstehen wir unter dieser "strukturierten Programmierung"? Es sind im Kern drei Dinge:
Ein ganz wesentliches Element dabei, das wir bisher nicht kennengelernt haben, ist die Referenz auf ein Speicherobjekt. D.h., wir übergeben einer Funktion nicht das Speicherobjekt selbst, sondern nur einen Zeiger auf dieses Objekt. Auf diese Art und Weise können wir sehr viel mehr Informationen innerhalb der Prozedur zugänglich bekommen, ohne ganze Kopierorgien starten zu müssen - die unter Umständen problematisch sein können.
Zur strukturierten Programmierung gehören auch leistungsfähige allgemeine Speicherformen wie Listen, Hashes oder Bäume. Diese setzen aber wiederum voraus, dass rekursiv programmiert werden kann.
Damit haben wir ersteinmal genug Themen...
Im folgenden reisse ich einige Schlüsselwörter und Themen nur an, eine gründliche Beschreibung finden Sie unter dem jeweiligen Schlüsselwort in der Referenz auf www.freebasic-portal.de
Es gibt die elementaren und die zusammengesetzten Datentypen, das haben wir schon gelernt. Bei den elementaren Datentypen haben wir bisher im Wesentlichen die Ganzzahltypen INTEGER und LONG und die Fliesskomma-Typen SINGLE und DOUBLE kennengelernt. Daneben kennt Freebasic allerdings noch ne ganze Menge andere und muss sie kennen, da sie von den Betriebssystemen und deren API's verwendet werden. BYTE und UBYTE sind Ganzzahltypen mit 1 Byte, SHORT und USHORT mit 2 Byte, INTEGER und UINTEGER mit 4 Byte und LONGINT und ULONGINT mit 8 Byte Grösse. Das U- steht jeweils für Unsigned - ohne Vorzeichen. Geht BYTE von -128 bis 127, dann geht UBYTE von 0 bis 255, d.h. das erste Bit wir nicht als Vorzeichen interpretiert.
Wie dem auch sei: In eigenen Programmen sollten Sie so wenig verschiedene dieser Typen wie möglich verwenden, INTEGER und DOUBLE reichen eigentlich immer. Ansonsten haben Sie immer die Unsicherheit, welcher Geltungsbereich eigentlich jetzt für die betrachtete Variable zu beachten ist, Sie müssen die Deklaration nachschauen und das ist alles umständlich. Speicherplatzsparen durch Verwenden von U-Versionen, BYTE oder SHORT ist fast immer die falsche Strategie.
Ausserdem können Sie ab jetzt diese seltsamen QBASIC-Sonderzeichen für die Datentypen vergessen. Datentypen werden mit DIM deklariert.
Ebenfalls bedingt durch die Bindung an Betriebssystem-API's gibt es auch mehrere Stringtypen. Zur Zeit sind das STRING und WSTRING. Wobei sich hinter STRING eigentlich zwei Typen verbergen, nämlich der alte QB-String fester Länge und der neue Freebasic-String variabler Länge:
DIM qbstring AS STRING*255 DIM fbstring AS STRING
Beide Stringtypen können bis zu 2 GB Zeichen enthalten und enden immer mit einem CHR(0)-Zeichen; es handelt sich daher um s.g. nullterminierte Strings, wie sie in C und C++ üblich sind. Der reservierte Speicherbereich von STRING ohne Längenangabe wird laufend den Erfordernissen angepasst.
WSTRING nimmt s.g. Unicode-Zeichen auf. Unicode ist eine alternative Codierung der Zeichen zu ASCII, bzw. ANSI und benötigt 2 Bytes pro Zeichen. Immer mehr Textdateien werden in Unicode gespeichert (insbesondere im Emailverkehr), da das das Handling der ganzen Umlaute enorm vereinfacht: Egal ob der Text Japanisch, Chinesisch oder Slawisch enthält, er wird auf Ihrem deutschen Bildschirm richtig angezeigt werden. Da die Unicode-Codierung allerdings die gesamten Stringmanipulationen wie STR(), VAL(), MID() usw. betrifft, gibt es dazu jeweils das W-Äquivalent.
Oft enthalten Variablen nur Grössen, die eine bestimmte Anzahl diskreter Zustände annehmen können. Beim Programmieren häufig treten s.g. Flags auf, die solch einen Programmzustand kennzeichnen und oft nur zwei oder drei Ausprägungen kennen. Oder man übergibt einer Funktion eine zusätzliche Modusvariable, die bestimmt, in welchem Modus die Funktion operieren soll - oft eine elegante Alternative, mehrere Versionen im Grunde ein- und derselben Funktion zu verhindern. Die einfachste und am meisten praktizierte Umsetzung ist die Deklaration einer INTEGER-Variablen und der entsprechenden Definition von Konstanten - siehe weiter unten. In einer der nächsten Versionen von Freebasic wird es explizit die s.g. ENUM-Listen von Freebasic geben.
ENUM tfarbe rot gruen blau END ENUM DIM a AS ta a=blau ? a '==> 2 SLEEP
Insbesondere lässt sich dadurch elegant ein wichtiger, scheinbar fehlender Datentyp definieren, nämlich der Datentyp "Boolean". Er kennt nur die Ausprägungen "Wahr" und "Falsch", also TRUE und FALSE. Wir haben diese in den bisherigen Programmen schon als Konstanten definiert gehabt. Jeder logische Ausdruck hat als Ergebnis einen solchen boolean-Wert und kann auch in einer entsprechenden Variable gespeichert werden. In Freebasic sind diese Booleans immer Integer. In QBASIC ergeben logische Ausdrücke integer-Werte, und zwar -1 für true und 0 für false. In Freebasic ist dies auch für alle Versionen bis 0.15b der Fall. Ab Ver. 0.16 folgt Freebasic der verbreiteten C-Logik: True=1 und False=0. Man könnte sich aber auch seinen eigenen Boolean-Typ via ENUM-Liste schaffen:
ENUM tboolean TRUE FALSE END ENUM
Konstanten sind Variablen mit Schreibschutz. Das hat Auswirkungen auf den Gebrauch: Da der Wert einer Konstanten zur Compilerzeit feststeht, können sie auch in Deklarationen auftauchen.
Thema Konstanten, die Parameter angeben
Grundsätzlich sollte man es dringlichst vermeiden, konstante Zahlenwerte irgendwo in den Routinen-Code zu schreiben. Ich habe z.B. im Vorkapitel bei der Implementation der twindow_xxx-Routinen ein wenig geschlampt und es dann gleich wieder bereut, als ich die Grösse des Schliessenknopfs am Fenster direkt in Routinen-Code schrieb. Warum?
Ausserdem wird das Programm selbst natürlich viel lesbarer, wenn da steht "if (screen.y<=closebutton_height) then...", statt "if (screen.y<=18) then..."
Die schwierigere Frage ist oft, ob man Parameter, die das Programm steuern und z.B. das Aussehen oder das Format oder andere Dinge im Programm verändern, ob man diese als Konstanten in den Quellcode reinschreiben soll oder aus einer ini-Datei einlesen soll. Diese Frage kann nicht so einfach beantwortet werden. Beide Lösungen haben Vor- und Nachteile. Hauptnachteil der ini-Lösung: Parameter und Quellcode sind nicht mehr untrennbar. Das kann bei Berechnungsprogrammen, z.B. im Finanzbereich, zum Problem werden. Man kann nicht mehr nachvollziehen, mit welchen Parametern das Programm gelaufen ist. Ausserdem ist es natürlich aufwändiger und dieser zusätzliche Komfort, die Parameter ohne Neukompilation ändern zu können, rentiert sich meist nur, wenn noch jemand anderes das Programm benutzt, nicht nur der Programmierer. Für den ist es oft einfacher, das Proggi einfach nochmal neu zu kompilieren.
Thema Konstanten für Aufzählungstypen und Felder
Konstanten leisten auch prima Dienste, um Aufzählungstypen bereitzustellen. Habe ich z.B. eine Routine, die drei verschiedene Betriebsmodi haben soll, "normal", "noview" und "verbose". Dann empfiehlt es sich, drei Konstanten zu definieren: CONST normal=0, CONST noview=1 und CONST verbose=2. Anschliessend ist der Aufruf der Routine sehr verständlich: "mymethod(somearg,verbose)" ist sehr viel klarer als "mymethod(somearg,2)". Und auch bei der Implementation ist eine Anweisung der Form "if (mode=verbose) then..." besser als 3 Kopfschmerztabletten...
Eine sehr flexible Art, mit Daten umzugehen, bietet die Kombination aus Konstanten und Arrays. Es ist nicht für alle Zwecke die beste Lösung, neue Strukturvariablen mit TYPE zu deklarieren. Arrays plus Konstanten können die gleiche Aufgabe oft effektiver erledigen. Nehmen wir z.B. ein Simulationsprogramm mit vielen Akteuren. Die Daten jedes Akteurs müssen gespeichert werden. Das kann man natürlich mit Einzelvariablen machen, die irgendwo im Programm rumfliegen - ganz schlecht. Man kann auch einen Typ tactor definieren und dort einzeln alle Eigenschaften festhalten:
TYPE tactor v1 AS DOUBLE vmax AS DOUBLE forcemax AS DOUBLE wheeldia AS DOUBLE usw. usf. END TYPE
Es gibt aber ein Problem, wenn alle Eigenschaften gemeinsam behandelt werden sollen, z.B. gemeinsam kopiert, gemeinsam in ein Logfile ausgegeben, gemeinsam initialisiert. Dann müssen immer alle Variablen einzeln angesprochen werden. Das ist nicht nur umständlich, das ist auch gefährlich: Kommt eine Eigenschaft hinzu, müssen alle diese Routinen aktualisiert werden. Blöd, wenn man die Initialisierungsroutine dabei vergisst...
Eine gute Alternative ist es, die ganzen Daten in einem oder zwei Arrays zu speichern und die Namen für die Einzelvariablen in Konstanten:
CONST Iv1=0, _ Ivmax=1, _ Iforcemax=2, _ Iwheeldia=3, _ usw. usf. TYPE tactor dat(100) AS DOUBLE ndat AS INTEGER END TYPE
Anschliessend kann man die gemeinsamen Aufgaben in einer Schleife erledigen: FOR i=0 TO ndat-1:dat(i)=0:next i. Die einzelnen Eigenschaften kann man mit actor.dat(Iwheeldia) aber immer noch verständlich abrufen.
Zum Schluss: Wo soll man Konstanten definieren? Lokal in der Methode oder global? Nun, in aller Regel ist eine globale Definition vorzuziehen. Man sollte grundsätzlich Konstanten global definieren, solange es nicht zu Überschneidungen kommt. Lokale Definitionen haben zwar den Vorteil, im Quellcode schneller sichtbar zu sein, aber meist braucht man die Konstante eben dann doch noch irgendwo anders und dann muss man sie sowieso global machen. Sollte die Zahl der Konstanten so ausufern, dass Sie den Überblick verlieren, dann erst wäre zu überlegen, welche Konstanten man lokalisieren sollte, bzw. welche gar keine Konstanten sein zu brauchen, so dass man sie dann auch in die Strukturvariablen mit reinpacken kann.
Die normale Form, die wir gelernt hatten, war:
DIM x as typ
Freebasic bringt glücklicherweise noch eine andere Form mit:
DIM as typ x
Die letzte ist vorzuziehen, weil man auf diese Art und Weise viele Variablen desselben Typs in eine Zeile schreiben kann. Das elendigliche "DIM i as integer, j as integer, k as integer" verkürzt sich dann zu "DIM as integer i,j,k".
Wie wir gesehen haben, kann man auch Deklaration und Initialisierung zusammenziehen:
DIM as typ x = 1, y = 2
funktioniert auch. Ob Sie diese Form oder die explizite Initialisierung bevorzugen, ist Geschmackssache.
Eine wichtige Sache ist allerdings der Bereich ("Scope"), auf dem die Variable gültig ist. Leider muss hier Freebasic noch eine unelegante Altlast aus QBASIC mit sich herumschleppen: Das Schlüsselwort SHARED. In strukturierten Programmiersprachen ist normalerweise nur die Position der Deklaration für den Gültigkeitsbereich der Variable entscheidend: Befindet sich die Deklaration ausserhalb einer Routine, ist die Variable global definiert, ansonsten lokal. In VBA hat Microsoft diese Regel übernommen. In Freebasic hingegen muss man bei globalen Variablen das SHARED noch dazuschreiben, da ansonsten die Variable ausschliesslich ausserhalb der Routinen gültig wäre, was ja sozusagen die Hauptroutine darstellt.
OPTION EXPLICIT DIM SHARED globx AS INTEGER DIM y AS INTEGER SUB routine() '==> Hier ist globx gültig, y nicht. ' In VBA wäre hier auch y gültig. END SUB y=globx '==> Hier ist y und globx gültig.
Eine ganz wichtige Neuerung für die strukturierte Programmierung, die wir bisher noch nicht explizit kennengelernt haben, stellen Scopes dar. Scopes sind Bereiche, in denen Deklarationen gültig sind. Ha, das kennen wir doch schon, "lokal" und "global", haben wir doch gerade eben besprochen. Richtig. Aber das reicht nicht. Es muss mehr Gültigkeitsbereiche geben und sie müssen vom Programmierer definiert werden können. Das leistet das Schlüsselwort SCOPE. Es ist dasselbe wie die geschweifte Klammer in C++,C# und Java und wie "BEGIN" und "END" in Pascal und Delphi. Auch SCOPE tritt im Pärchen auf: SCOPE und END SCOPE. Dazwischen können (und sollten) Deklarationen erfolgen, die nur zwischen SCOPE und END SCOPE Gültigkeit haben.
Beispiel:
DIM AS INTEGER i i=2 SCOPE DIM AS INTEGER i i=3 ? i END SCOPE ? i SLEEP Ausgabe: --------- 3 2
Scopes dienen der weiteren Kapselung (gegenseitige Abschottung) von Variablen, auch innerhalb von Funktionen. Dies ist vor allem für Zähler- und Hilfsvariablen sehr nützlich, die man oft innerhalb von verschachtelten Blöcken mehrmals benutzt. Was sind Blöcke? Nun, auf eine if-Bedingung kann entweder eine einzelne Anweisung folgen oder eine Reihe von Anweisungen, die mit IF...END IF eingegrenzt wird. Diese Reihe Anweisung zählt dann wie eine Anweisung. Genau so etwas nennt man einen Block: Eine Bündelung mehrer Anweisungen zu einer Anweisung. Solch eine Bündelung sieht QBasic/Freebasic auch bei den Schleifen vor, wenn man es dort auch nicht so explizit erkennt: FOR....NEXT, WHILE...WEND und LOOP....END LOOP definieren jeweils einen Block.
In C/C++ und Java öffnet jeder Block auch automatisch einen neuen Scope. (Wie es im modernen Delphi aussieht, weiss ich nicht.) In Freebasic müssen wir das von Hand machen, aber es ist sehr zu empfehlen, dies fast automatisch immer druchzuführen:
DIM AS INTEGER i FOR i=0 TO 9 SCOPE DIM AS INTEGER i FOR i=0 TO 3 SCOPE ? i END SCOPE NEXT i END SCOPE NEXT i
Jedenfalls kann man sich dadurch eine Menge Ärger und die eine oder andere Organisationsarbeit ersparen ("habe ich diese Variable jetzt schon benutzt oder nicht?"). Ein typisches Beispiel sind z.B. auch die Variablen x und y im letzten Beispiel des Vorkapitels, die ohne SCOPE leicht doppelt belegt werden können.
Welche Variablen sind innerhalb eines SCOPES sichtbar? Alle innerhalb des SCOPES deklarierten Variablen. Alle Variablen aller umhüllenden SCOPES, sofern sie nicht durch Variablen gleichen Namens im "lokalen" SCOPE überlagert werden. (Dies ist im obigen Beispiel mit i der Fall.) Der globale SCOPE ist damit nur der äusserste umhüllende SCOPE und für ihn das gleiche.
Im QBasic-Kapitel 2.15 haben wir schon den Begriff "By reference" kennengelernt: Eine Funktion und ihr Aufrufer verwalten die Variablen, die als Argumente übergeben werden, gemeinsam. Ruft Routine master() die Routine slave(x as double) auf, dann können beide, master() und slave() das x verändern. Das widerspricht dem Prinzip der Kapselung, nachdem es vermieden werden sollte, dass unbeabsichtigte Veränderungen "von aussen" passieren und daher der Scope der Variablen möglichst klein gehalten werden sollte. Daher gibt es die Möglichkeit, die Übergabe auf BYVAL umzustellen. slave(BYVAL x as double) hat den Effekt, dass innerhalb von slave() eine neue Variable x deklariert und ihr der Wert des x aus master() übergeben wird. Die Deklaration ist "unsichtbar", man kann sie nur an dem BYVAL erkennen. Und daran, dass x, wenn slave() beendet ist, in master() unverändert bleibt, egal, was auch slave() mit x angestellt hat - es war ja sein eigenes x, nicht das von master()!
SUB slave(BYVAL x AS double) x=x+3 END SUB SUB master() DIM AS DOUBLE x x=3.5 slave(x) ? x SLEEP END SUB Ausgabe: 3.5
Ein wesentliches Element strukturierter Programmierung ist es, die Paramter grundsätzlich BYVAL zu übergeben und nur dann auf BYREF umzuschwenken, falls dieses Argument als Rückgabevariable gebraucht wird. (Und es ist eine noch schönere Programmierung, wenn solche Argument-Rückgaben gar nicht benötigt werden...). Daher ist es ratsam, in Zukunft an den Anfang unserer Programme nicht nur "OPTION explicit" zu schreiben, sondern auch "OPTION byval". Dadurch schalten wir die Standardübergabemethode auf BYVAL um und müssen dann nur noch in den einzelnen Funktionendeklarationen BYREF dort angeben, wo wir es auch wirklich brauchen.
"Rekursiv" heisst: "Auf sich selbst beziehend". Eine rekursive Funktion ist eine, die sich auf sich selbst bezieht, indem sie sich selbst aufruft. Klingt paradox, geht aber. Genauso gibt es rekursive Strukturen: Ein zusammengesetzter Typ enthält eine Variable seines eigenen Typs. Noch häufiger: Typ A enthält eine Variable vom Typ B und Typ B enthält eine Variable vom Typ A. Das ist eher schon Programmiereralltag. Doch zurück zu den sich selbst aufrufenden Funktionen. Die sind nicht so oft vonnöten, aber hie und da schon, z.B. in der Numerik bei Iterationsaufgaben oder beim Suchen eines optimalen Weges in einem Netzwerk. Prinzipiell können die jeweiligen Problemstellungen auch ohne Rekursion gelöst werden, lediglich per Iteration in einer Schleife, aber in einigen Fällen wäre dies erheblich viel umständlicher. Es gibt auch so etwas wie Scheinrekursionen. Ich hatte in einem Projekt z.B. den Fall, dass Routine1 eine ziemlich aufwändige Aufgabe zu erledigen hatte. Irgendein Teil eines Teiles dieser Aufgabe war Routine2. Diese wiederum benötigte eine Information, die eigentlich nur Routine 1 bereitstellen konnte - für einen anderen Zeitpunkt. Für diese Information waren nur Teile von Routine1 notwendig, die nicht indirekt zum nochmaligen Aufruf von Routine2 führten. Also rief ich von Routine2 aus Routine1 auf, aber im Modus "einfach".
Das Prinzip ist schnell erklärt:
DECLARE SUB rectest1(i AS integer) SUB rectest1(i AS integer) i+=1 IF (i<=9) THEN rectest1(i) END SUB DIM i AS INTEGER i=0 rectest1(i) ? i SLEEP Ausgabe: 10
Wir müssen also die Funktion zuerst mit DECLARE deklarieren wie wir das früher schon gemacht haben. Wir werden im weiteren Verlauf lernen, dass das zumindest für etwas grössere Projekte ohnehin ratsam ist, alle Funktionen am Anfang zu deklarieren und erst später zu definieren. Das Hauptprogramm ruft rectest1() mit dem Argument 0 auf. Dort wird i inkrementiert (um eins erhöht), dann wird wieder rectest1 aufgerufen. Dort wird wieder i erhöht und wiederum rectest1 aufgerufen. Usw. usf., bis die if-Bedingungen einen weiteren Aufruf verhindert. Ab da werden dann alle aufgerufenen rectest's von innen nach aussen wieder verlassen. Bis am Ende das Hauptprogramm wieder erreicht wird.
So einfach das hier aussehen mag, in der Realität ist die Formulierung einer rekursiven Suche oder ähnliches meist ziemliche Kopfakrobatik. Das fängt schon mit der Frage an, wie die Aufgabe so formuliert werden kann, dass sie sich exakt ineinander verschachteln lässt. Dann kann man sich die Ausgangslage des Funktionsaufrufs meist schlecht vorstellen. Und schliesslich ist sicherzustellen, dass die Funktion auch in allen Fällen zum Abschluss kommt und nicht unendlich weiterrekursiert. Die goldene Seite der Medaillie: Hat man die oft wenigen Zeilen der Rekursion geschrieben, ist die Hauptaufgabe meist auch schon erschlagen.
Übung1:
Schreiben Sie eine rekursiv arbeitende Primzahlzerlegung. Jede Zahl kann ja in ein Produkt aus Primzahlfaktoren zerlegt werden. Die Frage ist nur: Welche sind das? Ihre Routine soll so vorgehen, dass sie von 1 angefangen alle ungeraden Zahlen darauf testet, ob sie Teiler des Produkts sind. Wird ein Teiler gefunden, hat man natürlich auch gleich einen anderen. Anschliessend ist auf beide Teiler wiederum die Primzahlzerlegung anzuwenden. Effektiv läuft das Ganze dann, wenn Sie dabei gleich eine Liste pflegen, in die die gefundenen Primzahlen während der Suche eingetragen werden. Viel Spass!