Scriptengine - der Weg zur eigenen Programmiersprache.


Vorwort
Um den Sinn dieses Tutorials ein wenig zu beleuchten möchte ich mich an dieser Stelle einmal mit der Frage befassen: "Was ist eigentlich ein Script?".

Ich zitiere hier mal Thomas Antoni der es in seiner QB-Monster-FAQ recht treffend formuliert hat:

"Als Scripte (oder "Skripts") werden kleine Programme bezeichnet, die nicht direkt ablauffähig sind, sondern von einem Interpreter Anweisung für Anweisung abgearbeitet werden und von diesem erst während der Programmausführung in die Maschinensprache übersetzt werden. Scripts lassen sich mit einem normalen ASCII Texteditor erstellen. Beipiele für Scriptsprachen sind die DOS-Batchsprache, Perl und PHP."

Interessanterweise könnte man defininitionsgemäß also behaupten das auch QBasic (nicht QuickBasic) demnach eine Scriptsprache ist und liegt mit dieser Annahme erstaunlicherweise sogar richtig.

Eine selbst erschaffene Scriptsprache umfasst also einen gewissen Befehls- oder auch Wortschatz, welcher von einem Interpreter, der ScriptEngine, interpretiert bzw. ausgeführt wird. Dabei hält sich die Sprache an einen vorgegebenen Syntax und gegebenenfalls auch an einen speziellen Schreibstil.

Vorbereitung
Vor dem Start sollte sehr klar definiert werden wozu die Sprache fähig sein soll. Hier ist sehr viel theoretische Vorarbeit notwendig und es müssen viele Überlegungen gemacht werden. Für den Anfang sollte man seine Ziele daher nicht all zu hoch stecken. Ganz wichtig bei diesen Überlegungen ist, daß die Ausführung der Befehle innerhalb der Scriptsprache niemals die Geschwindigkeit der Sprache erreichen kann in der die ScriptEngine programmiert wurde. Sehr wichtig wird dieser Gedanke insbesondere im Bezug auf QuickBasic welches von Hause aus nicht gerade zu den schnellsten Sprachen gehört.

Ausserdem wichtig, bzw. sehr empfehlenswert ist, die Sprache nicht zu kompliziert zu machen. Dies bezieht sich insbesondere auf den Syntax der Sprache. Mit einer Scriptsprache zu versuchen QuickBasic einen Hauch von C++ mitzugeben wird einen schnell in die Abgründe von Fehlerauswertungen stürzen. Was uns gleich zum nächsten Punkt führt. Die Fehlerauswertung. Im einfachsten Fall und für Anfänger zu empfehlen ist, bei einem Fehler, egal welcher Natur, die Ausführung des Scripts einfach zu unterbrechen und einen Fehler auszugeben der lediglich auf die Quelle, also die Programmzeile, hinweist. Alles weiterführende wäre echt Wahnsinn und führt zu nichts, es sei denn jemand möchte mit C++ eine neue Programmiersprache erschaffen. In diesem Fall bitte ich darum eine möglichst gute Fehlerverfolgung einzubauen ;-)

Wie man merkt ist die Verwandschaft zwischen Interpreter (ScriptEngine) und einer tatsächlichen Programmiersprache (einem Compiler) sehr eng. Der einzige Unterschied liegt eigentlich darin, daß ein Compiler in der Lage ist aus dem Script eine selbstständig ausführbare Datei zu erstellen welche keinen Interpreter, keine ScriptEngine, mehr benötigt. Genau da liegt aber auch die Schwierigkeit. Man muß trotz alledem den selben Aufwand einsetzen um ein solches Programm zu kreieren. Andererseits ist es natürlich eine interessante Herausvorderung.

Das Prinzip
Das Prinzip eines Interpreters, einer ScriptEngine, ist simpel. Ein Script wird von der ersten Zeile an Zeile für Zeile interpretiert und ausgeführt. Der Knackpunkt ist dabei die ZEILE. Jede Zeile muß "zerpflückt" und analysiert werden. Je nach Ergebnis erfolgt dann die Reaktion, d.h. die Ausführung einer oder mehrerer Routinen die diesen Befehl beschreiben. Die Art, Weise und der Aufwand der betrieben werden muß um eine Zeile zu analysieren, hängt vom Syntax und Befehlsumfang der Sprache ab.

