4. Listen

4.8 Zweites Programmierprojekt: Boolesche Formeln

  • Projektvorlage: project-2.zip
  • Abgabedatum: Sonntag, der 21. Januar 2024
  • Abgabeformat: eine Datei VornameNachname.elm wie unten beschrieben

Kennen Sie Boolesche Formeln? Dies sind Ausdrücke wie \((x \vee \neg y) \wedge (\neg x \vee y)\). Sie verhalten sich ein bisschen wie arithmetische Formeln, also \( (2x + y) (y - z) \), nur dass es sich um Boolesche Variable handelt, die nur zwei Werte annehmen können: False und True bzw. 0 und 1. Auch haben wir als Operatoren nicht \(+, -, *, /\), sondern \(\wedge\) (UND), \(\vee\) (ODER) und \(\neg\) (NICHT). Wenn wir eine Wahrheitsbelegung haben, die jeder Variable einen Booleschen Wert zuweist, können wir den Gesamtausdruck auswerten, auch wieder wie bei arithmetischen Ausdrücken. Dies geschieht nach folgenden Regeln:

  • Eine Formel \(A \wedge B\) wertet zu \(1\) aus, wenn sowohl \(A\) als auch \(B\) zu 1 auswerten; andernfalls wertet es zu 0 aus.
  • \(A \vee B\) wertet zu 1 aus, wenn \(A\) oder \(B\) zu 1 auswertet (oder beide); andernfalls wertet es zu 0 aus.
  • \(\neg A\) wertet zu 1 aus, wenn \(A\) zu 0 auswertet, und umgekehrt.

Als Beispiel nehmen wir die obige Formel \((x \vee \neg y) \wedge (\neg x \vee y)\) und die Wahrheitsbelegung \([x \mapsto 1, y \mapsto 1]\). Wenn wir die Wahrheitsbelegung in die Formel einsetzen, erhalten wir den Ausdruck \((1 \vee \neg 1) \wedge (\neg 1 \vee 1)\). Wir können ihn nun nach den drei oben aufgeführen Regeln vereinfachen:

\begin{align*} & (1 \vee \underline{\neg 1}) \wedge (\underline{\neg 1} \vee 1) \\ = & \underline{(1 \vee 0)} \wedge (0 \vee 1) \\ = & 1 \wedge \underline{(0 \vee 1)} \\ = & \underline{1 \wedge 1} \\ = & 1 \end{align*}

Eine Darstellungsweise, die die "Arbeitsweise" einer Booleschen Formel \(F\) vollständig spezifiziert, ist die Wahrheitstabelle. Hier listen wir alle möglichen Wahrheitsbelegungen auflisten und für jede den Wert, den die Auswertung der Formel unter dieser Belegung ergibt. Für \(F := (x \vee \neg y) \wedge (\neg x \vee y)\) wäre dies

\begin{align*} \begin{array}{cc|c} x & y & F \\ \hline 0 & 0 & 1 \\ 0 & 1 & 0 \\ 1 & 0 & 0 \\ 1 & 1 & 1 \\ \end{array} \end{align*}

Eine induktive Definition für Boolesche Formeln

Beachten Sie, dass eine Boolesche Formel eine Baumstruktur hat. Unsere Formel \(F := (x \vee \neg y) \wedge (\neg x \vee y)\) zum Beispiel so:

Diese Baumstruktur erlaubt uns, Boolesche Formeln induktiv zu definieren:

Definition Die Menge der Booleschen Formeln ist wie folgt induktiv definiert:

  1. Eine Konstante wie \(1\) oder \(0\) ist eine Boolesche Formel.
  2. Eine Variable wie \(x, y, x_1\) ist auch eine Boolesche Formel.
  3. Wenn \(f\) und \(g\) Boolesche Formeln sind, dann ist auch \(f \wedge g\) eine
  4. und auch \(f \vee g\).
  5. Wenn \(f\) eine Boolesche Formel ist, dann auch \(\neg f\).

Eine solche induktive Definition ist also wie eine Bauanleitung, die uns Grundbausteine (hier: Variable und Konstanten) zur Verfügung stellt sowie Operatoren (hier: \(\wedge, \vee, \neg\)), mit denen wir aus bereits geschaffenen Formeln neue bauen können. Die Baumdarstellung (siehe oben) beschreibt immer eindeutig, wie wir die Formel zusammengebastelt haben. Wenn wir die Formel allerdings in üblicher Notation als Zeichenkette schreiben, müssen wir aufpassen. Welcher Baum entspricht der Formel \(x \vee y \wedge z\)?

