Tutorial Scripting mit JvInterpreterProgram

Aus DGL Wiki
Wechseln zu: Navigation, Suche

Vorwort

Willkommen zu meinem neuen Tutorial. Eigentlich war ja ein kleines „paar Zeilen“-Tutorial geplant, aber irgendwie ist es dann doch mehr geworden. Allein schon aus dem Grund, weil ich der hier vorgestellten Komponente aufgrund ihrer Leistungsfähigkeit mehr als nur ein paar Worte widmen wollte. Folglich ergab sich eben dieses Werk, von welchem ich natürlich hoffe, dass es Euch weiterhilft, Scripts zu implementieren.

Voraussetzung für dieses Tutorial sind:

  • Grundkenntnisse in Delphi (ein Projekt und eine Komponente sollte man schon anlegen können)
  • Kenntnisse in Pascal
  • die JCL/JVCL (im 2. Kapitel wird erklärt, woher man diese bekommt)

Also, nun will ich euch aber auch nicht länger aufhalten sondern gleich loslegen. Ich wünsche euch viel Spaß dabei...


Scripten – wie und wozu?

Diese Frage mag sich so manch einer stellen. Und sie ist berechtigt. Immerhin schreiben wir hier unsere eigenen Programme, aber wozu brauchen wir dann noch Scripte? Wir können doch alles, was wir programmieren wollen, mit Delphi (oder C/C++, je nachdem, was verwendet wird) schreiben und fertig. So weit, so gut, aber gehen wir mal von Folgendem aus:

Wir wollen eine Game-Engine schreiben. Diese Engine stellt unser Level dar und lädt alles, was dazugehört. Natürlich haben wir auch so das ein oder andere Rätsel, ein paar NPCs (Non Playable Character), Truhen, Schalter, Türen und sonstige Objekte und dergleichen in unserem Level. Logischerweise sollen bei der Betätigung eines Schalters, beim Öffnen einer Truhe oder während des Gesprächs mit einem NPC irgendwelche Aktionen laufen, ausgelöst werden usw.

Nun haben wir die Möglichkeit, das Ganze fest in die Engine einzuprogrammieren. Funktioniert auch ganz gut, allerdings eben nur für dieses eine Game. Wenn wir dann ein anderes Spiel machen wollen, müssen wir unter Umständen das alles nochmal überarbeiten und anpassen. Die andere Variante wäre, für solche Aktionen einfach Scripts zu verwenden. Man könnte einer Kiste zum Beispiel ein OnOpen-Ereignis zuweisen. Ist dieses Ereignis belegt, wird beim Öffnen der Kiste das zugehörige Script ausgeführt. Dieses Script wird im Level-Editor erstellt und der Truhe zugewiesen. Dadurch sind wird deutlich flexibler, was das Ganze angeht.

Nun gibt es für die Verwirklichung von Scripts im Groben und Ganzen drei Möglichkeiten:

  • Ein Interpreter: Es werden die auszuführenden Befehle während der Ausführung des Scripts interpretiert.
  • Ein Compiler: Die Script-Befehle werden vor dem Ausführen in Maschinen-Code (auch Nativ-Code genannt) übersetzt.
  • Ein Emulator: Eine Mischung aus 1. und 2. Hier werden die Befehle in P-Code (Pseudo-Code) konvertiert und während der Laufzeit emuliert.

Nun, das sind 3 verschiedene Methoden zu Scripten. Welche ist nun die beste? Ich will mal kurz die Arbeitsweise sowie die Vor- und Nachteile der verschiedenen Varianten aufführen.

Der Interpreter

Hier liegt das auszuführende Script in einem für den Benutzer lesbaren Format vor. Jeder, der sich schon mal mit Basic beschäftigt hat, dürfte das kennen. Die meisten Basics sind Interpreter. Bei einem Interpreter wird der Befehl dann ausgewertet, wenn er ausgeführt werden soll. Kleines Beispiel:

Nehmen wir mal den Befehl Print (evtl. aus guten alten Basic-Zeiten noch bekannt). Print gibt auf der Default-Ausgabe (unter DOS noch der normale Bildschirm) den Wert, welcher dahinter kommt, aus. Print “Hallo“ gibt also Hallo aus. Der Interpreter arbeitet jetzt Zeile für Zeile des Programms ab. Irgendwann trifft er dann auf den Print-Befehl. Also macht der Compiler intern:

If AktuellerBefehl = Print then
  Alles nach Print ausgeben.

Logischerweise ist das nicht unbedingt der schnellste Weg, denn jedesmal, wenn diese Zeile abgearbeitet wird (weil sie z.B. innerhalb einer Schleife steht), wird überprüft, was die Zeile macht, obwohl das bereits getan wurde.

Ein Interpreter überprüft also, welcher Befehl jetzt ausgeführt werden soll, „interpretiert“ ihn (daher der Name) und übersetzt ihn in einen für den Prozessor verständlichen Befehl, welcher der gewünschten Funktion entspricht. PHP, Pearl oder Python beispielsweise sind solche Interpreter.

Der Compiler

Ein Compiler macht eigentlich fast das gleiche wie ein Interpreter, zumindest im Anfangsstadium (au Backe, ich höre schon die Buh-Rufe). Nur wird hier beim Antreffen von (in unserem Bsp.) Print nicht alles, was danach kommt, ausgegeben. Ein Compiler führt die Befehle, auf die er trifft, nicht sofort aus, sondern übersetzt sie in einen speziellen Code. Meistens in Nativ-Code, also in für den Prozessor verständliche Befehle. Diese werden dann in einer Datei abgelegt und mit Hilfe eines Linkers zu einer ausführbaren Datei zusammengesetzt. Das sind dann die unter Windows bekannten EXE-Dateien.

Dabei werden logischerweise noch verschiedene Optimierungen am Code durchgeführt, aber das ist ein Fall für sich. Hier sei nur erwähnt - wer sich einen Compiler selbst basteln will, hat sich einiges vorgenommen. Da der vom Nativ-Compiler erstellte Code für den Prozessor direkt verständlich ist, handelt es sich hierbei logischerweise um die schnellste der drei Varianten. Der Begriff Compiler kommt übrigens vom englischen Wort „to compile“ und bedeutet u.A. übersetzen.

Der Emulator ( Die Kombination )

Die Mischung aus beidem sind Compiler, die einen sogenannten P-Code erstellen (z.B. Visual-Basic, Java, .NET). Dieser P-Code wird dann von einem Emulator bzw. Interpreter ausgeführt. Der Vorteil liegt darin, dass P-Code schneller zu interpretieren ist als reiner Quell-Code. Denn wenn wir z.B. davon ausgehen, dass der P-Code in Form von DWord-Werten abgelegt wird, so ist klar, dass die Abfrage von DWord logischerweise schneller ist als die Abfrage von String. Im Klartext: AktuellerBefehl = $FE230312 ist für den Prozessor schneller abzuarbeiten als AktuellerBefehl = „Print“ (String-Vergleiche sind immer langsamer als Zahlen-Vergleiche). Einige der „Interpreter“ dieser Art verfügen über einen JIT-Compiler (JIT = Just In Time). Bei solch einem System wird festgestellt, welcher Bereich des P-Codes oft verwendet wird. Dieser Teil wird dann in Nativ-Code übersetzt (kompiliert) und als solcher abgespeichert. Dadurch sind diverse Optimierungen möglich. Auch für manche Interpreter gibt es solche JIT-Compiler, wie zum Beispiel für Python.

Die Tutorials von DelphiC basieren auf dieser Variante. Ich würde jedem empfehlen, diese gelungenen Werke durchzulesen, denn man lernt so einiges dabei. Wer sich für seine Scripts für die 3. Variante entscheidet und sich einen P-Code Compiler + entsprechenden Interpreter/Emulator schreiben will, für den sind die Tutorials von DelphiC auf jeden Fall Pflichtlektüre. Alle anderen können sich ohne Umschweife dem Kapitel 2 zuwenden.

Ach ja, eines noch: für eine Game-Engine ist im Normalfall ein Interpreter voll ausreichend. Denn eine Regel ist bei der Arbeit mit Scripten auf jeden Fall nicht zu vergessen: NIEMALS zeitkritische Berechnungen oder 100 Seiten langen Code innerhalb eines Scripts ausführen. Wer das macht, ist eigentlich selbst schuld. Alles andere ist durchaus mit einem Interpreter zu erledigen (was euch aber nicht davon abhalten soll, DelphiCs Tutorials durchzulesen und das Ganze selbst mal auszuprobieren). Logischerweise liegt die Optimierung auch hier in der Hand des jeweiligen Entwicklers oder Leveldesigners.

Und es stört wirklich nicht, wenn die Framerate für ein bis zwei Sekunden um 10 oder 20 FPS sinkt, während ein Schalter betätigt, eine Truhe geöffnet oder ein Kommando von der Konsole aus ausgeführt wird. Und so stark dürfte die Framerate sowieso nicht in die Knie gehen. Also ist ein Script bei sinnvollem Einsatz (egal mit welcher der 3 Varianten) durchaus zu verkraften. Wenn allerdings jemand seine halbe Engine, womöglich auch den Render-Code, mit Scripts realisiert, dann wünsche ich ihm viel Spaß bei seiner Dia-Show. :)

Die Komponente JvInterpreterProgram

Wer von Euch kennt die Helden im Kampf gegen das C/C++ Imperium nicht? Hand hoch! Hand wieder runter! Ihr habt es bestimmt schon erraten, ich rede von den Delphi-Jedis. Dort gibt’s neben ziemlich vielen guten Header-Konvertierungen auch die sogenannte JCL/JVCL Library (Jedi-Code-Library und Jedi-Visual-Component-Library). Dabei handelt es sich um eine Ansammlung von über 400 Komponenten (Stand Version2.1), die das Leben eines Delphi Programmierers leichter machen. Runterladen könnt ihr das Ganze auf der Homepage der Jedis. Die URL bekommt ihr, indem ihr in Delphi den Info-Dialog öffnet (Menu Hilfe – Info), die Alt-Taste gedrückt haltet und JEDI eintippt. N ach dem kleinen StarWars-ähnlichen Vorspann (welcher übrigens mit OpenGL realisiert wurde) erscheint die URL der Jedis im Info-Dialog von Delphi. Für alle, die das Easter-Egg nicht kannten: Versucht das Ganze mal mit ALT-TEAM! Ansonsten könnt ihr auch einfach direkt über die Adresse http://www.delphi-jedi.org dorthin gelangen. :)

Ok, nachdem ihr euch die aktuelle Version von der JCL/JVCL runtergeladen habt, müsst ihr das Teil nur noch installieren. Aber vorher Delphi schließen! Also entpacken, Install starten (welches witzigerweise zuerst kompiliert wird; erinnert irgendwie an Linux) und den Anweisungen des Bodenpersonals, also dem, was auf dem Monitor erscheint, folgen. Danach Delphi wieder öffnen und darüber schockiert sein, dass die Komponenten-Leiste aus allen Nähten platzt. Um das Problem zu umgehen, empfehle ich euch die G-Experts. Die gibt’s unter http://www.gexperts.org. Damit könnt ihr die Palette der Komponenten mehrzeilig anzeigen lassen. Außerdem gibt’s noch weitere für einen Programmierer nützliche Tools. Aber das nur am Rande.

