1. INTRODUZIONE
1.1 Definizione di linguaggio di programmazione
Un linguaggio di programmazione è uno strumento per la programmazione di sistemi
automatici di elaborazione delle informazioni. Da questa definizione si evince come un
linguaggio di programmazione non sia uno strumento fine a se stesso, quanto un mezzo a
disposizione del programmatore che gli consente di interagire con la macchina.
1.2 Elementi fondamentali di un LDP
Ciascun linguaggio di programmazione si può caratterizzare sulla base di tre elementi
fondamentali:
Modello computazionale: definisce come i costrutti di un linguaggio vengono
eseguiti da una macchina astratta. Il modello computazionale determina il
paradigma seguito dal linguaggio (es. dichiarativo, concorrente, imperativo,
orientato agli oggetti).
Modello di programmazione: si riferisce alle caratteristiche tecniche che un
linguaggio implementa per supportare il processo di sviluppo. Tra di esse rientrano:
o Gestione delle eccezioni: come il linguaggio gestisce errori e condizioni
eccezionali che si verificano a runtime.
o Concorrenza: come il linguaggio gestisce l’esecuzione parallela di più
processi o thread.
o Modularità: in che modo il linguaggio consente la divisione del codice in
moduli indipendenti, facilitando così la manutenzione ed il riutilizzo del
codice.
o Rappresentazione dello stato: come il linguaggio gestisce e rappresenta lo
stato interno dei programmi durante la loro esecuzione.
o Definizione e gestione di proprietà specifiche degli oggetti o delle funzioni,
come i tipi di dato, le variabili o i metodi.
Il modello di programmazione di un linguaggio influisce sulle tecniche e sulle
pratiche adottate nella scrittura del codice.
Modello di ragionamento: descrive come è possibile ragionare formalmente o
informalmente sui programmi scritti nel linguaggio. Dunque, il reasoning model
include tutti gli strumenti teorici e pratici che permettono di analizzare, comprendere
e verificare il comportamento dei programmi.
Ogni linguaggio di programmazione può essere associato a uno o più computation model,
a uno o più programming model e a uno o più reasoning model.
1.2.1 Esempi di modelli computazionali
Modello dichiarativo (SQL, Prolog): prevede che il programmatore si concentri sul cosa
deve essere clacolato, ma non sul come calcolarlo. Pertanto, un programma scritto in un
LDP che segue tale modello è espresso non come una sequenza di istruzioni, ma come
un insieme di definizioni di funzioni e strutture dati. I linguaggi basati sul modello
dichiarativo sono solitamente stateless; ciò significa che durante l’esecuzione del
programma non vengono mantenuti i risultati parziali.
Modello concorrente: permette l’esecuzione parallela di più parti del programma (thread),
per migliorare l’efficienza (sfruttando al massimo le capacità dei processori multicore) o
gestire più eventi simultaneamente.
Modello imperativo: nel modello imperativo il programmatore descrive come il sistema
deve arrivare al risultato, specificando una sequenza di istruzioni che modificano lo stato
del programma nel tempo. Dunque, i LDP che si basano su tale modello sono stateful,
ossia tengono traccia dei risultati intermedi nel corso dell’esecuzione di un programma.
Modello orientato agli oggetti: si basa sul concetto di oggetti, ossia entità che
combinano dati e comportamenti. Un programma consiste in un insieme di oggetti che
interagiscono tra loro attraverso i propri metodi. Tale modello favorisce la modularità ed il
riutilizzo del codice.
1.3 Componenti di un linguaggio di programmazione
Ciascun linguaggio di programmazione, indipendentemente dal paradigma seguito, è
costituito da tre componenti fondamentali: sintassi, semtantica e pragmatica
Sintassi - La sintassi definisce la forma dei costrutti del linguaggio e lo fa attraverso:
Una grammatica libera dal contesto (CFG) o l’Extended Backus-Naur Form (EBNF),
con cui viene definita la forma dei costrutti, costituendo la base della sintassi.
Insieme di condizioni aggiuntive non esprimibili direttamente mediante regole
grammaticali semplici. Esse includiono:
o Condizioni per garantire la non ambiguità, ossia che ogni istruzione possa
avere una sola interpretazione. Infatti, è fondamentale che ciascuna
istruzione e ciascuna espressione ammettano una sola intrepretazione (a
differenza del linguaggio naturale). Tra di esse rientrano le convenzioni di
precedenza degli operatori e l’associatività.
o Regole di scoping: determinano la visibilità e la durata delle variabili
all'interno di diversi blocchi di codice.
o Regole di tipizzazione: specificano le relazioni e le operazioni valide tra tipi di
dati. Ad esempio, in molti linguaggi non è possibile sommare direttamente un
intero e una stringa senza una conversione esplicita.
o Regole di sovraccarico: definiscono come funzioni o operatori con lo stesso
nome possono avere comportamenti diversi basati sui loro argomenti.
o Condizioni contestuali, ad esempio alcuni linguaggi richiedono che le variabili
siano dichiarate prima del loro utilizzo.
Semantica - La semantica di un linguaggio di programmazione definisce cosa fa
effettivamente un programma quando viene eseguito, come deve essere interpretata
ciscuna espressione e ciscuna istruzione. Definire la semantica di un linguaggio in
maniera completa significa esprimere il significato di ogni suo costrutto.
Tuttavia, la definizione completa della semantica di un LDP è possibile solo utilizzando il
linguaggio naturale (metodo informale), mentre non è possibile utilizzando un metodo
formale (se non parzialmente). Questo significa che non è possibile implementare un
software che verifichi la correttezza semantica di un programma. Quindi, per qualsiasi
linguaggio è possibile dare una rappresentazione semantica attraverso un metodo formale
che è solo parziale.
I metodi formali attraverso cui si può esprimere il significato di un costrutto sono tre:
Metodo operazionale: descrive il significato di un programma in termini degli effetti
computazionali che produce quando viene eseguito su un calcolatore astratto. Ad
esempio, descrive cosa succede durante l'esecuzione di un ciclo o di una chiamata
di funzione.
Metodo denotazionale: associa ogni costrutto di un linguaggio di programmazione a
una funzione matematica che mappa gli stati di ingresso in stati di uscita. Questo
approccio è basato sulla costruzione di modelli matematici che rappresentano il
comportamento del programma in termini di trasformazioni di stato.
Metodo assiomatico: associa ad ogni costrutto un insieme di pre-condizioni e di
post-condizioni. Il suo obiettivo è quello di permettere la verifica formale della
correttezza dei programmi specificando le "pre-condizioni" e "post-condizioni" che
un blocco di codice deve rispettare.
Pragmatica - La pragmatica di un LDP ha una duplice accezione. Con essa si indica:
Le caratteristiche pratiche del LDP che emergono nel momento della sua
implementazione su una macchina reale e che ne condizionano l’efficienza e la
precisione dell’esecuzione (es. precisione con cui vengono rappresentati i numeri
reali, la lunghezza significativa degli identificatori).
Le buone pratiche per lo sviluppo di programmi in quel determinato LDP, ossia
quelle linee guida (non regole imposte dal linguaggio stesso) che consentono di
ottenere programmi più efficienti e manutenibili (es. come gestire eccezioni in modo
efficace, come organizzare le gerarchie delle classi per garantire la manutenibilità).
1.4 Il modello dichiarativo
Il modello dichiarativo è un modello da cui, aggiungendo o togliendo degli elementi, è
possibile derivare tutti gli altri. Esso prevede che il programmatore si concentri sul cosa
deve essere clacolato, ma non sul come calcolarlo.
Un programma scritto in un LDP che segue il modello dichiarativo è espresso non come
una sequenza di istruzioni, ma come un insieme di definizioni di funzioni e strutture dati.
La sua elaborazione consiste quindi nella valutazione di funzioni applicate a strutture dati.
I linguaggi basati sul modello dichiarativo sono solitamente stateless; ciò significa che
durante l’esecuzione del programma i risultati parziali non possono essere manipolati. Ne
consegue che, una volta assegnato in valore ad una variabile o ad una struttura dati, tale
valore rimane immutabile. All’avanzare dell’elaborazione di un programma non vengono
modificati i valori legati alle variabili, ma vengono crete nuove versioni con nuovi valori. Ciò
consente di evitare il problema del non determinismo.
1.4.1 Strutture dati nel modello dichiarativo
Nel modello dichiarativo viene utilizzata un memoria di tipo SAS (Single Assignment
Store). Lo store rappresenta è costituito da un insieme di store entity, ossia di coppie
variabile-valore. All’interno di uno store possono essere presenti sia variabili bound, ossia
legate ad un valore, che variabili unbound, cioè non legate ad alcun valore. Uno store in
cui tutte le variabile sono bound è detto value store.
Operatore di unificazione - Un valore, nel contesto di un linguaggio dichiarativo, è una
costante matematica immutabile, il che significa che, una volta assegnato, non può essere
modificato durante l’esecuzione del programma. Per assegnare un valore ad una variabile
si utilizza l’operatore di unificazione (=). La Single-Assignment Operation
𝑥 = < 𝑣𝑎𝑙𝑢𝑒 >
effettua due azioni distinte:
Crea il valore nello store, se non esiste già;
Lega la variabile al valore.
Se la variabile è già legata a un valore, l’operazione verifica che il nuovo valore sia
unificabile (compatibile) con quello già assegnato. In caso contrario, viene generato un
errore di unificazione.
Identificatore di variabile - Va notato che ciò che comunemente viene chiamato
"variabile" è, in realtà, un identificatore di variabile, ossia un nome che viene associato a
una variabile.
La variabile, a sua volta, può essere legata a un valore.
Il legame tra una variabile e un valore è immutabile (tempo-invariante): una volta
stabilito, non può essere modificato.
Il legame tra un identificatore e una variabile, invece, è mutabile (tempovariante): uno stesso identificatore può riferirsi a variabili diverse nel corso
dell'esecuzione del programma.
Di conseguenza, i programmatori non interagiscono direttamente con le variabili, ma lo
fanno attraverso gli identificatori. Quando un identificatore viene ridichiarato, il sistema
crea una nuova variabile e associa ad essa l’identificatore. In questo modello:
Lo store (memoria dei dati) contiene i mapping tra variabili e valori.
L’environment (ambiente) contiene i mapping tra identificatori e variabili.
Strutture dati complesse – nel Single-Assignment Store (SAS), oltre a semplici valori
come i numeri, possono essere contenute anche strutture dati complesse, come record o
liste.
Ad esempio, nel caso di un record, nello store esiste un valore di tipo "record" (cioè il
valore a cui è associata la variabile che fa riferimento alla struttura), il quale contiene una
serie di coppie chiave-valore, ognuna relativa a un campo del record.
Quando si lavora con strutture dati complesse, possono presentarsi i cosiddetti partial
values, ovvero strutture dati in cui uno o più campi non sono ancora bound (cioè non
hanno ancora un valore assegnato).
Tuttavia, va sottolineato che la variabile associata alla struttura stessa è comunque bound:
è legata a un valore parziale, anche se non tutti i suoi componenti sono ancora
determinati.
Legame variabile-variabile – Una variabile può unificata, oltre che con un valore, anche
con un’altra variabile, formando in tal modo un equivalence set. L’unificazione una
variabile appartenente ad un equivalence set con un valore, comporta l’unificazione di
tutte le variabili dell’equivalence set con quel valore. In altre parole, l’equivalence set si
comporta come una singola variabile in attesa di essere legata a un valore.
Meccanismo di esecuzione dataflow – Generalmente, la dichiarazione di una variabile e
l’associazione di una variabile al suo valore possono avvenire separatamente. Tuttavia,
questo comporta che una variabile potrebbe essere utilizzata prima di essere legata ad un
valore. I diversi linguaggi di programmazione gestiscono questa situazione in modi
differenti:
Generando un errore di tipo variable use error ;
Imponendo l’assegnazione di un valore alla variabile nel momento della sua
dichiarazione (Python);
Assegnando alla variabile un valore speciale, come undefined (JavaScript), al
momento della dichiarazione;
Adottando un meccanismo di esecuzione dataflow (è il caso di Oz): questo
meccanismo prevede la sospensione dell’esecuzione del programma fino a quando
la variabile non viene legata ad un valore. Questa gestione permette a Oz di
implementare un modello concorrente basato su variabili dataflow, in cui
l’esecuzione di un programma non fallisce quando alcune variabili sono
temporaneamente unbound, ma aspetta finché tutti i dati necessari non saranno
disponibili.
Binding e tipizzazione delle variabili – Il tipo di dato associato ad una variabile può
essere determinato in due momenti diversi:
Al momento della dichiarazione della variabile: in tal caso si parla di binding statico.
Quando alla variabile viene assegnato un valore: in tal caso si parla di binding
dinamico.
Oz adotta un binding dinamico e una tipizzazione dinamica: ciò significa che il tipo di una
variabile viene determinato al momento dell’assegnazione del valore, e può essere
verificato o usato solo a tempo di esecuzione. Invece, nella tipizzazione statica ogni
espressione ha un tipo determinabile prima dell’esecuzione; dunque, il compilatore può
verificare tale tipo.
1.4.2 Funzioni nel modello dichiarativo
Il modello di programmazione dichiarativo si basa su una gestione del controllo di tipo
funzionale. Ciò significa che il programma stesso concepito come una funzione che
chiama altre funzioni che, a loro volta, possono chiamare altre funzioni, e così via.
Con funzione si intende una sequenza di istruzioni alla quale viene associato un nome e
un insieme di parametri. I parametri rappresentano le informazioni di ingresso utilizzate nel
processo di esecuzione della funzione:
I parametri formali sono quelli definiti nella dichiarazione della funzione;
I parametri attuali (o argomenti) sono i valori effettivi forniti al momento della
chiamata della funzione.
Funzioni ricorsive – Una funzione si dice ricorsiva quando all’interno del suo corpo
richiama se stessa. Si possono distinguere due tipologie di ricorsione:
Ricorsione diretta: si ha quando una funzione richiama se stessa direttamente
all’interno delle sue istruzioni.
Ricorsione indiretta: si ha quando la ricorsione avviene tramite una funzione
intermedia. Dunque, una funzione A chiama una funzione B che, a sua volta,
richiama nuovamente A.
Le funzioni ricorsive seguono una struttura logica basata su due componenti fondamentali:
Il caso base: definisce la condizione di terminazione della ricorsione. Senza un
caso base, si verificherebbe un ciclo infinito di chiamate;
Il caso induttivo (o ricorsivo): rappresenta la parte della funzione che richiama sé
stessa, avvicinandosi progressivamente al caso base. Serve a scomporre il
problema in sottoproblemi più semplici.
Esempio – Funzione fattoriale
Correttezza di una funzione ricorsiva – La correttezza di una funzione ricorsiva può
essere dimostrata attraverso il principio di induzione matematica. Questo approccio
prevede due passaggi:
1. Verifica del caso base: si dimostra che la funzione produce il risultato corretto per il
valore iniziale (tipicamente il più semplice);
2. Passo induttivo: si dimostra che, se la funzione produce il risultato corretto per un
caso generico n, allora produce il risultato corretto anche per il caso successivo n+1
(o n-1, a seconda della direzione della ricorsione).
Oltre alla dimostrazione per induzione, è fondamentale considerare anche le condizioni di
applicabilità (o pre-condizioni) della funzione, ovvero i vincoli sui valori di input per cui la
funzione è correttamente definita. Ad esempio, la funzione fattoriale è definita solo per
numeri interi non negativi. Tentare di calcolare il fattoriale di un numero negativo (come -1)
comporterebbe un ciclo ricorsivo infinito, poiché la condizione di terminazione non
verrebbe mai soddisfatta. Per questo motivo, è necessaria una validazione preliminare
degli input, da effettuare prima dell’invocazione della funzione ricorsiva. Pertanto, nella
verifica della correttezza dell’algoritmo, si devono considerare solo i valori ammessi,
escludendo quelli che violano le pre-condizioni.
Complessità computazionale – Per risolvere uno stesso problema esistono molti
algoritmi. Occorrono dunque dei criteri per scegliere l’algoritmo più adatto. Uno dei criteri
principali è l’efficienza, che è determinata principalmente dalla sua complessità
computazionale. Volendo concentrarsi sulla complessità temporale, essa rappresenta una
stima del tempo di esecuzione necessario affinché l’algoritmo elabori un determinato input,
in funzione della dimensione dell’input stesso. Per esprimere formalmente la complessità
temporale si utilizza la notazione asintotica O, detta anche "O grande", che descrive il
comportamento dell’algoritmo al crescere della dimensione dell’input. Si possono
distinguere, dalla più efficiente alla meno efficiente, le seguenti classi di complessità:
Complessità costante O(1): il tempo di esecuzione non dipende dalla dimensione
dell’input.
Complessità logaritmica O(log n): il tempo di esecuzione cresce in proporzione al
logaritmo della dimensione dell’input.
Complessità lineare O(n): il tempo di esecuzione cresce proporzionalmente alla
dimensione dell’input.
Complessità polinomiale O(n^k) (con k intero positivo): il tempo cresce come una
potenza della dimensione dell’input.
Complessità esponenziale O(2^n): il tempo di esecuzione cresce esponenzialmente
rispetto alla dimensione dell’input.
Complessità fattoriale O(n!): il tempo cresce come il fattoriale della dimensione
dell’input.
Gli algoritmi con complessità esponenziale o fattoriale diventano rapidamente impraticabili
all’aumentare della dimensione dell’input, e vengono quindi considerati inefficienti per
problemi di grandi dimensioni. Nel caso delle funzioni ricorsive, la complessità temporale
può essere stimata contando il numero di chiamate ricorsive necessarie per ottenere il
risultato.
Esempio - Funzione di Fibonacci
Consideriamo la funzione di Fibonacci che può essere espressa matematicamente come
segue.
Una prima implementazione, che deriva direttamente dalla sua formulazione matematica è
la seguente.
Tuttavia, questa implementazione ha una complessità computazionale esponenziale
O(2^n). Questa inefficienza deriva da chiamate ridondanti alla funzione (es. {Fibo 2} viene
chiamata ben tre volte nell’esecuzione di {Fibo 5}).
Esiste un’implementazione migliore che si ottiene applicando un approccio bottom-up,
ossia che parte dalle foglie dell’albero per risalire alla radice, anziché partire dalla radice
per arrivare alle foglie. Questo algoritmo ha complessità lineare O(n).
Valutazione delle espressioni – La valutazione di un’espressione in un linguaggio di
programmazione può avvenire secondo due modalità:
Eager evaluation (o data-driven): l’espressione viene calcolata non appena il
programma arriva al punto in cui essa compare, indipendentemente se il risultato
sarà effettivamente utilizzato in seguito.
Lazy evaluation (o demand-driven): la valutazione dell’espressioe viene rimandata
finchè il suo risultato non diventa necessario in una parte successiva del
programma. Questo approccio può aumentare l’efficienza, evitando calcoli inutili.
High order programming – Con high-order programming si intende la possibilità di
trattare le funzioni come valori di prima classe. Ciò significa che una funzione può essere:
passata come parametro ad un’altra funzione,
restituita come valore di ritorno da una funzione,
assegnata ad una variabile.
Questa caratteristica consente di scrivere funzioni più astratte e generali, favorendo la
modularità e la riusabilità del codice.
Esempio – Funzione generica per il calcolo del fattoriale e della somma gaussiana
Consideriamo la funzione di somma gaussiana e la funzione fattoriale.
Notiamo come le due funzioni siano molto simili. Le uniche due differenze sono il valore
del caso base (𝑛 = 0) e l’operatore utilizzato (+ nella somma gaussiana e * nella funzione
fattoriale).
Ciò permette di codificare una funzione generica che può essere utilizzata per calcolare
sia il fattoriale che la somma gaussiana, passando come parametro l’operatore da
utilizzare e il valore che ha la funzione per 𝑛 = 0.
Per passare gli operatori da utilizzare è necessaria la creazione di due funzioni di
supporto, una per la somma e una per la moltiplicazione.
Di seguito sono riportate le funzioni specializzate rispettivamente per il calcolo del
fattoriale e della somma gaussiana.
1.5 La concorrenza
La concorrenza in un programma è la capacità di eseguire più istruzioni in modo
temporalmente indipendente. Questo consente l’esecuzione parallela di porzioni di codice
(dette thread) per migliorare l’efficienza complessiva del programma.
Tuttavia, se da un lato la concorrenza consente di migliorare l’efficienza, dall’altra
introduce complessità relative alla sincronizzazione tra thread indipendenti e dipendenti.
Se il linguaggio di programmazione utilizzato non fornisce supporto per la gestione della
sincronizzazione, può diventare complesso evitare conflitti di accesso ai dati condivisi e
garantire la correttezza del programma.
La programmazione dichiarativa (come nel linguaggio Oz) offre un meccanismo efficace
per la sincronizzazione: le variabili dataflow. Questo meccanismo:
Garantisce che la correttezza del programma non dipenda dall’ordine di esecuzione
dei thread.
Fa in modo che quando un thread non dispone dei dati necessari, esso si sospenda
automaticamente senza generare errori. Il thread resta in attesa per poi riprendere
in modo trasparente quando i dati di cui necessita diventano disponibili.
In questo modo, la sincronizzazione tra thread viene gestita implicitamente, semplificando
la scrittura di programmi concorrenti.
1.6 Programmazione ad oggetti
La programmazione ad oggetti è un paradigma di programmazione che si basa sull’idea di
incapsulare dati e funzioni in unità coerenti, chiamate oggetti. Un oggetto è costituito da:
un insieme di proprietà (o attributi), che ne rappresentano lo stato;
un insieme di metodi (o funzioni), che definiscono il comportamento, cioè le
operazioni che può compiere sui propri dati o su altri oggetti.
Un programma orientato agli oggetti è organizzato come un insieme di oggetti che
interagiscono tra loro per raggiungere uno scopo comune, scambiandosi messaggi (cioè
chiamate di metodo).
La programmazione ad oggetti si fonda su tre principi fondamentali:
Incapsulamento: si riferisce al fatto che un oggetto racchiude sia i dati che le
funzioni che consentono di manipolare quei dati. Tuttavia, esso, in generale, limita
l’accesso ai dati esponendo all’esterno solamente alcuni metodi pubblici. Questo
garantisce maggiore sicurezza e maggiore controllo sullo stato di un oggetto.
Astrazione: si riferisce alla netta separazione che esiste tra l’interfaccia che
l’oggetto espone esternamente e l’implementazione sottostante. Tale separazione
semplifica l’interazione con l’oggetto perché fa in modo che l’utente non abbia
bisogno di conoscere i dettagli implementativi.
Ereditarietà: si tratta di un meccanismo che permette di definire nuove classi (dette
sottoclassi) a partire da classi esistenti (superclassi), riutilizzando il codice relativo
alla logica in comune e aggiungendo o modificando alcuni comportamenti.
1.7 Nondeterminismo
La condizione di nondeterminismo si verifica quando è impossibile determinare con
certezza quale sarà il risultato del programma per un determinato input. Tale condizione
può presentarsi quando un programma è sia concorrente (cioè con più thread in
esecuzione parallela) che stateful (ossia prevede uno stato condiviso e modificabile).
In tali casi, l’esito del programma dipende dall’ordine in cui i thread accedono e modificano
lo stato condiviso. Questo fenomeno è detto interleaving: significa che le istruzioni dei vari
thread possono essere interlacciate in modi diversi a ogni esecuzione, generando così
comportamenti differenti. Poiché l’interleaving non è prevedibile a priori e può cambiare tra
un’esecuzione e l’altra, l’output complessivo del programma può variare anche se l’input
iniziale è identico.
In particolare, si distinguono due tipologie di nondeterminismo:
Osservabile: si ha quando il risultato visibile del programma cambia in base
all’ordine di esecuzione dei thread.
Non osservabile: si ha quando il comportameto interno del programma cambia tra
un’esecuzione e l’altra, ma l’output finale visibile rimane invariato. Ad esempio, si
ha quando due thread eseguono operazioni indipendenti tra loro, quindi l’ordine non
modifica il risultato. Il nondeterminismo non osservabile andrebbe comunque
evitato ppoichè potrebbe portare a comportamenti imprevisti.
Esempio – Nondeterminismo osservabile
Un classico esempio di nondeterminismo osservabile si ha quando due thread modificano
la stessa variabile senza sincronizzazione. Consideriamo il seguente programma in cui
due thread assegnano un valore diverso alla stessa variabile (mutabile) globale.
Si può osservare come il risultato cambi in funzione di quale dei due thread viene eseguito
per primo. Consideriamo ora il seguente programma in cui ciascuno dei due thread prima
memorizza il contenuto della variabile C in una variabile locale e poi assegna a C il valore
di tale variabile incrementato di 1.
Anche in questo caso si ha nondeterminismo osservabile in quanto potrebbe accadere che
il secondo thread (J) venga eseguito completamente tra la lettura e l’aggiornamento del
primo thread (I). In questo scenario, entrambi i thread leggeranno lo stesso valore di C
(supponiamo che sia 0), lo incrementeranno separatamente (ottenendo entrambi 1) e
infine aggiorneranno C con il valore 1. Ciò significa che, nonostante i due thread eseguano
un’operazione di incremento, il risultato finale sarà 1.
Atomicità – Per risolvere i problemi legati al nondeterminismo nei programmi concorrenti,
è necessario racchiudere una sequenza di istruzioni all'interno di un blocco indivisibile,
detto atomico, che non può essere interrotto da altri thread durante la sua esecuzione. In
altre parole, le istruzioni contenute in un blocco atomico vengono eseguite come se
costituissero un’unica operazione, impedendo che altri thread accedano
contemporaneamente alle stesse risorse e causino condizioni di race (race condition).
Uno dei metodi più comuni per garantire l’atomicità è l’utilizzo dei semafori, strumenti di
sincronizzazione che permettono di controllare l’accesso concorrente a risorse condivise.
Un semaforo consiste in una variabile speciale, che può assumere valori interi e viene
utilizzata per regolare l’accesso a sezioni critiche del codice. Ad esempio, per gestire
l’accesso esclusivo a una risorsa si utilizzano i semafori binari, che possono assumere
solo i valori 0 o 1 e funzionano come un lucchetto: consentono l’accesso quando il valore
è 1 e lo bloccano quando è 0.
2. SINTASSI DEI LINGUAGGI DI PROGRAMMAZIONE
In questo capitolo vedremo gli strumenti sufficienti per la definizione di un linguaggio di
programmazione, ossia:
La definizione formale della sua sintassi.
L’implementazione di un parser sintattico e del traduttore in istruzioni in linguaggio
macchina.
La definizione delle strutture dati del linguaggio.
2.1 Definizione della sintassi
La sintassi di un linguaggio di programmazione definisce la forma dei costrutti del
linguaggio, dunque stabilisce quali programmi possono essere eseguiti da un calcolatore.
La sintassi di un linguaggio riguarda sia l’aspetto lessicale (la forma delle singole parole,
dette token) che l’aspetto frasale (la forma delle frasi, dette statement).
Grammatiche - Per descrivere formalmente la sintassi di un linguaggio di
programmazione si utilizza una grammatica, che consiste in un insieme di regole che
definiscono le parole e le frasi corrette all’interno di un linguaggio attraverso un approccio
generativo. Nel contesto dei linguaggi di programmazione, esistono due tipologie
fondamentali di grammatiche:
Grammatiche libere dal contesto (Context-Free Grammar, CFG): sono composte da
regole che possono essere applicate in modo indipendente dal contesto. In altre
parole, la correttezza di una frase viene valutata analizzando esclusivamente la sua
struttura interna, senza considerare la posizione in cui è collocata all’interno del
programma.
Grammatiche dipendenti dal contesto (Context-Sensitive Grammar, CSG): sono
composte da regole il cui soddisfacimento dipende non solo dalla struttura interna
della frase ma anche dal contesto in cui è collocata.
Definizione formale di CFG – Una CFG 𝐺 è una quadrupla (𝑇, 𝑁, 𝑆, 𝑃) dove:
𝑇 è l’insieme dei simboli terminali (o token) che rappresentano le unità fondamentali
(es. parole chiavi, operatori, …), dunque che non possono essere ulteriormente
scomposti.
𝑁 è l’insieme dei simboli non terminali (o categorie sintattiche), utilizzati per
rappresentare strutture astratte del linguaggio (ad esempio, espressioni, comandi,
istruzioni). Vale 𝑁 ∩ 𝑇 = ∅.
𝑆 è il simbolo di partenza, cioè il simbolo non terminale (𝑆 ∈ 𝑁) da cui ha inizio la
derivazione di una qualsiasi frase del linguaggio. Esso rappresenta la categoria
sintattica principale da cui possono essere generate tutte le frasi valide.
𝑃 è l’insieme delle regole di produzione. Ciascuna regola di produzione ha la forma
𝛼 → 𝑋, dove è 𝛼 un simbolo terminale e 𝑋 può essere un simbolo terminale o non
terminale (o una stringa vuota). Le regole di produzione descrivono come un
simbolo non terminale può essere riscritto come una combinazione di simboli
terminali e non terminali.
L’insieme delle frasi che possono essere (sequenze di simboli terminali) generate
dall’applicazione delle regole di produzione, partendo dal simbolo iniziale 𝑆, definisce il
linguaggio associato alla grammatica 𝐺, indicato come 𝐿(𝐺).
Le CSG – Le grammatiche dipendenti dal contesto (CSG) differiscono dalle grammatiche
libere dal contesto (CFG) principalmente per la forma delle regole di produzione. Infatti, le
regole di una CSG hanno la forma:
(A e B possono anche essere vuoti)
Ciò significa che 𝛼 può essere riscritto come X solo se 𝛼 è preceduto dal simbolo A e
seguito dal simbolo B. Quindi, tali regole includono anche un vincolo contestuale.
Validazione sintattica di un programma – Gran parte dei linguaggi di programmazione
moderni sono definiti attraverso sintassi descritte tramite CSG. Tuttavia, siccome le CSG
sono particolarmente complesse da formalizzare e difficili da gestire nei processi di
validazione automatica, nella pratica, nella pratica si preferisce trasformare la sintassi dei
linguaggi in grammatiche libere dal contesto (CFG, Context-Free Grammar) semplificate,
che sono più facili da elaborare.
Questa trasformazione avviene:
1. Associando al linguaggio una CFG che definisce le regole di base della sintassi;
2. Definendo i vincoli contestuali esternamente alla CFG, ad esempio attraverso
controlli aggiuntivi e regole semantiche.
Tale trasformazione comporta quindi una suddivisione della validazione sintattica in due
fasi:
1. Validazione acontestuale, effettuata da un parser acontesuale che si basa sulle
regole definite dalla CFG;
2. Validazione contestuale, effettuata da un modulo aggiuntivo che verifica il
soddisfacimento dei vincoli contestuali.
Strumenti per la validazione sintattica acontestuale - Per eseguire la validazione
sintattica acontestuale si utilizzano due strumenti fondamentali:
1. Un analizzatore lessicale (detto scanner), che raggruppa i caratteri che
compongono il codice sorgente del programma in sequenze di token (parole chiave,
identificatori, operatori, ecc.).
2. Un analizzatore sintattico (detto parser), che effettua il cosiddetto parsing: esso
prende in input la sequenza di token e la organizza secondo le regole della CFG,
costruendo un albero di derivazione sintattica (o albero di parsing), In questo
albero, i nodi rappresentano simboli (terminali o non terminali), mentre gli archi
descrivono le relazioni gerarchiche tra i costrutti sintattici. L’albero fornisce al
calcolatore una rappresentazione formale della sintassi del codice, che costituisce il
punto di partenza per la generazione dell’eseguibile.
La costruzione dell’albero di derivazione sintattica ha anche funzione di validazione: infatti,
se non è possibile costruire un albero completo a partire dal simbolo iniziale della
grammatica, allora la sequenza di token non appartiene al linguaggio generato dalla
grammatica e il programma risulta sintatticamente errato.
Extended Backus-Naur Form – Per rappresentare le regole sintattiche di un linguaggio in
maniera più compatta e leggibile rispetto alla quadrupla formale, si utilizza solitamente
l’Extended Backus-Naur Form (EBNF). Si tratta di un metalinguaggio, ossia un linguaggio
usato per descrivere altri linguaggi. L’EBNF esprime ciascuna regola di produzione con la
seguente notazione:
⟨𝑛𝑜𝑛𝑡𝑒𝑟𝑚𝑖𝑛𝑎𝑙⟩ ∶∶= ⟨𝑟𝑢𝑙𝑒 𝑏𝑜𝑑𝑦⟩
dove il simbolo ::= indica che il nonterminale a sinistra può essere sostituito dal corpo della
regola a destra. Nella seguente tabella sono riportati i simboli principali della notazione
EBNF.
(Per un esempio più approfondito vedere appunti del corso pag 44-46.)
2.1.1 Ambiguità
Una delle proprietà più importanti che deve possedere un linguaggio di programmazione è
la non ambiguità. Per quanto riguarda l’aspetto sintattico, un linguaggio di
programmazione si dice ambiguo sintatticamente se esiste almeno una sequenza di token
che può essere associata a due o più alberi di derivazione sintattica distinti. L’ambiguità
sintattica impedisce, quindi, di determinare un’unica interpretazione corretta della sintassi.
Per risolvere l’ambiguità in un linguaggio, è possibile adottare due soluzioni:
Ridefinire le regole grammaticali del linguaggio in modo tale da eliminare i casi di
ambiguità;
Implementare a livello del parser delle regole specifiche che determinino quale
albero sintattico considerare corretto tra tutti quelli possibili per una determinata
sequenza di token.
Una delle cause comuni di ambiguità nelle grammatiche riguarda l’interpretazione delle
espressioni, che possono dar luogo a differenti interpretazioni se non sono state definite
regole chiare di precedenza ed associatività degli operatori.
o La precedenza stabilisce l’ordine di valutazione tra operatori diversi all’interno di
un’espressione. Essa è determinata dalla profondità con cui un operatore viene
collocato nell’albero di derivazione sintattica (rispetto agli altri operatori): un
operatore ha precedenza su quelli che si trovano a una profondità minore.
o L’associatività stabilisce l’ordine di valutazione tra operatori dello stesso tipo
all’interno di un’espressione. L’associatività dipende dalla posizione, all’interno della
regola grammaticale, dell’operando contenente l’operatore stesso. Dunque, ci sono
due tipologie di associatività:
Associatività a sinistra: l’operatore viene valutato partendo da sinistra. Si ha
quando nella regola grammaticale l’operando contenente l’operatore si trova
a sinistra dell’operatore stesso.
Associatività a destra: l’operatore viene valutato partendo da destra. Si ha
quando nella regola grammaticale l’operando contenente l’operatore si trova
a destra dell’operatore stesso.
Esempio – Ambiguità
Data la seguente grammatica:
la sequenza di token 4 + 7 ∗ 2 può essere associata a due alberi di parsing distinti: il
primo corrisponde a (4 + 7) ∗ 2 mentre il secondo corrisponde a 4 + (7 ∗ 2).
Il problema risiede nel nonterminale ⟨ op⟩ , che racchiude tutti gli operatori senza stabilire
una precedenza tra di essi. Per risolvere questo tipo di ambiguità, è necessario modificare
la grammatica per imporre regole di precedenza e associatività tra gli operatori.
2.2 Tecniche di parsing
Con parsing si intende il processo che, a partire da una sequenza di token, porta alla
costruzione del corrispondente albero di derivazione sintattica, il quale rappresenta il
punto di partenza per la produzione dell’eseguibile.
Per un qualsiasi grammatica libera dal contesto (CFG) è possibile codificare un parser di
complessità 𝑂(𝑛3 ), dove 𝑛 rappresenta la lunghezza dell’input. All’interno dell’insieme
delle CFG esistono delle particolari classi di grammatiche per cui il parsing può essere
eseguito con complessità lineare 𝑂(𝑛). Si tratta delle grammatiche LL e LR.
Spesso è possibile trasformare un CFG in una grammatica LL o LR riscrivendone
opportunamente le regole di derivazione.
2.2.1 Grammatiche e parser LL
Le grammatiche LL (Left-to-right, Left-most-derivation) sono generalmente associate a
parser LL (detti anche parser top-down), che costruiscono l’albero di derivazione sintattica
partendo dalla radice e procedendo verso le foglie.
In particolare un parser LL segue il seguente algoritmo:
1. Definisce la radice dell’albero che corrisponde al simbolo distinto 𝑆 della
grammatica.
2. Iterativamente cerca di espandere il simbolo non terminale 𝛼 più a sinistra:
a. Cerca nella grammatica una regola di produzione definita per quel
nonterminale (del tipo 𝛼 → 𝑋), che sia (𝑋) compatibile con i successivi token
in input.
b. Se individua una regola di produzione di questo tipo, sostituisce 𝛼 con 𝑋
nell’albero di derivazione, ossia il nonterminale con la parte destra della
produzione selezionata.
Il processo termina quando non esistono simboli non terminali nell’albero e token nell’input
(vedi esempio pag 52-53).
I parser LL sono detti anche parser predittivi poiché la scelta della regola di produzione da
utilizzare per espandere un nodo dell’albero si basa, oltre che sul simbolo non terminale
da espandere, anche sui successivi 𝑛 token che compaiono nell’input. Un parser di questo
tipo viene indicato come 𝐿𝐿(𝑛), dove 𝑛 è il lookahead, ossia il numero di token successivi
nell’input che il parser per determinare, ad ogni passo, quale regola di produzione
utilizzare. I parser 𝐿𝐿(1) sono tra i più comuni.
Per poter effettuare il parsing utilizzando un parser 𝐿𝐿(𝑚) occorre che il codice sorgente
sia scritto in un linguaggio la cui grammatica sia 𝐿𝐿(𝑛), con 𝑛 ≤ 𝑚. In particolare, una
grammatica è di classe 𝐿𝐿(𝑛) se soddisfa due condizioni:
Non deve avere regole left-recursion, ossia regole in cui il nonterminale che
definisce la regola (che compare a sinistra) appare anche come primo simbolo a
destra
𝐴 → 𝐴𝛼
Regole di questo tipo non sono ammesse perché porterebbero a loop infiniti.
Le coppie di regole di produzione che partono dallo stesso nonterminale non
devono presentare a destra più di 𝑛 − 1 simboli iniziali (prefissi) in comune. Questo
perché altrimenti sarebbe impossibile decidere poer distinguere quale produzione
utilizzare osservando solamente n token dell’input. Dunque, renderebbe necessaria
una finestra di lookahead più ampia.
2.2.2 Grammatiche e parser LR
Le grammatiche LR (Left-to-right, Right most derivation) sono generalmente associate a
parser bottom-up (detti anche parser LR o shift-reduce), che, a partire dalla sequenza di
token in input, costruiscono l’albero di derivazione sintattica procedendo dal basso verso
l’alto, dunque dalle foglie fino alla radice (il simbolo distinto 𝑆 della grammatica).
Un parser LR si basa sul seguente algoritmo che, iterativamente, per ogni token in input,
alterna due fasi:
1. Shift: il parser legge (consuma) il token corrente dell’input e lo inserisce nello stack.
2. Reduce: il parser verifica se la coda dello stack (tutte le sequenze di simboli che si
ottengono partendo dalla cima, dall’ultimo inserito) corrisponde alla parte destra di
una produzione della grammatica. Se sì, sostituisce quella sequenza con il non
terminale corrispondente (parte sinistra della produzione). Nel caso sia possibile
l’applicazione di più produzioni, il parser sceglie quella che aggrega il maggior
numero di simboli.
L’algoritmo termina con successo quando l’intera sequenza di token è stata ridotta al
simbolo distinto 𝑆, dunque quando si arriva alla radice dell’albero (vedi esempio pag 5556).
2.2.3 Errori di sintassi
Quando un parser analizza la sequenza di token ricevuta in input dall’analizzatore
lessicale (scanner) può capitare che si imbatta in un errore sintattico. Tale condizione, si
verifica:
Nel caso di un parser LL quando non trova nella grammatica una produzione che
consenta di espandere il simbolo non terminale più sinistra in modo compatibile con
il token corrente dell’input.
Nel caso di un parser LR quando esso non trova nella grammatica una produzione
che consenta di ridurre lo stack e non è possibile nemmeno eseguire uno shift.
Quando il parser si accorge della presenza di un errore di sintassi:
1. Interrompe la generazione dell’albero di derivazione nel punto in cui non è possibile
applicare la regola;
2. Segnala l’errore con un messaggio di output che indica il tipo e la posizione
dell’errore;
3. Prosegue l’analisi sintattica per cercare ulteriori errori nel codice sorgente.
Per poter proseguire l’analisi sintattica oltre il punto in cui è presente l’errore, il parser
deve formulare delle ipotesi correttive per poter ripristinare la corretteza sintattica.
Tuttavia, se queste ipotesi sono errate, si possono generare cosiddette "allucinazioni
sintattiche": errori successivi che non sono reali, ma derivano da una cattiva
interpretazione dell’input dovuta all’errore iniziale.
In particolare, per riprendere l’analisi sintattica dopo un errore, il parser può ricorrere a una
delle seguenti tecniche di recupero:
o Panic mode: consiste nello scartare token fino ad incontrare un cosiddetto “safe
symbol”, ossia un simbolo considerato valido, nel contesto del linguaggio, per
ripristinare la correttezza sintattica. Un esempio comune di safe-symbol è il punto e
virgola (;) nel linguaggio C, che separa le istruzioni.
o Phrase-level recovery: si tratta di una tecnica simile alla panic mode ma più
raffinata, in cui o safe-symbol vengono definiti all’interno del contesto di specifiche
strutture sintattiche. Ad esempio, se viene rilevato un errore sintattico all’interno
della condizione di un’istruzione if, il parser può segnalare l’errore e scartare i token
fino a trovare il then, poiché in questa situazione il token then viene considerato
come safe-symbol.
La Phrase-level recovery consente di localizzare e segnalare l’errore in modo più preciso
rispetto alla Panic mode, tuttavia è più complessa da implementare perché richiede la
definizione di safe-symbol specifici per ogni contesto del linguaggio.
2.3 I traduttori
Un traduttore è un software che effettua il processo di traduzione di un codice sorgente,
scritto in un determinato linguaggio di programmazione, in un codice eseguibile dalla
macchina. Esistono due categorie di traduttori:
Compilatori, che traducono in blocco l’intero codice sorgente in un formato
eseguibile;
Interpreti, che traducono il sorgente in eseguibile e lo eseguono istruzione per
istruzione.
2.3.1 Compilatori
Un compilatore è un traduttore che converte in blocco l’intero codice sorgente in un
formato eseguibile dalla macchina. In realtà, l’output prodotto dal compilatore include non
solo il codice eseguibile (specifico per la macchina target) ma anche informazioni
aggiuntive su come gestire la memoria e le risorse necessarie per l’esecuzione del
programma. Il codice eseguibile generato dal compilatore viene poi fornito ad un
esecutore (generalmente il sistema operativo) che ne avvia l’esecuzione.
Processo di compilazione – Il processo di compilazione si articola nelle seguenti fasi:
1. Analisi lessicale: uno scanner raggruppa i caratteri che compongono il codice
sorgente in token, producendo in output una sequenza di token.
2. Analisi sintattica: un parser, a partire dalla sequenza di token ricevuta dallo scanner
costruisce l’albero di derivazione sintattica.
3. Analisi della semantica statica: un apposito modulo verifica il soddisfacimento delle
regole semantiche definite formalmente (la cosiddetta semantica statica), come
quelle sulla tipizzazione dei dati o sulla corretta corrispondenza tra parametri attuali
specificati nella chiamata ad una funzione e parametri formali della funzione stessa.
Tutti e tre i moduli iniziali (analisi lessicale, sintattica e semantica) interagiscono con il
gestore degli errori, a cui viene passato il controllo nel momento in cui viene rilevato un
errore. Inoltre, tali moduli comunicano con la tavola dei simboli, una struttura dati che tiene
traccia di tutte le variabili, funzioni e strutture definite nel programma, permettendo la
condivisione di tali informazioni tra i vari moduli.
A questo punto, il prodotto ottenuto è un albero di derivazione sintattica che è stato
validato anche dal punto di vista della semantica statica.
4. Generazione del codice intermedio: un generatore di codice intermedio traduce
l’albero di derivazione sintattica in un linguaggio intermedio di livello più basso
rispetto al LDP con cui era scritto il codice sorgente ma comunque non specifico
per una determinata architettura hardware.
5. Ottimizzazione: il generatore di codice intermedio, nella precedente fase ha tradotto
l’albero costrutto per costrutto. In questa fase si sfrutta la visione complessiva del
programma per ottimizzare il codice intermedio, eliminando codice ridondante o
semplificando espressioni per rendere il programma più efficiente.
6. Generazione del codice oggetto: si genera un codice oggetto che è specifico per il
processore su cui dovrà essere eseguito il programma. Tuttavia, tale codice non è
ancora eseguibile perché è specifico per il processore ma non per le periferiche.
Infatti, nel codice oggetto le porzioni di codice specifiche per le periferiche sono
rimpiazzate con dei placeholder.
7. Linking di librerie e driver: il linker risolve i placeholder presenti nel codice oggetto
"legando" il programma alle librerie e alle risorse esterne necessarie per la gestione
delle periferiche. Il linking porta alla generazione del codice eseguibile, pronto per
essere eseguito direttamente dal calcolatore.
2.3.2 Interpreti
Un interprete è un traduttore che traduce ed esegue un’istruzione alla volta.
Processo di interpretazione – Il processo di interpretazione è un processo ciclico che si
ripete per ogni istruzione presente nel codice sorgente. Ogni iterazione si articola nelle
seguenti fasi:
1. Recupero dell’istruzione successiva;
2. Recupero degli operandi necessari per eseguire l’istruzione;
3. Decodifica dell’istruzione: questa fase include l’analisi lessicale, l’analisi sintattica e
l’analisi di semantica statica (descritti in precedenza);
4. Selezione del codice eseguibile corrispondente all’istruzione decodificata: viene
scelto e attivato il modulo software che esegue direttamente l’istruzione
decodificata, senza passare per il codice intermedio.
Questo processo ciclico si ripete fino a quando non viene raggiunta l’istruzione 𝐻𝐴𝐿𝑇, che
segna la fine dell’esecuzione.
2.3.3 Compilatori vs. interpreti
Confrontiamo ora i compilatori e gli interpreti sulla base delle seguenti caratteristiche:
velocità di esecuzione del programma, facilità di debugging, efficienza nell’uso della
memoria e portabilità del programma.
Compilatori
Interpreti
Velocità di esecuzione del
programma
Facilità di debugging
Efficienza nell’uso della
memoria
Protabilità del programma
Velocità di esecuzione del programma – Un programma compilato viene eseguito molto
più velocemente rispetto ad un programma interpretato poiché un compilatore traduce il
codice in linguaggio macchina prima dell’esecuzione generando un file eseguibile che può
essere eseguito direttamente da un esecutore. Invece, un interprete traduce ed esegue il
codice riga per riga ogni volta che il programma viene lanciato, introducendo un
sovraccarico in termini di tempo e risorse. Questo significa anche che se una funzione
viene richiamata più volte, il compilatore traduce la funzione una volta sola e
successivamente richiama il corrispondente codice macchina. Al contrario, un interprete
tradurrà ciascuna istruzione della funzione ogni volta che la funzione verrà eseguita.
Facilità di debugging – I programmi interpretati risultano molto più facili da debuggare
rispetto ai programmi compilati poiché gli interpreti permetto di eseguire il codice
un’istruzione alla volta. Questo consente di fermare l’esecuzione in qualsiasi punto ed
esplorare, in quel punto, la call stack e i valori delle variabili.
Invece, durante il debugging di un programma compilato, si ha accesso principalmente
agli indirizzi di memoria e ad altre informazioni meno leggibili, dato che il compilatore ha
già tradotto il codice ad alto livello in linguaggio macchina, perdendo i nomi delle variabili e
altre informazioni simboliche utili. Per questa ragione, molti debugger per linguaggi
compilati, come quelli inclusi negli IDE, sono, dietro le quinte, basati su un interprete che
simula l’esecuzione del codice compilato, fornendo così informazioni dettagliate su
variabili e stack.
Efficienza nell’uso della memoria – Per quanto riguarda l’efficienza nell’uso della
memoria, non si ha una dominanza di una delle due tipologie dei traduttori sull’altra, in
quanto dipende da diversi fattori. Infatti, il compilatore si deve portare dietro tutte le librerie
linkate e alloca spazio in memoria per tutte le variabili statiche per tutta la durata del
programma, mentre un intrprete alloca spazio in memoria solo per le variabili che sta
utilizzando in quel momento. Dall’altra parte però l’interprete è un programma che deve
essere caricato in memoria.
Portabilità del programma – In termini di portabilità, l’uso di un interprete è molto
vantaggioso rispetto all’impiego di un compilatore poiché un programma interpretato può
essere eseguito su qualsiasi piattaforma, a condizione che l’interprete sia disponibile per
tale piattaforma (l’interprete funge da ponte tra programma e piattaforma e cambia in base
alla piattaforma) Invece, il codice eseguibile prodotto dalla compilazione è un file binario
specifico per una piattaforma. Ad esempio, un programma compilato su una macchina
Windows, ad esempio, non può essere eseguito direttamente su una macchina Linux, a
meno che non venga ricompilato specificamente per l’architettura e il sistema operativo
della nuova piattaforma (ripartendo dal codice intermedio).
2.4 Schemi di traduzione
Con schema di traduzione si intende un modello che descrive il processo di traduzione del
codice sorgente di un programma scritto in un linguaggio di alto livello in un formato
eseguibile dal calcolatore. Nella pratica non si utilizzano dei compilatori o degli interpreti
“puri” ma degli schemi di traduzione più articolati che includono fasi aggiuntive per
ottimizzare il processo.
Interprete con preprocessore – Per migliorare la velocità del processo di traduzione
svolto da un interprete, spesso si antepone a quest’ultimo un preprocessore, che ha il
compito eliminare o trasformare quelle parti del codice, come i commenti o le direttive di
inclusione delle librerie, che non sono di competenza dell’interprete. In questo modo, viene
alleggerito il lavoro dell’interprete, il quale non deve perdere tempo a riconoscere tali
porzioni di codice.
Compilatore classico – Il compilatore classico prevede l’integrazione di un linker
all’interno dello schema di traduzione. Il linker ha il compito di legare diversi moduli di
codice oggetto, risolvendo riferimenti a funzioni o a variabili definite in moduli separati.
Dunque, il linker trasforma il codice oggetto in codice eseguibile dalla macchina.
Compilatore con output in Assembler – Si tratta di uno schema di traduzione utilizzato
quando si vuole introdurre un certo grado di portabilità del codice compilato all’interno
della stessa famiglia di CPU. Questo schema di traduzione prevede un compilatore che
anziché produrre in output direttamente un codice eseguibile, genera un codice assembly.
Un codice assembly è un codice in un linguaggio intermedio specifico per la famiglia di
processori ma non per il singolo processore. Successivamente, questo codice intermedio
viene preso in input da un assembler che lo traduce in linguaggio macchina.
Questo approccio consente di eseguire lo stesso programma in architetture diverse
appartenenti alla stessa famiglia di processori ripartendo non dal codice sorgente ma dal
codice scritto in assembly.
Esiste anche una variante di questo schema che prevede un preprocessore a monte del
compilatore, il cui compito è quello di elaborare quelle strutture che non sono di
competenza del compilatore (es. le direttive di inclusione di librerie).
Compilatore in linguaggio intermedio – Lo schema di traduzione con compilatore in
linguaggio intermedio ha come obiettivo quello di migliorare la portabilità rispetto allo
schema con compilatore classico (anche tra architetture differenti). In particolare, questo
schema prevede una traduzione in due fasi:
1. Nella prima fase, il compilatore traduce il codice sorgente in un linguaggio
intermedio, semanticamente equivalente al linguaggio di partenza e indipendente
dalla piattaforma hardware.
2. Nella seconda fase, un traduttore specifico per l’hardware traduce il codice in
linguaggio intermedio in codice eseguibile specifico per l’architettura di
destinazione.
In questo modo è possibile compilare uno stesso programma in codice eseguibile per
architetture differenti senza dover riscrivere l’intero compilatore da zero, ma ripartendo dal
codice in linguaggio intermedio. Inoltre, il linguaggio intermedio consente di eseguire
ottimizzazioni generali sul codice prima che venga finalizzato per l’architettura hardware.
Un esempio di questo tipo di approccio è rappresentato da TypeScript, che non ha un
interprete o traduttore autonomo per l’esecuzione del codice, ma utilizza un compilatore
che lo traduce in JavaScript. In questo caso, quindi, JavaScript agisce come linguaggio
intermedio.
Compilatore Just In Time (JIT) – Il compilatore JIT consiste in una soluzione ibrida tra il
compilatore AOT (Ahead og Time) e l’interprete. Tale soluzione prevede dapprima la
compilazione del codice sorgente in un linguaggio intermedio. Successivamente, al
momento dell’esecuzione del programma, un’interprete traduce le istruzioni da linguaggio
intermedio a linguaggio macchina. Allo stesso tempo, se è la prima volta che
quell’istruzione viene eseguita, un compilatore JIT la compila in linguaggio macchina. In
questo modo se quell’istruzione dovrà essere eseguita nuovamente, non sarà necessario
reinterpretarla. Ciò offre un vantaggio prestazionale rispetto ad una soluzione con un
interprete puro ma anche migliore portabilità rispetto alla soluzione con compilatore
classico, in quanto il linguaggio intermedio non è specifico per la piattaforma.
Un classico esempio di compilatore JIT è quello implementato nella Java Virtual Machine
(JVM). Quando un programma Java viene eseguito, il codice sorgente viene prima
compilato in bytecode, un linguaggio intermedio che non è eseguibile direttamente
dall’hardware. Durante l’esecuzione del programma, la JVM utilizza un compilatore JIT per
tradurre il bytecode in codice macchina solo quando il metodo o la funzione viene
chiamato per la prima volta.
Compilatore per linguaggi interpretati – Un’altra soluzione ibrida è quella del
compilatore per linguaggi interpretati. Questo schema prevede un compilatore che genera
l’eseguibile e lo passa ad un esecutore. Quest’ultimo, a sua volta, è però supportato da un
interprete che consente di risalire al contesto di esecuzione. Dunque, questo schema offre
le performance del compilatore classico ma anche la facilità di debugging tipica
dell’interprete.
2.5 Strutture dati
2.5.1 Abstract Data Type
Un tipo di dato astratto (Abstract Data Type, ADT) è uno strumento formale che consente
di rappresentare e manipolare i dati in maniera indipendente dalle limitazioni specifiche di
un linguaggio di programmazione e dall’hardware sottostante.
Formalmente, un tipo di dato astratto 𝑇 è definito da una terna (𝑆, 𝐹, 𝐶) dove:
𝑆 è l’insieme dei domini {𝑉1 , … , 𝑉𝑛 } ed è composto da:
o un dominio detto dominio di interesse, che corrisponde all’insieme dei valori
moellati dal tipo di dato 𝑇;
o eventuali domini ausiliari, necessari per la definizione delle funzioni e che
cmpaiono nei domini e/o codomini delle stesse.
𝐹 è l’insieme delle funzioni (o operazioni) {𝐹1 , … , 𝐹𝑚 } legate al tipo di dato 𝑇. Ogni
funzione è definita come:
𝐹𝑖 ∶ 𝑉𝑖1 × 𝑉𝑖2 ×. . .× 𝑉𝑖ℎ → 𝑉𝑘
ossia ha come dominio un prodotto cartesiano di elementi appartenenti ad S e
come codominio un dominio anch’esso appartenente ad 𝑆. Affinchè una funzione 𝐹𝑖
appartenga al tipo di dato 𝑇, il dominio di interesse deve comparire nel dominio o
nel codominio della funzione stessa (o in entrambi).
𝐶 è l’insieme delle costanti significative, ossia l’insieme dei valori del dominio di
interesse dai quali, applicando le funzioni definite, si possono generare tutti gli altri
valori del dominio di interesse.
In sintesi, il dominio di interesse stabilisce i valori che il tipo può assumere, le funzioni
definiscono le operazioni ammesse su tali valori e le costanti 𝐶 rappresentano i valori
iniziali a partire dai quali è possibile generare tutti gli altri valori del tipo.
(Vedi esempio pag 67,68)
3. IL KERNEL LANGUAGE
3.1 La semantica di un linguaggio di programmazione
La semantica di un linguaggio di programmazione definisce cosa fa effettivamente un
programma quando viene eseguito. In altre parole, essa definisce come devono essere
interpretate ciascuna espressione e ciascuna istruzione. Dunque, definire la semantica di
un linguaggio in maniera completa significa esprimere il significato di ogni suo costrutto.
Tuttavia, la definizione completa della semantica di un LDP è possibile solo utilizzando il
linguaggio naturale (metodo informale), mentre non è possibile utilizzando un metodo
formale (se non parzialmente). Questo significa che non è possibile implementare un
software che verifichi la correttezza semantica di un programma. Quindi, per qualsiasi
linguaggio è possibile dare una rappresentazione semantica attraverso un metodo formale
che è solo parziale.
I metodi formali attraverso cui si può esprimere il significato di un costrutto sono tre:
Metodo operazionale: descrive il significato di un programma in termini degli effetti
computazionali che produce quando viene eseguito su una macchina astratta. Ad
esempio, descrive cosa succede durante l'esecuzione di un ciclo o di una chiamata
di funzione.
Metodo denotazionale: associa ogni costrutto di un linguaggio di programmazione a
una funzione matematica che mappa gli stati di ingresso in stati di uscita.
Metodo assiomatico: associa ad ogni costrutto un insieme di pre-condizioni e di
post-condizioni. Il suo obiettivo è quello di permettere la verifica formale della
correttezza dei programmi specificando le "pre-condizioni" e "post-condizioni" che
un blocco di codice deve rispettare.
3.2 L’approccio del Kernel Language
Durante il corso, per definire formalmente la semantica di un LDP, abbiamo utilizzato
l’approccio del Kernel Language. Tale approccio consiste i due parti:
1. Definire un linguaggio semplice e minimale, chiamato kernel language. La
semplicità di questo linguaggio fa sì che la semantica di ogni suo costrutto sia
definibile formalmente.
2. Definire la traduzione di ciascun costrutto del linguaggio di partenza in Kernel
Language. Esistono due tipi di traduzioni che permettono di riconvertire un costrutto
di un linguaggio reale in un’istruzione equivalente del KL, o viceversa: le astrazioni
linguistiche e gli zuccheri sintattici.
Astrazione linguistica – Un’astrazione linguistica consiste in una componente del
linguaggio di programmazione che permette di rappresentare concetti con un livello di
astrazione più elevato rispetto alle primitive del linguaggio stesso. Ciascun linguaggio di
programmazione moderno è ricco di astrazioni linguistiche che, nascondendo i dettagli
implementativi dietro ai costrutti, permettono al programmatore di concentrarsi sulla logica
del problema.
[Approfondimento personale] Esistono diversi tipi di astrazioni linguistiche:
Astrazione dei dati: riguarda la possibilità di definire tipi di dato complessi, come
strutture, oggetti, array, liste, ecc., nascondendo il modo in cui sono rappresentati in
memoria.
Astrazione del controllo: consiste in costrutti che nascondono il modo in cui
vengono eseguite le istruzioni: cicli, funzioni, pattern matching, coroutine, ecc.
Astrazioni sintattice: permette di creare nuovi modi di scrivere codice che
semplificano l’espressione di idee complesse.
Astrazioni semantiche: si riferisce a concetti come polimorfismo, interfacce, moduli,
o funzioni di ordine superiore, che permettono di scrivere codice generico e
riutilizzabile.
Esempio: una funzione map(f, lista) è un’astrazione sul concetto di "applicare
qualcosa a ogni elemento".
Zucchero sintattico – Uno zucchero sintattico è una scorciatoia sintattica per rendere il
codice più leggibile e coinciso. Dietro le quinte, lo zucchero sintattico viene tradotto in
costrutti più fondamentali del linguaggio, dunque non aggiunge nuove funzionalità al
linguaggio ma introduce semplicemente una scorciatoia sintattica.
Esempio – zucchero sintattico
Consideriamo un costrutto if in Oz che include l’inserimento di variabili locali. Nel codice
originale, senza zucchero sintattico, il costrutto si presenta come segue:
Con l’uso dello zucchero sintattico, la sintassi può essere semplificata, eliminando il blocco
esplicito local e riducendo la quantità di codice:
3.3 Definizione del Kernel Language
3.3.1 Computational model del KL
Il Kernel che abbiamo definito durante il corso è caratterizzato da un modello
computazionale di tipo dichiarativo sequenziale. Si tratta di un modello basato sugli
elementi fondamentali di quello dichiarativo, quali la presenza del SAS (contenente le
entità, ossia le coppie variabili-valore) e dell’environment (contenente le coppie
identificatore di variabile-entità).
L’elemento che distingue il modello computazionale dichiarativo sequenziale da quello
puramente dichiarativo è il fatto che esso prevede che le istruzioni siano eseguito in
maniera sequenziale, ossia una dopo l’altra, seguendo un flusso di controllo lineare.
Questo meccanismo è implementato attraverso la definizione di un execution stack, ossia
una struttura dati che memorizza le istruzioni da eseguire e che viene gestita con politica
LIFO (Last In First Out): l’ultima istruzione aggiunta sarà la prima ad essere eseguita.
3.3.2 Strutture dati nel Kernel Language
Valori – I valori nel Kernel Language possono essere definiti con la seguente notazione
EBNF (gli elementi scritti in grassetto rappresentano i caratteri del KL, mentre tutto il resto
fa parte della notazione EBNF):
Tutte le strutture dati che non sono definite nel Kernel Language possono essere derivate
da quelle qui presenti. Ad esempio, una funzione non è altro che una procedura con
esattamente un valore di ritorno, dunque si può esprimere come una procedura in il
parametro finale rappresenta il valore di ritorno.
Alcune osservazioni:
Un letterale può essere un atomo o un booleano;
Il record è l’unico tipo di dato strutturato in questo KL. Esso può essere definito
come un letterale o come un letterale a cui sono associate un insieme di coppie
chiave-valore. Le feature (chiavi) di un record possono essere interi, atomi o
booleani.
Le procedure sono anonime: il nome è sostituito dal placeholder $.
Identificatori di variabile – Gli identificatori di variabile nel KL sono simboli a cui vengono
associate le variabili del SAS. Tali associazioni sono contenute nell’environment. Gli
identificatori di variabile sono rappresentati da una sequenza di caratteri alfanumerici che
iniziano con una lettera minuscola. Includendo il nome dell’identificatore tra due backquote
(`), è possibile anche includere caratteri speciali all’interno di tale nome e farlo iniziare con
un numero o con una lettera maiuscola.
Tipi di dato – Anche nel KL i tipi di dato sono formalmente definiti attraverso il concetto di
tipo astratto di dato. Tuttavia, in questa trattazione non andremo così a fondo. I tipi
predefiniti nel KL sono:
Integer, per esprimere numeri interi.
Float, per esprimere numeri in virgola mobile.
Atom, consistono in costanti letterali indivisibili utilizzate per rappresentare etichette
o valori simbolici. Un atomo inizia sempre con una lettera minuscola, a meno che
non sia racchiuso tra apici. In tal caso può iniziare anche con una lettera maiuscola
o contenere caratteri speciali.
Boolean, letterali che possono assumere solo due valori: true o false.
Record (già descritto).
Dunque, l’unico tipo di dato strutturato tra i tipi predefiniti del KL è il Record. Tuttavia, a
partire da questi tipi di dato possono essere definiti altri tipi derivati. Ad esempio, le tuple
sono una specializzazione di record e possono essere definite formalmente come segue:
Analogamente, le liste possono essere definite come:
Ricordiamo che le liste, che derivano dalle tuple, consistono in una collezione di elementi
in cui ogni elemento ha un campo che punta all’elemento successivo.
Infine, le stringhe sono un caso particolare di liste. Nel KL, una stringa è definita come
una lista di Char, ovvero una sequenza di caratteri. Poiché i caratteri sono rappresentati
come numeri interi, una stringa può essere vista come una lista di Integer. In altre parole,
ogni carattere in una stringa è associato al suo corrispondente valore numerico nel
sistema di codifica utilizzato, come ad esempio UNICODE.
Operatori – Di seguito sono presentati i principali operatori in Oz. Successivamente,
verranno approfonditi l’operatore di unificazione e gli operatori di uguaglianza.
Operatori per eseguire operazioni algebriche:
Operatori sugli Integer: +,-, *, div, mod
Operatori sui Float: +,-, *, /
Operatori di confronto:
Operatori di uguaglianza: ==, \=
Operatori di ordine: =<, < , >, >=
Operatori specifici per i record:
{Arity X}: funzione che restituisce una lista contenente i nomi delle feature del
record in ordine lessicografico.
{Label X}: restituisce l’etichetta del record.
. : è l’operatore che consente di accedere ai campi del record in modo diretto (dot
notation).
3.3.3 Operatore di unificazione
L'unificazione è un’operatore con due argomenti che cerca di renderli uguali, se possibile.
Essa opera secondo le seguenti regole:
1. Entrambi le variabili sono bound:
1. Nessuna delle due è un partial value:
Se le due variabili (o termini) hanno la stessa struttura (es. stesso tipo di
record, stessa arietà di funzione, ecc.) e i valori corrispondenti nei campi
coincidono, l’unificazione non porta risultati (situazione di structure equality).
Se le strutture non corrispondono (es. una lista e una coppia) oppure i valori
nei campi corrispondenti sono diversi e già assegnati, l’unificazione fallisce e
viene sollevato un errore, perché è impossibile rendere i due argomenti
uguali.
2. Almeno una delle due è un partial value:
Se almeno uno dei due termini è parzialmente definito (ad es. contiene
variabili ancora non assegnate), l’unificazione cerca di rendere le due
variabili “uguali” se possibile, assegnando a ciascun campo il valore
corrispondente dell’altro termine. Dunque, in questo caso essa crea nuovi
binding.
2. Variabili non vincolate (unbound):
Se entrambe le variabili sono ancora unbound (non assegnate), l’unificazione
esegue il merge dei due eqiuvalence set alle quali esse appartengono
Definizione formale – Definizamo 𝑢𝑛𝑖𝑓𝑦(𝑥, 𝑦) la funzione che unifica due partial value
nello store. Lo store può essere considerato come un insieme {𝑥1 , 𝑥2 , … , 𝑥𝑛 } di variabili
costituito da:
Insiemi di variabili unbound uguali, cioè che possono essere accorpate in un
equivalence set (es. 𝑥1 = 𝑥3 = 𝑥6 ). Quando ad una di queste variabili viene
assegnato un valore, automaticamente anche tutte le altre variabile dell’equivalence
set vengono associate a quel valore. In questa casistica rientrano anche le singole
variabili unbound, in quanto possono essere considerate come un equivalence set
composto da un solo elemento.
Insiemi di variabili bound associate a valori, denominate determined variables (es.
𝑥2 = 7).
Per definire l’operatore 𝑢𝑛𝑖𝑓𝑦, occorre prima deinire l’operatore 𝑏𝑖𝑛𝑑. L’operatore:
𝑏𝑖𝑛𝑑(𝐸𝑆 < 𝑣𝑎𝑙𝑢𝑒 >) crea un bind (legame) tra tutte le variabili nell’equivalence set
ES e il valore <value>.
𝑏𝑖𝑛𝑑(𝐸𝑆1 𝐸𝑆2 ) realizza un merge tra i due equivalence set 𝐸𝑆1 ed 𝐸𝑆2 .
Ora possiamo definire formalmente l’operatore 𝑢𝑛𝑖𝑓𝑦(𝑥, 𝑦) che, sulla base della natura dei
suoi operandi, effettua una tra le seguenti operazioni:
Se 𝒙 ∈ 𝑬𝑺𝒙 e 𝒚 ∈ 𝑬𝑺𝒚 allora esegue 𝑏𝑖𝑛𝑑(𝐸𝑆𝑥 𝐸𝑆𝑦 ). In altre parole, se i due
operandi appartengono a due equivalence set distinti, allora viene effettuato il
merge dei due operandi.
Se 𝒙 ∈ 𝑬𝑺𝒙 e 𝒚 è determined, allora esegue 𝑏𝑖𝑛𝑑(𝐸𝑆𝑥 𝑦). Dunque, se il primo
operando è una variabile appartenente ad un equivalence set e il secondo è una
variabile bound associata ad un valore, allora l’operatore di unificazione associa
tutte le variabili dell’ES a tale valore.
Se 𝒙 è determined e 𝒚 ∈ 𝑬𝑺𝒚, allora esegue 𝑏𝑖𝑛𝑑(𝐸𝑆𝑦 𝑥). Si tratta del caso
speculare al precedente. Va notato che l’operatore bind è definito per 𝑏𝑖𝑛𝑑(𝐸𝑆 <
𝑣𝑎𝑙𝑢𝑒 >) e non per 𝑏𝑖𝑛𝑑(< 𝑣𝑎𝑙𝑢𝑒 > 𝐸𝑆), dunque la variabile determined deve
essere passata sempre come secondo parametro.
Se 𝑥 è bound alla generica struttura dati 𝑙(𝑙1 : 𝑥1 , . . . , 𝑙𝑛 : 𝑥𝑛 ) ed 𝑦 è bound alla
generica struttura dati 𝑙′(𝑙′1 : 𝑦1 , . . . , 𝑙′𝑚 : 𝑦𝑚 ) con 𝑙 ≠ 𝑙’ oppure (𝑙1 , . . . , 𝑙𝑛 ) ≠ 𝑙′1 , . . . , 𝑙′𝑚 )
, allora genera un errore. In altre parole, se i due operandi sono entrambi
variabili legate a due generiche strutture dati che differiscono per label o per
numero o nome delle feature, allora viene generato un errore.
Se 𝑥 è bound alla generica struttura dati 𝑙(𝑙1 : 𝑥1 , … , 𝑙𝑛 : 𝑥𝑛 ) ed 𝑦 è bound alla
generica struttura dati 𝑙(𝑙1 : 𝑦1 , … , 𝑙𝑛 : 𝑦𝑛 ), allora si esegue ricorsivamente 𝑢𝑛𝑖𝑓𝑦(𝑥𝑖 𝑦𝑖 )
per 𝑖 = 1, … , 𝑛. In altre parole, se x e y sono due variabili bound legate a due
strutture dati con stessa label e stesse feature (in nome e in numero), allora si
applica ricorsivamente l’operatore di ricorsione alle coppie di valori associate alla
stessa feature nelle due strutture dati.
Tuttavia, questa definizione non funziona nel caso di strutture cicliche (strutture ricorsive
infinite), come quelle definite dal seguente codice:
In tali casi, se si esegue un’unificazione utilizzando la definizione appena vista, si ha una
serie infinita di chiamate ricorsive. Il problema è che vengono unificate infinite volte le
stesse coppie di valori che erano già state unificate in precedenza. Per risolvere questo
problema, si fa uso di una memoization table, ossia una tavola che memorizza tutte le
coppie di variabili che sono già state unificate, in modo da evitare nuovi tentativi di
unificazione.
Più precisamente, possiamo definire l’operatore 𝒖𝒏𝒊𝒇𝒚’(𝒙, 𝒚) come un operatore che crea
la memoization table M, inizialmente vuota, per poi attivare l’operatore 𝑢𝑛𝑖𝑓𝑦’’(𝑥, 𝑦).
Quest’ultimo operatore:
Non fa nulla se (𝒙, 𝒚) ∈ 𝑴, ossia se la coppia di operandi è già stata unificata.
Se invece (𝒙, 𝒚) ∉ 𝑴, la coppia di variabili (𝑥, 𝑦) viene inserita nella memoization
table 𝑀 e si procede con l’esecuzione dell’operatore 𝒖𝒏𝒊𝒇𝒚(𝒙, 𝒚) definito in
precedenza, sostituiendo però la chiamata ricorsiva a 𝑢𝑛𝑖𝑓𝑦(𝑥, 𝑦) con una chiamata
a 𝑢𝑛𝑖𝑓𝑦′′(𝑥, 𝑦), in modo da eseguire la verifica sulla tavola ed evitare una serie
infinita di chiamate ricorsive.
3.3.4 Operatori di uguaglianza
Funzionamento dell’operatore di entailment (uguaglianza) – L’operatore di entailment
== verifica se due elementi nello store hanno la stessa strutture e lo stesso valore.
Dunque, l’espressione 𝑋 == 𝑌 restituisce:
true se i due grafi associati a X e Y hanno la stessa struttura e tutti i nodi
corrispondenti hanno o stesso valore o sono coincidenti;
false se le strutture o i valori dei due grafi differiscono in almeno un punto;
l’esecuzione viene sospesa, per il modello di esecuzione dataflow, se nei due grafi
esistono due nodi corrispondenti diversi (variabili appartenenti ad equivalence set
differenti) di cui uno è unbound. L’esecuzione riprenderà quando si renderà
disponibile il dato per eseguire il confronto. Va invece notato che nel caso in cui
esistono due nodi corrispondenti uguali entrambi unbound (variabili appartenenti
allo stesso equivalence set), quei due nodi vengono considerati uguali.
L’operatore di disentailment /= opera esattamente come l’operatore di entailment ma in
logica negata.
Per maggiore chiarezza vedere esempi pag. 91 e 92.
3.3.5 Strutture di controllo nel KL
Dopo aver analizzato le strutture dati del KL, spostiamo ora la nostra attenzione sulla
definizione delle sue strutture di controllo. Il costrutto di controllo principale del KL è
rappresentato dalle procedure, in linea con l’astrazione procedurale, secondo cui un
programma è concepito come un insieme di procedure che si richiamano reciprocamente.
Le procedure nel KL costituiscono un valore e sono anonime (il nome è sostituito dal
placeholder $). Esse sono definite attraverso la seguente sintassi:
Siccome sono dei valori, possono essere assegnate ad una variabile. L’assegnazione di
una procedura ad una variabile può avvenire sintatticamente in due modi:
Attraverso l’operazione di unificazione;
Utilizzando uno zucchero sintattico che prevede la sostituzione del placeholder $
con il nome della variabile a cui si vuole associare la procedura.
Mapping di una funzione in una procedura - Va osservato come nel KL non sia definito
il concetto di funzione ma solo quello di procedura, che è più generale. È comunque
possibile mappare una funzione in una procedura aggiungendo un parametro formale che
viene assegnato all’interno dell’esecuzione della procedura. Dunque, all’atto
dell’attivazione della procedura è necessario mappare quel parametro formale in un
parametro attuale coincidente con una variabile unbound. Una volta terminata
l’esecuzione della procedura quella variabile conterrà il risultato della funzione.
Esempio – Simulazione di una funzione con una procedura
Consideriamo la funzione di Oz che ritorna la somma degli elementi di una lista:
È possibile replicare questo comportamento anche con una procedura aggiungendo un
parametro inizialmente associato ad una variabile unbound. Nel caso induttivo occorre
dichiarare una nuova variabile unbound da passare alla procedura attivata ricorsivamente
e nella quale poi verrà “salvato” il risultato.
Operazioni sulle procedure – Le operazioni attuabili sulle procedure sono tre:
Definizione, usando la sintassi vista in precedenza.
Attivazione, usando la sintassi {𝑉𝑎𝑟𝑖𝑎𝑏𝑖𝑙𝑒𝑃𝑟𝑜𝑐𝑒𝑑𝑢𝑟𝑎 𝑃𝑎𝑟𝑎𝑚𝑒𝑡𝑟𝑖}.
Test di valore: è possibile verificare se una variabile è associata ad una procedura
tramite la funzione {𝐼𝑠𝑃𝑟𝑜𝑐𝑒𝑑𝑢𝑟𝑒 𝑉𝑎𝑟𝑖𝑎𝑏𝑖𝑙𝑒}, che ritorna un valore booleano.
3.3.6 Semplificazione nel KL
Per mantenere semplice il Kernel Language, sono state effettuate scelte, tra cui rientrano
le seguenti:
Non sono definite le funzioni, ma solo le procedure (attraverso le quali si può
comunque replicare il comportamento delle funzioni);
Esiste un solo tipo di dato strutturato e si tratta del tipo Record;
Le variabili locali possono essere dichiarate solo singolarmente
Una procedura non può contenere tra i suoi parametri un’altra procedura, come
invece avviene nel high order programming. Casi di questo tipo si traducono con la
dichiarazione di una variabile temporanea nella quale viene immagazzinato il
risultato della prima procedura; tale risultato viene poi passato come parametro
della seconda (quella che prima era la più esterna).
Non è permessa la definizione di una variabile all’atto della dichiarazione
La clausola dell’if non può essere un’espressione ma deve essere necessariamente
una variabile booleana.
Vedi da pag. 97 per il codice.
3.3.7 Definizione dello statement nel KL
Lo statement nel KL può essere definito in notazione EBNF nel seguente modo:
Dunque, uno statement può essere uno di questi otto elementi. Va notato come tra essi
non vi sia la dichiarazione di una procedura, poiché essa nel KL costituisce un valore e
non uno statement.
3.4 Definizione formale della semantica del KL
3.4.1 La macchina astratta
Uno dei modi in cui è possibile definire formalmente la semantica di un linguaggio consiste
nel descrivere gli effetti prodotti dall’esecuzione di ogni suo costrutto su una macchina
astratta. Una macchina astratta consiste nella versione semplificata di un calcolatore, di
cui è possibile osservarne lo stato; ed è proprio la differenza tra lo stato immediatamente
successivo e quello immediatamente precedente all’esecuzione di un’istruzione che
definisce semanticamente l’istruzione stessa.
La macchina astrattta si basa sui concetti di SAS (già descritto), environment (già
descritto), Semantic Statement, Semantic Stack e Execution State.
Operazioni sull’environment – L’environment della macchina astratta si può manipolare
attraverso due operatori:
Operatore di aggiunta (+): crea un nuovo environment 𝐸’, a partire da uno già
esistente 𝐸, agggiungendo all’environment di partenza nuovi legami tra identificatori
e variabili. Nel caso in cui gli identificatori coinvolti nei nuovi legami esistevano già
nell’environment di partenza ed erano associate ad altre variabili, nel nuovo
environment 𝐸’ quei mapping vengono rimpiazzati dai nuovi. Questo perché il
contesto locale maschera quello globale.
Questo operatore si utilizza al momento della definizione di un nuovo identificatore
(e ddunque anche una nuova variabile).
Operazione di restrizione (o proiezione) (|): a partire da un environment 𝐸, crea un
nuovo environment 𝐸’, che contiene solo un sottoinsieme dei legami identificatorevariabili presente nell’environment di partenza. Questo operatore consente di
“estrarre” dall’environment di partenza solo i legami di interesse.
Semantic Statement – Un semantic statement (𝑆𝑆) è una coppia ordinata (< 𝑠 >, 𝐸)
composta da uno statement < 𝑠 > e da un environment 𝐸, che specifica il contesto di
esecuzione dell’istruzione.
Va notato come un’istruzione possa essere eseguita in environment diversi a seconda
della posizione del programma in cui è collocata. Tuttavia, qualsiasi environment fa
sempre riferimento allo stesso store (SAS), che è unico per tutto il programma.
Gli statement si possono classificare in due categorie:
Non-suspending, in cui rientrano tutti quegli statement non in grado di portare il
semantic stack nello stato suspended, dunque sempre eseguibili.
Suspending, di cui fanno parte quegli statement che necessitano di leggere dati
nello store, dunque se tali dati non sono presenti causano il passaggio del semantic
stack allo stato supended.
Semantic Stack – Un semantic stack (ST) è una lista ordinata di coppie (< 𝑠 >, 𝐸)
composte da uno statement e dall’envirnoment che rappresenta il suo contesto di
esecuzione.
Si tratta di uno stack gestito con politica LIFO (Last In First Out). All’inizio del programma il
semantic stack contiene un solo statement, che rappresenta l’intero programma da
eseguire. Ad oggni passo di esecuzione viene prelevato ed eseguito lo statement in cima
allo stack. Questo, a sua volta, può causare l’inserimento di ulteriori SS all’interno dello
stack. Quando il ST si svuota, il programma termina.
In un dato momento, il ST può trovarsi in tre stati:
Runnable: quando è possibile eseguire lo statement successivo;
Suspended: quando lo stack non è vuoto, ma non è possibile eseguire lo statement
successivo a causa dell’indisponibilità di un dato.
Terminated: quando lo stack è vuoto, cioè quando si raggiunge la fine
dell’elaborazione del programma.
Execution State – L’execution state è una coppia ordinata (𝑆𝑇, 𝜎) costituita da un
semantic stack 𝑆𝑇 e da uno store 𝜎.
L’execution state rappresenta lo stato in cui si trova la macchina ad un dato istante, dopo
aver eseguito l’i-esima istruzione. È proprio attraverso il concetto di execution statement
che si può descrivere formalmente la semantica di un programma. Infatti, un programma
può essere visto come una sequenza di stati assunti dalla macchina astratta, in cui
l’esecuzione di ciascuno statement produce come effetto l’esecuzione da uno stato al
successivo.
Esecuzione di un programma - Lo stato iniziale della macchina astratta è unico ed è
caratterizzato da:
SAS vuoto (nessuna variabile e nessun valore)
Environment vuoto (nessun identificatore di variabile e nessuna variabile legata)
Semantic stack contenente un solo statement che rappresenta l’intero programma
da eseguire.
L’esecuzione del programma avviene per passi di esecuzione. Ad ogni passo viene
estratto ed eseguito il primo SS present nel ST. Ciascuna istruzione può apportare
modifiche sia allo store che all’ES. Il programma termina quando il semantic stack è vuoto,
dunque non ci sono più statement da eseguire.
Di seguito, attraverso il concetto di macchina astratta, verrà descritta formalmente la
semantica dei principali statement del KL.
3.4.2 Skip
Lo statement skip consiste in uno statement vuoto che non apporta modifiche né al SAS
né al ST, se non quella che dopo essere stato prelevato ed eseguito esso viene rimosso
dal ST. Infatti, l’unica azione che la macchina astratta compie per eseguire lo skip è il pop
dello statment dal ST.
Si tratta di uno statement di natura non-suspending, in quando non ci sono condizioni nelle
quali esso possa causare uno stato di sospensione del ST.
Semantic statement:
3.4.3 Composizione sequenziale
Lo statement di composizione sequenziale consente l’esecuzione di due statement in
maniera sequenziale, prima viene eseguito quello più a sinistra (< 𝑠1 >) e poi quello più a
destra (< 𝑠2 >).
Semantic statement:
L’esecuzione di tale statement avviene attraverso i seguenti passi:
1. Pop dello statement di composzione sequenziale dal ST;
2. Push del semantic statement (< 𝑠2 >, 𝐸) nel ST;
3. Push del semantic statement (< 𝑠1 >, 𝐸) nel ST.
Va notato che, affinchè < 𝑠1 > sia eseguito prima di < 𝑠2 >, quest’ultimo deve essere
inserito precedentemente a < 𝑠1 > (ricordiamo che il ST è gestito con politica LIFO).
Sia < 𝑠1 > che < 𝑠2 >verranno eseguiti sull’environment E, a meno che l’esecuzione di s1
non lo modifichi. In tal caso < 𝑠2 >verrà eseguito su un environment diverso.
Anche la composizione sequenziale è di natura non-suspending, poiché non dipende da
dati esterni che potrebbero causare una sospensione durante l’esecuzione.
3.4.4 Dichiarazione di un identificatore di variabile locale
Lo statement 𝑙𝑜𝑐𝑎𝑙 < 𝑥 > 𝑖𝑛 < 𝑠 > 𝑒𝑛𝑑 consente la dichiarazione di un identificatore di
variabile locale < 𝑥 > utilizzabile soltanto dallo statement < 𝑠 >.
Semantic statement:
L’esecuzione dello statement avviene con i seguenti passi:
1. Pop del SS dal ST.
2. Creazione di una variabile 𝑥1 nello store 𝜎.
3. Definizione di un nuovo environment 𝐸′ tramite l’operazione di aggiunta: partendo
dall’environment 𝐸 presente nel SS della local, si crea 𝐸′ aggiungendo il legame tra
il nuovo identificatore 𝑥 e la nuova variabile dello store 𝑥1 .
4. Push nel ST del SS (< 𝑠 >, 𝐸′). Il prossimo statement ad essere eseguito sarà
quindi < 𝑠 > che contestualizzato su 𝐸′.
Dunque, lo statement che dichiara un nuovo identificatore di variabile modifica sia lo store
che l’environment.
Anche questo statement è di natura non-suspending in quanto non richiede la presenza di
alcun dato esterno.
3.4.5 Unificazione tra variabili
Lo statement di unificazione tra due variabili consente di unificare le variabili presenti nello
store associate ai due identificatori specificati, creando un equivalence set tra le due
variabili.
Semantic statement:
L’esecuzione di questo statement avviene attraverso i seguenti passi:
1. Pop del SS dal ST.
2. Creazione, nello store, di un equivalence set tra le variabili associate agli
identificatori < 𝑥 > e < 𝑦 >.
Anche questo statement è non-suspending perché non si basa sui valori.
3.4.6 Assegnazione variabile-valore
Il SS di assegnazione di un valore ad una variabile è espresso come:
La sua esecuzione si articola nei seguenti passi:
1. Pop del SS dal ST;
2. Creazione, nello store, del valore < 𝑣 >.
3. Creazione, nello store, del binding tra la variabile associata (nell’environment)
all’identificatore < 𝑥 > e ed il valore < 𝑣 >.
Anche in questo caso si tratta di uno statement non-suspending in quanto non c’è nessuna
condizione in cui tale statement provoca la sospensione dell’esecuzione del programma.
3.4.7 Statement condizionale
Lo statement condizionale determina, sulla base del valore associato ad un identificatore
di variabile, quale statement deve essere eseguito tra due statement distinti.
Statement
semantico:
L’esecuzione di questo statement avviene attraverso i seguenti passi:
1. Pop del SS dal ST;
2. Se l’identificatore < 𝑥 > è legato ad una variabile unbound, l’esecuzione si
sospende. Altrimenti, se < 𝑥 > è legato ad una variabile bound, si possono
distinguere tre casi:
a. La variabile (𝐸(< 𝑥 >)) associata all’identificatore < 𝑥 > nell’environment 𝐸
è legata al valore true: viene eseguito il push del semantic statement (< 𝑠1 >
, 𝐸) nel ST.
b. La variabile (𝐸(< 𝑥 >)) associata all’identificatore < 𝑥 > nell’environment 𝐸
è legata al valore false: viene eseguito il push del semantic statement (<
𝑠2 >, 𝐸) nel ST.
c. Tale variabile è legata ad un valore che non è né true né false: viene
generato un errore.
Dunque, lo statement condizionale non modifica né lo store né l’environment, ma effettua
solo il push di un SS nello store.
Come si può evincere da quanto appena descritto, lo statement condizionale è di tipo
suspending. Infatti, tale statement può sospendere la sua esecuzione qualora
l’identificatore ⟨ x⟩ sia associato a una variabile unbound.
3.4.8 Pattern matching
Il pattern matching è uno statement che determina quali tra due statement eseguire sulla
base della struttura del valore associato ad un identificatore di variabile.
In particolare, il semantic statmente è:
(𝑐𝑎𝑠𝑒 ⟨𝑥⟩ 𝑜𝑓 ⟨𝑙𝑖𝑡𝑒𝑟𝑎𝑙⟩({⟨𝑓𝑒𝑎𝑡𝑢𝑟𝑒1 ⟩: ⟨𝑥1 ⟩, … , ⟨𝑓𝑒𝑎𝑡𝑢𝑟𝑒𝑛 ⟩ ∶ ⟨𝑥𝑛 ⟩}) 𝑡ℎ𝑒𝑛 ⟨𝑠1 ⟩ 𝑒𝑙𝑠𝑒 ⟨𝑠2 ⟩ 𝑒𝑛𝑑, 𝐸)
Dunque, se il valore associato alla variabile legata all’identificatore < 𝑥 > nell’environment
E ha una struttura che corrisponde a quella specificata dopo la parola chiave 𝑜𝑓, allora
viene eseguito lo statement < 𝑠1 >, altrimenti viene eseguito lo statement < 𝑠2 >.
L’esecuzione dello statement del pattern matching si articola nei seguenti passaggi:
1. Pop del SS dal ST;
2. Se la variabile associata all’identificatore < 𝑥 > è unbound, allora l’esecuzione si
sospende.
Altrimenti, se < 𝑥 > è legato ad una variabile bound, si distinguono due sottocasi:
a. Tale variabile è legata ad un valore che corrisponde al pattern atteso, cioè il
nome della label e il nome ed il numero delle feature del valore associato alla
variabile sono uguali a quelle del pattern specificato:
in questo caso si esegue il push del SS:
(⟨𝑠1⟩, 𝐸 + {⟨𝑥1 ⟩ → 𝐸(⟨𝑥⟩). 𝑓𝑒𝑎𝑡𝑢𝑟𝑒1 , … , ⟨𝑥𝑛 ⟩ → 𝐸(⟨𝑥𝑛 ⟩). 𝑓𝑒𝑎𝑡𝑢𝑟𝑒𝑛 })
Dunque, viene pushato ⟨𝑠1 ⟩ contestualizzato in un nuovo environment creato
aggiungendo ad E i mapping tra dei nuovi identificatori di variabile
⟨𝑥1 ⟩, … , ⟨𝑥𝑛 ⟩ i corrispondenti elementi della struttura dati associata
all’identificatore < 𝑥 >.
b. Il valore a cui è legata la variabile associata all’identificatore ⟨𝑥⟩ ha una
struttura che non coincide con il pattern specificato: in questo caso viene
eseguito il push del SS (⟨𝑠2 ⟩, 𝐸) nel ST.
Questo statement, come si evince da quanto appena descritto è di natura suspending.
4. ASTRAZIONE PROCEDURALE
L’astrazione procedurale consiste nel separare il cosa fa un blocco di codice dal come lo
fa, incapsulando una sequenza di istruzioni all’interno di una procedura o funzione. Ciò
permette di:
Dare un nome alla sequenza di operazioni. Questo consente di riutilizzare tale
blocco ogni volta che serve richiamandone il nome, senza doverlo riscrivere;
Specificare dei parametri (formali) per il blocco, in modo da poter applicare tale
sequenza di istruzioni su dati di volta in volta diversi.
Migliorare la leggibilità, la manutenibilità e la modularità del codice.
Procedura come valore - In Oz, così come nel KL che abbiamo definito, le procedure
sono valori, dunque possono essere assegnate ad una variabile. È possibile farlo sia un
maniera esplicita attraverso l’operazione di unificazione, sia sostituendo l’identificatore
associato alla variabile a cui si vuole associare la procedure al posto del placeholder $.
Parametri di output – Tra i parametri di una procedura possono esserci anche dei
parametri di output, ossia parametri formali a cui al momento dell’attivazione si unificano
variabili unbound (passate come parametri attuali) e che vengono valorizzati all’interno
della procedura con i risultati prodotti dalla stessa. In questo modo è possibile utilizzare tali
valori anche all’esterno del contesto della procedura. Convenzionalmente (non è
obbligatorio), si indica un parametro come parametro di output anteponendo ad esso il
carattere ?.
Identificatori bound e free – Un identificatore di variabile si dice bound se è dichiarato
all’interno della procedura tramite i costrutti local, proc o case (Nota: la definizione di
identificatore bound non ha nulla a che vedere con quella di variabile bound); in caso
contrario di dice free. Questo concetto è importante perché soltanto statement in forma
chiusa (ossia in cui compaiono solo identificatori bound) possono essere eseguiti.
Riferimenti esterni – All’interno del corpo di una procedura possono essere presenti
anche riferimenti esterni, ossia identificatori di variabile non dichiarati all’interno del
contesto della procedura.
Caratteristiche fondamentali di una variabile – Qualsiasi variabile è definita da cinque
caratteristiche fondamentali:
Nome: l’identificatore associato alla variabile e utilizzato all’interno del codice per
riferirsi ad essa.
Valore attualmente associato ad essa.
Tipo, definito formalmente attraverso il concetto di tipo di dato astratto, determina il
dominio di valori possibili e le operazioni consentite.
Tempo di vita: intervallo di tempo, durante l’esecuzione del programma, in cui la
variabile esiste in memoria.
Campo di azione (o scope): porzione di codice in cui la variabile è visibile e
accessibile.
Il binding tra una variabile e ciascuna di queste caratteristiche può essere statico
(determinato a tempo di compilazione) oppure dinamico (risolto a tempo di esecuzione), a
seconda delle regole del linguaggio di programmazione.
Campo d’azione (scope) – Come detto, il campo d’azione (o scope) di una variabile è la
porzione di codice in cui essa è visibile e utilizzabile. In particolare:
Si parla di scope statico (o lessicale) quando i riferimenti agli identificatori free (cioè
non dichiarati localmente) vengono risolti nel contesto in cui la procedura è
dichiarata. Con lo scope statico, il campo d’azione di un identificatore può essere
dedotto analizzando il codice sorgente, indipendentemente dall’ordine di
esecuzione.
Si parla di scope dinamico quando tali riferimenti vengono risolti nel contesto di
esecuzione, cioè nel blocco attivo al momento della chiamata. Con lo scope
dinamico, il campo d’azione dipende dalla sequenza concreta delle chiamate di
funzione, e quindi non è determinabile staticamente in generale.
Oz implementa uno scope di tipo statico.
Esempio – Binding statico vs. binding dinamico
Consideriamo il seguente codice:
L’output prodotto da esso varia a seconda che si utilizzi uno scope statico o uno scope
dinamico. Infatti, se si utilizza uno scope statico, il risultato sarà stat(hello), altrimenti
dyn(hello).
Closure e Contextual Environment – Per il implementare lo scope statico, si utilizza il
meccanismo di closure: quando viene dichiarata una procedura, nello store essa viene
memorizzata attraverso una struttura dati contenente la sua definizione (sequenza delle
istruzioni che la compongono) e il Contextual Environment (CE), che contiene tutti i legami
tra identificatori free e variabili, registrati al momento della dichiarazione. Dunque, il CE
cattura il contesto in cui è stata dichiarata la procedura.
4.1 Definizione formale della semantica di una procedura
4.1.1 Dichiarazione di una procedura
In un linguaggio come il KL definito in cui una procedura è un valore, la semplice
dichiarazione di una procedura non è uno statement. In questo caso consideriamo la
dichiarazione di una procedura con assegnazione ad un identificatore di variabile, la cui
sintassi è:
Il SS corrispondente è:
L’effetto prodotto da questo statement è analogo a quello della dichiarazione di una
variabile locale, con l’unica differenza che nello store il valore creato è di tipo procedura,
dunque si crea una closure.
Dunque, i passaggi sono i seguenti:
1. Pop del SS dal ST.
2. Creazione nello store di un valore di tipo procedura, ossia una closure composta
dalla sua definizione e dal CE della procedura. Il CE della procedura coincide con la
proiezione dell’environment E sui riferimenti esterni della procedura.
3. Assegnazione del valore creato alla variabile legata all’identificatore < 𝑥 >.
4.1.2 Attivazione di una procedura
Il semantic statement per l’attivazione di una procedura è:
L’esecuzione di tale statement avviene secondo i seguenti passi:
1. Pop del SS dal ST
2. A questo punto esistono diversi casi:
o Se l’identificatore ⟨𝑥⟩ non è bound, l’esecuzione si sospende;
o Se la variabile 𝐸(⟨𝑥⟩) è associata ad un valore che non è una procedura,
viene generato un errore;
o Se la variabile 𝐸(⟨𝑥⟩) è associata ad un valore procedura, ma con diverso
numero di parametri formali, si genera un errore;
o Se la variabile 𝐸(⟨𝑥⟩) è associata ad un valore procedura in cui il numero di
parametri formali con il numero di parametri attuali specificati nella chiamata
si esegue il push del SS:
ossia il SS che ha come primo elemento lo statement ⟨𝑠⟩ e come secondo
elemento un nuovo environment ottenuto aggiungendo al CE della procedura
i legami tra gli identificatori dei parametri formali e le variabili dei
corrispondenti parametri attuali. Dunque, al momento dell’attivazione avviene
l’unificazione tra parametri attuali e parametri formali.
4.2 High order programming
Con high order programming si intende l’insieme delle tecniche di programmazione che
sfruttano il fatto che le procedure (o funzioni) possono essere trattate come valori. Questo
paradigma si basa su quattro caratteristiche fondamentali:
Procedural abstraction: capacità di incapsulare qualsiasi istruzione o blocco di
codice in una procedura;
Genericity: capacità di passare procedure value come parametri a una procedura;
Instantiation: capacità di restituire procedure value come risultato dell’attivazione
di una procedura; ciò permette di ottenere come output di una procedura generica
una procedura specializzata per un particolare contesto definito dai parametri
passati. Dunque, la funzione generica consente di generare una classe di funzioni.
Embedding: capacità di includere procedure value all’interno di strutture dati (ad
esempio, in record i cui campi possono essere procedure).
Ordine di una procedura – Il termine high order si riferisce all’ordine di una procedura. In
particolare:
o una procedura è di ordine 0 se non accetta parametri di tipo procedura;
o è di ordine 1 se accetta almeno una procedura di ordine 0 come parametro;
o in generale, è di ordine n se accetta almeno una procedura di ordine n-1 come
argomento.
4.2.1 Procedural abstraction
La procedural abstraction è la capacità di trasformare qualsiasi statement in una
procedura, permettendo così la sua esecuzione differita (vedi definizione di inizio capitolo).
In particolare, l’astrazione procedurale consente di:
Astrarre gli statement attraverso le procedure. Uno statement < 𝑠 > si può
trasformare nella precedura anonima:
Astrarre le espressioni attraverso le funzioni. Un’espressione < 𝑒 > si può
trasformare nella funzione anonima:
Valutazione dei parametri – In alcuni casi può essere conveniente passare a una
procedura un parametro sotto forma di funzione piuttosto che come valore diretto. La
differenza tra passare un identificatore (che si riferisce direttamente a un valore) e passare
una funzione che restituisce tale valore risiede nel momento della valutazione:
Quando si passa un identificatore, il valore viene valutato immediatamente al
momento dell’attivazione della procedura.
Quando si passa invece una funzione, la valutazione del parametro avviene solo
quando la funzione viene invocata, ovvero in modo ritardato (lazy evaluation).
In ambienti con modello di esecuzione dataflow, se l’identificatore è unbound (cioè il valore
non è ancora disponibile), l’attivazione della procedura si sospende fino a quando il valore
non sarà istanziato.
Viceversa, se si passa una funzione che produce il valore, la procedura può essere
attivata subito perché la funzione è un valore sempre disponibile (anche se il risultato che
produce non lo è ancora). L’effettiva valutazione verrà rimandata al momento
dell'invocazione della funzione. Questo meccanismo consente di ritardare la valutazione
dei parametri e può essere usato come base per realizzare forme di valutazione parziale o
ottimizzazioni legate alla lazy evaluation (vedi esempio pag 124-125).
4.2.2 Genericity
La genericity è la capacità di passare valori di tipo procedura come parametri
nell’attivazione di un’altra procedura. Questo permette di scrivere una procedura generica
e flessibile e sfruttare i parametri per cambiare ogni volta non solo i valori sui quali opera
ma anche gli operatori che utilizza. Infatti, il passaggio di procedure consente di
specificare gli operatori da utilizzare. Dunque, grazie a questo meccanismo si possonoo
creare delle funzioni specializzate che al loro interno non fanno altro che utilizzare una
funzione più generale passando degli opportuni valori come parametri. Tutto ciò aumenta
la modularità e la riutilizzabilità del codice.
Esempio - FoldR
La funzione OZ che restituisce la somma degli elementi di una lista può essere
generizzata dando la possibilità di specificare come parametri anche il valore del caso
base (in questo caso 0) e l’operatore da applicare (in questo caso +).
La FoldR può essere utilizzata da funzioni più specifiche, come le seguenti, che eseguono
rispettivamente la somma e il prodotto degli elementi di una lista.
La genericity consente di definire due funzioni fondamentali nell’ambito dell’high order
programming e, più nel dettaglio, nella programmazione funzionale. Tali due funzioni sono
la funzione di mapping e la funzione di filtering.
Funzione di mapping – La funzione di mapping prende come parametri una lista e una
funzione che definisce un'operazione da applicare agli elementi della lista e restituisce una
nuova lista, in cui ogni elemento è il risultato della funzione applicata al corrispondente
elemento della lista di partenza.
Esempio di utilizzo:
Funzione di filtering – La funzione di filtering prende come parametri una lista e una
funzione predicato, cioè una funzione che restituisce true o false a seconda che l’elemento
soddisfi una certa condizione. In output essa restituisce una nuova lista contenente solo gli
elementi della lista di partenza per i quali il predicato restituisce true.
Esempio di utilizzo:
4.2.3 Embedding
L’embedding è la proprietà dell’high order programming che consente di inserire valori di
tipo procedura all’interno di strutture dati. In questo modo, si ottengono strutture dati i cui
valori non sono costanti, ma possono essere calcolati dinamicamente in base al risultato
dell’attivazione di una funzione o procedura.
L’embedding permette di definire strutture dati flessibili, come:
Strutture dati parzialmente istanziate: sono strutture dati in cui l’ultimo elemento è
una procedura che, se attivata, estende ulteriormente la struttura stessa generando
gli elementi successivi. Questo permette di definire gli elementi successivi solo
quando necessario.
Moduli: si tratta di record che accorpano operatori (sotto forma di funzioni) correlati
tra loro. Questo semplifica l’organizzazione di funzionalità affini.
Componenti software: procedure che prendono come parametri dei moduli e
producono nuovi moduli combinando quelli ricevuti come parametri.
Esempio – Modulo software
Algebra può essere considerato un modulo software, poiché è una struttura dati che
racchiude al suo interno operatori per eseguire operazioni di somma e moltiplicazione.
4.2.4 Astrazioni linguistiche generate con l’high order programming
L’high order programming viene utilizzato, tra le altre cose, anche per implementare
l’astrazione del controllo. Ad esempio, si può utilizzare per implementare cicli for e for-all.
Ciclo for – Di seguito l’implementazione di un ciclo for con il parametro I che rappresenta
il contatore e il parametro J il limite superiore. P è una procedura che contiene le istruzioni
da eseguire ad ogni iterazione.
Il ciclo for viene espresso solitamente utilizzando la seguente astrazione linguistica:
Ciclo for-all – Il ciclo for-all permette di applicare un’operazione (specificata da una
procedura o funzione) a tutti gli elementi di una lista. Dunque, ad ogni iterazione del ciclo
si applica l’operazione al successivo elemento della lista, partendo dal primo.
Il ciclo for-all viene espresso solitamente utilizzando la seguente astrazione linguistica:
Differenza tra cicli nel modello dichiarativo e cicli nel modello imperativo – I cicli nel
modello dichiarativo differiscono da quelli nel modello iterativo per la gestione del
contatore. Infatti, nel modello dichiarativo, per il principio del Single Assignment, ad ogni
iterazione viene creata una nuova variabile associata al valore aggiornato del contatore e
l’identificatore viene legato alla nuova variabile. Invece, nel modello imperativo la variabile
che rappresenta il contatore è unica e il suo valore viene modificata di iterazione in
iterazione. Questo aspetto può causare problemi nel caso di esecuzione concorrente non
ben gestita perché lo scope del contatore non è limitato al blocco for e potrebbe essere
modificato da un altro thread. Ciò provoca la condizione di non determinismo, in cui
l’output del processo non è univoco dato l’input ma dipende, ad esempio, da come lo
scheduler del SO schedula i processi.
4.2.5 Operatore di Folding (o reduce)
L’operatore di folding (detto anche reduce) combina tutti gli elementi di una lista in un
unico valore utilizzando una funzione binaria F (ossia che prende due argomenti) e un
valore iniziale S. Esistono due tipologie di folding:
Folding associativa a destra (FoldR): la FoldR applica la funzione binaria F in
maniera associativa a destra. Dunque, scorre tutta la lista fino ad arrivare alla fine
per poi iniziare ad applicare F partendo dall’ultimo elemento. Questo implica che
ogni chiamata si sospende fino a quando non si arriva all’ultimo elemento, per poi
tornare indietro con i risultati.
Folding associativa a sinistra (FoldL): applica la funzione binaria F in maniera
associativa a sinistra, dunque partendo dal primo elemento e aggiornando di volta
in volta il risultato parziale.
Va notato come la foldL è tail recursive, mentre la foldR non lo è.
4.3 Tecniche di programmazione dichiarativa per l’astrazione
procedurale
Funzioni tail recursive – Una funzione ricorsiva si dice tail recursive se richiama se
stessa esclusivamente nell’ultima istruzione. Le funzioni tail recursive sono una classe di
funzioni ricorsive molto efficienti in termini di memoria. In particolare l’esecuzione di una
funzione tail recursive accresce lo stack di un numero di statement fisso, indipendente da
quante volte essa richiama se stessa. Ciò è dovuto al fatto che al momento della chiamata
ricorsiva non ci sono statement successivi che rimangono in sospeso e che ad ogni
chiamata accrescono lo stack, dunque non è necessario conservare in memoria il contesto
delle attivazioni precedenti.
Una procedura che viene eseguita con un execution stack di dimensione costante è detta
anche iterativa. Ne consegue che, quando possibile è vantaggioso trasformare un
procedura ricorsiva non iterativa in una che lo è. Questa trasformazione prende il nome di
last call optimization.
Esempio – Last call optimization
La seguente funzione:
può essere codificata nel seguente modo:
Tuttavia, tale implementazione non è tail recursive. È però possibile applicare una last call
optimization
Accumulatori – Una delle tecniche di last call optimization consiste nell’introduzione di un
accumulatore, ossia una struttura dati che memorizza il risultato parziale di un’iterazione.
Al termine del processo di esecuzione, quindi, la variabile accumulatore conterrà il risultato
finale dell’esecuzione della funzione. Un accumulatore è uno state invariant, ovvero una
proprietà valida in tutti gli stati di un’elaborazione. L’introduzione di un accumulatore
comporta spesso l’aggiunta di un parametro alla funzione. Per mantenere la stessa
interfaccia e per non renderlo visibile all’esterno è buona norma wrappare la nuova
funzione ricorsiva.
Esempio – Last call optimization con accumulatore
Consideriamo la funzione potenza definita come segue:
Una possibile implementazione in Oz è la seguente.
Tale implementazione non è però tail recursive. Possiamo però renderla tail recursive con
l’introduzione di un accumulatore che di volta in volta mantiene il risultato parziale.
A questo punto è bene wrappare la nuova funzione per mantenere la precedente
interfaccia e wrappare l’accumulatore.
5. TECNICHE DI PROGRAMMAZIONE DICHIARATIVA
5.1 Gestione delle eccezioni
Durante l’esecuzione di un programma di possono verificare delle condizioni eccezionali,
che possono essere:
Interne al codice, come nel caso di una divisione per 0;
Esterne all’ambiente di esecuzione, come nel caso del tentativo di apertura di un
file inesistente.
Tali eccezioni, se non gestite adeguatamente, provocano l’interruzione del programma
prima della sua conclusione. Al fine di evitare questa situazione, occorre mettere in atto un
meccanismo di rilevamento e gestione delle eccezioni, che sia in grado quindi di rilevare
l’errore e di trasferire il controllo all’exception endler, passandogli un valore che descrive
l’errore occorso. Un meccanismo di questo tipo è basato sul principio di confinamento
dell’errore.
Principio di confinamento dell’errore – Il principio di confinamento dell’errore si basa
sulla concezione di un programma come un insieme di componenti interagenti tra loro e
organizzati in modo gerarchico (ogni componente è costituito da componenti più piccoli).
Tale principio afferma che un errore in un componente dovrebbe essere intercettato ai
confine del componente stesso, mentre all’esterno dovrebbe essere riportato in modo
controllato e non distruttivo, permettendo al resto del sistema di continuare a funzionare
correttamente.
Funzionamento del meccanismo - Quando viene sollevata un’eccezione, il meccanismo
di gestione delle eccezioni provoca un salto nell’esecuzione del programma, alla ricerca
del primo blocco in grado di gestirla. Se il componente (contesto di esecuzione) in cui si è
verificata l’eccezione non è in grado di gestirla, l’eccezione viene propagata al livello
superiore, ovvero al componente esterno che lo ha invocato. Nel processo di risalita
occorre passare al livello più esterno anche la natura dell’errore. Questo processo
continua fino a quando non viene trovato un gestore appropriato. Se nessun gestore viene
trovato lungo la catena di invocazioni, il programma termina con un errore non gestito,
generalmente mostrando un messaggio diagnostico.
Statement try-catch– In Oz è possibile catturare e gestire un errore con lo statement trycatch, che ha la seguente sintassi:
A livello semantico, questo statement esegue lo statement ⟨𝑠1 ⟩ e, se durante la sua
esecuzione viene generata un’eccezione ⟨𝑥⟩, allora viene sospesa l’esecuzione di ⟨𝑠1 ⟩ e
procede con l’esecuzione di ⟨𝑠1 ⟩, che solitamente si occupa di gestire l’errore.
È possibile anche specificare uno statement che deve essere eseguito dopo lo statement
⟨𝑠1 ⟩ in ogni caso, indipendentemente dal fatto che si sia generato o meno un errore. Lo si
può fare attravero la parola chiave finally. Dunque, la sintassi diventa:
𝑡𝑟𝑦 ⟨𝑠1 ⟩ 𝑐𝑎𝑡𝑐ℎ ⟨𝑥⟩ 𝑡ℎ𝑒𝑛 ⟨𝑠2 ⟩ 𝑓𝑖𝑛𝑎𝑙𝑙𝑦 ⟨𝑠3 ⟩ 𝑒𝑛𝑑
Un tipico caso dell’utilizzo di finally è quello in cui all’interno di ⟨𝑠1 ⟩ si legge all’interno di un
file. Indipendentemente dall’esito di tale operazione, quando sono terminate le operazioni
sul file occorre chiuderlo.
Statement raise – Oz prevede anche uno statement
che consente al programmatore di generare manualmente un errore in un punto specifico
del codice per segnalare il verificarsi di una condizione indesiderata relativamente alla
logica dell’algoritmo. Tale statement provoca una condizione di errore di tipo ⟨𝑥⟩ e salta al
contesto di gestione delle eccezioni (try-catch) più vicino.
5.1.1 Definizione formale della semantica di try-catch
La semantica formale del SS:
è descritta dai seguenti passaggi:
1. Pop del SS dal ST;
2. Push del SS (𝑐𝑎𝑡𝑐ℎ < 𝑥 > 𝑡ℎ𝑒𝑛 < 𝑠2 > 𝑒𝑛𝑑 , 𝐸) nel ST;
3. Push del SS (< 𝑠1 > , 𝐸)
Si tratta di uno statement non-suspending.
5.1.2 Definizione formale della semantica di raise
La semantica formale del SS:
può essere descritta formalmente con i seguenti passaggi:
1. Pop del SS dal ST;
2. Si esegue il pop degli statement successivi fino a che non si trova uno statement di
catch (𝑐𝑎𝑡𝑐ℎ ⟨𝑦⟩ 𝑡ℎ𝑒𝑛 ⟨𝑠⟩ 𝑒𝑛𝑑, 𝐸) in cui ⟨𝑦⟩=⟨𝑥⟩.
a. Se viene trovato uno statement con queste caratteristiche si esegue il pop
dello stesso e poi il push dello statement (⟨𝑠⟩, 𝐸 + {𝑦 → 𝐸(< 𝑥 >)}) nel ST.
b. Se non viene trovato si genera una condizione di errore e l’esecuzione del
programma viene interrotta.
5.2 Programmazione basata sui tipi
La programmazione basata sui tipi è un approccio in cui la progettazione e lo sviluppo di
un programma partono dalla definizione dei tipi di dati con cui il programma deve operare.
Tipi intesi come ADT che quindi definiscono non solo l’insieme dei valori possibili del dato
ma anche quali sono le operazioni applicabili su esso.
Questo insieme di funzioni costituisce un’interfaccia per il tipo di dato di dato, definendo
come il dato può essere manipolato ma nascondendo l’implementazione sottostante
(come il dato è rappresentato e modificato). Dunque, per utilizzare un tipo di dato è
sufficiente conoscere la sua interfaccia. Tale meccanismo rende il codice più modulare e
facilmente manutenibile, poiché, se l’interfaccia rimane la stessa, eventuali modifiche al
tipo di dato non si propagano anche sul codice che lo utilizza.
Vedi esempio definizione di stack e dizionario come esempio a pag 151-155.
5.3 Concorrenza nel modello dichiarativo
Con concorrenza si intende la capacità di eseguire le diverse parti che compongono un
programma in maniera logicamente simultanea.
Concorrenza vs. Parallelismo - Il termine concorrenza va distinto da quello di
parallelismo con cui invece si intende un’elaborazione fisicamente simultanea. Dunque, la
concorrenza è un concetto più generale si può applicare anche a calcolatori sequenziali,
mentre il parallelismo è applicabile esclusivamente a calcolatori con più processori.
Vantaggi – La concorrenza offre numerosi vantaggi:
Aumento delle prestazioni:
o Durante le attese il sistema può iniziare altri lavori che non necessitano di
quei dati;
o Nel caso di processori multicore si sfrutta la presenza di più CPU per
parallelizzare l’esecuzione delle diverse porzioni del programma.
Nel caso in cui i dati di input incrementali (es. stream) è consente di elaborarli in
restituire un output in maniera incrementale, a mano a mano che si rendono
disponibili i dati di input.
5.3.1 Implementazione della concorrenza con i thread
Uno dei modi per realizzare un programma concorrente è utilizzare il concetto di thread.
Un thread consiste in una porzione di codice in esecuzione e un programma può essere
formato da uno o più thread.
In Oz si può creare e attivare un thread con la seguente sintassi:
Una volta creato un thread, esso viene automaticamente eseguito appena possibile, ossia
quando tutte le variabili coinvolte sono definite e non ci sono vincoli che ne impediscano
l’esecuzione. I thread definiti all’interno dello stesso programma condividono lo stesso
SAS. Di conseguenza:
Variabili diverse possono essere lette e scritte simultaneamente da thread diversi.
Una stessa variabile può essere solo letta e non scritta simultaneamente da thread
diversi. La scrittura in una stessa cella di memoria da parte di thread diversi può
avvenire solo sequenzialmente. La sequenzializzazione di tali operazioni è un
compito svolto dal SO.
Definizione formale della semantica dello statement per la creazione di un thread Per definire formalmente la semantica dello statement per la creazione di thread, occorre
modificare la definizione della macchina astratta. Infatti, affinchè più thread possano
essere eseguiti simultaneamente, occorre che ciascuno di essi sia associato ad un diverso
semantic stack. Questi semantic stack fanno riferimento comunque allo stesso SAS.
Ora possiamo definire formalmente la semantica del SS che consente la creazione di un
thread. La sua sintassi è:
Esso viene eseguito secondo i seguenti passaggi:
1. Pop del SS dal ST;
2. Creazione di un nuovo semantic stack 𝑆𝑇1 ;
3. Push del SS (< 𝑠1 >, 𝐸) nel nuovo semantic stack 𝑆𝑇1 .
Si tratta di uno statement non-suspending, in quanto non si basa sulla presenza di dati.
Causal Order e modello dichiarativo concorrente – Si definisce causal order la
sequenza degli execution state (ES) attraversati dalla macchina durante l’esecuzione di un
programma.
In un programma sequenziale gli execution state sono totalmente ordinati, ossia per
ogni ES è possibile determinare l’ES precedente e quello successivo, dal momento
che vengono eseguiti sempre nello stesso ordine.
In un programma concorrente:
o Gli ES all’interno di un singolo thread sono totalmente ordinati;
o Gli ES globali del programma sono invece parzialmente ordinati, poiché
l’ordine degli ES dipende dall’interleaving delle istruzioni (da come vengono
schedulati i thread), il che può variare da un’esecuzione all’altra. Tale
variabilità può generare nondeterminismo, ovvero una condizione per cui il
programma può produrre risultati diversi anche a parità di input, rendendo il
comportamento non prevedibile a priori.
Tuttavia, nel modello dichiarativo non occore gestire questa problematica perché esso la
previene grazie al concetto di SAS, che non consente l’assegnazione di più valori ad una
stessa variabile nel corso del programma. Di conseguenza, ogni esecuzione dello stesso
programma concorrente con lo stesso input produce sempre risultati equivalenti (anche se
parziali) rispetto a ogni altra possibile esecuzione, oppure termina con un errore. Questa
proprietà prende il nome di modello dichiarativo concorrente.
Nota: con terminazione parziale si intende la condizione di sospensione di un thread del
programma in attesa dei dati necessari per proseguire l’esecuzione. Tuttavia, il
programma può aver già prodotto una parte dell’output.
Scheduling – Lo scheduling è un meccanismo che definisce quando e per quanto tempo
un thread viene eseguito. Un thread si definisce:
Eseguibile (runnable) se non è bloccato in attesa che una variabile dataflow diventi
bound, cioè le venga associato un valore.
Sospeso (suspended) se è bloccato in attesa che una variabile dataflow divenga
bound.
Uno scheduler si dice fair (equo) se garantisce l’esecuzione (prima o poi) di tutti i thread
eseguibili. Uno scheduler di questo tipo, nel modello dichiarativo sequenziale, rende un
programma prevedibile.
5.3.2 Implementazione della concorrenza con le coroutine
Un’alternativa ai thread per introdurre la concorrenza all’interno di un programma è
rappresentata dalle coroutine. Una coroutine è un’unità di esecuzione non preemptive,
ovvero la sua attivazione non è gestita da uno scheduler esterno (come quello del sistema
operativo), ma è il programma stesso a decidere quando e quale coroutine attivare,
secondo la propria logica.
La creazione di una coroutine avviene attraverso la funzione Spawn:
𝐶 = {𝑆𝑝𝑎𝑤𝑛 𝑃}
dove P è il corpo della coroutine e C è un identificatore (o handle) alla coroutine creata.
Mentre quando viene definito un thread l’attivazione avviene automaticamente il prima
possibile (la gestione è a carico dello scheduler del SO), nel caso di una coroutine è il
programma stesso che deve esplicitamente attivarla con la funzione Resume:
{𝑅𝑒𝑠𝑢𝑚𝑒 𝐶}
Dunque, la creazione di una coroutine definisce un nuovo potenziale luogo di esecuzione
ma non lo attiva immediatamente. Quando viene invocata la funzione Resume,
l’esecuzione della coroutine chiamante viene sospesa e il controllo viene trasferito alla
coroutine attivata. Di conseguenza, con le coroutine si realizza solo un parallelismo logico,
e non fisico, poiché viene eseguita una sola porzione di codice alla volta.
Le coroutine differiscono, però anche dalle procedure perché queste ultime hanno un solo
luogo di esecuzione e una volta terminate il controllo ritorna al chiamante e non possono
riprendere. Invece, con il meccanismo delle coroutine si hanno più luoghi di esecuzione
distinti e ciascuna coroutine, può cedere e riprendere il controllo in punti successivi del
programma, mantenendo il suo stato interno.
In definitiva, le coroutine offrono un maggiore controllo sull'interleaving, poiché è il
programma stesso a decidere quando sospendere e riprendere l'esecuzione. Consumano
meno risorse rispetto ai thread, dal momento che non richiedono intervento del sistema
operativo né il supporto a livello di kernel.
Tuttavia, non consentono parallelismo fisico, in quanto l'esecuzione avviene in un solo
luogo di esecuzione alla volta, e la scrittura del codice può risultare più complessa per il
programmatore, che deve gestire esplicitamente la cooperazione tra le coroutine (es.
quando sospendere/riprendere).
5.3.3 Modello produttore-consumatore
Il modello Produttore-Consumatore fa riferimento alla coordinazione tra thread che
consumano i dati e thread che li consumano. Il modello prevede due entità:
Il produttore che crea dati;
Il consumatore che prende quei dati e li elabora o li utilizza.
Gli stream - Lo scambio di dati avviene attraverso una struttura dati condivisa da
entrambi. Una struttura dati particolarmente efficace in questo contesto è quella dello
stream, che consiste in una lista il cui ultimo elemento è una variabile unbound. Questo
permette al produttore di inserire a runtime nuovi elementi nella lista. Dall’altra parte, il
consumatore elabora i nuovi dati man mano che si rendono disponibili. Un thread che
comunica attraverso uno stream è detto stram object.
Pipeline – Il modello produttore-consumatore può essere replicato più volte creando una
catena di stream object, in cui ciascun elemento alimenta il successivo. Una struttura di
questo tipo è detta pipeline e consente l’esecuzione di tutti gli stream object che la
compongono in maniera concorrente. Allo stesso tempo la pipeline solleva il
programmatore dalla gestione esplicita della sincronizzazione, poiché ogni stream object
(tranne il primo della catena) elabora i dati non appena l'entità precedente li rende
disponibili, e si sospende automaticamente quando non ci sono dati da processare.
Il modello Produttore-Consumatore può essere Data-Driven o Demand-Driven.
Modello Producer-Consumer Data-Driven – Nel modello Producer-Consumer DataDriven è il Producer che guida l’esecuzione producendo i dati da passare al Consumer
non appena ne ha la possibilità e il consumatore consuma fin tanto che il produttore
produce. Dunque si tratta di un modello eager. Di seguito è riportata l’implementazione in
Oz.
Modello Producer-Consumer Demand-Driven - Nel modello Producer-Consumer DataDriven non è il Producer che decide quando produrre, bensì esso produce un dato
solamente quando richiesto dal Consumer (che dunque guida l’esecuzione). Dunque, in
tale modello il Producer produce solo i dati necessari, senza sprecare risorse.
Questo ribaltamento del controllo, a livello implementativo si traduce nel fatto che in tal
caso è il Consumer a creare lo stream condiviso e, ogniqualvolta deve richiedere un dato,
ad estenderlo con uno “slot” vuoto che il Producer riempie.
Lazy Evaluation - Il modello Producer Consumer Demand-Driven può essere
implementato anche attraverso l’utilizzo esplicito della lazy evalutation. Una funzione lazy
viene eseguita solamente quando il risultato si rende necessario per valutare
un’espressione eager. L’esecuzione lazy è basata sul concetto di trigger.
Un trigger è una coppia (F,X) composta da una funzione F senza argomenti e un
identificatore di variabile X. La creazione di un trigger avviene attraverso l’attivazione della
funzione built-in ByNeed.
Eseguendo questo codice, all’identificatore X non viene assegnato alcun valore, ma
quando tale identificatore viene utilizzato in un’espressione eager, allora, in un thread,
viene attivata la funzione F e il risultato di tale funzione viene assegnato all’identificatore
X.
Come detto, possiamo implementare il produttore demand-driven utilizzando la lazy
evaluation esplicita. Per realizzare ciò, è possibile adottare due approcci: utilizzare i trigger
o utilizzare la parola chiave lazy, che rappresenta un’astrazione linguistica dei trigger.
Dynamic Linking - L’esecuzione lazy di una funzione trova applicazione in diversi
contesti, tra cui il dynamic linking. Il dynamic linking è una tecnica di esecuzione del codice
che prevede il caricamento in memoria di moduli di programma solo quando sono
effettivamente richiesti. Se il programma viene strutturato in moduli, ognuno dei quali
rappresentato da un record con campi costituiti da funzioni elaborative (lazy), queste
ultime verranno eseguite solo nel momento in cui la macchina richiede la loro valutazione
durante l’esecuzione. Ciò consente un uso ottimale delle risorse e caricando in memoria
solo i moduli effettivamente necessari.
6. PROGRAMMAZIONE BASATA SU STATI
6.1 Il concetto di stato
Definizione – Con stato si intende il risultato intermedio di un’elaborazione; rappresenta
cioè una configurazione temporanea del sistema.
Stato implicito ed esplicito – Anche nell’ambito della programmazione dichiarativa è
presente il concetto di stato. Tuttavia, si tratta di uno stato implicito.
La caratteristica dello stato implicito (rispetto ad una funzione) è quella di rimanere
confinato all’interno della funzione elaborativa e si perde quando essa termina. Un
esempio è il caso di un accumulatore definito come parametro formale di una funzione
ricorsiva.
Quello che realmente differenzia la programmazione stateful da quella dichiarativa è la
presenza di uno stato esplicito. Uno stato si dice esplicito rispetto ad una procedura (o
funzione) se non è un parametro della procedura e se il suo tempo di vita si estende oltre
la singola attivazione della procedura stessa. Lo stato esplicito è dunque incompatibile con
la programmazione dichiarativa poiché la sua introduzione non garantisce la proprietà per
cui il risultato di una funzione dipende esclusivamente dall’input (concetto di funzione
matematica). Lo stato esplicito, infatti, può essere visto come una memoria a lungo
termine di una funzione e influenza il risultato. Dunque, nella programmazione stateful, in
generale, l’output di un componente dipende sia dall’input che dal suo stato esplicito.
Stato esplicito e astrazione – Con l’introduzione dello stato esplicito, è possibile
introdurre un livello interno della funzione, in cui è codificata la sua conoscenza. Dunque,
si possono distinguere due livelli di una funzione:
Livello esterno (o interfaccia), che consiste nella parte visibile a chi utilizza la
funzione.
Livello interno, in cui è codificata la codificata la sua conoscenza e dettagliata la
logica del suo comportamento. Questa parte è visibile solo a chi sviluppa la
funzione.
Hidden state – Nascondendo lo stato esplicito di un componente all’interno del
componente stesso, attraverso un meccanismo di incapsulamento, è possibile farlo
apparire all’esterno come deterministico e prevedibile (a parità di input viene prodotto
sempre lo stesso output). Dunque, visto dall’esterno il componente si comporta in accordo
con il modello dichiarativo, ma all’interno è stateful. Tale concetto prende il nome di hidden
state. Un esempio è quello di una funzione che ha tra i suoi parametri formali un
accumulatore e viene wrappata con un’altra funzione per nascondere l’accumulatore
all’esterno.
6.2 Estensione del modello dichiarativo
Cella – Per introdurre lo stato esplicito all’interno di un programma occorre estendere il
modello dichiarativo con il concetto di cella. Una cella consiste in una particolare tipologia
di variabile che, nell’arco dell’esecuzione di un programma può essere legata a diversi
valori. Tuttavia, all’interno del SAS i legami variabile-valore sono tempo-invarianti.
Mutable store – Dunque, per supportare le variabili di tipo cella è necessario introdurre un
nuovo componente: il Mutable Store (MS). Il MS contiene al suo interno un insieme di
coppie nome_cella:variabile, ossia di legami tra una cella e una variabile del SAS. Questi
legami sono però tempo-varianti.
Grazie al mutable store è possibile assegnare valori diversi ad una cella durante
l’esecuzione di un programma mantenendo il principio del single-assignment all’interno del
SAS. Nel dettaglio, nel caso delle celle:
1. Nell’environment, l’identificatore è legato ad una variabile memorizzata nel SAS;
2. Nel SAS, la variabile è legata ad una cella;
3. Nel MS, la cella è legata ad un’altra variabile del SAS;
4. Nel SAS, quest’ultima variabile è associata ad un valore.
Dunque, i legami nel SAS tra variabile e cella e tra l’altra variabile e il valore sono tempoinvarianti. Il valore associato alla cella può essere cambiato modificando il legame nel MS,
cioè legando la cella ad un’altra variabile nel SAS.
6.3 Definizione formale della semantica dei nuovi statement
Formalmente, estendiamo il modello dichiarativo aggiungendo gli statement:
6.3.1 NewCell
Lo statement NewCell crea una nuova cella associata all’identificatore C unbound e lega
tale cella al valore associato a X.
Formalmente:
1. Si esegue il pop del SS dal ST;
2. Si crea un nuovo nome di cella nel SAS;
3. Si associa, nel SAS, il nome di cella creato alla variabile E(C).
4. Nel Mutable Store si aggiunge la coppia che lega il nuovo nome di cella alla
variabile E(X).
6.3.2 Exchange
Lo statmente exchange assegna a X il valore associato alla cella C e poi a C il valore
legato a Y con un’operazione atomica.
Formalmente:
1. Viene eseguito il pop del SS dal ST;
2. Se E(C) nel SAS non è legato al nome di una cella, allora si genera un errore. In
caso contrario l’esecuzione prosegue.
3. Nel MS si associa al nome della cella (che nel SAS è associata a E(C)) la variabile
legata a Y, cioè E(Y).
4. Nell’environment si associa all’identificatore X la variabile che prima nel MS era
associata al nome della cella.
6.4 Programmazione modulare
L’introduzione dello stato consente di implementare tecniche di programmazione
modulare. Un programma (o sistema) è modulare se le modifiche che vengono apportate
ad esso sono confinate al componente interessato dal cambiamento. La modularità non è
sempre compatibile con il modello di programmazione dichiarativa.
6.5 Indexed collections
Le indexed collections sono collezioni di valori (in generale di partial values), in cui ogni
elemento è accessibile tramite un indice. In Oz, le tuple ed i record sono esempi di
indexed collections stateless. Si tratta infatti di collezioni di partial values accessibile
rispettivamente specificando la posizione o il nome della feature associato all’elemento.
Tuttavia, ogni elemento di queste collezioni può essere assegnato una sola volta
all’interno di un programma.
Nella programmazione stateful si possono introdurre nuove indexed collections, come gli
array e i dizionari. Un array può essere visto come una tupla di celle, ossia una struttura
dati che realizza un mapping tra numeri interi e partial values. Gli estremi del dominio
possono cambiare durante l’esecuzione del programma, rendendo l’array una struttura
dati dinamica. I dizionari possono essere pensati invece come record di celle.
6.6 Tipi di ADT
Un tipo di dato astratto può essere caratterizzato sulla base di diverse proprietà, ortogonali
tra loro:
Visibilità
Separazione tra dati e funzioni
Mutabilità dello stato
Visibilità della struttura – In termini di visibilità un ADT può essere:
Open, se la sua struttura è visibile in tutto il programma, dunque completamente
esposta (si applicano comunque le regole sullo scope deifnite dal linguaggio).
Secure, se la struttura dell’ADT è visibile solo in una porzione limitata di codice (ad
esempio nel contesto in cui è definito).
Separazione tra strutture dati e funzioni – Un altro aspetto con cui si può caratterizzare
un ADT è il livello di separazione che esiste tra valori e operazioni. Sulla base di tale
aspetto si distinguono ADT:
Bundled: sono quelli in cui i valori e le operazioni che permettono di manipolarli
sono definiti insieme. Questo approccio è utilizzato nella programmazione orientata
agli oggetti, in cui dati e operazioni ad essi applicabili vengono racchiusi all’interno
della stessa entità, ch prende il nome di oggetto.
Unbundled: sono quelli in cui i valori e le operazioni sono definiti separatamente,
ossia come entità distinte. In questo caso si ha una maggiore separazione tra
elaborazionee struttura dei dati.
Mutabilità dello stato – Infine, in base alla mutabilità del loro stato, gli ADT si possono
classificare in:
Stateless: in questa categoria rientrano quegli ADT in cui lo stato dell’istanza è
tempo-invariante. Questi ADT sono quelli usati nel modello dichiarativo, in cui ogni
modifica genera una nuova istanza, lasciando inalterate le precedenti.
Stateful: si tratta di ADT in cui lo stato dell’istanza può cambiare nel tempo.
Dunque, è possibile modificare i dati dell’istanza senza creare una nuova entità.
6.6.1 Gli ADT Secure
Gli ADT sicuri consentono di rendere l’utilizzo del dato indipendente dalla sua
implementazione, attraverso l’esposizione di un interfaccia non aggirabile. Questo
conferisce robustezza all’ADT perché previene l’accesso diretto alla struttura dati, che
potrebbe comprometterne il corretto funzionamento.
L’implementazione di un ADT sicuro può avvenire principalmente attraverso due approcci:
Stationary value: prevede che l’implementazione (ossia la rappresentazione del
valore dell’ADT) sia sempre confinata in un determinato contesto, sul quale può
agire solo un numero limitato di operazioni. Dunque, il dato non esce mai dal suo
contesto di sicurezza e può essere manipolato solo dalle funzioni che
caratterizzano l’ADT.
Mobile value: prevede la possibilità di spostare l’implementazione in contesti
diversi, ma rimane comunque manipolabile solo da chi possiede la chiave. La
sequenza d’uso prevede:
1. L’estrazione del dato dal contesto sicuro;
2. L’elaborazione del dato;
3. Il reinserimento del dato nel contesto sicuro.
7. PROGRAMMAZIONE AD OGGETTI
7.1 Introduzione
Nella programmazione orientata agli oggetti, i programmi possono essere descritti come
insiemi di ADT stateful e bundled che interagiscono tra loro.
Gli oggetti - Dunque, si tratta di ADT che incapsulano uno stato mutabile e le funzioni che
consentono di manipolarlo nella medesima entità (bundled), chiamata oggetto. Le funzioni
(dette metodi) definite da un oggetto costituiscono la sua interfaccia, che rappresenta
l’unico modo per interagire con esso. Infatti, lo stato di un oggetto non è accessibile
dall’esterno. Questa proprietà prende il nome di incapsulamento.
Le classi - Un’altra proprietà fondamentale del computational model dell’OOP è quella
dell’istanziazione: essa permette di creare oggetti diversi a partire da una definizione
astratta, detta classe. Una classe definisce gli elementi comuni a tutti gli oggetti che
discendono da essa; in particolare essa specifica:
Gli attributi, ossia strutture dati stateful che nel loro insieme rappresentano lo stato
di un oggetto di quella classe.
I metodi: funzioni che permettono di manipolare lo stato di un oggetto, dunque di
interagire con esso.
Le proprietà: caratteristiche particolare della classe o degli oggetti istanziati a
partire da essa (es. alcune classi non possono essere ulteriormente specializzate).
Una classe può essere definita da zero oppure in modo incrementale, a partire dalla
definizione di un’altra classe, attraverso il meccanismo dell’ereditarietà.
Ereditarietà - L’ereditarietà è il vero elemento distintivo della programmazione ad oggetti.
Essa si basa sull’osservazione che gli ADT hanno spesso molti elementi in comune.
Ad esempio, ci sono molte astrazioni che sono collezioni di elementi in cui si possono
aggiungere nuovi elementi o eliminare elementi già esistenti. Alcune volte si desidera che
essi si comportino come stack (LIFO) altre come code (FIFO) e altre ancora che si
comportino in modo ancora diverso. Però sempre di collezioni di elementi si tratta.
Gli elementi in comune generano del codice duplicato che, oltre a rendere il programma
più lungo, rende più difficile la sua manutenzione. L’ereditarietà consente ad un ADT di
ereditare le caratteristiche di un altro ADT e di definire solo le differenze rispetto all’ADT
da cui deriva. Dunque, grazie all’ereditarietà è possibile creare strutture gerarchiche che
permettono una definizione incrementale degli ADT.
7.2 Computational model dell’OOP
Il Computational Model dell’OOP si fonda su quattro pilastri (molti dei quali già descritti):
Incapsulamento
Composizionalità: possibilità di comporre oggetti per formarne di nuovi. Questo
consente di creare strutture complesse a partire da strutture più semplici.
Istanziazione
Ereditarietà
7.3 Classi e oggetti in Oz
Classe – In Oz una classe è un valore. Ciò significa che può essere assegnata ad una
variabile, passata come parametro ad una procedura ed essere restituita da una funzione.
Dunque è possibile definire classi anonime e unificarle con un identificatore di variabile, il
che consente di creare dinamicamente classi a runtime, adattandole a diversi scenari che
si possono presentare durante l’esecuzione del programma.
Gli attributi in Oz sono implementati di default come celle. Tuttavia, attraveros la parola
chiave feat è possibile definire attributi con comportamento single-assignment (non
modificabile dopo l’inizializzazione).
Oggetti – Un oggetto si può istanziare usando diverse funzioni predefinite; la più comune
è New.
Essa richiede come parametri l’identificatore della classe da cui discende l’oggetto e
l’identificatore di un metodo, generalmente utilizzato per inizializzare i valori degli attributi
dell’oggetto. La funzione New restituisce una struttura dati che rappresenta l’oggetto
creato.
In Oz gli oggetti sono considerati come delle procedure.
Inizializzazione degli attributi – Nell’ambito della definizione di una classe, in Oz
esistono tre modi per inizializzare gli attributi:
Per classe: se un attributo viene istanziato direttamente a livello di classe
(attraverso l’operatore : seguito dal valore desiderato), tutti gli oggetti discendenti
da quella classe avranno lo stesso valore iniziale per quell’attributo.
Per istanza: l’inizializzazione di un attributo per istanza fa in modo che il valore
iniziale di quell’attributo, in generale, sia diverso da oggetto a oggetto (tra quelli
discendenti da quella classe). Tipicamente questo approccio si implementa
attraverso il metodo chiamato al momento della creazione dell’oggetto, assegnando
all’attributo un valore che è funzione dei parametri passati a tale metodo.
Per brand: consiste nell’assegnare lo stesso valore ad attributi di classi diverse
facenti parte di un brand. Un brand consiste in un insieme di classi non legate tra
loro da legami di ereditarietà ma che condividono un contesto comune. A livello
implementativo, lo si ottiene assegnando a livello di classe uno stesso identificatore
agli attributi delle diverse classi.
NOTA
La prima istruzione agisce a livello di SAS assegnando un valore alla variabile
(inizialmente unbound) che è associata alla cella tramite il MS. Dunque, può essere
eseguito solo una volta.
La seconda istruzione agisce a livello di MS legando la cella ad un'altra variabile.
7.4 Ereditarietà
L’ereditarietà è il meccanismo precedentemente discusso che consente di definire una
classe in maniera incrementale, partendo da una o più altre classi e specificando
solamente le peculiarità della nuova classe. La nuova sottoclasse acquisisce
automaticamente attributi e metodi delle sue superclassi.
7.4.1 Tipologie di ereditarietà
La programmazione orientata agli oggetti prevede due tupologie di ereditarietà tra classi:
Ereditarietà singola: una classe eredita attributi e metodi da una sola superclasse.
Ereditarietà multipla: una classe eredita attributi e metodi da più superclassi.
Quando in un oggetto di una classe si invoca un metodo
definito in più di una sua superclasse (o definito dalla
classe di cui è istanza e da almeno una superclasse),
per determinare quale metodo eseguire, ci sono due
possibilità:
Binding dinamico: prevede l’invocazione del
metodo definito nella superclasse più vicina. Si
tratta del comportamento predefinito nella
maggior parte dei LDP.
Binding statico: prevede che venga esplicitata
la classe di cui deve essere attivato il metodo,
anteponendo il nome della classe al nome del
metodo.
Va notato come nel caso degli attributi il binding sia sempre dinamico.
7.4.2 Alternative all’ereditarietà: delegation e forwarding
Oltre alla tradizionale definita sulle classi, i moderni linguaggi di programmazione offrono
due alternative che permettono di creare legami di ereditarietà tra oggetti in maniera
dinamica (a run-time): forwarding e delegation.
Dati due oggetti O1 e O2, se O1 forwarda O2 o O1 delega O2, nel momento in cui su O1
viene attivato un metodo non definito, l’attivazione passa automaticamente ad O2.
Dunque, queste due tipologie di legame consentono di attivare su un oggetto un metodo o
una proprietà di un altro oggetto.
Tuttavia, le due tipologie di legame presentano una differenza significativa:
Il Forwarding è un legame lasco, cioè gli oggetti coinvolti mantengono una
separazione di scope. Questo significa che il self in O2 ha come scope O2 stesso
(si accede ai metodi e agli attributi di O2).
La delegation è un legame stretto (come l’ereditarietà, ma si applica a livello di
oggetti). Dunque esso si basa sul concetto di common self: se O1 chiama un
metodo definito in O2 tramite delegation, allora il self durante l’esecuzione di quel
metodo non è O2 ma O1, anche se il corpo del metodo è definito in O2.
In ogni caso, come accennato, entrambi sono legami dinamici, definibili a runtime, mentre
l’ereditarietà è statica.
7.5 Visibilità
Visibilità definite dal linguaggio. Linguaggi diversi possono utilizzare gli stessi termini per
definire visibilità diverse. Esempio membri privati:
In Java e C++: Un membro private è accessibile solo all’interno della classe dove è
definito. Tutti gli oggetti della stessa classe possono accedere ai membri privati
l’uno dell’altro.
In Oz: La visibilità private è più stretta e verticale: accessibile solo agli oggetti di
quella classe o che sono istanza delle sue sottoclassi. Un oggetto può accedere ai
propri membri privati, ma non a quelli di altri oggetti, nemmeno se istanze della
stessa classe.
In Oz, tutti gli attributi di una classe sono intrinsecamente privati, ossia visibili solo negli
oggetti generati dalla classe che li definisce o dalle sue sottoclassi. I metodi, invece, sono
pubblici per impostazione predefinita.
7.6 Tecniche di OOP
7.6.1 Strutturazione di una gerarchia di classi
Ogniqualvolta si struttura una gerachia di classi si devono rispettare due vincoli:
o Aciclicità del grafo, in modo da poter determinare un ordine logico
nell’ereditarietà.
o Unicità dei metodi: per ogni classe ogni metodo deve avere un nome univoco e,
se ereditato, deve essere riconducibile ad una e una sola superclasse (tenendo
conto delle regole di overriding).
Per la progettazione di una gerarchia di classi esistono due approcci principali:
Type View: in tale approccio le classi sono viste come ADT e le sottoclassi come
sottotipi di dato. Pertanto, esso si basa sul rispetto della substitution property, per la
quale ogni condizione che vale per oggetti di una classe vale anche per quelli delle
sue sottoclassi. A livello pratico questo implica che ogni sottoclasse non può
modificare né metodi né attributi ereditati ma solo aggiungerne di nuovi.
Structure View: in questo approccio la gerarchia delle classi è progettata
esclusivamente in funzione delle esigenze di progettazione dell’applicazione, senza
necessariamente rispettare la substitution property.
L’approccio type view è più stringente ma offre vantaggi in termini di robustezza e
manutenibilità del codice. Al contrario, l’approccio structure view è più pratico ma introduce
il rischio di comportamenti inattesi e malfunzionamenti. Inoltre, quando la catena di
ereditarietà diventa particolarmente lunga e complessa, l’approccio structure view
aumenta ulteriormente il rischio di perdita di comprensione del codice.
7.6.2 Polimorfismo
Un metodo identificato dal nome viene definito polimorfo se può essere applicato ad
oggetti diversi o attivato con parametri diversi. Il polimorfismo è ottenibile attraverso due
tecniche:
Override: consiste nel ridefinire il metodo di una classe in una sua sottoclasse.
Dunque, il metodo implementa azioni diverse in contesti diversi.
Overload: consiste nel ridefinire il metodo con un numero diverso di parametri
all’interno della stessa classe o gerarchia.
0
You can add this document to your study collection(s)
Sign in Available only to authorized usersYou can add this document to your saved list
Sign in Available only to authorized users(For complaints, use another form )