Wir könnten jetzt eine Operatorenpräzedenz à la Punkt vor Strich definieren. Das würde allerdings erstens alles komplizierter machen und zweitens uns nicht helfen, zu \(x \vee y \vee z\) einen eindeutigen Baum zu zeichnen:

Sie könnten jetzt anmerken, dass sei doch das gleiche. Ist es aber strenggenommen nicht. Die zwei Bäume sind andere, auch die "Baugeschichte", also die Reihenfolge, wie wir Punkte 1-5 angewandt haben, um die Formel zu bauen, ist anders. Also: zwei verschiedene Formeln. Äquivalent, aber nicht gleich.

Klammernsetzung. Um Uneindeutigkeiten auszuschließen, verlangen wir, dass "zusammengesetzten" Formeln eingeklammert werden, also \((f \wedge g)\), \((f \vee g)\) und \((\neg f)\) statt \(f \wedge g\), \(f \vee g\) und \(\neg f\).

Formeln auswerten

Die "Semantik" Boolescher Formeln ergibt sich ja aus den Werten, die sich ergeben, wenn man den Variablen eine Wahrheitsbelegung gibt. Wir brauchen also einen Datentyp für Wahrheitsbelegungen. Da gibt es mehrere Möglichkeiten. Oben haben wir ja bereits eine Belegung gesehen: \([x \mapsto 1, y \mapsto 1]\). Welchen Wert würde beispielsweise \((x \vee (y \wedge z))\) unter der Belegung \([x \mapsto 0, y \mapsto 1]\) annehmen? Probieren wir es aus: wir ersetzen \(x\) durch 0 und \(y\) durch 1 und erhalten

\begin{align*} & (0 \vee \underline{(1 \wedge z)}) \\ = & \underline{(0 \vee z)} \\ = & z \end{align*}

Dies ist also gar kein fertiger Wert. Uns fehlt der Wert von \(z\). Und natürlich können wir gar keinen endgültigen Wert ausrechnen, wenn uns einer der "Eingabewerte" fehlt. Oder doch? Was ist denn mit \((x \vee (y \wedge z))\) unter der Belegung \([x \mapsto 0, y \mapsto 0]\)? Der Wert von \(z\) ist weiterhin nicht definiert, allerdings erhalten wir:

\begin{align*} & (0 \vee \underline{(0 \wedge z)}) \\ = & \underline{(0 \vee 0)} \\ = & 0 \\ \end{align*}

Wir haben also die Formel erfolgreich ausgewertet, ohne überhaupt für alle Variablen einen Wert gehabt zu haben.

Definition. Eine Belegung wie \([x \mapsto 0, y\mapsto 1]\), die nicht unbedingt allen Variablen einen Wert zuweist, nennt man eine partielle Belegung. Weißt sie allen in der Formel vorkommenden Variablen einen Wert zu, ist es eine totale Belegung.

Erweitern wir also unseren Horizont. Wenn \(F\) eine Boolesche Formel ist und \(\alpha\) eine partielle Belegung, dann ist \(F|_{\alpha}\) das Ergebnis, das wir erhalten, wenn wir die Werte von \(\alpha\) einsetzen und die Formel soweit wie möglich auswerten. \(F|_{\alpha}\) ist wiederum eine Formel. Falls die Belegung \(\alpha\) total ist, dann ist \(F_{\alpha}\) entweder 0 oder 1. Die (eventuell partielle) Auswertung verläuft wie im obigen Beispiel nach den folgenden Regeln:

  1. eine Variable \(x\) wird durch ihren Booleschen Wert ersetzt, sofern die Belegung \(\alpha\) ihr einen Wert zuweist. Andernfalls lassen wir sie stehen.
  2. \((0 \wedge f)\) und \((f \wedge 0)\) ersetzen wir durch 0,
  3. \((1 \wedge f)\) und \((f \wedge 1)\) ersetzen wir durch \(f\),
  4. \((0 \vee g)\) und \((g \vee 0)\) ersetzen wir durch \(g\),
  5. \((1 \vee g)\) und \((g \vee 1)\) ersetzen wir durch 1,
  6. \((\neg 0)\) ersetzen wir durch 1 und \((\neg 1)\) durch 0.

