Costruzione di un interprete

    Come si costruisce un interprete? Questa relazione cerca di rispondere a questa domanda. Lo fa descrivendo sia i passi teorici necessari, sia quelli realmente affrontati nel corso del progetto.
    Il primo passo teorico è quello di capire cos'è e come si definisce un linguaggio. Se introduciamo un insieme di simboli, l'alfabeto, allora possiamo definire un linguaggio come "un particolare insieme di frasi formate dai simboli  dell'alfabeto". Ma come fare a descrivere quell'insieme se è infinito? È necessario introdurre una notazione che consenta di descrivere l'insieme in modo finito e non ambiguo. Il linguaggio viene descritto allora tramite la grammatica che lo definisce. Di seguito si darà un breve accenno all'argomento, per una trattazione dettagliata si veda la pagina dedicata all'argomento sul sito del corso.

    Una grammatica G è l'insieme di 4 enti: un insieme VN di meta-simboli detti anche simboli non terminali, un insieme VT di simboli terminali, lo scopo S della grammatica (un elemento di VN detto start-symbol), un insieme P di produzioni, ovvero di regole di riscrittura. Un linguaggio L(G) generato dalla grammatica G = {VT,VN,S,P} è l'insieme delle frasi s tali che:

s appartiene a VT*    e    S =>* s,    ove

VT*=chiusura di VT, ovvero insieme di tutte le stringhe componibili con i simboli di VT,
S =>* s significhe che s è derivabile a partire dallo scopo S applicando zero o più applicazioni di produzioni.

    Le grammatiche sono suddivise in 4 tipi, in base alla classificazione proposta da Noahm Chomsky. La classificazione è basata su un insieme di restrizioni sulle produzioni.

    I vari tipi di grammatiche sono in relazione gerarchica fra loro, ovvero le grammatiche di tipo 4 sono anche di tipo 3, quelle di tipo 3 sono anche di tipo 2, etc.
    Nella descrizione dei linguaggi di programmazione generalmente si usano grammatiche libere da contesto in quanto queste consentono di derivare le frasi del linguaggio in modo semplice e automatizzabile. Quest'ultimo fatto è molto importante perché consente una facile realizzazione di compilatori ed interpreti.

    Dopo aver definito la grammatica da cui deriva il linguaggio è possibile avviare l'implementazione dell'interprete (o del compilatore). Interprete e compilatore possono essere schematizzati nel modo seguente:

figura 1.

I componenti indicati sono:

    Usando un linguaggio di programmazione ad oggetti l'AL può essere pensato come un'entità (oggetto) che possiamo chiamare lexer e che riceve in ingresso una stringa. Il lexer è dotato del metodo next(): ad ogni chiamata di tale metodo l'oggetto lexer processa la stringa selezionando e restituendo un token. In questo modo è possibile ottenere la successione dei tokens utilizzando un semplice ciclo che ad ogni iterazione effettui una chiamata del metodo next() dell'oggetto lexer.
    I tokens costituiscono l'input dell'AS, quindi sarà esso a contenere il ciclo con le chiamate a next(). A sua volta l'AS potrà essere realizzato come un oggetto che chiameremo parser. Il suo compito sarà quello di implementare il processo di derivazione delle frasi del linguaggio a partire dallo scopo della grammatica. In sostanza il parser deve implementare un automa riconoscitore del linguaggio. Il problema del riconoscimento delle frasi di un linguaggio è tutt'altro che semplice. Vediamo di capirne il motivo. È possibile associare a ciascun tipo di grammatica un particolare tipo di automa in grado di riconoscere le frasi appartenenti ad un linguaggio che sia generato da quel tipo di grammatica. Tutto ciò è riassunto nella seguente tabella:
 
Linguaggio generato da
Riconosciuto da
Note
Grammatica di tipo 0 Macchina di Touring con nastro espandibile senza limite Il riconoscimento di una frase potrebbe richiedere tempo infinito
Grammatica di tipo 1 Macchina di Touring con nastro limitato Nessuna nota
Grammatica di tipo 2 Automa a stati finiti con stack Facile da implementare
Grammatica di tipo 3 Automa a stati finiti Facile da implementare

    I linguaggi di tipo 2 ed 3 consentono un'operazione di riconoscimento molto più semplice e quindi efficiente. Per questo motivo i liguaggi di programmazione sono normalmente generati da grammatiche di tipo 2 e 3 (queste ultime sono solitamente usate per sottoparti del linguaggio come ad esempio gli identificatori ed i numeri).

    Stabilito l'automa bisogna decidere come implementarlo. L'implementazione può avvenire in vari modi:


    L'analisi ricorsiva discendente consente di realizzare un riconoscitore per un linguaggio definendo una procedura per ogni simbolo non terminale X della grammatica. Ogni procedura ha il compito di "consumare" la parte di frase corrispondente al linguaggio generato a partire dal simbolo X (L(X)). Se il simbolo X si sostituisce un un simbolo terminale la procedura associata ad X consuma il simbolo terminale. Se invece il simbolo terminale X si sostituisce con un simbolo non terminale Y allora la procedura associata ad X delega la procedura associata ad Y a consumare L(Y). Esempio:

X ::= x | x Y
Y ::= y | y X

allora la frase "xyx" viene riconosciuta nel modo seguente (X sia lo scopo):

    L'interprete implementato nell'ambito di questo progetto utilizza un riconoscitore (parser) basato prorpio sull'analisi ricorsiva discendente.