Progettini per l'anno accademico 96/97

Ai fini dell'esame per il corso di Linguaggi di Programmazione e` necessario svolgere, insieme ad un gruppo di 2 o 3 persone (al limite 4, anche se cio` e` sconsigliato), uno dei progetti sottoelencati, a scelta. I progetti qui elencati sono validi per tutti gli esami dell'anno accademico 96/97, cioe' dal primo esame di febbraio 97 al primo esame di febbraio 98.

Progetto 1: Interprete per lambda-calc+

Si richiede di scrivere un interprete per un linguaggio (linguaggio oggetto) lambda-calc+ che contiene il lambda calcolo e alcune costanti e funzioni primitive quali i numeri, i valori "vero" e "falso", le operazioni aritmetiche e di confronto sui numeri, e l'if-then-else.

La scelta precisa del linguaggio oggetto e' lasciata libera, a condizione che contenga il lambda-calcolo e che le funzioni primitive siano sufficienti a scrivere programmi quali ad es. il fattoriale con uno stile "tradizionale" (cioe' senza ricorrere ai complicati encodings dei numeri e funzioni numeriche nel lambda calcolo). In particolare, e' lasciata libera la scelta di avere o meno il tipo di dato tupla.

La sintassi

Una possibile grammatica per le espressioni di tale linguaggio (comprendente le tuple) e':

Exp ::= Ide | (lambda Ide. Exp) | (Exp Exp) | (Exp {,Exp}) | Prim

dove Prim rappresenta l'insieme delle costanti e funzioni primitive.

Un'altra possibile grammatica per le espressioni di tale linguaggio (senza le tuple) e':

Exp ::= Ide | (lambda Ide. Exp) | (Exp Exp) | Num | Bool | (Prim_bin Exp Exp) | (ite Exp Exp Exp)

dove Num rappresenta l'insieme dei numeri, Bool quello dei booleani, Prim_bin quello delle funzioni numeriche e di confronto, e ite e' l'if-then-else.

La semantica operazionale

Il passo di riduzione, che indicheremo con -->, e' specificato dalle seguenti regole (piu', eventualmente, la regola per le tuple):

---------------------------------------------- beta
((lambda x. M) N) --> M[N/x]


M --> N
---------------------------------------------- csi
(lambda x. M) --> (lambda x. N)


M --> P
-------------------------- nu
(M N) --> (P N)


N --> P
-------------------------- mu
(M N) --> (N P)


Eval_prim M N
-------------------------- prim
M --> N

Nella regola beta, M[N/x] rappresenta il termine ottenuto da M sostituendo N al posto di tutte le occorrenze libere di x, eventualmente ridenominando tramite alfa-conversione i sottotermini di M della forma (lambda y. M'), con y appartenente a N, per evitare il problema del "capture" (variabili libere di N che diventano legate dopo la sostituzione).

Eval_prim si occupa della valutazione delle espressioni che contengono una funzione primitiva applicata a top-level. Potra' essere definita con una semantica a piccoli passi oppure a grandi passi, a piacere (cioe': in (Eval_prim M N), N puo' essere un'espressione ottenuta da M valutando alcune operazioni, e ulteriormente riducibile, oppure puo' essere la forma normale di M, cioe' una costante numerica o booleana.)

La riduzione in forma normale e' specificata dalla relazione

(Eval M N) sse M -->* N -/->

dove -->* e' la chiusura riflessiva e transitiva di -->, e -/-> rappresenta l'inesistenza di un ulteriore passo di riduzione.

Cosa e' richiesto di fare nel progetto

Si richiede di implementare la relazione del passo di riduzione e quella di riduzione a forma normale in uno dei seguenti linguaggi di programmazione, a scelta: Prolog, Lambda Prolog o ML.

