Berechenbarkeit und Lambda-Kalkül

  • Marco Block-Berlitz
  • Adrian Neumann
Chapter
Part of the Xpert.press book series (XPERT.PRESS)

Zusammenfassung

In den 1930er Jahren, als sich die Informatik gerade als eigenständige wissenschaftliche Disziplin etablierte und die ersten programmierbaren Rechenmaschinen gebaut wurden, wurde die Frage diskutiert: Welche Probleme lassen sich theoretisch überhaupt durch Computer lösen bzw. berechnen?

In den \( 1930 \)er Jahren, als sich die Informatik gerade als eigenständige wissenschaftliche Disziplin etablierte und die ersten programmierbaren Rechenmaschinen gebaut wurden, wurde die Frage diskutiert: Welche Probleme lassen sich theoretisch überhaupt durch Computer lösen bzw. berechnen?

Um eine Antwort auf diese Frage zu finden, musste zunächst ein formales System definiert werden, das möglichst gut den Begriff der Berechenbarkeit erfasst. Die Idee war, ein einfaches System zu beschreiben, mit dem alle intuitiv berechenbaren Funktionen formuliert werden können. Es haben sich einige konkurrierende Ansätze entwickelt, von denen wir uns den recht abstrakten \( \lambda \)-Kalkül näher ansehen werden. Der Grund dafür ist die einfache Tatsache, dass Haskell auf dem \( \lambda \)-Kalkül basiert und uns ein grundlegendes Verständnis dafür auch das Verständnis für die Funktionsweise von Haskell verbessert.

Es ist nicht möglich, die Eigenschaft „intuitiv berechenbar“ mathematisch exakt zu definieren. Da es aber viele verschiedene Ansätze gibt, den Begriff zu erfassen und sich alle als gleich mächtig herausgestellt haben, glaubt man allgemein, dass diese Systeme tatsächlich alle intuitiv berechenbaren Funktionen abbilden (bekannt als Church Turing These, s. z.B. [?]).

Seither hat sich die Berechenbarkeitstheorie zu einem wichtigen Teilgebiet der theoretischen Informatik entwickelt, dessen Ergebnisse durchaus auch von philosophischem Interesse sind, da hier beispielsweise konkret die Grenzen der Machbarkeit aufgezeigt werden.

19.1 Der Lambda-Kalkül

Der \( \lambda \)-Kalkül ist eine universelle Programmiersprache und Basis verschiedener funktionaler Programmiersprachen [19.19], so auch bei Haskell. Eingeführt wurde der \( \lambda \)-Kalkül \( 1930 \) von Alonso Church [19.33].

Eine Besonderheit des \( \lambda \)-Kalküls ist es, dass alle Komponenten, z.B. Datenstrukturen, über Funktionen dargestellt werden. Wie in jeder funktionalen Programmiersprache gibt es keinen Speicher, der manipuliert werden kann. Wir arbeiten ausschließlich mit Funktionen.

19.2 Formale Sprachdefinition

Der \( \lambda \)-Kalkül ist induktiv definiert. Das heißt, es werden einige wenige primitive Ausdrücke und Regeln eingeführt, aus denen sich komplexere Ausdrücke konstruieren lassen. Alle Ausdrücke müssen dabei eine endliche Größe haben.

19.2.1 Bezeichner

Der einfachste \( \lambda \)-Ausdruck besteht nur aus einem Bezeichner. In der Regel genügen die Kleinbuchstaben des Alphabets, aber prinzipiell lassen sich beliebig aber endlich viele Bezeichner verwenden.

*<Bezeichner> := a,b,c,...,z,...

19.2.2 \( \lambda \)-Funktion

Aus einem Ausdruck lässt sich eine Funktion machen, indem „\( \lambda \) *<Bezeichner>.“ davor geschrieben wird. Der Bezeichner ist dann das Argument der neuen Funktion. Der nach dem Punkt folgende Funktionskörper erstreckt sich dabei soweit wie möglich nach rechts:

*<Funktion> :=\( \lambda \) *<Bezeichner>.<Ausdruck>

Dadurch sind \( \lambda x.x \) und \( \lambda z.xy \) reguläre Ausdrücke.

Es ist üblich, mehrere aufeinanderfolgende \( \lambda \)-Abstraktionen verkürzend aufzuschreiben: \( \lambda x.\lambda y.\lambda z.A\equiv\lambda xyz.A \).

19.2.3 Applikation

Zwei \( \lambda \)-Ausdrücke hintereinander geschrieben ergeben eine Applikation:

*<Applikation> := <Ausdruck><Ausdruck>

So sind \( (\lambda x.x)(\lambda y.xy) \) und \( (\lambda i.i)a \) ebenfalls reguläre Ausdrücke. An dieser Stelle ist es oft ratsam, die beiden Ausdrücke einzuklammern.

19.2.4 Reguläre \( \lambda \)-Ausdrücke

Ein regulärer \( \lambda \)-Ausdruck ist also entweder ein Bezeichner, eine Funktion oder eine Applikation:

*<Ausdruck> := <Bezeichner>|<Funktion>|<Applikation>

Damit haben wir eine rekursive Definition für \( \lambda \)-Ausdrücke angegeben. Weiterhin ist die Klammerung von Ausdrücken möglich. Wobei ohne explizite Klammerung Linksassoziativität vorausgesetzt wird:
$$ \begin{aligned} A_{1}A_{2}A_{3}\ldots A_{n}\equiv(\ldots((A_{1}A_{2})A_{3})\ldots A_{n})\end{aligned} $$
Wir verwenden Kleinbuchstaben für Variablen und Großbuchstaben um ganze Ausdrücke zu bezeichnen. Beispielsweise könnten wir der Übersicht halber \( A:=\lambda x.x \) definieren und damit die Applikation \( (\lambda x.x)(\lambda x.x) \) durch \( AA \) verkürzt angeben.

19.3 Freie und gebundene Variablen

In einem \( \lambda \)-Ausdruck gibt es mit den freien und gebundenen zwei Arten von Variablen. Gebundene Variablen sind all jene, die Bezeichner für Funktionsargumente sind. Kommt eine Variable nur im Körper der Funktion vor, nicht aber in ihren Argumenten, so ist diese frei. Im Ausdruck \( \lambda xy.ayx \) sind \( x \) und \( y \) gebunden und \( a \) ist frei.

Es gibt eine einfache rekursive Vorschrift, um die Menge \( G \) der gebundenen Variablen in einem Ausdruck zu finden, die sich aus der rekursiven Definition herleitet:
  1. 1.

    Ein Bezeichner ist nicht gebunden: \( G(x)=\{\} \)

     
  2. 2.

    Der Bezeichner hinter einem \( \lambda \) ist gebunden: \( G(\lambda x.A)={x}\cup G(A) \)

     
  3. 3.

    Bei Applikationen werden die gebundenen Variablen in den einzelnen Ausdrücken weitergesucht: \( G(AB)=G(A)\cup G(B) \)

     
Es kann leicht überlegt werden, wie sich freie Variablen mit einer ähnlichen Vorschrift finden lassen.

