weissensteiner.dev

09. Mai 2025

Implizite Syntax

Für meine Programmiersprache wollte ich eine möglichst minimale Syntax. Primär ging es mir darum, möglichst wenige spezielle Sonderzeichen (;, :, ...) oder Schlüsselwörter zu verwenden. Hier beschreibe ich einen theoretischen Ansatz bzw. Entwurf, wie man dabei vorgehen könnte ...

Hierbei habe ich zwei Bereiche festgelegt, die ganz allgemein voneinander getrennt werden müssen: Blöcke und Statements.

Blöcke und Statements

Ausdrücke sind einfach nur eine Ansammlung von Tokens, die auf eine bestimmte Art und Weise angeordnet werden müssen. Besagte Tokens teilen wir hier zwischen Operanden und Operatoren auf.

Statements definiere ich jetzt einfach mal so, dass sie jedoch für sich "alleine stehen können". Das können nämlich Ausdrücke (4 * x + 5) nicht! Das entsprechende Statement wäre z.B.: y = 4 * x + 5. Ausnahmen bilden hierbei Funktionsaufrufe und spezielle Anweisungen in Verbindung mit Schlüsselwörtern. Diese Unterscheidung ist im späteren Verlauf sehr wichtig, wobei ich hier meistens von "Statements" sprechen werde, obwohl streng genommen Ausdrücke gemeint sind.

Blöcke dagegen beinhalten Statements, welche dann in Konstrukten wie einer Funktion, Schleifen, usw. verschachtelt sind.

Statements trennen

Die meisten Programmiersprachen machen sich es insofern einfach, dass sie ein spezielles Zeichen festlegen, um Statements zu trennen.

// Entweder so ...
x = 5;
y = 4;

// Oder auch so ...
x = 5; y = 3;

Der Parser weiß hier, dass z.B. x = 5 in sich abgeschlossen sein muss.

Hat man nicht so ein spezielles Zeichen, dann muss der Parser selbst wissen, wann so ein Statement abgeschlossen sein "könnte". Hierbei kann sich der Parser eben auch nicht auf das Zeilenende verlassen (also das Zeilenende markiert NICHT das Ende Statements), da es sein kann, dass Statements in der nächsten Zeilen weitergehen (hier wird ein Zeilenende wie ein Leerzeichen behandelt).

Mit ein paar Regeln, die man mit Operanden mit Operatoren aufstellt, kann der Parser selbst wissen, wann ein Statement terminiert.

Operanden

Operanden sind z.B. 500, 0x200, x, "hallo, welt", usw.

Ein (alleinstehender) Operand an sich bildet bereits einen gültigen Ausdruck, aber man kann sagen, dass Operanden stets den nächsten binären Operatoren "an sich binden" können. Wenn das passiert, führt der Parser das Statement fort. Operanden können aber nicht den nächsten Operanden an sich binden, damit ist das Statement vorbei, weil <Operand> <Operand> kein gültiges Statement produziert.

Operatoren

Bei Operatoren gibt es zwei Arten von Operatoren: Binäre und unäre Operatoren. Binäre Operatoren haben links und rechts einen Operanden: x + 3. Unäre Operatoren haben nur rechts einen Operanden: -x. Kleines Beispiel:

x + 3

x bindet "+" an sich und "+" bindet wiederum "3" an sich. Jedoch gilt zu beachten, dass ein + (egal ob un- oder binär) immer einen Operanden fordert.

Es gibt auch Operatoren, die je nach Kontext unär oder binär sind: -3 (negatives Vorzeichen), x - 3 (Subtraktion).

x = 5
-2

Dieser Code hätte nun zwei Lesarten: x = 5 - 2 und x = 5 mit -2. Wir haben jedoch festgelegt, dass Statements für sich alleine stehen können, und Ausdrücke eben nicht. 5 würde also -2 an sich binden, und damit ist nur die erste Lesart die richtige.

Ein ähnliches Problem gibt es mit ++ bzw. --, die entweder als Postfix oder Präfix genutzt werden können:

x = y
++z

Hier würde y das ++ an sich binden und z alleine produziert kein Statement. Hier muss eine Alternative geschaffen werden oder der Operator gänzlich entfernt werden.

Generell sind hier auch Lösungen zu präferieren, die nicht versuchen eine Ambiguität aufzulösen (etwa weil z kein gültiges Statement produziert, und es daher anders "gemeint sein muss").

Programmieransatz

Der Compiler besteht zunächst zumindest aus einem Lexer und Parser.

Der Lexer hat hier eine Funktion next(), um den nächsten Token aus dem Quellcode zu holen. Desweiteren gibt es einen "Cache", um den letzten Token zurückzulegen. Der Token, der im "Cache" liegt, wird beim nächsten Aufruf von next() sofort zurückgegeben. Die Funktion des Token-Zurücklegens könnte nennen wir put_back().

Andere Implementierung haben oft eine peek()-Funktion. Hier wird der nächste Token zwar "geholt", jedoch nicht "konsumiert"! Die beschriebene Vorgehensweise verfolgt also eher einen optimistischen Ansatz.

Der Lexer wird regelmäßig vom Parser aufgerufen. Der Parser produziert so lange Ausdrücke (durch die Tokens, die der Lexer liefert), bis der produzierte Ausdruck ungültig sein würde, z.B. (wie oben beschrieben) wenn zwei Operanden aufeinander folgen. In so einem Fall haben wir ein gültiges, maximales Statement (oder zumindest Ausdruck) gefunden und können den letzten Token mit put_back() zurücklegen.

Blöcke trennen

Mit den Grundlagen von den Statements können wir uns jetzt ansehen, wie wir Blöcke voneinander trennen können. Blöcke sind nichts anderes als eine Sammlung von Statements und weiteren/verschachtelten Blöcken.

Beispiel: if-Abfragen

if(x % 2 == 0) {
    ...
}

Der obige Code hat zwei Klammerpaare, wobei (...) eine ähnliche Funktion wie bei den Statements erfüllt, nämlich dass der Ausdruck darin abgeschlossen sein muss. Da wir bereits wissen, wie wir durch den Kontext feststellen können, ob der Ausdruck abgeschlossen ist, brauchen wir die runden Klammern sowieso nicht.

Für { gilt dasselbe, da der darauffolgende Block bereits implizit eröffnet wird.

Bei } wird es allerdings komplizierter. Tatsächlich gibt es nicht direkt eine Möglichkeit zu erkennen, wann ein Block aufhört - das ergibt sich nicht aus dem Kontext heraus! Das verdeutlicht folgendes Beispiel (Syntaxänderungen inkludiert):

// 1. Lesart
if x % 2 == 0
    y = 10
if z % 2 == 0
    x = 10

// 2. Lesart
if x % 2 == 0
    y = 10
    if z % 2 == 0
        x = 10
    

Aus dem Kontext ergibt sich nicht, ob der zweite if-Block im ersten ist, da beide Varianten gültig sind (Vergleich mit Statements: Wo so viele Tokens aufgesammelt werden, bis kein gültiges Statement mehr produziert wird).

Sprachen, die kein spezielles Symbol oder Schlüsselwort haben, greifen meistens auf Tabs zurück (bzw. die Einrückung ergibt sich durch das Format). Für meine Programmiersprache habe ich mich für das Schlüsselwort end entschieden.

if x % 2 == 0
    y = 10
end

if z % 2 == 0
    x = 10
end