Schaut euch ruhig mal die ganzen Komponenten an. Wie gesagt, es sind einige nette und nützliche Sachen dabei. So können zum Beispiel alle, die nicht selbst was für die Abfrage des Joysticks schreiben wollen, die entsprechende Komponente der Jedis verwenden. Außerden gibt es noch einen Syntax-Highlighting-Editor, welcher für euren Level-Editor und den Script-Bereich sehr praktisch ist. Bleibt nur noch zu erwähnen, dass die JCL/JVCL unter der MPL-Lizenz vertrieben werden, so dass einem kommerziellen Einsatz nix im Wege steht! Lediglich eine namentliche Erwähnung sollte schon sein (wenn ich die MPL richtig durchgelesen hab’), aber das ist ja wohl auch selbstverständlich! Übrigens: für alle, die die RxLib vermissen: diese ist ein Bestandteil der JVCL! Ebenso wie die RaLib, deren Bestandteil u.A. die hier vorgestellte Interpreter-Komponente ist. Also nicht wundern, wenn die Kommentare bzw. die Docu der JEDIs manchmal auf RxLib bzw. RaLib Bezug nehmen.

Ok, lange genug in der Gegend herumgeredet (wohl eher geschrieben)! Kommen wir zu der eigentlich interresanten Komponente: JvInterpreterProgram. Diese ist im TabSheet „Jv Interpreter“ zu finden. Die zweite Komponente (JvInterpreterFM) kann momentan ignoriert werden. Eventuell gehe ich auf diese noch näher ein. Jetzt ist erstmal die erste, also JvInterpreterProgram, von Interesse. Diese Komponente ist so unscheinbar und klein, hat’s aber in sich. Sie arbeitet mit Pascal-Dialekt, kann auf externe Funktionen von DLLs zugreifen, OLE Objekte ansteuern, Delphi-Klassen sowie Funktionen/Variablen eurer Delphiprogramme verwenden - und das Ganze in einer passablen Geschwindigkeit. Aber eins nach dem anderen.

Ein einfaches Skript

Fangen wir ganz einfach mal mit einer simplen mathematischen Formel an. Wir wollen, dass der Computer eine Formel, welche in eine Edit-Line eingegeben wurde, berechnet. Dazu erstellen wir zuerst mal ein einfaches Projekt und bestücken die Form mit den Komponenten JvInterpreterProgram, 2x EditLine und Button. Setzt die Text-Eigenschaften der Edit-Lines auf einen leeren Wert und weist dem Button folgendes OnClick-Ereignis zu:

procedure TForm1.Button1Click(Sender: TObject);
begin
  //----------------------------------------------------------------------------
  // Die eingegebene Formel der JvInterpreterProgram komponente zuweisen, die
  // Formel ausführen und das Ergebnis der 2. EditLine zuweisen.
  //----------------------------------------------------------------------------
  JvInterpreterProgram1.Pas.Text := Edit1.Text;
  JvInterpreterProgram1.Run;
  Edit2.Text := FloatToStr(JvInterpreterProgram1.VResult);
end;

So, startet nun euer Programm und gebt diverse mathematische Formeln ein. Was nicht funktioniert, sind Befehle wie „Sqr“ oder das „^“ für Potenzzahlen. Diese Berechnungen können aber dennoch durchgeführt werden. Wie, darauf gehe ich später noch ein (mal wieder, ich weiß). Aber immerhin, euer erstes frei definierbares Script existiert schon! Allerdings ist das nicht so ganz das, was wir hier machen wollten, oder? Aber kein Problem. Ich werde versuchen, euch die Komponente so weit näher zu bringen, dass ihr sie auch gut einsetzen könnt.

Mehr Skript muss her...

Wie bereits erwähnt, ist eine einfache Formel, die der PC errechnet, nicht genau das, was uns für ein Game so richtig weiterhilft. Es muss also etwas her, was mehr einer Funktion gleicht. Und das ist auch möglich. Wäre ja wirklich den ganzen Aufwand des Tutorials nicht wert, wenn diese Komponente nicht mehr als eine lächerliche Formel könnte...

Mehr Skript ...

Ziehen wir uns dieses mal eine Edit-Line, eine JvInterpreterProgram und einen Button in die Form. Nun weisen wir dem OK-Button die folgenden Zeilen zu:

procedure TForm1.Button1Click(Sender: TObject);
begin
  //----------------------------------------------------------------------------
  // Der JvInterpreterProgram-Komponente die Funktion zuweisen.
  //----------------------------------------------------------------------------
  with JvInterpreterProgram1.Pas do
  begin
    Clear;
    Add('var                          ');
    Add('  i: Integer;                ');
    Add('                             ');
    Add('begin                        ');
    Add('  // Result vorinitialisiern ');
    Add('  Result := 0;               ');
    Add('  for i := 0 to 100 do       ');
    Add('    Result := Result + 3;    ');
    Add('end;                         ');
  end;

  //----------------------------------------------------------------------------
  // Nun das Script ausführen und das Ergebnis der Edit-Line zuweisen.
  //----------------------------------------------------------------------------
  JvInterpreterProgram1.Run;
  Edit1.Text := IntToStr(JvInterpreterProgram1.VResult);
end;

Ok, das Script Programm müsste hier nicht immer im OnClick-Ereignis des Buttons zugewiesen werden, aber damit konnte ich mir das Erstellen einer zweiten Text-Box sparen. Wie hier ersichtlich ist, kann man sogar Kommentare wie in Delphi verwenden. Die Scripts können also auch beschrieben werden. Außerdem sehen wir hier, dass es auch die For-Schleife und Variablen gibt. Dieses Script macht nun eigentlich aber auch nichts Außergewöhnliches. Ich hatte bereits erwähnt, dass Klassen, Funktionen und Variablen eurer Delphi-Programme dem Script freigegeben werden können. Nur wie soll das Ganze funktionieren?

Zugriff auf Programm-Interna

Wenn wir uns im Objekt-Inspektor die Ereignisse der Komponente ansehen, dann fallen uns insgesamt vier verschiedene auf:

  • OnGetUnitSource: Wird aufgerufen, wenn die Komponente auf eine Uses-Anweisung trifft und die angeforderte Unit nicht findet.
  • OnGetValue: Wird aufgerufen, wenn ein Identifier (Funktions- oder Variabelenname) im Script verwendet wird, welcher diesem unbekannt ist.
  • OnSetValue: Das gleiche wie OnGetValue, nur dass hier dem entsprechenden Identifier ein Wert zugewiesen werden soll.
  • OnStatement: Wird während der Ausführung des Scripts aufgerufen. Wäre z.B. ein guter Platz, um per Application.ProcessMessages das Programm „weiterleben“ zu lassen.

Momentan ist für uns nur OnGetValue von Interesse. Bei diesem Ereignis werden ein paar Parameter übergeben, mit deren Hilfe wir ermitteln können, was die Komponente von uns erwartet und was wir demnach tun sollen. Folgende Parameter werden von der Funktion verwendet:

  • Sender: Der von Delphi gewohnte Sender. Das Objekt, welches das Ereignis auslöst.
  • Identifier: Der Identifier bezeichnet den Datentyp, dessen Wert der Interpreter ermittelt.
  • Value: Der Wert von Identifier. Dieser wird anstelle des Identifiers im zu interpretierenden Code eingesetzt. Value ist vom Typ Variant, wie so vieles bei JvInterpreterProgram.
  • Args: Das Objekt mit den Argumenten (Parametern) von Identifier. Ist eigentlich nur dann von Interesse, wenn es sich bei Identifier um eine Funktion / Prozedur mit Parametern handelt. Die im Script angegebenen Parameter sind dann im Objekt Args hinterlegt. Auf die Beschreibung von Args gehe ich später noch etwas detaillierter ein.
  • Done: Wenn dieses Flag innerhalb von OnGetValue auf True gesetzt wird, ist für den Interpreter die Verarbeitung von Identifier abgeschlossen. Wird Done nicht auf True gesetzt und der Wert von Identifier nicht anderweitig implementiert, wird eine Exception ausgelöst (unknown identifier).

urück zu unserem Beispiel. Wir wollen nun unser Programm so erweitern, dass eine Zufallszahl ermittelt und entsprechend zurückgeliefert wird. Dazu nehmen wir das Programm aus Kapitel 3.1 und weisen JvInterpreterProgram.OnGetValue folgende Funktion zu:

procedure TForm1.JvInterpreterProgram1GetValue(Sender: TObject;
  Identifier: string; var Value: Variant; Args: TjvInterpreterArgs;
  var Done: Boolean);
begin
  //----------------------------------------------------------------------------
  // Überprüfen, ob es sich bei Identifier um den "Befehl" Random handelt. Um
  // nicht wie in C/C++ Case-Sensitive zu arbeiten, wird Identifier vor der
  // Überprüfung im Kleinbuchstaben konvertiert.
  //----------------------------------------------------------------------------
  if LowerCase(Identifier) = 'random' then
  begin
    //--------------------------------------------------------------------------
    // Ok, es soll eine Zufallszahl ermittelt werden. Also den Zufallsgenerator
    // initialisieren und eine Zufallszahl ermitteln. Diese in Value ablegen,
    // damit der Interpreter den Wert weiterverarbeiten kann.
    //--------------------------------------------------------------------------
    Randomize;
    Value := Random($FF);
    // Dem Interpreter bescheid sagen, das der Befehl verarbeitet wurde.
    Done := True;
  end;
end;

In der OnClick-Methode des Buttons erweitern wir das Programm für den Interpreter so, dass der auszuführende Programmcode folgendermaßen aussieht:

Add('var                          ');
Add('  i: Integer;                ');
Add('                             ');
Add('begin                        ');
Add('  // Result vorinitialisiern ');
Add('  Result := 0;               ');
Add('  for i := 0 to 100 do       ');
Add('    Result := Result + 3;    ');
Add('                             ');
Add('  Result := Result + Random; ');
Add('end;                         ');

Zur Erklärung: Der Interpreter arbeitet alles ab, bis er auf die vorletzte Zeile trifft. Result ist ihm bekannt, Random aber nicht. Folglich wird vom Interpreter OnGetValue aufgerufen. Wir stellen jetzt innerhalb der Funktion fest, dass wir Random als Interne Funktion sozusagen „zur Verfügung stellen“. Also initialisieren wir den Zufallsgenerator, ermitteln eine Zufallszahl und liefern diese in Value zurück. Danach müssen wir dem Interpreter noch mitteilen, dass wir den Befehl Random verarbeitet haben. Dies geschieht, indem wir Done auf True setzen. Der Interpreter verwendet jetzt anstelle von Random innerhalb des Scripts den von uns in Value abgelegten Wert und addiert diesen zu Result.

Wenn wir jetzt das Programm starten, stellen wir fest, dass bei jedem Klick auf den Button ein anderer Wert dargestellt wird, der aber immer größer als 303 ist, denn 303 ist bekanntlich der Wert, der vom Script als Basis innerhalb der For-Schleife ermittelt wird. Faszinierend, oder? Ihr erinnert euch vielleicht, dass ich bei unserer ersten Formel erwähnt habe, dass die Berechnung von Potenzzahlen oder Wurzeln mit dem Interpreter nicht direkt funktioniert. Macht ja auch Sinn, denn in Delphi müssen wir für Potenz- und Wurzelrechnung entsprechende Funktionen aufrufen (IntPower und Sqr). Diese kennt der Interpreter aber nicht, also müssen wir sie von Delphi aus freigeben. OnGetValue wäre z.B. eine Möglichkeit. Es gibt noch eine andere, aber dazu später... (naja, den Rest kennt Ihr ja schon *g*).

Mehrere Funktionen innerhalb einer Komponente

