image: teaser.webp image_alt: "Drei Syntaxdiagramme in grauen Linien: expression mit einem Kasten term und einem Weg zurück über ein +, term mit einem Kasten factor und einem Weg zurück über ein *, darunter factor mit zwei Wegen, einer durch number, einer durch eine runde Klammer auf, einen violetten Kasten expression und eine runde Klammer zu." kind: projekt language: Ruby / Python languages: [ruby, python] duration: 2 Doppelstunden curriculum: "Oberstufe: Syntax und Semantik (Grammatik, EBNF, Syntaxdiagramme, Syntaxbaum, rekursiver Abstieg), Sprachen und Automaten (natürliche und formale Sprachen, kontextfreie Grammatiken), Vertiefung Theoretische Informatik (Chomsky-Hierarchie, Mehrdeutigkeit)" topics: [grammatiken, kellerautomaten] requires: [kellerautomat] reference: [automaten, listen, ruby-python]

Grammatiken

Woher weiß Python, dass print((1 + 2) * 3) ein Programm ist und print((1 + 2) *) nicht? Hinter jeder Programmiersprache stehen Regeln, die festlegen, welche Texte dazugehören: eine Grammatik. Du liest und schreibst Grammatiken in EBNF und als Syntaxdiagramme und baust in Ruby oder Python aus einer Grammatik ein Programm, das Ausdrücke wie 2+3*(4+5) prüft, mit einer Funktion für jede Regel.

In Kellerautomaten hast du Klammern mit einer Maschine geprüft: Sie liest ein Wort Zeichen für Zeichen, legt Klammern auf einen Stack und sagt am Ende Ja oder Nein. Eine Grammatik beschreibt eine Sprache von der anderen Seite: mit Regeln, nach denen man alle Wörter der Sprache bauen kann. Jede Programmiersprache hat eine. Für Python steht sie vollständig in der Dokumentation, unter Full Grammar specification, und das Datenformat JSON ist auf json.org ganz als Bilder solcher Regeln beschrieben, als Syntaxdiagramme.

1. Einrichten

Lege im Terminal (StrgJ) einen Ordner an und öffne ihn mit StrgK StrgO:

mkdir /workspace/grammatiken

Alle Dateien dieser Aktivität kommen in diesen Ordner. Ein Programm startest du im Terminal mit ruby und dem Namen der Datei.

Alle Dateien dieser Aktivität kommen in diesen Ordner. Ein Programm startest du im Terminal mit python3 und dem Namen der Datei.

2. Regeln, die Sätze bauen

Eine Grammatik für eine winzige Sprache, deren Sätze aus drei Wörtern bestehen:

sentence = subject verb object
subject  = "Pip" | "Bello"
verb     = "findet" | "sucht"
object   = "Bello" | "die Lampe" | "den Weg"

Jede Zeile ist eine Regel. Links vom = steht ein Name, ein Nichtterminal: Es steht für einen Teil des Satzes, der noch gebaut werden muss. In Anführungszeichen stehen Terminale, die so, wie sie sind, im Satz landen. | heißt »oder«. Die erste Regel ist die, mit der man anfängt; ihr Name heißt Startsymbol. Die Namen im Code sind englisch, so wie die Namen der Funktionen, die du gleich daraus machst.

Einen Satz baut man, indem man beim Startsymbol beginnt und immer ein Nichtterminal durch das ersetzt, was rechts in seiner Regel steht, bei | durch eine der Möglichkeiten. Sind nur noch Terminale übrig, ist der Satz fertig. Diese Kette heißt Ableitung:

sentence
⇒ subject verb object
⇒ "Pip" verb object
⇒ "Pip" "findet" object
⇒ "Pip" "findet" "die Lampe"

Alle Sätze, die sich so ableiten lassen, sind die Sprache der Grammatik.

Aufgabe 1Wie viele Sätze?

Wie viele verschiedene Sätze lassen sich mit der Grammatik oben ableiten, sentence als Startsymbol?

Dieselbe Ableitung als Baum heißt Syntaxbaum. Oben steht das Startsymbol, darunter, wodurch es ersetzt wurde. Nichtterminale stehen in eckigen Kästen, Terminale in runden; von links nach rechts gelesen ergeben die runden Kästen den Satz:

sentence(subject("Pip") verb("findet") object("die Lampe"))