Beachten müssen wir, dass Variablen sowohl frei als auch gebunden vorkommen können, da ein Bezeichner auch mehrfach verwendet werden kann. Beispielsweise kommt \( x \) in dem Ausdruck \( (\lambda x.x)x \) sowohl frei als auch gebunden vor.

19.4 \( \lambda \)-Ausdrücke auswerten

Kommen wir nun dazu, wie sich \( \lambda \)-Ausdrücke auswerten bzw. reduzieren lassen. Das ist erstaunlich einfach, denn es gibt mit der \( \alpha \)-Konversion und der \( \beta \)-Reduktion nur zwei Regeln, die dabei eingehalten werden müssen.

19.4.1 \( \alpha \)-Konversion

Eine gebundene Variable kann umbenannt werden, solange der neue Name noch nicht an eine freie Variable in demselben Teilausdruck vergeben wurde. Das ist nötig, um Mehrdeutigkeiten zu vermeiden. Wir notieren das wie folgt:
$$ \begin{aligned} \lambda x.A\longrightarrow\lambda y.(A[x:=y])\end{aligned} $$
Wir haben den Ausdruck \( \lambda x.A \) und wollen diesen in \( \lambda y.A \) unwandeln. Dann müssen aber alle gebundenen Vorkommen von \( x \) in \( A \) durch \( y \) ebenfalls ersetzt werden. Daraus folgt aber auch, dass beispielsweise \( \lambda x.x \) und \( \lambda i.i \) dieselben Ausdrücke repräsentieren, da \( \lambda x.x\longrightarrow\lambda i.i \) mit \( [x:=i] \).

Wir können also \( \lambda a.ba \) zu \( \lambda c.bc \) umwandeln, nicht jedoch zu \( \lambda b.bb \), da \( b \) im Originalausdruck frei war.

19.4.2 \( \beta \)-Reduktion

Liegt eine Applikation der Form \( (\lambda x.A)B \) vor, so können wir eine \( \beta \)-Reduktion ausführen. Jedes Vorkommen von \( x \) in \( A \) wird dabei durch \( B \) ersetzt. Wir schreiben dann:
$$ \begin{aligned} (\lambda x.A)B\longrightarrow A[x:=B]\end{aligned} $$
Dabei dürfen in \( B \) keine Variablen frei vorkommen, die in \( A \) gebunden sind. Sollte das der Fall sein, muss vorher eine \( \alpha \)-Konversion durchgeführt werden. Ein Ausdruck, in dem eine \( \beta \)-Reduktion ausgeführt werden kann, bezeichnen wir als Redex. Kann keine Reduktion mehr ausgeführt werden, liegt der Ausdruck in Normalform vor.
Aus der Regel für die \( \beta \)-Reduktion wird auch das Currying verständlich. Eine Funktion mit mehreren Argumenten konsumiert während der \( \beta \)-Reduktion ihre Eingaben eine nach der anderen und wird von einer \( k \)-stelligen Funktion zu einer \( (k-1) \)-stelligen und so weiter:
$$ \begin{aligned} \begin{array}{rcl} (\lambda xyz.(xyz))abc & \equiv & (\lambda x.\lambda y.\lambda z.xyz)abc\\ & \stackrel{\beta}{\equiv} & (\lambda y.\lambda z.ayz)bc\\ & \stackrel{\beta}{\equiv} & (\lambda z.abz)c\\ & \stackrel{\beta}{\equiv} & abc\end{array}\end{aligned} $$
Machen wir ein kleines Beispiel, um die beiden Regeln einmal anzuwenden. Wir wollen den Ausdruck \( (\lambda xyz.x(yz))y(\lambda x.xx)(\lambda a.b) \) Schritt für Schritt auswerten:
$$ \begin{aligned} \begin{array}{rcl} (\lambda xyz.x(yz))y(\lambda x.xx)(\lambda a.b) & \stackrel{\alpha}{\equiv} & (\lambda xkz.x(kz))y(\lambda x.xx)(\lambda a.b)\\ & \stackrel{\beta}{\equiv} & (\lambda\underline{k}z.y(kz))\underline{(\lambda x.xx)}(\lambda a.b)\\ & \stackrel{\beta}{\equiv} & (\lambda\underline{z}.y((\lambda x.xx)z))\underline{(\lambda a.b)}\\ & \stackrel{\beta}{\equiv} & y((\lambda\underline{x}.xx)\underline{(\lambda a.b)})\\ & \stackrel{\beta}{\equiv} & y((\lambda\underline{a}.b)\underline{(\lambda a.b)})\\ & \stackrel{\beta}{\equiv} & y(\lambda a.b)\end{array}\end{aligned} $$
An diesem Beispiel sehen wir, dass es manchmal mehr als eine Möglichkeit gibt, Ausdrücke zu reduzieren. Gelegentlich macht es einen Unterschied, welche Reduktionen zuerst ausgeführt werden. Es kann passieren, dass wir aufgrund einer ungünstigen Auswertungsreihenfolge niemals zu einer Normalform gelangen, obwohl diese existiert. Das in diesem Zusammenhang erwähnenswerte Church-Rosser-Theorem besagt allerdings: Wird eine Normalform durch eine Auswertungsreihenfolge erreicht, so ist diese, bis auf eine Umbenennung, immer eindeutig [19.37].

Glücklicherweise gibt es mit der Normal-Order-Reduction eine Auswertungsstrategie, die garantiert zu einer Normalform führt, falls diese existiert. Dabei wird stets der am weitesten links und am weitesten außen, in Bezug auf die Klammerung, stehende Redex zuerst reduziert.

Im Beispiel haben wir diese Reduktionsstrategie bereits verwendet. Oftmals braucht sie allerdings mehr Schritte als unbedingt notwendig. Beim Auswerten von \( \lambda \)-Ausdrücken per Hand, sollten bevorzugt die Reduktionen ausgeführt werden, die den Ausdruck verkürzen.

19.5 Boolesche Algebra

Wir können jetzt \( \lambda \)-Ausdrücke konstruieren und reduzieren, aber dass wir eine vollwertige Programmiersprache vor uns haben, ist noch nicht offensichtlich. Versuchen wir uns also zu überlegen, wie sich die boolesche Algebra im \( \lambda \)-Kalkül beschreiben lässt.

Nun haben wir aber keine Werte im \( \lambda \)-Kalkül, sondern nur Funktionen. Wir müssen also zuerst definieren, wie wir Wahrheitswerte als Funktionen kodieren wollen. Die Definition ist vollkommen willkürlich. Wir könnten zum Beispiel sagen, dass \( \lambda abc.bca \) für *True und \( \lambda xy.xxyx \) für *False stehen. Natürlich müssen wir eine Definition finden, die möglichst einfach zu handhaben ist.

19.5.1 True und False

Später wollen wir die Wahrheitswerte beispielsweise einsetzen, um Fallunterscheidungen in unseren Funktionen realisieren zu können. Wenn ein Ausdruck zu *True reduziert wird, wollen wir das Eine machen, ansonsten das Andere.