Per la valutazione del progetto, e' richiesto di presentare il codice del programma, commentato, ed eventualmente una descrizione della rappresentazione scelta per il linguaggio oggetto. Inoltre si dovranno presentare delle prove di sessione, in cui venga calcolata la forma normale di alcune espressioni, fra cui almeno una contenente una funzione ricorsiva tipo il fattoriale. Notare che il linguaggio oggetto non ha il costrutto let, quindi per descrivere funzioni ricorsive si dovra' fare uso della tecnica vista a lezione, basata sull'operatore di punto fisso

Y = lambda f. ( (lambda x.f(x x)) (lambda x.f(x x)) )

Suggerimenti per l'implementazione in Prolog

Poiche' il Prolog non e' in grado di manipolare tipi di ordine superiore, la rappresentazione delle lambda-astrazioni dovra' essere fatta tramite termini del prim'ordine. Ad esempio potremo usare il termine abst(x,e) per rappresentare l'astrazione (lambda x.e). Il passo di beta-riduzione dovra' essere implementato andando a sostituire esplicitamente tutte le occorrenze libere di x in e. Nel far questo, la cosa concettualmente piu' complicata e' l'eventuale ridenominazione delle variabili bound (alfa-conversione), poiche' cio' richiede l'introduzione di un'identificatore "nuovo". Fortunatamente, il Prolog mette gia' a disposizione un meccanismo per creare nuove variabili, per cui, se si rappresentano gli identificatori del linguaggio oggetto tramite le variabili Prolog, potremo sfruttare questa caratteristica. Bisogna pero' fare attenzione a non unificare mai due variabili Prolog che rappresentano identificatori distinti del linguaggio oggetto. A questo fine potranno tornare utili le seguenti metaprimitive Prolog:
X == Y Ha successo sse X e Y rappresentano sintatticamente lo stesso termine (tenuto conto dei legami creati dalle unificazioni eseguite fino a quel momento)
X \==Y Ha successo sse X e Y non rappresentano sintatticamente lo stesso termine, cioe' sse X == Y fallisce

Alternativamente, si puo' implementare la creazione di un identificatore nuovo tramite assert e retract. Assumendo di rappresentare un identificatore con ide(n), dove ne' un numero, il predicato newide(ide(n)), che genera ogni volta un identificatore nuovo, potra' essere definito da:

newide(ide(N)) :- retract(numero(N)), M is N+1, assert(numero(M)).
numero(0).

La combinazione delle regole nu, mu e csi puo' provocare delle sequenze infinite di riduzioni, anche per espressioni che possono essere ridotte a forma normale applicando le regole in ordine diverso, cioe' applicando sempre la beta-rule e la prim-rule quando queste possono essere applicate. Cio' fa parte della semantica del lambda calcolo, e va quindi considerata una caratteristica "naturale" dell'interprete. E' pero' opportuno scrivere l'interprete in modo che esso segua per primo il cammino che porta alla forma normale (se questa esiste), invece di quelli infiniti. A questo scopo, tenendo presente che il Prolog segue una strategia di ricerca depth-first, bastera' scrivere le clausole per il passo di riduzione in modo che la beta-rule e la prim-rule siano considerate prima della nu e della mu.

Per avere un idea della lunghezza del progetto: La docente ha effettuato un'implementazione in Prolog delle relazioni richieste, con un programma che consiste di 15 clausole, piu' quelle relative alla valutazione delle funzioni primitive.

Suggerimenti per l'implementazione in Lambda Prolog

Il Lambda Prolog offre la possibilita' di manipolare direttamente tipi di ordine superiore, per cui la rappresentazione delle lambda-astrazioni piu' naturale e conveniente sara' del tipo (abs M), dove M rappresenta una funzione da espressioni in espressioni (rappresentabile direttamente in lambda-Prolog da un termine del tipo x\E, dove E e' un espressione contenente eventualmente x). Usando tale rappresentazione, e la rappresentazione (app E1 E2) per l'applicazione di E1 ad E2, il passo di beta-riduzione sara' semplicemente dato da (app (abs M) E) --> (M E). Supponendo che M sia della forma x\E', l'interprete Lambda Prolog provvedera' lui stesso ad effettuare la beta riduzione del termine (M E), sostituendo E al posto di x in E'.