Die bisherigen Beispiele hatten allesamt nur eine einfache Funktion. Aber was ist bitteschön, wenn ich eine Script-Funktion aus einer anderen heraus aufrufen will? Muss ich dann pro Funktion eine Script-Komponente verwenden und in meinem Delphi-“GetValue“-Ereignis den Inhalt der entsprechenden Komponente aufrufen? Sicher, das wäre eine Möglichkeit. Um das zu verwirklichen, müsste man „nur“ ein Array mit den Infos Funktionsname und zugehörige Komponente mitführen, damit die Daten alle vorhanden sind, die man benötigt. Allerdings wäre das etwas umständlich.Was wir bisher immer ignoriert haben, sind die Anweisungen „procedure“ und „function“. Es ist machbar, mehrere Funktionen in einer Script-Datei unterzubringen. Dabei müssen wir allerdings auf das bewährte Prinzip der Units zurückgreifen. Ja, ihr werdet es nicht glauben, aber Units werden von der Komponente unterstützt.

Eine Unit im Interpreter

Wie eine Unit definiert ist, was sie kann und was sie darstellt, wisst Ihr bereits. Wenn nicht, verweise ich an dieser Stelle einfach auf die entsprechenden Bereiche in der Borland Delphi Hilfe bzw. im Handbuch zu Delphi.

Nehmen wir nun das Programmgerüst aus Kapitel 4.2 und ändern den Bereich der Zuweisung des Script-Programms folgendermaßen ab:

with JvInterpreterProgram1.Pas do
begin
  Clear;
  Add('unit MyFirstUnit;                                       ');
  Add('                                                        ');
  Add('interface                                               ');
  Add('                                                        ');
  Add('function CalcValue: integer;                            ');
  Add('                                                        ');
  Add('implementation                                          ');
  Add('                                                        ');
  Add('function ScriptRandom(Base: Integer): Integer;          ');
  Add('begin                                                   ');
  Add('  Result := Random(Base);                               ');
  Add('end;                                                    ');
  Add('                                                        ');
  Add('function CalcValue: Integer;                            ');
  Add('var                                                     ');
  Add('  i : Integer;                                          ');
  Add('begin                                                   ');
  Add('  // Result vorinitialisiern                            ');
  Add('  Result := 0;                                          ');
  Add('  for i := 0 to 100 do                                  ');
  Add('    Result := Result + 3;                               ');
  Add('                                                        ');
  Add('  Result := Result + ScriptRandom($FF);                 ');
  Add('end;                                                    ');
  Add('                                                        ');
  Add('end.                                                    ');
end;

Wir haben nun die Unit mit dem Namen MyFirstUnit deklariert und an den Interpreter übergeben. Allerdings gleich eines vorweg: der Interface- und Implementations-Bereich ist eigentlich nur der „Schönheit“ wegen von mir mitverwendet worden. Zwar akzeptiert der Interpreter alles zwischen Interface und Implementation, aber es ist nicht zwingend notwendig. Funktionen, welche nicht im Interface aufgeführt und somit freigegeben sind, sondern nur im Implementations-Bereich auftreten, können vom Delphi-Programm und der JvInterpreter-Komponente direkt aufgerufen werden. Also hat der Interface- und Implementations-Bereich eigentlich keine Funktionalität. Allerdings ist das kein direktes Problem, da es sich hierbei um einen Interpreter mit Pascal-Dialekt handelt. Und wie jeder weiß, ist ein Dialekt immer der Ursprungssprache ähnlich, aber nicht 100%ig identisch! :)

Ok, weiter im Text. Der Rest des Programms wird eigentlich unverändert übernommen. Zumindest fast. Lediglich im OnClick-Ereignis muss die folgende Änderung durchgeführt werden:

procedure TForm1.Button1Click(Sender: TObject);
begin
  //----------------------------------------------------------------------------
  // Nun das Script ausführen und das Ergebnis der Edit-Line zuweisen.
  //----------------------------------------------------------------------------
  Edit1.Text :=
    IntToStr(JvInterpreterProgram1.CallFunction('CalcValue', nil, []));
end;

Dadurch rufen wir nun die Funktion CalcValue auf. Parameter gibt es keine, also wird ein „leeres“ Array übergeben. Der Interpreter stellt selbständig fest, dass die im Programm aufgerufene Funktion ScriptRandom sich innerhalb des zu interpretierenden Programms befindet und verwendet dann diese. Sprich, er springt zur ScriptRandom-Funktion, interpretiert alles bis zum Ende und fährt dort mit der Interpretierung fort, wo er vorher aufgehört hatte, wie man es eben von Delphi gewohnt ist. Super, oder? Jetzt haben wir ja fast schon alles, was wir brauchen. Nur eine Frage bleibt offen: Was ist mit der Unterstützung von verschiedenen Units?

Die Uses-Klausel

Natürlich unterstützt der Interpreter auch die Uses-Klausel. Wer keine Ahnung hat, was Uses macht, soll einfach mal wieder ins Programmierhandbuch von Delphi schauen. Wie am Anfang schon beschrieben, gibt es ein OnGetUnitSource-Ereignis der Komponente. Diese wird aufgerufen, wenn der Interpreter auf die Uses-Klausel trifft und die Unit nicht intern vermerkt hat (wie das interne Registrieren von Units funktioniert, wird später geklärt). Wir wollen nun eine einfache Unit definieren, die sowohl eine MessageBox ausgibt als auch eine simple Funktion zur Verfügung stellt. Also definieren wir das OnGetUnitSource-Ereignis und implementieren den folgenden Code (das Programm basiert auf dem von Kapitel 5.1):

procedure TForm1.JvInterpreterProgram1GetUnitSource(UnitName: string;
  var Source: String; var Done: Boolean);
begin
  //----------------------------------------------------------------------------
  // Zuerstmal muss überprüft werden, ob die FirstUses-Unit geladen werden soll.
  //----------------------------------------------------------------------------
  if LowerCase(UnitName) = 'firstuses' then
  begin

    //--------------------------------------------------------------------------
    // Ok, nachdem das nun geklärt wurde, einfach die Unit in Source übergeben.
    //--------------------------------------------------------------------------
    Source :=
      'unit FirstUses;                                                 ' + #13 +
      '                                                                ' + #13 +
      'const                                                           ' + #13 +
      '  MB_YESNO = $00000004;                                         ' + #13 +
      '  IDYES    = 6;                                                 ' + #13 +
      '                                                                ' + #13 +
      '// Fügt den in AddTo übergebenen Wert zu x hinzu.               ' + #13 +
      'function AddTo(x, DoAdd: Integer): Integer;                     ' + #13 +
      'begin                                                           ' + #13 +
      '  Result := x + DoAdd;                                          ' + #13 +
      'end;                                                            ' + #13 +
      '                                                                ' + #13 +
      'function MessageBox(HWND: integer; Msg: PChar; Caption: PChar;  ' + #13 +
      '  Flags: Integer): Integer;                                     ' + #13 +
      '  external ''user32.dll'' name ''MessageBoxA'';                 ' + #13 +
      '                                                                ' + #13 +
      'end.                                                            ';

    //--------------------------------------------------------------------------
    // Das der Source-Code übergeben wurde, muss nun nur noch mitgeteilt werden,
    // das alles in Ordnung ging. Also Done entsprechend setzen.
    //--------------------------------------------------------------------------
    Done := True;
  end;
end;

Wie zu sehen ist, habe ich hier auf die Interface- und Implementations-Befehle verzichtet, einfach um zu verdeutlichen, wie das Ganze dann aussieht (man könnte auch sagen, ich war zu faul zum Tippen). Die erste Funktion ist nur zum „Beweis“, dass sich in einer Script-Unit Funktionen befinden dürfen, welche dann von der Haupt-Unit aufgerufen werden. Die Konstanten werden für die MessageBox benötigt und stehen entsprechend zur Verfügung. Die andere Funktion (MessageBox) zeigt, wie mit der JvInterpreter-Komponente auf externe Funktionen zugegriffen werden kann! Dazu sei allerdings Folgendes gesagt: External-Funktionen werden von der Komponente automatisch als STDCALL verwendet. Deswegen muss diese Anweisung auch nicht angegeben werden. Wer es doch versucht, wird mit einer Fehlermeldung belohnt. Desweiteren muss der Name der Funktion innerhalb der DLL angegeben werden (alternativ wird auch der Index akzeptiert). Aber wenn man das beachtet, funktioniert der Zugriff auf External-Deklarationen wunderbar.

Hinweis:An dieser Stelle sei erwähnt, dass beim Testen mit der Komponente 2 Bugs aufgetreten sind:

  1. Die Komponente scheint Schwierigkeiten mit Cardinal oder von Cardinal abgeleiteten Typen (z.B. DWORD) zu haben. Beim Einsatz eines dieser Typen kam es zu einer Schutzverletzung bzw. zu fehlerhaften Ergebnissen, wenn man Cardinals als Result zurückliefern wollte. Als Abhilfe kann einfach ein Integer verwendet werden.
  2. Die Übergabe VAR-Parameter funktioniert nicht. Ich wollte die hier verwendete AddTo-Funktion eigentlich als Prozedur verwenden und X als VAR-Parameter übergeben. Leider war nach dem Aufruf von AddTo die Variable, welche für X übergeben wurde, immer 0. Also muss auch hier mit entsprechenden Workarrounds gearbeitet werden. In diesem Fall bleibt eben nur noch eine Funktion zur Rückgabe von Werten übrig! Aber damit kann man leben.

Soweit zu den bisher aufgetretenen Bugs. Mann kann allerdings davon ausgehen, dass diese früher oder später behoben sein werden. Außerdem sollte man hin und wieder auf der Homepage der JEDIs vorbeischauen und sich evtl. im CVS-Verzeichnis die aktuellen Source-Codes der Komponente (oder anderer Komponenten der Sammlung) herunterladen. Jetzt aber weiter im Text...

Was wird nun in dieser Funktion gemacht? Nun, als erstes wird überprüft, welche Unit geladen werden soll. Dies geschieht durch die Überprüfung des ersten Parameters des OnGetUnitSource-Ereignisses (UnitName). Diese wird wieder zuerst in Kleinbuchstaben konvertiert, da wir nicht wie in C/C++ Case-Sensitive arbeiten möchten. Nachdem festgestellt wurde, dass es sich um die von uns definierte Unit handelt, müssen wir den Inhalt der Unit nur noch in Source zurückliefern. Natürlich kann hier die entsprechende Unit auch aus einer Datei geladen und in Source entsprechend zurückgeliefert werden. Ich hab's zur Verdeutlichung aber einfach mal fest implementiert. Zum Schluss, wie aus den OnGetValue und OnSetValue Ereignissen bekannt, einfach wieder Done auf True setzen, damit der Interpreter weiß, dass die Unit von unserem Programm „gefunden“ wurde und zur Verfügung steht.

Jetzt müssen wir die Unit eigentlich nur noch verwenden. Dazu passen wir die Zuweisung des Source-Codes der Haupt-Unit im FormCreate-Ereignis folgendermaßen an:

    Add('implementation                                          ');
    Add('                                                        ');
    Add('uses                                                    ');
    Add('  FirstUses;                                            ');
    Add('                                                        ');