Das Projekt

Endziel dieses Projekts soll eine Web-Anwendung sein, die ungefähr so aussieht:

Ein Elm-Datentyp für Boolesche Formeln und Wahrheitsbelegungen

Wie sollen wir Boolesche Formeln in Elm darstellen? Listen sind ungeeignet, da die "Verschachtelungstiefe" nicht homogen ist. Glücklicherweise lassen sich induktive Definitionen fast wörtlich in eine Elm-Typendefinition übersetzen:

type Formula
    = Const Bool
    | Var String
    | And Formula Formula
    | Or Formula Formula
    | Not Formula

Sehen Sie, wie die fünf Alternativen dieser Custom-Typ-Definition genau den fünf Punkten der induktiven Definition entsprechen? Die Formel \((x \vee (y \wedge z))\) entspricht also dem Elm-Objekt Or (Var "x") (And (Var "y") (Var "z"))

Übungsaufgabe Laden Sie sich die Projektvorlage project-2.zip herunter und entzippen Sie sie. Stellen Sie sicher, dass project-2 kein Unterverzeichnis Ihres bereits existierenden Elm-Ordners ist!

Im Verzeichnis project-2 sehen Sie bereits eine Datei elm.json, einen Ordner solutions/ und einen Ordner src/. Letzterer enthält wichtige Typendefinitionen und den Code, den Herr Schellenberger für Sie bereitgestellt hat, also zum Beispiel die graphischen Oberflächen. Starten Sie elm reactor und gehen dann im Browser auf localhost:8000/src/Main.elm

Sie sehen nun die graphische Oberfläche, allerdings mit einer Dummy-Implementierung ohne jegliche Funktionalität. Im Order solutions/ finden Sie die Datei solutions/DummySolution.elm. Die enthält das "Gerüst", das Sie nun mit Inhalt füllen müssen. Erstellen Sie eine Kopie der Datei DummySolution.elm und benennen Sie sie um in VornameNachname.elm. Öffnen Sie nun die Datei src/SolutionDispatcher.elm und ändern Sie sie wie folgt:

module SolutionDispatcher exposing (solution)

import VornameNachname
-- import DummySolution


solution =
    VornameNachname.solution --DummySolution.solution

Jetzt weiß die App src/Main.elm, dass Sie "Ihre" Lösung verwenden soll. Jede Änderung in der Datei solutions/VornameNachname.elm wirkt sich jetzt auf das Verhalten der App aus. Probieren Sie's aus: ändern Sie die Variable name und laden die App-Seite neu.

Sehen Sie: auch das "Bündeln" Ihrer Funktionen im Record-Type type alias Solution = { ... }, dass Sie auf meine Bitte im ersten Projekt nachgeholt haben, habe ich hier bereits übernommen. Wenn Sie Ihr Projekt abgeben wollen, müssen Sie also nur eine einzige Datei verschicken: VornameNachname.elm.

Nebenbemerkung: eigentlich ist es kein guter Stil, alles in eine Datei zu schreiben. Für das Projekt hier ist es aber am Einfachsten.

Testataufgabe Implementieren Sie die Funktion toString, die eine Formel in einen String codiert, wobei wir das \(\wedge\) als &, das \(\vee\) als | und das \(\neg\) als ! schreiben.

f = Or (Var "x") (And (Var "y") (Var "z"))                         
Or (Var "x") (And (Var "y") (Var "z")) : Formula
BooleanFormulas.toString f
"(x | (y & z))" : String

Da eine Wahrheitsbelegung manchen Variablen einen Wert zuweist, aber nicht allen, könnten wir sie als List (String, Bool) darstellen. Die Belegung \([x \mapsto 1, y \mapsto 0]\) also als [("x",True), ("y", False)]. Diese Darstellung hat allerdings zwei Nachteile. Erstens sind nun [("x",True), ("y", False)] und [("y", False), ("x",True)] in Elm zwei verschiedene Dinge, obwohl sie doch die gleiche Belegung darstellen. Zweitens stellen Sie sich vor, dass wir es mit einer Formel mit tausenden von Variablen zu tun haben (kommt in der Praxis tatsächlich vor). Jedes Mal, wenn Sie wissen wollen, welchen Wert "x" hat, müssen sie die Liste durchgehen, im schlimmsten Fall bis zum Ende.