Es bietet sich also an, Wahrheitswerte als Funktionen mit zwei Argumenten zu definieren, die eine Projektion auf jeweils eine der beiden Argumente machen. Üblich ist die folgende Definition mit \( T \) für *True und \( F \) für *False:
$$ \begin{aligned} T & :=\lambda xy.x\\ F & :=\lambda xy.y\end{aligned}$$
Wenn wir einen Ausdruck haben, der zu einem auf diese Weise definierten booleschen Wert reduziert wird, können wir noch zwei Funktionen dahinter schreiben. Je nachdem, ob *True oder *False herauskommt, wird dann entweder die erste oder die zweite Funktion weiter reduziert und die andere fällt weg.

Mit diesen Definitionen können wir jetzt die bekannten booleschen Funktionen AND, OR und NOT (s. Abschn. 2.1) herleiten.

19.5.2 Negation

NOT ist eine Funktion, die ein Argument erhält. Wenn es sich dabei um *True handelt, wollen wir *False zurückgeben, ansonsten *True. Wir verwenden die Projektionseigenschaft der Wahrheitswerte:
$$ \begin{aligned} Not:=\lambda x.xFT\end{aligned} $$
Zur besseren Lesbarkeit wollen wir ab dieser Stelle neben den Großbuchstaben auch Platzhalter, wie z.B. \( Not \), für Ausdrücke erlauben. Streng genommen müssten wir z.B. \( N \) verwenden (s. Abschn. 19.2.4).
Wenn wir für \( x \) ein *True einsetzen, wird das erste Argument von \( x \), also *False, zurückgegeben, ansonsten das zweite. Schauen wir uns ein Beispiel an:
$$ \begin{aligned} (\lambda x.xFT)(T)=(\lambda x.xFT)(\lambda xy.x)\stackrel{\beta}{\equiv}(\lambda xy.x)FT\stackrel{\beta^{*}}{\rightsquigarrow}F\end{aligned} $$

19.5.3 Konjunktion und Disjunktion

AND und OR benötigen jeweils zwei Argumente. Wir können aber wieder die Projektionseigenschaften der Wahrheitswerte einsetzen:
$$ \begin{aligned} And:=\lambda xy.xyF\qquad Or:=\lambda xy.xTy\end{aligned} $$

19.6 Tupel

Es lassen sich auch Tupel als Beispiel für eine einfache Datenstruktur im \( \lambda \)-Kalkül definieren. Versuchen wir uns zu überlegen, wie \( n \)-Tupel definiert sind. In einem \( n \)-Tupel sind \( n \) Werte gespeichert. Die Funktionen, die sie repräsentieren sollen, müssen also mindestens \( n \) Argumente haben.

19.6.1 \( 2 \)-Tupel

Jetzt könnten wir probieren, ein \( 2 \)-Tupel als \( \lambda ab.ab \) darzustellen. Damit wenden wir aber die Elemente, die wir eigentlich speichern wollten, aufeinander an. Wir müssen also auf eine bestimmte Weise dafür Sorge tragen, dass im Tupel keine Auswertung stattfinden kann. Das lässt sich zum Beispiel über eine freie Variable realisieren, z.B. \( \lambda ab.pab \). Das funktioniert schon ganz gut, aber nachdem wir die Werte im Tupel gespeichert haben, haben wir keine einfache Möglichkeit mehr, diese wieder auszulesen.

Deswegen sollten wir keine freien Variablen, sondern ein weiteres Argument \( p \) hinzunehmen. So ist ein gefülltes Tupel noch immer eine Funktion, die ein Argument benötigt und ihr Argument auf die Elemente des Tupels anwendet:
$$ \begin{aligned} Pair:=\lambda abp.pab\end{aligned} $$
Diese Definition ermöglicht es uns, ein gefülltes Tupel auf eine Projektionsfunktion anzuwenden, um an die einzelnen Elemente heranzukommen. Demzufolge haben \( n \)-Tupel also \( n+1 \) Argumente.

19.6.2 First und Second

Für \( 2 \)-Tupel sind die Projektionen für das erste und zweite Element isomorph zur Definition von *True und *False:
$$ \begin{aligned} Fst:=\lambda ab.a\quad Snd:=\lambda ab.b\end{aligned} $$
Für \( n \)-Tupel werden analog Funktionen mit \( n \) Argumenten verwendet, die das \( k \)-te Argument zurückliefern.

Wie wir an diesem Beispiel sehen, müssen wir uns stets im Klaren darüber sein, was unsere Funktionen aussagen sollen. Wie so oft ist die Bedeutung der Dinge ganz uns überlassen.

19.7 Listen

In Tupeln können wir \( k \) Werte speichern, aber wir müssen vorher schon wissen, wie groß \( k \) ist. Viel komfortabler wäre es, wenn wir dynamische Listen formulieren und einsetzen könnten. Eine Idee wäre, die Werte als verschachtelte Paare darzustellen:
$$ \begin{aligned} [A,B,C]=(A,(B,(C,Nil)))\end{aligned} $$
Wie wir das aus Haskell bereits kennen, wollen wir folgende Funktionen haben: \( Head \) – Extrahieren des ersten Elements, \( Tail \) – Bildung der Restliste und \( Empty \) – Test auf leere Liste. Außerdem müssen wir uns ein geeignetes \( Nil \)-Element für die leere Liste definieren. Noch wissen wir nicht genau, welche Eigenschaften \( Nil \) haben muss. Das wird sich erst ergeben, wenn wir versuchen, den Test auf eine leere Liste zu definieren.

19.7.1 \( Head \) – Kopf einer Liste

Am einfachsten ist die Funktion \( Head \), die den Kopf einer Liste liefert. Da wir die Liste mit verschachtelten Tupeln realisieren, ist das erste Element einfach die erste Komponente des äußersten Tupels:
$$ \begin{aligned} Head=Fst:=\lambda xy.x\end{aligned} $$

19.7.2 \( Tail \) – Rest einer Liste

Nach dem gleichen Prinzip können wir die Restliste über die Funktion \( Tail \) bilden. Wir nehmen einfach die zweite Komponente des äußersten Tupels:
$$ \begin{aligned} Tail=Snd:=\lambda xy.y\end{aligned} $$

19.7.3 \( Empty \) – Test auf eine leere Liste und Nil

Kommen wir nun dazu, wie wir mit \( Empty \) testen können, ob eine Liste leer ist. Die Funktion, die wir suchen, erhält eine Liste als Argument. Damit wir die Liste irgendwie verarbeiten können, müssen wir etwas für die Selektorfunktion des äußersten Tupels (das \( p \) in \( \lambda abp.pab \)) einsetzen. Wenn wir an der Stelle zwei Argumente sehen, wissen wir, dass die Liste nicht leer ist, und können gleich *False zurückliefern:
$$ \begin{aligned} Empty:=\lambda l.l(\lambda xy.F)\end{aligned} $$
Jetzt brauchen wir uns nur noch ein \( Nil \) so zu definieren, dass *True geliefert wird, wenn wir \( Empty \) darauf anwenden. Es soll also \( Nil\ (\lambda xy.False)=True \) gelten. Dies lässt sich leicht definieren:
$$ \begin{aligned} Nil:=\lambda x.T\end{aligned} $$
Wie wir sehen, können auch etwas kompliziertere Datenstrukturen, wie z.B. Listen, problemlos im \( \lambda \)-Kalkül beschrieben werden. Es können sogar alle anderen Datenstrukturen, die wir bisher besprochen haben, prinzipiell mit Funktionen kodiert werden.