Per quanto riguarda il problema delle sequenze infinite di riduzioni vale quanto detto sopra per il Prolog (anche il Lambda Prolog segue una strategia depth-first).

Grazie al suo potere espressivo, il Lambda Prolog e' il linguaggio che permette l'implementazione piu' compatta delle relazioni richieste. La docente ha effettuato una tale implementazione con un programma che consiste di sole 7 clausole, piu' quelle relative alla valutazione delle funzioni primitive, piu' le dichiarazioni kind e type.

Suggerimenti per l'implementazione in ML

ML e' un linguaggio funzionale e come tale deterministico. La relazione del passo di riduzione, invece, e' non-deterministica. Per simularla in ML dovremo quindi gestire esplicitamente il backtracking e realizzare una funzione "reduction_step" che prende una espressione E del linguaggio oggetto e restituisce l'insieme (cioe' la lista) di tutti gli E' tali che E --> E'.

Per quanto riguarda la riduzione a forma normale: Se le funzioni primitive sono tutte deterministiche, allora si pou' sfruttare la proprieta' di confluenza (debole) del lambda-calcolo che garantisce che tutte le forme normali coincidono. Quindi bastera' arrestarsi quando si trova una forma normale, e restituire quella (l'unica cosa a cui occorre fare attenzione e' l'ordine in cui vengono generati nella lista gli E' detti sopra, per evitare il problema delle sequenze infinite di riduzioni. Vedi quanto detto a proposito del Prolog).
Se invece si vuole arricchire il linguaggio con primitive non-deterministiche (ad es. una primitiva random_bool che restituisce non-deterministicamente vero o falso) allora c'e' il problema che le forme normali possono essere infinite. Una funzione che restituisca tutte le forme normali, in tal caso, puo' essere implementata solo in modo lazy. Dovranno quindi essere usate le tecniche viste a lezione per simulare la valutazione lazy in ML.

Per quanto riguarda la rappresentazione delle lambda-astrazioni, ML offre i tipi di ordine superiore, come il Lambda Prolog, per cui si puo' effettuare la codifica come detto per il Lambda Prolog, e sfruttare esattamente lo stesso meccanismo per l'implementazione della beta-rule. A differenza del Lambda Prolog pero', in ML non e' affatto banale, con questa rappresentazione, implementare la csi-rule (secondo me e' impossibile, a causa della non disponibilita' del pattern-matching sui tipi funzionali). Poiche' lo svolgimento del progetto in ML e' gia' reso abbastanza complicato dal problema del non-determinismo menzionato sopra, si accettano progetti che omettono l'implementazione della csi-rule (l'interprete risultante dovra' essere completo solo rispetto alle riduzioni alla weak-normal form).

Alternativamente, si potra' usare la stessa rappresentazione first-order che si userebbe in Prolog, con un'analoga implementazione della beta-rule. C'e' pero' il problema che ML non mette a disposizione un meccanismo primitivo per generare nuovi identificatori (necessari per l'eventuale alfa-conversione). Dovremo realizzare allora una funzione newide che, ogni volta che viene invocata, restituisce un nuovo identificatore. Un modo di farlo e' quello di sfruttare la possibilita' di side-effect offerta dal tipo reference, e rappresentare i nuovi identificatori come ide(n), dove n e' un numero. La funzione newide potra' allora essere definita come:

val newide =
let val numero = ref 0
in fn() => (numero := !numero + 1 ; ide(!numero))
end


Con questa definizione, la dichiarazione let x = newide() ... assegnera' ad x un nuovo identificatore ogni volta che viene valutata.


Progetto 2: Type-System per lambda-calc+

Si richiede di implementare il type system del linguaggio oggetto lambda-calc+, per la cui definizione si rimanda alla spiegazione del progetto 1.