Was wir brauchen, ist eine Dictionary-Datenstruktur. Eine solche erlaubt es uns, Schlüssel-Wert-Paare zu speichern und schnell abzufragen. In Java gibt dafür zum Beispiel die Klasse Hashtable. In Elm gibt es den Datentyp Dict:

import Dict                        
d = Dict.fromList [ ( "Montag", 1 ), ( "Dienstag", 2 ), ( "Mittwoch", 3 ), ( "Donnerstag", 4 ), ( "Freitag", 5 ), ( "Samstag", 6 ), ( "Sonntag", 7 ) ]
Dict.get "Donnerstag" d
Just 4
Dict.get "Friday" d
Nothing

Mit dem Wert Nothing teilt uns das Dictionary mit, dass es zum Schlüssel "Friday" keinen entsprechenden Wert gibt. Für Wahrheitsbelegungen brauchen wir also ein Dictionary, dass als Schlüssel Strings nimmt (Variablennamen) und als Werte Boolesche Werte. Also:

belegung = Dict.fromList [("x",True),("y",False)]                        
Dict.fromList [("x",True),("y",False)] : Dict.Dict String Bool

Der Datentyp ist also Dict String Bool. Das Repl-Fenster sagt uns Dict.Dict, weil der Typ Dict in einem Modul Dict definiert ist. Wenn Sie das stört, können Sie den Datentyp exposen, dann "kennt" die Laufzeitumgebung diesen beim Namen:

import Dict exposing (Dict)                        
Dict.fromList [("x",True),("y",False)] : Dict String Bool

Es ist in Elm durchaus üblich, Modul und Datentyp gleich zu benennen. Und beim benutzen dann den Namen des Typs zu exposen, also import Dict exposing (Dict).

Testataufgabe Implementieren Sie die Funktion evaluate, die eine Boolesche Formel unter einer gegebenen Wahrheitsbelegung auswertet:

belegung = Dict.fromList [("x",False),("y",True)]                            
f = Or (Var "x") (And (Var "y") (Var "z"))
evaluate f belegung
Var "z" : Formula
evaluate f (Dict.fromList [("x", True), ("y", False)])
Const True : Formula

Wahrheitstabellen

Jetzt wollen wir für eine gegebene Formel automatisch eine Wahrheitstabelle ausrechnen, also wie die hier:

\begin{align*} \begin{array}{cc|c} x & y & F \\ \hline 0 & 0 & 1 \\ 0 & 1 & 0 \\ 1 & 0 & 0 \\ 1 & 1 & 1 \\ \end{array} \end{align*}

Welcher Datentyp ist hierfür geeignet? Eine Zeile in der Wahrheitstabelle besteht aus einem linken Teil und einem rechten Teil. Der linke Teil ist die Belegung, der rechte Teil der sich ergebende Wert, also das Ergebnis von evaluate. In Wahrheitstabellen listen wir zwar üblicherweise alle totalen Belegungen auf. Es spricht allerdings nichts dagegen, nur einen Teil der Variablen zu verwenden; dann hätte man links nur partielle Belegungen und rechts eventuell Formeln statt konstante Werte. Wenn wir für Formel \( g = (x \vee (y \wedge z))\) nur die Möglichkeiten für \(x, z\) durchspielen, erhalten wir folgende Wahrheitstabelle:

\begin{align*} \begin{array}{cc|c} x & z & g \\ \hline 0 & 0 & 0 \\ 0 & 1 & y \\ 1 & 0 & 1 \\ 1 & 1 & 1 \\ \end{array} \end{align*}

Ein Eintrag in der Wahrheitstabelle könnte also vom Datentyp (Dict String Bool, Formula) sein. Allerdings will ich hier kein Dict verwenden: eventuell wollen wir ja die Reihenfolge der Variablen ändern, also beispielsweise \(z,x\). Das kann sinnvoll sein, weil so eine Operation die Wahrheitstabelle übersichtlicher machen könnte (kommt auf die Formel drauf an). Daher will ich eine Tabellenzeile als (List (String, Bool), Formula) darstellen und die ganze Tabelle dann als List (List (String, Bool), Formula).

