Scriptsprache 3/10
Design und Technik


Was ist eine Scriptsprache

Warnung: Dies ist erst eine Vorabversion des Kapitels. Es ist möglich, das ich einiges ändere. Außerdem ist es noch nicht vollständig.

Eine Scriptsprache ist eine interpretierte Programmiersprache, die meistens dazu dient ein anderes Programm zu steuern. Eine Scriptsprache wird Anweisung für Anweisung aus einer Dateigelesen und ausgeführt. Man kann natürlich auch die nächsten Befehle oder sogar die ganze Datei vorher im Arbeitsspeicher cachen.
Scriptsprachen sollten möglichst einfach gehalten werden. Man will normalerweise keine komplexen Programme mit ihnen gestalten. Eine objektrientierte Scriptsprache ist also totaler Overkill. Sie sollte so ausgelegt werden, dass urze Programme möglichst einfach getaltet werden können ohne rücksicht auf große Projete. QB selbst ist leider auch in disem Stiel gehalten, weshalb das verwirklichen großer Projete recht schwierig ist.
Das Format einer Scriptdatei ist nicht vorgeschreiben. Üblich ist es jedoch pro Befehl eine Zeile zu verwenden. Natürlich kann man auch andere Formate, wie das der Befehl beim nächsten Semikolon, wie es in 'C' und dessen Verwanten ist, endet. Oder den Befehl in eine Zeile und dessen Argumente in die darauf folgende. Der Fantasie sind keine Grenzen gesetzt. Es sollte nur für Menschen lesbar sein. Wen die Scripte nicht lesbar sein sollen, können Sie sie verschlüsseln, was aber die Ausfürgeschwindigkeit reduziert, da die Datei erst entschlüsselt werden muss. Eine Alternative ist ein Scriptsprachencompiler, der die Scriptsprache in ein anderes leicht zu interpretierendes Format überführt. Ein Befehl besteht dann meist nur noch aus einem einzelnen Byte. Das ist dann geleichzeitig auch noch platzsparend. Ich werde hier jedch zunächst ohne Compiler oder verschlüsselung arbeiten.
Die häufigste Anwendung von Scriptsprachen in QB ist wohl in RPGs (Role Play Games -> Rollenspiele). Satt die gesammten Dialoge direkt ins Programm zu schreiben, wodurch es gigantisch groß würde, teilt man es auf mehrere kleine Scripte auf, die dann dynamisch geladen werden. Dadurch hat man viele Vorteile. Beispielsweise kann man ohne das Programm neu zu kompilieren Dialoge anpassen oder korigieren.
Ein Beispiel für ein solches Script könnte so aussehen:

Comment Erstes script (erstes.txt)
Clear
Display "Wow! Das ist meine erste Scriptsprache!"
Display "Drücke eine Taste zum Beenden."
Wait_for_key
Display "Du hast eine Taste gedrückt und nun wird das Programm beendet!"

In diesem Kapitel geht es darum ein solches Script zu interpretieren. Zunächst zeige ich einen sehr naiven Ansatz, der 1999 in der QB Times zu finden war. Hierbei handelt es sich allerdings um eine an meine Scriptsprache angepasste Version:

OPEN "erstes.txt" FOR INPUT AS 1
DIM zeile AS STRING

DO
   LINE INPUT #1, zeile
   zeile = LTRIM$(RTRIM$(zeile))

   IF LEFT$(zeile, 7) = "Display" THEN
      PRINT RIGHT$(zeile, LEN(zeile) - 8)
   END IF

   IF LEFT$(zeile, 12) = "Wait_for_key" THEN
      WHILE INKEY$ = "": WEND
   END IF

   IF LEFT$(zeile, 5) = "Clear" THEN
      CLS
   END IF

   IF LEFT$(zeile, 7) = "Comment" THEN
   END IF

LOOP UNTIL EOF(1)
CLOSE 1