Direkt nach der Implementation weisen wir den Interpreter an, die FirstUses-Unit einzubinden. Beim Interpretieren (bzw. beim Aufruf von Compile) wird dann das OnGetUnitSource-Ereignis aufgerufen, welches von uns ja entsprechend implementiert wurde. Um nun die Funktionen FirstUses-Unit zu verwenden, ändern wir weiter unten die von unserem Programm aufzurufende Funktion „CalcValue“ wie folgt ab:

    Add('function CalcValue: Integer;                            ');
    Add('var                                                     ');
    Add('  i : Integer;                                          ');
    Add('begin                                                   ');
    Add('  // Result vorinitialisiern                            ');
    Add('  Result := 0;                                          ');
    Add('                                                        ');
    Add('  while true do                                         ');
    Add('  begin                                                 ');
    Add('    for i := 0 to 100 do                                ');
    Add('      Result := Result + 3;                             ');
    Add('                                                        ');
    Add('    Result := AddTo(Result, ScriptRandom($FF));         ');
    Add('    if MessageBox(0, ''Fertig?'', ''Frage'', MB_YESNO)= ');
    Add('      IDYES then Break;                                 ');
    Add('  end;                                                  ');
    Add('end;                                                    ');

Wir haben nun eine Endlosschleife, welche erst durch anklicken von „Nein“ in der erscheinenden MessageBox unterbrochen wird. Dadurch wird der Wert von Result immer weiter erhöht. Und, wie man sieht, unterstützt der Interpreter nicht nur die While-Schleife, sondern auch die Befehle zur Schleifensteuerung. Feine Sache! Aber nehmen wir einmal an, eine Game-Engine stellt diverse Units zu Verfügung, welche den Zugriff auf die verschiedensten Bereiche ermöglichen. Ist es dann nicht ziemlich umständlich, die Units so zur Verfügung zu stellen? Irgendwie schon. Aber wie oben bereits erwähnt, gibt es auch noch eine andere Möglichkeit. Diese ist meiner Meinung nach auch für die Bereitstellung von Units und seitens eines Programms deutlich besser, wenn auch etwas komplizierter. Was ich meine ist...

Ein Adapter

Einige mögen jetzt fragen: Was bitte?!? Wollen wir irgendetwas „konvertieren“?!? Keine Panik, ich erklär es einfach mal. Ein Adapter ist ein Objekt, welches beim Interpreter „registriert“ wird und diesem diverse Funktionen, Variablen etc. zur Verfügung stellt. Diese sind dann allerdings nicht per Script, sondern in Delphi selbst implementiert (also passt Adapter doch, da damit sozusagen von Delphi nach JvInterpreterProgram „konvertiert“ wird).

Ist euch eigentlich aufgefallen, dass ich bis jetzt noch keine Klasse erstellt habe? Versucht es doch mal, und ihr werdet sehen, dass es einfach nicht geht. Jetzt mag der eine oder andere sich beschweren und sagen: „Ich dachte, der Interpreter unterstützt sowas! Was soll ich ohne eine Klasse!!!“ Und das zu Recht. Klassen sind immerhin recht praktisch. Aber keine Angst, mit Hilfe des Adapters ist das Verwenden von Klassen dann eben doch möglich.

Wie bereits gesagt, ein Adapter wird beim Interpreter registriert. Der Interpreter weiß dann, welche Unit der Adapter „darstellt“ und was für Variablen, Klassen, Funktionen uvm. damit für den Interpreter verfügbar sind. Da fängt das Ganze allerdings an, leicht komplex zu werden. Denn für jede Funktion einer Klasse, für jede Prozedur, Variable etc. müssen entsprechende Funktionen erstellt werden. Zwar besagt die Docu der JEDIs, dass es einen entsprechenden Konverter gibt, allerdings ist dieser weder im CVS noch im offiziellen Download enthalten. Dann muss man das Ganze eben von Hand schreiben. (Und das, wo Progger doch soooo extrem schreibfaul sind!)

Einen kleinen Trost gibt's dann doch: Die Standard Units von Delphi (Windows, SysUtils, Forms (teilweise) etc.) sind bereits von den Entwicklern von JvInterpreterProgram konvertiert und als Adapter beigefügt worden. Die haben eben ein Herz für Entwickler und deren Schreibfaulheit. Lässt sich nur noch darüber streiten, ob man letztere wirklich unterstützen sollte...

Wie dem auch sei, an dieser Stelle will ich nochmal betonen, dass ich mir das Ganze hier weitergegebene Wissen sozusagen aus den Fingern gesogen habe. Quellcodes durchlesen, Beispielprogramme debuggen etc. Wenn also jemand meint, „Moment mal, das stimmt einfach nicht, das geht anders!“, dann soll er es mir einfach mitteilen.

Wo war ich....ach ja, Adapter. In der JvInterpreter-Unit ist ein Objekt namens GlobalJvInterpreterAdapter vorhanden. Alle Funktionen, welche für eine Adapter-Funktionalität gelten sollen, werden in diesem Objekt entsprechend registriert. Bevor ich nun zu sehr auf die Theorie eingehe, wird es Zeit für etwas Praxis.

Der erste Adapter

Nehmen wir das Programm von Kapitel 5.2. Es ist für den ersten von uns verwendeten Adapter voll ausreichend. Nun gehen wir wieder in die FormCreate-Funktion und fügen (direkt nach Begin, also vor der Code-Zuweisung) die folgenden Zeilen ein:

  with GlobalJvInterpreterAdapter do
  begin
    // Die MessageBox-Funktion hinzufügen.
    AddFun('Dummy', 'MessageBox', FirstAdapter_MsgBox, 3,
      [varString, varString, varInteger], varInteger);
    // Die beiden notwendigen Konstanten ebenfalls hinzufügen.
    AddConst('Dummy', 'MB_YESNO', 4);
    AddConst('Dummy', 'IDYES', 6);
  end;

Logisch, der ein oder andere versteht jetzt nur Bahnhof. Aber keine Panik. Fangen wir mit dem verwendeten Objekt an. Wie bereits beschreiben ist dieses Objekt Global (was der Name ja auch irgendwie ausdrückt, oder?). Wenn ich also irgendeinen Adapter registrieren will, dann in diesem globalen Objekt. Aus zwei Gründen: 1. wird es in den Beispiel-Programmen und vordefinierten Adaptern der Komponente genauso gemacht, und 2. funktioniert das Ganze anders nicht.

Nun schauen wir uns die AddConst Funktion an. Diese erwartet die folgenden 3 Parameter:

   *UnitName: Der Name der Unit, in welcher sich die Konstante „befindet“.
   *Identifier: Der Name der zu registrierenden Konstante.
   *Value: Der Wert für die Konstante.

Das dürfte soweit klar sein. Das Witzige ist nur, dass UnitName eigentlich alles sein kann. Sobald eine Funktion, Konstante, ein Objekt etc. in der GlobalJvInterpreterAdapter registriert ist, kann es verwendet werden, ohne dass man die in UnitName angegebene Unit mit Hilfe von Uses in sein Script einbindet. Im Gegenteil, wenn man versucht, die angegebene Unit per Uses einzubinden, bekommt man einen Fehler, weil die Unit nicht gefunden werden konnte. Aber ich würde dennoch empfehlen, einen passablen Unitnamen anzugeben, denn man weiß nie, wann JvInterpreterProgram dies in zukünftigen Versionen voraussetzt.

Weiter geht's mit Identifier und Value. Eigentlich klar, oder? Identifier ist der Name der Konstante und Value der Wert, den der Interpreter beim Antreffen der Konstante innerhalb eines Scripts verwenden soll.

Die andere Funktion ist AddFun. Damit lässt sich eine Funktion für den Interpreter registrieren. AddFun hat die folgenden Parameter:

  • UnitName: Der Name der Unit, in welcher die „Funktion“ enthalten ist. Wie gesagt, der Wert wird noch ignoriert.
  • Identifier: Der Name der Funktion/Prozedur, welche hiermit registriert wird.
  • GetFunc: Pointer auf die Funktion in Delphi, welche aufgerufen werden soll, wenn der Interpreter auf den in Identifier übergebenen Wert trifft.
  • ParamCount: Die Anzahl der Parameter, welche an die Funktion/Prozedur übergeben werden soll.
  • ParamTypes: Die Typen der Parameter als Variant-Array.
  • ResType: Der Typ von Result.

Tja, sagt nicht, ich hätte euch nicht gewarnt. Aber das wird auch noch gemeistert. Schauen wir uns die Parameter einmal näher an. UnitName dürfte klar sein und wird, wie anscheinend bei sämtlichen AddXXX-Funktionen, einfach ignoriert. Identifier ist der Name der Funktion/Prozedur, welche von einem zu interpretierenden Programm aufgerufen werden kann und somit zur Verfügung steht. GetFunc ist die Funktion, welche vom Interpreter aufgerufen wird, wenn ein zu interpretierendes Programm die Funktion (welche in Identifier übergeben wurde) aufrufen will. Die Erklärung von GetFunc kommt später noch.

Als nächstes kommt ParamCount. Dies ist die Anzahl der Parameter, welche an die Funktion/Prozedur von Identifier übergeben werden können. Danach kommt ein Array (ParamTypes), mit dem varXXX-Konstanten übergeben werden. Die Anzahl der Einträge in diesem Array muss mit ParamCount übereinstimmen. Nur welche Konstanten müssen an dieser Stelle angegeben werden? Nun, das hängt davon ab, welche Parameter die Funktion im Interpreter übernehmen kann. Es handelt sich dabei um die von Delphi gestellten Variant-Typen. Für String und PChar muss z.B. varString angegeben werden. Für Integer varInteger usw. Wenn selbsterstellte Objekte oder Strukturen übergeben werden sollen, empfiehlt sich varEmpty. Letztendlich ist es fast egal, da Variants in alles Mögliche konvertiert werden können. Wichtig ist nur, dass die Funktion von GetFunc weiß, mit welchem Parameter sie was tun muss.

Kleines Beispiel: Wie im obigen Source-Code-Bereich zu sehen ist, ist die erste Funktion, welche wir mit Hilfe eines Adapters implementieren, die Funktion MessageBox. Diese erwartet 3 Parameter:

  1. Der Text der MessageBox als String
  2. Die Caption der MessageBox als String
  3. Die Flags der MessageBox als Integer

(Wir verwenden bei der Ausführung Application.MessageBox, wodurch das Handle, also der erste Parameter, den Windows bei dieser Funktion erwartet, wegfällt.) Nun wissen wir also, welche Parameter an die Funktion übergeben werden. Somit ist ParamCount 3 (da es 3 Parameter sind) und das ParamTypes-Array sieht folgendermaßen aus: [varString, varString, varInteger]. (Erster und zweiter Parameter als String, der dritte als Integer). Ich hoffe, das war soweit klar. Wenn nicht, lest euch die letzten Abschnitte noch mal genau durch. Alternativ (oder evtl. sogar zusätzlich) können auch die Default-Adapter betrachtet werden.

Jetzt bleibt eigentlich nur noch der letzte Parameter übrig. Mit Hilfe diesen Parameters wird definiert, was für ein Typ der Rückgabe-Wert von Identifier ist, wenn es sich dabei um eine Funktion und nicht eine Prozedur handelt. Da der Parameter immer angegeben werden muss empfiehlt es sich, notfalls varEmpty anzugeben. Denn wie in Delphi selbst auch wird der Rückgabe-Wert einer Funktion nur dann ausgewertet, wenn er weiterverwendet wird. Bei einer Prozedur ist das logischerweise kein Thema, weswegen der letzte Parameter für eine Prozedur uninterresant ist.