Ausführung
Machen wir uns also an ein Beispiel und versuchen eine Engine zu entwerfen die auf einer ganz einfachen Sprache beruht. Den Grundbefehlssatz unserer Sprache, wir nennen sie mal EPScript, sollen erst mal 6 Befehle bilden.
CLS
COLOR VF,HF
PRINT "Ausdruck"
WAITKEY
END
; Kommentarzeilen werden mit Semikolon gekennzeichnet
Hier ist auch gleich der Syntax erkennbar, bzw. festgelegt, in dem diese Befehle zu benutzen sind. Wir lassen in unserer Sprache einen Befehl pro Zeile zu, d.h. mehrere Befehle pro Zeile durch Doppelpunkt getrennt sind unzulässig.

Unsere ScriptEngine muß natürlich als erstes mal alle Befehle kennen. Dazu legen wir uns ein Datenfeld an welches die Befehle enthält. Auf diese Weise können wir Worte aus dem Script ziemlich einfach vergleichen und so feststellen ob sie überhaupt zu unserem Befehlsschatz gehören. Da wir das ganze später wohl öfter machen werden, ist es sinnvoll die Worte unseres Befehlsschatzes in ein Array zu übertragen. Folgende Routine erledigt das:
DIM BEFEHL$(10)
RESTORE Befehle

FOR I=0 TO 5
   READ Befehl$(I)
NEXT

Befehle:
DATA ";"
DATA "CLS"
DATA "COLOR"
DATA "END"
DATA "PRINT"
DATA "WAITKEY"
Da unser Script in einer externen Datei, vornehmlich einer Textdatei steht, müssen wir diese Datei öffnen und einlesen. Wir beschränken uns hier mal auf eine maximale Größe der Datei von 40 Zeilen. Auch diese Zeilen lesen wir in ein Array damit wir sie im Speicher haben und nicht ständig auf die Datei zugreifen müssen.
DIM SHARED SCRIPT$(40)
OPEN "MYSCRIPT.TXT" FOR INPUT AS #1
L = 0
DO
   LINE INPUT #1, SCRIPT$(L)
   L = L + 1
   IF L = 40 THEN EXIT DO
LOOP UNTIL EOF(1)
CLOSE #1
So, wir haben eine Befehlsliste und wir haben die Befehle. Kommen wir zur Engine. Genauer gesagt zum ersten Teil, dem Analyseteil. Hier wollen wir Zeile für Zeile durch den ScriptCode gehen und feststellen ob ein Befehl in der aktuellen Zeile steht den wir kennen. Das ganze machen wir recht elegant mit einer Schleife.
FOR L = 0 TO 39
   FOR B = 0 TO 5
      IF BEFEHL$(B) = LEFT$(UCASE$(SCRIPT$(L)), LEN(BEFEHL$(B))) THEN
         Ergebnis = B: EXIT FOR
      ELSE
         Ergebnis = 6
      END IF
   NEXT

   ;--> hier geht's gleich weiter

NEXT
Wow, wow, wow...nicht so schnell...was treibt der Typ denn da? Ganz ruhig. Die wichtigste Zeile ist die die am kompliziertesten aussieht. Nehmen wir sie mal auseinander. In BEFEHL$(B) steht der jeweilige Befehl aus unserer DATA-Liste. LEN(BEFEHL$(B)) ergibt dessen Länge. Mit UCASE$(SCRIPT$(L)) sorgen wir dafür das, egal wie ein Befehl im Script geschrieben wurde, er nun in Grossbuchstaben verglichen werden kann. Damit wir aber nicht die ganze Befehlszeile vergleichen, denn das würde ja nicht immer stimmen, vergleichen wir nur ein Stück der Zeile welches so lang ist wie der Befehl den wir gerade untersuchen. Wenn wir also z.B. auf das Semikolon prüfen, brauchen wir ja nicht die ganze ScriptZeile zu prüfen, uns genügt das erste Zeichen und wir wissen bescheid. Wenn ein uns bekannter Befehl gefunden wird, verlassen wir sofort die Schleife und merken uns in der Variablen Ergebnis die Tokennummer des Befehls.

Tokennummer? Was ist das nun wieder? Nun ja, was wir noch brauchen sind die entsprechenden Unterprogramme oder Entscheidungsprozeduren für unsere Befehle. Anders ausgedrückt, wir brauchen die Reaktion auf die Analyse. Wir haben ja im Vorfeld schon gut nachgedacht und uns die ganze Sache durch den Kopf gehen lassen, oder? Ja, haben wir...also haben wir dafür gesorgt das uns das Analyseprogramm je nach gefundenem Befehl einen Zahlenwert zurückgibt nämlich den sogenannten Token. Der Zahlenwert (Token) entspricht dem Befehl. Da wir nur 6 Befehle haben, kann der Wert also nur zwischen 0 und 5 liegen. Der Wert soll der Reihenfolge entsprechen in der unsere Befehle auch im Array abgelegt sind. Demnach ist ein Kommentar die Null, CLS die Eins und so weiter. Alle anderen Werte sind für uns Fehler.

