În literatura de specialitate, embedded domain-specific languages (EDSL) sunt considerate unele dintre principalele aplicații ale programării funcționale. Sintagma merită explicată pe componente. Un domain-specific language (DSL) este un limbaj de programare restrâns, dedicat unui domeniu particular; exemple bine-cunoscute sunt HTML (pentru descrierea paginilor web) și SQL (pentru interogarea bazelor de date). Acestea se opun așa-ziselor general-purpose languages, care pot fi utilizate într-o gamă largă de domenii, fiind cazul limbajelor C, Java, Python, și al limbajelor studiate la PP. Deși restrânse, DSL-urile sunt înzestrate cu o serie de operații expresive dedicate, care simplifică masiv modelarea în cadrul domeniului specific către care au fost țintite.
Pentru implementarea unui DSL, există următoarele două variante:
Continuând pe varianta embedded (de exemplu, în Haskell), un DSL poate utiliza două tipuri de reprezentări în cadrul limbajului gazdă:
Int
etc.), iar operațiile pe expresii (adunare etc.) manipulează valorile acestoraTema propune ca studiu de caz un mic DSL embedded în Haskell pentru lucrul cu imagini. Pe parcursul etapelor, vom explora ambele modalități de reprezentare de mai sus (shallow, respectiv deep), avantajele și dezavantajele fiecăreia, precum și corespondența lor conceptuală.
Tema este împărțită în 3 etape:
Deadline-ul depinde de semigrupa în care sunteți repartizați. Restanțierii care refac tema și nu refac laboratorul beneficiază de ultimul deadline, și anume în zilele de 26.04, 10.05, respectiv 17.05.
Rezolvările tuturor etapelor pot fi trimise până în ziua laboratorului 10 (deadline hard pentru toate etapele). Orice exercițiu trimis după un deadline soft se punctează la jumătate. Cu alte cuvinte, nota finală pe etapă se calculează conform formulei: n = (n1 + n2) / 2 (n1 = nota obținută înainte de deadline; n2 = nota obținută după deadline). Când toate submisiile preced deadline-ul, nota pe ultima submisie constituie nota finală (întrucât n1 = n2).
În fiecare etapă, veți valorifica ce ați învățat în săptămâna anterioară și veți avea la dispoziție un schelet de cod, cu toate că vor exista trimiteri la etapele anterioare. Enunțul caută să ofere o imagine de ansamblu atât la nivel conceptual, cât și în privința aspectelor care se doresc implementate, în timp ce detaliile se găsesc direct în schelet.
În această etapă, ne vom concentra pe o reprezentare concretă a imaginilor din cadrul DSL-ului menționat anterior, corespunzătoare ideii de shallow embeddings. Astfel, fiecare imagine, denumită de acum încolo regiune (bidimensională), va fi reprezentată prin funcția ei caracteristică, având tipul Point → Bool
. Rolul acestei funcții este de a preciza care puncte din spațiu aparțin regiunii respective (rezultat True
) și care nu (rezultat False
). Similar, o transformare a unei regiuni (de exemplu, translație), este reprezentată printr-o funcție care operează la nivelul punctelor, având tipul Point → Point
.
Veți implementa funcții care:
Deși numărul funcțiilor este mai mare, majoritatea se implementează în câteva cuvinte. cicles
și infiniteCircles
sunt deja implementate, dar sarcina este de a înțelege utilitatea evaluării leneșe în cadrul acestora.
Construcțiile și mecanismele de limbaj pe care le veți exploata în rezolvare sunt:
let
sau where
).
Modulul de interes din schelet este Shallow
, care conține reprezentarea regiunilor și a transformărilor, precum și operațiile pe care trebuie să le implementați. Găsiți detaliile despre funcționalitate și despre constrângerile de implementare, precum și exemple, direct în schelet. Aveți de completat definițiile care încep cu *** TODO ***
.
Pentru rularea testelor, încărcați în interpretor modulul TestShallow
și evaluați main
.
Este suficient ca arhiva pentru vmchecker să conțină doar modulul Shallow
.
În cadrul reprezentării concrete din etapa 1, funcțiile de definire și manipulare a regiunilor și transformărilor surprind complexitatea operațiilor, în timp ce funcția inside
, de interogare a regiunilor, are o implementare banală, în termenii reprezentării regiunilor ca funcții caracteristice. Această abordare are avantajul că noi regiuni, transformări și operații pe acestea pot fi adăugate ușor, independent de definițiile anterioare. De exemplu, dacă dorim să adăugăm o nouă regiune elementară, cum este un triunghi, sau o nouă operație, ca diferența a două regiuni, putem introduce funcții separate, fără a modifica definițiile existente.
Ce se întâmplă totuși dacă încercăm să schimbăm interpretarea unei regiuni? Spre exemplu, în loc de funcția caracteristică din etapa 1, care stabilește apartenența unui punct la o regiune, acum am dori ca printr-o regiune să înțelegem numărul de subregiuni și transformări elementare din descrierea acelei regiuni. De exemplu, regiunea union (circle 2) (applyTransformation (translation 1 0) (rectangle 2 2))
ar căpăta reprezentarea (2, 1)
, pentru că sunt utilizate 2 subregiuni elementare (un cerc și un dreptunghi) și 1 transformare elementară (o translație) în construcția ei. Într-o altă situație, am putea dori ca printr-o regiune să înțelegem descrierea textuală a operațiilor care construiesc acea regiune. De exemplu, pentru aceeași regiune ca mai sus, reprezentarea textuală ar putea fi “Circle 2 + Translation 1 0 @ Rectangle 2 2”
. Această schimbare de interpretare s-ar realiza anevoios, fiind necesară reimplementarea tuturor funcțiilor existente în termenii noii interpretări.
În etapa 2, vom explora așa-numitele deep embeddings pentru regiuni și transformări, în sensul că funcțiile de definire și manipulare a regiunilor nu vor mai viza o anumită reprezentare concretă, ci vor genera un arbore de sintaxă abstractă (AST), care surprinde operațiile generice utilizate în construcția unei regiuni sau transformări. De exemplu, regiunea de mai sus ar căpăta reprezentarea abstractă Union (Circle 2) (Transform (Translation 1 0) (Rectangle 2 2))
, unde simbolurile reprezintă constructori de date definiți de noi. Abordarea are următoarele avantaje:
Ca dezavantaj, adăugarea de noi regiuni, transformări și operații pe acestea se realizează dificil, fiind necesară modificarea tipurilor care descriu AST-urile, și în consecință extinderea tuturor interpretărilor existente.
Observați dualismul celor două abordări, bazate pe shallow, respectiv deep embeddings, în sensul că ce se realizează ușor într-una se implementează dificil în cealaltă, fără ca una dintre abordări să o domine pe cealaltă. În literatură, acest lucru poartă numele de problema expresivității (expression problem), care se manifestă la mai multe niveluri, inclusiv între diferite paradigme de programare.
În cadrul etapei 2, veți porni de la tipuri de date deja definite în schelet pentru reprezentarea AST-urilor regiunilor și transformărilor, și veți implementa funcții care:
combineTransformations
) în variante liniarizateConstrucțiile și mecanismele noi de limbaj pe care le veți exploata în rezolvare, pe lângă cele din etapa 1, sunt:
Întrucât optimizarea AST-ului constituie funcționalitatea cea mai pretențioasă din această etapă, o exemplificăm, pas cu pas, pentru AST-ul de mai jos (exemplul apare și în comentariile funcției optimizeTransformations
):
Union (Transform (Combine [ Translation 1 2 , Combine [ Translation 3 4 , Scaling 2 ] , Scaling 3 ]) (Complement (Transform (Scaling 4) (Transform (Scaling 2) (Circle 5))))) (Transform (Translation 4 6) (Rectangle 6 7))
Principiile de optimizare sunt descrise pe larg în schelet, dar întotdeauna când prelucrăm o anumită regiune, trebuie să optimizăm mai întâi recursiv subregiunile. Prima optimizare propriu-zisă se produce la nivelul subarborelui Transform (Scaling 4) (Transform (Scaling 2) (Circle 5))
, care conține două scalări succesive, care pot fi alipite în Transform (Scaling 8) (Circle 5)
, iar întregul AST este acum:
Union (Transform (Combine [ Translation 1 2 , Combine [ Translation 3 4 , Scaling 2 ] , Scaling 3 ]) (Complement (Transform (Scaling 8) (Circle 5)))) (Transform (Translation 4 6) (Rectangle 6 7))
În continuare, mergem un nivel mai sus, la subarborele Complement (Transform (Scaling 8) (Circle 5))
. Pentru a permite eventuala alipire a scalării cu 8 cu o altă transformare de deasupra, este util să extragem transformarea în fața complementului, ca în Transform (Scaling 8) (Complement (Circle 5))
, iar întregul AST devine:
Union (Transform (Combine [ Translation 1 2 , Combine [ Translation 3 4 , Scaling 2 ] , Scaling 3 ]) (Transform (Scaling 8) (Complement (Circle 5)))) (Transform (Translation 4 6) (Rectangle 6 7))
Mergem iarăși un nivel mai sus, la subarborele Transform (Combine …) (Transform (Scaling 8) (Complement (Circle 5)))
, unde observăm că transformările din secvența Combine
, și anume două translații, respectiv două scalări consecutive, pot fi alipite; mai departe, scalările mai pot fuziona cu scalarea cu 8 din fața complementului. Din păcate, secvența Combine
conține transformări imbricate, care trebuie mai întâi liniarizate pentru a putea fi ușor alipite. În urma liniarizării, se obține lista [Translation 1 2, Translation 3 4, Scaling 2, Scaling 3]
, iar dacă la aceasta adăugăm și prima transformare de mai jos, Scaling 8
, și fuzionăm, obținem lista [Translation 4 6, Scaling 48]
, iar întregul AST devine:
Union (Transform (Combine [Translation 4 6, Scaling 48]) (Complement (Circle 5))) (Transform (Translation 4 6) (Rectangle 6 7))
Rămâne să prelucrăm întreaga reuniune. Obiectivul este același: să înlesnim eventuale alipiri cu transformări de deasupra, extrăgând în fața reuniunii transformările de dedesubt, dacă este posibil, cum am procedat mai sus la complement. Din moment ce avem acum două ramuri, nu putem să extragem decât cel mai lung prefix de transformări comune, lăsând sufixele necomune dedesubt. Prefixul cu pricina este [Translation 4 6]
, iar AST-ul devine în final:
Transform (Translation 4 6) (Union (Transform (Scaling 48) (Complement (Circle 5))) (Rectangle 6 7))
Modulul de interes din schelet este Deep
, care conține reprezentarea AST-urilor regiunilor și a transformărilor, precum și operațiile pe care trebuie să le implementați. De asemenea, trebuie să aveți în același director și modulul Shallow
din etapa 1. Găsiți detaliile despre funcționalitate și despre constrângerile de implementare, precum și exemple, direct în schelet. Aveți de completat definițiile care încep cu *** TODO ***
.
Pentru rularea testelor, încărcați în interpretor modulul TestDeep
și evaluați main
.
Este suficient ca arhiva pentru vmchecker să conțină modulele Deep
și Shallow
.
Data.List
. Este foarte posibil ca o funcție de prelucrare de care aveți nevoie să fie deja definită aici.case
și gărzi, în locul if-urilor imbricate.combineTransformations
, pentru evidențierea corespondenței cu aplicările individuale ale transformărilor din listă.