Introduzione al corso di Programmazione

Transcript

Introduzione al corso di Programmazione
Introduzione al corso di Programmazione
Sabrina Mantaci
A.A.2007-2008
1
Il Computer
Supponiamo di volere risolvere un’equazione di secondo grado:
ax2 + bx + c = 0
Per trovare la soluzione dobbamo svolgere diversi passi:
1. Determinare il ∆ = b2 − 4ac;
2. Studiare il segno del ∆:
(a) Se
√ ∆ > 0, allora determinare le soluzioni x1 = (−b +
∆)/2a;
√
∆)/2a e x2 = (−b −
(b) Se ∆ < 0 non ci sono soluzioni reali;
(c) Se ∆ = 0 le due soluzioni coincidono e sono x1 = x2 = −b/2a.
Se è dato questo metodo per la risoluzione delle equazioni, sono dati i parametri del
problema (nel nostro caso i coefficienti a, b, c), se abbiamo la possibilità di memorizzare
i dati parziali su un supporto fisico (come, per esempio della carta da minuta), e se
abbiamo una calcolatrice per effettuare i calcoli, siamo in grado di determinare facilmente
le soluzioni.
In pratica, una volta che qualcuno fornisce il metodo di risoluzione e i dati del problema, il lavoro dell’esecutore è talmente semplice da poter essere svolto da un dispositivo
automatico in grado di leggere i dati e di eseguire le istruzioni del procedimento descritto.
Indipendentemente dalle modalità con cui realizziamo questi dispositivi nella pratica, dal punto di vista concettuale, un calcolatore deve essere costituito dai seguenti
componenti di base:
1. Memoria: permette di memorizzare la procedura, i dati iniziali, i risultati intermedi, i risultati finali;
1
2. Funzione aritmetica: permette di effettuare operazioni aritmetiche sui dati;
3. Ingresso/uscita: dispositivi atti a ricevere dati dall’esterno e a comunicare i
risultati all’esterno;
4. Controllo: serve ad eseguire i passi della procedura, coordinando il flusso dei dati
tra i vari componenti.
Più in dettaglio, i calcolatori elettronici sono formati dalle seguenti componenti:
• La memoria del calcolatore è costituita da due parti principali. Una parte è detta unità RAM (Random Access Memory - Memoria ad accesso casuale) ed
è costituita da una successione ordinata di celle o registri, ciascuno dei quali è capace di memorizzare una stringa di bit (binary digit - cifra binaria, ossia 0 o 1) di
lunghezza fissata dipendente dal tipo di calcolatore (in genere 8 o multipli di 8)
detta parola-macchina. Le celle sono numerate da 0 a k − 1: si dirà allora che la
memoria ha dimensione k. Ciascuno dei numeri da 0 a k − 1 denota una locazione
o indirizzo di memoria. In particolare la locazione di memoria 0 prende il nome di
accumulatore ed è la cella dove viene conservato il risultato dell’ultima operazione
effettuata dal calcolatore. Un’altra parte della memoria, detta unità ROM (Read
Only Memory - Memoria a sola lettura), contiene dati e programmi inalterabili,
come, ad esempio, i codici di caratteri e il sistema operativo.
• Il processore o CPU (Central Processing Unit) è il vero e proprio “cervello”
del computer. L’attività principale della CPU consiste nel recuperare le istruzioni
da eseguire, decodificarle ed eseguirle. La CPU è costituita da due parti principali,
l’ALU e la CU. L’ ALU (Arithmetic Logic Unit) è la parte attiva del sistema dove vengono eseguite operazioni aritmetiche (+,-,*,/) e le elaborazioni logiche
(confronti e operazioni logiche). I dati vengono letti dalla memoria e scritti nella
memoria facendo in modo che il processore contenga solo i dati di volta in volta
necessari. La CU (Control Unit) si occupa di dirigere il flusso dei dati all’interno del processore, andando a cercare nella memoria i dati che gli sono necessari e
depositando nella memoria i dati elaborati.
• Unità di INPUT è un qualunque dispositivo che permette di immettere i dati
dall’esterno (ad esempio la tastiera o il mouse).
• Unità di OUTPUT è invece un qualunque dispositivo che permette di visualizzare
all’esterno i risultati del calcolo (ad esempio la stampante o lo schermo).
2
2
La risoluzione dei problemi
In questo corso siamo interessati ad imparare delle tecniche che ci permettano di scrivere dei programmi informatici per la soluzione di determinati problemi formalizzabili
in termini matematici. Per questo ci poniamo il problema di stabilire quali sono i passi
fondamentali che ci permettono di arrivare ad una soluzione di un dato problema. La
soluzione di un problema nel nostro ambito consiste infatti di diversi passaggi, ognuno dei
quali deve essere preso in considerazione nella progettazione di un programma:
• definizione del problema: si deve definire e formalizzare il problema che ci poniamo;
• caratterizzazione della soluzione: bisogna specificare qual’è l’output che vogliamo
che il nostro programma ci fornisca;
• sviluppo di un algoritmo: questa è la fase più importante del progetto, ossia quella di
stabilire quale metodo intendiamo utilizzare per fornire una soluzione al problema;
• codifica dell’algoritmo. Questa è la fase di programmazione propriamente detta,
ossia la traduzione dell’algoritmo in un particolare linguaggio di programmazione;
• verifica del programma: una volta che il programma è stato scritto, si deve verificare
che da una risposta corretta al problema;
• documentazione della soluzione: consiste nell’integrare il programma con dei commenti che assolvono al compito di rendere il programma leggibile, e, di conseguenza, più facile da correggere, da modificare, adattare e da condividere con altri
programmatori;
• manutenzione: consiste nel tenere il programma aggiornato con modifiche che rispondono alle nuove esigenze delle specifiche del problema.
3
Gli Algoritmi
Ci concentriamo qui sulla fase più importante della progettazione di un programma. Supporremo che sia già stato formalizzato il problema e stabilito quali sono le risposte che
vogliamo dal programma.
La definizione di Informatica data dall’ACM (Association for Computing Machinery) è la seguente: l’informatica è lo studio sistematico degli algoritmi che descrivono e
trasformano l’informazione: la loro teoria, analisi, progetto, efficienza, realizzazione ed
applicazione.
In questa definizione viene utilizzata la nozione di “algoritmo” che può informalmente
essere definito come un “metodo” per la risoluzione di un problema specifico.
3
Per avere un’intuizione su cosa sia un algoritmo, possiamo pensare per esempio a
una ricetta di cucina: vengono definiti tutti gli ingredienti e le dosi, e infine vengono
date le istruzioni per l’esecuzione, in cui si descrivono ordinatamente tutti i passi che si
devono svolgere per ottenere il risultato finale. Oppure pensiamo alle istruzioni per l’uso
del videoregistratore, che descrivono passo dopo passo tutto quello che si deve fare per
farlo funzionare (collegare il videoregistratore al televisore; inserire la spina; accendere il
videoregistratore; inserire la cassetta; premere il tasto “play”).
Diamo qui una definizione più formale:
Definizione 3.1 Un algoritmo è un insieme finito di regole che dà la sequenza delle
operazioni da svolgere al fine di risolvere un problema specifico.
Un algoritmo deve soddisfare le seguenti proprietà fondamentali:
1. Finitezza: un algoritmo deve sempre terminare dopo un numero finito di passi.
Ogni volta che si scrive un algoritmo bisogna sempre verificare che a un certo punto
termini.
2. Non ambiguità: in un algoritmo ogni passo deve essere ben specificato, in modo
che un esecutore (umano o automatico) sappia esattamente e in maniera univoca (o
deterministica) cosa deve fare ad ogni passo. L’uso dei linguaggi di programmazione
permette di eliminare certe ambiguità che sono presenti nei linguaggi naturali.
3. Input: un algoritmo ha zero o più input, che sono i dati che vengono forniti prima
che il calcolo abbia inizio, e sui quali vengono svolte le computazioni;
4. Output: un algoritmo ha uno o più output, che rappresentano i risultati del
problema, quantità che hanno una specifica relazione con gli input;
5. Realizzabile praticamente: tutte le operazioni che sono descritte nell’algoritmo
devono essere sufficientemente semplici da poter essere, in linea di principio, eseguite
in tempo finito da un uomo con carta e penna.
Inoltre ad un algoritmo si richiede che sia corretto ed efficiente. Un algoritmo è
corretto se perviene alla soluzione del compito a cui è preposto. Un algoritmo è efficiente
se perviene alla soluzione del problema nel modo più veloce possibile e usando la più
piccola quantità possibile di risorse fisiche (memoria).
Diamo ora alcuni esempi di algoritmi, partendo da alcuni problemi più semplici, fino
ad arrivare alla formalizzazione algoritmica un problema matematico classico.
Esempio: [Somma col pallottoliere] Supponiamo di avere un pallottoliere con tre
file di palline. Vogliamo effettuare la somma di due numeri utilizzando il pallottoliere.
Supponiamo che la prima riga rappresenti il primo addendo e la seconda riga il secondo
addendo, mentre la terza riga conterrà il risultato della somma. Nella prima e nella
4
seconda riga disponiamo sul lato sinistro tante palline quanto è il valore del primo e del
secondo addendo, rispettivamente. Nella terza riga tutte le palline si trovano sul lato
destro. Ci muoviamo come segue:
1. nella prima riga, si sposta una pallina da sinistra a destra e, contestualmente, nella
terza riga se ne sposta una da destra a sinistra.
2. si ripete l’operazione precedente finché non si è svuotata la prima riga.
3. non appena la prima riga è vuota, nella seconda riga si sposta una pallina dalla
sinistra alla destra e, contestualmente, nella terza riga se ne sposta una da destra a
sinistra.
4. si ripete l’operazione precedente finché non si è svuotata la seconda riga.
5. il numero di palline che si trova nel lato sinistro della terza riga al termine delle
operazioni è il risultato cercato.
Esempio: [Ricerca in uno schedario] Supponiamo di volere cercare il titolo di un
libro in uno schedario di una biblioteca. Se non abbiamo alcuna informazione su come è
organizzato lo schedario, l’algoritmo che possiamo utilizzare è il seguente:
1. si esamina la prima scheda dello schedario;
2. se il titolo coincide con quello cercato allora terminiamo la ricerca. Altrimenti si
passa alla scheda successiva.
3. si continua cosı̀ fino a quando si trova la scheda cercata oppure si arriva all’ultima scheda. Nel primo caso estraiamo la scheda cercata, mentre nel secondo caso
possiamo affermare che la ricerca è fallita, cioè il libro non è presente nella biblioteca.
È facile notare che questo algoritmo può essere molto “costoso” in termini di tempo.
Infatti nel caso peggiore occorrerà consultare tutte le schede, che sono tante quante i libri
presenti nella biblioteca. È per questo che molte volte è utile organizzare i titoli in ordine
lessicografico. In tal caso la ricerca può essere resa molto più rapida, seguendo l’algoritmo
descritto qui di seguito.
1. si prende la scheda centrale dello schedario;
2. se la scheda è quella cercata, la ricerca si interrompe;
3. in caso contrario, se il titolo del libro precede in ordine lessicografico quello della
scheda appena estratta, si ripete il procedimento nella metà dello schedario che
precede la scheda estratta. In caso contrario si procederà alla ricerca nel gruppo di
schede che seguono la scheda estratta.
5
Si intuisce che il secondo algoritmo proposto giunge alla soluzione più velocemente del
precedente, in quanto in nessun caso esamina tutte le schede (ad ogni passo ne esclude la
metà). In particolare, come vedremo da un’analisi più precisa, esaminerà un numero di
schede proporzionale al logaritmo in base 2 del numero di libri nella biblioteca, riducendo
drasticamente il tempo della ricerca. Questa osservazione mette in evidenza come oltre al
problema di determinare un algoritmo per la soluzione di un problema, ci interesseremo a
trovare l’algoritmo più efficiente possibile. Oltre a questo, è importante osservare che un
altra fase fondamentale nella progettazione di un algoritmi è quella di strutturare i dati in
maniera tale da rendere più efficiente l’algoritmo stesso. Per esempio, ci siamo resi conto
che nella gestione di un archivio, è conveniente mantenere le schede ordinate, in maniera
tale da facilitare la fase di ricerca. Questo viene realizzato mediante strutture dati per la
gestione di operazioni di dizionario (inserimento, cancellazione e ricerca).
Esempio: [Massimo Comun Divisore - Algoritmo di Euclide] Dati due interi a e
b, vogliamo calcolare il massimo comun divisore (M CD) di a e b. Ricordiamo che:
Definizione 3.2 Il massimo comun divisore (MCD) tra due interi a e b è un intero q
tale che q divide a e b, e se p è un altro divisore di a e b, allora p divide q.
Esistono diversi algoritmi per determinare il M CD fra due numeri, ma il più efficiente
è il cosiddetto Algoritmo di Euclide. Tale algoritmo è basato sul seguente teorema:
Teorema 3.1 (Algoritmo della Divisione) Sia a un intero e b un intero positivo.
Allora esiste un’unica coppia di interi q ed r (quoziente e resto della divisione di a per b)
con 0 ≤ r ≤ b tale che a = bq + r.
Lemma 3.1 Sia a = bq + r dove a, b, q, r sono interi. Allora M CD(a, b) = M CD(b, r).
Dimostrazione: Se d divide a e d divide b allora d divide r = a − bq. Viceversa, se d
divide b e d divide r, allora d divide a = bq + r. 2
Il lemma appena enunciato ci dà la possibilità di trovare un “metodo generale” determinare il M CD tra due numeri. Siano a e b due interi positivi con a ≥ b. È chiaro che
se b = 0, allora M CD(a, b) = M CD(a, 0) = a. Altrimenti sia r0 = a, r1 = b. Possiamo
applicare ripetutamente l’algoritmo della divisione e ottenere:
r0 = r1 q1 + r2
r1 = r2 q2 + r3
···
rn−2 = rn−1 qn−1 + rn
rn−1 = rn qn
0 ≤ r2 ≤ r1
0 ≤ r3 ≤ r2
0 ≤ rn ≤ rn−1
Per il Lemma 3.1 si ha:
M CD(a, b) = M CD(r0 , r1 ) = M CD(r1 , r2 ) = · · · = M CD(rn−1 , rn ) = M CD(rn , 0) = rn
6
Dunque il massimo comun divisore è l’ultimo resto non nullo nella sequenza delle
divisioni. L’algoritmo di Euclide si può quindi esprimere nella seguente forma:
Algoritmo di Euclide
1. x ← a, y ← b;
2. dividi x per y e sia r il resto della divisione;
3. se r = 0 l’algoritmo termina, e y sarà il MCD;
4. altrimenti poni x ← y, y ← r e torna al passo 2.
Questo algoritmo termina perchè i valori di x e y sono non negativi decrescenti. Inoltre
questo algoritmo calcola correttamente il M CD per quanto dimostrato dal Lemma 3.1.
4
Complessità degli Algoritmi
Lo studio della complessità di un algoritmo consiste nel determinare la quantità di risorse
impiegate durante esecuzione dell’algoritmo su un input di taglia generica n. Fra queste
terremo in particolare considerazione due grandezze fondamentali: il tempo di calcolo e lo
spazio di memoria utilizzata. Nel primo caso conteremo il numero di operazioni elementari svolte dall’algoritmo su un input di taglia n. Si parla in questo caso di complessità
di tempo. Nel secondo caso calcoleremo la quantità di locazioni di memoria che devono
essere utilizzate nello svolgimento dell’algoritmo. In questo caso parleremo di complessità di spazio. In questo corso prenderemo principalmente il tempo come parametro
per calcolare l’efficienza di un algoritmo. Quindi, dati due programmi che risolvono lo
stesso problema, diremo che il più efficiente è quello che impiega il minor tempo nella sua
esecuzione.
Ma cosa intendiamo per tempo? Come lo misuriamo e in base a quali parametri? Se
considerassimo un’unità di misura standard per il tempo, come i secondi, un possibile
metodo di confronto potrebbe consistere nel mandare in esecuzione i due programmi e
vedere quale dei due finisce per prima i suoi calcoli. Ma questo metodo di valutazione
non è attendibile se non consideriamo le condizioni in cui si effettuano le prove. Bisogna
infatti tener conto:
• dell’elaboratore su cui il programma viene eseguito. Il confronto fra due programmi
sarà valido solo se li mandiamo in esecuzione sullo stesso elaboratore;
• del particolare compilatore. Infatti compilatori diversi possono generare programmi
in linguaggio macchina con caratteristiche diverse;
• dei dati di ingresso. Per confrontare l’efficienza di due programmi essi devono essere
eseguiti sullo stesso insieme di dati di ingresso.
7
• della significatività dei dati di ingresso, perché un certo programma può essere molto
efficiente rispetto a specifici dati di ingresso, mentre può non esserlo rispetto ad
altri dati. Dunque per confrontare l’efficienza di due programmi, essi devono essere
eseguiti più volte su dati differenti.
Anche ammesso che il test venga fatto sugli stessi dati, può succedere che uno dei due
programmi si comporti meglio su un certo input e peggio su un altro. Qual’è quindi un
criterio di valutazione oggettiva dell’efficienza di un programma?
Un’analisi oggettiva del tempo di computazione deve ignorare tutti i fattori che dipendono dalla macchina, dal compilatore, dai dati di input utilizzati e i dettagli implementativi legati al linguaggio di programmazione utilizzato.
Per determinare la funzione che ci definisce il tempo di calcolo in funzione della
dimensione dell’input, dobbiamo fare delle semplificazioni. Considereremo che:
• il costo di esecuzione di ogni istruzione semplice (assegnazione, lettura, scrittura,
operazioni aritmetiche e logiche) è 1;
• il costo di esecuzione di un’istruzione composta è pari alla somma delle istruzioni
semplici che la compongono;
• il costo di un ciclo è dato dal costo delle istruzioni del ciclo più il costo del test di
fine ciclo, moltiplicato per il numero di volte che il ciclo viene eseguito;
• il costo di un’istruzione condizionale è dato dal costo del test più il costo delle
istruzioni che vengono eseguite se la condizione è vera;
• il costo di attivazione di un sottoprogramma è pari al costo di esecuzione delle
istruzioni che compongono il sottoprogramma.
Si può pensare che assegnare a tutte le istruzioni semplici valore 1 sia una semplificazione troppo forte. Infatti questo equivarrebbe a dire che, per esempio, le istruzioni a:=a+1
e a:=b+(c*b)/(a+c)+2 valgono entrambe 1, malgrado la seconda appaia più complessa
della prima. Ma si può osservare che se t1 è il tempo che occorre a calcolare la prima
istruzione e t2 il tempo necessario a calcolare la seconda, allora possiamo sempre trovare
una costante c tale che t2 < c · t1 . Quindi possiamo dire che le due istruzioni hanno lo
stesso costo, a meno di un fattore costante. Le stesse considerazioni si possono fare per la
valutazione delle altre istruzioni. Possiamo concludere che la nostra valutazione del costo
di un programma è approssimata per un fattore moltiplicativo c e che è indipendente dal
tipo di calcolatore, programma o compilatore usato.
Fatte queste considerazioni, come determiniamo qual’è la funzione che ci dà una valutazione della complessità di un algoritmo? Osserviamo che il tempo di esecuzione di un
programma dipende dai suoi dati di ingresso. Infatti prendiamo ad esempio il problema
8
della ricerca di una scheda in un archivio. In questo caso, indipendentemente dal programma utilizzato, tanto più grande è l’archivio, tanto più tempo impiegherà il programma a
trovare la scheda cercata. Una valutazione dell’efficienza di un algoritmo sarà quindi una
funzione della dimensione dell’input. Per dimensione dell’input si intende la quantità di
memoria necessaria per memorizzare i dati di input del problema.
Tuttavia la dimensione dell’input non è l’unico parametro da prendere in considerazione nella valutazione dell’efficienza di un algoritmo. Infatti, a parità di dimensioni
dell’input, il numero di istruzioni semplici eseguite da un programma dipende molto da
“come è fatto” l’input. Per una fissata dimensione n, quali input dobbiamo considerare
di volta in volta?
Consideriamo ancora una volta il problema della ricerca in un archivio. Supponiamo
che l’archivio non sia ordinato e dobbiamo quindi fare una ricerca esaustiva. Se l’elemento
da cercare è il primo dello schedario, siamo fortunati e dobbiamo fare una sola operazione
di confronto. Ma se l’elemento cercato è l’ultimo dello schedario, o addirittura non è
contenuto nell’archivio, faremo un numero di confronti uguale al numero delle schede
presenti. È chiaro che, per avere una valutazione oggettiva sul costo di esecuzione di un
algoritmo, la valutazione non deve dipendere dal particolare dato di ingresso. Proprio per
questo il costo del programma verrà valutato in funzione delle dimensioni dell’input con
riferimento al caso peggiore, cioè quello in cui l’esecuzione impiega più tempo. Dovremo
quindi di volta in volta individuare qual’è il caso peggiore per quell’algoritmo, e valutare
come si comporta l’algoritmo su questo input.
Nel caso dell’esempio considerato, il caso peggiore è quello in cui l’elemento cercato
non è contenuto nell’archivio, per cui l’algoritmo di ricerca esaustiva effettua esattamente
n confronti, e diremo quindi che l’algoritmo ha un costo proporzionale ad n.
Vogliamo formalizzare quanto detto sopra. Diamo le seguenti definizioni.
Definizione 4.1 Siano date due funzioni f (n) e g(n). Diciamo che:
• g(n) è O grande di f (n), e scriviamo g(n) ∈ O(f (n)) se esistono due costanti c ed
n0 tali che
g(n) ≤ c · f (n)
per ogni n ≥ n0 .
• g(n) è Omega di f (n), e scriviamo g(n) ∈ Ω(f (n)) se esistono due costanti c ed n0
tali che
g(n) ≥ c · f (n)
per ogni n ≥ n0 .
• g(n) è Theta di f (n), e scriviamo g(n) ∈ Θ(f (n)) se esistono tre costanti c1 , c2 ed
n0 tali che
c1 · f (n) ≤ g(n) ≤ c2 · f (n)
per ogni n ≥ n0 .
9
Definizione 4.2 Un algoritmo ha complessità worst case (o, nel caso peggiore) O(f (n))
se che il numero di istruzioni t(n) che devono essere eseguite nel caso peggiore con un
input di dimensione n è O(f (n)).
Esempio: Se un algoritmo svolge un numero di istruzioni t(n) = 4n + 3, l’algoritmo
è O(f (n)) dove f (n) = n2 . Infatti basta scegliere c = 4 e n0 = 2 (sostituire i valori e
verificare!). Si può anche osservare che t(n) = O(n). Infatti basta prendere in questo caso
c = 5 e n0 = 3.
Definizione 4.3 Diremo che un problema P ha una delimitazione superiore (o upper
bound) O(f (n)) alla sua complessità di tempo se esiste un algoritmo che risolve P la cui
complessità di tempo è O(f (n)).
Definizione 4.4 Diremo che un problema P ha una delimitazione inferiore (o lower
bound) Ω(f (n)) alla sua complessità se ogni algoritmo che risolve P ha complessità di
tempo Ω(f (n)).
Un lower bound ci indica qual’è la minima complessità teoricamente possibile per un
algoritmo che risolve quel problema sotto certe condizioni fissate. La dimostrazione di un
lower bound garantisce che non si potrà mai trovare un algoritmo che sia più efficiente
della sua delimitazione inferiore. Dimostrare che una data funzione è un lower bound è
generalmente molto difficile, soprattutto se il lower bound è stretto, ossia il più grande
possibile.
In generale quando troviamo la funzione di complessità worst case otteniamo quella
che viene detta complessità asintotica di un algoritmo, che ci indica quanto cresce il
tempo di calcolo dell’algoritmo quando l’input cresce di dimensione. Visto che si tratta di
un’analisi più qualitativa che quantitativa, qualora la funzione fosse una somma di diversi
termini, considereremo solo quello “dominante”, ossia quello che al crescere di n cresce
più velocemente. Questo perchè è sempre possibile, con opportuni calcoli, fare assimilare
i termini non dominanti dalla costante c. Il seguente teorema in particolare afferma che se
una funzione è un polinomio, allora la funzione è O grande del termine di grado massimo,
che è, infatti, il termine dominante.
Teorema 4.1 Sia t(n) = am nm + am−1 nm−1 + · · · + a1 n + a0 il numero di operazioni
elementari svolte da un certo algoritmo. Allora t(n) = O(nm ).
Dimostrazione:
Poiché il tempo di calcolo di un algoritmo è ovviamente positivo, esso è uguale al suo
valore assoluto:
t(n) = |t(n)| ≤ |am |nm + |am−1 |nm−1 + · · · + |a1 |n + |a0 | ≤
≤ (|am | + |am−1 |/n + · · · + |a0 |/nm ) · nm ≤ (|am | + |am−1 | + · · · + |a0 |) · nm = cnm .
10
log n
0
1
2
3
4
5
n n logn
1
0
2
2
4
8
8
24
16
64
32
160
n2
1
4
16
64
256
1.024
n3
1
8
64
512
4.096
32.768
2n
2
4
16
256
65.536
4.294.967.296
Figura 1: Come variano le funzioni di complessità al variare di n
dove c = |am | + |am−1 | + · · · + |a0 |. Ossia t(n) ≤ c · nm per ogni n > 0. 2
Questo teorema permette di dire che se un’algoritmo ha una complessità che è un polinomio di grado m, allora ha complessità O(nm ). Cioè si può non tenere conto dei termini
di grado inferiore, poiché sono termini che crescono con n meno velocemente di quello
“dominante”. Per lo stesso motivo si può non tenere conto delle costanti moltiplicative,
che vengono “assorbite” dalla costante c.
Abbiamo detto che questo criterio per la valutazione degli algoritmi non tiene conto né
delle costanti moltiplicative né dei termini di ordine inferiore. Si potrebbe però obiettare
che se un programma fa 2n operazioni elementari è sicuramente diverso da uno che ne
fa 1000n. D’altra parte si può osservare che per quanto le costanti moltiplicative siano
grandi, alla fine l’andamento qualitativo è determinato dalla funzione di n.
Se la funzione di complessità di un algoritmo è O(nm ), per qualche intero m, si dice
che ha complessità polinomiale. In particolare se m = 1, cioè l’algoritmo ha complessità
O(n) si dice che l’algoritmo ha complessità lineare. Se m = 2 si dice che l’algoritmo ha
complessità quadratica. Se la funzione di complessità di un algoritmo è O(log n), diremo
che l’algoritmo ha complessità logaritmica. Se la funzione di complessità di un algoritmo
è O(k n ), per qualche costante k > 1, diremo che ha complessità esponenziale.
Un problema per cui il migliore algoritmo possibile ha complessità esponenziale (ossia
ha un lower bound esponenziale) si dice intrattabile. Gli algoritmi esponenziali richiedono
un tale tempo di calcolo che nessun miglioramento futuro della velocità dei calcolatori
sequenziali produrrà mai un range più grande dell’insieme dei problemi risolubili nella
pratica.
La tabella in Figura 4 indica di quanto crescono le diverse funzioni di complessità al
variare di n.
11
5
Il Sistema Binario
Tutti i dati elaborati da un calcolatore vengono immagazzinati in memoria sotto forma di
parole binarie. La rappresentazione dell’informazione (o codifica) è l’assegnazione di una
stringa di simboli a ciascuno degli oggetti che vogliamo rappresentare. Abbiamo quindi
bisogno di un insieme finito di simboli, detto alfabeto. Generalmente considereremo un
alfabeto di due simboli (0 e 1) detto alfabeto binario. Se fissiamo un intero k, le possibili
sequenze distinte di lunghezza k costituite da due simboli sono in numero 2k . Infatti per
ciascuna posizione, il simbolo può essere scelto in due modi distinti, e otteniamo quindi
2k possibili combinazioni. Normalmente le parole binarie contenute nelle celle di memoria
sono di lunghezza 16 o 32 bits, quindi possiamo rappresentare fino a 216 o 232 diversi
elementi. Osserviamo che
28 = 216
216 = 65.536
232 = 4.294.967.296
Teorema 5.1 Sia b ∈ Z+ un intero non negativo. Ogni intero n > 1 può essere espresso
in maniera unica nella forma
n = am bm + am−1 bm−1 + · · · + a1 b + a0
dove m è un intero non negativo, 0 ≤ ai < b per i 6= m e 0 < am < b.
Questo teorema ci permette di rappresentare gli interi non negativi in qualunque base
b. Se infatti b è un intero non negativo, il numero n potrà essere rappresentato dalla
sequenza am , am−1 , ..., a1 , a0 dove tutti gli ai sono minori di b. La sequenza am am−1 ...a1 , a0
sarà la rappresentazione numero n in base b. In particolare se b = 2, allora otteniamo
la rappresentazione binaria di n. Nel sistema binario quindi, ogni numero sarà espresso
con le sole cifre 0 e 1 (< b = 2). Il motivo per cui il sistema binario viene utilizzato
nei calcolatori è che un segnale binario si può facilmente ottenere mediante apertura e
chiusura dei circuiti. Se quindi ci viene dato un numero in forma binaria, per determinare
qual’è il suo valore in forma decimale, basta applicare la formula del teorema.
Ci interessa descrivere un algoritmo che ci permette di effettuare l’operazione contraria,
ossia dato un numero espresso in forma decimale, rappresentarlo in forma binaria.
5.1
Metodo delle Divisioni Successive
Tale metodo permette di esprimere un qualsiasi numero intero decimale n in forma binaria.
Il nostro obiettivo è quello di scrivere n come somma di potenze di 2. Il metodo generale
è quello di trovare la più grande potenza di 2 che si approssima per difetto ad n, sottrarla
al numero e iterare il procedimento sulla differenza fino ad arrivare al valore 1. Si scrive
quindi 1 in corrispondenza di ogni potenza di 2 presente e 0 per quelle che non figurano.
12
Esempio:
N = 149
149 − 128 = 21
21 − 16 = 5
5−4=1
27 = 128
24 = 16
22 = 4
20 = 1
quindi possiamo scrivere
n = 1 ∗ 27 + 0 ∗ 26 + 0 ∗ 25 + 1 ∗ 24 + 0 ∗ 23 + 1 ∗ 22 + 1 ∗ 20 .
Questo può essere espresso dicendo che (n)2 = 10010101.
Il metodo descritto è tuttavia algoritmicamente poco efficiente, poiché dobbiamo trovare,
provandole una per una, qual’è la potenza di 2 che più si approssima per difetto al numero
dato. Il metodo più frequentemente usato per determinare la rappresentazone binaria di
un numero è quello delle divisioni successive.
Consideriamo l’espressione am bm + am−1 bm−1 + · · · + a1 b + a0 che è un numero generico
scritto in base b. Se dividiamo il numero per b otteniamo:
am bm−1 + am−1 bm−2 + · · · + a1 + a0 /b
Questo equivale a dire che am bm−1 + am−1 bm−2 + · · · + a1 è il quoziente e a0 è il resto della
divisione. Questo significa che per ottenere il termine meno significativo della rappresentazione in base b basta dividere il nostro numero (in base decimale) per b e prendere il
resto. Per ottenere il secondo termine meno significativo basta iterare il procedimento sul
quoziente. Il resto della divisione per b darà tale termine. Tale procedura viene iterata
finché non si arriva ad avere quoziente uguale a 1. Il numero in forma binaria si ottiene
leggendo l’ultimo quoziente (Sicuramente un 1) e tutti i resti letti dall’ultimo al primo.
Esempio: Sia n = 155
155 : 2 = 77 + 1
77 : 2 = 38 + 1
38 : 2 = 19 + 0
19 : 2 = 9 + 1
9:2=4+1
4:2=2+0
2:2=1+0
1
(155)2 = 10011011
5.2
Addizione fra due numeri binari
Per sommare due numeri espressi in forma binaria, si ricorre alla seguente tavola di
addizione per le cifre binarie
13
+ 0 1
0 0 1
1 1 10
Per effettuare la somma di due numeri si agisce mediante il metodo di addizione noto
per i numeri decimali.
Esempio:
11001101 + 10101110 = 101111011
Si osservi che per sommare due numeri di taglia n si compiono circa 2n operazioni di accesso alla tavola di addizione (n per le somme delle cifre più eventualmente altre n somme
per i riporti). Diremo quindi che quest’algoritmo svolge O(n) operazioni elementari.
5.3
Moltiplicazione fra due numeri binari
Per moltiplicare 2 numeri di taglia n abbiamo bisogno di definire una tavola di moltiplicazione. Abbiamo che:
×
0
1
0 1
0 0
0 1
Per quanto riguarda la moltiplicazione a più cifre ricorriamo al classico algoritmo della
moltiplicazione, per cui ogni cifra del secondo numero viene moltiplicata per il primo, e
infine i risultati, shiftati ciascuno di una posizione verso sinistra rispetto al precedente,
vengono sommati fra loro. Quindi per moltiplicare due numeri di taglia n, si ricorre n2
volte alla tavola di moltiplicazione, si compiono circa n SHIFT (spostamenti) e infine
si operano O(n2 ) somme. In totale otteniamo quindi che questo algoritmo svolge O(n2 )
operazioni elementari.
Ma questo è l’unico algoritmo per il prodotto che esiste? Ovviamente no. Questo
algoritmo è basato sull’ipotesi che gli interi sono espressi in notazione posizionale, metodo
di rappresentazione degli interi noto da quando sono stati introdotti i numeri arabi.
In realtà esiste un altro algoritmo noto dal 1650 A.C. e che fa uso delle sole operazioni
di moltiplicazione per due, divisione per due e somma di due interi. Queste operazioni
sono molto facili da svolgere col solo uso di un pallottoliere, ciò fa si che il metodo si presti
a effettuare prodotti di numeri espressi in notazione non posizionale (ad esempio, con i
numeri romani). Si noti inoltre che le operazioni di moltiplicazione e divisione per due sono
molto facili (computazionalmente!) se il numero è espresso in binario. La moltiplicazione
per due infatti consiste nell’aggiungere uno zero a destra del numero binario, shiftando
di una posizione a sinistra tutte le cifre. La divisione per due consiste nel trascurare il
termine meno significativo, shiftando l’intero numero a sinistra di una posizione.
14
Supponiamo di voler moltiplicare due numeri n ed m. L’algoritmo (detto del Contadino Russo) è il seguente:
• se m = 1 allora n × m = m;
• altrimenti
– Se m è dispari, allora n × m = n + (n × (m − 1));
– se m è pari, allora n × m = (2 × n) × (m/2).
L’algoritmo termina perchè il valore del secondo fattore diminuisce fino a diventare 1,
che è il caso base della ricorsione.
Esempio: 741 × 11 = 741 + (741 × 10) = 741 + (1482 × 5) = 741 + 1482 + (1482 × 4) =
741 + 1482 + (2964 × 2) = 741 + 1482 + 5928 = 8151.
Osservazione: Esistono altri algoritmi per la moltiplicazione che sfruttano la strategia
del “divide et impera”, che vedremo più avanti durante questo corso.
Esercizi
1. Tradurre i seguenti numeri espressi in binario nei corrispondenti numeri decimali:
1001101, 1100110, 1010110, 11111, 111010, 1000001, 1101111, 1010000.
2. Tradurre i seguenti numeri espressi nel sistema decimale nei corrispondenti numeri
in sistema binario: 342, 118, 201, 74, 174, 131, 200, 100.
3. Effettuare le seguenti somme di numeri binari: 100100101 + 1001010, 11111111 +
1110101, 101001000 + 111100001, 1100110010 + 1010000100.
4. Effettuare i seguenti prodotti fra numeri binari: 10010×101, 10111×111, 1101×110,
11111×100. Provare ad effettuarli anche applicando l’algoritmo del contadino russo.
5.4
Rappresentazione binaria degli interi relativi
Nei calcolatori è necessario dare una rappresentazione, oltre che degli interi positivi, anche
degli interi negativi. Esistono due possibili modi di rappresentare gli interi relativi (cioè
sia positivi che negativi).
Il primo è la rappresentazione con modulo e segno, che utilizza il primo bit disponibile
per la rappresentazione del segno (0 positivo e 1 negativo) e tutti gli altri per la rappresentazione del modulo del numero. In tal modo, se abbiamo a disposizione 4 bit per
la rappresentazione del numero, avremo che, per esempio, 3 sarà rappresentato da 0011,
mentre -3 sarà rappresentato da 1011. Osserviamo che però questa rappresentazione crea
15
ambiguità rispetto allo zero. Infatti 1000 e 0000 rappresentano entrambi lo zero. Questo
può complicare il controllo delle operazioni aritmetiche.
Per questo motivo spesso si adotta una rappresentazione diversa, che è la rappresentazione in complemento a due. In questa rappresentazione, se abbiamo a disposizione k bit
per rappresentare il nostro intero x, consideriamo la rappresentazione binaria di 2k + x,
ed eliminiamo la cifra più significativa (ossia quella più a sinistra).
Per esempio, se abbiamo a disposizione 4 bit e vogliamo rappresentare il numero 3,
consideriamo la rappresentazione binaria di 16(= 24 ) + 3 = 19 che è uguale a 10011, ed
eliminiamo la prima cifra, ottenendo cosı̀ 0011 per la rappresentazione di 3 (che poi è
esattamente corrispondente alla rappresentazione binaria di 3). Stessa cosa se dobbiamo
fare una rappresentazione di −3: calcoliamo 16 − 3 = 13, calcoliamo la sua rappresentazione binaria 01101, ed eliminando la prima cifra, otteniamo 1101. Si noti che tutti
i numeri positivi inizieranno per 0 mentre tutti i numeri negativi inizieranno per 1. In
questo modo lo zero avrà un’unica rappresentazione 0000 però potremo rappresentare i
numeri interi da −2k fino a 2k − 1.
Vedremo in seguito come vengono rappresentati i numeri razionali e i numeri reali
all’interno del calcolatore.
5.5
Rappresentazione di dati non numerici
Oltre che i numeri, il calcolatore deve anche registrare dei dati non numerici, ossia le lettere
dell’alfabeto e tutti gli altri caratteri (punteggiatura, parentesi, simboli speciali, etc.). Per
fare questo si sono stabiliti dei codici, che sono degli standard universali, per cui i caratteri
alfanumerici vengono codificati con particolari sequenze nell’alfabeto {0, 1}. Per quanto
riguarda i linguaggi occidentali, come l’italiano e l’inglese, il codice più frequentemente
usato è il Codice ASCII (American Standard Code for Information Interchange) che
codifica i simboli utilizzando 7 bit (cioè vengono codificati 27 = 128 caratteri). Tuttavia
questo codice non riesce, per esempio a rappresentare le lettere accentate, quindi esistono
delle versioni estese di codice ASCII (oggi più frequentemente usata) che utilizza 8 bit,
con un’estensione a 256 caratteri. Simile a questa versione estesa del codice ASCII è
il codice EBCDIC (Extended Binary-Coded Decimal Interchange Code) sviluppato da
IBM e che fa uso, anch’esso di 8 bit. Tuttavia 8 bit non sono sufficienti a rappresentare i
caratteri di varie lingue del mondo (i caratteri greci, i caratteri cirillici, i caratteri arabi,
etc). Per questo è stato inventato un tipo di codice, chiamato UNICODE, basato su
successioni di 16 bit, in grado quindi di codificare 216 = 65536 diversi caratteri. Per
facilitare il passaggio da ASCII a UNICODE, i primi 128 caratteri del codice UNICODE
sono gli stessi del codice ASCII.
16
6
I linguaggi di programmazione
Un programma è un algoritmo, espresso in un determinato linguaggio, detto linguaggio di
programmazione.
Un’importante attività svolta da alcuni ricercatori in informatica consiste nella definizione di linguaggi di codifica degli algoritmi, cioè i linguaggi che consentono di scrivere
gli algoritmi sotto forma di programmi che possono essere interpretati dal calcolatore, che
ne è l’esecutore.
Agli albori dell’informatica, con l’avvento dei primi calcolatori, la programmazione
consisteva nella traduzione minuziosa in codice binario (linguaggio macchina) delle
istruzioni, e la successione delle cifre binarie che codificava le istruzioni forniva il programma, già pronto per essere eseguito dal calcolatore. Questo lavoro di traduzione era
chiamato codifica. La programmazione in linguaggio macchina portava comunque una
serie di problemi:
• questo metodo comportava un enorme lavoro da parte del programmatore e inoltre
la complessità del linguaggio stesso impediva anche ai programmatori più esperti di
scrivere programmi molto complessi.
• Questo tipo di linguaggio portava i programmatori ad utilizzare certi tipi di “astuzie”
che erano spesso comprensibili solo da colui che aveva realizzato il programma, e
quindi difficilmente comprensibili ad un altro programmatore;
• per le stesse ragioni era difficilissimo in tali programmi, riconoscere e correggere gli
errori;
• questi linguaggi, essendo molto vicini alla logica del calcolatore, erano molto difficilmente trasportabili ad un altro tipo di calcolatore;
• infine il linguaggio macchina era molto lontano dal modo di ragionare dell’uomo,
nonché dai suoi mezzi espressivi (cioè il suo linguaggio naturale).
Tutte queste ragioni portarono gli studiosi del tempo a tentare di semplificare il modo
di programmare, cercando di esprimere i programmi in un linguaggio più facilmente comprensibile all’uomo. Un primo tentativo di semplificare il linguaggio di programmazione
fu la creazione del linguaggio Assembler, in cui alcune istruzioni (binarie in linguaggio
macchina) venivano codificate mediante delle parole chiave. Tuttavia la logica di base
per la scrittura dei programmi restava legata alla logica del calcolatore. Questo tipo di
linguaggi, detti a basso livello, proprio perché vicini alla logica del calcolatore, furono
accantonati quando con l’invenzione del linguaggio FORTRAN (FORmula TRANslation) venne per la prima volta introdotto un linguaggio ad alto livello, ossia più vicino
alla logica dell’uomo e più lontano dalla logica del calcolatore. I linguaggi ad alto livello, oltre che a risultare più comprensibili, si rivelarono subito più adatti a codificare gli
17
algoritmi. Inoltre il compito di tradurre il programma nel linguaggio macchina è affidato
alla macchina stessa mediante l’uso di un particolare software, detto compilatore, che
ha il compito da un lato di verificare la correttezza sintattica del programma, e dall’altro
di tradurre il programma in linguaggio macchina.
L’introduzione dei linguaggi di programmazione a basso livello ha portato dei grandissimi vantaggi:
• Molte più persone sono oggi in grado di programmare in questi linguaggi, dunque
si sono potuti realizzare programmi che risolvono problemi più complessi rispetto a
quelli che si riuscivano ad affrontare con i linguaggi a basso livello;
• Questo sistema risulta portabile da un calcolatore a un altro, quindi un cambiamento
della macchina non stravolge drammaticamente il lavoro fatto;
• Il programma risulta più leggibile, nel senso che sia il programmatore originario che
un qualunque altro programmatore sono in grado di leggere, modificare o correggere
senza grosse difficoltà un programma già esistente.
Una volta verificata la maggiore efficacia dei linguaggi di programmazione ad alto
livello rispetto ai linguaggi a basso livello, tutto un filone della ricerca informatica cominciò
ad occuparsi della progettazione di linguaggi di programmazione ad alto livello. In base
al modo di approcciarsi alla soluzione del problema, si parla di diversi paradigmi (dal
greco paradeigma=modello) di programmazione. Esistono due grosse classi di paradigmi
di programmazione: il paradigma dichiarativo e il paradigma procedurale o imperativo.
7
Il paradigma dichiarativo
Nel paradigma dichiarativo si elimina la fase di ricerca della strategia per la risoluzione
del problema (algoritmo) facendo in modo che la strategia risolutiva sia trovata dal programma stesso. Ci si concentra più sul “cosa” si deve risolvere piuttosto che sul “come”:
ecco perché i tempi di elaborazione possono essere piuttosto lunghi.
Esempio: Un programma di tipo dichiarativo per il Massimo Comun Divisore (MCD)
specificherà:
• La teoria dei numeri naturali e le definizioni di addizione, moltiplicazione etc.
• “cosa” vuole il problema, cioè la definizione di MCD. Per esempio “Il Massimo
Comun Divisore tra x e y è il massimo numero z tale che sia x che y divisi per z
danno come resto 0”.
• Il programma verificherà per ogni numero più piccolo di x e y se soddisfa la definizione.
18
I programmi dichiarativi sono in genere brevi, leggibili, verificabili e facilmente riutilizzabili. Sono molto vicini al metodo matematico di deduzione di predicati a partire
da certe premesse o assiomi. Per questo sono molto utilizzati per effettuare delle dimostrazioni automatiche. Tuttavia la programmazione dichiarativa ha un grande costo in
termini di efficienza. Per esempio nel caso del MCD l’interprete applicherà la definizione
sostituendo a una variabile z tutti i valori 1, 2, 3 . . . fino a trovare il valore di z che soddisfa
la definizione di Massimo Comun Divisore.
Nell’ambito dei paradigmi dichiarativi ritroviamo il paradigma logico, ossia quello
utilizzato nel linguaggio di programmazione PROLOG (PROgrammazione LOGica):
• Si definisce una serie di assiomi o fatti elementari assunti come veri;
• I risultati vengono prodotti da un processo di calcolo deduttivo;
• Per definire un modello logico occorre definire: oggetti (domains), relazioni fra gli
oggetti (predicates), fatti e regole (clauses), obiettivi (goals);
Sempre nell’ambito del paradigma dichiarativo, la Programmazione Funzionale
trasforma invece il problema in una funzione, quindi l’OUTPUT sarà dato dal valore che
la funzione assume sul valore dell’INPUT. Il linguaggio funzionale più utilizzato nella
pratica è il LISP (LISt Processing) che è stato utilizzato per esempio per realizzare
certi text editor come l’emacs.
8
Il Paradigma Imperativo o Procedurale
Il paradigma imperativo è caratterizzato dal fatto che, nella progettazione del programma,
la fase fondamentale consiste nella ricerca di una strategia risolutiva per un problema
specifico, espressa mediante un algoritmo.
Fanno parte del Pradigma imperativo i linguaggi strutturati (Pascal, C,...) e la programmazione ad oggetti (Delphi, Java, C++). In questo corso ci occuperemo della
programmazione strutturata.
8.1
La Programmazione Strutturata
La Programmazione strutturata, alle cui regole si ispira il linguaggio Pascal, nasce da
una critica sollevata negli anni 60 da Edsger Dijkstra, che discusse approfonditamente gli
effetti deleteri del goto (salto incondizionato) sulla qualità del software, e in particolare
sulla sua leggibilità e modificabilità.
La programmazione strutturata è basata su dei principi fondamentali:
19
1. Scomposizione del problema in blocchi o sottoprogrammi, ossia un insieme di istruzioni di per se completo e delimitato da alcuni marcatori (in pascal dalle istruzioni
begin e end). Ogni blocco può essere pensato come una singola istruzione. Può
apparire anche sotto forma di procedura o funzione.
2. Ogni blocco ha un solo punto di entrata e un solo punto di uscita.
3. Utilizzazione di solo 3 costrutti di controllo (sequenza, selezione, iterazione).
Ci si potrebbe chiedere se i principi della programmazione strutturata non siano troppo
restrittivi. In particolare ci si chiede se l’assenza del costrutto di salto incondizionato
(GOTO), utilizzato ampiamente in alcuni linguaggi di programmazione, che permette
di passare direttamente a una certa istruzione del programma, non limiti l’efficacia del
linguaggio.
In una celebre pubblicazione dal titolo “Flow Diagrams, Turing Machines, and Languages with Only Two Formation Rules”, due scienziati italiani, Corrado Böem e Giuseppe
Jacopini, dimostrarono un importante teorema che porta in loro nome, dove si dimostra
che tutto ciò che si può calcolare utilizzando paradigmi di programmazione più flessibili,
può essere calcolato utilizzando la programmazione strutturata.
Teorema 8.1 (di Böem - Jacopini) Un qualunque programma non strutturato può
essere realizzato utilizzando i soli costrutti di sequenza, selezione e iterazione.
8.2
Costrutto di sequenza
Il costrutto di sequenza consiste nell’elencare in maniera sequenziale le istruzioni che
devono essere eseguite. La sintassi di questo costrutto in Pascal è la seguente:
BEGIN
<istruzione 1>;
<istruzione 2>;
···
···
<istruzione n>;
END;
che equivale a dire: “Esegui la seguente sequenza di istruzioni in quest’ordine”.
8.3
Costrutto di selezione
Nel costrutto di selezione un’istruzione o una serie di istruzioni viene eseguita in dipendenza di una certa condizione. Il principale costrutto di selezione utilizzato in Pascal è il
seguente:
20
if < condizione > then
BEGIN
<sequenza di istruzioni 1>
END
else
BEGIN
<sequenza di istruzioni 2>;
END;
Questo costrutto significa: “se la <condizione> è vera allora esegui <sequenza di istruzioni 1>
altrimenti esegui <sequenza di istruzioni 2>.
In Pascal possiamo avere anche il seguente costrutto:
if <condizione> then
BEGIN
<sequenza di istruzioni>;
END;
che significa: “se la condizione è vera allora esegui istruzioni altrimenti passa all’istruzione
successiva”.
In Pascal esiste anche un costrutto di selezione che verifica allo stesso tempo diverse
condizioni:
case <espressione di controllo> of
V 1: BEGIN
<sequenza di istruzioni
END;
V 2: BEGIN
<sequenza di istruzioni
END;
...
V k: BEGIN
<sequenza di istruzioni
END;
else
BEGIN
<sequenza di istruzioni> {per
END;
1>
2>
k>
ogni valore non codificato}
I valori V k sono detti valori-chiave.
Questo costrutto significa: “Se il risultato dell’<espressione di controllo> è uno
dei valori chiave V i allora esegui le istruzioni corrispondenti <sequenza di istruzioni i>,
altrimenti esegui la <sequenza di istruzioni>”.
21
L’espressione di controllo deve essere una variabile intera o un carattere: non può
assumere valori reali. Tale costrutto non può essere utilizzato nei casi in cui la scelta è
fondata sulla valutazione di espressioni o il valore chiave è un certo intervallo: le grandezze
devono essere ben definite.
8.4
Costrutto di Iterazione
Nel costrutto di iterazione un’istruzione o una serie di istruzioni vengono ripetute tante
volte in relazione al verificarsi o meno ad una certa condizione che viene controllata all’inizio o alla fine di ogni ciclo. Affinché un costrutto di iterazione funzioni correttamente, le
condizioni devono essere sempre inizializzate e devono modificare il proprio valore durante
l’esecuzione del ciclo. Nei cicli condizionali (while e repeat) la condizione può essere o
una variabile booleana o un espressione valutabile in forma booleana (TRUE o FALSE). In
Pascal uno dei cicli condizionali è il ciclo while:
while <condizione> do
BEGIN
<sequenza di istruzioni>;
END;
che significa “continua ad eseguire la <sequenza di istruzioni> fintantoché la <condizione>
continua ad essere vera”.
In Pascal possiamo anche realizzare il ciclo condizionale mediante il costrutto repeat-until,
che ha la seguente sintassi:
repeat
<sequenza di istruzioni>
until <condizione>;
Che significa: “ripeti la <sequenza di istruzioni> fino a quando la <condizione>
diventa vera”.
Anche se le istruzioni sono più di una, i comandi BEGIN e END si possono omettere, in
quanto repeat e until svolgono loro il compito di parentesi.
Un altro costrutto di iterazione disponibile in Pascal è il ciclo for, che si può applicare
quando conosciamo a priori il numero di volte in cui il ciclo deve essere eseguito. In Pascal
ha la seguente sintassi:
for <variabile:= k to h> do
BEGIN
<sequenza di istruzioni>;
END;
22
che significa: “esegui h − k volte le istruzioni”.
Se h ≤ k allora il programma salta l’intera <sequenza di istruzioni>. La variabile deve essere un intero o un carattere (tra apici) o una variabile di tipo enumerativo.
Nelle dichiarazioni deve essere specificato l’intervallo in cui varia la variabile contatore.
i:=n..t; Se al posto di to si scrive downto esegue il conto alla rovescia da k ad h.
8.5
Cicli equivalenti
I cicli while e repeat possono, con alcuni accorgimenti, essere utilizzati in maniera equivalente. Cosı̀ come sono definiti, essi differiscono dal fatto che mentre nel ciclo repeat-until
le istruzioni vengono eseguite almeno una volta, visto che la condizione viene verificata
alla fine, nel ciclo while la condizione viene testata all’inizio del ciclo, per cui se essa
non è vera la prima volta, il programma non eseguirà nemmeno una volta le istruzioni
del ciclo. Si noti inoltre che mentre nel ciclo while la condizione deve essere vera perchè
le istruzioni vengano eseguite, nel ciclo repeat-until l’istruzione deve risultare falsa per
riprendere ad eseguire da capo le istruzioni. Infine il ciclo for viene utilizzato quando
conosciano esattamente il numero di volte in cui le istruzioni devono essere eseguite. Esso
può essere simulato mediante i cicli while e repeat-until, ma non è vero il viceversa, cioè i cicli while e repeat-until non sempre possono essere simulati mediante un
ciclo FOR. Diamo ora uno schema di come si possono simulare alcune istruzioni di ciclo
condizionale mediante altre:
Il ciclo:
for i:=1 to n do
BEGIN
<sequenza di istruzioni>;
END;
è equivalente a:
i:=1; {inizializzazione del contatore i a 1}
while i<=n do
BEGIN
<sequenza di istruzioni>;
i:=i+1;
END;
oppure alle istruzioni:
i:=0; {inizializzazione del contatore i a 0}
repeat
<sequenza di istruzioni>;
23
i:=i+1;
until i=n;
Anche i cicli while e repeat-until possono essere resi equivalenti con particolari accorgimenti. Per esempio:
while <condizioni> DO
BEGIN
<sequenza di istruzioni>;
END;
è equivalente a:
if <condizioni> then {se la condizione e’ vera}
repeat
<sequenza di istruzioni>;
until not <condizioni>; condizione falsa
Il ciclo:
repeat
<sequenza di istruzioni>;
until <condizioni>
è equivalente a:
<sequenza di istruzioni>;
while not <condizioni> DO
BEGIN
<sequenza di istruzioni>;
END
24
9
Schema generale di un programma
Un programma in Pascal ha essenzialmente la seguente forma:
Program nome (input,output);
dichiarazioni;
begin
istruzioni;
end.
Le parti essenziali di un programma sono quindi l’intestazione, ove compare il nome
del programma, la sezione dichiarativa, ove vengono fatte tutte le dichiarazioni (variabili,
le costanti, i tipi, etc), e il corpo del programma, che è racchiuso dalle parole chiave begin
e end., ove sono contenute le istruzioni che il programma deve eseguire.
Nell’intestazione del programma, alla parola chiave program segue sempre il nome
del programma.
Le parole chiave sono delle parole che il compilatore riconosce come delle istruzioni ben
precise, attribuendo ad esse un significato specifico e non ambiguo. Osserviamo che nel
Pascal standard (ma questo non è necessario se si utilizza in compilatore Pascal di Delphi),
dopo il nome del programma, possono figurare tra parentesi le parole chiave input e output.
La parola chiave input si inserisce se il programma richiede all’utente di immettere dei
dati; la parola chiave output viene invece inserita se il programma fornisce dei risultati
di output. Entrambe le parole possono non figurare nel caso in cui il programma non ha
input e non ha output. Per esempio il programma vuoto:
Program vuoto;
BEGIN
END.
è programma valido (cioè è corretto, e quindi sarà compilato dal compilatore e genera il
programma eseguibile vuoto) pur non contenendo alcuna dichiarazione e alcuna istruzione.
Questo programma non esegue nessun compito. Possiamo inoltre avere un programma
senza dichiarazioni e con delle istruzioni, come ad esempio:
Program ciao(output);
BEGIN
write (’ciao’)
END.
Questo programma non farà altro che scrivere la parola ciao sullo schermo.
Anche il programma con delle dichiarazioni ma senza istruzioni è un programma valido
e anch’esso non svolgerà nessuna funzione.
25
Quando il programma contiene più di un’istruzione, ogni istruzione tranne l’ultima
termina con un punto e virgola (;). Si noti inoltre che il Pascal non distingue fra lettere
maiuscole e minuscole.
9.1
Programmi di output
I programmi più semplici che possiamo scrivere sono quelli che consentono di visualizzare
determinate frasi, o stringhe, o sequenze di lettere, nello schermo. Supponiamo di voler
scrivere un programma che scrive sullo schermo la frase: “sempre caro mi fu quest’ermo
colle”.
Per fare questo abbiamo bisogno di un comando che dice al compilatore di visualizzare
delle frasi o delle parole nello schermo. Esistono due comandi di questo tipo, write e
writeln. Il comando write viene utilizzato nel modo seguente:
write (’sempre ’);
La parola o la frase che si vuole visualizzare viene inserita fra parentesi e fra apici. Se
un’istruzione write viene seguita da un’altra istruzione write, allora la parola visualizzata
dalla seconda istruzione sarà disposta nel punto successivo alla fine della prima parola.
Esempio:
write (’sempre ’);
write (’caro ’);
visualizzerà sullo schermo:
sempre caro
Si noti che al fine di avere lo spazio fra le due parole occorre inserire lo spazio all’interno delle virgolette, in quanto anch’esso viene considerato come un qualunque carattere.
Inoltre se la frase contiene un apostrofo, al fine di distinguere fra l’apostrofo e l’apice di
chiusura frase, l’apostrofo si fa seguire da un apice (’’). Un possibile programma che
stampa sullo schermo la frase sempre caro mi fu quest’ermo colle. sarà il seguente:
program infinito (output)
begin
write(’sempre ’);
write(’caro ’);
write(’mi ’);
write(’fu ’);
write(’quest’’’);
write(’ermo ’);
write(’colle.’)
end.
26
Si noti che nella quinta istruzione ci sono tre apici consecutivi. Questo perchè i primi
indicano al compilatore che vogliamo stampare un apice, e il terzo è quello di chiusura
della frase da stampare. Alternativamente è anche possibile inserire tutte le parole nella
stessa riga. Un programma alternativo che svolge la stessa funzione del precedente, è il
seguente:
program infinito1 (output)
begin
write(’sempre caro mi fu quest’’ermo colle.’)
end.
Inoltre il comando:
write(’ ’:n);
permette di lasciare n spazi vuoti. Se per esempio scriviamo il programma:
program infinito1 (output)
begin
write(’sempre caro mi fu’);
write(’ ’:7);
write(’quest’’ermo colle.’)
end.
la frase quest’ermo colle verrà stampata in una posizione che si discosta di 7 caratteri
dalla stampa precedente:
sempre caro mi fu
quest’ermo colle
Vedremo in seguito un altro uso dell’istruzione write quando l’argomento è una variabile.
Un comando di output simile al write è il writeln (si legge writeline). La differenza col
write consiste nel fatto che appena il comando writeln finisce di stampare la frase tra
parentesi, il cursore va a capo. Questo significa che se un’altra istruzione di stampa segue
un writeln all’interno del programma, la stampa della frase relativa avverrà nella riga
successiva. Per esempio:
program infinito2 (output)
begin
writeln(’sempre caro mi fu quest’’ermo colle’);
writeln(’e questa siepe che da tanta parte’);
writeln(’dell’’ultimo orizzonte il guardo esclude.’)
writeln;
27
writeln(’Ma sedendo e mirando, interminati spazi di la’ da quella’);
writeln(’e sovrumani silenzi, e profondissima quiete io nel pensier mi fingo’);
writeln(’ove per poco il cor non si spaura.);
end.
produrrà in stampa
sempre caro mi fu quest’ermo colle
e questa siepe che da tanta parte
dell’ultimo orizzonte il guardo esclude.
Ma sedendo e mirando, interminati spazi di la’ da quella
e sovrumani silenzi, e profondissima quiete io nel pensier mi fingo
dove per poco il cor non si spaura.
Il comando writeln senza argomenti viene utilizzato quendo si vuole lasciare una riga
vuota
10
La sezione dichiarativa
La maggior parte dei programmi in Pascal contengono subito dopo l’intestazione, una
sezione dichiarativa. Nella sezione dichiarativa vengono dichiarati tutti gli oggetti che
servono per lo svolgimento del programma. Lo scopo delle dichiarazioni è quello di associare degli identificatori agli oggetti che saranno utilizzati in seguito dalle istruzioni. Le
dichiarazioni possono essere (nell’ordine):
CONST
TYPE
VAR
PROCEDURE
FUNCTION
10.1
Identificatori
Un identificatore è il nome che scegliamo per il programma o per qualunque oggetto
(costante, variabile, tipo, procedura o funzione) utilizzato nel programma.
L’identificatore può essere scelto arbitrariamente dal programmatore purché soddisfi
le seguenti regole:
• deve essere costituito da caratteri non accentati, numeri o lettere;
• deve iniziare necessariamente con una lettera o eventualmente con un simbolo di
underscore;
28
• Se ci sono numeri, devono figurare all’interno, mai all’inizio dell’identificatore (esempio program prova 2);
• Non ci devono essere spazi all’interno dell’identificatore;
• L’identificatore non può essere una parola chiave né può essere uguale all’identificatore di un altro oggetto;
• Sia per gli identificatori che per le parole chiave, il compilatore pascal non è casesensitive, nel senso che non riconosce come diverse le maiuscole e le minuscole.
10.2
Costanti - CONST
Quando un valore non viene modificato nel corso del programma ma viene utilizzato
frequentemente, è utile sostituirlo con un simbolo. Per esempio, in un programma che
ha a che fare con cerchi e circonferenze, sarà utile memorizzare il valore di π con un
identificatore, per esempio PIGRECO. Questi valori simbolici si chiamano costanti. In
Pascal le costanti vengono dichiarate nella sezione dichiarativa subito dopo l’intestazione,
nel modo seguente:
CONST
nomecostante = valorecostante;
Per esempio la costante π sarà dichiarata:
CONST
PIGRECO = 3.1415;
L’uso delle costanti può essere utile perché se un parametro è ricorrente, e per un certo
motivo questo parametro deve essere cambiato, è sufficiente cambiare il valore alla costante
e non il valore del parametro in tutto il programma. Per esempio in un programma di
gestione di un’azienda, un parametro importante può essere il valore dell’IVA. Se a un
certo punto il valore dell’IVA viene cambiato, ed esso è definito come una costante, non è
necessario modificare questo valore all’interno di tutto il programma, ma solamente nella
sezione dichiarativa.
10.3
Variabili
Una variabile identifica un valore che può variare nel corso dell’esecuzione del programma.
A ciascuna variabile è associato un identificatore, che è il nome della variabile, e un tipo.
Il tipo specifica appunto il tipo di variabile rappresentata dall’identificatore (es. intero,
reale, carattere, booleano...). Nella fase di lettura delle dichiarazioni delle variabili, in
base alla definizione del tipo, il compilatore riserva una parte di memoria necessaria
29
a contenerle: questa operazione è detta allocazione della memoria. In qualche modo,
mentre l’identificatore specifica il nome della variabile, il tipo ne definisce la dimensione
e l’insieme delle operazioni che si possono effettuare.
Le variabili vengono dichiarate nella sezione dichiarativa (e quindi, differentemente dal
linguaggio C, prima che il programma abbia inizio) mediante la parola chiave var seguita
dalla lista degli identificatori e dei rispettivi tipi:
VAR
identificatore1:
identificatore2:
tipo1;
tipo2;
Se ci sono più variabili dello stesso tipo, possiamo accorparle in un’unica linea, inframezzando gli identificatori con virgole e specificando alla fine dopo i due punti il
tipo.
VAR
identificatore 1,...,identificatore n : tipo1;
identificatore (n+1),...,identificatore m : tipo2;
10.4
Assegnamento di una variabile
Una volta definita la variabile, all’interno del corpo del programma occorre assegnarle dei
valori. Infatti finché la variabile è semplicemente dichiarata, il compilatore ha semplicemente allocato la memoria, ma quella locazione di memoria non contiene nessun valore
specifico. Nel corpo del programma vengono assegnati alla variabile dei valori. La prima
volta che a una variabile viene assegnato un valore specifico si chiama inizializzazione.
Le variabili vengono assegnate scrivendo l’identificatore della variabile seguito da := e il
valore che si vuole assegnare o eventualmente un’espressione, il cui risultato sarà il nuovo
valore della variabile:
nomevariabile:= valore o espressione;
Esempio: Facciamo un esempio di un piccolo programma che utilizza gli elementi di
programmazione che conosciamo. Supponiamo di volere calcolare l’area di un rettangolo
di base 12 e altezza 7. Un programma per calcolare quest’area può essere il seguente:
program arearettangolo (output);
var base, altezza, area: integer;
begin
base:=12;
altezza:=7;
area:= base*altezza;
30
writeln(area)
end.
Si noti che questa volta l’istruzione writeln è stata utilizzata in un modo leggermente
diverso da quello già visto. Infatti l’argomento della funzione writeln è la variabile
area. Se l’argomento della funzione writeln (o anche write) è una variabile, allora in
esecuzione il programma stamperà il valore che quella variabile contiene nel momento in
cui è richiamata. Avremmo potuto anche accompagnare questo valore con una parte di
testo. In generale se la funzione writeln contiene più argomenti, essi saranno inframezzati
da virgole. Per esempio avremmo potuto scrivere:
writeln(’l’’area del rettangolo e’’ ’, area)
produrrà in output:
l’area del rettangolo e’ 84
Ancora sul comando write, possiamo avere anche il seguente comando
write(x:n)
Questo comando riserva n spazi per scrivere i caratteri o le cifre che compongono x.
Tuttavia ci interessa che un programma sia più flessibile e ci dia un metodo generale
per calcolare l’area di tutti i rettangoli possibili, non solo quello i cui parametri sono
specificati dal programmatore. Vogliamo quindi che sia l’utente specificare tali valori.
Per fare questo abbiamo bisogno di una funzione che permette al programma di acquisire
dei dati dall’esterno. Tali funzioni sono read e readln. Se vogliamo che l’utente immetta
il dato base e il dato altezza del rettangolo, dovremo fare in modo che tali dati vengano
immesso nelle variabili che rappresentano la base e l’altezza del rettangolo, che nel nostro
caso sono proprio le variabili base e altezza. Quindi rispettivamente base ed altezza
saranno gli argomenti della funzione read, che sarà quindi utilizzata nel modo seguente:
read(base);
read(altezza);
Un programma che calcola l’area del rettangolo con base ed altezza specificati dall’utente
sarà il seguente:
program arearettangolo1 (input, output);
var base, altezza, area: integer;
begin
writeln(’Inserire il valore della base del rettangolo’);
readln(base)
31
writeln(’Inserire il valore dell’’altezza del rettangolo’);
readln(altezza)
area:= base*altezza;
writeln(’l’’area del rettangolo e’’ ’, area)
end.
Riguardo al comando readln(lista di variabili), come per il writeln, dopo aver
eseguito le stesse operazioni di read, dispone il cursore sulla linea successiva.
Se si scrive readln senza argomenti, il cursore va a capo e aspetta che venga dato un
invio. Questa istruzione può essere utile alla fine del programma in modo che prima che la
finestra dell’eseguibile scompaia alla fine delle computazioni, aspetti che venga schiacciato
il tasto di invio.
10.5
Commenti
In un programma in Pascal è possibile inserire dei commenti. I commenti sono delle parti
del programma che il compilatore non legge, ma che possono essere utili per la lettura del
codice del programma da parte del programmatore. I commenti possono essere o racchiusi
tra parentesi graffe oppure tra parentesi tonda aperta seguita da *, (* e parentesi tonda
chiusa preceduta da * , *).
11
Tipi standard o predefiniti
• integer numeri interi
• real numeri reali
• char caratteri
• boolean valori booleani
11.1
Il tipo integer
Quando una variabile viene definita di tipo integer, il compilatore riserva a questa variabile una quantità di spazio ben precisa che in genere dipende dal tipo di compilatore Pascal.
Nella figura 11.1 sono indicati il numero dei bit utilizzati nei vari tipi interi presenti in
Delphi. Il comando maxint permette di visualizzare sullo schermo il massimo valore che
si può attribuire ad un intero.
- Le operazioni che si possono effettuare con gli interi sono
• l’operazione (unaria) di negazione -x, che ad ogni variabile x associa il valore
opposto;
32
Type
integer
32-bit Cardinal
Shortint
Smallint
Longint
Int64
Byte
Word
Longword
Range
-2147483648..2147483647
0..4294967295
-128..127
-32768..32767
-2147483648..2147483647
−26 3..26 3 − 1
0..255
0..65535
0..4294967295
Format
signed 32 bit
unsigned 32-bit
signed 8-bit
signed 16-bit
signed 32-bit
signed 64-bit
unsigned 8-bit
unsigned 16-bit
unsigned 32-bit
Figura 2: La tabella indica il numero di bit utilizzati per la rappresentazione dei vari tipi
interi utilizzati in Delphi
• la somma x+y, che a due interi x e y associa la loro somma;
• il prodotto x*y, che a due interi x e y associa il loro prodotto;
• la differenza x-y, che a due interi x e y associa la loro differenza;
• la divisione intera (x div y), che dà come risultato la parte intera del quoziente
della divisione tra x e y;
• l’operazione modulo (x mod y), che dà come risultato il resto della divisione tra x
e y;
La priorità delle operazioni su interi è la seguente:
1. operatore di negazione -;
2. operatori di Moltiplicazione (*) divisione intera (div) e modulo (mod);
3. operazioni di somma (+) e sottrazione (-)
Inoltre sugli interi esistono le seguenti funzioni (predefinite)
• sqr(x) che dà il quadrato di x;
• succ(x) che dà il successivo di x;
• pred(x) che dà il predecessore di x.
33
I compilatori Pascal più moderni contengono anche la funzione random. random(n)
restituisce un numero intero casuale tra zero e n-1. Si noti che in generale il numero cosı̀
generato è pseudo-casuale, nel senso che è generato come risultato di una funzione che è
calcolata a partire da un “seme” detto randomseed. Per modificare questo seme occorre
utilizzare l’istruzione randomize; inserita all’inizio del programma.
Inoltre per gli interi si possono utilizzare i seguenti operatori di confronto
=
<>
<
11.2
uguale
diverso
minore
>
<=
>=
maggiore
minore o uguale
maggiore o uguale
Il tipo boolean
Una variabile di tipo boolean assume solo i valori True o False. A una variabile di tipo
Boolen viene assegnato un byte di memoria (la minima quantità di memoria possibile).
Se b è una variabile booleana, possiamo fare le assegnazioni:
b:=False,
b:=True,
oppure possiamo assegnare il risultato di un’espressione logica, per esempio:
b:=x>0,
che significa che la variabile booleana b assume valore True se x è maggiore di 0 e False
altrimenti. Le variabili di tipo booleano sono molto utili soprattutto perchè possono essere
utilizzate come condizioni nei costrutti di selezione e di iterazione. Si noti che quando
una variabile booleana viene utilizzata come condizione in un costrutto di selezione o di
iterazione(di tipo while o repeat), il controllo effettuato per default è che la variabile sia
True. Quindi le espressioni:
if b=True then
if b<>False then
if b then
sono equivalenti.
Il contenuto di una variabile booleana può essere visualizzato mediante l’istruzione
write, mentre in genere il valore di una variabile booleana non può essere inserito da
parte dell’utente. L’istruzione read(b) darà errore in compilazione.
Le operazioni che si possono definire sulle variabili booleane sono i connettori logici, AND,
OR e NOT. Queste operazioni seguono le seguenti tavole di verità:
34
AND
TRUE
FALSE
TRUE
FALSE
TRUE
FALSE
FALSE FALSE
OR
TRUE
FALSE
TRUE FALSE
TRUE TRUE
TRUE FALSE
NOT
TRUE
FALSE
FALSE
TRUE
Figura 3: Le tavole in figura rappresentano il comportamento degli operatori logici al
variare dei valori delle variabile booleane su cui vengono applicati.
In molti compilatori Pascal è anche disponibile l’operatore xor che corrisponde all’or
esclusivo. date due variabili x ed y, x xor y sarà TRUE se solo una fra le due variabili è
TRUE. La tavola di verità corrispondente è la seguente:
XOR
TRUE
FALSE
TRUE
FALSE
False TRUE
TRUE
FALSE
Si noti inoltre che anche per le variabili booleane si possono applicare gli operatori relazionali (=, <, >, <>, >=,<=). In particolare si noti che FALSE<TRUE. Tutti i connettivi
logici e relazionali seguono la seguente scala di priorità:
1. NOT;
2. AND;
3. OR, XOR;
4. =, <, >, <>, >=,<= (operatori relazionali).
Inoltre la funzione odd(n) prende come argomento un intero e restituisce True se n è
dispari, False se n è pari.
12
Il tipo Char
Le variabili di tipo char assumono valori alfanumerici che comprendono le lettere maiuscole e minuscole, le cifre, la punteggiatura e i simboli.
L’assegnazione ad una variabile di tipo ASCII viene fatta mettendo il simbolo fra apici:
x:=’A’;
y:=’8’;
z:=’:’;
35
Per una variabile di tipo char il compilatore assegna un byte di memoria, dunque 8
bit. Questo significa che si possono avere 256 caratteri diversi. La macchina assegna ad
ogni carattere un numero da 0 a 255, secondo una determinata codifica. Il codice che viene
ormai universalmente usato è il codice ASCII (American Standard Code for Information
Interchange).
Alle variabili di tipo carattere possiamo applicare gli operatori relazionali (>, <, >=,
<=, =, <>), poiché essi sono ordinati secondo il loro numero d’ordine nel codice ASCII.
Si noti che le lettere sono ordinate nel codice ASCII secondo l’ordine alfabetico, e le
maiuscole sono in generale minori delle minuscole (maiuscole da 65 a 90, minuscole da 97
a 122).
Inoltre sono definite come per gli interi le funzioni pred(x) e succ(x) che danno
rispettivamente il carattere precedente e successivo nella tabella ASCII. Inoltre è definita
la funzione ord(x) a valori interi che dà il numero d’ordine corrispondente al carattere
nella tabella ASCII. Al contrario è definita la funzione chr(n) dove n è un intero tra 0 e
255, che dà il carattere corrispondente nella tabella ASCII a quel numero.
Una variabile x di tipo carattere può essere visualizzata mediante l’istruzione write
(x).
12.1
Il tipo Real
In genere, quando una variabile numerica può assumere valori non interi (ossia decimali)
deve essere dichiarata come una variabile di tipo real. In generale, quando una variabile
è definita come real, il compilatore riserva nella memoria 8 bytes (ossia 64 bits), che
sono sufficienti a contenere valori positivi e negativi compresi tra 10−38 e 1038 . In generale
un numero reale viene rappresentato in memoria come prodotto di un numero minore di
zero per una potenza di 10. Quindi un real sarà un oggetto del tipo: 0, c1 c2 . . . ck · 10i .
la sequenza c1 c2 . . . ck si chiama mantissa, e l’esponente di 10 si dice caratteristica. In
genere il compilatore riserva 7 byte, ossia 56 bits per la mantissa, 1 per il suo segno, 7
bits per la caratteristica e 1 per il suo segno. Poichè viene utilizzata la base 2, i valori
positivi rappresentabili vanno da 2−127 (corrispondente a 38 cifre decimali cioè 10−38 ) a
2127 (1038 ).
Un numero reale può essere assegnato in uno dei seguenti modi:
x:=32.16
y:=0.02
z:=.02
t:=7E+20
Quando si scrive z:=.02 è la stessa cosa che scrivere z:=0.02. Invece l’espressione
t:=7E+20 significa che t è uguale a 7 per 10 elevato a 20 (ossia 7 seguito da 20 zeri).
Questo tipo di notazione con mantissa ed esponente si chiama notazione scientifica.
36
Vediamo come funzionano le istruzioni di tipo write sui reali. Per default quando si
chiede al programma di stampare un numero reale, il programma utilizzerà la rappresentazione a virgola mobile, per cui la virgola viene posta dopo la cifra più significativa, e
il numero viene moltiplicato per un opportuna potenza di 10. In questa notazione per
esempio il numero 31738.456 verrà rappresentato come 3.1738456E+4. Tuttavia esistono
altri modi per far rappresentare i reali al calcolatore. Infatti se x è una variabile di tipo
real, si può trovare un’istruzione del tipo
writeln(x:15);
che significa che è riservato al numero reale uno spazio per contenere 15 cifre. Invece la
scritta
writeln(x:15:5)
significa che dei 15 caratteri riservati per contenere il numero, 5 vengono riservati alle
cifre decimali. In questo caso il numero sarà rappresentato mediante la rappresentazione
a noi nota. Nel caso in cui le cifre decimali eccedono questo numero, vengono eliminate
le cifre meno significative. Se invece la parte intera eccede lo spazio riservatole, il campo
della parte intera viene esteso fino a contenerla tutta.
Sulle variabili di tipo real si possono effettuare tutte le operazioni aritmetiche che
conosciamo: +, -, *, /. Se un’operazione coinvolge un numero real, il suo risultato
deve essere attribuito a una variabile di tipo real. Analogamente, se facciamo una divisione
per mezzo dell’operatore / fra interi, il risultato deve essere attribuito a una variabile di
tipo real.
Si possono definire diverse funzioni sui reali:
• abs(x) restituisce il valore assoluto di x;
• sqr(x) restituisce il quadrato di x;
• round(x) arrotonda x all’intero più vicino;
• trunc(x) scrive la parte intera inferiore di x;
• frac(x) restituisce la parte frazionaria di x;
• int(x) restituisce la parte intera (come valore real);
• sin(x) restituisce il seno di x, espresso in radianti;
• cos(x) restituisce il coseno di x, espresso in radianti;
• arctan(x) restituisce il l’arcotangente di x in radianti;
37
• ln(x) restituisce il logaritmo naturale (in base e) di x;
• exp(x) restituisce e elevato ad x;
• sqrt(x) radice quadrata di x.
13
Il costrutto di selezione
Le istruzioni decisionali sono quelle che permettono di realizzare il costrutto di selezione.
In genere il costrutto di selezione si realizza mediante l’istruzione:
if <condizione> then
istruzione1
else
istruzione2
Esempio: Supponiamo di voler scrivere un programma che riconosca se un numero intero
immesso da tastiera è pari o dispari. Un programma che svolge questo compito sarà il
seguente:
program parita(input, output);
var n:integer;
begin
write(’Inserire un intero: ’);
readln(n);
if (n mod 2=0) then writeln(’Il numero dato e’’ pari’);
else writeln(’Il numero dato e’’ dispari’);
readln
end.
Se si vuole che in base alla condizione si svolgano un gruppo di istruzioni, allora occorre
racchiudere queste istruzioni tra begin ed end;
Si noti che è possibile annidare degli if..then... else uno dentro l’altro. In questo caso
è necessario ricorrere a certi accorgimenti.
Supponiamo che l’if più esterno non abbia un else, mentre quello più interno si. Allora
avremo un’istruzione del tipo:
if <condizione1> then
if <condizione2> then
istruzione1
else istruzione2;
38
il compilatore capisce che l’else è riferito all’if più interno. Se invece l’else si riferisce
all’if più esterno, occorre racchiudere l’if più interno fra begin ed end:
if <condizione1> then
begin
if <condizione2> then
istruzione1
end
else istruzione2;
Esercizi
1. Scrivere un programma che, presi in input due interi qualsiasi, stabilisca se i due
numeri sono uguali e, in caso contrario, segnali quale dei due è maggiore.
2. Scrivere un programma che, presi in input due parametri a e b, dia in output il
risultato dell’equazione di primo grado ax + b = 0.
3. Scrivere un programma che, presi in input tre parametri a, b e c dia in output il
risultato dell’equazione di secondo grado ax2 + bx + c = 0.
4. Scrivere un programma che, presi in input tre parametri a, b, c, stabilisca se essi
possono essere i lati di un triangolo. In caso positivo, stabilire se il triangolo e’
equilatero, isoscele o scaleno. Infine verificare se il triangolo è rettangolo.
14
L’istruzione case
Se la selezione viene fatta in dipendenza di diverse condizioni, anzichè una sequenza di
selezioni mediante il costrutto if ... then... else a cascata, in alcuni casi si può
usare il costrutto case of. Esso viene usato seguendo la seguente sintassi:
case <espressione chiave> of
valore1: ISTRUZIONE1
valore2: ISTRUZIONE2
...
valorek: ISTRUZIONEk
else ISTRUZIONE(k+1)
L’else può anche non essere presente. Per esempio supponiamo di volere fare un programma che dà le diverse tariffe ferroviarie in dipendenza di varie categorie di viaggiatori.
allora possiamo utilizzare il costrutto case... of nella seguente forma:
39
case persona of
’i’: riduz:=100;
’a’,’p’: riduz:=50;
’o’: riduz:=0;
’t’: riduz:=30;
’q’: riduz:=40
15
Il costrutto di iterazione con contatore
Supponiamo di volere ripetere una stessa operazione un numero fissato di volte. Questo
si può realizzare mediante il costrutto di iterazione con contatore, che ha la seguente
sintassi:
for i:=h to k do
begin
istruzioni
end;
Questo costrutto farà si che le istruzioni saranno svolte k − h + 1 volte. La variabile
i che deve essere definita nella sezione dichiarativa, e viene chiamata contatore. Questo
costrutto è tale che la variabile contatore viene inizializzata ad h dopo la parola for,
quindi vengono eseguite una volta le istruzioni. Alla fine della sequenza tra il begin e
l’end il programma torna all’istruzione for, incrementa di uno il contatore i e ripete le
istruzioni fino a quando il contatore non raggiunge il valore k.
Supponiamo per esempio di volere far stampare i primi 10 numeri pari. Un programma
che realizza questo avrà la seguente forma:
program pari(output);
var i:integer;
begin
for i:=1 to 10 do
write(2*i, ’ ’);
readln;
end.
Questo programma produrrà in output i valori 2, 4, 6, 8, 10, 12, 14, 16, 18, 20.
Supponiamo di volere calcolare la somma di 5 numeri introdotti dall’utente tramite
tastiera. Il programma sarà il seguente:
program somma (input, output);
const n=5;
40
var somma, k, i :integer;
begin
somma:=0;
for i:=1 to n do
begin
readln(k);
somma:=somma+k;
end;
writeln(’la somma dei numeri introdotti e’’ :’, somma)
end.
I numeri di Fibonacci sono definiti ricorsivamente come segue:
f (0) = 0, f (1) = 1, f (n) = f (n − 1) + f (n − 2).
Scriviamo un programma che calcola l’n-esimo numero di Fibonacci.
program fibonacci (input, output);
var fib, n, i :integer;
begin
write(’n= ’);
read(n);
if n=0 then fib:=0
else if n=1 then fib:=1;
else
begin
f:=0;
g:=1;
for i:=2 to n do
begin
fib:=f+g
f:=g;
g:=fib;
end;
end;
writeln(’L’’n-esimo numero di Fibonacci e’’ :’, fib)
end.
ESERCIZI
1. Scrivere un programma per il calcolo del fattoriale di un numero (ricordare che
n! = 1 · 2 · 3 · · · n.)
41