Testataufgabe Implementieren Sie die Funktion getAllVariables: Formula -> List String , die Ihnen die Liste aller Variablen, die in der Formel vorkommen, liefert.

Tip: um sicherzustellen, dass keine Variable in der Liste doppelt vorkommt, empfehle ich Ihnen, die Datenstruktur Set zu verwenden:

import Set exposing (Set)                            
set1 = Set.fromList ["x", "y", "z"]
set2 = Set.fromList ["y", "v"]
Set.union set1 set2
Set.fromList ["v","x","y","z"] : Set String

Die Datenstruktur sorgt also dafür, dass das "y" nicht doppelt vorkommt. Mit Set.toList erhalten Sie wieder eine Liste:

Set.toList (Set.union set1 set2)                            
["v","x","y","z"] : List String

Testataufgabe Implementieren Sie die Funktion getAllAssignments : List String -> List (List (String, Bool)), die Ihnen eine Liste aller Wahrheitsbelegungen auf diesen Variablen liefert:

getAllAssignments ["x", "y"]
[[("x",False),("y",False)],[("x",False),("y",True)],[("x",True),("y",False)],[("x",True),("y",True)]] : List (List ( String, Bool ))

Tip: der Code ist ähnlich wie der von powerSet im vorherigen Kapitel.

Testataufgabe Implementieren Sie die Funktion generateTruthTable:

generateTruthTable : String List -> Formula -> List (List (String, Bool), Formula)                                
generateTruthTable variables formula = 
    ...
die die Wahrheitstabelle einer Formel berechnet und dafür alle partiellen Belegungen über variables auflistet.

Formeln einlesen

Bereits oben haben wir einen Datentyp Formula definiert, und Sie haben eine Funktion toString implementiert. Für unsere App ist nun aber die Umkehrfunktion interessanter: einen String in eine Formel zu decodieren. Das könnte in etwa so aussehen:

stringToFormula "(x | ((!y) & z))"    
Or (Var "x") (And (Not (Var "y")) (Var "z"))

Was Sie hier sehen, ist eine fundamentale Aufgabenstellung in der Informatik: einen String in ein sinnvolles Objekt zu übersetzen. Am Anfang ist ja alles ein String: Benutzer-Eingaben, Daten, die über ein Netzwerk gesendet werden, Inhalte einer Datei etc. Wenn Sie zum Beispiel im Elm-Repl-Fenster einen Ausdruck eingeben:

if (3 < 5) then (True,3) else (False, 6)

dann sieht der Elm-Interpreter erst einmal einfach den String "if (3 < 5) then (True,3) else (False, 6)", also im Prinzip eine Liste von Characters. Der Elm-Integerpreter muss jetzt die logische Struktur dieses Strings erkennen: es ist ein if-Ausdruck; die Bedingung ist 3 < 5, der then-Wert ist (True,3), was ein Tupel aus einem Bool und einem number ist, und so weiter. Wir müssen also aus dem String - einer Zeichenkette mit linearer Struktur - ein Objekt, häufig etwas Baumförmiges, bauen, das die logische Struktur des Strings widerspiegelt. Man nennt dies Parsing. Sie müssen in diesem Teil also einen Parser schreiben, der einen String in eine Boolesche Formel umwandelt.

Der Benutzer soll auf der Web-App eine Boolesche Formel eingeben kann, also zum Beispiel (x & (! y)), und die Web-App zeigt dann die Formel als Bäumchen an, berechnet die Wahrheitstabelle etc. Machen wir uns als erstes Gedanken über die Signatur des Parsers. parse : String -> Formula, ist doch klar, oder? Naja, wenn Sie mit Benutzerinput arbeiten, dann müssen Sie damit rechnen, dass der Input fehlerhaft ist, beispielsweise x & (y | z) (hier fehlen die äußeren Klammern) oder x & | y oder sogar ganz Unsinnges wie {x/\ &y}. Sie müssen also damit rechnen, dass es einen Parse Error gibt, wenn der String nicht im richtigen Format ist. Also

parse : String -> Result ParseError Formula

Result ist ein Datentyp definiert als type Result a b = Err a | Ok b, der also einen "Erfolgstyp" und einen "Fehlertyp" vereint.

Das Format. Wir müssten nun als erstes ein Format für unsere Formeln definieren. Das haben wir ja irgendwie bereits getan. Wir könnten sagen, "alles, was toString zurückliefert, ist ein gültiger String. Dann könnten Sie allerdings dummes Zeug machen wie