Il Sistema dei tipi

Il type system di lambda-calc+ e' basato sulla relazione

r |- M : T

(il termine M ha tipo T nell'ambiente r, dove r e' un insieme di associazioni identificatori-tipi), definita dalle seguenti regole (piu' eventualmente quella/e per le tuple)

r(x) = T
---------------- ide
r |- x : T

r[T1/x] |- M :T2
----------------------------------------- abs
r |- (lambda x. M) : T1 -> T2

r |- M : T1 -> T2 , r |- N : T1
---------------------------------------------- app
r |- (M N) : T2

Type_prim M r T
------------------------------ prim
r |- M : T
Type_prim si occupa della valutazione delle espressioni che contengono una funzione primitiva applicata a top-level.

La sintassi dei tipi dovra' contenere i seguenti (piu' eventualmente i tipi delle tuple e una rappresentazione dell'errore)

Type ::= Par | (Type -> Type) | integer | boolean

dove Par rappresenta l'insieme dei parametri (variabili di tipo).

Cosa e' richiesto di fare nel progetto

Si richiede di implementare una relazione type_check tale che, data una espressione E del linguaggio oggetto, restituisca il tipo polimorfo piu' generale di E, se E e' chiuso (cioe' non contiene variabili libere) ed E e' tipabile, e che fallisca o dia errore altrimenti. L'implementazione deve essere fatta in uno dei seguenti linguaggi, a scelta: Prolog, Lambda Prolog, o ML.

Per la valutazione del progetto, e' richiesto di presentare il codice del programma, commentato, ed eventualmente una descrizione della rappresentazione scelta per il linguaggio oggetto. Inoltre si dovranno presentare delle prove di sessione, in cui venga calcolato il tipo di alcune espressioni, fra cui alcune con tipo polimorfo di ordine superiore. Notare che l'operatore di punto fisso non e' tipabile, quindi ovviamente non e' richiesto calcolare il tipo di espressioni rappresentanti funzioni ricorsive

Suggerimenti per l'implementazione in Prolog

Si consiglia di rappresentare le variabili di tipo con variabili Prolog, in questo modo nell'implementazione della regola abs si potra' sfruttare il meccanismo primitivo del Prolog per creare una nuova variabile (il T1 da associare a x), e si potra' sfruttare almeno in parte il meccanismo di unificazione del Prolog.

L'implementazione della regola app richiede di effettuare l'unificazione del tipo calcolato per N con una parte del tipo calcolato per M (cioe': calcola il tipo T1 di N, calcola il tipo T3 di M, controlla che T3 sia della forma T1'->T2, e unifica T1 con T1'). Sfortunatamente, non si puo' usare direttamente il meccanismo di unificazione fornito dal Prolog perche esso non e' completamente corretto, non essendo implementato l'occur-check (ad esempio il Prolog permetterebbe di unificare i tipi T1 e T1->T2, il che e' ovviamente scorretto). Occorrera' quindi modificare l'algoritmo di unificazione del Prolog per tener conto dell'occur check. In altre parole, occorre definire ed usare un predicato di unificazione esplicita, unify(X,Y), che ha successo sse X e Y rappresentano tipi unificabili tenendo conto dell'occur check. A tale scopo torneranno utili le metaprimitive == e \== (vedi spiegazione del progetto 1).

Se abbiamo lo stesso identificatore in lambda astrazioni diverse (come ad esempio nell'espressione (lambda x.x)(lambda x. (x 1))), occore stare attenti a non associare le due occorrenze bound di x allo stesso tipo (nell'esempio, la seconda x deve avere tipo integer -> A, e la prima (integer -> A) -> A ). Per aggirare il problema, la cosa piu' conveniente e' alfa-convertire l'espressione iniziale in modo che tutti gli identificatori usati nelle lambda astrazioni siano diversi fra loro. Anche in questo caso sara' conveniente rappresentare gli identificatori del linguaggio oggetto con variabili Prolog, cosi' da poter usare il meccanismo del Prolog per generare nuove variabili.

Per avere un'idea della dimensione del progetto: La docente ha effettuato un'implementazione Prolog del type system con un programma contenente 26 clausole, piu' quelle per le primitive. Di queste 26, 11 sono per la ridefinizione dell'algoritmo di unificazione (per le espressioni che rappresentano tipi), e 8 per la alfa-conversione dell'espressione di partenza.

Suggerimenti per l'implementazione in Lambda Prolog

Il Lambda Prolog e' di gran lunga il linguaggio piu' conveniente per questo progetto. Uno dei motivi e' che l'algoritmo di unificazione e' implementato in modo corretto, e quindi non ci sara' bisogno di ridefinirlo. Inoltre, grazie al fatto che permette di manipolare tipi di ordine superiore, una lambda espressione (lambda x. M) puo' essere rappresentata direttamente con un termine (abs (x\M)) (vedi anche spiegazione del progetto 1). Questo permettera' di implementare la regola abs in modo estremamente semplice, basandosi sulla seguente riformulazione:

for all y ( |- y : T1 => |- (N y) : T2 )
--------------------------------------------------- abs
|- (abs N) : T1 -> T2

In questa implementazione, non c'e' bisogno ne' della regola ide ne' di introdurre l'ambiente r (si ricorda che lo scopo del progetto e' di scivere un tipe-system per le espressioni chiuse. Sulle espressioni aperte il programma deve dare errore o fallire).

La docente ha effettuato un'implementazione Lambda Prolog del type system con un programma contenente solo 3 clausole, piu' quelle per le primitive, piu' le dichiarazioni kind e type.

Suggerimenti per l'implementazione in ML

Il type system e' deterministico, nel senso che, se un'espressione M chiusa e' tipabile, allora il tipo piu' generale di M, ed e' unico (modulo renaming). Quindi il fatto che ML sia deterministico in questo caso non crea problemi.

Poiche' anche ML consente di manipolare tipi di ordine superiore, si potrebbe pensare di usare la stessa rappresentazione delle lambda-espressioni suggerita per Lambda Prolog e di seguire la stessa idea per l'implementazione di abs. Purtroppo pero' ML non ha un meccanismo automatico di ragionamento ipotetico (per trattare l'ipotesi |- y : T1), e quindi dovremo usare esplicitamente l'ambiente. La regola sara' quindi della forma:

for all y ( r[T1/y] |- (N y) : T2 )
---------------------------------------------- abs
r |- (abs N) : T1 -> T2

Naturalmente, con questa implementazione di abs dobbiamo implementare anche la regola ide. Il "for all y ( r[T1/y] |- (N y) : T2 )" della premessa puo' essere implementato semplicemente generando un'dentificatore y nuovo, distinto da tutti gli altri, e poi calcolando il tipo T2 dell'espressione (N y) (che verra' normalizzata automaticamente da ML).

Alternativamente si possono rappresentare le lambda astrazioni con termini del prim'ordine come in Prolog. In questo caso sara' utile effettuare la alfa-conversione dell'espressione iniziale in modo che tutti gli identificatori delle lambda astrazioni siano distinti fra loro (vedi quanto detto per il Prolog).

Poiche' in ML non esiste un meccanismo di unificazione, la dovremo implementare. Si suggerisce quindi di suddividere il problema in due problemi principali:

Lo schema di funzionamento del type system sara' quindi il seguente. Data un'espressione M:

  1. Calcola type_and_constraints su M a partire dall'ambiente vuoto, sia T il tipo restituito e V l'insieme dei vincoli.
  2. Calcola il most general unifier theta di V.
  3. Applica theta a T e restituisci il risultato cosi' ottenuto.

In questa implementazione, sara' necessario sia generare nuovi identificatori (del linguaggio oggetto), sia nuove variabili di tipo (parametri). Per fare questo, si consiglia di usare la newide (vedi spiegazione del progetto 1) e una funzione newpar definita analogamente.

Si prevede che un'implementazione ML del type system richieda 40-50 righe di codice (tutto compreso), usando la prima delle rappresentazioni possibili per le lambda astrazioni, cioe' quella higher-order. Piu' o meno la stessa dimensione dell'implementazione Prolog: In ML dobbiamo implementare esplicitamente la creazione di nuovi identificatori e parametri, e l'applicazione di una sostituzione ad una espressione di tipo (necessaria per applicare theta a T nel passo 4). Inoltre l'implementazione dell'algoritmo di unificazione e' leggermente piu' lunga (in Prolog e' facilitata dal fatto che in certi passi dell'algoritmo possiamo sfruttare l'unificazione del linguaggio stesso). In compenso, con la rappresentazione higher-order delle lambda astrazioni, non e' necessario effettuare l'alfa-conversione dell'espressione iniziale.