Es funktioniert zwar, ist aber unübersichtlich, fehleranfällig und langsam. Die erste offesichtliche Kritik ist, dass es keine Behandlung für falschgeschriebene Anweisungen gibt. Sollte die Datei leer sein, stürzt das Programm ab. Und warum werden mehrere eintelne 'IF's verwendet. Würden verschachtelte 'ELSEIF's verwendt, müssten nicht jedesmal alle Bedingungen überprüft werden, sondern nur die Bedingunen bis zu der Bedingung, die auch tatsächlich zutrifft. Eine erste Verbesserung sähe also so aus:

OPEN "erstes.txt" FOR INPUT AS 1
DIM zeile AS STRING

DO UNTIL EOF(1)
   LINE INPUT #1, zeile
   zeile = LTRIM$(RTRIM$(zeile))

   IF LEFT$(zeile, 7) = "Display" THEN
      PRINT RIGHT$(zeile, LEN(zeile) - 8)

   ELSEIF LEFT$(zeile, 12) = "Wait_for_key" THEN
      WHILE INKEY$ = "": WEND

   ELSEIF LEFT$(zeile, 5) = "Clear" THEN
      CLS

   ELSEIF LEFT$(zeile, 7) = "Comment" THEN

   ELSEIF zeile = "" THEN
      ' sonst gäbe es Fehler bei leerzeilen

   ELSE
      PRINT "Unbekannte Anweisung"

   END IF

LOOP
CLOSE 1

So wird bei unbekannten Befehlen eine Meldung ausgegeben und das Script weiter abgearbeitet. Soll das Programm bei einer Fehlerhaften Anweisung beendet werden können Sie eine Exception werfen (siehe dazu das apitel 'Fehlerbehandlung').


Weitere Verbesserungen

Jetzt ist es ein wenig weniger Fehleranfällig und etwas schneller, aber bei weitem noch nicht perfekt.
Z.B. wenn wir Variablen einführen, wird die Ausgabefunktion 'Display' komplexer. Außerdem benötigen wir zum Arbeiten mit Variablen noch einige Anweisunen mehr. Der Auswertungsteil würde schließlich so kompiziert, dass der Teil unwartbar wird.
Wie in der prozeduralen Programmierung üblich wird das Programm in mehrere Prozeduren unterteil.
Eine Anweisung besteht jeweils aus einem Befehl und einer Reihe von Argumenten. Es sei denn es handlet sich um eine Zuweisung einer Variable. Also sollte man testen, ob es sich um eine Zuweisung handlt. Falls nicht sollte die Anweisung in den Befehl und die Argumente aufgeteilt werden. Abhängig vom Befehl sollte dann eine entsprechende Prozedur ausgeführt werden. So bleibt das Programm übersichtlich, wartbar und eventuelle Fehler lassen sich gut lokalisieren.
Außerdem sollte auch das öffnen und durchlauen der Datei in eine Prozedur, da die Auswertung sicherlich nicht die einzige Aufgabe des Programms wird.
Dann ist da noch der Festgelegte Handle für die Datei: Hier sollte mit 'FREEFILE' ein noch nicht benutzter Handel gesucht werden, damit es keine Probleme mit anderen Teilen des Programms geben kann, das ja auch Dateien öffnen kann.


Prozeduraler Aufbau

Nochmal zuer Erinerung: In der prozeduralen Programmierung bekommt jede Aufgabe seine eigene Prozedur. Als erstes die Prozedur, die das Script (mit hilfe vieler weiterer Prozeduren) ausführt:

DEFINT A-Z
SUB ExecuteScript (filename AS STRING)

   handle = FREEFILE
   OPEN filename FOR INPUT AS handle

   DO UNTIL EOF(1)
      DIM scriptLine AS STRING
      LINE INPUT #handle, scriptLine
      scriptLine = LTRIM$(RTRIM$(scriptLine))

      IF LEN(scriptLine) > 0 THEN InterpretLine scriptLine
         ' Leere Zeilen zu interpreteiren würde nur unnötig Zeit kosten

   LOOP
   CLOSE handle

