2. Sintassi e Semantica

Transcript

2. Sintassi e Semantica
2. Sintassi e Semantica
Un linguaggio di programmazione è un linguaggio formale dotato di una sintassi e una semantica
ben definita. Per linguaggio formale si intende un insieme di stringhe di lunghezza finita costruite
sopra un alfabeto finito, cioè sopra un insieme finito di oggetti tendenzialmente semplici che
vengono chiamati caratteri, simboli o lettere. Il compito della sintassi è quello di stabilire quali, tra
le innumerevoli stringhe generabili sull’alfabeto, costituiscono le “parole” (leggi istruzioni) valide
che formano il linguaggio. Il compito della semantica, invece, è quello di assegnare un significato
alle innumerevoli “frasi” (leggi programmi) ottenibili combinando le diverse parole del linguaggio.
2.1 Sintassi
La sintassi è un insieme di regole che definiscono la “forma” di un linguaggio; esse definiscono
come ottenere delle frasi sequenzializzando una serie di componenti fondamentali, dette parole.
Applicando le regole della sintassi è possibile stabilire se una frase è corretta oppure no. Un
programma non è altro che un insieme di frasi esprimibili in un particolare linguaggio di
programmazione.
Ma come si fa a definire la sintassi di un linguaggio? Poiché ci sono teoricamente un numero
infinito di programmi (sintatticamente corretti o meno), chiaramente non possiamo minimamente
pensare di enumerarli tutti. Abbiamo bisogno di un modo per definire un insieme infinito attraverso
una descrizione finita. Il FORTRAN fu definito semplicemente enunciando alcune regole in
Inglese. L’ALGOL60 fu definito attraverso una grammatica libera dal contesto sviluppata da John
Backus. Questo metodo è diventato noto come Backus-Naur Form (BNF), dato che Peter Naur fu
colui che scrisse il report dell’ALGOL60. La BNF fornisce un modo chiaro, potente e compatto per
definire la sintassi di un linguaggio di programmazione.
Illustriamo ora, attraverso la BNF, la sintassi di un semplice linguaggio di programmazione.
< program > : : = {< statement >*}
< statement > : : = < assignment > | < conditional > | < loop >
< assignment > : : = < identifier > = < expression >;
< conditional > : : = if < expression > {< statement >+} |
if < expression > {< statement >+} else {< statement >+}
< loop > : : = while < expression > {< statement >+}
< expression > : : = < identifier > | < number > | (< expression >) |
< expression > <operator > < expression >
< operator > : : = + | – | * | / | = | ≠ | < | > | ≤ | ≥
< identifier > : : = < letter > < id >*
< id > : : = < letter > | < digit >
< number > : : = < digit >+
< letter > : : = a | b | …| z
< digit > : : = 0 | 1 | … | 9
1
Il significato di quanto riportato in una BNF è il seguente:
•
•
•
•
Ogni simbolo non delimitato da parentesi angolari è un simbolo terminale del linguaggio,
ovvero un simbolo appartenente all’alfabeto sul quale il linguaggio è definito.
Ogni stringa delimitata da parentesi angolari è un simbolo non terminale del linguaggio,
ovvero un simbolo che non appartiene direttamente al linguaggio, ma è un simbolo
transitorio che può essere riscritto come sequenza di simboli terminali e non.
Il simbolo : : = definisce una regola di riscrittura. Significa che il simbolo non terminale
alla sua sinistra si riscrive in una delle sequenze di simboli terminali e non riportati alla sua
destra. Le possibili scelte sono delimitate dal simbolo |. Ad esempio, l’ultima riga della BNF
di cui sopra significa che il simbolo non terminale < digit > si può riscrivere in uno a scelta
tra i simboli terminali 0, 1,…, 9.
Il simbolo + posto come apice di un simbolo non terminale sta ad indicare una sequenza non
vuota di tali simboli non terminali. Il simbolo * si differenzia da + in quanto sta ad indicare
una sequenza potenzialmente anche vuota. Ad esempio, la prima riga della BNF di cui sopra
ci dice che < program > è una sequenza eventualmente vuota di < statement >.
In sintesi, la descrizione sintattica di un linguaggio di programmazione ha due motivi fondamentali:
1. Aiuta il programmatore nello scrivere programmi sintatticamente corretti.
2. E’ lo strumento formale che i traduttori (interpreti e compilatori) usano per stabilire se un
programma è sintatticamente corretto.
Consideriamo ora il seguente programma e illustriamo come si possa stabilire la sua correttezza
sintattica attraverso l’uso della BNF.
{ while ( x ≠ y ) { x = x + 1;} }
Per stabilire se tale programma è sintatticamente corretto è sufficiente trovare una derivazione,
ovvero una sequenza di applicazioni delle regole stabilite dalla BNF che, partendo dal simbolo non
terminale < program > (detto simbolo iniziale), arrivi a generare il programma in questione.
< program > → {< statement >} → {< loop >} → { while < expression > {<statement>} } → {
while (< expression >) {< assignment >} } → { while (< expression > < operator > < expression >)
{< identifier > = < expression >;} } → { while (< identifier > ≠ < identifier >) {< letter > = <
expression > < operator > < expression >;} } → { while (< letter > ≠ < letter >) { x = < identifier >
+ < number >;} } → { while ( x ≠ y ) { x = < letter > + < digit >;} } → { while ( x ≠ y ) { x = x + 1;
} }1
ESERCIZIO. Dire se i seguenti programmi sono sintatticamente corretti e determinare, in caso
affermativo, la relativa derivazione:
1.
2.
3.
4.
{ while ( x <> y ) { x = x + 1;} }
{3<2}
{ if ( a + 2 < 3 ) { y = 2;} }
{ x = 2 + 13 * 6 / alpha;}
1
Normalmente la derivazione viene rappresentata tramite un albero in cui ad ogni nodo è associato un simbolo
(terminale e non) e ad ogni arco un’applicazione di una regola di riscrittura. Da qui il nome di albero di derivazione.
2
2.2 Semantica
La semantica definisce il significato dei programmi sintatticamente corretti nel linguaggio di
programmazione in questione. Per esempio, la semantica del C ci dice che la dichiarazione
int vector[10];
fa sì che si riservi spazio per 10 interi in memoria associati ad una variabile di nome vector. Gli
elementi di vector possono essere referenziati attraverso un indice che va da 0 a 9. In realtà non tutti
i programmi sintatticamente corretti possono avere un significato. La semantica, dunque, ha anche il
compito di separare i programmi realmente corretti da quelli semplicemente corretti sintatticamente.
Ad esempio, il programma
{ if ( x + 3 ) { x = 2;} }
è sintatticamente corretto in base alla BNF presentata nel paragrafo precedente, ma non lo è dal
punto di vista semantico dato che la semantica dell’istruzione condizionale richiede che la sua
valutazione dia luogo ad un risultato booleano di tipo true o false.
Un metalinguaggio per descrivere formalmente la semantica di un linguaggio di programmazione
deve basarsi su concetti matematici ben formulati, in modo che la definizione risultante sia rigorosa
e non ambigua. La possibilità di fornire una semantica formale rende la definizione di un linguaggio
indipendente dall’implementazione. La descrizione specifica ciò che fa il linguaggio senza basarsi
su come questo è effettivamente ottenuto nella sua implementazione. Tuttavia, il formalismo spesso
non va a braccetto con la leggibilità. Il formalismo può essere utile nella descrizione minuziosa di
un manuale avanzato per l’uso del linguaggio, ma nella maggior parte dei casi, una descrizione
informale, seppur rigorosa, si rivela sufficiente.
Se la BNF è stata universalmente accettata come lo strumento standard per la definizione della
sintassi dei linguaggi di programmazione, lo stesso compromesso non si è raggiunto per quanto
riguarda la definizione della semantica. Tre, infatti, sono gli approcci fondamentali utilizzati in
questo caso i quali sono noti con il nome di semantica assiomatica, denotazionale e operazionale.
Analizzeremo i primi due brevemente per poi soffermarci in maniera più dettagliata sulla
definizione della semantica operazionale di un linguaggio di programmazione semplificato nel
capitolo 4.
2.2.1 Semantica assiomatica
L’uso principale che si fa della semantica assiomatica è quello di provare la correttezza formale di
un programma, provare cioè che sotto certi vincoli specificati sui dati in input, il programma
termina soddisfacendo i vincoli specificati sui dati in output.
La semantica assiomatica modella un programma come una macchina a stati. Uno stato è descritto
attraverso un predicato della logica del prim’ordine che definisce le proprietà soddisfatte dai valori
delle variabili del programma in un determinato momento. In questo modo il significato di ogni
istruzione è definito da una regola mette in relazione i due stati che sussistono rispettivamente
prima e dopo la sua esecuzione.
Un predicato P che si vuole sia vero dopo l’esecuzione di un’istruzione S è detto una postcondizione
per S. Un predicato Q che risulti vero prima dell’esecuzione di S e garantisce che l’esecuzione di S
3
termina in uno stato in cui vale la postcondizione P è detto una precondizione per S e P. Per
esempio, y = 3 è una possibile precondizione per l’istruzione x = y + 1; e la postcondizione x > 0.
Il predicato y ≥ 0 è anch’esso una precondizione per la stessa istruzione e la stessa postcondizione.
Un predicato W è chiamato precondizione più debole per un’istruzione S e una postcondizione P se
ogni precondizione Q per S e P implica W. Tra tutte le possibili precondizioni per S e P, W è la più
debole, ovvero quella che specifica meno vincoli. E’ la precondizione necessaria e sufficiente che
deve valere perché S termini in uno stato che soddisfa P. Nell’esempio precedente si può facilmente
vedere come y = 3 implichi y ≥ 0. In effetti, y ≥ 0 è la precondizione più debole per l’istruzione x =
y + 1; e la postcondizione x > 0.
La semantica assiomatica definisce il significato di una istruzione S per mezzo di una funzione
asem che restituisce la precondizione più debole W per ogni postcondizione P. Essa fornisce anche
una regola di composizione in modo da risalire alla precondizione più debole per un intero
programma sulla base della postcondizione specificata dall’output desiderato.
Consideriamo l’istruzione di assegnamento x = expr; e la postcondizione P. La precondizione più
debole si ottiene sostituendo ogni occorrenza di x in P con l’espressione expr. Esprimiamo questo
procedimento con la notazione Px→expr. Ne consegue che asem(x = expr;, P) = Px→expr.
Semplici istruzioni, come ad esempio quella di assegnamento, possono essere sequenzializzate al
fine di formare un programma complesso. Consideriamo la sequenza S2;S1. Se sappiamo che
asem(S1, P) = Q e che asem(S2, Q) = R, otteniamo che asem(S2;S1, P) = R.
Anche la semantica dell’istruzione condizionale è piuttosto semplice. Abbiamo che asem(if B { L1
} else { L2 }, P) = (B ⇒ asem(L1, P)) and (not B ⇒ asem(L2, P)). Per esempio, consideriamo
l’istruzione if x ≥ y { max = x; } else { max = y; } e la postcondizione P : = (max = x and x ≥ y) or
(max = y and y > x); è facile vedere come la precondizione più debole sia true, ovvero l’istruzione
soddisfa la postcondizione senza alcun vincolo sulle variabili. Infatti,
•
•
•
La precondizione più debole per P e l’istruzione max = x è x ≥ y.
La precondizione più debole per P e l’istruzione max = y è y ≥ x.
La precondizione più debole per l’istruzione if relativa è (x ≥ y ⇒ x ≥ y) and (y > x ⇒ y ≥
x) il cui valore è true.
La specifica della semantica dei cicli diventa più complessa. Per semplicità assumiamo che ogni
ciclo termini sempre. Sia P la postcondizione che deve essere soddisfatta dall’istruzione while B {
L; } dove B è un’espressione booleana e L è una lista di istruzioni. Il problema sta nel fatto che non
sappiamo a priori quante volte il corpo del ciclo sarà ripetuto. Infatti, se sapessimo, ad esempio, che
il numero di iterazioni fosse n, l’istruzione while (e di conseguenza, la sua semantica) sarebbe
equivalente a una sequenza di n istruzioni di tipo L. Poiché il numero di iterazioni è sconosciuto, ci
accontenteremo di determinare una precondizione (non necessariamente la più debole), la quale è in
grado di fornirci una definizione approssimata di ciò che fa il ciclo. Una tale precondizione Q per
un ciclo while e una postcondizione P deve essere tale che
•
•
il ciclo termina
all’uscita dal ciclo, P è vera.
Il predicato Q può essere scritto quindi, come Q = T and R, dove T è un predicato che implica la
terminazione del ciclo e R implica P all’uscita dal ciclo. Ignoriamo il problema della terminazione
e concentriamoci sulla determinazione di R. Questo non può essere fatto meccanicamente, ma
richiede alcuni accorgimenti. Un metodo sistematico consiste nell’identificare un predicato I, detto
4
invariante, che sia vero sia prima che dopo ogni iterazione del ciclo e tale che, quando il ciclo
termina (ovvero il valore di B diventa uguale a false), I implica P. Formalmente I deve soddisfare
le seguenti condizioni:
1. I and B ⇒ asem(L, I),
2. I and not B ⇒ P.
Se riusciamo a determinare I tale che soddisfi le condizioni 1 e 2, allora possiamo porre R = I,
poiché P è vera all’uscita dal ciclo se I è vera prima dell’esecuzione dello stesso.
ESEMPIO: Proporre un programma per il calcolo della divisione intera tra due numeri interi n ≥ 0 e
m > 0 dati in input e dimostrarne la correttezza.
Definiamo il seguente programma:
q = 0;
r = n;
while r ≥ m
{
r = r – m;
q = q + 1;
}
La postcondizione P che vogliamo soddisfare alla fine del programma è la seguente:
n ≥ 0 and m > 0 and r < m and q ≥ 0 and n = m*q + r.
Per prima cosa, dobbiamo calcolare la precondizione più debole relativa al ciclo while. Il predicato
invariante I durante l’esecuzione del ciclo è il seguente:
n ≥ 0 and m > 0 and n = m*q + r and q ≥ 0.
Calcoliamo asem(r = r – m; q = q + 1;, I) = (n ≥ 0 and m > 0 and n = m*q + r and q ≥ –1).
Dobbiamo provare ora che I and B ⇒ n ≥ 0 and m > 0 and n = m*q + r and q ≥ –1, il che è
banalmente vero. Resta da verificare che I and not B ⇒ n ≥ 0 and m > 0 and r < m and q ≥ 0 and n
= m*q + r, il che è di nuovo banalmente vero. Abbiamo quindi che I è una precondizione per il ciclo
while. E’ facile vedere ora che la precondizione più debole per la sequenza di istruzioni q = 0; r = n;
e la postcondizione I è n ≥ 0 and m > 0 che specifica proprio il predicato che risulta vero all’inizio
del programma sulla base dei valori richiesti in input. Questo dimostra che il programma proposto è
effettivamente corretto.
2.2.2 Semantica denotazionale
La semantica denotazionale associa ad ogni istruzione del linguaggio una funzione dsem che va
dallo stato precedente l’esecuzione a quello successivo. Lo stato (ovvero l’insieme dei valori salvati
in memoria) è rappresentato da una funzione mem che va dall’insieme ID di tutti i possibili nomi di
variabili all’insieme dei possibili valori. Il valore di una generica variabile x sarà quindi denotato
come mem(x). Per semplicità assumiamo che i possibili valori possano essere soltanto di tipo intero.
La semantica denotazionale si differenzia quindi da quella assiomatica per il modo in cui sono
descritti gli stati (funzioni vs predicati).
5
Iniziamo la nostra analisi con le espressioni aritmetiche e gli assegnamenti. Per un’espressione expr,
mem(expr) è definita come un errore se mem(v) non è definito per qualche variabile v che occorre in
expr. Altrimenti mem(expr) è il risultato della valutazione di expr dopo aver sostituito ogni
occorrenza di variabile v in expr con mem(v). Se x = expr è un’istruzione di assegnamento e mem è
la funzione che descrive lo stato precedente l’esecuzione dell’assegnamento, abbiamo che dsem(x =
expr;, mem) = error se mem(v) non è definito per qualche variabile v che occorre in expr. Altrimenti
dsem(x = expr;, mem) = mem’ dove mem’(y) = mem(y) per ogni y ≠ x e mem’(x) = mem(expr).
Così come per quella assiomatica, la semantica denotazionale è definita sfruttando il meccanismo di
composizionalità. La trasformazione di uno stato determinata da istruzioni composte ed,
eventualmente, da un intero programma si basa sulla trasformazione determinata da ogni singola
istruzione. Consideriamo dunque una lista di istruzioni come S1;S2. Se dsem(S1, mem) = mem1 e
dsem(S2, mem1) = mem2, allora dsem(S1;S2, mem) = mem2. Lo stato error si propaga
implicitamente, ovvero dsem(S, error) = error per qualsiasi istruzione S.
La semantica dell’istruzione condizionale if B { L1 } else { L2 } è definita come segue: dsem(if B {
L1 } else { L2 }, mem) = U, dove U = dsem(L1, mem) se mem(B) = true; altrimenti U = dsem(L2,
mem).
Infine, consideriamo l’istruzione di ciclo while B { L }. La sua semantica è dsem(while B { L },
mem) = mem se mem(B) = false; altrimenti dsem(while B { L }, mem) = dsem(while B { L },
dsem(L, mem)) se mem(B) = true.2
2
Se da una parte tale scelta garantisce una definizione rigorosa della semantica dei cicli, dall’altra essa si rivela inadatta
a fornire una caratterizzazione precisa di tale semantica. Questa proprietà viene raggiunta definendo la semantica dei
cicli come il punto fisso di un relativo funzionale la cui formalizzazione, però, esula dai nostri scopi.
6