f = Or (Var "(") (Var "|")                        
toString f
"(( | |)" : String

Schlimmer noch: Sie könnten einer Variable einen Namen geben, der selbst wieder aussieht wie eine Formel:

g = Or (Var ("(a & b)")) (Var "y")                        
toString g
"((a & b) | y)" : String

Die Funktion toString ist nicht eineindeutig: die zwei verschiedenen Instanzen von Formula, nämlich Or (Var ("(a & b)")) (Var "y") und Or (And (Var "a") (Var "b")) (Var "y") ergeben den gleichen String, nämlich "((a & b) | y)". Wir haben jetzt im Prinzip zwei Möglichkeiten: entweder, wir ändern die Codierung, indem wir beispielsweise alle Variablennamen in Anführungszeichen setzen:

toStringWithQuotes (Or (Var ("(a & b)")) (Var "y"))
"(('a' & 'b') | 'y')" : String
toString (Or (Var ("(a & b)")) (Var "y"))
"('(a & b)' | 'y')" : String

Jetzt müssen Sie aber wieder aufpassen: Spaßvögel (oder Angreifer) fügen eventuell Single-Quotes in die Variablennamne mit ein:

toString (Or (Var ("a' & 'b")) (Var "y"))                        
"('a' & 'b' | 'y')" : String

Sie könnten das umgehen, indem Sie Escape-Characters verwenden, also statt ' und " in Ihren Output-String die Kombination \' bzw. \" schreiben; und statt \ die Kombination \\. Man nennt dies auch "Input Sanitization". Der Web-Comic xkcd hat hierzu folgende Illustration:

https://xkcd.com/327/

Auch wenn ein deutsches Standesamt Ihnen wohl nicht erlauben würde, Ihr Kind "Robert'); DROP TABLE Students; --" zu nennen, wäre ich mir bei den Kindern gewisser amerikanischer Unternehmer nicht so sicher; und wenn schon nicht das Standesamt, dann erlaubt mir vielleicht die Webseite, einen solchen Namen einzugeben.

In dieser Anwendung wählen wir einen anderen Weg, der auch bei Programmiersprachen üblich ist: wir führen strenge Regeln ein, wie ein Variablenname aufgebaut sein darf, beispielsweise eine beliebige Kombination alphanumerischer Zeichen, also a-z, A-Z, 0-9 sowie dem Sonderzeichen _, wobei das erste Zeichen ein Buchstabe sein muss. Also: "x_1" und "x___1_2" ja, aber "1_" und "_x" nein, und schon gar nicht "x & y" oder "(x & (y | z))".

Als erstes brauchen wir nun eine Funktion, die den Eingabestring, also sowas wie "((a_1 & 0) | y)", in lexikalische Bestandteile zerlegt, also etwa ["(", "(", "a_1", "&", "0", ")", "|", "y" ")"] oder am Besten sogar schon ein bisschen Syntax erkennt, also dass es sich bei & um einen Operator, bei a_1 um eine Variable, bei 0 um eine Boolesche Konstante und bei "(" um eine öffnende Klammer handelt. Solche lexikalischen Einheiten nennt man Tokens. Hier sehen Sie meinen Datentyp BoolToken:

type BoolToken
    = CONST Bool
    | VAR String
    | AND
    | OR
    | NOT
    | OPEN
    | CLOSE

Ein String wie "((a_1 & 0) | y)" entspräche nun also der Tokenliste [OPEN, OPEN, VAR "a_1", AND, CONST False, CLOSE, OR, VAR "y", CLOSE] Diesen Prozess, einen Eingabestring in lexikalische Tokens zu zerlegen, nennt man Lexing. Eine Implementierung, die das tut, nennt man Lexer. Beachten Sie, dass ein Lexer wirklich nur lokal arbeitet und keine größeren Strukturen erkennt. Strings wie "x & | y_2 (!)" würde er anstandlos in die Tokenliste [VAR "x", AND, OR, VAR "y_2", OPEN, NOT, CLOSE] zerlegen.

Die Implementierung des Lexers hat Herr Schellenberger für uns erledigt. Sie müssen nur die Funktion aufrufen:

BooleanLexer.lex
<function> : String -> List BoolToken
BooleanLexer.lex "x & | y_2 (!)"
[VAR "x", AND, OR, VAR "y_2", OPEN, NOT, CLOSE]
BooleanLexer.lex "((a_1 & 0) | y)"
[OPEN, OPEN, VAR "a_1", AND, CONST False, CLOSE, OR, VAR "y", CLOSE]

Die Syntax, die Herrn Schellenbergers Lexer akzeptiert, ist übrigens nicht so streng wie meine oben geschilderte: als Variablennamen ist alles erlaubt außer Whitespace (Leerzeichen, Tab, neue Zeile) und den syntaktisch bedeutsamen Zeichen ( ) & | !.

Einen Parser schreiben

Um nun einen Parser zu entwerfen, beschreibe ich erst einmal formal die Syntax unserer Formeln als Folge von Lex-Tokens. Ich tue dies mit einer formalen Grammatik (mehr dazu in der Veranstaltung Theoretische Informatik). Eine formale Grammatik beschreibt eine Syntax als Menge von Bildungsgesetzen. In unserem Falle steht jedes Bildungsgesetz für einen Punkt in der induktiven Definition 4.8:

\begin{align*} \textnormal{Formel} & ::= \mathtt{CONST\ True} \\ \textnormal{Formel} & ::= \mathtt{CONST\ False} \\ \textnormal{Formel} & ::= \mathtt{VAR} \textnormal{ name} \\ \textnormal{Formel} & ::= \mathtt{OPEN} \textnormal{ Formel } \mathtt{AND} \textnormal{ Formel } \mathtt{CLOSE}\\ \textnormal{Formel} & ::= \mathtt{OPEN} \textnormal{ Formel } \mathtt{OR} \textnormal{ Formel } \mathtt{CLOSE}\\ \textnormal{Formel} & ::= \mathtt{OPEN} \ \mathtt{NOT} \textnormal{ Formel } \mathtt{CLOSE} \end{align*}

Wie können wir nun eine Liste von Lextokens in eine Formel überführen? Die Idee ist, dass wir die Tokens Stück für Stück auf einen Stapel, den Stack legen, bis wir ein Muster erkennen, das einem der sechs Bildungsgesetze entspricht. Dann fassen wir diese Stücke dieses Musters zu einer Formel zusammen. Der Stack schrumpft dann also. Hier sehen Sie es als Klick-Animation:

Wir brauchen für unseren Parser also zwei Dinge: ersten die Tokenliste, die uns der Lexer von Herrn Schellenberger ja bereits liefert; zweitens einen Stack, auf den wir die Tokens legen oder, falls wir ein Bildungsgesetz erkannt haben, mehrere Stack-Elemente in eine Formel zusammenfassen. Der Stack soll also sowohl Elemente vom Typ BoolToken als auch vom Typ Formula enthalten. Das geht in Elm natürlich nicht. Sie brauchen also einen weiteren Datentyp (der bereits in src/Types.elm definiert ist):

type StackElement
    = Formula Formula
    | Token BoolToken

Testataufgabe Implementieren Sie in solutions/VornameNachname.elm eine Funktion

parseStep : BoolToken -> List StackElement -> List StackElement
parseStep token stack = ...

die einen Schritt des Parsers ausführt. Das heißt, sie überprüft, ob das neue token zusammen mit den letzten \(k\) Elementen von stack ein Bildungsgesetz bildet, in welchem Fall es diese Elemente vom Stack nimmt und eine neu gebildete Formel oben (vorne) drauflegt; falls es kein Bildungsgesetz entdeckt, legt es, wie wir in unserer Animation, das neue Token einfach oben auf den Stack.

Ein Bildungsgesetz erkennt es beispielsweise, wenn das neue Token ein VAR "x" ist. Dann muss es nun die Formel Var "x" oben auf den Stack legen. Dafür muss die Formel erst einmal als StackElement verpackt werden. Ihre Funktion parseStep müsste also irgendwo den Code Formula (Var "x") :: stack ausführen. Es hat also den Token erfolgreich in eine Formel umgewandelt.

Spannend wird es, wenn das neue token den Wert CLOSE hat. Dann sollte der neue Token, zusammen mit den obersten paar Werten auf stack einem der drei letzten Bildungsgesetze entsprechen, und Sie müssen dementsprechend eine neue Formel bauen und auf den Stack legen.