Progetto 3: Implementazione degli alberi razionali

Si richiede di definire, in C++, la classe degli "Alberi binari razionali". Si ricorda che un albero razionale e' un albero, possibilmente infinito, i cui sottoalberi possono avere solo un numero finito di forme diverse. Un modo di rappresentare gli alberi razionali e' quello di considerare alberi binari in cui, in un nodo n, il puntatore al sottoalbero sinistro o destro di n puo' anche "tornare indietro" e puntare ad un antenato di n. (In realta' tutti i grafi finiti diretti e connessi e con un nodo selezionato rappresentano alberi razionali, ma e' meglio restringersi alla rappresentazione detta prima per evitare i problemi di sharing quando dobbiamo fare il garbage collection).

Le informazioni della classe devono essere nascoste in modo opportuno, in modo che chi usa oggetti di quella classe non possa creare strutture circolari che rappresentano grafi arbitrari.

Cosa e' richiesto di fare nel progetto

Si richiede di implementare gli alberi binari razionali in modo che vi si possano effettuare le tipiche operazioni degli alberi binari, piu' le operazioni che settano un puntatore, mettendolo a puntare ad un antenato, o a un nuovo albero. L'eventuale struttura a cui il puntatore puntava prima andra' disallocata, se la radice non era un antenato del nodo considerato (cioe' se il puntatore non puntava "all'indietro"). Si richiede inoltre di scrivere una procedura di stampa, la quale sara' basata, ovviamente, su una qualche rappresentazione "printabile" di tali strutture (ad esempio una rappresentazione in forma di sistema di equazioni, o una rappresentazione grafica).