END SUB

Sie sehen, dass das Programm bereits wesentlich übersichtlicher ist. Egal wie viele Befehl zum Sprachumfang ihrer Sprache gehören. Diese Prozedur ändert sich nicht.
Bleibt zu klären, was das uminöse 'InterpretLine' macht. Es muss die Zeile in den eigentlichen Befehl und seine Parameter zerlegen. Anschließend muss der Befehl ausgeführt werden. Da eine gute Scriptsprache aber auch Variablen zulässt, ist statt eines Befehls eine Zuweisung möglich. Darum kümmern wir uns aber zunächst nicht. Die Funktion 'IsAssignment' des folgenden Programmausschnittes liefert einfach erstmal '0' zurück:

DEFINT A-Z
SUB InterpretLine (scriptLine AS STRING)

   IF IsAssignment(scriptLine) THEN
      ' ...
   ELSE

      DIM befehl AS STRING
      DIM parameter(25) AS STRING

      ExtractCommand scriptLine, befehl, parameter(), parameterCount

      ExecuteCommand befehl, parameter(), parameterCount

   END IF

END SUB

Wenn Sie glauben, dass jemals mehr als 25 Parameter vorkommen können, dann müssen Sie die Größe des Feldes erhöhen oder vielleicht sollten Sie das dynamische Feld, das in einem der vorigen Kapitel aufgebaut wurde verwnden.
Sie sehen auch diese Funktion ist nicht sonderlich kompliziert. Und sie muss auch nicht verändert werdn, wenn Sich der Sprachumfag erweitert. Nur, wenn eine grundsätzlich neue Art von Anweisung hinzukommt muss diese Prozedur angepasst werden. (Wir werden einen solchen neuen Typ erschaffen.)
Jetzt zum Extrahieren des Befehls:

DEFINT A-Z
SUB ExtractCommand (scriptLine AS STRING, command AS STRING, parameter() AS STRING, parameterCount AS INTEGER)

   DIM position AS INTEGER
   position = INSTR(scriptLine, " ")

   ' Falls kein Lerrzeichen vorhanden ist, suche stattdessen einen Tabulator
   IF position = 0 THEN position = INSTR(scriptLine, CHR$(34))

   IF position = 0 THEN
      command = scriptLine

   ELSE
      command = MID$(scriptLine, 1, position - 1)

      parameterCount = ExtractParameter(MID$(scriptLine, position + 1), parameter())
   END IF

END SUB

Hier kan man ein tolles Phänomen beobachten: 'COMMAND$' ist eine QB-Funktion, die die omandozeilen Parameter entgegen nimmt. Ohne das Suffix ist es jedoch eine legale Variable. Häufig stört der große Sprachumfang von QB, da viele Bezeichner, die man gerne verwenden würde bereits eine bedeutung für die Sprache haben.
Die Funktionsweise ist einfach: Es wird nach einem Leerzeichen gesucht. Ist keins vorhanden, wird statdessen nach einem Tabulator gesucht, da in dieser Scriptsprache sowohl Leerzichen, als auch Tabulatoren als Trennzeichen erlaubt sind. Ist keins von beiden Vorhanden, ist die gesammte Zeile der Befehl, ansonsten wird das, was hinter dem Trenzeichen steht in Parameter zerlegt und deren Anzahl festgehalten.
Das ist nicht sonderlich aufregend, da nur grundlegende Stringmanipulationen vorgenommen werden. Nun zum Zerlegen der Parameter:

DEFINT A-Z
FUNCTION ExtractParameter% (paramString AS STRING, parameter() AS STRING)

   DIM inString AS INTEGER
   FOR i = 1 TO LEN(paramString)
      DIM byte AS STRING * 1

      byte = MID$(paramString, i, 1)

      IF (byte = " " OR byte = CHR$(9)) AND NOT inString THEN
         whichParam = whichParam + 1

      ELSEIF byte = CHR$(34) THEN
         IF inString THEN whichParam = whichParam + 1

         inString = NOT inString

      ELSE
         parameter(whichParam) = parameter(whichParam) + byte

      END IF

   NEXT

   ExtractParameter = whichParam