Uff, das war jetzt kompliziert. Vielleicht auch kompliziert von mir erklärt, aber ganz leicht ist das Thema auch wirklich nicht. Jedem, der es bis hierher geschafft hat und dem der Kopf raucht, empfehle ich, eine kleine Pause einzulegen und was Sinnvolles zu machen ('ne Prise frische Luft wäre an dieser Stelle nicht schlecht. Denn die ist bei Proggern meistens Mangelware...)

Die Funktion GetFunc

Wie bereits erwähnt, ist diese Funktion das eigentliche Kernstück eines Adapters, was Funktionen/Prozeduren angeht. (Ok, genauso hab ich's nicht erwähnt, aber wir wollen jetzt doch nicht kleinlich werden, oder?!? ;) )

Nochmal zur Erinnerung: diese Funktion wird an AddFun übergeben. Trifft nun der Interpreter auf den Namen einer Funktion, welche wir mit Hilfe von AddFun registriert haben, ruft er nicht das OnGetValue-Ereingis auf, sondern die übergebene GetFunc Funktion. Das Erspart uns eine ewig lange Liste an „if - then – else“ Befehlen im OnGetValue-Ereignis. Dafür haben wir eben eine ewig lange Liste an AddFun und dazugehörige GetFunc Funktionen. Naja, für einen der beiden umständlichen Wege muss man sich eben entscheiden.

Aber zurück zu GetFunc. Diese Funktion ist vom Typ TjvInterpreterAdapterGetValue hat die folgende Definition:

TJvInterpreterAdapterGetValue = procedure(var Value: Variant;
  Args: TJvInterpreterArgs);

Value ist das, was zurückgegeben wird, wenn mit AddFun eine Funktion registriert wurde. (Im Fall unserer MessageBox müsste in Value also das Ergebnis von Application.MessageBox abgelegt werden). Das Args-Objekt kennen wir ja bereits. Da wir wissen, wie viele Parameter übergeben wurden (ihr erinnert euch, das haben wir mit Hilfe von ParamCount und ParamTypes bei AddFun angegeben), müssen hier keine weiteren Überprüfungen durchgeführt werden. Wir können also direkt darauf zugreifen.

Bevor es jetzt wieder ewig theoretisch wird, zeige ich euch anhand unseres MassageBox-Beispieles einfach mal, wie so eine Funktion aussehen muss:

procedure FirstAdapter_MsgBox(var Value: Variant; Args: TJvInterpreterArgs);
begin
  Value := Application.MessageBox(PChar(VarToStr(Args.Values[0])),
    PChar(VarToStr(Args.Values[1])), Args.Values[2]);
end;

Ist eigentlich nicht wirklich viel los. Das einzige, was auf den ersten Blick vielleicht etwas verwirrt, sind wohl die Variant-Konvertierungs-Funktionen. Wie oben bereits geschrieben, werden 3 Parameter an die MessageBox im zu interpretierenden Programm übergeben. Ein Text-String, ein Caption-String und ein Flag-Integer. Also sind die ersten beiden Parameter-Values (Args.Values[0] und Args.Values[1], da das Array 0-basierend ist) als String zu verwenden. Leider hab ich auf die Schnelle keine Variant-To-PChar Funktion gefunden. Deswegen konvertiere ich die ersten beiden Parameter in einen String und diesen dann in einen PChar per TypeCast. Der letzte Parameter (die Flags vom Typ Integer) wird direkt übergeben. Delphi macht das dann schon richtig. Jetzt muss nur noch das Ergebnis von Application.MessageBox in Value abgelegt werden, damit das interpretierte Programm dieses auch mitbekommt. Das war's dann schon!

Der Name der GetFunc-Funktion (hier FistAdapter_MsgBox) ist übrigens unwichtig. Er wird in AddFun verwendet und danach nicht mehr benötigt, da JvInterpreter intern nur die Adresse vermerkt. Mein Vorschlag wäre allerdings (der Übersichtlichkeit halber), UnitName_FunktionsName zu verwenden. Ok, hab ich hier auch nicht gemacht, aber bei einem Adapter ist das Ganze noch kein Problem. Wenn man aber eine komplette API freigeben will, wird's schnell unübersichtlich.

Um das Ganze zu verwenden, sind nur noch zwei weitere, kleinere Änderungen (neben den bereits aufgeführten) am Beispiel-Programm aus Kapitel 5.2 notwendig: 1. fliegt das OnGetUnitSource-Ereignis wieder raus, da wir es in diesem Beispiel-Programm nicht mehr benötigen. Die notwendigen Konstanten und Funktionen sind ja nun per Adapter verfügbar gemacht worden. 2. muss man noch die Uses-Anweisung aus dem Scipt-Programm entfernen. Und schon kann das Beispiel, wie vorhin in Kapitel 5.2, ausgeführt werden. Herzlichen Glückwunsch, der erste Adapter läuft. Aber es folgen weitere.

Records per Adapter

Records können im Source des Interpreters direkt eingegeben werden. Eben so, wie man's unter Delphi gewohnt ist. Einfach einen Type-Abschnitt und seinen Record definieren, später eine Variable von diesem Record ableiten und verwenden. (Lediglich das Zuweisen der Daten mit Hilfe von with ... do geht nicht. Und das Definieren eines Records mit Hilfe von Type innerhalb einer Funktion bringt den Interpreter ebenfalls durcheinander.)

Aber wir können die Adapter auch für Records verwenden. Und das ist, wenn man mal vom komplizierten Handling der Adapter absieht, gar nicht so schwer. Naja, fast. Aber eins nach dem anderen.

Ein kleiner Ausflug in Delphi-Interna

Damit wir Records per Adapter freigeben können, schauen wir uns doch einfach mal an, wie die Records in Delphi eigentlich verwaltet werden. Und bevor sich wieder jemand beschwert: das, was jetzt kommt, trifft eigentlich auf alle Programmiersprachen zu. Aber wir Delphi-Progger wollen doch loyal sein, oder? Wer übrigens hofft, jetzt eine detaillierte Erklärung über die Speicherverwaltung zu bekommen, den muss ich enttäuschen. Das hier dient nur zur kurzen Übersicht bzw. Auffrischung.

Schauen wir uns erst nochmal an, wie wir in Delphi einen Record definieren:

type
  TAddRec = record
    Var1 : Integer;
    Var2 : Integer;
    Var3 : integer;
  end;

Soweit nichts Aufregendes. Was aber passiert intern mit diesem Record? Was macht Delphi damit? Es wird natürlich Speicher benötigt. Jeder Integer belegt 4 Byte, da ein Integer eine 32-Bit-Zahl ist (bei einem 32-Bit Compiler wie Delphi. Bei 16-Bit Compilern kann das variieren). Da wir 3 Integers haben, benötigt der ganze Record insgesamt 12 Byte (3 x 4 Byte).

Delphi merkt sich nun die Anfangsadresse von TAddRec. Wenn also die Speicheradresse von TAddRec 4000 ist, dann steht die erste Variable (also TAddRec.Var1) an eben dieser Stelle. Die zweite Variable kommt dann 4 Byte später, da Var1 ja die ersten 4 Bytes des Speichers belegt. Somit wäre die Adresse von TAddRec.Var2 = 4004. Und, wie ihr euch wahrscheinlich schon denken könnt, ist Var3 dann (vom Anfang aus gesehen) 8 Byte später. Somit wäre die Adresse von TAddRec.Var3 = 4008. Um die Verwirrung wenigstens etwas zu verhindern, eine kleine Grafik:

Tutorial JvInterpreter RAM.png

Jeder Block steht für ein Byte. Hier ist ersichtlich, dass der gesamte Record 12 Byte groß ist, jede einzelne Varx-Variable eine Größe von 4 Byte hat und die Variablen hintereinander liegen. Bei der Adressierung ist die sogenannte Basis des Records immer die Speicheradresse des gesamten Records. Der Abstand von einer Variablen innerhalb des Records zur anderen wird als Offset bezeichnet (die Variablen selbst oft auch als Felder des Records). Dieser Offset wird, wie bereits erwähnt, zur Basis des Records hinzugefügt, um die verschiedenen Felder ansprechen zu können. Errechnet wird der Offset aus der Anzahl der Bytes der vorherfolgenden Felder. Dabei wird jede Größe der vorherigen Felder zusammengezählt. In diesem Fall sieht das Ganze dann so aus:

Feld/Variable Offset
Var1 0 (da es am Anfang steht)
Var2 4 (da eine Variable mit der Größe von 4 Byte vor Var2 definiert wurde)
Var3 8 (da 2 Variablen mit der Größe von je 4 Byte vor Var3 definiert wurden)

Ich hoffe, das war nun soweit verständlich. Falls nicht, einfach noch mal durchlesen, denn das ist nicht ganz unwichtig für das folgende Kapitel.

Ein Record namens TAddRec

Jetzt aber genug der Vorrede. Es wird Zeit, dass der Record endlich einmal definiert wird. Dabei fangen wir wie immer einfach mal klein an. Nehmen wir doch „rein zufällig“ mal den Record, der im vorherigen Kapitel als Beispiel herhalten musste. Noch mal zur Erinnerung:

type
  TAddRec = record
    Var1 : Integer;
    Var2 : Integer;
    Var3 : integer;
  end;

Um diesen Record per Adapter freizugeben, müssen wir ihn entsprechend beim Interpreter registrieren. Dazu dient die Funktion AddRec, welche folgendermaßen deklariert ist:

procedure TJvInterpreterAdapter.AddRec(UnitName: string; Identifier: string;
  RecordSize: Integer; Fields: array of TJvInterpreterRecField;
  CreateFunc: TJvInterpreterAdapterNewRecord;
  DestroyFunc: TJvInterpreterAdapterDisposeRecord;
  CopyFunc: TJvInterpreterAdapterCopyRecord);

Hm, so langsam nehmen die Parameter überhand. Wer soll denn da noch den Überblick behalten? Keine Panik, bis jetzt haben wir alles gemeistert, oder? Ok, zu den Parametern:

  • UnitName: Der Name der Unit, welcher der Record zugeordnet wird. Aber wie bereits erwähnt, wird das irgendwie ignoriert.
  • Identifier: Der Name des Records.
  • RecordSize: Die Gesamtgröße des Records in Bytes.
  • Fields: Die Felder (also die einzelnen Variablen) des Records. Diese werden als Array von TjvInterpreterREcField erwartet, aber darauf komm ich noch genauer zu sprechen.
  • CreateFunc: Eine Funktion, welche zum Erstellen des Records aufgerufen wird.
  • DestroyFunc: Eine Funktion zum Freigeben des Reccords.
  • CopyFunc: Eine Funktion zum Kopieren des Records.

CreateFunc, DestroyFunc und CopyFunc können auch NIL sein, wenn der Record nicht dynamisch verwaltet werden muss. Weiter geht's mit dem Fields-Parameter. Der Typ TjvInterpreterRecField ist wie folgt definiert:

  TJvInterpreterRecField = record
    Identifier: string;
    Offset: Integer;
    Typ: Word;
  end;

Na, dämmert es nun, warum der kurze Ausflug zum Thema Speicherverwaltung und Records notwendig war? Genau, hier muss jedes einzelne Feld angegeben werden (Identifier), der Offset vom Anfang des Records aus und der Typ des Feldes. Zum Erstellen dieser Struktur kann die Hilfsfunktion RFD verwendet werden. Wenn wir nun also den Record im Adapter registrieren, dann sieht das Ganze so aus:

  with GlobalJvInterpreterAdapter do
  begin
    // Den Record hinzufügen.
    AddRec('Dummy', 'TAddRec', SizeOf(TAddRec), [
        RFD('Var1', 0, varInteger),
        RFD('Var2', 4, varInteger),
        RFD('Var3', 8, varInteger)
      ], nil, nil, nil);
  end;

Die Art, wie dieser Teil des Codes geschrieben wurde, verstößt zwar gegen jegliche Pascal-Regel, aber dafür ist meiner Meinung nach der Code etwas übersichtlicher.

Hier wurde nun also für die Unit Dummy ein Record namens TAddRec definiert, welcher 3 Felder vom Typ Integer hat. Bei jedem Feld wird der Offset desselbigen relativ zum Anfang des Records angegeben. Das war eigentlich schon alles. Einfach, oder? Die Verwendung des Records ist etwas sinnlos, soll aber auch nur das Prinzip verdeutlichen. Wir schreiben ein Script, welches einen Zufallswert in Var1 ablegt (die Funktion zum Ermitteln einer Zufallszahl hängt in unseren Beispielen ja schon seit den ersten Kapiteln mit drin. Allerdings wurde sie ab diesem Beispiel von mir per Adapter realisiert). Dasselbe nochmal für Var2, wobei dieser Wert dann noch mit Var multipliziert wird. Danach... aber seht selbst. Das Script sieht nun folgendermaßen aus:

  with JvInterpreterProgram1.Pas do
  begin
    Clear;
    Add('unit MyFirstUnit;                                       ');
    Add('                                                        ');
    Add('interface                                               ');
    Add('                                                        ');
    Add('procedure MainFnc;                                      ');
    Add('                                                        ');
    Add('implementation                                          ');
    Add('                                                        ');
    Add('procedure MainFnc;                                      ');
    Add('var                                                     ');
    Add('  AddRec : TAddRec;                                     ');
    Add('begin                                                   ');
    Add('  AddRec.Var1 := Random($FF);                           ');
    Add('  AddRec.Var2 := Random($FF) * AddRec.Var1;             ');
    Add('  AddRec.Var3 := AddRec.Var2 + AddRec.Var1;             ');
    Add('                                                        ');
    Add('  OutRec(AddRec);                                       ');
    Add('end;                                                    ');
    Add('                                                        ');
    Add('end.                                                    ');
  end;

Dem aufmerksamen Leser wird aufgefallen sein, dass es da noch die Funktion OutRec gibt. Diese macht eigentlich nichts anderes, als den übergebenen Record im Memofeld des Beispiels auszugeben.

  with GlobalJvInterpreterAdapter do
  begin
    // Die MessageBox-Funktion hinzufügen.
    AddFun('Dummy', 'OutRec', FirstRecord_OutRec, 1, [varRecord], varEmpty);
  end;

Ist ja bekannt, nur sieht man hier, dass man nur einen Parameter übergibt, und dieser vom Typ Record (varRecord) ist. Die Funktion selbst, die für OutRec verwendet wird, hat noch eine kleine Raffinesse zu bieten:

procedure FirstRecord_OutRec(var Value: Variant; Args: TJvInterpreterArgs);
var
  AddRec : TAddRec;
begin
  AddRec := TAddRec(V2R(Args.Values[0])^);
  with Form1.Memo1.Lines do
  begin
    Clear;
    Add('AddRec.Var1 = ' + IntToStr(AddRec.Var1));
    Add('AddRec.Var2 = ' + IntToStr(AddRec.Var2));
    Add('AddRec.Var3 = ' + IntToStr(AddRec.Var3));
  end;
end;

Da liegt das eigentliche Problem: ohne Typecasting geht's ab jetzt nicht mehr, denn als Parameter wird nicht der eigentliche Record, sondern seine Speicheradresse übergeben. Da größtenteils alles im Interpreter mit Variants verwaltet wird, wird diese Speicheradresse mit der Funktion V2R (Variant-To-Record) in ein Format gebracht, welches dann per TAddRec(Adresse) in den entsprechenden Record konvertiert bzw. ge-TypeCastet werden kann. Damit das nicht ständig notwendig ist, wird das Ergebnis in AddRec abgelegt.

Ab jetzt kann der Record wie gewohnt in Delphi verwendet werden. Wie bereits angedeutet, werden einfach die Werte der Record-Felder im Memo ausgegeben. Dabei muss allerdings per Form1.Memo1 darauf zugegriffen werden, da sich die Funktion FirstRecord_OutRec außerhalb von Form1befindet.

Nun einfach noch per JvInterpreter1.Compile das Script kompilieren und die Main-Funktion aufrufen. Das war's dann auch schon. Der erste Record wurde per Adapter realisiert. Eigentlich wäre es auch möglich, den Record einfach innerhalb des Scripts zu definieren, jedoch muss dies dann im Delphi-Programm selbst auch noch geschehen. Wenn nicht, kann (logischer weise) die Delphi Funktion FirstRecord_OutRec nicht darauf zugreifen.

Die Sonderfälle in Records

Wäre ja auch zu schön gewesen, wenn kein Haken an der Sache wäre. Versucht doch einfach mal, einen String im Record zu definieren und zu verwenden. Ihr werdet sehen, dass das nicht klappt. Oder aber einen varianten Teil in einem Record (wie z.B. in TRect). Aber auch das ist möglich, jedoch müssen wir nun ein paar kleinere Unwege in Kauf nehmen, um zum Ziel zu kommen.

Wir nehmen nun einfach mal das Beispiel mit unserem ersten Adapter. Dort haben wir die MessageBox-Funktion per Adapter realisiert. Das Ganze wird nun abgeändert, damit die Daten der MessageBox mit Hilfe eines Records übergeben werden können. Zuerst müssen wir den Record definieren:

Type
  PAddRec = ^TAddRec;
  TAddRec = record
    Cap : string;
    Msg : string;
    Flags : integer;
  end;

Wie man sieht, ist noch eine dynamische Variante des Records deklariert worden, da dies jetzt notwendig ist. Das Problem bei diesem Record sind die Strings. Ein String hat eine Größe von 4 Byte, da es genaugenommen ein Pointer ist. Eigentlich kennt ein Computer keine Strings in diesem Sinne, sondern nur ein Array mit Zeichen, welches mit dem Zeichen Char(0) abschließt. Diese sind auch als sogenannte PChars bekannt (Pointer auf Chars). Delphi selbst arbeitet intern eigentlich auch mit PChars, weswegen ein TypeCast von String nach PChar einfach zu realisieren ist (PChar(String-Variable)). Normalerweise müsste bei der Zuweisung eines Strings der Programmierer folgendes erledigen:

Die Größe des Speicherbereiches des „Strings“ muss auf die Größe der zuzuweisenden Zeichen + 1 (wegen Char(0)) gesetzt werden. Für den Fall, dass „Hallo“ zugewiesen werden soll, müsste man also für den String (welcher ein PChar ist) 6 Byte Speicherplatz reservieren. Dieser Speicher muss allerdings ständig dynamisch angepasst werden, wenn man zu dem String etwas hinzufügt, oder entfernt usw. Also ist ein String eigentlich nichts anderes als ein Array Of Char, ein dynamisches Char-Array.

Um es dem Entwickler nun leicht zu machen, übernimmt der Compiler die komplette Speicherverwaltung und -anpassung. Sobald Delphi auf eine Variable vom Typ String trifft, weiß es, dass ein bestimmtes Handling notwendig ist und implementiert an den entsprechenden Stellen den jeweiligen Zusatzcode. Das macht es, wie gesagt, dem Entwickler einfach, aber für den Interpreter gibt das Ganze ein Problem. Denn wenn per Adapter ein Record mit String-Feldern definiert wird, geht der Interpreter davon aus, dass er nichts weiter tun muss als einfach in den entsprechenden Speicher zu schreiben. Dummerweise passt der Interpreter den String-Speicherbereich nicht an - es sei denn, der Record mit Strings oder gar die Strings selbst sind im Script deklariert, was jetzt aber nicht der Fall ist.

Was tun? Auch dafür gibt es eine Lösung: Für jedes Feld eines Records kann eine Get- und eine Set-Funktion definiert werden. Diese Funktionen sind wie die Get- und Set-Ereignisse der Komponente selbst deklariert. Allerdings müssen wir den Record nun dynamisch erstellen. Kümmern wir uns also zuerst einmal um die Registrierung des Records:

  with GlobalJvInterpreterAdapter do
  begin
    AddRec('Dummy', 'TMsgRec', SizeOf(TMsgRec), [RFD('Flags', 8, varInteger)],
      New_MsgRec, Free_MsgRec, nil);
    AddRecSet('Dummy', 'TMsgRec', 'Cap', Set_MsgRec_Cap, 0, [varEmpty]);
    AddRecSet('Dummy', 'TMsgRec', 'Msg', Set_MsgRec_Msg, 0, [varEmpty]);
  end;

Wie wir sehen, sind die Felder Cap und Msg mit Hilfe der Funktionen AddRecSet registriert worden. Das bedeutet aber auch, dass wir sie bei der Registrierung des Records mit AddRec nicht mehr auflisten dürfen. Denn dann meint der Interpreter wieder, er soll sich selbst darum kümmern, und die ganze Aktion schlägt fehl.

Die AddRecSet Funktion ist folgendermaßen definiert:

procedure TJvInterpreterAdapter.AddRecSet(UnitName: string; RecordType: string;
  Identifier: string; SetFunc: TJvInterpreterAdapterSetValue;
  ParamCount: Integer; ParamTypes: array of Word);

Die Funktion hat die folgenden Parameter:

  • UnitName: Soweit klar, irgendwie immer das Gleiche...
  • RecordType: Der Name des Records, für welchen das Feld definiert wurde.
  • Identifier: Der Name des zu registrierenden Feldes.
  • SetFunc: Die Funktion, welche beim Setzen eines Wertes aufgerufen werden soll.
  • ParamCount: Die Anzahl der Parameter, welche an SetFunc übergeben werden sollen (bei einem Record nicht von Bedeutung, deswegen immer 0).
  • ParamTypes: Ein Array mit den Parameter-Typen. Bei einem Record ebenfalls belanglos und deswegen immer [varEmpty].

Damit wäre das klar. Ach ja, zwei Dinge noch:

  1. Da die Flags des Records vom Typ Integer sind, können diese mit Hilfe von AddRec registriert werden. Da die beiden Strings jedoch vorher kommen, wird ein Offset von 8 angegeben. Es ist immer wichtig, dass die Offsets stimmen!
  2. Wenn einer der Stringparameter im Script selbst ausgelesen werden soll, muss natürlich noch eine entsprechende Get-Funktion mit Hilfe von AddRecGet angegeben werden.

Weiter geht's. Wie bei AddRec zu sehen ist, werden zwei Funktionen zum Erstellen und Freigeben des Records angegeben. Sobald ein Record dynamische Variablen enthält, wie z.B. Strings, Objekte, dynamische Arrays, etc., muss er selbst dynamisch erstellt und nach der Verwendung auch wieder freigegeben werden. Der Grund liegt auf der Hand: Wenn der Record nicht dynamisch erstellt wird, kann sich Delphi auch nicht um die Speicherverwaltung der dynamischen Felder kümmern. Allerdings ist dies eben zwingend erforderlich, denn der Interpreter hat keine eigene Speicherverwaltung in diesem Sinne.

Nachdem nun auch das geklärt wäre, schauen wir uns noch die zwei Funktionen zum Erstellen und Freigeben des Records an. (Der letzte Parameter, die Funktion zum Kopieren, ist nur dann notwendig, wenn ein Record Delphi-like kopiert werden soll (also Rec2 := Rec1)):

procedure New_MsgRec(var Value: Pointer);
begin
  New(PMsgRec(Value));
end;

procedure Free_MsgRec(const Value: Pointer);
begin
  Dispose(PMsgRec(Value));
end;

Igitt, Pointer. Naja, was sein muss, muss eben sein. Um’s mal zu erklären: Mit Hilfe von New wird ein neuer, dynamischer Record erstellt. Normalerweise würde das Ganze so aussehen:

procedure New_MsgRec(var Value: Pointer);
var
  Tmp : PMsgRec;
begin
  New(Tmp);
  Value := Tmp;
end;

New reserviert Speicher (und zwar so viel, wie ein TMsgRec belegt) und weist diesen an PMsgRec zu. Die zugewiesene Adresse wird dann in Value zurückgeliefert. Diesen Zwischenschritt erspar ich mir einfach, indem ich New sage, es soll Value als PMsgRec betrachten und den entsprechenden Speicher reservieren. So, wie es eben oben dargestellt ist. Damit keine Memory-Leaks (Speicherlöcher – Speicher der reserviert, aber nicht mehr verwendet und freigegeben wurde) entstehen, wird in der Free_MsgRec Funktion der übergebene Speicherbereich mit Hilfe von Dispose wieder freigegeben. Wer mehr über New und Dispose wissen möchte, der schaue in der Delphi-Hilfe oder im -Handbuch unter dynamischen Variablen bzw. den entsprechenden Befehlen (New und Dispose) nach.

Jetzt hätten wir den Record reserviert und auch wieder freigegeben. Wie wird nun den beiden Strings ein Wert zugewiesen?

procedure Set_MsgRec_Cap(const Value: Variant; Args: TJvInterpreterArgs);
begin
  TMsgRec(P2R(Args.Obj)^).Cap := Value;
end;

procedure Set_MsgRec_Msg(const Value: Variant; Args: TJvInterpreterArgs);
begin
  TMsgRec(P2R(Args.Obj)^).Msg := Value;
end;

In diesen Funktionen wird in Value der zu setzende Wert übergeben. In Args ist der Record „versteckt“. Und zwar in Args.Obj, was per P2R (Pointer-To-Record) wieder in ein Format gebracht wird, welches uns dann TypeCast zu TMsgRec ermöglicht. P2R liefert also die Speicheradresse zurück. Mit Hilfe von TMsgRec(Speicheradresse) sagen wir Delphi, wie wir eben diese Adresse verwenden möchten.

Sei nur noch erwähnt, dass P2R nicht die Adresse des Records (im Folgenden RecAdr genannt) zurückliefert, sondern eine Speicheradresse, in welcher die RecAdr hinterlegt ist. Deswegen wird mit dem Dächlein (^) der Inhalt des Speichers ausgelesen, was der gewünschten Adresse (also RecAdr) entspricht. Wer jetzt nicht verwirrt ist, hebe die Hand. ;) Es handelt sich also um einen Pointer auf einen Pointer. Wie immer gilt: für nähere Infos siehe Delphi-Docu.

Somit haben wir also den beiden Feldern Msg und Cap die gewünschten Werte zugewiesen. Unser erster Record mit dynamischen Feldern ist per Adpater registriert. Jetzt müssen wir ihn nur noch anwenden. Dazu verwenden wir folgendes Script:

  with JvInterpreterProgram1.Pas do
  begin
    Clear;
    Add('unit MyFirstUnit;                                       ');
    Add('                                                        ');
    Add('interface                                               ');
    Add('                                                        ');
    Add('procedure MainFnc;                                      ');
    Add('                                                        ');
    Add('implementation                                          ');
    Add('                                                        ');
    Add('procedure MainFnc;                                      ');
    Add('var                                                     ');
    Add('  MsgRec : TMsgRec;                                     ');
    Add('begin                                                   ');
    Add('  MsgRec.Cap := ''Script running'';                     ');
    Add('  MsgRec.Msg := ''Hello from Script!'';                 ');
    Add('  MsgRec.Flags := MB_OK or MB_ICONINFORMATION;          ');
    Add('                                                        ');
    Add('  MessageBox(MsgRec);                                   ');
    Add('end;                                                    ');
    Add('                                                        ');
    Add('end.                                                    ');
  end;

Damit das Ganze läuft, müssen wir nur noch die MessageBox-Funktion implementieren. Also zuerst mal wieder die mittlerweile schon ziemlich vertrauten Funktionen AddFun und AddConst:

  with GlobalJvInterpreterAdapter do
  begin
    // Die MessageBox-Funktion hinzufügen.
    AddFun('Dummy', 'MessageBox', RecordParam_MsgBox, 1, [varRecord],
      varInteger);
    AddConst('Dummy', 'MB_OK', MB_OK);
    AddConst('Dummy', 'MB_ICONINFORMATION', MB_ICONINFORMATION);
  end;

Damit sind die MessageBox und die beiden notwendigen Konstanten registriert. Jetzt müssen wir nur noch einen Blick auf die Funktion RecordParam_MsgBox werfen:

procedure RecordParam_MsgBox(var Value: Variant; Args: TJvInterpreterArgs);
var
  MsgRec : TMsgRec;
begin
  MsgRec := TMsgRec(V2R(Args.Values[0])^);
  Value := Application.MessageBox(PChar(MsgRec.Msg), PChar(MsgRec.Cap),
    MsgRec.Flags);
end;

Eigentlich nichts Neues. Wir konvertieren den ersten Parameter in den Record und übergeben die einzelnen Felder des Records an die MessageBox-Funktion der Application (die Strings eben als PChars). Zwar wird in Value der Rückgabewert der MessageBox abgelegt, jedoch wird dieser nicht weiterverwendet. Wie einfach, nach dem ganzen Chaos.

Nun das Programm starten, auf den Button klicken und über die MessageBox freuen. Hui...eine Meldung. Wie erstaunlich! Aber da wir gerade so schön dabei sind, auf zum nächsten und letzten großen Thema: Objekte.

Objekte im Script

Auch Objekte sind in Scripts machbar. Allerdings, im Gegensatz zu Records, diesmal nur per Adapter. Tja, es wird eben nicht einfacher, aber das hat ja auch keiner behauptet, oder? Aber keine Angst. Wenn man die Record-Geschichte kann, sind Objekte eigentlich nur noch 'n schlechter Witz. Genaugenommen sind Objekte nur eine „leicht“ erweiterte Version von Records. Naja, stimmt nicht ganz, aber was die Speicherverwaltung angeht, so sind sich die beiden doch recht ähnlich. Für unser Beispiel verwenden wir ein einfaches, zur Demonstration gut geeignetes Objekt:

Eine Stringliste

Wie bereits erwähnt, sind sich Records und Objekte eigentlich relativ ähnlich. Jedenfalls für den Interpreter. Zum Registrieren eines Objekts wird die Funktion AddClass verwendet. AddClass deswegen, weil Objekte in diesem Sinne eigentlich ein Überbleibsel aus Turbo-Pascal Zeiten ist. Denn genau genommen ist die Definition (von z.B. TStringList) eine Klasse. Erstellte Instanzen von TStringList sind dann Objekte. Aber wir wollen doch hier nicht mit Haarspalterei anfangen! Stattdessen schauen wir uns lieber die Deklaration von AddClass an:

procedure TJvInterpreterAdapter.AddClass(UnitName: string; AClassType: TClass;
  Identifier: string);

Relativ wenig Parameter, was? Eigentlich sind sie selbsterklärend, aber der Vollständigkeit halber:

  • UnitName: Das übliche eben.
  • AClassType: Der Typ der Klasse.
  • Identifier: Der Name der Klasse.

Damit hätten wir dann das Objekt definiert. Aber was ist mit den Funktionen und Variablen? Dazu gibt's die Funktion AddGet bzw. AddSet. Auch hier werfen wir einen Blick auf die Deklaration:

procedure TJvInterpreterAdapter.AddGet(AClassType: TClass; Identifier: string;
  GetFunc: TJvInterpreterAdapterGetValue; ParamCount: Integer;
  ParamTypes: array of Word; ResTyp: Word);

Tja, da waren es dann gleich wieder ein paar Parameter mehr. Aber es sieht schlimmer aus als es ist, denn das meiste kennen wir eigentlich schon:

  • AClassType: Der Typ der Klasse, welcher bereits bei AddClass angegeben wurde. Dadurch weiß der Interpreter, zu welcher Klasse das nun Hinzugefügte gehört.
  • Identifier: Der Name des hinzuzufügenden Feldes (Funktion oder Variable).
  • GetFunc: Die Funktion, die bei Antreffen von Identifier aufgerufen wird.
  • ParamCount: Die Anzahl der Parameter, die an GetFunc übergeben werden sollen.
  • ParamTypes: Die Parameter-Typen (in altbewährter Verwendungsweise).
  • ResType: Wie gewohnt, das Ergebnis von GetFunc.

Wie gesagt, alles bekannte Parameter. AddSet ist relativ identisch mit AddGet. Lediglich der letzte Parameter (ResType) fällt weg. Also registrieren wir gleich mal eine StringListe. Dabei wurde das Ganze auf ein paar für die Demonstration notwendige Funktionen beschränkt:

  with GlobalJvInterpreterAdapter do
  begin
    // Die Grund-Klasse definieren.
    AddClass('Dummy', TStringList, 'TStringList');
    // Die Funktionen der Klasse.
    AddGet(TStringList, 'Create', TStringList_Create, 0, [varEmpty], varEmpty);
    AddGet(TStringList, 'Add', TStringList_Add, 1, [varString], varInteger);
    AddGet(TStringList, 'Clear', TStringList_Clear, 0, [varEmpty], varEmpty);
  end;

Simple Sache, oder? Das einzig Ungewöhnliche ist evtl. der erste Parameter von AddGet. Hier wird direkt die Klasse übergeben (Damit dürfte auch klar sein, warum es eben einen Unterschied zwischen Klasse und Objekt gibt). Allerdings, ganz neu ist das auch nicht. Schaut einfach mal in die DPR-Datei einer typischen Delphi-Anwendung rein, dort ist das eher üblich (Stichwort Application.CreateForm).

Wie versprochen, eigentlich kein allzu großer Unterschied zu den Records. Stellt sich nur noch die Frage, was in den TStringList_xxx Funktionen so alles passiert. Auch hier gibt es, wie eben schon, keinen allzu großen Unterschied. Aber werfen wir einfach mal einen Blick darauf:

procedure TStringList_Create(var Value: Variant; Args: TJvInterpreterArgs);
begin
  Value := O2V(TStringList.Create);
end;

procedure TStringList_Add(var Value: Variant; Args: TJvInterpreterArgs);
begin
  Value := (Args.Obj as TStringList).Add(Args.Values[0]);
end;

procedure TStringList_Clear(var Value: Variant; Args: TJvInterpreterArgs);
begin
  (Args.Obj as TStringList).Clear;
end;

Auch nichts besonderes. Zumindest fast. Bei Create wird das Objekt erstellt und mit Hilfe der Funktion O2V (Object-To-Variant) in einen Variant konvertiert. Dieser wird dann in Value zurückgeliefert. Somit wäre das Objekt erstellt.

Nur was hat es mit dem „as“ auf sich? Ganz einfach. Im Falle von Add und Clear wird das erstellte Objekt in Args.Obj übergeben. Ist uns doch irgendwie bekannt, oder? Genau, war beim Record mit dynamischer Erstellung genauso. Aber im Gegensatz zum Record handelt es sich hier wirklich um ein Objekt. Also ist eigentlich kein weiteres TypeCasting notwendig. Da Args.Obj allerdings vom Typ TObject ist und wir eigentlich eine StringListe (TStringList) benötigen, müssen wir Delphi noch mitteilen, dass das Objekt von Delphi als TStringList betrachtet werden soll. Das machen wir mit diesem „as“. Auch hier verweise ich wieder schlicht und einfach auf die Delphi-Doku.

So weit, so gut. Nun fragt sich der ein oder andere: Toll, jetzt kann ich ein Objekt erstellen und verwenden. Aber was ist mit der Freigabe eines Objektes? Stimmt, Free wurde von uns nicht registriert. Das ist aber auch nicht notwendig, denn Free ist bereits in TObject definiert und dadurch an alle anderen Objekte vererbt. Der Interpreter nimmt sich nun die Freiheit heraus, das Objekt einfach direkt freizugeben, wenn im Script Objekt.Free aufgerufen wird. Wenigstens etwas, um das man sich nicht kümmern muss.

Was fehlt noch? Nun, damit wir sehen, was das Script mit der StringListe anstellt, hab ich noch die Funktion StringsToMemo registriert. Aber hier passiert eigentlich auch nichts Aufregendes:

procedure UsesClasses_S2M(var Value: Variant; Args: TJvInterpreterArgs);
begin
  Form1.Memo1.Lines.Assign(V2O(Args.Values[0]) as TStringList);
end;

Es wird lediglich per V2O (Variant-To-Object) der Variant Args.Values[0] in ein Objekt konvertiert (wer hätte das gedacht *g*) und Delphi mitgeteilt, dass wir das Objekt als Instanz von TStringList verwenden wollen. Das Ganze wird dann per Assign den Lines des MemoFeldes zugewiesen. Eben nichts Aufregendes.

Jetzt fehlt eigentlich nur noch das Script. Die MainFnc sieht folgendermaßen aus:

    Add('procedure MainFnc;                                      ');
    Add('var                                                     ');
    Add('  StrLst: TStringList;                                  ');
    Add('begin                                                   ');
    Add('  StrLst := TStringList.Create;                         ');
    Add('                                                        ');
    Add('  StrLst.Add(''Der Inhalt einer'');                     ');
    Add('  StrLst.Add(''einfachen StringListe,'');               ');
    Add('  StrLst.Add(''welche im Script erstellt'');            ');
    Add('  StrLst.Add(''und mit Daten gefüllt wurde'');          ');
    Add('                                                        ');
    Add('  StringsToMemo(StrLst);                                ');
    Add('                                                        ');
    Add('  StrLst.Free;                                          ');
    Add('end;                                                    ');

Das Ganze laufen lassen, auf den Button klicken und wie immer: freuen. ;)