Die Grammatik erlaubt auch »Bello findet Bello«. Der Satz ist richtig gebaut, aber ob er etwas Sinnvolles sagt, steht nicht in der Grammatik. Wie ein Text gebaut ist, heißt seine Syntax, was er bedeutet, seine Semantik. In Programmen ist es genauso: print(1 / 0) ist nach der Grammatik von Python ein richtiges Programm, und erst beim Ausführen stellt sich heraus, dass es nichts bedeutet, was der Rechner tun könnte.

Diese Schreibweise geht auf John Backus zurück. 1959 schlug er sie vor, um die Programmiersprache ALGOL festzulegen, und Peter Naur benutzte sie 1960 im Bericht über ALGOL 60, den er herausgab. Seitdem heißt sie Backus-Naur-Form, kurz BNF.

John Backus (1924–2007), amerikanischer Informatiker. Ab 1954 leitete er bei IBM das Team, das Fortran und den ersten Fortran-Compiler baute: Statt Maschinenbefehle schrieb man nun Formeln wie x = a * b + c, und der Compiler machte daraus ein Programm, das fast so schnell lief wie eines von Hand. Wettermodelle wie das des Deutschen Wetterdiensts rechnen bis heute in Fortran.

»Vieles an meiner Arbeit kommt daher, dass ich faul war«, sagte er später: Er schrieb nicht gern Programme, also baute er etwas, das es leichter macht. Die Schreibweise, mit der man festlegt, wie eine Programmiersprache aussieht, heißt nach ihm Backus-Naur-Form.

Peter Naur 2008: ein älterer Mann mit weißem Haar, weißem Bart und Brille, im grünen Pullover, spricht in ein Mikrofon.

Peter Naur (1928–2016), dänischer Informatiker, zuerst Astronom. 1960 gab er den Bericht über die Programmiersprache ALGOL 60 heraus und legte darin die ganze Sprache mit der Schreibweise von John Backus fest, die seitdem Backus-Naur-Form heißt. Dafür bekam er 2005 den Turing Award.

Das Wort »computer science« mochte er nicht und schlug »datalogi« vor; in Dänemark und Schweden heißt die Informatik bis heute so.

Foto: Eriktj, 2008, CC BY-SA 3.0, zugeschnitten (Wikimedia Commons)

Report on the Algorithmic Language ALGOL 60, herausgegeben von Peter Naur, Communications of the ACM, 1960. Der Bericht, der eine ganze Programmiersprache auf wenigen Seiten in BNF festlegt; unter seinen Autoren sind auch Bauer und Samelson, die den Keller erfanden. Bei der ACM, und die überarbeitete Fassung von 1963 zum Lesen im Browser.

3. Wörter ohne Ende

Die Sprache der Sätze oben hat nur endlich viele Wörter. Für eine Programmiersprache reicht das nicht: Es gibt unendlich viele Programme. Dafür hat die Schreibweise drei Klammern, mit denen auch Ein Rechner für Ausdrücke arbeitet:

In der Regel heißt
{ … } beliebig oft, auch keinmal
[ … ] einmal oder keinmal
( … ) zusammen, etwa ( "+" | "-" ) für ein Plus oder ein Minus