19.8 Arithmetik

So wie wir uns im Abschn. 19.5 überlegt haben, wie wir Wahrheitswerte im \( \lambda \)-Kalkül kodieren können, wollen wir uns jetzt der Arithmetik zuwenden.

19.8.1 Natürliche Zahlen

Zunächst brauchen wir eine geeignete Repräsentation der natürlichen Zahlen. Es gibt viele Möglichkeiten, wie sich diese realisieren lassen. Durchgesetzt hat sich aber die Church-Kodierung [19.33]. Dabei wird analog zur induktiven Definition der natürlichen Zahlen gearbeitet: \( 0 \) ist eine natürliche Zahl, jeder Nachfolger einer natürlichen Zahl ist auch eine natürliche Zahl (s. dazu Abschn. 18.1.1).

Wir verwenden Funktionen mit zwei Argumenten: \( z \), das symbolisch für die \( 0 \) steht, und \( s \), das als die Nachfolgerfunktion interpretiert werden kann. Dadurch ergeben sich die folgenden Definitionen der Zahlen \( 0-3 \):
$$ \begin{aligned} 0 & :=\lambda sz.z\\ 1 & :=\lambda sz.sz\\ 2 & :=\lambda sz.s(sz)\\ 3 & :=\lambda sz.s(s(sz))\end{aligned}$$
Wir haben allgemein \( n:=\lambda sz.A \) mit \( s \) kommt \( n \)-mal im Ausdruck \( A \) vor:
$$ \begin{aligned} n\,:=\lambda sz.\underbrace{s(s(\ldots s}_{n{\text{-mal}}}z)\ldots)\end{aligned} $$
Jetzt können wir uns die üblichen arithmetischen Basisfunktionen überlegen. Fangen wir mit dem Test auf Null an.

19.8.2 \( Zero \) – Der Test auf Null

Wir suchen eine Funktion \( Zero \), die genau dann *True liefert, wenn wir ihr \( \lambda sz.z \) geben und ansonsten zu *False reduziert wird. Das heißt, dass wir *False liefern, sobald ein \( s \) vorkommt.

Die Funktion, die wir für \( s \) einsetzen, muss also immer *False liefern, egal was dahinter noch steht. Sie ist damit \( \lambda x.F \). Wenn nur ein \( z \) da steht, muss *True herauskommen. Wir sollten also für \( z \) *True einsetzen. Damit ergibt sich:
$$ \begin{aligned} Zero:=\lambda n.n(\lambda x.F)T\end{aligned} $$

19.8.3 \( S \) – Die Nachfolgerfunktion

Damit wir den Nachfolger \( S \) einer natürlichen Zahl berechnen können, müssen wir eine Funktion schreiben, die eine Zahl als Argument erhält und eine neue Funktion, also die nachfolgende Zahl, zurückliefert, bei der das erste Argument genau einmal mehr angewendet wird.

Damit wir ein \( s \) mehr an unsere Zahl anhängen können, müssen wir zunächst das \( \lambda \) entfernen, indem wir der Zahl zwei Argumente liefern.

Mit diesen Vorüberlegungen kommen wir recht schnell auf diesen \( \lambda \)-Ausdruck:
$$ \begin{aligned} S:=\lambda nyx.y(nyx)\end{aligned} $$
In der Klammer geben wir der Zahl \( n \) zwei Argumente mit, so dass das \( s \) durch \( y \) und das \( z \) durch \( x \) ersetzt wird. Dann schreiben wir noch ein \( y \) davor. Da mit \( \lambda xy \) noch zwei Argumente übrig sind, haben wir genau das, was wir wollten. Eine Funktion mit zwei Argumenten, bei der das erste Argument genau einmal mehr auf das zweite Argument angewendet wird als in der ursprünglichen Zahl.
Machen wir ein Beispiel, damit die Funktionsweise klar wird. Wir berechnen den Nachfolger \( S \) von \( 2 \):
$$ \begin{aligned} S\,2 & \equiv(\lambda nyx.y(nyx))(\lambda sz.s(sz))\\ & \stackrel{\beta}{\equiv}(\lambda yx.y((\lambda sz.s(sz))yx))\\ & \stackrel{\beta}{\equiv}(\lambda yx.y((\lambda z.y(yz))x))\\ & \stackrel{\beta}{\equiv}(\lambda yx.y(y(yx)))\end{aligned}$$
Wenn wir wollen, können wir anschließend noch eine \( \alpha \)-Konversion zum bekannten \( s \) und \( z \) machen und sehen, dass wir das erwartete Ergebnis erhalten:
$$ \begin{aligned} (\lambda yx.y(y(yx))) & \stackrel{\alpha}{\equiv}(\lambda sz.s(s(sz)))\\ & \equiv3\end{aligned}$$
Natürlich ist die Nachfolgerfunktion nicht eindeutig. Neben der soeben vorgestellten gibt es noch zahlreiche weitere Varianten, die dasselbe Ergebnis liefern.

19.8.4 Die Addition

Da wir schon über die Nachfolgerfunktion \( S \) verfügen, ist es leicht, die Addition zu definieren. Sie ist ja nichts anderes als die wiederholte Anwendung der Nachfolgerfunktion:
$$ \begin{aligned} a+b=\underbrace{S(S(\ldots S}_{a{\text{-mal}}}b)\ldots)\end{aligned} $$
Die wichtige Beobachtung an dieser Stelle ist, dass wir Zahlen so kodiert haben, dass sie ihr erstes Argument \( n \)-mal auf ihr zweites Argument anwenden. Das ist aber genau das, was wir wollen. Folglich ist die Addition im \( \lambda \)-Kalkül so zu definieren:
$$ \begin{aligned} (+):=\lambda ab.aSb\end{aligned} $$
Für eine bessere Lesbarkeit wollen wir auch noch arithmetische Operatoren als Makros für reguläre Ausdrücke erlauben.
Rechnen wir zum Beispiel \( 2+3 \), also \( (+)\,2\ 3 \):
$$ \begin{aligned} (+)\ 2\ 3 & \equiv\overbrace{(\lambda ab.aSb)}^{+}\overbrace{(\lambda sz.s(sz))}^{2}\overbrace{(\lambda sz.s(s(sz)))}^{3}\\ & \stackrel{\beta}{\equiv}(\lambda b.(\lambda sz.s(sz))Sb)(\lambda sz.s(s(sz)))\\ & \stackrel{\beta}{\equiv}(\lambda b.(\lambda z.S(Sz))b)(\lambda sz.s(s(sz)))\\ & \stackrel{\beta}{\equiv}(\lambda b.S(Sb))(\lambda sz.s(s(sz)))\\ & \stackrel{\beta}{\equiv}(S(S(\lambda sz.s(s(sz)))))\\ & \stackrel{\beta^{*}}{\rightsquigarrow}(S(\lambda sz.s(s(s(sz)))))\\ & \stackrel{\beta^{*}}{\rightsquigarrow}\lambda sz.s(s(s(s(sz))))\\ & \equiv5\end{aligned}$$
Wir können uns auch andere Funktionen überlegen, die die Addition darstellen. Beispielsweise können wir dieselben Ideen, die wir bei der Nachfolgerfunktion hatten, auch für die Addition einsetzen und erhalten diesen Ausdruck:
$$ \begin{aligned} (+):=\lambda nmsz.(ns)(msz)\end{aligned} $$
Schauen wir uns auch dafür ein Beispiel an:
$$ \begin{aligned} (+)\ 2\ 3 & \equiv(\lambda\underline{n}msz.(ns)(msz))\underline{(\lambda sz.s(sz))}(\lambda sz.s(s(sz)))\\ & \stackrel{\beta}{\equiv}(\lambda\underline{m}sz.((\lambda sz.s(sz))s)(msz))\underline{(\lambda sz.s(s(sz)))}\\ & \stackrel{\beta}{\equiv}\lambda sz.(\lambda\underline{s}z.s(sz))\underline{s}(\lambda sz.s(s(sz)))sz\\ & \stackrel{\beta}{\equiv}\lambda sz.(\lambda\underline{z}.s(sz))\underline{(\lambda sz.s(s(sz)))}sz\\ & \stackrel{\beta^{*}}{\rightsquigarrow}\lambda sz.(s(s(s(s(sz)))))\end{aligned}$$

