4. Formale Sprachen - Einführung und Beispiele
Hier sehen Sie einige Beispiele für gültige und ungültige Email-Adressen. Mit gültig meine ich, dass sie syntaktisch korrekt sind, ungeachtet, ob ein Konto mit dieser Email-Adresse besteht.
thomas.schmitz@hszg.de Gültig dominik@cs.sjtu.edu.cn Gültig raffaela@mayer@gmail.com Ungültig: @ kommt zweimal vor lorenz.klein@greatest/company/in/the/world.com Ungültig: Domain-Name darf kein / enthalten .schlaumeier@gmail.com Ungültig: Google will kein . an erster Stelle
Hier sehen Sie den Teil eines HTML-Dokuments. Beachten Sie die typische hierarchisch-geschachtelte Struktur (sie müssen es nicht im Detail lesen):
<div class='carousel-inner' style='display:inline-block'> <div class='item active'> <p>\(110 x + 794\)</p><img loading="lazy" src='../img/hash/hashfunction_110_794.svg'> </div> <div class='item'> <p>\(502 x + 121\)</p><img loading="lazy" src='../img/hash/hashfunction_502_121.svg'> </div> <div class='item'> <p>\(617 x + 5\)</p><img loading="lazy" src='../img/hash/hashfunction_617_5.svg'> </div> <div class='item'> <p>\(815 x + 562\)</p><img loading="lazy" src='../img/hash/hashfunction_851_562.svg'> </div> <div class='item'> <p>\(868 x + 858\)</p><img loading="lazy" src='../img/hash/hashfunction_868_858.svg'> </div> <div class='item'> <p>\(915 x + 320\)</p><img loading="lazy" src='../img/hash/hashfunction_915_320.svg'> </div> </div class='carousel-inner'>
Hier sehen Sie einen Ausschnitt aus einem Elm-Programm (auch diesen müssen Sie nicht im Detail lesen):
find : Bst -> String -> Maybe String
find tree key =
case tree of
Empty _ ->
Nothing
Node ( keyHere, valueHere ) leftChild rightChild ->
if key == keyHere then
Just valueHere
else if key < keyHere then
find leftChild key
else
find rightChild key
Als letztes Beispiel sehen Sie hier eine svg-Datei. Dies ist ein Dateiformat für Vektorgrafiken. In diesem Falle ein Kreis mit einer Geraden:
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="102pt" height="102pt"
viewBox="0 0 102 102"
version="1.1">
<g id="surface2322">
<circle style="fill:none;stroke-width:0.4;stroke:rgba(0,0,0,100);"
cx="51" cy="51" r="40"/>
<path style="fill:none;stroke-width:0.4;stroke:rgba(0,0,0,100);"
d="M 50 50 l 33.5 -22.5"/>
</g>
</svg>
Was haben diese vier Beispiele gemeinsam? Es handelt sich in allen Fällen um Daten, die in einem bestimmten festgelegten Format dargelegt werden. Soll ein Rechner etwas sinnvolles damit anfangen (zum Beispiel das Elm-Programm starten oder die HTML-Seite oder die Svg-Datei auf dem Bildschirm darstellen), muss er dieses Format erst einmal "verstehen", also den bloßen String aus ASCII-Zeichen umwandeln in eine logisch sinnvolle Struktur. Und genau darum geht es in den Formalen Sprachen: wir wollen Begriffe, Regeln, Methoden, Algorithmen entwickeln, um Daten, die in einem bestimmten Format vorliegen, zu verarbeiten; ja auch erst einmal überhaupt Begriffe festlegen, wie man solche Formate definiert.
Korrekte Email-Adressen
Kommen wir auf unser erstes, einfachstes Beispiel zurück: die Email-Adressen. Können Sie möglichst präzise und möglichst formal beschreiben, wie eine korrekte Email-Adresse auszusehen hat? Hier versuche ich es einmal:
@
verbunden sind.
Der Benutzername ist ein nichtleerer String bestehend aus Groß- und Kleinbuchstaben, Zahlen, und
Punkten (.
).
Erster und letzter Buchstaben dürfen keine Punkte sein, außerdem dürfen keine zwei Punkte
nebeneinander stehen.
Der Domainname ist eine Folge von mindestenes zwei Labels, die jeweils mit einem
.
separiert sind. Ein Label
ist ein nichtleerer String aus Groß- und Kleinbuchstaben, Zahlen und dem Bindestrich
(-
).
Die genauen Regeln mögen von Anbieter zu Anbieter variieren; ich habe mich mal an das gehalten,
was
ich experimentell bei gmail.com
herausgefunden
habe. Die obige Beschreibung ist (hoffentlich) verständlich und präzise und unzweideutig.
Allerdings
ist sie in natürlicher Sprache verfasst;
es ist beispielsweise nicht klar, wie ein Rechner aus der obigen Beschreibung einen Algorithmus
konstruieren kann, der Korrektheit einer Email-Adresse überprüft.
Außerdem schleichen sich bei natürlicher Sprache schnell Zweideutigkeiten ein, die a priori
nicht
immer zu erkennen sind.
Wir wollen daher ein formales Regelwerk erstellen, wie wir Formate dieser Art vollständig und
eindeutig beschreiben können.
Ich werde dies nun Schritt für Schritt entwickeln, erst informell anhand des
Email-Adressen-Beispiels und dann, im nächsten Kapitel, formal und abstrakt.
Eine Emailadresse ist von der Form Benutzername@
Domainnname. Dies
können wir als Ableitungsregel darstellen:
<EmailAdress> -> <User> @ <Domain>
Wie können wir nun beispielsweise eine ähnliche Ableitungsregeln für <Domain>
erstellen? Eine <Domain>
soll
eine Folge aus mindestens zwei <Label>
sein, jeweils durch einen
.
separiert. Wir erreichen dies, indem wir einen an Rekursion
erinnernden Trick anwenden: entweder gibt es genau zwei Labels oder die Domain beginnt mit einem
Label,
gefolgt von einem Punkt und wiederum einer Folge von mindestens zwei durch .
separierten Labels,
also wiederum etwas, das wie ein Domainname aussieht. Daher:
<Domain> -> <Label> . <Label> <Domain> -> <Label> . <Domain>
Wir geben also zwei Möglichkeiten an, wie mit einem <Domain>
zu
verfahren ist.
Was ist nun <Label>
? Dies ist eine nichtleere Folge von in Domainnamen
erlaubten
Zeichen.
Diese sind alphanumerisch (Buchstaben und Zahlen) und der Strich -
(in der Praxis
sind
eventuell noch weitere Zeichen erlaubt;
im Ernstfall hängt dies davon ab, was die Domain Name Server des jeweiligen Landes / der
jeweiligen
Top-Level-Domain erlauben, siehe
zum Beispiel Wikipedia:
Internationalized Domain Name).
Wie formulieren wir nichtleere Folge von ... mit unserer -->
-Notation?
Wieder mit dem Rekursionstrick:
<Label> -> <AlphaNumOrDash> <Label> -> <AlphaNumOrDash> <Label> <AlphaNumOrDash> -> <AlphaNum> <AlphaNumOrDash> -> -
Nun müssen wir noch Regeln für <AlphaNum>
angeben. Hier führen wir eine
weitere
Konvention ein: nämlich, dass wir
verschiedene Alternativen mit einem senkrechten Strich | separieren:
<AlphaNum> -> a | b | c | d | e | f | g | h | i | j | k | l | m | n | o | p | q | r | s | t | u | v | w | x | y | z <AlphaNum> -> A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | P | Q | R | S | T | U | V | W | X | Y | Z <AlphaNum> -> 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
Beachten Sie: ich habe hier absichtlich nicht
<AlphaNum> -> a | ... | z
geschrieben, weil ich in diesem Beispiel wirklich alles ausschreiben wollte und mit der
...-Notation
schon wieder etwas menschen- aber nicht maschinenlesbares
eingeführt hätte.
Wir brauchen noch Regeln für <User>
. Dies ist ein nichtleerer String aus
alphanumerischen Zeichen und dem Punkt .
, wobei
der Punkt nicht am Anfang und nicht am Ende stehen darf. Also: eine nichtleere Folge von
Namensblöcken, die jeweils durch .
separiert sind,
wobei ein Namensblock eine nichtleere Folge alphanumerischer Zeichen ist.
<User> -> <NameBlock> | <NameBlock> . <User> <NameBlock> -> <AlphaNum> | <AlphaNum> <NameBlock>
Nun haben wir unser Emailformat vollständig beschrieben. Das gesamte Regelwerk sehen Sie hier noch einmal im Ganzen:
<EmailAddress> -> <User> @ <Domain>
<Domain> -> <Label> . <Label> | <Label> . <Domain>
<User> -> <NameBlock> | <NameBlock> . <User>
<NameBlock> -> <AlphaNum> | <AlphaNum> <NameBlock>
<Label> -> <AlphaNumOrDash> | <AlphaNumOrDash> <Label>
<AlphaNumOrDash> -> <AlphaNum> | -
<AlphaNum> -> a | b | c | d | e | f | g | h | i | j | k | l | m | n | o | p | q | r | s | t | u | v | w | x | y | z
<AlphaNum> -> A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | P | Q | R | S | T | U | V | W | X | Y | Z
Ein Beispiel einer formalen Grammatik und einer Ableitung
Was Sie hier sehen, nennt man eine formale Grammatik. Ihre Bestandteile sind:
- Das Alphabet \(\Sigma\) aller verwendeten Zeichen, in unserem Fall also \(\Sigma = \{a,\dots,z,A,\dots,Z,.,-,@\}\). Wir nennen \(\Sigma\) die Menge der terminalen Symbole.
- Eine Menge \(N\) abstrakter Symbole, hier $$ N = \{\texttt{<EmailAddress>, <Domain>, <User>,<NameBlock>,<Label>,<AlphaNumOrDash>,<AlphaNum>} \} \ . $$ Diese Menge nennen wir die nichtterminalen Symbole. Wir verlangen, dass \(N \cap \Sigma = \emptyset\) gilt; ein Symbol kann also nicht gleichzeitig Terminalsymbol und Nichtterminalsymbol sein.
- Eine Menge \(P\) von Regeln, auch Produktionen genannt, wobei jede Regel die Form \(X \rightarrow \alpha\) hat, wobei \(\alpha\) eine beliebig lange endliche Folge von Symbolen in \(\Sigma \cup N\) ist.
-
Ein Startsymbol \(S \in N\), das angibt, wo wir mit unserer Ableitung beginnen sollen. Im
obigen
Beispiel
sind wir ja an Emailadressen interessiert, also ist
<EmailAddress>
das Startsymbol.
Wenn wir nun so eine Grammatik gegeben haben, können wir Wörter ableiten; das heißt, wir beginnen mit dem Startsymbol und ersetzen in jedem Schritt ein nichtterminales Symbol durch die rechte Seite einer entsprechenden Regel. Dieser Vorgang ist nicht eindeutig und lässt mehrere Möglichkeiten offen; das ist auch gut so, denn es soll ja mehr als eine Email-Adresse geben. Hier ist ein Beispiel für eine Ableitung basierend auf der obigen Grammatik:
<EmailAddress> -> <User>@<Domain>
-> <NameBlock>.<User>@<Domain>
-> <NameBlock>.<NameBlock>@<Domain>
-> <NameBlock>.<NameBlock>@<Label>.<Domain>
-> <NameBlock>.<NameBlock>@<Label>.<Label>.<Label>
-> <AlphaNum>.<NameBlock>@<Label>.<Label>.<Label>
-> <AlphaNum>.<AlphaNum>@<Label>.<Label>.<Label>
-> <AlphaNum>.<AlphaNum>@<AlphaNumOrDash>.<Label>.<Label>
-> <AlphaNum>.<AlphaNum>@<AlphaNumOrDash>.<AlphaNumOrDash>.<Label>
-> <AlphaNum>.<AlphaNum>@<AlphaNumOrDash>.<AlphaNumOrDash>.<AlphaNumOrDash><Label>
-> <AlphaNum>.<AlphaNum>@<AlphaNumOrDash>.<AlphaNumOrDash>.<AlphaNumOrDash><AlphaNumOrDash>
-> d.<AlphaNum>@<AlphaNumOrDash>.<AlphaNumOrDash>.<AlphaNumOrDash><AlphaNumOrDash>
-> d.<AlphaNum>@<AlphaNumOrDash>.<AlphaNumOrDash>.<AlphaNumOrDash>e
-> d.<AlphaNum>@<AlphaNumOrDash>.<AlphaNumOrDash>.de
-> d.s@<AlphaNumOrDash>.<AlphaNumOrDash>.de
-> d.s@<AlphaNumOrDash>.b.de
-> d.s@a.b.de
Nach dem gleichen Schema könnten wir d.s.@-.-.--
ableiten, was
darauf schließen lässt, dass unsere Grammatik nicht wirklich das
tut, was wir beabsichtigen, dass sie nämlich zu viele Emailadressen
herleitet, auch solche, die wir nicht als zulässige Adressen gelten lassen wollen.
-.-.--
zu verbieten. Wie müssen Sie die obige Grammatik ändern?
Terminologie, formale Definitionen und Beispiele
Im letzten Abschnitt haben wir die Regeln für die Bildung syntaktisch korrekter Emailadressen formalisiert. Zwar unvollständig, doch hoffe ich, dass das allgemeine Schema klar geworden ist. Wir werden nun alles formaler und abstrakter definieren.
Alphabet
Wenn wir über formale Sprachen reden, so liegt immer eine (endliche) Menge von
Symbolen zugrunde, das Alphabet \(\Sigma\). Im Emailadressen-Beispiel war (\Sigma) recht groß:
die 52 Buchstaben; 10 Ziffern; die Zeichen @ . -
. Für Java-Programme oder
andere Programmiersprachen kämen
noch weitere Zeichen hinzu, zum Beispiel + - / \ { }
und so weiter;
wenn wir alle Unicode-Zeichen miteinschließen, landen wir im Millionenbereich.
In den theoretischen Beispielen, die in diesem Kurs folgen werden, ist das Alphabet fast immer
viel kleiner: typische Alphabete zum Beispiel sind \( \{0,1\}\) , \(\{a,b,c,d\}\) oder auch
\(\{1\}\), ein Alphabet mit nur einem Zeichen.
Für ein Alphabet \(\Sigma\) bezeichnen wir mit \(\Sigma^*\) die Menge aller endlichen Strings über diesem Alphabet; das schließt den leeren String mit ein, den wir mit \(\epsilon\) bezeichnen. So ist beispielsweise $$ \{a,b\}^* = \{\epsilon, a, b, aa, ab, ba, bb, aaa, aab, aba, ...\} $$ Ein Element \(x \in \Sigma^*\), also einen endlichen String aus \(\Sigma\)-Symbolen, bezeichnen wir als Wort über \(\Sigma\). Mit \(\Sigma^+\) bezeichnen wir die Menge aller nichtleeren Strings, also \(\Sigma^+ = \Sigma^* \setminus \{\epsilon\}\).
Sprachen
Eine Teilmenge \(L \subseteq \Sigma\) bezeichnen wir in diesem Kontext als Sprache und kürzen Sie oft mit \(L\) ab, was für language steht. Beispiele für Sprachen wären
- Die Sprache aller syntaktisch korrekten Emailadressen.
- Die Sprache aller Java-Programme, die ohne Fehlermeldung kompilieren
- Die Sprache aller Java-Programme, die kompilieren, dann aber mit einem Laufzeitfehler abbrechen.
- Die Sprache aller Java-Programme, die kompilieren und nicht mit einem Laufzeitfehler abbrechen.
- Die Sprache aller Wörter über \(\{a,b\}\), die gleich viele \(a\)'s wie \(b\)'s enthalten.
- Die Sprache aller Palindrome über \(\{a,b,c,d\}\), also Wörter, die von vorne wie hinten gelesen gleich aussehen.
Wir wollen herausfinden, welche Arten von Sprachen wir mit den im letzten Abschnitt eingeführen Regelwerk aus Ableitungen beschreiben können. Für die gerade aufgelisteten sechs Sprachen lautet die Antwort
- Ja, können wir.
- Ja, wenn wir leicht komplexere Ableitungsregeln erlauben.
- Ja, wenn wir leicht komplexere Ableitungsregeln erlaubten.
- Nein, können wir nicht.
- Ja, können wir.
- Ja, können wir.
Grammatiken
- einem endlichen Alphabet \(\Sigma\), den terminalen Symbolen;
- einer dazu disjunkten endlichen Menge \(N\), genannt die nichtterminalen Symbole;
- einer endlichen Menge \(P\) von Produktionsregeln der Form \(X \rightarrow \alpha\) mit \(X \in N\) und \(\alpha \in (\Sigma \cup \N)^*\); formal also \(P \subseteq N \times (\Sigma \cup \N)^*\).
- einem Startsymbol \(S \in N\).
Woher der Name kontextfrei kommt, werden Sie hoffentlich verstehen, wenn wir Ableitungen definiert haben. Die Tradition will es, dass wir für die terminalen Symbole Zahlen oder lateinsiche Kleinbuchstaben und für die nichtterminalen Symbole lateinische Großbuchstaben verwenden. Dies ist eine Konvention, die hilfreich ist, solange wir auf abstrakt-theoretischer Ebene über formale Grammatiken sprechen; wenn Sie z.B. eine Grammatik für Java erstellen wollen, dann wird \(\Sigma\) natürlich auch Großbuchstaben enthalten.
In jedem Schritt wählen wir ein Nichtterminal aus, zum Beispiel im zweiten Schritt \(A\), und wenden eine Regel an, zum Beispiel \(A \rightarrow aA\). Dadurch wird \(AB\) zu \(aAB\). Wir setzen diese Ableitungsschritte so lange fort, bis nur noch terminale Symbole übrigbleiben. Dann hat sich ein Wort \(\alpha \in \Sigma^*\) ergeben.
Wenn wir \(\alpha = \beta\) "vorlesen", dann sagen wir \(\beta\) kann aus \(\alpha\) in einem Schritt abgeleitet werden. Wenn \(\beta\) aus \(\alpha\) in mehreren (im Extremfall null) Schritten abgeleitet werden kann, so schreiben wir \(\alpha \Rightarrow^* \beta\).
Formal bedeutet \(\alpha \Rightarrow^* \beta\), dass es ein \(k \geq 0\) gibt und "Zwischenwortformen" \(\alpha_0, \alpha_1, \dots, \alpha_k\) mit \(\alpha = \alpha_0\) und \(\alpha_k = \beta\), sodass \(\alpha_i \Rightarrow \alpha_{i+1}\) für alle \(i = 0, 1, \dots, k-1\) gilt. Dies schließt den "trivialen" Fall \(k=0\) mit ein, in welchem \(\alpha = \beta\) gilt.
Nochmals: wenn \(\alpha\) die Form \(\alpha_1 X \alpha_2\) hat, dann dürfen Sie das Nichtterminal \(X\) durch die rechte Seite einer Produktionsregel \(X \rightarrow \gamma\) ersetzen; Sie dürfen das unabhängig von dem Kontext, in welchem \(X\) in der Wortform \(\alpha\) vorkommt. Daher rührt der Name kontextfreie Grammatik.
Beachten Sie, dass \(P\) per Definition eine endliche Menge von Regeln sein muss, dass jedoch \(\Rightarrow\) im Allgemeinen unendlich ist. Bereits für unsere einfache Grammatik mit den Produktionsregeln
\begin{align*} S & \rightarrow A B \\ A & \rightarrow \epsilon \ | \ a A \\ B & \rightarrow \epsilon \ | \ b B \ . \\ \end{align*} haben wir beispielsweise \begin{align*} A & \rightarrow aA \\ aA & \rightarrow aaA \\ aaA & \rightarrow aaaA \\ \dots \end{align*} und sehen, dass die Menge aller Paare \(\alpha \Rightarrow \beta\) unendlich ist.Wir betrachten nun einige weitere Beispiele
Wir sehen also: das gleiche Wort kann mehrere Ableitungen haben. Da die Ersetzungsregeln kontextfrei sind, spielt es keine Rolle, in welcher Reihenfolge wir nichtterminale Symbole ersetzen. Wenn Sie scharf hinschauen, werden Sie erkennen, dass die beiden Ableitungen "irgendwie gleich" sind, dass nur die Ableitungen in anderer Reihenfolge durchgeführt worden sind. Ich werde das in einem späteren Kapitel formal definieren, was ich mit damit meine. Um Ordnung in das Chaos zu bringen, könnten wir uns zum Beispiel einigen, dass man immer das am weitesten links stehende Nichtterminal ersetzen muss. Das nennt man eine Linksableitung. Dies ist nicht wirklich eine Einschränkung, da die Ersetzungsreihenfolge keine Rolle spielt. Wir sehen, dass die zweite Ableitung des Wortes \(abab\) oben eine Linksableitung ist; zusammen mit der Beschriftung \(\stackrel{(i)}{\Rightarrow}\), die die Nummer der angewendeten Regel angibt, ist eindeutig, wie wir von \(S\) zum abgeleiteten Wort gekommen sind. Betrachten Sie nun eine weitere Linksableitung \(S \Rightarrow^* abab\):
\begin{align*} S & \stackrel{(1)}{\Rightarrow} aSbS \stackrel{(2)}{\Rightarrow} abSaSbS \stackrel{(3)}{\Rightarrow} abaSbS \stackrel{(3)}{\Rightarrow} ababS \stackrel{(3)}{\Rightarrow} abab \end{align*}Sehen Sie, dass diese Ableitung qualitativ anders ist, da wir hier auch die Regel \(S \rightarrow bSaS\) angewendet haben? Um die Struktur der Ableitung zu verdeutlichen, könnten wir die ersten beiden Ableitungen mit Wort \(S \Rightarrow^* (ab)(ab)\) bezeichnen und die dritte mit Wort \(S \Rightarrow^* a(ba)b\).
Ziele der Theorie der formalen Sprachen
Ganz allgemein gesagt wollen wir lernen, wie wir Sprachen formal beschreiben können; wie wir, gegeben eine Grammatik \(G\) und ein Zielwort \(x\), eine Ableitung \(G \Rightarrow^* x\) finden können. Anhand der Ableitungssequenz können wir dann oft auf die logische Struktur von \(x\) schließen. Handelt es sich bei \(G\) zum Beispiel um eine Grammatik für die Programmiersprache Java, so wäre ein Ziel, aus der Ableitungssequenz \(G \Rightarrow^* x\) die Struktur des Programms \(x\), also Klassenstruktur, Methoden, etc., ablesen zu können und schlussendlich das Programm in ausführbaren Maschinencode kompilieren zu können.
Für einen String \(x\) eine Ableitung zu finden bezeichnen wir als parsen, das zugehörige Hauptwort als Parsing.
Die gute Nachricht: Die gute Nachricht: wir kennen Algorithmen, die dieses Problem im effizient lösen, wenn wir den "theoretischen" Effizienzbegriff zugrund legen.
Die schlechte Nachricht: wir kennen keinen Algorithmus, der das Parsing kontextfreier Sprachen in seiner ganzen Allgemeinheit in linearer Zeit erledigt, dessen Laufzeit also proportional zur Länge des Zielwortes \(x\) ist. Dies ist aber, was wir in der Praxis, zum Beispiel bei Compilern, erwarten.
Die gute Nachricht: in fast allen praktisch relevanten Fällen haben wir es mit Grammatiken zu tun, die Parsing in linearer Zeit ermöglichen. Und wenn wir Programmiersprachen, Datenformate etc. entwerfen, haben wir es ja in der Hand, Sprache und Grammatik so anzulegen, dass effizientes Parsen möglich ist.
Im nächsten Kapitel lernen wir eine stark eingeschränkte, aber dennoch sehr wichtige Klasse kontextfreier Grammatik kennen, die allesamt ein sehr effizientes Parsing erlauben: die sogenannten regulären Grammatiken.