Zugriff auf Controls

Ich will noch kurz auf die Schnelle darauf eingehen, wie man auf Controls von Delphi zugreifen kann. Dazu greifen wir mit unserem nächsten Script einfach direkt auf das MemoFeld der Form zu. Leider muss man hier den ein oder anderen Umweg in Kauf nehmen. Aber schauen wir uns zuerst die Registrierung der Adapter an:

  with GlobalJvInterpreterAdapter do
  begin
    // Die Grund-Klasse definieren.
    AddClass('Dummy', TStrings, 'TStrings');
    // Die Funktionen der Klasse.
    AddGet(TStrings, 'Add', TStrings_Add, 1, [varString], varInteger);
    AddGet(TStrings, 'Clear', TStrings_Clear, 0, [], varEmpty);
    // Die Funktionen, um per [] auf die Strings zuzugreifen.
    AddIDGet(TStrings, TStrings_ID_Get, 1, [varInteger], varString);
    AddIDSet(TStrings, TStrings_ID_Set, 1, [varInteger]);

    // Die Daten für das Memo.
    AddClass('Dummy', TMemo, 'TMemo');
    AddGet(TMemo, 'Lines', TMemo_GetLines, 0, [], varEmpty);

    // Die Funktion zum Ausgeben des Strings.
    AddFun('Dummy', 'GetMemo', UsesControls_GML, 0, [], varObject);
    AddFun('Dummy', 'Sleep', UsesControls_SLP, 1, [varInteger], varEmpty);
  end;