19.8.5 Die Multiplikation

Die Multiplikation ist nur eine andere Schreibweise für eine wiederholte Addition. Wir können also die Addition verwenden, um die Multiplikation zu beschreiben:
$$ \begin{aligned} (\times):=\lambda mn.m((+)n)0\end{aligned} $$
Diese Variante ist etwas unüblich. Für gewöhnlich wird die folgende Form angegeben:
$$ \begin{aligned} (\times):=\lambda mnf.m(nf)\end{aligned} $$
Die Auswertung von \( \lambda \)-Ausdrücken sollte mittlerweile kein Problem mehr darstellen, wir geben aber trotzdem mit \( (\times)\,2\,3 \) ein kleines Beispiel für die Auswertung der Multiplikation an:
$$ \begin{aligned} (\times)\,2\ 3 & \equiv(\lambda mnf.m(nf))(\lambda sz.s(sz))(\lambda sz.s(s(sz)))\\ & \stackrel{\beta}{\equiv}(\lambda nf.(\lambda sz.s(sz))(nf))(\lambda sz.s(s(sz)))\\ & \stackrel{\beta}{\equiv}\lambda f.(\lambda sz.s(sz))((\lambda sz.s(s(sz)))f)\\ & \stackrel{\beta}{\equiv}\lambda fz.((\lambda sz.s(s(sz)))f)(((\lambda sz.s(s(sz)))f)z)\\ & \stackrel{\beta}{\equiv}\lambda fz.(\lambda z.(f(f(fz))))(((\lambda sz.s(s(sz)))f)z)\\ & \stackrel{\beta}{\equiv}\lambda fz.f(f(f(((\lambda s.(\lambda z.s(s(sz))))f)z)))\\ & \stackrel{\beta}{\equiv}\lambda fz.f(f(f((\lambda z.f(f(fz)))z)))\\ & \stackrel{\beta}{\equiv}\lambda fz.f(f(f(f(f(fz)))))\end{aligned}$$
Als Ergebnis erhalten wir wie erwartet die \( \lambda \)-Repräsentation der \( 6 \).

19.8.6 Die Vorgängerfunktion

Beim Nachfolger haben wir uns überlegt, dass wir einfach ein zusätzliches \( s \) an die Zahl anhängen müssen. Beim Vorgänger müssen wir folglich ein \( s \) entfernen. Das hat sich allerdings in der Praxis als schwierig herausgestellt. Wir wollen aber eine Lösung vorstellen, die das leisten kann.

Wenn wir den Vorgänger einer Zahl \( n \) wissen wollen, zählen wir einfach mit \( 0 \) beginnend hoch, bis wir bei \( n \) angekommen sind. Dabei merken wir uns in einem Tupel immer die Zahl, die an der vorhergehenden Stelle stand, also den Vorgänger. Nach dem Fertigzählen haben wir gerade \( n \) und den Vorgänger \( n-1 \).

Um die Übersicht zu behalten, definieren wir uns zwei Funktionen: Die erste Funktion \( \phi \) führt einen Schritt aus und merkt sich die vorherige Zahl. Die zweite Funktion \( P \) liefert dann den tatsächlichen Vorgänger.

Die Funktion \( \phi \) arbeitet auf einem Tupel, in dem die aktuelle Zahl und deren Vorgänger gespeichert werden. Im Tupel steht also \( (k-1,\, k) \). Wir wollen uns jetzt \( k \) merken und \( k+1 \) in die zweite Komponente schreiben. Die erste Komponente lassen wir dabei wegfallen:
$$ \begin{aligned} \phi:=\lambda p.Pair\ (Snd\ p)(S\ (Snd\ p))\end{aligned} $$
Diese Funktion wollen wir jetzt \( n \)-mal anwenden. Das heißt, wir wollen sie hinter die Zahl \( n \) schreiben. Als Startwert nehmen wir ein Tupel, in dem \( (0,0) \) steht. Damit erhalten wir dann den Vorgänger:
$$ \begin{aligned} P:=\lambda n.Fst\ ((n\phi)(Pair\ 0\ 0))\end{aligned} $$
Mit dem Vorgänger können wir jetzt auch die Subtraktion definieren. Das ist leicht und wird dem Leser als Übung überlassen.

19.9 Rekursion

Bisher haben wir schon viele interessante Konzepte des \( \lambda \)-Kalküls kennengelernt. Wir können mit booleschen Werten und mit Zahlen rechnen und einfache Datenstrukturen einsetzen. Allerdings fehlt noch das grundlegende Entwurfskonzept Rekursion, das wir mehrfach in Haskell eingesetzt haben. In Haskell war es einfach, im \( \lambda \)-Kalkül ist es allerdings mit einem Kniff verbunden, den wir ausführlich vorstellen werden.

19.9.1 Alternative zu Funktionsnamen

Wir haben zwar den Funktionen, die wir uns bisher definiert haben, Namen gegeben, aber anders als in Haskell sind diese Namen nur Platzhalter, die uns das viele Schreiben ersparen. Im \( \lambda \)-Kalkül gibt es keine Funktionsnamen. Folglich können wir die Namen auch nicht in der Funktion verwenden, wie wir es von der Rekursion kennen. Wir brauchen eine andere Möglichkeit um an die Definition der Funktion im Inneren der Funktion selbst heranzukommen.

Bis wir uns überlegt haben, wie wir das anstellen werden, können wir in unseren Funktionen ja schon einmal einen Platzhalter für die Rekursion als zusätzliches Argument hernehmen und zum Beispiel die Fakultätsfunktion vorbereitend hinschreiben:
$$ \begin{aligned} fak^{\prime}:=\lambda fn.(Zero\ n)\ 1\ ((\times)n(f(P\ n)))\end{aligned} $$
Was wir jetzt also machen wollen, ist die eigentliche Fakultätsfunktion folgendermaßen zu definieren: \( fak:=fak^{\prime}\ fak \). Damit wird für den Platzhalter \( f \) die Definition der Fakultätsfunktion eingesetzt und anschließend können wir einen rekursiven Aufruf machen. Wir müssen also auf irgendeine Weise die Definition der Fakultätsfunktion geschickt beschreiben.