END FUNCTION

Hier wird die Sache schon komplexer, obwohl sie im Grunde noch nicht kompliziert ist. Die Aufgabe lautet wie folgt: Zerlege den String in einzelne Parameter. Parameter sind von Leerzeichen oder Tabulatoren getrennte Zeichenketten. Allerdings können mehrere Zeichenketten durch Anführungszeichen gruppiern. Treffen zwei solcher Gruppierungen aufeinander, sollen Sie auch als einzelne Parameter gelten: 'hallo bla "foo""bar"' => 'hallo', 'bla', 'foo' und 'bar'.
Und so wird's gemacht: Der String, der die Parameter enthält wird zeichenweise durchlaufen. Treffen wir auf ein Leerzeichen oder Tabulator und befinden uns nicht in einem String (also einem durch Anführungszeichen eingeschlossenen Bereich), ist der arameter beendet und wir können den nächsten bearbeiten. Treffen wir auf ein Anführungszeichen und wir befinden uns bereits in einem String ist der String zuende und wir können einen neuen Parameter bearbeiten, ansonsten fängt der String grade erst an. Wenn die beiden anderen Bedingungen nicht zutrafen wird das eichen einfach an den aktuellen Parameter angehängt.
Aber moment: Was ist, wenn die Anzahl der Anführungszeichen nicht aufgeht?, was wenn mehr Parameter einglesen werden, als erlaubt sind? Also linken wir das Modul 'throw.bas' und binden die 'throw.bi' ein. Nach einer kleinen Anpassung sieht der Code wie flgt aus:

DEFINT A-Z
FUNCTION ExtractParameter% (paramString AS STRING, parameter() AS STRING)

   DIM inString AS INTEGER
   FOR i = 1 TO LEN(paramString)
      DIM byte AS STRING * 1

      byte = MID$(paramString, i, 1)

      IF (byte = " " OR byte = CHR$(9)) AND NOT inString THEN
         whichParam = whichParam + 1

      ELSEIF byte = CHR$(34) THEN
         IF inString THEN whichParam = whichParam + 1
         inString = NOT inString

      ELSE
         parameter(whichParam) = parameter(whichParam) + byte

      END IF

      IF whichParam > UBOUND(parameter) THEN
         mqbThrow "Scripting error", "Zu viele Argumente"
      END IF

   NEXT

   IF inString THEN
      mqbThrow "Scripting error", "Ungrade Anzahl von Anführungszeichen"
   END IF


   ExtractParameter = whichParam
END FUNCTION

Jetzt noch zum Auswerten der Befehle. Das eigentliche Auswerten sieht immernoch fast genauso aus, wie vorher:

DEFINT A-Z
SUB ExecuteCommand (command AS STRING, parameter() AS STRING, count AS INTEGER)

   SELECT CASE command
   CASE "Display"
      Display parameter(), count

   CASE "Wait_for_key"
      WHILE INKEY$ = "": WEND

   CASE "Clear"
      CLS

   CASE "Comment"

   CASE ELSE
      PRINT "Unbekannte Anweisung"

   END SELECT

END SUB