An sich alles bekannt. Nur 2 Unterschiede: Es wurde diesmal keine TstringList, sondern die Klasse TStrings verwendet. Der Grund liegt auf der Hand: Da TMemo.Lines vom Typ TStrings ist, muss es der Typ, der später im Script für den entsprechenden Zugriff verwendet wird, eben auch sein. Stimmt das nicht überein, funktioniert es nicht.

Der andere Unterschied sind die Funktionen AddIDGet und AddIDSet. Diese werden verwendet, um auf die Strings in der in Delphi gewohnten Art und Weise zugreifen zu können: per Index (z.B. Lines[3]). Allerdings erspar ich mir nun wirklich die Erklärung der Parameter, denn das sind eigentlich keine neuen!

Die Funktion GetMemo liefert das MemoFeld der Form zurück. Sleep hält den Thread der Anwendung für ein paar Millisekunden an. Wie lang, das wird per Parameter übergeben. Desweiteren führt die Implementierung von Sleep für diese Demo noch ein Application.ProcessMessages aus, damit die Controls auch aktualisiert werden.

Werfen wir doch gleich einen Blick auf die ID-Funktionen. Diese sind, wie wahrscheinlich bereits erwartet, simpel wie immer:

procedure TStrings_ID_Get(var Value: Variant; Args: TJvInterpreterArgs);
begin
  Value := (Args.Obj as TStrings)[Args.Values[0]];