Weil die Klammern zur Schreibweise gehören, steht eine Klammer, die selbst im Wort vorkommt, in Anführungszeichen: "(" ist ein Terminal, ( gehört zur Regel. Mit diesen Klammern heißt die Schreibweise EBNF, erweiterte Backus-Naur-Form; vorgeschlagen hat sie Niklaus Wirth 1977.

What can we do about the unnecessary diversity of notation for syntactic definitions?, Niklaus Wirth, Communications of the ACM, 1977. Darin schlägt Wirth EBNF vor, mit { }, [ ] und ( ), weil fast jedes Buch über Programmiersprachen seine eigene Schreibweise für Grammatiken hatte: bei der ACM.

Die Klammerwörter aus dem Kellerautomaten, (), (())() und so weiter, beschreibt eine einzige Regel:

brackets = { "(" brackets ")" }

Ein Klammerwort ist eine Reihe von Klammerpaaren, beliebig viele, und in jedem Paar steht wieder ein Klammerwort. Die Regel benutzt sich selbst, sie ist rekursiv. So beschreibt eine Grammatik mit einer einzigen Zeile unendlich viele Wörter. Der Syntaxbaum von (())(): Das äußere brackets nimmt zwei Paare, das linke enthält ein Paar, alle anderen keins.

brackets("(" brackets("(" brackets ")") ")" "(" brackets ")")

4. Syntaxdiagramme

Niklaus Wirth hat Grammatiken auch gezeichnet, zuerst für seine Sprache Pascal. Ein Syntaxdiagramm liest man von links nach rechts, die Linien entlang: Wo sich ein Weg teilt, wählst du einen. Ein runder Kasten ist ein Terminal, das so im Wort steht. Ein eckiger ist ein Nichtterminal: Dort gehst du durch dessen eigenes Diagramm und kommst danach zurück. So sieht eine Grammatik für Zahlen aus:

number = digit { digit }
digit  = "0" | "1" | … | "9"
number = digit { digit }
digit  = "0" | "1" | … | "9"

Bei number geht es einmal durch digit und danach unten zurück, so oft du willst. So zeichnete Wirth x { x }: Ein Pfeil auf dem Weg zurück zeigt die Richtung. Die … stehen für die Ziffern 2 bis 8, die das Bild auslässt.

Schwarzweißfoto des jungen Niklaus Wirth: ein lachender Mann mit hellem Haar, zur Seite blickend.

Niklaus Wirth (1934–2024), Schweizer Informatiker an der ETH Zürich. Er entwarf Pascal als Sprache, an der man sauberes Programmieren lernt. Eines seiner Bücher heißt Algorithms + Data Structures = Programs: Ein Programm, das sind Algorithmen plus Datenstrukturen.

1995 warb er für schlanke Software und zitierte einen Kollegen: Software werde schneller langsamer, als die Hardware schneller wird. Der Satz heißt heute Wirthsches Gesetz. 1984 bekam er den Turing Award, den wichtigsten Preis der Informatik.

Foto: Robert M. McClure, CC BY-SA 3.0, zugeschnitten (Wikimedia Commons)

Ein Syntaxdiagramm und eine Regel in EBNF sagen dasselbe; man kann jedes in das andere übersetzen. Diese Zahlen haben ein Vorzeichen, wenn man will, und ein Komma, wie man sie im Deutschen schreibt:

decimal = [ "-" ] digit { digit } [ "," digit { digit } ]
Aufgabe 2Ein Diagramm lesen

Welches dieser Wörter beschreibt das Syntaxdiagramm decimal nicht?

Ein Diagramm lesen

Jetzt in die andere Richtung. Dieses Diagramm beschreibt Listen von Zahlen zwischen spitzen Klammern, mit Semikolons dazwischen:

list = "<" [ number { ";" number } ] ">"
Aufgabe 3Vom Diagramm zur Regel

Welche Regel in EBNF beschreibt genau das Syntaxdiagramm list, also genau dieselben Wörter?

Vom Diagramm zur Regel

5. Ein Programm für jede Regel

Aus einer Grammatik lässt sich fast von selbst ein Programm machen, das prüft, ob ein Wort zur Sprache gehört. Jede Regel wird eine Funktion. Sie bekommt das Wort und einen Index i und liest ab dort, was ihre Regel erlaubt. Zurück gibt sie den Index hinter dem, was sie gelesen hat, und wenn das Wort an der Stelle nicht passt, nichts. Ein Nichtterminal in der Regel wird ein Aufruf seiner Funktion. Eine kleine Hilfsfunktion peek zeigt das nächste Zeichen, ohne es zu lesen; am Ende des Worts gibt sie einen leeren String zurück.

So wird jedes Stück einer Regel zu Code:

In der Regel In der Funktion
ein Terminal, etwa "(" Ist peek(word, i) genau dieses Zeichen, geht es bei i + 1 weiter; sonst passt das Wort nicht.
ein Nichtterminal, etwa brackets i = brackets(word, i); passt es nicht, passt auch das Ganze nicht.
{ … } eine Schleife: solange das nächste Zeichen eines ist, mit dem … anfangen kann
[ … ] ein if mit derselben Frage
a | b if und else: Das nächste Zeichen entscheidet, ob a oder b kommt.

Lege brackets.rb an:

# Wörter aus Klammerpaaren, nach der Grammatik
#   brackets = { "(" brackets ")" }
# Jede Funktion liest ab dem Index i, was ihre Regel erlaubt, und gibt den
# Index dahinter zurück, oder nil, wenn das Wort dort nicht passt.

# Das Zeichen am Index i, am Ende des Worts ein leerer String
def peek(word, i)
    word[i] || ''
end

# brackets = { "(" brackets ")" }
def brackets(word, i)
    while peek(word, i) == '('
        i = brackets(word, i + 1)
        return nil if i.nil? || peek(word, i) != ')'
        i += 1
    end
    i
end

# Das Wort gehört dazu, wenn brackets es bis zum Ende liest
def accepts(word)
    brackets(word, 0) == word.length
end

['(())()', '(()', '())', ''].each do |word|
    puts "#{word.inspect} #{accepts(word)}"
end

word[i] ist hinter dem Ende des Worts nil, darum gibt peek dort '' zurück. Die Schleife in brackets ist das { … } der Regel: Solange eine ( kommt, liest sie ein Paar. Nach der ( ruft brackets sich selbst auf, für das Klammerwort im Paar, und danach muss eine ) kommen. Starte es mit ruby brackets.rb:

Lege brackets.py an:

# Wörter aus Klammerpaaren, nach der Grammatik
#   brackets = { "(" brackets ")" }
# Jede Funktion liest ab dem Index i, was ihre Regel erlaubt, und gibt den
# Index dahinter zurück, oder None, wenn das Wort dort nicht passt.

# Das Zeichen am Index i, am Ende des Worts ein leerer String
def peek(word, i):
    return word[i] if i < len(word) else ''


# brackets = { "(" brackets ")" }
def brackets(word, i):
    while peek(word, i) == '(':
        i = brackets(word, i + 1)
        if i is None or peek(word, i) != ')':
            return None
        i += 1
    return i


# Das Wort gehört dazu, wenn brackets es bis zum Ende liest
def accepts(word):
    return brackets(word, 0) == len(word)


for word in ['(())()', '(()', '())', '']:
    print(repr(word), accepts(word))

word[i] gäbe hinter dem Ende des Worts einen Fehler, darum fragt peek erst nach der Länge. Die Schleife in brackets ist das { … } der Regel: Solange eine ( kommt, liest sie ein Paar. Nach der ( ruft brackets sich selbst auf, für das Klammerwort im Paar, und danach muss eine ) kommen. Starte es mit python3 brackets.py:

"(())()" true
"(()" false
"())" false
"" true

Bei ()) liest brackets ein Paar und hört vor der zweiten ) auf, weil danach keine ( mehr kommt. Es gibt 2 zurück, das Wort ist aber 3 Zeichen lang: Darum prüft accepts, ob das ganze Wort gelesen ist.

Vermutung 4Wie tief geht es?

Wie viele Aufrufe von brackets sind bei accepts('((()))') höchstens gleichzeitig offen, also angefangen und noch nicht zurückgekehrt?

Hier geht es weiter, sobald du deine Antwort oben abgegeben hast.

Vier. Bei der ersten ), am Index 3, ist jeder Aufruf, der eine ( gelesen hat, noch offen und wartet auf seine ), und der Aufruf für das innerste Klammerwort liest gerade:

Das Wort ((())) in sechs Feldern mit den Indizes 0 bis 5; die drei öffnenden Klammern sind grau, die erste schließende am Index 3 ist violett umrandet, darunter steht i = 3. Ein Pfeil führt zu einem Stapel rechts: unten accepts(word), darüber brackets(word, 0), brackets(word, 1), brackets(word, 2) und oben, violett, brackets(word, 3).

Wie viele Aufrufe offen sind, merkt sich das Programm auf dem Stack der Aufrufe: Jeder Aufruf, der noch nicht fertig ist, liegt darauf, bis er zurückkehrt. Das ist der Stack des Kellerautomaten, nur dass jetzt Aufrufe darauf liegen statt Klammern. Eine Regel, die sich selbst aufruft, braucht ihn; ein endlicher Automat hat keinen.

Jetzt du: Zahlen

Lege number.rb mit diesem Anfang an; peek kopierst du aus brackets.rb:

# Zahlen aus Ziffern, nach der Grammatik
#   number = digit { digit }
#   digit  = "0" | "1" | … | "9"

# Das Zeichen am Index i, am Ende des Worts ein leerer String
def peek(word, i)
    word[i] || ''
end

# hier: digit und number

p number('123', 0)
p number('12a', 0)
p number('a1', 0)
Jetzt bist du dran: Schreib die Funktionen digit(word, i) und number(word, i) nach ihren Regeln. digit liest eine Ziffer, number so viele Ziffern, wie hintereinander kommen, mindestens eine. Beide geben den Index dahinter zurück, oder nil. Am Ende soll das Programm 3, 2 und nil ausgeben.
Tipp

Geh 12a von Hand durch, ab dem Index 0: Am Index 0 steht eine Ziffer, am Index 1 auch, am Index 2 keine mehr. Also hört number vor dem Index 2 auf und gibt 2 zurück. Bei a1 steht am Index 0 keine Ziffer; dann ist es keine Zahl.

Nächster Tipp

Ob ein Zeichen eine Ziffer ist, sagt peek(word, i).between?('0', '9'); für den leeren String am Ende ist das false. number = digit { digit } heißt: einmal digit, und wenn das nil gibt, nil zurückgeben; danach eine Schleife, solange die nächste Ziffer kommt, wie in brackets.

Nächster Tipp

Ein Gerüst:

def digit(word, i)
    return nil unless …
    i + 1
end

def number(word, i)
    i = digit(word, i)
    return nil if i.nil?
    i = digit(word, i) while …
    i
end
Lösung anzeigen
# digit = "0" | "1" | … | "9"
def digit(word, i)
    return nil unless peek(word, i).between?('0', '9')
    i + 1
end

# number = digit { digit }
def number(word, i)
    i = digit(word, i)
    return nil if i.nil?
    i = digit(word, i) while peek(word, i).between?('0', '9')
    i
end

Lege number.py mit diesem Anfang an; peek kopierst du aus brackets.py:

# Zahlen aus Ziffern, nach der Grammatik
#   number = digit { digit }
#   digit  = "0" | "1" | … | "9"

# Das Zeichen am Index i, am Ende des Worts ein leerer String
def peek(word, i):
    return word[i] if i < len(word) else ''


# hier: digit und number

print(number('123', 0))
print(number('12a', 0))
print(number('a1', 0))
Jetzt bist du dran: Schreib die Funktionen digit(word, i) und number(word, i) nach ihren Regeln. digit liest eine Ziffer, number so viele Ziffern, wie hintereinander kommen, mindestens eine. Beide geben den Index dahinter zurück, oder None. Am Ende soll das Programm 3, 2 und None ausgeben.
Tipp

Geh 12a von Hand durch, ab dem Index 0: Am Index 0 steht eine Ziffer, am Index 1 auch, am Index 2 keine mehr. Also hört number vor dem Index 2 auf und gibt 2 zurück. Bei a1 steht am Index 0 keine Ziffer; dann ist es keine Zahl.

Nächster Tipp

Ob ein Zeichen eine Ziffer ist, sagt '0' <= peek(word, i) <= '9'; für den leeren String am Ende ist das False. number = digit { digit } heißt: einmal digit, und wenn das None gibt, None zurückgeben; danach eine Schleife, solange die nächste Ziffer kommt, wie in brackets.

Nächster Tipp

Ein Gerüst:

def digit(word, i):
    if not …:
        return None
    return i + 1


def number(word, i):
    i = digit(word, i)
    if i is None:
        return None
    while …:
        i = digit(word, i)
    return i
Lösung anzeigen
# digit = "0" | "1" | … | "9"
def digit(word, i):
    if not '0' <= peek(word, i) <= '9':
        return None
    return i + 1


# number = digit { digit }
def number(word, i):
    i = digit(word, i)
    if i is None:
        return None
    while '0' <= peek(word, i) <= '9':
        i = digit(word, i)
    return i
Aufgabe 5Zahlen nach ihrer Regel

Schreib in number.rb die Funktionen digit(word, i) und number(word, i) nach den Regeln digit = "0" | "1" | … | "9" und number = digit { digit }. Beide lesen ab dem Index i und geben den Index hinter dem Gelesenen zurück, oder nil, wenn dort keine Ziffer steht. Die Prüfung ruft sie mit eigenen Wörtern auf.

Tipp

Geh 12a von Hand durch, ab dem Index 0: Wo steht die erste Ziffer, wo die letzte? Welchen Index soll number zurückgeben?

Nächster Tipp

digit fragt peek nach dem Zeichen und gibt i + 1 zurück, wenn es zwischen '0' und '9' liegt. number ruft digit einmal auf und dann in einer Schleife, solange die nächste Ziffer kommt.

Aufgabe 5Zahlen nach ihrer Regel

Schreib in number.py die Funktionen digit(word, i) und number(word, i) nach den Regeln digit = "0" | "1" | … | "9" und number = digit { digit }. Beide lesen ab dem Index i und geben den Index hinter dem Gelesenen zurück, oder None, wenn dort keine Ziffer steht. Die Prüfung ruft sie mit eigenen Wörtern auf.

Tipp

Geh 12a von Hand durch, ab dem Index 0: Wo steht die erste Ziffer, wo die letzte? Welchen Index soll number zurückgeben?

Nächster Tipp

digit fragt peek nach dem Zeichen und gibt i + 1 zurück, wenn es zwischen '0' und '9' liegt. number ruft digit einmal auf und dann in einer Schleife, solange die nächste Ziffer kommt.

6. Ausdrücke

Jetzt eine Sprache, die schon nach Programmieren aussieht: Ausdrücke aus Zahlen mit +, * und runden Klammern, ohne Leerzeichen, etwa 2+3*4 oder (1+2)*3.

expression = term { "+" term }
term       = factor { "*" factor }
factor     = number | "(" expression ")"
expression = term { "+" term }
term       = factor { "*" factor }
factor     = number | "(" expression ")"

Ein Ausdruck ist eine Summe aus Termen, ein Term ein Produkt aus Faktoren, und ein Faktor ist eine Zahl oder wieder ein ganzer Ausdruck in Klammern. Dass Multiplizieren stärker bindet als Plus, steht damit in der Grammatik selbst: Ein Term sammelt alle Faktoren mit * ein, bevor das nächste + drankommt. Der Syntaxbaum von 2+3*4 zeigt es: Das * hängt tiefer, in einem Term, und wird zuerst gerechnet.

expression(term(factor(number("2"))) "+" term(factor(number("3")) "*" factor(number("4"))))

Die Zahlen sind hier Blätter; ihre Ziffern bis hinunter zu digit lässt der Baum weg.

Kopiere number.rb nach expression.rb, zum Beispiel mit cp number.rb expression.rb im Terminal, und ersetze die Zeilen am Ende durch diese:

['2+3*4', '(1+2)*3', '2+', '(1+2', '2*+3'].each do |word|
    puts "#{word.inspect} #{accepts(word)}"
end

Kopiere number.py nach expression.py, zum Beispiel mit cp number.py expression.py im Terminal, und ersetze die Zeilen am Ende durch diese:

for word in ['2+3*4', '(1+2)*3', '2+', '(1+2', '2*+3']:
    print(repr(word), accepts(word))
Aufgabe 6Ausdrücke prüfen

Schreib in expression.rb die Funktionen expression, term und factor nach den drei Regeln oben, jede mit (word, i) wie number, und accepts(word), die true zurückgibt, wenn word ein ganzer Ausdruck ist. Die Prüfung ruft accepts mit eigenen Wörtern auf, ohne Leerzeichen.

Tipp

Geh (1+2)*3 von Hand durch: expression ruft term auf, term ruft factor auf. Was sieht factor als Erstes? Welche Funktion liest das 1+2 in der Klammer?

Nächster Tipp

expression sieht aus wie number: einmal term, dann eine Schleife, solange das nächste Zeichen ein + ist; in der Schleife term ab dem Zeichen hinter dem +. term genauso mit factor und *. factor fragt peek: Bei ( liest es einen Ausdruck und danach die ), sonst eine Zahl.

Nächster Tipp

Ein Gerüst für zwei der Funktionen:

def expression(word, i)
    i = term(word, i)
    while !i.nil? && peek(word, i) == '+'
        i = …
    end
    i
end

def factor(word, i)
    return … unless peek(word, i) == '('
    i = expression(word, i + 1)
    return nil if i.nil? || peek(word, i) != ')'
    …
end
Aufgabe 6Ausdrücke prüfen

Schreib in expression.py die Funktionen expression, term und factor nach den drei Regeln oben, jede mit (word, i) wie number, und accepts(word), die True zurückgibt, wenn word ein ganzer Ausdruck ist. Die Prüfung ruft accepts mit eigenen Wörtern auf, ohne Leerzeichen.

Tipp

Geh (1+2)*3 von Hand durch: expression ruft term auf, term ruft factor auf. Was sieht factor als Erstes? Welche Funktion liest das 1+2 in der Klammer?

Nächster Tipp

expression sieht aus wie number: einmal term, dann eine Schleife, solange das nächste Zeichen ein + ist; in der Schleife term ab dem Zeichen hinter dem +. term genauso mit factor und *. factor fragt peek: Bei ( liest es einen Ausdruck und danach die ), sonst eine Zahl.

Nächster Tipp

Ein Gerüst für zwei der Funktionen:

def expression(word, i):
    i = term(word, i)
    while i is not None and peek(word, i) == '+':
        i = …
    return i


def factor(word, i):
    if peek(word, i) != '(':
        return …
    i = expression(word, i + 1)
    if i is None or peek(word, i) != ')':
        return None
    return …
Ausprobieren: Schreib die Regel für Ausdrücke einmal anders, so wie man es sagen würde: Ein Ausdruck ist ein Ausdruck, ein + und ein Term, also expression = expression "+" term | term. Lass dazu expression als Erstes expression(word, i) aufrufen und starte das Programm. Achte darauf, welcher Fehler kommt und welche Funktion in den Zeilen darüber immer wieder steht. Stell expression danach wieder zurück.
Ausprobieren: Schreib die Regel für Ausdrücke einmal anders, so wie man es sagen würde: Ein Ausdruck ist ein Ausdruck, ein + und ein Term, also expression = expression "+" term | term. Lass dazu expression als Erstes expression(word, i) aufrufen und starte das Programm. Achte darauf, welcher Fehler kommt und welche Funktion in den Zeilen darüber immer wieder steht. Stell expression danach wieder zurück.

Eine Funktion, die sich als Erstes selbst aufruft, mit demselben Index, kommt nie zu einem Zeichen: Sie ruft sich auf, bis der Stack der Aufrufe voll ist. Darum schreibt man solche Regeln in EBNF mit { … }, als Schleife. Ein Programm, das so für jede Regel eine Funktion hat und die Funktionen sich gegenseitig aufrufen lässt, heißt rekursiver Abstieg: Es steigt von der Regel für das Ganze zu den Regeln für die Teile hinab.

7. Ein Ausdruck, zwei Bäume

Warum so umständlich mit term und factor? Kürzer ginge es so:

expression = expression "+" expression | expression "*" expression | number
Vermutung 7Wie viele Bäume?

Wie viele verschiedene Syntaxbäume hat 2+3*4 nach dieser kürzeren Grammatik, mit expression als Startsymbol?

Hier geht es weiter, sobald du deine Antwort oben abgegeben hast.

Zwei. In dem einen hängt das * tiefer, wie es sein soll:

expression(expression(number("2")) "+" expression(expression(number("3")) "*" expression(number("4"))))

Im anderen hängt das + tiefer, und wer diesen Baum ausrechnet, rechnet erst 2 + 3 und dann mal 4, also 20 statt 14:

expression(expression(expression(number("2")) "+" expression(number("3"))) "*" expression(number("4")))

Eine Grammatik, nach der ein Wort zwei verschiedene Syntaxbäume haben kann, heißt mehrdeutig. Für eine Programmiersprache ist das schlecht: Der Baum entscheidet, was ein Programm tut. Die Grammatik mit term und factor erzeugt dieselben Wörter, aber für jedes nur einen Baum, den mit Punkt vor Strich.

8. Grammatiken und Automaten

Noam Chomsky, ein Sprachwissenschaftler, hat 1956 Grammatiken danach geordnet, was ihre Regeln dürfen. Jede Stufe passt zu einer Art von Automaten, der genau ihre Sprachen erkennt:

Typ Die Regeln Erkennt sie Zum Beispiel
3, regulär Rechts steht ein Terminal, höchstens gefolgt von einem Nichtterminal, etwa a = "x" b. In EBNF geht dasselbe mit Schleifen { … }, solange keine Regel sich selbst mittendrin enthält, auch nicht über andere Regeln. ein endlicher Automat Zahlen, Namen, Zahlenschlösser
2, kontextfrei Links steht ein einzelnes Nichtterminal, rechts beliebig viel, auch das Nichtterminal selbst mittendrin. ein Kellerautomat Klammern, Ausdrücke, Programmiersprachen
1, kontextsensitiv Links dürfen mehrere Zeichen stehen, sodass eine Regel nur in einer bestimmten Umgebung gilt; ein Wort wird dabei nie kürzer. eine Turingmaschine, deren Band nicht länger ist als das Wort anbncn
0 beliebige Regeln eine Turingmaschine alles, was ein Programm überhaupt erkennen kann

Diese Ordnung heißt Chomsky-Hierarchie. Jede Stufe enthält die darunter: Eine reguläre Grammatik ist auch kontextfrei. Alle Grammatiken auf dieser Seite sind kontextfrei; was Programmiersprachen brauchen, liegt auf Stufe 2.

Schwarzweißporträt des jungen Noam Chomsky: ein Mann mit kurzem Haar und runder Brille, in Jackett und dunkler Krawatte, leicht zur Seite blickend.

Noam Chomsky (geboren 1928), amerikanischer Sprachwissenschaftler am MIT. Er beschrieb Sprachen durch Regeln, die alle ihre Sätze erzeugen, durch Grammatiken, und machte das 1957 mit seinem Buch Syntactic Structures bekannt.

1956 ordnete er Grammatiken danach, was ihre Regeln dürfen; die Ordnung heißt heute Chomsky-Hierarchie. Die Syntax von Programmiersprachen beschreibt man seit ALGOL 60 mit kontextfreien Grammatiken, ihrer Stufe 2.

Foto: unbekannt, 1961, aus The Technology Review, MIT Museum, gemeinfrei, zugeschnitten (Wikimedia Commons)

Aufgabe 8Wo endet regulär?

Die Grammatik der Ausdrücke besteht aus fünf Regeln. Wegen welcher Regel erkennt kein endlicher Automat ihre Sprache?

Wo endet regulär?

Hier geht es weiter, sobald du deine Antwort oben abgegeben hast.

In factor steht expression zwischen zwei Klammern, also mitten in einer Regel, die über term und expression wieder bei factor landet. Ohne diese Klammern wären Ausdrücke eine reguläre Sprache, Zahlen mit + und * dazwischen, und ein endlicher Automat könnte sie prüfen. Mit ihnen muss sich ein Programm merken, wie viele Klammern offen sind, ohne Grenze, und dafür braucht es einen Stack.

9. Zusammenfassung

  • Eine Grammatik besteht aus Regeln. Nichtterminale werden ersetzt, Terminale stehen im Wort, das Startsymbol ist die Regel, mit der man anfängt. Eine Ableitung ersetzt so lange, bis nur Terminale übrig sind; als Baum heißt sie Syntaxbaum.
  • EBNF schreibt Regeln mit |, { … }, [ … ] und ( … ). Ein Syntaxdiagramm zeichnet dieselbe Regel: runde Kästen für Terminale, eckige für Nichtterminale.
  • Die Grammatik legt die Syntax fest, wie ein Text gebaut ist, nicht die Semantik, was er bedeutet.
  • Rekursiver Abstieg: Jede Regel wird eine Funktion, jedes Nichtterminal ein Aufruf, { … } eine Schleife. Wie tief die Aufrufe gerade gehen, merkt sich der Stack der Aufrufe.
  • Eine Grammatik, nach der ein Wort zwei Syntaxbäume hat, ist mehrdeutig.
  • Die Chomsky-Hierarchie ordnet Grammatiken: reguläre zu endlichen Automaten, kontextfreie zu Kellerautomaten, beliebige zu Turingmaschinen.

10. Wie geht es weiter?

  • Ein Rechner für Ausdrücke. In Ein Rechner für Ausdrücke bekommt dieselbe Grammatik noch - und /. Die Funktionen geben dort nicht nur zurück, ob ein Ausdruck passt, sondern bauen seinen Syntaxbaum, und am Ende rechnet der Rechner ihn aus.
  • Eine echte Grammatik lesen. Auf json.org steht das Datenformat JSON ganz als Syntaxdiagramme. Versuch, ein Stück JSON aus dem Handbuch unter JSON mit dem Finger auf den Diagrammen nachzufahren.

Compiler Construction, Niklaus Wirth, 1996, überarbeitet 2017. Das Buch fängt mit Terminalen, Nichtterminalen und Startsymbol an, kommt über reguläre Sprachen zum rekursiven Abstieg und baut damit einen ganzen Compiler für eine kleine Sprache, bis zu den Befehlen eines Prozessors. Wirth stellt es auf seiner Seite an der ETH Zürich frei zum Lesen bereit.