An der Stelle wo wir den Kommentar (;--> hier geht's gleich weiter) eingefügt hatten, folgt nun die "Reaktion". Unsere Befehle haben ja (Gott sei dank) eine recht einfache Struktur. Auf einen Kommentar z.B. brauchen wir überhaupt nicht reagieren. Bis auf zwei Befehle haben wir auch keine Parameter. Daher werden wir die ausführenden Programmteile nur für diese zwei Befehle in extra Prozeduren packen, das Andere erledigen wir gleich an Ort und Stelle. Das Gesamtergebnis ist nun folgendes:
FOR L = 0 TO 39
   FOR B = 0 TO 5
      IF BEFEHL$(B) = LEFT$(UCASE$(SCRIPT$(L)), LEN(BEFEHL$(B))) THEN
         Ergebnis = B: EXIT FOR
      ELSE
         Ergebnis = 6
      END IF
   NEXT

   SELECT CASE Ergebnis
      CASE 0
      CASE 1: CLS
      CASE 2: ScriptColor
      CASE 3: END
      CASE 4: ScriptPrint
      CASE 5: WHILE INKEY$ = "": WEND
      CASE ELSE
           PRINT "Im Script ist an Zeile "; L; "ein Fehler aufgetreten": END
   END SELECT
NEXT
Wie wir sehen werden nur für den Fall 2 (COLOR) und 4 (PRINT) Unterprogramme aufgerufen. Wir brauchen also noch diese beiden Prozeduren und dann sind wir auch schon fast am Ende. Beginnen wir mit dem Fall 2, dem Befehl Color.

Der Befehl Color erwaret 2 Variablen die durch Komma voneinander getrennt sind. Eine korrekte Zeile müsste also z.B. so aussehen:
COLOR 15,0
Da wir wissen das das Wort COLOR auf jeden Fall vorkommt, können wir die ersten 5 Zeichen also ganz beruhigt abschneiden.

Übrig bleibt in unserem Beispiel also der Audruck " 15,0". Diese beiden Werte gilt es nun voneinander zu trennen. Vorgegeben durch unseren Syntax ist, das sich zwei Werte durch Komma getrennt in diesem Ausdruck befinden müssen. Wenn wir also nach dem Komma suchen und alles links davon wegschneiden, müssten wir den ersten Wert erhalten. Alles rechts vom Komma ergibt unseren zweiten Wert. Fehl schlagen kann das ganze nur dann, wenn kein Komma in der Zeile ist. In einem solchen Fall sollten wir wieder einen Fehler ausgeben und beenden. Die komplette Routine könnte dann so aussehen:
SUB ScriptColor

   SCRIPT$(L) = RIGHT$(SCRIPT$(L), LEN(SCRIPT$(L)) - 5)
   P = INSTR(SCRIPT$(L), ",")

   IF P = 0 THEN
      PRINT "Im Script ist an Zeile "; L; "ein Fehler aufgetreten": END
   ELSE
      VF = VAL(LEFT$(SCRIPT$(L), P - 1))
      HF = VAL(RIGHT$(SCRIPT$(L), LEN(SCRIPT$(L)) - P))
      COLOR VF, HF
   END IF

END SUB
Kommen wir nun noch zur zweiten Prozedur, unserem Print-Befehl. Hier verhält es sich ähnlich wie beim Color-Befehl. Es wird eine Variable erwartet. Genauer gesagt ein Ausdruck. Der Ausdruck wird eingefasst durch " (Gänsefüschen). Alles was sich dazwischen befindet wird von uns auf dem Bildschirm ausgegeben. Gibt es nur ein oder gar kein Gänsefüschen, dann beenden wir wieder mit einem Fehler. Die Routine dazu:
SUB ScriptPrint

   SCRIPT$(L) = RIGHT$(SCRIPT$(L), LEN(SCRIPT$(L)) - 5)
   P1 = INSTR(SCRIPT$(L), CHR$(34))

   IF P1 = 0 THEN
      PRINT "Im Script ist an Zeile "; L; "ein Fehler aufgetreten": END
   ELSE
      P2 = INSTR(P1 + 1, SCRIPT$(L), CHR$(34))
      IF P2 = 0 THEN
         PRINT "Im Script ist an Zeile "; L; "ein Fehler aufgetreten": END
      ELSE
         Ausdruck$ = MID$(SCRIPT$(L), P1 + 1, P2 - P1 - 1)
         PRINT Ausdruck$
      END IF
   END IF

END SUB
Jetzt fehlt uns eigentlich nur noch ein Script welches wir ausführen können. Dieses speichern wir (für unser Beispiel) als MYSCRIPT.TXT ab. Es könnte z.B. so aussehen:
; Beispiel-Script (MYSCRIPT.TXT)
CLS
COLOR 15, 1
PRINT "Ich glaube es funktioniert."
PRINT ""
PRINT "Bitte eine Taste drücken."
WAITKEY
END
Soweit so gut. Packen wir nun alles zusammen in ein Programm (siehe unten) und probieren das ganze aus. Wie wir sehen arbeitet alles recht zufriedenstellend. Natürlich ist das ganze nur eine mehr oder weniger alberne Spielerei, aber das Prinzip dieses kleinen Beispiels ist das gleiche wie in einem großen Interpreter. Einlesen, Vergleichen, Analyse, Reaktion.

Wie weit man es letzten Endes treibt ist jedem seine Entscheidung. Ich wollte hier nur einmal beleuchten wie einfach doch eigentlich das Prinzip ist. Alles weitere überlasse ich deiner Phantasie und deinem Eifer. Ich hoffe ich konnte dir mit diesem Tutorial ein wenig den Weg weisen und wünsche dir viel Spaß und Erfolg. Anregungen Kritik und Fragen kannst du gerne an mich richten.
; Mini-ScriptEngine
; Tutorial von East-Power-Soft

DECLARE SUB ScriptColor ()
DECLARE SUB ScriptPrint ()

DIM SHARED L AS INTEGER

DIM BEFEHL$(10)
RESTORE Befehle
FOR I = 0 TO 5
   READ BEFEHL$(I)
NEXT

Befehle:
DATA ";"
DATA "CLS"
DATA "COLOR"
DATA "END"
DATA "PRINT"
DATA "WAITKEY"

DIM SHARED SCRIPT$(40)
OPEN "MYSCRIPT.TXT" FOR INPUT AS #1
   L = 0
   DO
      LINE INPUT #1, SCRIPT$(L)
      L = L + 1
      IF L = 40 THEN EXIT DO
   LOOP UNTIL EOF(1)
CLOSE #1

FOR L = 0 TO 39
    FOR B = 0 TO 5
       IF BEFEHL$(B) = LEFT$(UCASE$(SCRIPT$(L)), LEN(BEFEHL$(B))) THEN
          Ergebnis = B: EXIT FOR
       ELSE
          Ergebnis = 6
       END IF
    NEXT

    SELECT CASE Ergebnis
        CASE 0
        CASE 1: CLS
        CASE 2: ScriptColor
        CASE 3: END
        CASE 4: ScriptPrint
        CASE 5: WHILE INKEY$ = "": WEND
        CASE ELSE
        PRINT "Im Script ist an Zeile "; L; "ein Fehler aufgetreten": END
    END SELECT
NEXT

SUB ScriptColor

    SCRIPT$(L) = RIGHT$(SCRIPT$(L), LEN(SCRIPT$(L)) - 5)
    P = INSTR(SCRIPT$(L), ",")
    IF P = 0 THEN
        PRINT "Im Script ist an Zeile "; L; "ein Fehler aufgetreten": END
    ELSE
        VF = VAL(LEFT$(SCRIPT$(L), P - 1))
        HF = VAL(RIGHT$(SCRIPT$(L), LEN(SCRIPT$(L)) - P))
        COLOR VF, HF
    END IF

END SUB

SUB ScriptPrint

    SCRIPT$(L) = RIGHT$(SCRIPT$(L), LEN(SCRIPT$(L)) - 5)
    P1 = INSTR(SCRIPT$(L), CHR$(34))
    IF P1 = 0 THEN
        PRINT "Im Script ist an Zeile "; L; "ein Fehler aufgetreten": END
    ELSE
        P2 = INSTR(P1 + 1, SCRIPT$(L), CHR$(34))
        IF P2 = 0 THEN
            PRINT "Im Script ist an Zeile "; L; "ein Fehler aufgetreten": END
        ELSE
            Ausdruck$ = MID$(SCRIPT$(L), P1 + 1, P2 - P1 - 1)
            PRINT Ausdruck$
        END IF
    END IF

END SUB