end;

procedure TStrings_ID_Set(const Value: Variant; Args: TJvInterpreterArgs);
begin
  (Args.Obj as TStrings)[Args.Values[0]] := Value;
end;

Ich weiß, ich wiederhole mich: aber einfach simpel, oder? Wer bis hierher durchgehalten hat, dürfte das nun ohne Probleme verstehen.

Jetzt aber zum eigentlich wirklich Wichtigen: Die Script-Funktion MainFnc:

    Add('procedure MainFnc;                                      ');
    Add('var                                                     ');
    Add('  MemoLines: TStrings;                                  ');
    Add('  i : Integer;                                          ');
    Add('begin                                                   ');
    Add('  InitArray;                                            ');
    Add('                                                        ');
    Add('  MemoLines := GetMemo.Lines;                           ');
    Add('  MemoLines.Clear;                                      ');
    Add('  Sleep(50);                                            ');
    Add('                                                        ');
    Add('  for i := 0 to 5 do                                    ');
    Add('  begin                                                 ');
    Add('    MemoLines.Add(Infos[i]);                            ');
    Add('    Sleep(500);                                         ');
    Add('    MemoLines[i] := MemoLines[i] + '' OK'';             ');
    Add('    Sleep(100);                                         ');
    Add('  end;                                                  ');
    Add('                                                        ');
    Add('end;                                                    ');

Die Funktion InitArray ist im Script selbst implementiert und füllt das Array „Infos“ mit den auszugebenden Werten. Aber jetzt wird es interessant. Wir haben eine Variable „MemoLines“ vom Typ TStrings. Wie bereits erwähnt, liefert GetMemo das MemoFeld der Form zurück. Theoretisch könnte man sich das Memo merken und mit Memo.Lines auf die einzelnen Zeilen zugreifen. Leider funktioniert das nicht, da eine Unterstützung seitens des Interpreters fehlt. Deshalb lasse ich mir „Lines“ mit Hilfe von GetMemo zurückliefern und lege diese in MemoLines ab. Da der Interpreter nun weiß, von welchem Typ MemoLines ist, kann er auch damit umgehen.

So nebenbei: Da GetMemo eigentlich vom Typ TMemo ist (zumindest, was die Rückgabe angeht), kann direkt auf Lines zugegriffen werden. Eine andere Möglichkeit wäre:

    Add('procedure MainFnc;                                      ');
    Add('var                                                     ');
    Add('  MemoLines: TStrings;                                  ');
    Add('  MyMemo   : TMemo;                                     ');
    Add('  i : Integer;                                          ');
    Add('begin                                                   ');
    Add('  InitArray;                                            ');
    Add('                                                        ');
    Add('  MyMemo := GetMemo;                                    ');
    Add('  MemoLines := MyMemo.Lines;                            ');
    ...

Hab ich schon erwähnt, dass Programmierer schreibfaul sind?

Der Rest ist wieder einfach. Ich lasse alle 6 Info-Werte in den Strings ausgeben (nachdem ich den aktuellen Inhalt der Strings geleert habe) und füge nach einer kurzen Pause noch ein OK hinten an. Damit wäre eine Art Log simuliert. Das OK hab ich eigentlich nur hinten angehängt, um aufzuzeigen, dass per Index auf die verschiedenen Zeilen zugegriffen werden kann. Wir haben ja nicht umsonst die entsprechenden Funktionen implementiert.

Sleep macht eine Pause, damit das Ganze nicht zu schnell wirkt. Außerdem wird noch ein Application.ProcessMessages in Sleep ausgeführt, damit die Änderungen der Lines am MemoFeld direkt sichtbar sind. Wie gewohnt ausführen und... cool. Das Script ändert die Zeilen des MemoFeldes!

Abschließendes

Ich gratuliere, ihr habt es geschafft. Ihr seid am Ende des Tutorials angelangt. Naja, Tutorial ist vielleicht ein klein wenig untertrieben. Irgendwie ist das Ganze etwas mutiert. Wie dem auch sein, ab jetzt seid ihr auf euch selbst gestellt. Nur eines, bevor ihr euch unnötig die Finger wund tippt.

Der Interpreter verfügt über eine Liste bereits vorgefertigter Adapter. Laut dem Demoprogramm sind die folgenden Delphi-Units als Adapter verfügbar:

  • JvInterpreter_System
  • JvInterpreter_SysUtils
  • JvInterpreter_Windows (nur ein paar Funktionen und Konstanten)
  • JvInterpreter_Classes
  • JvInterpreter_Controls
  • JvInterpreter_StdCtrls
  • JvInterpreter_ExtCtrls
  • JvInterpreter_Forms
  • JvInterpreter_Dialogs
  • JvInterpreter_Graphics
  • JvInterpreter_Menus
  • JvInterpreter_Grids
  • JvInterpreter_Db
  • JvInterpreter_DbTables
  • JvInterpreter_DBCtrls
  • JvInterpreter_DBGrids
  • JvInterpreter_Quickrpt

Das sollte für die meisten Scripts ausreichen. Die Units basieren auf Delphi 3. Aber sie können einfach neu kompiliert und notfalls angepasst werden. Ich hatte bei diversen Stichproben-Tests keine Probleme. Als kleinen Tipp kann ich noch sagen, dass man eigentlich nur JvInterpreter_All einbinden muss, damit alle Units zur Verfügung stehen.

Jetzt überlasse ich euch aber wirklich eurem Schicksal. Spielt ein bisschen mit der Komponente rum. Es ist zum Beispiel möglich, eine Script-Funktion als OnClick-Ereignis zu verwenden. Oder per OLE Word zu bedienen. Schaut euch einfach mal die Demos der JEDIs an, denn es gibt noch viel zu entdecken...

Ich hoffe, ich konnte euch den Interpreter etwas näherbringen. Und scheut euch nicht, eure Meinung und konstruktive Kritik zu dem Tutorial im Forum abzugeben. Das gilt übrigens für alle Tutorials dieser Seite, da wir auf Feedback wirklich angewiesen sind. Denn wenn sich keiner für die Tutorials interessiert, brauchen wir ja auch keine mehr zu schreiben, oder?

In diesem Sinne – Happy Scripting

Euer

SchodMC



Vorhergehendes Tutorial:
-
Nächstes Tutorial:
Tutorial Scriptsprachen Teil 1

Schreibt was ihr zu diesem Tutorial denkt ins Feedbackforum von DelphiGL.com.
Lob, Verbesserungsvorschläge, Hinweise und Tutorialwünsche sind stets willkommen.