19.9.2 Fixpunktkombinatoren

Dieses Problem lässt sich mit den sogenannten Fixpunktkombinatoren lösen [19.30]. Das sind Funktionen \( F \) mit der erstaunlichen Eigenschaft \( Fx=x(Fx) \). Wenn wir so ein \( F \) haben, können wir einfach \( fak:=F\ fak^{\prime} \) schreiben. Das scheint ausgesprochen praktisch zu sein. Versuchen wir also, uns zu überlegen, wie wir so ein \( F \) definieren können.

Die erste Beobachtung ist, dass die Expansion \( Fx=x(Fx) \) möglicherweise nie terminiert und eine unendliche Kette von \( x \) produziert. Fangen wir also mit einer Funktion an, die bei der Auswertung niemals terminiert.

Die einfachste Funktion mit dieser Eigenschaft ist vermutlich \( (\lambda x.(xx))(\lambda x.(xx)) \). Jetzt möchten wir diese Funktion derart erweitern, dass noch ein weiterer Term gebraucht wird, der bei der Auswertung einmal nach vorn kopiert wird. Wir möchten also um das \( x \) erweitern in \( Fx=x(Fx) \). Natürlich dürfen wir diesen Term nicht weglassen, damit sich die Kette fortsetzen lässt. Wir erhalten:
$$ \begin{aligned} F:=(\lambda xf.f(xxf))(\lambda xf.f(xxf))\end{aligned} $$
Probieren wir die Auswertung einmal aus:
$$ \begin{aligned} FA & \equiv(\lambda xf.f(xxf))(\lambda xf.f(xxf))A\\ & \stackrel{\beta}{\equiv}(\lambda f.f((\lambda xf.f(xxf))(\lambda xf.f(xxf))f))A\\ & \stackrel{\beta}{\equiv}A((\lambda xf.f(xxf))(\lambda xf.(xxf))A)\\ & \equiv A(FA)\end{aligned}$$
Wie erwartet erhalten wir das korrekte Ergebnis. Wir können jetzt also schreiben:
$$ \begin{aligned} fak=\overbrace{(\lambda xf.f(xxf))(\lambda xf.f(xxf))}^{F}fak^{\prime}\end{aligned} $$
Damit wir uns durch die Notation nicht verwirren lassen, verwenden wir Zahlen als Platzhalter für die entsprechenden \( \lambda \)-Ausdrücke und kürzen auch sonst, wie gewohnt, so weit wie möglich ab. Berechnen wir doch mal \( fak\,3 \):
$$ \begin{aligned} fak\ 3 & \equiv F\ fak^{\prime}\ 3\\ & \stackrel{\beta^{*}}{\rightsquigarrow}fak^{\prime}\ \overbrace{(F\ fak^{\prime})}^{fak}\ 3\\ & \equiv(\lambda fn.(Zero\ n)\ 1\ ((\times)n\ (f(P\ n))))\ fak\ 3\\ & \stackrel{\beta^{*}}{\equiv}(Zero\ 3)\ 1\ ((\times)3\ (fak\ (P\ 3)))\\ & \stackrel{\beta^{*}}{\rightsquigarrow}(\times)3\ (fak\ 2)\\ & \stackrel{\beta^{*}}{\rightsquigarrow}(\times)3\ ((\times)2\ ((\times)1(fak\ 0)))\\ & \stackrel{\beta}{\equiv}(\times)3\ ((\times)2\ ((\times)1\ ((Zero\ 0)\ 1\ ((\times)0\ (fak\ (P\ 0)))))\\ & \stackrel{\beta^{*}}{\rightsquigarrow}(\times)3\ ((\times)2\ ((\times)1\ 1))\\ & \equiv6\end{aligned}$$
Mit Fixpunktkombinatoren ist es also ganz leicht, Rekursion im \( \lambda \)-Kalkül darzustellen. Wir müssen uns lediglich eine Hilfsfunktion mit einem Platzhalter für den rekursiven Aufruf überlegen und diese hinter einen Fixpunktkombinator schreiben.

Es stellt sich heraus, dass es nicht nur diesen einen Fixpunktkombinator gibt, den wir uns selbst überlegt haben, sondern unendlich viele. Der von uns verwendete wurde erstmals von Alan Turing beschrieben und heißt deswegen auch Turing-Fixpunktkombinator [19.30].

Deutlich bekannter ist der \( Y \)-Kombinator:
$$ \begin{aligned} Y:=\lambda f.(\lambda x.f(xx))(\lambda x.f(xx))\end{aligned} $$
In der Literatur sind noch viele weitere zu finden, die sich auf die gleiche Weise einsetzen lassen.

19.10 Projekt: \( \lambda \)-Interpreter

Da es mühevoll ist, \( \lambda \)-Ausdrücke von Hand zu reduzieren, wollen wir uns in diesem Abschnitt ein Haskellprogramm schreiben, das das für uns erledigt.

19.10.1 \( \lambda \)-Ausdrücke repräsentieren

Dazu müssen wir zuerst einen geeigneten Datentypen definieren, um \( \lambda \)-Ausdrücke zu repräsentieren. Es gibt hier viele Möglichkeiten. Am naheliegendsten ist es aber, die induktive Definition der \( \lambda \)-Ausdrücke aus dem Abschn. 19.2 abzuschreiben: * data Lam = Var Char | – Variablen L Char Lam | – Abstraktionen App Lam Lam – Applikationen deriving (Show, Eq)

Damit wir etwas eleganter arbeiten können, definieren wir uns außerdem eine Faltung für diesen Typen. Wir erinnern uns, dass die Faltung für Listen den Listenkonstruktor *(:) durch eine Funktion *f ersetzt hat (s. Abschn. 6.3).

\( \lambda \)-Ausdrücke haben drei Konstruktoren, deswegen übergeben wir dieser Faltung drei Funktionen *v, *l und *a sowie einen Startwert *s: * foldLam v l a s (Var c) = v c s foldLam v l a s (L c lam) = l c (foldLam v l a s lam) foldLam v l a s (App lam lam2) = a (foldLam v l a s lam) (foldLam v l a s lam2)

Damit können wir alle \( \lambda \)-Ausdrücke in Haskell repräsentieren. Im folgenden Abschnitt werden wir damit beginnen, die Ausdrücke auszuwerten.

19.10.2 Auswerten von \( \lambda \)-Ausdrücken

Jetzt definieren wir eine Funktion *evaluate, die einen Ausdruck soweit wie möglich reduziert. Alle Reduktionen in einem Schritt zu machen, scheint ganz schön schwierig zu sein. Deswegen machen wir immer nur einen Reduktionsschritt und vergleichen, ob sich etwas verändert hat: * evaluate x | x == x' = x | otherwise = evaluate x' where x' = reduce x

Die Funktion *reduce können wir jetzt mit einer Faltung definieren. Wir wollen den Ausdruck entlanglaufen und unverändert lassen, bis wir auf die Applikation einer Funktion in einem Ausdruck treffen.

Dann wollen wir alle Vorkommen der gebundenen Variablen durch den Ausdruck ersetzen. Das lässt sich z.B. so definieren: * reduce = foldLam (\c _-> Var c) L app undefined app (L c l) x = substitute c x l app x y = App x y

Da uns nur ersteres interessiert, machen wir in der Funktion * app eine Fallunterscheidung zwischen einer Funktionsanwendung und allem anderen. Für den Startwert übergeben wir *undefined, weil dieser nie benötigt wird. Mit *(\c _ -> Var c) ignorieren wir den Startwert.

Durch die Faltung wird sichergestellt, dass wir an allen Stellen die Reduktionen ausführen, an denen es auch möglich ist.

Wie können wir jetzt die Funktion *substitute definieren? Wir müssen den Ausdruck entlanglaufen, bis wir ein \( \lambda \) antreffen. Dann müssen wir testen, ob die gebundene Variable der entspricht, die wir ersetzen wollen. In diesem Fall brauchen wir nicht mehr weiter zu machen, weil die neue Bindung die alte überschreibt. Wir können die Rekursion beenden und den ganzen Ausdruck unverändert zurückliefern. Ansonsten laufen wir in die Tiefe des Ausdrucks und ersetzen die passenden Variablen: * substitute c x (L c' l) | c == c' = L c' l | otherwise = L c' (substitute c x l)

In allen anderen Fällen, also wenn wir kein \( \lambda \) haben, lassen wir den Ausdruck unverändert und arbeiten auf den Unterausdrücken rekursiv weiter: * substitute c x (App l l') = App (substitute c x l) (substitute c x l') substitute c x (Var c') | c == c' = x | otherwise = Var c' Soweit so gut. Das können wir jetzt schon ausprobieren: * Hugs> evaluate (App (L 'c' (Var 'c')) (Var 'd')) Var 'd' Hugs> evaluate (App (L 'c' (L 'd' (Var 'c'))) (Var 'd')) L 'd' (Var 'd')

Das erste Beispiel ist korrekt, da \( (\lambda c.c)d\stackrel{\beta}{\equiv}d \). Bei der zweiten Eingabe wurde allerdings eine falsche Ersetzung vorgenommen, da \( (\lambda c.\lambda d.c)d\stackrel{\beta}{\equiv}\lambda e.d \) ist und nicht wie das Ergebnis zeigt \( \lambda d.d \).

19.10.3 Freie und gebundene Variablen

Wir haben bisher noch keine \( \alpha \)-Konversionen betrachtet. Dazu müssen wir erst einmal die freien und die gebundenen Variablen eines Ausdrucks bestimmen.

Damit wir effiziente Mengenoperationen ausführen können, benutzen wir *Data.Set mit einem *import qualified Data.Set as S. Dann lässt sich der Algorithmus zum Finden der freien und gebundenen Variablen elegant als Faltung ausdrücken. Das kann dann zum Beispiel so aussehen: * vars :: Lam -> (S.Set Char, S.Set Char) vars x = (frei, gebunden) where frei = S.difference alle gebunden alle = foldLam S.insert (const id) S.union S.empty x gebunden = foldLam (const id) S.insert S.union S.empty x

An dieser Stelle wird der Startwert der Faltung auch tatsächlich einmal verwendet. Da wir Mengen rekursiv aufbauen wollen, müssen wir als Startwert die leere Menge übergeben.

19.10.4 Wörterbuch für Substitutionen

Jetzt wollen wir feststellen, welche Variablen bei einem *substitute c x l in *l umbenannt werden müssen. Das sind alle, die in der Menge \( \mbox{frei}(x)\cap\mbox{gebunden}(l) \) vorkommen. Anschließend legen wir uns ein Umbenennungswörterbuch an.

Dazu benutzen wir die Datenstruktur aus *Data.Map, die wir wieder qualifiziert importieren. Wir definieren uns die Menge aller zur Verfügung stehenden Variablenbezeichnungen, also aller Kleinbuchstaben, von der wir die in *l gebundenen Variablen entfernen. Dann falten wir den Ausdruck weiter und ersetzen dabei die Variablen, die wir im Wörterbuch finden.

Das alles führen wir bereits in der *app-Funktion durch, bevor wir *substitute aufrufen: * app (L c l) x = substitute c x l' where l' = rename l

Wie schon gesagt, können wir die Umbenennung durch eine Faltung ausdrücken. Wir schreiben uns dazu eine Hilfsfunktion *modify, die das eigentliche Umbenennen übernimmt. Dabei müssen wir nur Variablen und \( \lambda \) betrachten, Applikationen lassen wir unverändert: * rename = foldLam (modify (\c _ -> Var c)) (modify L) App undefined

Die Funktion * modify sieht im Wörterbuch nach, ob wir die aktuelle Variable umbenennen müssen. Falls ein Wörterbucheintrag vorliegt, wird die Funktion, die wir als erstes Argument bekommen haben, auf den neuen Namen, ansonsten auf den alten Namen, angewendet: * modify f c s= let c' = M.lookup c map in case c' of (Just newVar) -> f newVar s _ -> f c s

Das Wörterbuch erstellen wir, wie oben beschrieben, für den Schnitt zwischen freien Variablen in *x und gebundenen Variablen in *l, *vs. Jedem Element dieser Menge weisen wir einen neuen Buchstaben zu, der noch nicht als gebundene Variable im Ausdruck vorkommt: * map = foldr (uncurry M.insert) M.empty (zip (S.toAscList vs) (S.toAscList (S.difference identifiers b)))

In den obigen Definitionen haben wir die folgenden Werte verwendet, die wir hier der Vollständigkeit halber aufführen: * (f,_) = vars x (_,b) = vars l vs = S.intersection f b identifiers = S.fromAscList (['a'..'z'])

Der Rest des Programms bleibt unverändert. Jetzt liefert der Interpreter auch das erwartete, korrekte Ergebnis: * Main> evaluate (App (L 'c' (L 'd' (Var 'c'))) (Var 'd')) L 'a' (Var 'd')

Somit können wir mit unserem Programm schon \( \lambda \)-Ausdrücke auswerten.

19.10.5 \( \lambda \)-Parser

Die manuelle Eingabe der Ausdrücke ist aber noch mühselig und etwas schlecht zu lesen. Besser wäre es, wenn wir einen \( \lambda \)-Ausdruck aus einem String herauslesen könnten. Dazu müssen wir eine Funktion schreiben, die einen String in die Datenstruktur übersetzt.

Solche Funktionen werden im Allgemeinen als Parser bezeichnet. In Haskell gibt es mit Parsec eine sehr schöne Bibliothek, um komplizierte Parser zu schreiben [19.72]. Da \( \lambda \)-Ausdrücke aber sehr leicht zu parsen sind, wollen wir uns nicht in eine komplizierte Bibliothek einarbeiten, sondern unsere eigene Funktion dafür schreiben.

Parsen kann im Allgemeinen nicht funktionieren, wenn die Eingabe kein gültiger Ausdruck ist. In unserem Fall also ein \( \lambda \)-Ausdruck. Deswegen verwenden wir zum Parsen die Monade *Maybe (s. Kap. 17). Wenn das Parsen fehlschlägt, geben wir einfach *Nothing zurück.

Wir wollen \( \lambda \)-Ausdrücke linksassoziativ parsen. Das geht am besten, wenn wir den bisher geparsten Teil des Ausdrucks in einem akkumulierenden Parameter mitführen. Es gibt vier Fälle, die wir dabei beachten müssen:
  1. 1.

    Wir treffen auf einen leeren String. Dann geben wir den bereits geparsten Ausdruck zurück.

     
  2. 2.

    Wir treffen auf eine Variable.

     
  3. 3.

    Wir treffen auf ein \( \lambda \). Das wollen wir mit einem Backslash schreiben.

     
  4. 4.

    Wir treffen auf eine öffnende Klammer. Dann müssen wir die passende schließende Klammer finden und den Strings dort teilen, um die einzelnen Abschnitte zu einer Applikation zu parsen.

     
Dazu definieren wir uns zwei Hilfsfunktionen. Die Funktion *maybeApp, testet zwei Ausdrücke auf eine Applikation, wenn der zweite Ausdruck nicht *Nothing ist. Der erste Parameter wird später der Akkumulator sein, den wir mit uns führen. Da er zwischenzeitlich leer sein darf, kann das erste Argument *Nothing sein, ohne dass ein Parsefehler vorliegt: * maybeApp _ Nothing = Nothing maybeApp Nothing (Just s) = Just s maybeApp (Just x) (Just y) = Just $ App x y

Die zweite Funktion *splitAtBrace, teilt einen String an der Position auf, die zur ganz links stehenden Klammer passt. Wir implementieren sie durch das einfache Zählen der öffnenden und schließenden Klammern. Wenn wir genauso viele öffnende wie schließende Klammern gesehen haben, sind wir an der richtigen Stelle.

Dabei merken wir uns den bereits gesehenen Teil in einem zusätzlichen Parameter. Sollte der String zu Ende sein, bevor wir die passende schließende Klammer gesehen haben, geben wir *Nothing zurück. Der Ausdruck war dann falsch geklammert und konnte nicht erfolgreich geparst werden: * splitAtBrace (')':s) f 1 = Just (reverse f, s) splitAtBrace ('(':r) f n = splitAtBrace r ('(':f) (n+1) splitAtBrace (')':r) f n = splitAtBrace r (')':f) (n-1) splitAtBrace (c:r) f n = splitAtBrace r (c:f) n splitAtBrace "" _ _ = Nothing

Nun zur eigentlichen Funktion *parse. Wir müssen hier die vier auftretenden Fälle abarbeiten. Sollte irgendetwas unerwartetes auftreten, beenden wir das Parsen mit einem *Nothing.

Der einfachste Fall ist die Verarbeitung einer leeren Eingabe: * parse = parseL Nothing parseL :: Maybe Lam -> String -> Maybe Lam parseL s "" = s

Der zweite Fall behandelt Buchstaben, also Variablen im \( \lambda \)-Ausdruck: * parseL s (c:r) | isLetter c = parseL (maybeApp s (Just $ Var c)) r

Im dritten Fall wollen wir *\abc. parsen. Um uns die Aufgabe leichter zu machen, verändern wir das zu *\a\b\c\. (analog zu der Regel in Abschn. 19.2.2). So landen wir mit unserem Pattern matching immer in demselben Fall. Wir unterscheiden mit Guards, ob wir beim *. angelangt sind, oder ob wir den Startwert auf ein neues \( \lambda \) applizieren müssen. In diesem Fall fangen wir mit einem neuen Startwert *Nothing an, da sich das \( \lambda \) so weit wie möglich nach rechts erstreckt. Natürlich geben wir *Nothing zurück, wenn das weitere Parsen wieder fehlschlägt: * parseL s ('\\':c:r) | c == '.' = parseL s r | otherwise = maybeApp s (parseL Nothing ('\\':r) >>= return.(L c))

Im vierten Fall müssen wir die Eingabe anhand der Klammern aufteilen. Hier können mehrere Dinge schief gehen. Wir verwenden daher die *do-Notation, die automatisch dafür sorgt, dass *Nothing zurückgegeben wird, sobald eine Funktion fehlschlägt: * parseL s ('(' :r) = let parts = splitAtBrace r [] 1 in do (a,e) <- parts s' <- (maybeApp s (parseL Nothing a)) parseL (Just s') e

Jetzt haben wir alles zusammen, um bequem \( \lambda \)-Ausdrücke verarbeiten zu können. Wir können noch eine eigene *Show Instanz schreiben, damit auch die Ausgabe lesbarer wird: * Hugs> parsen "(\\wyz.y(wyz))(\\sz.s(sz))" >>= return.evaluate Just \y.\z.(y(y(yz))) Hugs> parsen "(\\sz.s(s(sz)))(\\sz.s(sz))" >>= return.evaluate Just \z.\a.(z(z(z(z(z(z(z(za))))))))

Unser letztes Haskellprojekt hat uns gezeigt, dass wir in der Lage sind, den \( \lambda \)-Kalkül in Haskell komplett zu simulieren. Damit haben wir quasi den kleinen Bruder von Haskell in Haskell programmiert.

Eine alternative Einführung in den \( \lambda \)-Kalkül, die zudem sehr lustig ist, bietet [19.73]. Mit Hilfe von bunten Alligatoren, die sich gegenseitig verspeisen, werden die Reduktionsschritte bildhaft erläutert.

19.11 Übungsaufgaben

Aufgabe 1) Welche der folgenden Ausdrücke sind gültige \( \lambda \)-Ausdrücke?

(i) \( \lambda x.yz \), (ii) \( \lambda x.y\lambda z \), (iii) \( (\lambda x.xx)\lambda x.xx \), (iv) \( \lambda abcdefg.istgueltig \), (v) \( \lambda.(\lambda x.y) \)

Spezifizieren Sie in den gültigen Ausdrücken die freien und gebundenen Variablen.

  Aufgabe 2) Werten Sie die folgenden beiden Ausdrücke aus:

