xscDevBlog – LastSharp & Co.

Der xscheme-DevelopmentBlog

Wie entwickle ich meine eigene Scriptsprache? (Teil 3: Syntax)

without comments

Nun, wie weit sind wir bisher? Wir haben einen Lexer, der unsere Eingabe in Einzelteile spaltet, und wir können einfache Ausdrücke in eine verwertbare Form bringen. Was wir bisher noch nicht (bewusst) gemacht haben, ist, einen Ausdruck zu analysieren und zu prüfen, ob er sinnvoll ist oder nur unzusammenhängend aneinandergereihte Token enthält.

Zur Erinnerung: sowohl “1+2+3″ als auch “1+(” wären Zeichenfolgen, die der Lexer akzeptieren und ohne zu meckern zerkleinern würde. Das ist auch in Ordnung so, da die syntaktische Prüfung ohnehin eher Aufgabe des Parsers ist. Aber was genau gilt es denn dabei zu beachten?

“Syntaxfragen”

  • Sind genau so viele öffnende wie schließende Klammern vorhanden und passen diese zusammen?
  • Stimmt die Zahl der Parameter, mit der eine Funktion aufgerufen wird?
  • Stimmen die Parametertypen, mit denen eine Funktion aufgerufen wird? (“+” arbeitet normalerweise nur auf Zahlen, der Punkt z.B. in PHP auf Strings, etc…)
  • Befinden sich Operatoren an den richtigen Positionen? (Die Fakultät einer Zahl wird z.B. durch Anhängen eines Ausrufezeichens gekennzeichnet: “3!”)
  • Darf eine Funktion geschachtelt vorkommen? (z.B. wäre “echo(echo(1))” eher sinnlos, wenn “echo” die Ausgabefunktion ist)
  • Darf eine Funktion/ein Konstrukt in einer untergeordneten Umgebung vorkommen? (Das wäre z.B. der Fall, wenn man Funktionsdefinitionen innerhalb von Funktionsdefinitionen zulässt.)

Neben der Syntax gibt es noch weitere Punkte, die eine Programmier-/Scriptsprache ausmachen. Eine Auflistung findet man z.B. hier.

Einiges ist schon erledigt

Der im vorhergehenden Teil präsentierte Shunting-Yard-Algorithmus nimmt uns die Überprüfung der Klammern, sowie der Position der Parameter bereits ab. Des weiteren ist es unumgänglich zur Erstellung des Syntaxbaumes die Anzahl der Parameter zu kennen, die eine bestimmte Funktion benötigt. Wir können also unsere Liste auf die Hälfte kürzen.

Einiges noch nicht

Der Syntaxbaum selbst macht die Syntaxprüfung recht simpel. Aber zuerst müssen wir für jede Funktion, jeden Operator und jedes Terminalsymbol festlegen, welche syntaxktischen Eigenschaften es hat. Das wird letztlich mittels einer Klasse SyntaxDefinition realisiert werden, die die entsprechenden Daten kapselt.

Wichtig ist hierbei: wir betrachten all die genannten Datentypen als Funktionen, auch die Terminalsymbole. Jede Funktion hat einen Rückgabetyp und beliebig viele Parametertypvarianten. Eine “Zahl” wäre also eine Funktion mit dem Parametertyp “Zahl” und dem Rückgabetyp “Zahl”.

Eine beispielhafte Aufstellung für die Syntax einer Sprache wäre die folgende:

  • Terminalsymbol Integer:
    • Eingabe: Integer
    • Rückgabe: Integer
    • Verschachtelung erlaubt
    • Unterordnung erlaubt
  • Terminalsymbol String:
    • Eingabe: String
    • Rückgabe: String
    • Verschachtelung erlaubt
    • Unterordnung erlaubt
  • Terminalsymbol Identifier:
    • Eingabe: Identifier
    • Rückgabe: Identifier
    • Verschachtelung erlaubt
    • Unterordnung erlaubt
  • Funktion DefConstant:
    • Eingabe: (Identifier, Integer) oder (Identifier, String)
    • Rückgabe: keine
    • Verschachtelung nicht erlaubt
    • Unterordnung erlaubt
  • Operator Plus:
    • Eingabe: (Integer, Integer)
    • Rückgabe: Integer
    • Verschachtelung erlaubt
    • Unterordnung erlaubt

Es gibt aber auch Funktionen, deren Rückgabetyp erst zum Ausführungszeitpunkt feststeht. Bestes Beispiel ist hier die Auswertung von Variablen, die ja Werte verschiedenster Typen enthalten können.

Wie würde also nun die Überprüfung der Typkorrektheit und der Verschachtelung ablaufen? Wie bereits erwähnt nutzen wir hierfür den Syntaxbaum und testen jeden einzelnen Knoten unter Berücksichtigung der Kindknoten:

               operator : +
              /            \
        integer : 1     operator : *
                       /            \
                  integer : 2   function : echo
                                     |
                                constant : e

Für unseren Test entspräche das dem folgenden Baum (Notation: “Rückgabe / Verschachtelung erlaubt?”):

               integer / ja
              /            \
        integer / ja    integer / ja
                       /            \
                  integer / ja  void / nein
                                     |
                                runtime / ja

Die Wurzel (das Plus) erwartet zwei Parameter des Typs “integer”. Eine Überprüfung der Kindknoten zeigt, dass diese genau diesen Rückgabetyp besitzen – also alles in Ordnung , auch die Verschachtelung.
Betrachtet man den rechten Ast weiter, sieht man zwei Probleme: die Multiplikation benötigt zwei “integer”-Parameter, erhält aber “integer” und “void” (void ist der Ausdruck für “keine Rückgabe”); und die “echo”-Funktion kann nicht geschachtelt auftreten, befindet sich aber auf Ebene 2.

Einen dieser Fehler sollte der Parser letztlich ausgeben und abbrechen!

Einiges muss warten

Ob ein Ausdruck in einer untergeordneten Umgebung auftritt, kann ein reiner Parser nicht wissen. Man könnte ihm zwar diesbezügliche Informationen zukommen lassen (und sollte das auch, wenn man vorhat, einen Compiler o.Ä. zu schreiben), der einfachste Ort für diese Überprüfung ist jedoch die Auswertung. (Das ist auch scriptsprachentauglich.)

Was die Parametertypen angeht, die erst bei der Ausführung feststehen, kann man entweder mit regulären Ausdrücken arbeiten und mit deren Hilfe die Teilergebnisse untersuchen, bevor man die übergeordnete Funktion aufruft, oder man überlässt das ganz der jeweiligen Funktion selbst. Geschmacksache.

Spezielle Konstrukte

In jeder Sprache gibt es bestimmte Schlüsselwörter, die spezielle Konstrukte beschreiben/einleiten. Gemeint ist soetwas wie:

def a = 2

Der Shunting-Yard-Algorithmus kommt aber nur mit Operatoren oder mit Funktionen der Form “f(p1, p2, …)” klar.

Kein großes Problem?

Gut, das wäre jetzt kein großes Problem für das oben stehende Beispiel: man definiert “def” und “=” als Operatoren, wobei “def” ein Präfix-Operator mit genau einem Parameter ist und eine höhere Priorität als “=” hat. Der Operator “def” legt eine leere Variable mit dem übergebenen Namen an und liefert diesen Namen als Rückgabewert.
“=” wiederum ist ein Infix-Operator mit zwei Parametern, das die (existierende!) Variable des übergebenen Namens mit dem übergebenen Wert füllt.

Der Syntaxbaum für oben genannten Ausdruck wäre dann:

              operator : =
              /          \
     operator : def   integer : 2
             |
     identifier : a

Und schon hätten wir das  gewünschte Verhalten, allerdings auf Kosten der Übersichtlichkeit in der Implementierung, sowie eines sehr großen Freiraums in der Grammatik. Immerhin wäre so etwas dann auch erlaubt:

(((((def a))))) = 2
def b = def a

Ein Verhindern von Verschachtelungen ist hier nicht mehr möglich.

Bessere Lösung

Wir brauchen einen Mechanismus, der aus “def a = 2″ so etwas macht wie “def(a, 2)”, also einen regulären Funktionsaufruf. Es handelt sich hierbei um eine Mustererkennung auf Basis bereits gelesener Token, d.h. die eigentliche Umwandlung findet nach dem erstmaligen Einlesen der Eingabesequenz durch den Lexer statt:

keyword : def
identifier : a
operator : =
integer : 2

wird zu

function : def
open : (
identifier : a
comma : ,
integer : 2
close : )

In meinen Augen bietet sich hierfür wieder  der in Teil 1 unter Alternative 4 vorgestellte Algorithmus an, der z.B. mit einem Automaten arbeiten könnte, der die folgende Akzeptanzfunktion besitzt (Vorsicht: Pseudocode!):

private int tokenNumber = -1;
public bool accept(Token t)
{
    tokenNumber++;
    return
        (tokenNumber == 0 && "t ist vom Typ 'keyword' und hat den Wert 'def'") ||
        (tokenNumber == 1 && "t ist vom Typ 'identifier'") ||
        (tokenNumber == 2 && "t ist vom Typ 'operator' und hat den Wert '='") ||
        tokenNumber > 2;
}

Gleichzeitig müssten irgendwo die relevanten Token gesichert werden, damit die Umwandlung in eine Funktion später auch reibungslos vonstatten gehen kann.

Ich würde sagen, damit haben wir eine vernünftige Lösung gefunden.

Fazit

Wir können nun unseren Syntaxbaum auf syntaktische Merkmale hin untersuchen und spezielle Ausdrücke und Konstrukte berücksichtigen. Langsam sollten wir uns also an die Ausführung eines Ausdrucks machen.

Ein in diesem Artikel häufig verwendetes Wort war “Umgebung”. Es handelt sich dabei um den Speicher für Variablen, Funktionen, etc… Ohne diesen ist die Entwicklung einer Scriptsprache relativ witzlos, weswegen wir an genau dieser Stelle weitermachen werden.

Inhalt

  1. Einführung: Ein Abenteuer in Teilen
  2. Der Lexer
  3. Grundlagen des Parsens
  4. Syntax

Written by WordPress

August 30th, 2009 at 1:26 pm

Leave a Reply