Il valutatore può essere realizzato secondo
una fra le seguenti due metodologie principali (cfr ling98exp.htm):
metodologia "funzionale",
in cui si racchiude in un'unica funzione tutto il codice necessario, realizzato
con una serie di istruzioni condizionali che verificano di che tipo è
l'oggetto che deve essere valutato e poi agiscono di conseguenza:
vantaggi:
consente di separare la sintassi, ovvero la gerarchia, dall'interpretazione,
così da rendere semplice l'aggiunta di una nuova interpretazione
(lo stesso linguaggio può avere più interpretazioni), in
quanto è sufficiente aggiungere un'unica funzione;
tutto il codice associato ad una funzione di interpretazione è racchiuso
in un'unica procedura e quindi le eventuali modifiche in fase di programmazione
sono localizzate e non distribuite;
svantaggi:
è oneroso aggiungere una nuova produzione, in quanto è necessario
modificare tutte le funzioni di interpretazione definite;
metodologia "object oriented",
in cui ogni classe è dotata di un metodo di interpretazione che,
se chiamato, provoca la autovalutazione dell'oggetto e restituzione del
risultato:
vantaggi:
facilita l'aggiunta di nuove produzioni in quanto le nuove classi che si
creeranno si autovalutano;
svantaggi:
è oneroso aggiungere una nuova interpretazione, in quanto è
necessario definire un nuovo metodo in tutte le classi;
il codice associato ad una funzione di interpretazione è distribuito
fra tutte le classi e quindi le eventuali modifiche in fase di programmazione
sono distribuite e non localizzate (maggiori probabilità di introdurre
errori);
La metodologia funzionale è preferibile in quanto
solitamente un linguaggio è caratterizzato da un'unica grammatica
e molteplici funzioni di interpretazione. Tuttavia sarebbe opportuno mantenere
i vantaggi della programmazione ad oggetti.
Una terza possibilità, nata per conciliare
metodologia funzionale e programmazione ad oggetti è il Visitor.
Il pattern Visitor prevede la creazione di una oggetto, il Visitor, che
ha il compito di effettuare la valutazione. In esso si definiscono tanti
metodi quante sono le classi della gerarchia e ciascuno si occupa della
valutazione degli oggetto della classe corrispondente. Tutte le classi
vengono poi dotare di un metodo, che può essere chiamato accept()
e che riceve come parametro l'oggetto Visitor: chiamando questo metodo
si richiede all'oggetto di valutarsi. L'oggetto in realtà si limita
a richiamare dal metodo accept il metodo corrispondente alla sua classe
all'interno del Visitor che gli è stato passato, metodo che potremo
chiamare visit_nomeDellaClasse() o semplicemente visit().
Inoltre passa se stesso a questo metodo. In questo modo il Visitor effettua
la valutazione. Questa tecnica di programmazione si chiama double dispatch.
L'interprete Scheme presentato in questa relazione
segue proprio il pattern Visitor. Però non ha una gerarchia completamente
specializzata come già si è osservato nella
precedente pagina a proposito della classe ConsSexp. Si è detto
in quell'occasione che la soluzione adottata e quella alternativa di utilizzare
una classe diversa per ogni operatore presentano entrambe vantaggi
e svantaggi. Vediamo ora quali sono:
soluzione comprendente tutte le sottoclassi di ConsSexp:
vantaggi:
soluzione ad oggetti modulare: si possono facilmente aggiungere e modificare/togliere
primitive ed è anche facilmente espandibile.
svantaggi:
il parser ed il Visitor devono conoscere tutte le primitive: il parser
deve contenere una parte di codice dedicato ad ogni primitiva; se si aggiunge
una primitiva bisogna dunque aggiungere codice nel parser;
il Visitor deve contenere una visit_primitiva per ogni primitiva (in quanto
deve replicare la gerarchia);
chi volesse estendere l'interprete, per esempio per aggiungere nuove primitive,
deve ridefinire sia la classe Parser che la classe Visitor (che nel nostro
caso abbiamo chiamato EvalSexpVisitor);
soluzione che usa la sola ConsSexp:
vantaggi:
soluzione ad oggetti (anche se il Visitor è sfruttato poco) e modulare:
si possono facilmente aggiungere e modificare/togliere primitive, in più
è anche facilmente espandibile: mostreremo in
seguito come è possibile espandere l'interprete;
parser molto semplice e generico: chi volesse estendere l'interprete, per
esempio per aggiungere nuove primitive, non deve ridefinire anche la classe
Parser;
svantaggi:
nel Visitor si ha quasi tutto il codice in una unica funzione;
meno efficiente in fase di valutazione, perché la valutazione di
un'operatore può avvenire solo dopo che sono stati effettuati i
test di riconoscimento (la catena degli if);
Conclusioni:
entrambe le soluzioni sono ad oggetti e modulari, anche se in quella adottata
si è molto più vicini allo stile funzionale di quanto non
avvenga nell'altra soluzione;
gli if che in una soluzione sono messi nel valutatore nell'altra devono
essere spostati nel parser;
La nostra scelta è stata per utilizzare la sola
classe ConsSexp. Tale scelta è in effetti supportata anche dall'osservazione
che il Lisp è stato pensato come list processor, dunque l'unica
struttura essenziale è la lista e quindi esistono solo delle cons,
oltre atomi e nil (elemento nullo). Tutto deve essere una lista: non
c'è distinzione tra dati e funzioni. In verità la lista
stessa è definita come coppia e quindi non aggiunge alcuna capacità
computazionale ad un linguaggio che già possiede il concetto di
coppia. Tuttavia ciò su cui qui preme porre l'attenzione è
la rappresentazione esterna di coppia e lista. Essendo la lista una coppia
sarebbe naturale fornirne una rappresentazione esterna uguale a quella
della coppia. Per esempio la lista (1 2 3) potrebbe essere rappresentata
come (1 . (2 . (3 . nil))). Questa è però una rappresentazione
un po' scomoda e poco pratica e quindi non la si utilizza. La rappresentazione
esterna è quindi (1 2 3). La costruzione di quella lista
avviene con il seguente codice:
(cons 1 (cons 2 (cons 3 nil))).
Il nil finale fa sì che quella creata sia effettivamente una
lista, ovvero una vera lista, tant'è che quella struttura
è chiamata true list. Se invece si scrive:
(cons 1 (cons 2 3))
si crea una struttura chiamata dotted list, che in Lisp viene
stampata come segue:
(1 2 . 3).
True list e dotted list sono stampate in modo analogo, l'unica differenza
è l'inserimento nelle dotted di un punto prima del secondo elemento
dell'ultimo coppia. Quindi il punto serve semplicemente a differenziare
le rappresentazioni esterne di true e dotted list. Ne segue che l'utilizzo
del punto non è necessario in input e la possibilità di utilizzarlo
è data solo per uniformità con l'output scelto.
Un'altra possibile rappresentazione esterna poteva
essere quella di stampare tutte le strutture composte usando il formato
lista ed esplicitando l'evetuale nil finale. Ovvero la true list sarebbe
rappresentata come (1 2 3 nil), mentre la dotted list si scriverebbe come
(1 2 3). La true list sarebbe quindi quella struttura composta il cui ultimo
elemento è un nil, in tutti gli altri casi le strutture composte
sarebbero delle dotted list (la coppia (cons a b) invece che (a . b) si
scriverebbe (a b)). Così facendo si eviterebbe l'utilizzo del punto
sia in input che in output e la grammatica diventerebbe LL(1) senza bisogno
di trasformazioni:
Completato il discorso sul problema dell'utilizzo
del punto elenchiamo ora le varie primitive e gli operatori implementati
nell'interprete Scheme costruito:
Nome
Note
quote
consente di denotare un simbolo senza che venga valutato
' (apice)
'x equivale a (quote x): è stato introdotto per comodità
cons
costruisce una coppia, ovvero una struttura composta
car
seleziona la testa di una struttura composta
cdr
seleziona la testa di una struttura composta
cond
istruzione condizionale
atom
verifica se il suo argomento è un atomo
null
verifica se il suo argomento è un nil
lambda
lambda expression;
consente di denotare delle chiusure lessicali (in Lisp la lambda
expression è una notazione e non una forma e valutata
dà errore perché all'interno dell'interprete Lisp la lambda
non è ricosciuta come una funzione predefinita, invece in Scheme
essa è una forma, ovvero una s-expression, e valutata restituisce
una chiusura lessicale)
define
non necessaria ma utile: crea un binding permanente
defmacro
per la definizione di macro
` (backquote)
macro che consente di costruire strutture complesse apartire
da uno scheletro: la struttura segue il backquote viene restituita così
com'è eccetto dove compare la virgola
, (comma)
macro: la forma che segue la virgola viene valutata e
l'oggetto restituito viene inserito nella struttura
,@ (comma-at)
macro: la forma che segue il comma-at viene valutata per
produrre una lista di oggetti che viene inserita senza parentesi nella
struttura
eq ed equal
confrontano oggetti
eval
rende disponibile all'utente la funzione di valutazione Scheme
set e setq
per assegnamento di valori a variabili
let
zucchero sintattico per introdurre legami lessicali
print e prin1
stampa e stampa senza "NewLine"
read
consente di leggere dallo standard input
file
consente la consultazione di un file che si trovi nella directory padre
della directory Interpreter
+ - * /
operatori aritmetici
> = <
operatori relazionali
Osservazione L'interprete mette a disposizione la primitive lambda,
define
e defmacro così che sia possibile definire nuove operazioni
ed associare ad esse un simbolo. Per esempio se non esistesse la primitiva
let
sarebbe comunque possibile definirla facendo uso delle primitive esistenti.
Per ogni forma let esiste una lambda expression equivalente. Allora si
potrebbe pensare di definire la let usando la primitiva lambda. Tuttavia
se si prova a farlo ci si accorgerà che tale definizione non è
possibile se si vuole usare la sola lambda. Per la definizione della let
è necessaria la defmacro, infatti per utilizzare la let si scrive:
(let ((a '3)) (+ a a))
ma se la let fosse una lambda ((a '3)) e (+ a a) verrebbero valutati
all'inizio (valutazione degli argomenti della funzione let).
Sarebbe dunque necessario scrivere:
(let '((a '3)) '(+ a a))
e non è ciò che vogliamo.
La soluzione che fa uso di defmacro può essere
(usiamo mylet perché in realtà la let esiste già):
ottenendo il risultato atteso, cioè 7. Dopo questa chiamata (macro-call)
le variabili a e b non sono più legate, tant'è che se si
chiama la valutazione di a o b si ottiene un'eccezione di identificatore
sconosciuto. Allo stesso modo se prima di effettuare la macro-call si definisce
il simbolo "a" al valore 10 (con l'istruzione "(define a 10)" ) e dopo
la macro-call si chiede il valore di a si ottengono sempre i risultati
attesi, ovvero:
(define a 10)
;produce il bind (a . 10) e lo aggiunge all'environmente globale
(mylet ((a (+ 1 2))(b 4)) (+ a b))
;produce 7
a
;produce 10
A tal proposito sarebbe da notare che nell'esempio
sopra la mylet associa un valore al simbolo a dopo che questo è
stato legato da una define ad un altro valore. La define definisce una
costante, ovvero indica all'interprete che il simbolo "a" è una
costante che denota il valore 10. Allora poteva essere corretto vietare
l'utilizzo dello stesso simbolo "a" per associargli altri legami tramite
per esempio la let. Si è però preferito lasciare questa possibilità.
Non solo, ma è non è stata vietata neppure la ridefinizione
tramite una nuova define. In questo caso viene però generato un
warning:
> (define a 3)
restituisce a
> (define a 3)
produce:
Warning: define redefines identifier a. Old value=3
The overwriting is supported only to correct typing errors.
If you intend to change the value of a variable you'd better use "set"
and "setq".