Per la valutazione del progetto, e' richiesto di presentare il codice del programma, commentato (e commentato bene, data la poca leggibilita' del C++), ed una descrizione delle operazioni principali. Inoltre si dovranno presentare delle prove di sessione, in cui vengano costruiti e stampati alcuni alberi, fra cui naturalmente almeno alcuni infiniti.

Opzionale: Gli alberi razionali sono strutture disponibili in Prolog II, una versione estesa del Prolog. Chiaramente, la presenza di queste strutture implica una modifica dell'algoritmo di unificazione. Quello che abbiamo visto a lezione e' basato sul concetto di sostituzione come funzione da variabili in termini finiti; Se si considerano anche termini (alberi) razionali infiniti, ovviamente il concetto di unificazione cambia, e si potranno unificare, ad esempio, il termini f(x,a) e f(g(b,x),a). Lo mgu sara' una sostituzione che lega x ad un termine infinito della forma g(b,g(b,g(b,...))))), che e' un albero razionale rappresentabile con l'equazione x=g(b,x).

Sara' considerato opzionale, ma valutato molto positivamente nel caso che venga fatto, l'arricchimento del progetto con l'implementazione (sempre in C++) dell'algoritmo di unificazione del Prolog II.

Per altre informazioni riguardo questo progetto, leggere le "Frequently Asked Questions".