Statt der 'IF's kann jetzt ein 'SELECT CASE' verwendet werden, wodurch die Abfrage noch übersichtlicher wird. Sie mögen gehört haben, das 'SELECT CASE' langsamer ist, als 'IF. Das stimmt zwar, aber der Unterschied ist so gering, das es beim auswerten eines Scripts nicht ins Gewicht fällt. Sie müssen immer Abwägen ob eine Optimierung Sinn oder nur den Code nur unübersichtlicher macht.
Statt die Auswertung der Befehle direkt hier fest einzuprogrammieren, werden eigene Prozeduren verwendet. 'CLS' kann man bereits als Prozedur ansehen. Eine eigene Prozedur für die Schleife zum Warten auf einen Tastendruck scheint mir in diesem Fall übertrieben, da die Anweisung recht kurz ist, sie nicht häufig, sondern nur einmal aufgerufen wird, und da es unwarscheinlich ist, dass sich viel an dieser Anweisung ändert.
Jetzt zeige ich noch meine 'Display'-Prozedur und dann machen wir erstmal einen Zwischenstopp.

DEFINT A-Z
SUB Display (what() AS STRING, count AS INTEGER)

   FOR i = 0 TO count
      IF what(i) = "\n" THEN
         PRINT

      ELSE
         PRINT what(i);

      END IF
   NEXT

END SUB

Die Parameter werden nacheinander Ausgegeben. '\n' wird als besonderes Zeichen aufgefasst und provoziert die Ausgabe in einer neuen Zeile. Im moment ist es noch egal, ob Sie Anführungszeichen verwenden oder nicht. Ohne könnte öchstens die Anzahl zulässiger Parameter überschritten werden. Später sollen die nicht in Anführungszeichen stehenden Parameter als Variablen ausgewertet werden.


Zwischenstopp

Der Programmcode ist jetzt fünf Mal so lang und alles, was er mehr kann ist, dass durch die folgende Anweisung ein zweizeiliger Text ausgegeben wird:

Display "Erste" \n "Zweite"

Kein umwerfendes Ergebnis, könnte man meinen.
Immerhin werden jetzt auch Syntaxfehler berücksichtigt. Das Erweitern um weiterer solche Sequenzen, wie '\n' ist ein Kinderspiel; vorher hätte man richtig denken müssen. Das Erweitern um weitere Befehle ist leicht, da Sie die Parameter schon mundgerecht gestückelt bekommen. Und auch das lästige Abzählen, wieviel Buchstaben ein Befehl lang ist (wegen des 'LEFT$' in der Originalversion), ist überflüssig. Wenn Sie sich weiter daran halten für jeden Befehl, der eine etwas längere Auswertung erfprdert eine Prozedur zu schreiben, bleibt 'ExecuteCommand' witerhin so übersichtlich. Große Teile des Codes werden vermutlich gar nicht mehr angerührt werden müssen, wenn Sie neue Befehle hinzufügen. Und wir sind bereits perfekt auf Variablen vorbereitet. Das beste ist: Alles in allem ist der Code nicht komplizierter, als der Ursprüngliche Code, nur etwas umfangreicher. Eine ausnahme bildet leider das Aufteilen des Parameterstrings, aber glücklicherweise müssen wir das ja nur einmal Programmieren und können es für ale folgenden Befehle ausnutzen.


Kleiner Einschub

Wir wollen ja möglichst Variabel und anpassungsfähig sein. Einige Scriptsprachentutorien schlagen vor die Befehle in ein Feld einzuladen:

DIM befehl(3) AS STRING

RESTORE Befehle
FOR i = 0 TO 5
   READ befehl(i)
NEXT

Befehle:
DATA "Comment"
DATA "Clear"
DATA "Display"
DATA "Wait_for_key"

Welche Vorteile haben wir dadruch? Im Grunde keine, wie Sie noch sehen werden.
Zunächst zu den Nachteilen: Erstens 'DATA' ist antiquert. Stattdessen läd man für gewöhnlich aus einer externen Datei. Dadurch hält man sein Programm übersichtlicher und Fexibler. In diesem speziellen Fall sind die Namen der Befehle jedoch fest forgeschreiben. Dynamische Namen würden nur Sinn machen, wenn auch das Verhalten dynamisch geladen werden könnte. Deswegen wäre hier DATA Ausnamsweise der Vorzug zu geben. Allerdings ist es nicht übersichtlicher, als folgendes:

DIM befehl(3) AS STRING
befehl(0) = "Comment"
befehl(1) = "Clear"
befehl(2) = "Display"
befehl(3) = "Wait_for_key"

Zudem ist kürzer, da die Ladeschleife entfällt.
Aber wieso sollte man die Befehle überhaupt in einem Feld speichern? In besagten Tutorien wird es verwendet, um die Befehlsstrings auf Zahlen zu mappen, die dann schneller abfragbar sind. Allerdings Verbrauchen wir dann die Zeit des Auswertens für das zusätzliche Mappen. Statt eines Mappingvorgangs benötigen wir jetzt zwei: erst von Befehl auf Zahl, dann von Zahl auf Ausführung. Außerdem gestalten wir das Programm unnötig unübersichtlicher, da unnötiger Code eingeführt wird und statt Namen irgendwelche willkürliche Zahlen verwendet werden.
Damit das ganze leichter verständlich ist, zeige ich das an Hand etwas Code:

FOR i = 0 TO 3
   IF befehl(i) = command THEN ergebnis = i ' erstes Mapping
NEXT

SELECT CASE ergebnis                        ' zweites Mapping
CASE 0:
   Display ...

CASE 1:
   WHILE INKEY$ = "": WEND
...
END SELECT

Erst das Mapping von String auf Zahl, dann von Zahl auf auszuführenden Code. Mein Vorschlag hat nur ein String auf auszuführenden Code Mapping. Außerdem ist er übersichtlicher, da '"Display"' mehr sagt, als '0'.Die Zahlen könnte man kaum unkomentiert sthen lassen.
Also lassen wir die Sache mit den Befehlen im Feld.
Da wir grade dabei sind andere Tutorien zu zerpflücken, machen wir doch gleich damit weiter ;). Viele Befehle benötigen Parameter. In vielen Tutorien werden diese pro Befehl einzeln Ausgewertet. Das bläht den Code unheimlich auf, da die Auswertung redundanter Weise mehrfach im Programm vorkommt. Und jede Auswertung muss einzeln gedebugt werden. Unsere Lösung ist wesentlich ausgereifter. Sie ist wesentlich fehlerunanfälliger, leichter anpassbar und vereinfacht das weitere Programmieren unheimlich.


Vorbereitung

Sie wissen, was eine Schleife ist. Um eine Schleife implementieren zu können, müssen Sie in der Lage sein, an eine andere Stelle des Scripts springen zu können. Bisher ist das aber unmöglich. Um uns darauf vorzubereiten, werden wir jetzt die grundlegende Fähigkeit implementieren, sich positionen in der Datei zu merken.
Dabei handelt es sich leider um eine grundlegende Änderung, an der viele schichten des Programms beteiligt sind.
Zunächst muss 'ExecuteScript' angepasst werden, da nur diese Funktion über positionen innerhalb der Datei bescheid weiß:

handle = FREEFILE
OPEN filename FOR INPUT AS handle

DO UNTIL EOF(1)
   DIM scriptLine AS STRING
   lineNr = SEEK(handle)
   LINE INPUT #handle, scriptLine
   scriptLine = LTRIM$(RTRIM$(scriptLine))

   IF LEN(scriptLine) > 0 THEN InterpretLine scriptLine, lineNr
      ' Leere Zeilen zu interpretieren würde nur unnötig Zeit kosten

LOOP
CLOSE handle

Natürlich muss 'InterpretLine' angepasst werden. Es muss den neuen Parameter entgegen nehmen und an 'ExecuteCommand' weiterreichen, da diese Prozedur mit der Position arbeiten muss.

Variablen

Dazu nutzen wir natürlich das System, was wir im Kapitl 'dynamisch' aufgebaut haben.

Dies ist bei weitem noch nicht die endgültige Version. Dies ist erst der erste Versuch dieses Kapitels und nichtmals der ist beendet, wie Sie sehen.


vorheriges
Index
nächstes