Lambda Calculus Interpreter
În cadrul acestei teme va trebui să realizezi un interpretor de expresii lambda în Haskell.
O să definim o expresie lambda cu ajutorul următorului TDA:
data Lambda = Var String | App Lambda Lambda | Abs String Lambda
Variabilele sunt declarate de tipul String, pentru simplitate o să considerăm variabilă orice șir de caractere format numai din litere mici ale alfabetului englez.
1. Evaluation
- redex - o expresie reductibilă, i.e. are forma $( \lambda x.e_1 \ e_2 $)
- normal-form - expresie care nu mai poate fi redusa (nu contine niciun redex)
Evaluarea unei expresii lambda constă în realizarea de $\beta$-reducerii până ajungem la o expresie echivalentă în formă normală.
Un detaliu de implementare este că înainte de a realiza $\beta$-reducerea, va trebui să rezolvăm posibilele coliziuni de nume.
Dacă am încerca să reducem un redex fără a face substituții textuale există riscul de a pierde întelesul original al expresiei.
Spre exemplu redex-ul: $(\lambda x.\lambda y.(x \ y) \ \lambda x.y)$, ar fi redus la: $\lambda y.(\lambda x.y \ y)$. Acest efect nedorit are denumirea intuitivă de variable-capture: Variabila inițial liberă $ y$ a devenit legată după reducere.
Puteți observa că expresia și-a pierdut sensul original, pentru că y-ul liber din $\lambda x.y$ e acum bound de $\lambda y.$ din expresia în care a fost înlocuit.
Astfel, reducerea corectă ar fi: $\lambda a.(\lambda x.y \ a)$.
Pentru a detecta și rezolva variable capture, o să pregătim câteva funcții ajutătoare:
1.1. (2.5p) Implementați funcția auxiliară vars care returnează o listă cu toate String-urile care reprezintă variabile într-o expresie.
1.2. (3p) Implementați funcția auxiliară freeVars care returnează o listă cu toate String-urile care reprezintă variabile libere într-o expresie. (notă: dacă o variabilă este liberă în expresie în mai multe contexte, o să apară o singură dată în listă).
1.3. (7p) Implementați funcția auxiliară newVar care primește o listă de String-uri și intoarce cel mai mic String lexicografic care nu apare în listă (e.g. newVar [“a”, “b”, “c”] o să întoarcă “d”).
1.4. (3p) Implementați funcția isNormalForm care verifică daca o expresie este în formă normală.
1.5. (11p) Implementați funcția reduce care realizează $\beta$-reducerea unui redex luând în considerare și coliziunile de nume. Funcția primește redex-ul 'deconstruit' și returnează expresia rezultată.
reduce :: String -> Lambda -> Lambda -> Lambda reduce x e_1 e_2 = undefined -- oriunde apare variabila x în e_1, este inlocuită cu e_2
Acum că putem reduce un redex, vrem să reducem o expresie la forma ei normală. Pentru asta trebuie să implementăm o strategie de alegere a redex-ului care urmează să fie redus, și să o aplicăm până nu mai există niciun redex. În această temă o să implementăm 2 strategii: Normală și Aplicativă (studiate la curs).
- Normală: se alege cel mai exterior, cel mai din stânga redex
- Aplicativă: se alege cel mai interior, cel mai din stânga redex
O să facem reducerea „step by step”, implementăm o funcție care reduce doar următorul redex comform unei strategii. Apoi aplicăm acesți pași până expresia rămasă este în formă normală.
1.6. (5p) Implementați funcția normalStep care aplică un pas de reducere după strategia Normală.
1.7. (5p) Implementați funcția applicativeStep care aplică un pas de reducere după strategia Aplicativă.
1.8. (3.5p) Implementați funcția simplify, care primeste o funcție de step și o aplică până expresia rămâne în formă normală, și întoarce o listă cu toți pași intermediari ai reduceri.
2. Parsing
Momentan putem să evaluăm expresii definite tot de noi sub formă de cod. Pentru a avea un interpretor funcțional, trebuie să putem lua expresii sub forma de șiruri de caractere și să le transformăm în TDA-uri (acest proces se numește parsare).
O gramatică pentru expresii lambda este:
<lambda> ::= <abs> | <app>
<abs> ::= '\' <variable> '.' <lambda>
<app> ::= <atom> (' ' <atom>)*
<atom> ::= '(' <lambda> ')' | <variable>
<variable> ::= <variable><alpha> | <alpha>
<alpha> ::= 'a' | 'b' | 'c' | ... | 'z'
(x (y z))). Exemple:
x y zse parsează ca((x y) z)\x.x yse parsează ca\x.(x y)(abstracția consumă tot corpul până la sfârșit / o paranteză închisă)(x y)șix yproduc același TDA
2.1. (20p) Implementați funcția parseLambda care parsează un String și returnează o expresie
Parser din schelet, nu o să fie punctată pentru cerințele de parsare.
newtype Parser a = Parser { parse :: String -> Maybe(a, String) }
Obervați că tipul care îl întoarce funcția de parsare este Maybe(a, String), el întoarce Nothing dacă nu a putut parsa expresia sau Just (x, s) dacă a parsat x, iar din String-ul original a rămas sufix-ul s.
3. Steps towards a programming language
Folosind parserul și evaluatorul anterior, putem să evaluăm orice rezultat computabil, expresiile lambda fiind suficient de expresive, însă, cum probabil ați văzut la curs și laborator, este foarte greu să scrii astfel de expresii. Pentru a fi mai ușor de folosit, vrem să putem denumi anumite sub-expresii pentru a le putea refolosi ulterior. Pentru asta o să folosim conceptul de macro. Primul pas ar fi să extindem definiția unei expresii cu un constructor Macro care acceptă un String ca parametru (denumirea macro-ului). O să introducem și sintaxa: orice șir de caractere format numai din litere mari ale alfabetului englez si cifre e considerat un macro.
Câteva exemple de expresii cu macro-uri sunt:
$ TRUE $
$\lambda x.FALSE $
$ \lambda x.(NOT \ \lambda y.AND) $
Pentru a putea folosi macro-uri, trebuie să introducem noțiunea de context computațional. Contextul în care evaluăm o expresie este pur și simplu un dicționar de nume de macro-uri și expresii pe care aceste nume le înlocuiesc. Astfel când evaluăm un macro, facem pur și simpu substituție textuală cu expresia găsită în dicționar.
În cazul în care nu găsim macro-ul în context, nu o să știm cum să evaluăm expresia, asa că am vrea să întoarcem o eroare. O să extindem tipul de date întors la Either String [Lambda] și o să întoarce Left în caz de eroare și Right în cazul în care evaluarea se termina cu succes.
3.1. (14p) Implementați funcția simplifyCtx care ia un context și o expresie care poate să conțină macro-uri, face substituțiile macro-urilor (sau returnează eroare dacă nu reușeste) și evaluează expresia rezultată folosind strategia de step primită. (Hint: putem refolosi simplify ca să nu rescriem logica?)
Maybe sau Either poate să devina complicat dacă folosim case-uri pe toate variabilele, pentru a ușura lucrul cu ele există monade definite atât peste tipul de date Maybe cât și peste Either, poți folosi do notation să îți ușurezi viața.
lookup este foarte utilă pentru lucrul cu dicționare (liste de perechi)
Ultimul pas ca să ne putem folosi de macro-uri e să găsim o metodă de a le defini. Pentru asta o sa definim conceptul de linie de cod:
data Line = Eval Lambda | Binding String Lambda
O linie de cod poate să fie ori o expresie lambda, ori o definiție de macro. Astfel daca o sa evaluam mai multe linii de cod, în expresii o sa ne putem folosi de macro-urile definite anterior.
3.2. (3p) Modificați parser-ul vostru astfel încât să parsați și expresii care conțin macro-uri.
3.3. (3p) Implementați funcția parseLine care să parseze o linie de cod, dacă găsește erori o să întoarcă o eroare (sub formă de String).
4. De Bruijn
Lucrul cu nume de variabile face $\beta$-reducerea complicată: trebuie să detectăm variable capture și să generăm nume noi (vezi newVar). De Bruijn indices elimină complet problema: fiecare variabilă legată e înlocuită cu un număr care indică câte $\lambda$-uri trebuie urcate până la binder-ul ei. Două expresii $\alpha$-echivalente devin astfel identice sintactic.
Spre exemplu:
- $\lambda x.x$ devine $\lambda.0$
- $\lambda x.\lambda y.x$ (combinatorul K) devine $\lambda.\lambda.1$
- $\lambda x.\lambda y.(x \ y)$ devine $\lambda.\lambda.(1 \ 0)$
Variabilele libere nu au un binder pe care să îl „numere”, așa că le păstrăm separat prin numele lor original. TDA-ul folosit este:
data DeBruijn = DBVar Int | DBFree String | DBApp DeBruijn DeBruijn | DBAbs String DeBruijn
DBAbs păstrează numele original al variabilei pe care a legat-o — este doar un hint pentru conversia înapoi spre reprezentarea cu nume, și nu este luat în considerare la egalitatea structurală (vezi instanța Eq din schelet).
4.1. / 4.2. (7p) Implementați funcțiile toDB care convertește o expresie Lambda la reprezentarea De Bruijn. Funcția primește și un context (o listă de nume care reprezintă binder-ele active, de la cel mai interior la cel mai exterior) si fromDB care convertește înapoi de la De Bruijn la Lambda, folosind contextul pentru a reconstrui numele variabilelor legate. Macro-urile se tratează ca variabile libere.
toDB :: Context -> Lambda -> DeBruijn fromDB :: Context -> DeBruijn -> Lambda
4.3. (2.5p) Implementați funcția isNormalForm peste DeBruijn care verifică dacă o expresie este în formă normală.
4.4. (3.5p) Implementați funcția reduce care realizează un pas de $\beta$-reducere peste reprezentarea De Bruijn. Spre deosebire de varianta cu nume, nu mai este nevoie de detecția variable capture, în schimb trebuie să tratați corect shifting-ul indecșilor: atunci când coborâți într-un DBAbs, indecșii liberi din valoarea substituită cresc cu 1.
reduce :: DeBruijn -> DeBruijn -> DeBruijn -- reduce val e = corpul în care indexul 0 a fost înlocuit cu val
4.5. / 4.6. (4.5p) Implementați funcțiile normalStep (strategia normală) peste DeBruijn si applicativeStep (strategia aplicativă) peste DeBruijn.
4.7. (2.5p) Implementați funcția simplify care aplică pași de reducere până la forma normală, analog cu cea din cerința 1.8.
DeBruijn.hs. Codul peste DeBruijn va fi mai concis decât echivalentul cu nume: asta este și ideea reprezentării.
REPL
La finalul temei, puteți rula make repl (sau make) urmat de ./repl pentru a vedea aplicația creată de voi :). O să pornească un REPL în care puteți scrie expresii lambda pentru a le evalua (main-ul se folosește de evaluarea normală implementată de voi), puteți crea binding-uri noi sau folosi binding-uri din contextul default creat.
Există și câteva comenzi utile:
:q- pentru a ieși din REPL:r- pentru a sterge contextul, reluând contextul default:ctx- pentru a afișa contextul curent
Punctare
Tema are un punctaj total de 0.5p din nota finală, împărțit pe subpuncte:
- Evaluation - 40p
- Parsing - 20p
- Steps towards a programming language - 20p
- De Bruijn - 20p
Totalul de 100p o sa fie scalat la 0.5p.
După cum s-a anunțat la începutul semestrului, pentru studenții care au punctaj maxim pe cele 2 teme de pe parcursul semestrului, o să se echivaleze examenul din sesiune cu punctaj maxim.
Testing
Testarea se face prin Makefile-ul inclus în schelet. Comenzile disponibile sunt:
make test- compilează tot proiectul și rulează întreaga suită de testemake repl(sau doarmake) - compilează REPL-ul (vezi secțiunea de mai jos)make clean- șterge artefactele de build (directorulbuild/și executabilele)
După make test, puteți rula direct executabilul ./run_tests cu unul din argumentele [lambda | parser | code | debruijn] pentru a rula doar testele unei singure cerințe (1, 2, 3 sau 5). Fără argument se rulează toate testele.
Pentru fiecare test v-a aparea PASSED / FAILED, și în caz de FAILED, diferențele între rezultatul vostru și cel dorit.
Makefile-ul pentru testare.
Trimitere
Temele trebuie submise pe curs.upb.ro, în assignment-ul Tema 3.
În arhivă trebuie să se regăsească cel puțin:
Lambda.hsParser.hsCode.hsDefault.hsDeBruijn.hsMakefileID.txt- acest fișier va conține o singură linie, formată din ID-ul unic al fiecărui student