(i) \( 2\,2 \), (ii) \( (\lambda x.x)((\lambda abc.b(abc))(\lambda fx.f(fx))) \)

  Aufgabe 3) Definieren Sie die Exponentiation im \( \lambda \)-Kalkül. Es gibt mindestens zwei nichtrekursive und eine rekursive Variante.

  Aufgabe 4) Definieren Sie die Subtraktion im \( \lambda \)-Kalkül, ohne die Rekursion dabei einzusetzen.

  Aufgabe 5) Definieren Sie die ganzzahlige Division im \( \lambda \)-Kalkül.

  Aufgabe 6) Definieren Sie eine Funktion im \( \lambda \)-Kalkül, die die \( n \)-te Fibonaccizahl berechnet.

  Aufgabe 7) Schreiben Sie für den Datentypen *Lam eine Instanz für *Show und eine Instanz für *Read.

  Aufgabe 8) Erweitern Sie den \( \lambda \)-Parser derart, dass Zahlen und Rechenzeichen verwendet werden können. Beispielsweise sollte *\a.+3b ein gültiger \( \lambda \)-Ausdruck sein.

Copyright information

© Springer Berlin Heidelberg 2011

Authors and Affiliations

  • Marco Block-Berlitz
  • Adrian Neumann
    • 1
  1. 1.Universität des SaarlandesSaarbrückenDeutschland

Personalised recommendations