dispense del corso di programmazione i con …baioletti/.../materiale/dispense-progr1-c++.pdf · la...

158
DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON LABORATORIO Marco Baioletti A.A. 2009/10

Upload: phungtruc

Post on 15-Feb-2019

220 views

Category:

Documents


2 download

TRANSCRIPT

Page 1: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON

LABORATORIOMarco Baioletti

A.A. 2009/10

Page 2: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Indice generale1 Introduzione.......................................................................................................................................6

1.1 Concetti di base..........................................................................................................................61.2 Problemi ed istanze....................................................................................................................61.3 Metodi per la descrizione degli algoritmi.................................................................................. 8

1.3.1 Diagrammi di flusso...........................................................................................................81.3.2 Pseudo-codifica.................................................................................................................. 9

1.4 Proprietà degli algoritmi.......................................................................................................... 101.4.1 Eseguibilità.......................................................................................................................101.4.2 Finitezza e terminazione...................................................................................................111.4.3 Correttezza....................................................................................................................... 111.4.4 Efficienza......................................................................................................................... 11

1.5 Introduzione al costo computazionale .................................................................................... 121.6 Esercizi.....................................................................................................................................14

2 Livelli di programmazione.............................................................................................................. 162.1 Struttura di un elaboratore....................................................................................................... 162.2 Il linguaggio macchina.............................................................................................................172.3 Caratteristiche negative del L.M..............................................................................................182.4 La programmazione ad alto livello ed i paradigmi di programmazione..................................19

2.4.1 Il paradigma imperativo................................................................................................... 202.4.2 Il paradigma funzionale................................................................................................... 202.4.3 Il paradigma logico.......................................................................................................... 202.4.4 Il paradigma ad oggetti.................................................................................................... 21

2.5 La traduzione........................................................................................................................... 212.5.1 La compilazione............................................................................................................... 212.5.2 L'interpretazione...............................................................................................................22

2.6 Le fasi della compilazione....................................................................................................... 222.7 Gli strumenti della programmazione....................................................................................... 24

3 La sintassi dei linguaggi di programmazione.................................................................................. 253.1 Definizioni generali: stringhe e linguaggi............................................................................... 253.2 Grammatiche............................................................................................................................263.3 Grammatiche libere dal contesto ed alberi di derivazione.......................................................273.4 Forma estesa di Backus e Naur (EBNF)..................................................................................283.5 Alcuni semplici linguaggi in EBNF.........................................................................................293.6 Esercizi.....................................................................................................................................30

4 Tipi di dati, variabili ed espressioni.................................................................................................314.1 Concetto di tipo di dato............................................................................................................314.2 Classificazione dei tipi di dato.................................................................................................31

4.2.1 Tipi di dati numerici.........................................................................................................32Tipi interi.............................................................................................................................. 32Tipi reali............................................................................................................................... 33Conversione di tipo.............................................................................................................. 34Altri tipi numerici.................................................................................................................34

4.2.2 Tipi di dati non numerici..................................................................................................34Tipo logico............................................................................................................................34Tipo carattere........................................................................................................................35Tipo stringa...........................................................................................................................36Tipo puntatore...................................................................................................................... 36

4.3 Costanti.................................................................................................................................... 374.4 Variabili....................................................................................................................................374.5 Espressioni............................................................................................................................... 38

Page 3: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

4.6 Introduzione alla semantica operazionale: il concetto di stato................................................ 404.7 Semantica operazionale delle espressioni................................................................................414.8 Valutazione pigra..................................................................................................................... 424.9 Esercizi.....................................................................................................................................43

5 Introduzione al linguaggio C++.......................................................................................................445.1 Programma di esempio e sintassi di base.................................................................................44

5.1.1 Commenti.........................................................................................................................445.1.2 Maiuscole e minuscole.....................................................................................................445.1.3 Spazi e indentazione.........................................................................................................455.1.4 Struttura di un programma............................................................................................... 45

5.2 Dichiarazione di variabili e costanti........................................................................................ 465.3 Semantica operazionale delle istruzioni.................................................................................. 465.4 Assegnamento.......................................................................................................................... 475.5 Istruzioni di Input/Output........................................................................................................ 48

5.5.1 Istruzione di input............................................................................................................ 485.5.2 Istruzione di output.......................................................................................................... 49

5.6 Sequenza di istruzioni e blocchi.............................................................................................. 495.7 Effetti collaterali...................................................................................................................... 505.8 Esercizi.....................................................................................................................................51

6 Programmazione strutturata e strutture di controllo condizionali................................................... 526.1 Programmazione strutturata e teorema di Jacopini-Bohm.......................................................526.2 Sequenza e istruzioni composte...............................................................................................546.3 Istruzione if-else...................................................................................................................... 546.4 If annidati e costrutto if-else-if................................................................................................ 576.5 Istruzione switch...................................................................................................................... 616.6 Operatore condizionale............................................................................................................ 636.7 Esercizi.....................................................................................................................................64

7 Strutture di controllo iterative......................................................................................................... 657.1 Generalità sull'iterazione......................................................................................................... 657.2 Istruzione while........................................................................................................................657.3 Istruzione do-while.................................................................................................................. 687.4 Istruzione for............................................................................................................................697.5 Iterazione limitata con il ciclo for............................................................................................707.6 Indici definiti all'interno del for............................................................................................... 727.7 Cicli for annidati...................................................................................................................... 727.8 Varianti del ciclo for standard.................................................................................................. 73

7.8.1 Estremo escluso................................................................................................................737.8.2 Ciclo all'indietro...............................................................................................................747.8.3 Ciclo a passo non unitario................................................................................................ 747.8.4 Condizione aggiuntiva..................................................................................................... 74

7.9 Istruzioni break e continue.......................................................................................................757.10 Esercizi...................................................................................................................................76

8 Correttezza dei programmi.............................................................................................................. 788.1 Il problema della correttezza e l'approccio mediante test........................................................ 788.2 Introduzione alla semantica assiomatica..................................................................................788.3 Invarianti di ciclo e correttezza parziale.................................................................................. 798.4 Terminazione............................................................................................................................818.5 Considerazioni finali e problema della fermata.......................................................................818.6 Esercizi.....................................................................................................................................82

9 Tipi di dato strutturati...................................................................................................................... 839.1 Generalità.................................................................................................................................839.2 Gli array................................................................................................................................... 83

Page 4: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

9.3 Implementazione delle operazioni elementari sugli array....................................................... 859.3.1 Riempimento.................................................................................................................... 869.3.2 Copia................................................................................................................................ 869.3.3 Lettura da tastiera.............................................................................................................869.3.4 Scrittura su schermo.........................................................................................................869.3.5 Sommatoria e calcolo del massimo..................................................................................869.3.6 Conteggio ed esistenza.....................................................................................................879.3.7 Filtro.................................................................................................................................88

9.4 Array multidimensionali.......................................................................................................... 889.5 Le stringhe............................................................................................................................... 899.6 Le strutture (record)................................................................................................................. 909.7 Array di strutture......................................................................................................................929.8 Le unioni.................................................................................................................................. 939.9 Creazione di tipi di dati............................................................................................................94

9.9.1 Tipi di dati enumerativi.................................................................................................... 949.9.2 Nuovi tipi per costruzione................................................................................................95

9.10 Esercizi...................................................................................................................................9610 Algoritmi elementari......................................................................................................................98

10.1 Algoritmi di ricerca................................................................................................................9810.1.1 Algoritmo di ricerca lineare........................................................................................... 9810.1.2 Algoritmo di ricerca binaria........................................................................................... 99

10.2 Algoritmi di ordinamento.....................................................................................................10010.2.1 Algoritmo di ordinamento a bolle (Bubblesort)........................................................... 10010.2.2 Algoritmo di ordinamento per selezione...................................................................... 10210.2.3 Algoritmo di ordinamento per inserzione.................................................................... 103

10.3 Esercizi.................................................................................................................................10411 Programmazione modulare e funzioni.........................................................................................105

11.1 Generalità sui sottoprogrammi.............................................................................................10511.2 Funzioni............................................................................................................................... 105

11.2.1 Definizione di una funzione......................................................................................... 10511.2.2 Chiamata di funzione................................................................................................... 10611.2.3 Istruzione return........................................................................................................... 107

11.3 Semantica della chiamata e passaggio dei parametri per valore..........................................10811.3.1 Caratteristiche del passaggio per valore.......................................................................109

11.4 Regole di visibilità............................................................................................................... 10911.5 Funzioni void........................................................................................................................11111.6 Passaggio per riferimento.....................................................................................................11211.7 Parametri di tipo strutturato................................................................................................. 114

11.7.1 Parametri di tipo array..................................................................................................11411.7.2 Passaggio per riferimento costante...............................................................................11511.7.3 Parametri di tipo struct................................................................................................. 116

11.8 Intento e passaggio...............................................................................................................11711.9 Definizione e dichiarazione di funzioni............................................................................... 11811.10 Record di attivazione......................................................................................................... 11911.11 Considerazione finali sull'uso delle funzioni..................................................................... 12311.12 Esercizi...............................................................................................................................123

12 Ricorsione....................................................................................................................................12512.1 Definizioni ricorsive............................................................................................................ 12512.2 Ricorsione nei linguaggi di programmazione......................................................................12612.3 L'esempio del fattoriale........................................................................................................12612.4 Programmazione ricorsive ed altri esempi di funzioni ricorsive......................................... 129

12.4.1 Elevamento a potenza – metodo lento......................................................................... 130

Page 5: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

12.4.2 Elevamento a potenza – metodo veloce....................................................................... 13012.4.3 Scrittura di un numero in binario................................................................................. 13112.4.4 Calcolo della successione di Fibonacci........................................................................132

12.5 Ricorsione sugli array.......................................................................................................... 13212.5.1 Ricorsione a prefisso.................................................................................................... 13212.5.2 Ricorsione a suffisso.................................................................................................... 13312.5.3 Ricorsione “binaria”.....................................................................................................13312.5.4 Altri esempi di ricorsione sugli array........................................................................... 134

12.6 Confronti tra ricorsione ed iterazione.................................................................................. 13512.7 Esercizi.................................................................................................................................136

13 Puntatori e variabili dinamiche....................................................................................................13713.1 Il tipo di dato puntatore........................................................................................................137

13.1.1 Dominio e dichiarazione di puntatori...........................................................................13713.1.2 Operazioni supportate.................................................................................................. 138

13.2 Aritmetica dei puntatori....................................................................................................... 13913.3 Passaggio per riferimento e puntatori.................................................................................. 14013.4 Allocazione dinamica...........................................................................................................14213.5 Array dinamici..................................................................................................................... 14313.6 Esercizi.................................................................................................................................143

14 Liste ed altre strutture dati elementari......................................................................................... 14514.1 Array e liste..........................................................................................................................14514.2 Implementazione delle liste puntate unidirezionali............................................................. 146

14.2.1 Strutture dati.................................................................................................................14614.2.2 Inserimento all'inizio....................................................................................................14614.2.3 Inserimento in fondo.................................................................................................... 14714.2.4 Eliminazione del primo elemento................................................................................ 14714.2.5 Lettura da tastiera.........................................................................................................14814.2.6 Inserimento dopo un dato nodo....................................................................................14914.2.7 Inserimento prima di un dato nodo.............................................................................. 14914.2.8 Eliminazione dopo un dato nodo................................................................................. 150

14.3 Scansione ed altre operazioni di base.................................................................................. 15014.3.1 Scansione di una lista................................................................................................... 15014.3.2 Scrittura sullo schermo.................................................................................................15114.3.3 Calcolo della lunghezza............................................................................................... 15214.3.4 Somma degli elementi di una lista............................................................................... 15214.3.5 Ricerca di un nodo avente una data chiave.................................................................. 15214.3.6 Eliminazione di un nodo avente una data chiave......................................................... 15214.3.7 Copia di una lista..........................................................................................................15314.3.8 Ultimo elemento ed ennesimo elemento...................................................................... 154

14.4 Liste e ricorsione..................................................................................................................15414.4.1 Calcolo della lunghezza............................................................................................... 15514.4.2 Scrittura sullo schermo.................................................................................................15514.4.3 Eliminazione di un nodo avente una data chiave......................................................... 15514.4.4 Copia di una lista..........................................................................................................156

14.5 Cenni ad altre strutture dati dinamiche................................................................................ 15614.5.1 Pile............................................................................................................................... 15614.5.2 Code............................................................................................................................. 157

14.6 Esercizi.................................................................................................................................158

Page 6: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

1 Introduzione

1.1 Concetti di base

La programmazione è la scienza che ha come oggetto di studio le metodologie e gli strumenti usati per creare programmi adatti ad essere eseguiti da un computer. Tra i concetti principali della programmazione troviamo quelli di programma e di linguaggio di programmazione.

Per capire meglio tali concetti è utile introdurre un concetto più generale rispetto a quello di programma, cioè quello di algoritmo.

Un algoritmo è un procedimento finito mediante il quale un agente di calcolo è in grado di risolvere in maniera automatica un compito complesso.

Innanzitutto chiariamo cosa si intende per agente di calcolo (o esecutore).

Un agente di calcolo C è un dispositivo ideale dotato di due capacità:

• svolgere delle operazioni elementari (dette istruzioni di base dell'agente), il cui insieme è indicato con E;

• eseguire in maniera autonoma (o automatica) dei procedimenti finiti scritti con un linguaggio L a lui comprensibile ed espressi in termini delle istruzioni di base appartenenti a E.

L'agente di calcolo è l'esecutore materiale dell'algoritmo. L'agente di calcolo per antonomasia è il computer, ma è possibile immaginare algoritmi pensati per gli esseri umani (si pensi ai metodi imparati a scuola per svolgere le varie operazioni, quali l'addizione, la moltiplicazione, ecc.) o per dispositivi meccanici (attuatori, robot, ecc.). Infine un agente di calcolo puramente teorico molto studiato nell'informatica è la macchina di Turing.

È importante notare che oltre che eseguire le singole istruzioni di base, l'agente di calcolo deve essere in grado di eseguire dei programmi: ad esempio una semplice calcolatrice non può essere considerata un agente di calcolo perché non è in grado di svolgere che singole operazioni su richiesta dell'utente. Solo le calcolatrici programmabili possono essere equiparate ai computer.

Un compito complesso è un'operazione che deve essere compiuta dall'agente di calcolo C ma che non rientra in E. Un algoritmo è perciò un procedimento che permette ad C di svolgere tali operazioni non elementari e deve essere scritto in L in termini delle sole operazioni di E, altrimenti non sarebbe possibile la sua esecuzione da parte di C.

Possono esistere compiti complessi così difficili per C tali che non è possibile trovare un procedimento risolutivo. Tali compiti sono da considerare irrisolvibili per C. Un esempio di problema non risolubile mediante un computer sarà illustrato nel capitolo 8.

Poiché un computer è quasi esclusivamente utilizzato come strumento per l'elaborazione dati, tra i principali compiti complessi risolubili da un computer troviamo i problemi computazionali.

1.2 Problemi ed istanzeUn problema computazionale è definito mediante

• un insieme I di possibili combinazioni dei dati in ingresso,

• un insieme O di possibili combinazioni dei risultati in uscita,

Page 7: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

• una funzione o:IO che associa ad ogni possibile input i∈I il corrispondente output o(i)∈O.

Un problema computazionale è quindi un compito di elaborazione di dati, in cui C ottiene dei dati in ingresso, deve calcolare dei risultati e inviarli all'esterno. È importante capire che nella descrizione del problema non è descritto come ottenere i risultati dai dati, ma solo in che relazione stanno fra di loro.

Presentiamo ora alcuni esempi di problemi computazionali

1. Dati due numeri interi a e b, calcolare il loro massimo comun divisore (abbreviato in MCD(a,b)).

2. Dato un numero naturale n, determinare se n è un numero primo.

3. Dati i coefficienti a,b,c di un'equazione di II grado ax2+bx+c=0, determinare le soluzioni reali o stabilire che non ammette soluzioni

4. Data una sequenza finita di stringhe (ad esempio nominativi di persone), disporre le stringhe in ordine alfabetico.

5. Dato un grafo pesato (ad esempio una rete ferroviaria con le rispettive lunghezze dei collegamenti tra stazioni) e due vertici u e v (ad esempio la stazione di partenza e quella di arrivo) calcolare il percorso più breve che collega u a v.

Nel problema 1 l'insieme I corrisponde all'insieme di tutte le coppie di numeri interi (Z2), l'insieme O coincide con Z, mentre la funzione o(a,b) è il MCD di a e b.

Nel problema 2 l'insieme I corrisponde all'insieme dei numeri naturali N, l'insieme O è composto da due elementi { vero, falso }, mentre la funzione o(n) è definita dalle leggi

o(n)=vero se n è primo

o(n)=falso se n non è primo

Un problema in cui O={ vero, falso } (o simili) si chiama problema decisionale.

Nel problema 3 l'insieme I è l'insieme di tutte le terne di numeri reali a,b,c in cui a ≠0, l'insieme O è l'unione dell'insieme delle possibili coppie non ordinate di numeri reali distinti (nel caso vi siano due soluzioni distinte), dell'insieme dei numeri reali R (nel caso in cui vi siano due soluzioni reali coincidenti) e dell'insieme {⊥}, in cui il simbolo ⊥ significa che non vi sono soluzioni reali. La funzione o(a,b,c) è definita da

o(a,b,c)={x1,x2} se x1 e x2 sono soluzioni dell'equazione ax2+bx+c=0

o(a,b,c)=x1 se x1 è l'unica soluzione dell'equazione ax2+bx+c=0

o(a,b,c)=⊥ se l'equazione ax2+bx+c=0 non ammette soluzioni reali

Nel problema 4, I è l'insieme formato da tutte le sequenze finite di stringhe, O è il sottoinsieme di I formato dalle sole sequenze ordinate e la funzione o(s), in cui s è la sequenza da ordinare, è la sequenza s' che ha gli stessi elementi di s disposti però in ordine alfabetico.

Nel problema 5, I è l'insieme di tutti i grafi pesati e di tutte le coppie di vertici, O è costituito da tutti i percorsi possibili nei grafi presenti in I e o(G,u,v) è il percorso più breve dal vertice u al vertice v nel grafo G.

I problemi di quest'ultimo tipo sono detti di ottimizzazione, perché richiedono di trovare un elemento (in questo caso un percorso) che minimizza o massimizza una determinata funzione (nell'esempio minimizzare la lunghezza del percorso).

Dato un problema P, un'istanza di P si ottiene specificando una combinazione dei dati di ingresso. Un'istanza è quindi un elemento di I, a cui corrisponde l'elemento o(i) di O.

Ad esempio alcune possibili infinite istanze del problema 1 sono

Page 8: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

1. a=3, b=6

2. a=10, b=45

3. a=19, b=20

4. a=55, b=55

5. ecc.

1.3 Metodi per la descrizione degli algoritmiGli algoritmi possono essere descritti a parole, ad esempio per risolvere il problema 1 (calcolare il MCD di due numeri interi) si puo' utilizzare il procedimento descritto da Euclide nel settimo libro “Elementi”:

Si sottragga ripetutamente il più piccolo dei due numeri al più grande, fino a che i due numeri non diventano uguali. Il numero trovato è il massimo comun divisore dei due numeri di partenza.

Per avere una descrizione più precisa e e con una terminologia uniforme si preferiscono usare metodi più formalizzati. Tra questi i più diffusi sono i diagrammi di flusso e lo pseudo-codice.

1.3.1 Diagrammi di flussoIl diagramma è costituito da un insieme finito di nodi e da un insieme di frecce che collegano i nodi.

I nodi sono di quattro tipi

1. Ellisse: inizio e fine del diagramma

2. Rettangolo: istruzioni di calcolo

3. Rombo: biforcazioni del diagramma associate a condizioni (vero/falso)

4. Parallelogramma: istruzioni di ingresso o di uscita

Sono ammessi punti di raccordo a cui si può arrivare seguendo percorsi diversi.

Ogni nodo del diagramma, ad eccezione dei rombi e del nodo finale, ha un'unica freccia uscente che lo collega al nodo che lo segue nell'ordine di esecuzione.

Il nodo finale non ha frecce uscenti, mentre il nodo iniziale non ha frecce entranti. I nodi rombo hanno due frecce uscenti: una etichettata con SÌ e una con NO.

L'esecuzione di un diagramma di flusso parte con il nodo INIZIO e sono eseguite le istruzioni che si incontrano durante il percorso, passando da un nodo a quello successivo seguendo le frecce. Arrivando ad un nodo rombo, il percorso prosegue percorrendo l'arco etichettato con SÌ se la condizione è vera, quello con NO se la condizione è falsa.

I nodi di calcolo richiedono di effettuare dei calcoli e di assegnare il risultato alle variabili. Ciò corrisponde ad una o più operazioni dell'insieme E. La forma più utilizzata è:

X ← e

in cui X è una variabile ed e è un'espressione.

Le istruzioni di ingresso (contrassegnate dalla parola Leggi) consentono all'esecutore di ricevere dei dati dall'esterno, mentre quelle di uscita (contrassegnate dalla parola Scrivi) indicano all'esecutore di produrre all'esterno dei risultati.

L'esecuzione termina quando si arriva al nodo FINE.

Come esempio di diagramma di flusso vediamo l'algoritmo di Euclide per il MCD.

Page 9: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

È perfettamente lecito che in un diagramma di flusso ci siano nodi contenenti operazioni non elementari, purché sia noto (ad esempio con un altro algoritmo) come effettuare tali operazioni.

1.3.2 Pseudo-codifica

La pseudo-codifica utilizza un linguaggio testuale non troppo dissimile (almeno come struttura) rispetto ad un linguaggio di programmazione. Le versioni inglesi della pseudo-codifica hanno molte istruzioni simili a quelle presenti nei veri linguaggi di programmazione, come Algol, Pascal o C. Noi però utilizzeremo una versione italiana, avente le seguenti istruzioni:

1. Inizio, che segna l'inizio dell'algorimo.

2. Fine, che segna la fine dell'algoritmo.

3. Leggi, che ottiene dati dall'esterno.

4. Scrivi, che invia dati all'esterno.

Page 10: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

5. Assegnamento, in una forma simile a quella usata nei diagrammi di flusso.

6. Se condizione Allora istruzioni_1 Altrimenti istruzioni_2 Fine-se, che indica di eseguire le istruzioni tra Allora e Altrimenti (chiamate istruzioni_1) se la condizione è vera, le istruzioni tra Altrimenti e Fine-Se (istruzioni_2) se è falsa. In ogni caso l'esecuzione continuerà con l'istruzione successiva alla Fine-Se.

7. Mentre condizione Ripeti istruzioni Fine-Ripeti, che indica di eseguire ripetutamente le istruzioni tra Ripeti e Fine-Ripeti fintantoché la condizione resta vera, quando questa diventa falsa il ciclo termina e l'esecuzione continua con l'istruzione successiva alla Fine-Ripeti.

Come esempio di algoritmo in pseudo-codifica presentiamo ancora l'algoritmo di Euclide per il M.C.D.:

InizioLeggi a,bMentre a ≠ bRipeti

Se a>b Alloraa ← a-b

Altrimentib ← b-a

Fine-seFine-RipetiScrivi a

Fine

Anche in uno pseudo-codice possono comparire operazioni non elementari, se è noto come sia possibile eseguirle.

1.4 Proprietà degli algoritmiNon tutti i procedimenti risolutivi per un determinato compito complesso possono essere considerati soluzioni algoritmiche valide. Infatti un algoritmo sviluppato per risolvere un problema P mediante un agente di calcolo C deve avere tre proprietà fondamentali per essere ritenuto tale

1. eseguibilità

2. finitezza

3. correttezza

Se almeno una di queste proprietà non è verificata il procedimento non è un algoritmo accettabile. Una quarta proprietà auspicabile, ma non indispensabile, è l'efficienza.

1.4.1 EseguibilitàUn procedimento A si dice eseguibile da un agente di calcolo C se è scritto in modo tale che C sia in grado di eseguirlo in perfetta autonomia. A deve essere scritto in un linguaggio comprensibile a C e deve far riferimento solo ad operazioni eseguibili da C.

In A non ci devono essere istruzioni che richiedono particolari nozioni o abilità da parte di C, al di là di quelle viste per un agente di calcolo, né deve essere richiesto a C di prendere decisioni (al di là dei punti di scelta, che però sono decisioni che non sono a carico di C, ma dipendono dai dati in suo possesso).

Page 11: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

1.4.2 Finitezza e terminazioneUn procedimento A si dice finito se C, per eseguire A, utilizza una quantità finita di risorse di calcolo, qualunque siano i dati in input che riceve, ovvero qualunque sia l'istanza del problema P.

Tra le risorse usate da C per eseguire un procedimento le più importanti sono il tempo di calcolo e lo spazio in memoria. Il tempo di calcolo è approssimativamente indicato dal numero di passi che l'agente esegue, mentre lo spazio in memoria tiene conto dello spazio occupato dai dati elaborati.

Per garantire la proprietà di finitezza è sufficiente provare che il procedimento termina sempre dopo un numero finito (non necessariamente costante) di passi. Tale proprietà è detta di terminazione, ed è sufficiente perché si presuppone che ogni passo consumi una quantità finita di risorse.

La terminazione è fondamentale in quanto un procedimento termina solo quando ha prodotto tutti i risultati desiderati. Perciò se un procedimento non termina dopo un numero finito di passi, C non è in grado di restituire i risultati voluti e quindi non riesce a svolgere il compito per cui A era stato ideato.

Dimostrare la terminazione di un procedimento può essere complicato e vedremo una tecnica per provare la terminazione di un programma nel capitolo 8.

L'algoritmo di Euclide termina qualsiasi siano i numeri a,b>0 per il seguente motivo: ad ogni passo accade sempre che uno dei due numeri diminuisce, rimanendo comunque entrambi maggiori di zero. Dopo un numero finito di passaggi diventeranno uguali e il procedimento terminerà, nel caso peggiore ciò si verificherà quando saranno tutti e due uguali a 1.

1.4.3 CorrettezzaUn procedimento A si dice corretto per un problema P se i risultati prodotti coincidono da A con quelli previsti dalla funzione o nella definizione di P. In termini formali, A è corretto se, per ogni i∈ I, il risultato prodotto da A coincide con o(i).

È palese che un procedimento non corretto non è in grado di risolvere il problema per cui è stato designato e quindi non è utilizzabile.

Nel capitolo 8 vedremo delle tecniche dimostrative anche per la proprietà di correttezza.

L'algoritmo di Euclide è corretto perché ad ogni passo i due numeri sono modificati ma il loro MCD rimane inalterato. Quindi alla fine, essendo i due numeri uguali, il loro MCD coincide con essi, ma allo stesso tempo è rimasto uguale al MCD dei due numeri di partenza.

1.4.4 EfficienzaSe per un dato problema esiste un algoritmo A, allora è facile vedere che ne esistono infiniti: basta aggiungere istruzioni inutili ad A. Comunque per molti problemi possono esistere due o più algoritmi sostanzialmente diversi. In tali situazioni un criterio di scelta tra varie soluzioni algoritmiche si basa sul costo di ciascun algoritmo, preferendo quindi algoritmi con minor costo.

Un algoritmo A si dice efficiente (rispetto ad una risorsa di calcolo R) se l'agente di calcolo C usa la quantità minima possibile di R per eseguire A. In termini più precisi A è efficiente se non esiste un algoritmo A' che risolve P richiedendo una quantità minore di R per la sua esecuzione.

L'efficienza è una proprietà auspicabile ma non sempre realizzabile.

Innanzitutto è molto difficile trovare algoritmi efficienti (detti anche ottimi), per cui nella pratica si preferisce usare uno tra i migliori algoritmi esistenti, nel senso che l'algoritmo scelto è il più efficiente tra tutti quelli che sono stati ideati, potendo comunque succedere che in futuro siano scoperti algoritmi migliori.

Page 12: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Inoltre in alcune situazioni, possono esistere più algoritmi che sono sostanzialmente alla pari: in alcune istanze è meglio uno, in altre è meglio un altro, e così via. In questi casi si dovrebbe cercare di capire quali sono le istanze più frequentemente risolte e utilizzare un algoritmo che su tali istanze si comporta meglio.

Infine può succedere che ci si possa accontentare anche di algoritmi non completamente efficienti (subottimali), perché magari sono più semplici da scrivere. Tale scelta deve essere oculata, evitando comunque di usare algoritmi troppo inefficienti.

1.5 Introduzione al costo computazionale Il calcolo del costo di un algoritmo A, ideato per un problema P, in termini di una risorsa R non è semplice ma spesso è sufficiente avere una stima del costo piuttosto che l'andamento esatto.

Più in particolare anziché tentare di calcolare una funzione di costo c(i) per tutte le possibili istanze i di P, ci si accontenta di avere una funzione di costo c(d) che dipende dalle possibili grandezze d delle istanze di P.

Così facendo si ottiene un oggetto più facilmente calcolabile e confrontabile. Il problema è trovare una sintesi tra le tante (e in alcuni casi infinite) istanze aventi la stessa grandezza d.

La scelta più diffusa per la valutazione degli algoritmi è il costo nel caso peggiore, in cui c(d) è il costo dell'algoritmo quando tenta di risolvere la peggiore istanza possibile avente grandezza d.

Come esempio di applicazione di questa tecnica vediamo la valutazione di due algoritmi per risolvere il seguente problema:

Data una sequenza S di n numeri naturali, determinare l'elemento più grande di S.

Il primo dei due algoritmi, che chiameremo M1, è basato sulla definizione di elemento più grande: è quell'elemento che è maggiore o uguale a tutti gli altri elementi di S.

Un diagramma di flusso molto generico che illustra il procedimento è il seguente:

Page 13: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

In tale diagramma si usa l'operazione di estrazione di un elemento per volta da una sequenza (senza ripetizioni). Inoltre si noti che per controllare se un elemento è maggiore di tutti gli altri sono necessarie n-1 operazioni elementari di confronto.

Il secondo algoritmo, che chiameremo M2, si basa sul concetto di “massimo parziale”, cioè dell'elemento più grande tra quelli già estratti. M2 è illustrato dal seguente diagramma di flusso:

Page 14: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

In tale diagramma si usa un ulteriore operazione che controlla se esistono elementi non ancora estratti dalla sequenza.

L'algoritmo M1 ha un costo nel caso peggiore, in termini di confronti svolti, che cresce in maniera quadratica rispetto a n (il numero di elementi della sequenza). Infatti nel caso peggiore l'elemento più grande viene estratto all'ultimo tentativo e quindi occorrono complessivamente n(n-1) operazioni di confronto.

L'algoritmo M2 invece usa sempre solo n-1 confronti. Pertanto M2 è migliore di M1, in quanto usa una quantità nettamente inferiore di operazioni per risolvere lo stesso problema.

1.6 EserciziScrivere tramite diagramma di flusso o pseudo-codice degli algoritmi per i seguenti problemi

Page 15: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

1.1. Preparare la colazione.

1.2. Calcolare i coefficienti dell'equazione della retta y=mx+q che passa per due punti di coordinate (x1,y1) e (x2,y2) ricevuti come input.

1.3. Risolvere un'equazione di primo grado ax=b ricevendo come input i coefficienti sotto forma di numeri reali.

1.4. Risolvere un'equazione di secondo grado ax2+bx+c=0 ricevendo come input i coefficienti sotto forma di numeri reali.

1.5. Determinare il plurale di un sostantivo italiano a partire dalla sua forma singolare.

1.6. Calcolare xn mediante moltiplicazioni successive (esempio 54=5⋅5⋅5⋅5)

1.7. Addizionare due numeri intere visti come sequenze di cifre decimali (ipotizzare che abbiano la stessa lunghezza).

Page 16: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

2 Livelli di programmazione

2.1 Struttura di un elaboratoreUn computer convenzionale è organizzato secondo l'architettura di Von Neumann ed è composto da un processore, una memoria centrale, una o più memorie secondarie, vari dispositivi di ingresso/uscita e un bus che collega tra di loro le varie componenti.

Il processore è la parte del computer che esegue i programmi e le sue componenti principali sono l'unità di controllo (CU), l'unità logica-aritmetica (ALU) e i registri.

La CU riceve, decodifica le istruzioni e predispone la loro esecuzione attivando le componenti necessarie. L'ALU esegue le operazioni aritmetiche (come ad esempio l'addizione) e le operazioni logiche (come ad esempio l'operazione AND). Infine i registri sono delle piccole memorie di transito in cui il processore memorizza i dati su cui sta lavorando. Tra i registri assume particolare importanza il registro PC (program counter) il quale contiene l'indirizzo dell'istruzione corrente, cioè quella che il processore sta eseguendo.

La principale tipologia di memoria centrale è la RAM, che è la parte del computer che serve a memorizzare i programmi in esecuzione ed i relativi dati. E' organizzata in celle di uguale grandezza in bit, ognuna delle quali è identificata da un indirizzo (di solito numeri naturali consecutivi) e può contenere un numero intero (o in maniera equivalente una sequenza di bit).

In una memoria di tipo RAM sono possibili due operazioni: la lettura e la scrittura. L'operazione di lettura ha come operando un indirizzo i e restituisce come risultato il contenuto della cella con tale indirizzo.

Ad esempio nella RAM seguente:

indirizzo 0 1 2 3 4 5 6 7 8 9 ...

contenuto 35 32 44 71 0 13 8 99 123 45 ...

la lettura della cella 3 produce come risultato il valore 71.

L'operazione di scrittura ha due operandi, un indirizzo i e un valore v, e come effetto modifica la cella di indirizzo i sostituendo il contenuto attuale con v. Tale sostituzione è irreversibile.

Ad esempio scrivendo il valore 88 nella cella 5 si ottiene la situazione:

indirizzo 0 1 2 3 4 5 6 7 8 9 ...

contenuto 35 32 44 71 0 88 8 99 123 45 ...

La RAM è la memoria più veloce (a parte la memoria cache ed i registri) a disposizione del processore, in quanto le operazioni di lettura e di scrittura avvengono in tempi abbastanza rapidi in confronto alle altre operazioni svolte dal processore. Lo svantaggio maggiore è quello di essere volatile: il contenuto delle celle è conservato fintantoché la memoria è alimentata dalla corrente.

Tra le memorie secondarie segnaliamo i dischi magnetici (floppy disc, hard disc), i dischi ottici (CD, DVD, ecc.), le memorie di tipo flash (pen drive), ecc. Tali memorie sono utilizzate per

Page 17: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

contenere grandi quantità di dati e in maniera persistente (cioè in grado di mantenere il contenuto anche senza la presenza di corrente e in alcuni casi al di fuori del computer stesso). I dati nelle memorie di massa sono solitamente organizzati in file.

I principali dispositivi di ingresso (cioè quelli con cui è possibile ricevere dati dall'esterno) sono la tastiera, il mouse e il microfono. I principali dispositivi in uscita (cioè quelli con cui è possibile inviare dati all'esterno) sono il monitor, gli altoparlanti e la stampante. Infine quelli di ingresso/uscita sono il modem e la scheda di rete.

2.2 Il linguaggio macchinaIl processore è in grado di eseguire solo programmi scritti in un linguaggio particolare, chiamato linguaggio macchina. Ogni istruzione è codificata mediante un numero naturale univoco, stabilito in sede di progettazione del processore.

Scrivere programmi mediante codici numerici risulta essere estremamente scomodo, per cui si utilizza normalmente una versione “leggibile” del linguaggio macchina, detta linguaggio assemblativo (assembly), in cui ogni istruzione è rappresentata simbolicamente mediante codici mnemonici (ad esempio parole inglesi per esteso o abbreviate). Ad esempio l'istruzione mov eax, ebx che indica di copiare il contenuto del registro ebx nel registro eax, si usa al posto del corrispondente codice numerico.

Per avere un'idea della struttura di un programma in linguaggio macchina (da ora in avanti abbreviato in L.M.) riportiamo un'implementazione dell'algoritmo di Euclide per il processore Pentium, supponendo che sia memorizzato in RAM a partire dalla cella numero 1000.

1000 mov eax, (2000)1004 mov ebx, (2004)1008 cmp eax, ebx1012 jeq 10361016 jlt 10281020 sub eax, ebx1024 jmp 10081028 sub ebx, eax1032 jmp 10081036 mov (2008), eax1040 ret

Ogni istruzione è preceduta dall'indirizzo in cui è memorizzata, ad esempio l'istruzione mov eax, (2000) si trova nella locazione di memoria 1000. Si noti che ogni istruzione occupa 4 celle di memoria.

Le prime due istruzioni caricano nei registri eax e ebx i due numeri di cui calcolare il MCD, supponendo che essi siano memorizzati nelle locazioni 2000 e 2004.

L'istruzione all'indirizzo 1008 confronta il contenuto di eax con il contenuto di ebx.

L'istruzione successiva si chiede se i due dati confrontati sono uguali e in tal caso salta (rimanda l'esecuzione del programma) all'istruzione di indirizzo 1036.

Se invece i contenuti dei due registri non sono uguali, l'esecuzione prosegue con l'istruzione 1016, la quale si chiede se il contenuto del registro eax è minore di quello del registro ebx.

In caso affermativo l'esecuzione del programma continua all'istruzione 1028, altrimenti con l'istruzione successiva (1020).

Page 18: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Le istruzioni 1020 e 1028 svolgono compiti simili: la 1020 sottrae dal registro eax il contenuto di ebx e mette il risultato della sottrazione in eax, la 1028 effettua la stessa operazione scambiando i ruoli di eax e di ebx.

Le istruzioni 1024 e 1032 saltano all'istruzione 1008.

Infine l'istruzione 1036 copia il contenuto del registro eax nella locazione 2008 che conterrà quindi il risultato (il MCD dei due numeri) e l'istruzione successiva “termina” l'esecuzione del programma.

In un comune linguaggio macchina esistono vari tipi di istruzioni:

1. Istruzioni logico-aritmetiche, che sono eseguite dall'ALU. Esempi di tali istruzioni visti nel programma sono sub e cmp, tra le altre troviamo add, mul, and, or, ecc.

2. Istruzioni di caricamento da e verso la memoria centrale, che servono a copiare dati dai registri verso la RAM o viceversa. Tra queste istruzioni possono esservi anche quelle che gestiscono una parte della memoria con una struttura a pila (stack). Un esempio di tale istruzione è mov che può copiare dati da una locazione di memoria verso un registro, da un registro verso una locazione di memoria o da registro a registro.

3. Istruzioni di I/O, mediante le quali il processore comunica con le periferiche inviando dati verso un dispositivo o ricevendo dati da esso.

4. Istruzioni di controllo, che alterano l'esecuzione del programma. Normalmente l'esecuzione di un programma avviene in modo sequenziale. Tale andamento può essere modificato attraverso le istruzioni di salto, le quali semplicemente modificano il contenuto del registro PC. Esse si distinguono in salto incondizionato (istruzione jmp), in cui il salto avviene sicuramente, e salto condizionato (istruzioni jeq e jlt), in cui il salto avviene solo se una determinata condizione (specificata nell'istruzione stessa) si verifica. Un'altra istruzione di controllo è la chiamata a sottoprogramma, di cui parleremo brevemente nel capitolo 11.

2.3 Caratteristiche negative del L.M.Dall'esempio precedente, ma soprattutto da un'analisi più approfondita dei L.M., che in questa sede non è possibile svolgere, si possono trovare alcuni aspetti negativi della programmazione in tali linguaggi.

Innanzitutto il L.M. è molto “rudimentale”, ovvero le istruzioni elementari sono relativamente poche e hanno come operandi solo oggetti fisici del computer (celle di memoria, porte di I/O, registri, ecc.). Infatti ogni istruzione praticamente corrisponde ad una parte di hardware o, al limite, di firmware, il che limita fortemente il numero e la complessità delle istruzioni e che rende pressoché impossibile estendere o modificare il L.M. di un processore esistente.

Un'immediata conseguenza è la mancanza di astrazione: un programma in L.M. deve utilizzare solo istruzioni e dati previsti dalla progettazione del processore per cui tutti gli algoritmi devono essere scritti in modo da rientrare in questi vincoli. Ad esempio in un processore a 16 bit, i numeri interi a 32 bit devono essere trattati usando due registri o due celle di memoria e non possono essere considerati come un'unica entità. Allo stesso modo le operazioni su numeri a 32 bit vanno scomposte in operazioni su più celle o più registri. Non vi è modo di vedere i dati e le operazioni in maniera astratta.

Una caratteristica negativa ulteriore è quella della scarsa componibilità: l'unica forma possibile di composizione di istruzioni è la sequenza. Ad esempio non è possibile tradurre un'espressione complessa come A+B*C direttamente in L.M. ma è necessario scomporla in un'operazione di moltiplicazione B*C e in una successiva operazione di addizione tra A e un opportuno operando

Page 19: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

(registro o cella di memoria) in cui è stato salvato il risultato precedente. Non vi è modo per il programmatore di “concatenare” le istruzioni di calcolo senza necessariamente scegliere il collegamento da usare. Allo stesso modo non esiste alcun modo di comporre cicli o strutture condizionali se non utilizzando sequenze di istruzioni e salti.

Queste ed altre caratteristiche rendono particolarmente difficile il compito dei programmatori in L.M.. Vi è però un altro grave problema in tale linguaggio.

Infatti un programma scritto in L.M. presenta una portabilità scarsa o nulla, nel senso che difficilmente sarà eseguibile in una piattaforma diversa da quella per cui è stato scritto. Innanzitutto tra due processori diversi vi sono solitamente insuperabili problemi di incompatibilità per i rispettivi L.M. dovuti a differenze architetturali. Ma anche a parità di processore, usare un programma con un sistema operativo diverso da quello previsto potrebbe non essere possibile. Infine un'altra causa di incompatibilità potrebbe nascere in quei programma in L.M. che accedono direttamente a componenti hardware, per cui anche semplicemente cambiando la configurazione esterna del sistema (cambiando ad esempio la scheda grafica) il programma non funziona più correttamente.

La mancanza di portabilità fa sì che ogni volta che si cambia la configurazione hardware o software in cui un programma deve essere eseguito c'è il rischio che il programma debba essere riscritto o modificato pesantemente. Non è possibile pensare a soluzioni in L.M. che siano in qualche modo indipendenti dalla piattaforma.

2.4 La programmazione ad alto livello ed i paradigmi di programmazionePer superare le difficoltà e le caratteristiche negative del L.M. fin dagli anni cinquanta sono stati definiti dei linguaggi di programmazione ad alto livello. Essi non hanno la struttura tipica dei L.M. di un computer reale, ma sono organizzati in modo da essere più vicini ai linguaggi usati dall'uomo, come ad esempio la notazione algebrica per le espressioni o l'utilizzo di forme tipiche dei linguaggi naturali.

L'idea alla base dell'utilizzo di un qualsiasi linguaggio di programmazione è quella di liberare il programmatore dai problemi relativi all'uso del L.M. in modo da migliorare, anche in maniera considerevole, la facilità di scrittura e di mantenimento del codice e la produttività.

Usare un linguaggio ad alto livello consente al programmatore di lavorare con una macchina astratta, più generale e molto più semplice da programmare, in cui si riesce meglio a concentrarsi sugli aspetti algoritmici piuttosto che sugli aspetti tecnici del L.M..

Per consentire l'esecuzione di un programma scritto con un linguaggio siffatto vi è però bisogno di una fase di traduzione, illustrata nella sezione 2.X, in quanto nessun computer sarebbe in grado di eseguire direttamente programmi del genere.

Sono stati ideati un numero impressionante di linguaggi di programmazione, con grandi differenze in successo, diffusione e reale utilizzo. Esistono comunque un discreto numero di linguaggi che hanno avuto una certa rilevanza nell'informatica. Tra questi si possono distinguere i linguaggi orientati ad alcuni tipi di applicazioni (ad esempio Fortran per le applicazioni numeriche o Cobol per quelle gestionali) da quelli general-purpose (come ad esempio C++ o Java), cioè sufficientemente generali da poter essere usati in tutti i tipi di applicazione.

Una classificazione molto importante è data dal paradigma a cui si attengono i linguaggi. Il paradigma corrisponde al modo di intendere i concetti di programma e di programmazione.

Esistono tre tipi paradigmi fondamentali: imperativo, funzionale e logico.

Page 20: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

2.4.1 Il paradigma imperativoNel paradigma imperativo le istruzioni sono visti come comandi che la macchina deve eseguire. La macchina possiede uno stato interno (una sorta di memoria) e la maggior parte dei comandi hanno lo scopo di modificare lo stato della macchina. Lo stato è composto da variabili modificabili attraverso le istruzioni di assegnamento. La maggior parte dei linguaggi prevedono istruzioni strutturate di tipo condizionale e di tipo iterativo. I programmi sono solitamente composti da vari sottoprogrammi. Un livello più alto di strutturazione dei programmi è ottenuto, in alcuni linguaggi, medianti i moduli.

Molti linguaggi di programmazione famosi appartengono a questa categoria: Fortran, Cobol, Algol, Basic, PL/I, Pascal, C, Ada.

I linguaggi imperativi definiscono una macchina astratta avente una struttura interna non troppo distante dalla struttura di un reale calcolatore, per cui la traduzione di un programma scritto in un linguaggio imperativo è più semplice e più efficace. Infatti i programmi che necessitano di alte prestazioni sono normalmente implementate mediante linguaggi imperativi.

2.4.2 Il paradigma funzionaleNel paradigma funzionale le istruzioni sono visti come espressioni che la macchina deve valutare. In un linguaggio funzionale puro non esiste il concetto di stato e di variabile modificabile. Come tale non ha nemmeno senso l'utilizzo di strutture iterative, che sono sostituite dalla ricorsione. I programmi sono composti dalla definizione di funzioni, le quali possono anche essere di ordine superiore, cioè restituire come risultato altre funzioni.

Tra i linguaggi più noti in questa categoria vi sono Lisp, APL, Haskell, Miranda, ML e CaML.

La macchina astratta definita nei linguaggi funzionali ha una struttura sostanzialmente diversa da quella di una macchina convenzionale, rendendo così difficile una traduzione efficiente. I linguaggi funzionali hanno però alcuni vantaggi rispetto a quelli imperativi, ad esempio sono più adatti ad elaborazioni di tipo simbolico e in generale le soluzioni scritte in questi linguaggi sono più compatte ed eleganti rispetto alle loro versioni nei linguaggi imperativi.

2.4.3 Il paradigma logicoNel paradigma logico le istruzioni sono visti come interrogazioni a cui la macchina deve rispondere mediante un processo di deduzione. Un'interrogazione può prevedere una risposta sì/no (ad esempio “Luca è il padre di Laura ?”) oppure una risposta più articolata (ad esempio “Chi sono i figli di Simona ?”). Un programma è diviso in fatti e regole. I fatti denotano verità già note, mentre le regole permettono di dedurre nuove verità.

Le idee di programma e di istruzione in un linguaggio di tipo logico sono molto lontane dai rispettivi concetti tradizionali, infatti un programma logico è essenzialmente dichiarativo, cioè lascia molta libertà all'esecutore su come deve essere eseguito. Questo aspetto è molto diverso da quello tipicamente procedurale dei linguaggi imperativi e funzionali. Lo stesso processo di traduzione di un programma logico non è semplice e solo in tempi relativamente recenti sono stati proposti dei metodi non troppo inefficienti di traduzione.

Il linguaggio di programmazione logica per antonomasia è Prolog. Comunque un approccio dichiarativo si riscontra anche in SETL, Snobol e infine anche in SQL, il linguaggio di gestione dei database.

Page 21: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

2.4.4 Il paradigma ad oggettiUn paradigma trasversale è quello ad oggetti. E' possibile definire linguaggi funzionali ad oggetti (ad esempio OcaML e Ruby) e linguaggi logici ad oggetti, ma è molto più frequente trovare linguaggi imperativi ad oggetti (ad esempio Java, C++, C#). In questo tipo di linguaggi è possibile creare e manipolare oggetti, che sono componenti aventi uno stato interno e delle operazioni definite su di essi. Molti linguaggi richiedono la definizione di classi come tipologie di oggetti. Alcuni degli aspetti peculiari sono l'incapsulamento (restringere le operazioni che si possono svolgere sugli oggetti), l'ereditarietà (definire classi a partire da classi esistenti) e il polimorfismo. L'approccio ad oggetti presenta molti risvolti positivi nella costruzione di programmi, anche di grandi dimensioni e da parte di più sviluppatori, per cui ormai la maggior dei linguaggi ideati recentemente sono ad oggetti, come lo sono anche le versioni più recenti di linguaggi esistenti (ad esempio Fortran 95).

2.5 La traduzioneCome è già stato detto nelle sezioni precedenti, per superare la “barriera linguistica” che si crea tra il programmatore, che scrive i programmi usando un linguaggio ad alto livello, ed il computer, che è in grado di eseguire solo programmi scritti nel proprio L.M., è indispensabile una fase in cui il programma ad alto livello è tradotto in L.M. per essere eseguito.

Esistono due modalità di base per effettuare tale traduzione: la compilazione e l'interpretazione.

Normalmente ogni linguaggio può essere utilizzato con una qualsiasi delle tecniche di traduzione, ma è prassi che ogni linguaggio abbia una forma preferita di traduzione.

È comunque possibile creare meccanismi misti di traduzione, come avviene di norma per il linguaggio Java, in cui si usa una compilazione seguita poi da un'interpretazione.

2.5.1 La compilazioneNella compilazione si usa un programma, detto appunto compilatore, che riceve in input il programma da tradurre S (detto sorgente, solitamente sotto forma di file) scritto nel linguaggio ad alto livello L e che produce come risultato un programma equivalente E (detto eseguibile) scritto completamente nel L.M. di un computer M.

Per “eseguire” S basterà far eseguire E da M.

Gli aspetti positivi della compilazione sono notevoli.

Innanzitutto la traduzione è quindi svolta una sola volta prima dell'esecuzione, in una fase separata. Una volta ottenuto il programma E, questo può essere eseguito da M tante volte quante occorrono, anche senza avere a disposizione il sorgente S. Però ogni volta che S viene modificato, è necessaria una nuova fase di traduzione per aggiornare E.

La traduzione di S può essere fatta in maniera da rendere E il più efficiente possibile, mediante una o più fasi di ottimizzazione del codice. Tali fasi, che rendono più lenta la traduzione, hanno il vantaggio che possono invece rendere molto più veloce l'esecuzione di E.

Le prestazioni dei programmi compilati sono così buone che molti linguaggi convenzionali (Fortran, Cobol, Pascal, C, C++) sono normalmente utilizzati con la compilazione.

Un compilatore dipende da due parametri: il linguaggio di “partenza” L e la macchina di “destinazione” M. Normalmente M coincide con la macchina in cui si esegue la compilazione, ma nel caso della cross-compilazione queste due possono essere diverse.

Si noti che lo schema della traduzione per compilazione si può anche usare per tradurre da un linguaggio L1 verso un altro linguaggio L2, entrambi ad alto livello. Ad esempio è possibile costruire un traduttore automatico da Pascal a C.

Page 22: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Nella sezione successiva vedremo in maggiore dettaglio le varie fasi della compilazione di un programma.

2.5.2 L'interpretazioneNell'interpretazione si usa un programma, detto interprete, che riceve in input il programma S, scritto nel linguaggio L, e l'eventuale input x per S e che produce come risultato quello che produrrebbe S a partire da x.

Detto in altri termini un interprete è un esecutore di programmi scritti in L. Dato che L è un linguaggio ad alto livello, l'esecuzione diretta è impossibile e quindi è necessario tradurre S nel linguaggio macchina di M (la macchina che esegue l'interprete).

La modalità di traduzione è sostanzialmente diversa da quella usata nella compilazione: un interprete traduce ed esegue immediatamente un'istruzione alla volta senza memorizzarne la traduzione. Alla fine perciò non produce nessun file eseguibile.

Ogni istruzione è tradotta ogni volta che viene eseguita. Quindi da un lato, le istruzioni che non sono eseguite non sono tradotte, invece nella compilazione il programma è tradotto per intero, anche se viene eseguito solo in parte. D'altra parte se un'istruzione è eseguita più volte (ad esempio perché è all'interno di un ciclo) viene ritradotta ogni volta ex novo.

In conclusione, l'esecuzione tramite interpretazione è molto più inefficiente della compilazione: l'esecuzione è ritardata dalla traduzione e i cicli sono particolarmente colpiti da questo fenomeno.

È comunque vero che usando un interprete non vi è nessun tempo di attesa: appena l'interprete entra in funzione, il programma inizia ad essere tradotto ed eseguito subito. Invece con un compilatore il programmatore deve aspettare che l'intero programma sia stato tradotto, prima di poterlo avere in esecuzione. Questa caratteristica degli interpreti è particolarmente utile in ambiente interattivi, quali le shell dei sistemi operativi o le interfacce utente dei programmi di calcolo interattivi matematici e statistici, in cui l'uso dell'interpretazione è molto diffuso.

Si noti inoltre che addirittura il sorgente potrebbe essere modificato in corso di esecuzione, tranne che l'istruzione in corso. Comunque tale possibilità è poco utile nella pratica.

2.6 Le fasi della compilazioneLa compilazione di un programma si svolge in varie fasi:

1. analisi lessicale

2. analisi sintattica

3. traduzione in assembly

4. ottimizzazione del codice

5. traduzione in linguaggio macchina (creazione del file oggetto)

6. collegamento con librerie ed altri file oggetti (creazione del file eseguibile)

L'analisi lessicale del sorgente scompone il programma in una sequenza di token: parole chiave, identificatori, operatori e simboli (parentesi, punteggiatura, ecc.) e costanti.

Ad esempio l'istruzione

if(x>4) a=3;

è scomposta nella sequenza

Page 23: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

parola chiave if

parentesi (

identificatore x

operatore >

costante intera 4

parentesi)

identificatore a

operatore =

costante intera 3

punto e virgola

L'analisi sintattica effettua una scansione del sorgente in versione tokenizzata utilizzando le regole grammaticali del linguaggio ottenendo una scomposizione in base alle funzioni grammaticali degli elementi.Nell'esempio precedente produrrebbe la struttura:

La generazione del codice assembly produce una prima traduzione letterale mediante regole di traduzione (del tipo l'istruzione di assegnamento si traduce con un'istruzione mov, ecc.). Sia prima che dopo tale traduzione possono essere effettuate delle fasi di ottimizzazione del codice. Tali fasi riscrivono il codice in maniera da aumentare la velocità di esecuzione, pur producendo lo stesso risultato. Non è difficile immaginare che una traduzione letterale possa produrre codice inefficiente, dato che ogni istruzione è tradotta senza tenere conto delle altre. L'ottimizzazione potrebbe ridurre o eliminare tali inefficienze riorganizzando in maniera opportuna il programma in assembly.

Il risultato finale potrebbe essere equiparabile o al limite superiore ad una versione di S scritta direttamente in L.M. da un programmatore esperto. Infatti in alcuni casi le tecniche di ottimizzazione si spingono oltre le normali capacità umane di sfruttamento delle peculiarità del processore.

La traduzione in L.M. produce un programma scritto in L.M., detto programma oggetto, ma non completo e quindi non eseguibile dalla macchina. Infatti il programma oggetto potrebbe mancare dei collegamenti esterni, verso le librerie, contenenti funzioni già tradotte e rese disponibili al programmatore, o verso altri programmi oggetto (traduzioni in L.M. di altri sorgenti facenti parte dello stesso programma complessivo). Il primo tipo di collegamenti esterni è quello più diffuso: è praticamente impossibile scrivere un programma che non faccia uso di funzioni presenti nelle librerie, ad esempio in C tutte le istruzioni di ingresso/uscita sono implementate nelle librerie. Il secondo tipo di collegamenti è necessario nel caso della compilazione separata, di cui si discuterà nel capitolo 11. I collegamenti esterni sono lasciati in sospeso durante la fase di produzione dei programmi oggetto.

E' quindi indispensabile una fase finale, detta di collegamento (linking) e svolta da un programma chiamato linker, che ha lo scopo di risolvere i collegamenti esterni, completando le istruzioni che ne fanno uso. Alla fine di tale fase si ottiene un unico file eseguibile mediante fusione dei programmi oggetti ed eventualmente delle librerie, pronto per essere eseguito dalla macchina.

istruzione if

condizione istruzione

maggiore_di assegnamento

variabile costante variabile costante

Page 24: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

2.7 Gli strumenti della programmazioneTra gli strumenti software utilizzati nella programmazione, oltre ai compilatori e agli interpreti, i più importanti sono gli editor, gli ambienti di esecuzione e i debugger.

Gli editor sono programmi che consentono al programmatore di scrivere in maniera efficace i programmi sorgenti. Gli editor moderni effettuano alcuni controlli di sintassi delle istruzione durante la digitazione (ad esempio tramite il syntax highlighting), evitando così alla fonte alcuni errori di sintassi, e aiutano il programmatore mediante di meccanismi di completamento del codice.

Gli ambienti di esecuzione consentono di far eseguire i programmi compilati, anche in maniera controllata. I debugger permettono modalità di esecuzione diverse dal solito:

• esecuzione “un passo alla volta”, in cui è possibile eseguire una singola istruzione, vedere il contenuto delle variabili, eventualmente modificarle e ripartire con l'istruzione successiva

• esecuzione con “break-point”, in cui è possibile inserire dei punti di fermata (break-point) all'interno di un programma, eseguirlo normalmente, controllare cosa succede al break-point e poi ripartire

• tracciare il valore delle variabili

Gli ambienti integrati di sviluppo (IDE) sono infine degli insiemi di programmi che comprendono, tra l'altro, editor, compilatore, ambiente di esecuzione e debugger e che permettono di gestire un programma dalla digitazione del codice all'esecuzione tutto all'interno dello stesso sistema.

Page 25: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

3 La sintassi dei linguaggi di programmazione

Forniremo in questo capitolo alcuni concetti di base sui linguaggi formali, per poi introdurre il formalismo di Backus-Naur nella versione estesa (EBNF) che sarà utilizzato in queste dispense per definire la sintassi del linguaggio di programmazione C++.

3.1 Definizioni generali: stringhe e linguaggi

Un alfabeto è un insieme finito di simboli.

Esempi di alfabeti sono l'insieme delle cifre binarie {0,1}, quello delle cifre decimali {0,1,2,3,4,5,6,7,8,9}, quello delle lettere maiuscole {A,B,C,...,Z}. Un alfabeto molto utilizzato nell'informatica è il set dei caratteri ASCII.

Una stringa su un alfabeto A è una sequenza finita di simboli di A.

La stringa vuota, cioè quella composta da zero simboli, si indica con ε.

L'insieme di tutte le stringhe su A si indica con A*.

Ad esempio se A={0,1} allora A*={ ε, 0, 1, 00, 01, 10, 11, 000, 001, ..., 110, 111, 0000, ...}

La lunghezza di una stringa w è il numero di simboli di cui w è composta (contando anche le ripetizioni) e si indica con |w|.

Ad esempio | abcbd | = 5. Chiaramente | ε |=0.

Date due stringhe u e v, la concatenazione di u con v è la stringa indicata con u ⋅ v che si ottiene prendendo prima tutti i simboli che compongono u e poi di seguito quelli che compongono v. Chiaramente | u ⋅ v | = | u | + | v |. Ad esempio 011 ⋅ 10 = 01110, mentre 10 ⋅ 011 = 10011.

L'operazione di concatenazione è un'operazione associativa, ovvero u ⋅ (w ⋅ v) = (u ⋅ w) ⋅ v . Ad esempio sia con (011 ⋅ 10) ⋅ 11= 01110 ⋅ 11=0111011 che con 011 ⋅ (10 ⋅ 11)= 011 ⋅ 1011=0111011 si ottiene lo stesso risultato. Invece non vale in generale la legge commutativa, come dimostra l'esempio 01 ⋅ 10 = 0110, mentre 10 ⋅ 01 = 1001. La stringa vuota agisce da elemento neutro della concatenazione in quanto u ⋅ ε = ε ⋅ u=u per ogni stringa u.

Date due stringhe u e v, si dirà che u occorre in v se esiste una sottosequenza di simboli consecutivi di v che corrisponde elemento per elemento a u. Ad esempio la stringa 011 occorre in 1101101, la stringa 00 occorre in 01001101 e in 01001100. Si noti che 00 occorre due volte in 01001100.

Un linguaggio su A è un qualsiasi sottoinsieme di A* . Alcuni esempi di linguaggi sono

1.su A={a,b,c} il linguaggio delle stringhe con al massimo 5 simboli

2.su A={1,2,3,4} il linguaggio delle stringhe che viste come numeri naturali sono minori di 2000 (ad esempio1234 e 1442 lo sono, mentre 2112 non lo è).

3.su A={0,1} il linguaggio di tutte le stringhe che terminano per 0

4.su A={0,1} il linguaggio di tutte le stringhe palindrome (cioè che lette da sinistra a destra sono uguali si ottiene lo stesso risultato che leggendole da destra verso sinistra, ad esempio 11011).

Page 26: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Si noti che i linguaggi 1 e 2 sono finiti ed è possibile elencarne tutte le stringhe. Invece i linguaggi 3 e 4 sono infiniti, quindi per specificarli è necessario descriverli a parole o usare un meccanismo formale. Un meccanismo di definizione dei linguaggi che useremo è quello generativo mediante una grammatica formale.

Il concetto chiave di grammatica è quello di produzione.

Una produzione è una coppia di stringhe, indicata con u→v. Una produzione u→v è applicabile ad una stringa w se u occorre in w. La produzione genera una o più stringhe che si ottengono da w sostituendo una sola volta la stringa u con la stringa v, in un qualsiasi punto in cui u occorre in w.

Con w w' si indica il fatto che la stringa w' si ottiene da w mediante l'applicazione di una data produzione. Ad esempio data la produzione 10→ 01 si ha che 010001011 001001011 ed anche

010001011 010000111. Mentre a partire da 010001111 si ha solo 010001111 001001111.

In generale a partire da una stringa w si possono generare più stringhe, se la parte sinistra u della produzione occorre più volte in w.

3.2 GrammaticheUna grammatica è definita mediante quattro componenti:

● T, alfabeto di simboli terminali

● N, alfabeto di simboli non terminali

● S, un particolare simbolo di N

● P, un insieme finito di produzioni; in ogni produzione u→v, u e v sono stringhe su NT e u deve contenere almeno un simbolo non terminale

Una grammatica (T,N,S,P) genera il linguaggio su T formato da tutte (e sole) le stringhe w di T che si ottengono da S mediante un numero finito di applicazioni delle produzioni.

Ad esempio la grammatica avente alfabeto terminale T={ a,b,c}, alfabeto non terminale N={S,A,B}, simbolo iniziale S e produzioni { S→ AB, S→ A, A→ a, A→ bB, B→ c} genera il linguaggio { a, bc, ac, bcc }. Infatti

1.la stringa a si ottiene da S A, A a

2.la stringa bc si ottiene da S A, A bB, bB bc

3.la stringa ac si ottiene da S AB, AB aB, aB ac

4.la stringa bcc si ottiene da S AB, AB bBB, bBB bcB, bcB bcc

5.non è possibile ottenere altre stringhe su T

La sequenza di stringhe intermedie che si ottengono per arrivare alla stringa voluta si chiama derivazione. Ad esempio una derivazione di bcc è la sequenza S, AB, bBB, bcB, bcc.

Per generare linguaggi infiniti è necessario avere alcune produzioni ricorsive, ovvero quelle in cui la parte sinistra occorre nella parte destra. Ad esempio per generare il linguaggio delle stringhe su {0,1} che iniziano per 1 e che contengono poi solo il simbolo 0 {1, 10, 100, 1000, 10000, … } si può usare la grammatica

Page 27: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

{ S→ 1, S→ 1A, A→ 0, A→ 0A }.

Ad esempio la stringa 1000 si ottiene mediante S 1A, 1A 10A, 10A 100A, 100A 1000.

Le costanti intere decimali senza segno esprimibili in C possono essere 0 o devono iniziare per una cifra diversa da 0. Una grammatica che genera tale linguaggio è data dalle produzioni

{ I→ C, I→ DJ, J→ C, J→ CJ, D→1, D→2, …, D→9, C→0, C→D}.

Ad esempio 15 si ottiene attraverso questa derivazione I, DJ, 1J, 1C, 1D, 15.

3.3 Grammatiche libere dal contesto ed alberi di derivazione

Una grammatica è libera dal contesto se in ogni produzione u→v si ha che |u|=1, ovvero che u è formato da un solo simbolo non terminale e da nessun simbolo terminale.

Un linguaggio è libero dal contesto se è generato da una grammatica libera dal contesto. I linguaggi liberi dal contesto sono molto utilizzati nell'informatica, oltre che nella linguistica.

Data una grammatica libera dal contesto G si può definire (almeno) un albero di derivazione per ogni stringa w appartenente al linguaggio generato da G. In realtà l'albero, come dice il nome, è associato alla derivazione di w.

La radice dell'albero è il simbolo iniziale di G, mentre le foglie sono i simboli di w, disposti nell'albero da sinistra verso destra. Ogni nodo interno (cioè escluse le foglie) contiene un simbolo non terminale ed è collegato direttamente ai simboli presenti nel lato destro della produzione che è stata utilizzata per ottenere w.

Ad esempio per w=15 l'albero di derivazione è

I

D J

D

J

C1

5

Page 28: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Una grammatica si dice ambigua se esiste una stringa del linguaggio generato avente almeno due alberi di derivazione diversi.

Un esempio di grammatica ambigua è la grammatica che genera le espressioni (polinomiali) con le variabili x,y,z

{E→ (E), E→ V, E→ E+E, E→ E-E, E→ E*E, V→ x, V→ y, V→ z}

La stringa x+y*z ammette due alberi di derivazione

i quali corrispondono a due diverse letture dell'espressione corrispondente. L'albero (a) corrisponde ad interpretare l'espressione (x+y)*z, mentre l'albero (b) corrisponde a x+(y*z). Ovviamente la lettura corretta è la seconda, perché in matematica (e nei normali linguaggi di programmazione) la moltiplicazione ha priorità maggiore dell'addizione.

Per rendere non ambigua tale grammatica è possibile riorganizzare le produzioni. Altrimenti è possibile indicare, medianti ulteriori specificazioni, quale degli alberi di derivazione, ovvero quale delle interpretazioni possibili, è quello corretto. Nel caso delle espressioni è sufficiente indicare la priorità degli operatori.

3.4 Forma estesa di Backus e Naur (EBNF)Al posto delle grammatiche (libere dal contesto) si preferisce utilizzare una rappresentazione più compatta, ma perfettamente equivalente, ad una grammatica, chiamata forma estesa di Backus e Naur (Extended Backus Naur Form, EBNF).

I simboli non terminali sono sostituiti da nomi (stringhe) composti da più caratteri, mentre le stringhe di simboli terminali (anche i singoli simboli terminali) devono essere delimitati da virgolette. Il simbolo → è sostituito da ::=.

Per cui al posto delle produzioni I→ C, I→ DJ, D→1, D→2,..., D→9 si scriverà

E

E E

E EV

V Vx

+

y z

*

E

E E

V

z

*

E E

V V

x y

+

Page 29: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

costante_int_dec_senza_segno ::= cifra

costante_int_dec_senza_segno ::= cifra_non_zero resto_costante_int_dec_senza_segno

cifra_non_zero ::= “1”

cifra_non_zero ::= “2”

cifra_non_zero ::= “9”

In maniera più compatta tante produzioni aventi a sinistra lo stesso simbolo non terminale

u::=v1

u::=v2

u::=vn

possono essere accorpate in un'unica produzione indicata con la notazione

u ::= v1 | v2 | … | vn

Ad esempio

costante_int_dec_senza_segno ::= cifra | cifra_non_zero resto_numero

cifra_non_zero ::= “1” | “2” | … | “9”

In generale il simbolo | separa due o più alternative tra cui scegliere.

Le parentesi quadre servono ad indicare delle parti facoltative, cioè che possono essere presenti oppure no.

Ad esempio nella produzione

istruzione_if ::= “if” condizione “then” istruzione [ “else” istruzione ]

si intende dire che la parte composta da else e una seconda istruzione è una parte facoltativa. Quindi un'istruzione if legale può essere di due tipi: uno con else ed uno senza else.

Le parentesi graffe servono invece ad indicare delle parti che possono essere ripetute un numero arbitrario di volte.

Ad esempio nella produzione

sequenza ::= “begin” { istruzione “;” } “end”

si intende dire che una sequenza inizia con begin, contiene un numero imprecisato di istruzioni e termina con end.

3.5 Alcuni semplici linguaggi in EBNF

Il linguaggio delle costanti intere decimali è generato dalla seguente grammatica

costante_int_dec ::= [ “+” | “-” ] costante_int_dec_senza_segno

costante_int_dec_senza_segno ::= cifra | cifra_non_zero { cifra }

cifra ::= “0” | “1” | “2” | ... | “9”

cifra_non_zero ::= “1” | “2” | ... | “9”

Page 30: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Il linguaggio degli identificatori è generato dalla seguente grammatica

identificatore ::= lettera { ( cifra | lettera ) }

lettera ::= “_” | “A” | “B” | ... | “Z” | “a” | “b” | ... | “z”

cifra ::= “0” | “1” | “2” | ... | “9”

3.6 Esercizi3.1. Scrivere una grammatica per generare semplici frasi italiane del tipo soggetto verbo complemento oggetto, in cui il soggetto, il verbo e il complemento oggetto possono essere presi da un elenco piccolo

3.2. Scrivere una grammatica che descrive il linguaggio { a, b, c, xa, yb, zc, xxa, yyb, zzc, xxxa, yyyb, zzzc, ... }

3.3.Scrivere una grammatica che descrive il linguaggio delle stringhe palindrome sull'alfabeto { a,b} (cioè che sono uguali se lette in entrambe le direzioni, ad esempio abba o abababa).

3.4. Si renda non ambigua la grammatica delle espressioni polinomiali.

3.5. Scrivere in EBNF una grammatica per indicare la dichiarazione delle variabili in Pascal, che è fatta con la parola chiave VAR, una serie di identificatori separati da virgole, i due punti e il tipo.

Page 31: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

4 Tipi di dati, variabili ed espressioni

4.1 Concetto di tipo di datoI dati trattati dal computer sono classificati in categorie, chiamate tipi di dato. La classificazione è più importante di quello che si possa pensare a priori, in quanto la rappresentazione interna dei dati in un computer è fatta comunque mediante sequenze di bit e quindi dati appartenenti a tipi diversi sono di fatto indistinguibili. Per un computer è indispensabile distinguere in ogni situazione il tipo dei dati che sta trattando per effettuare le operazioni in modo corretto (ad esempio l'operazione di somma tra due numeri naturali è sostanzialmente diversa da quella per i numeri reali) ed evitare errori di tipo (ad esempio sommare un numero con un dato non numerico).

I linguaggi di programmazione si comportano rispetto ai tipi essenzialmente in due modi diversi:

• i linguaggi a tipizzazione statica controllano i tipi di dato a tempo di compilazione e quindi analizzano il tipo di tutti i dati ancora prima dell'esecuzione del programma; in tali linguaggi è indispensabile dichiarare anticipatamente i tipi delle variabili

• i linguaggi a tipizzazione dinamica invece controllano i tipi di dato solo durante l'esecuzione del programma; non richiedono che le variabili abbiano un tipo fisso, ma al contempo il controllo dei tipi rallenta l'esecuzione del programma.

Un tipo di dato T è identificato da un nome e definito mediante due insiemi D e I

● D è il dominio, cioè l'insieme dei valori possibili che può assumere un dato di tipo T

● I è costituito dalle operazioni di base, cioè l'insieme delle operazioni che sono supportate dal linguaggio.

Le operazioni di I sono le uniche operazioni di base ammesse dal linguaggio: ogni operazione che si vuole svolgere su un dato deve essere un elemento di I o si deve poter scrivere in termini degli elementi di I. Ad esempio in C++ è possibile accedere ai bit di un numero intero dato che tra le operazioni di base ci sono le classiche operazioni binarie (AND, OR e NOT). Non è invece possibile fare la stessa cosa in Pascal perché tali istruzioni non sono supportate.

4.2 Classificazione dei tipi di dato

I tipi di dato si possono innanzitutto suddividere in tipi elementari e tipi strutturati.

I tipi elementari sono costituiti da un unico dato, mentre quelli strutturati sono composti mediante aggregazione di più dati, che a loro volta possono essere strutturati o semplicemente elementari.

La suddivisione tra dati elementari e dati strutturati non è sempre ovvia e può dipendere dal linguaggio. Ad esempio nei linguaggi moderni le stringhe sono trattate come dati elementari, ma negli altri linguaggi (ad esempio il C) sono visti puramente come dati strutturati. I dati strutturati saranno descritti nel capitolo 9.

I dati elementari sono suddivisi in dati numerici e dati non numerici. Nel resto del capitolo faremo una panoramica dei tipi di dato elementare presenti in C++, trattando anche i tipi che sono presenti in altri linguaggi.

Page 32: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

4.2.1 Tipi di dati numericiI tipi di dati numerici si dividono ulteriormente in due grandi categorie: i tipi interi e i cosiddetti tipi reali. Tutti i linguaggi di programmazione supportano i tipi di dati numerici con le usuali operazioni aritmetiche e di confronto.

Tipi interi

I tipi interi servono a rappresentare numeri interi. Poiché i numeri interi sono infiniti, sarà possibile rappresentarne solo un sottoinsieme finito.

La rappresentazione più usata è quella in complemento a due con un numero fissato N di bit. In tale rappresentazione un bit serve per indicare il segno del numero intero mentre il resto serve ad indicare il valore assoluto del numero (seppure in maniera diversa per i numeri positivi o nulli rispetto a quelli negativi).

Con N bit si possono rappresentare i numeri interi che vanno da -2N-1 a +2N-1-1.

Ad esempio con N=16 si possono rappresentare i numeri compresi tra -32.768 e +32.767, mentre con N=32 i numeri tra -2.147.483.648 e +2.147.483.647.

In C++ si distinguono interi corti (short int), interi (int) e interi lunghi (long int). Rientrano tra i tipi numerici anche i caratteri (char). Il numero di bit usati non è specificato nello standard, però è possibile ordinare i tipi in base al numero di bit

char ≤ short int ≤ int ≤ long int

Le principali operazioni supportate in C++ sono

● operazioni aritmetiche +, -, *, /, %

● operazioni logiche sui bit <<, >>, &, |, ^, ~

● operazioni di confronto <, >, <=, >=, ==, !=

● funzione valore assoluto labs

L'operazione / restituisce il quoziente della divisione, quindi 7 / 2 fa 3 (e non 3.5 come in molti altri linguaggi), mentre l'operazione % restituisce il resto, quindi 7 % 2 fa 1. Esiste anche l'operatore unario – che restituisce come risultato l'opposto del proprio argomento, ad esempio -(-5) fa 5.

Le operazioni logiche operano sugli interi trattandoli come sequenze di bit:

● << effettua lo shift a sinistra,

● >> effettua lo shift a destra,

● & effettua l'operazione AND,

● | effettua l'operazione OR,

● ^ effettua l'operazione XOR,

● ~ effettua l'operazione NOT.

Le operazioni di confronto <= e >= corrispondono chiaramente a ≤ e ≥ rispettivamente, l'operatore == corrisponde all'uguaglianza (e non l'operatore =, che invece è l'assegnamento) e il != corrisponde alla disuguaglianza ≠.

Le costanti intere si possono inserire, oltre che in base 10, anche in base 8 o in base 16. Per usare la base 8 il numero deve iniziare per 0 e contenere solo cifre tra 0 e 7. Ad esempio 013 è il numero decimale 11. Per usare la base 16 il numero deve iniziare con 0x o 0X e contenere cifre tra 0 e 9 e tra A e F (anche in minuscolo). Le cifre tra A e F hanno valori da 10 a 15. Ad esempio 0x2A è il numero decimale 42.

Per distinguere le costanti di tipo long da quelle di tipo int il C++ richiede l'utilizzo del suffisso

Page 33: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

L. Ad esempio la costante 5L è di tipo long, mentre 5 è di tipo int.

In C++ è possibile anche usare numeri naturali, ovvero positivi o nulli, con i tipi unsigned int, unsigned short e unsigned long. Con N bit si possono rappresentare i numeri interi che vanno da 0 a +2N-1.

Ad esempio con N=16 si possono rappresentare i numeri compresi tra 0 e +65.535.

Analogamente a quanto visto per le costanti di tipo long, le costanti di tipo unsigned int hanno il suffisso U. Ad esempio la costante 5U è di tipo unsigned int.

Tipi reali

I tipi reali servono a rappresentare quelli che in matematica sono chiamati numeri razionali. In particolare saranno rappresentati solo un sottoinsieme finito. La rappresentazione più utilizzata è quella in base due a virgola mobile. Una parte dei bit a disposizione, detta mantissa, serve a rappresentare le cifre complessive del numero (sia quelle prima che quelle dopo la virgola), un bit della mantissa indica il segno, mentre la parte restante, detta esponente, indica la posizione della virgola all'interno del numero.

Il dominio dei tipi reali è un po' complicato da descrivere e si rimanda ad un testo specialistico per una sua descrizione dettagliata. Possiamo comunque dire che il dominio nella parte positiva è limitato superiormente da un numero massimo M ed inferiormente da un numero minimo m e analogamente nella parte negativa è compreso tra -M e -m. Essendo finito il dominio, non tutti i numeri reali in tali intervalli sono rappresentabili esattamente, gli altri possono essere rappresentati in maniera approssimata.

Le operazioni supportate in C sono ● operazioni aritmetiche +, -, *, /

● operazioni di confronto <, >, <=, >=, ==, !=

● funzioni matematiche fabs, pow, sqrt, ceil, floor, sin, cos, tan, exp, log, ...

L'operazione di divisione non produce il resto: ad esempio 7.0/2.0 fa 3.5.

La funzione fabs calcola il valore assoluto, sqrt la radice quadrata, sin il seno, exp la funzione esponenziale ex, log il logaritmo naturale, ecc. Tali funzioni hanno un argomento che deve indicato tra parentesi tonde. Ad esempio sqrt(2.0) calcola la radice quadrata di 2.

La funzione pow ha due argomenti x e y e calcola l'elevamento a potenza xy. Ad esempio pow(3.1, 5.0) calcola 3.15.

La funzione floor(x) restituisce la parte intera di x, ovvero il numero intero inferiore o uguale a x, ad esempio floor(3.7) fa 3.0, mentre ceil restituisce il numero intero superiore o uguale a x ad esempio ceil(3.7) fa 4.0.

Le costanti di tipo reale possono essere introdotte in due modi: la notazione standard e la notazione scientifica. La notazione standard prevede che il numero sia scritto secondo la grammatica

costante_reale_standard ::= [“+” | ”-”] sequenza_cifre “.” sequenza_cifre

sequenza_cifre ::= cifra { cifra }

Si noti che il punto e la parte decimale sono obbligatorie anche se il numero è intero. Ad esempio 3.14 o -7.0.

La notazione scientifica serve a scrivere in maniera compatta numeri molto grandi o molto piccoli, sia positivi che negativi. La grammatica di tale notazione è

costante_reale_scientifica ::= costante_reale_standard (“E” | “e”) costante_intera_decimale

Page 34: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Il numero è espresso mediante il prodotto tra la costante reale e 10 elevato alla costante intera.

Ad esempio 1.3e-4 corrisponde 1.3 10-4, mentre -1.2e+9 corrisponde -1.2 109.

Conversione di tipo

La conversione di un dato da un tipo di dato ad un altro è di solito un'operazione impossibile. Tra i casi in cui ciò ha senso vi sono quelli in cui si vuole convertire un dato numerico in un altro tipo numerico.

I tipi di dato numerici formano una gerarchia simile a quanto si riscontra nella matematica. In C++ si ha

short int ≤ int long int float double

ove con il simbolo T1 T2 intendiamo denotare che T1 è un sottotipo di T2. Essere sottotipo comporta che la conversione di un dato dal tipo T1 al tipo T2 è agevole: ad esempio è facile convertire un numero int in un numero long int. Dato che tale conversione è sempre possibile e produce un risultato corretto, in C++ quando è necessario è fatta automaticamente (tramite opportune istruzioni in L.M. inserite dal compilatore). Ad esempio nell'espressione 4+3.1 il numero int 4 è convertito in float prima di effettuare l'addizione.

La conversione da un tipo numerico ad un suo sottotipo è invece un'operazione che potrebbe essere impossibile o far perdere informazione. Ad esempio è impossibile convertire esattamente il numero 123456789 al tipo short int (a 16 bit), in quanto il massimo intero rappresentabile in quest'ultimo tipo di dato è 32767. Mentre la conversione da double a float fa perdere le ultime cifre decimali: convertendo il numero 4.25167536214367 in float produce il numero 4.25168.

Perciò in C++ tali conversioni devono essere richieste esplicitamente nel programma mediante gli operatori di cast. Il modo più semplice, valido anche per il C, è quello di anteporre al dato il tipo (scritto tra parentesi) a cui lo si vuole convertire. Ad esempio per convertire 2.0 in int si scrive (int) 2.0. Il risultato sarà il numero intero 2.

La conversione di tipo è possibile anche in altri casi, ad esempio con i puntatori, come si vedrà nel capitolo 13.

Altri tipi numerici

Alcuni linguaggi permettono di usare anche i numeri complessi. In C++ questo è possibile tramite la libreria standard complex.

Nelle applicazioni finanziare è essenziale lavorare con decimi e centesimi in maniera esatta ed è quindi necessario rappresentare i numeri reali in base 10 (tipo di dato decimal, presente in qualche linguaggio).

Infine in alcuni linguaggi è possibile usare numeri naturali arbitrariamente grandi (interi multiprecisione). Ad esempio in Java esiste il tipo di dato bignum.

4.2.2 Tipi di dati non numericiLa lista dei tipi di dati non numerici è una lista aperta, molto variabile da linguaggio a linguaggio. In C++ esistono alcuni tipi non numerici: bool, char, string, puntatori.

Tipo logico

Il tipo logico ha come dominio l'insieme { false, true } (o sue forme equivalenti) e ha tre operazioni di base {AND, OR, NOT}. In C tali operazioni sono indicate con i simboli && per AND, || per OR e ! per NOT. In C++ esiste il tipo di dato bool avente come costanti false e true, mentre in C non

Page 35: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

esiste un tipo di dato logico, dato che false è rappresentato con il numero intero 0 e true da qualsiasi numero intero diverso da 0 (solitamente 1).

Un dato di tipo logico rappresenta il valore di verità di una data affermazione, ovvero se è vera oppure no.

Le operazioni AND, OR e NOT servono a creare affermazioni complesse, costruite a partire da affermazioni di base.

Date due affermazioni P e Q, P && Q è la congiunzione di P e Q ed è vera se sia P che Q sono vere, invece è falsa se almeno una delle due è falsa. Ad esempio 4==3 && 5>2 è falsa in quanto 4==3 è falsa, mentre 4>=3 && 5>2 è vera in quanto sono vere sia 4>=3 che 5>2.

P || Q è la disgiunzione di P e Q ed è vera se almeno una tra P e Q è vera, invece è falsa quando sono entrambe false. Ad esempio 4==3 && 5>2 è vera in quanto 5>2 è vera, mentre 4==3 && 5<2 è falsa dato che sia 4==3 che 5<2 sono false.

Infine ! P è la negazione di P ed è vera solo quando P è falsa, mentre è falsa quando P è vera. Ad esempio ! 4==3 è vera, mentre ! 4>3 è falsa.

Indicando con x il valore di verità di P e con y quello di Q, per calcolare i valori di verità di P && Q, P || Q e ! P si usano le seguenti tabelle di verità:

x y x && y x || y ! x

false false false false truefalse true false true truetrue false false true falsetrue true true true false

Come è noto le operazioni && e || sono commutative e associative, && è distributiva rispetto a || e viceversa, vale la legge di De Morgan e della doppia negazione. In formule

● x && y = y && x, x || y = y || x

● x && (y && z) = (x && y) && z, x || (y || z) = (x || y) || z

● x || (y && z) = (x || y) && (x || z), x && (y || z) = (x && y) || (x && z),

● ! (x && y) = !x || !y, !(x || y) = !x && !y

● ! !x = x

Le operazioni && e || hanno un meccanismo particolare di valutazione chiamato corto-circuitazione come vedremo nella sezione 4.X.

Tipo carattere

Il tipo carattere serve a trattare singoli caratteri, di solito, appartenenti all'insieme dei caratteri ASCII o all'insieme Unicode.

In C++ esiste il tipo di dato char per i caratteri ASCII e il tipo wchar per i caratteri Unicode. In questo testo useremo solo i char.

A differenza di quanto avviene in molti linguaggi, in C++ il tipo carattere è in realtà un particolare tipo di dato numerico intero (i char usano 8 bit, i wchar 16 bit) che rappresenta direttamente il codice ASCII/Unicode del carattere.

Quindi il tipo char consente (almeno teoricamente) le stesse operazioni visti per gli int. Le uniche differenze sono che è possibile usare le costanti di tipo char: singoli caratteri racchiusi tra apici, ad

Page 36: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

esempio 'A', il cui valore è 65, oppure 'a', il cui valore è 97. Inoltre i char sono trattati come caratteri (e non come numeri) nelle operazioni di ingresso e di uscita.

Illustrazione 1: I caratteri “stampabili” nella parte standard dell'insieme ASCII

Le operazioni di confronto sono fatte in base al codice, il quale rispetta l'ordine alfabetico per le lettere maiuscole e minuscole. È da notare, come riportato dalla tabella che le cifre precedono le lettere maiuscole, che a loro volta precedono quelle minuscole.

Dato che tra ogni lettera maiuscola e la rispettiva lettera minuscolo vi è una distanza fissa di 32 unità, 'A'+32 fa proprio 'a', 'B'+32 fa 'b', ecc..

Invece la conversione tra un numero compreso tra 0 e 9 e la cifra corrispondente si può fare semplicemente aggiungendo '0' al numero: ad esempio 2+'0' fa '2'.

I char sono visti come numeri interi con segno a 8 bit, quindi compresi tra -128 e 127. Esiste però anche il tipo unsigned char.

Tipo stringa

Il tipo di dato stringa, presente ormai in tutti i linguaggi moderni, è un tipo di dato a metà strada tra i tipi elementari e i tipi strutturati. Si tratta di un tipo il cui dominio è A* con A l'insieme dei caratteri (ASCII o Unicode). Le operazioni più diffuse sono la concatenazione tra stringhe, l'estrazione di una parte della stringa e il calcolo della lunghezza.

In C++ tale tipo di dato si chiama string (o wstring per le stringhe di caratteri Unicode) e sarà descritto nella sezione 9.X. Le costanti di tipo stringa sono delimitate dalle virgolette, ad esempio:

“io sono una stringa”.

Tipo puntatore

Il tipo di dato puntatore serve a manipolare indirizzi di memoria o, più genericamente, riferimenti di variabili o di funzioni.

In C++ tale tipo di dato è dotato anche di alcune operazioni “aritmetiche” e sarà descritto nel capitolo 13.

0 1 2 3 4 5 6 7 8 930

33

! " # $ % & '40 ( ) * + , - . / 0 150 2 3 4 5 6 7 8 9 : ;60 < = > ? @ A B C D E70 F G H I J K L M N O80 P Q R S T U V W X Y90 Z [ \ ] ^ _ ` a b c

100 d e f g h i j k l m110 n o p q r s t u v w120 x y z { | } ~ �

Page 37: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

4.3 Costanti

Le costanti sono valori di un determinato tipo di dato che possono essere scritti direttamente all'interno di un programma. Ovviamente tali valori sono immutabili. Ad esempio scrivendo 5 all'interno di un'istruzione si intende usare il valore intero 5. Come si è visto le costanti devono essere specificate usando opportune regole grammaticali, che dipendono dal linguaggio.

Molti linguaggi consentono di creare costanti con nome, cioè attribuire un nome ad un valore costante ed utilizzare il nome al posto di tale valore.

Ad esempio è possibile definire una costante di nome pi_greco dal valore di 3.141592653589793, in modo che al posto di scrivere ogni volta tutta la sequenza di cifre, si possa semplicemente scrivere pi_greco. La modalità di definizione delle costanti in C++ sarà descritta nel prossimo capitolo.

In altri casi si usano costanti con nome per rendere parametrico un programma allo scopo di facilitare le operazioni di modifica. Ad esempio se in un programma si fa uso molteplice della percentuale dell'IVA conviene definire una costante iva con il valore 0.20, di modo che sia più semplice aggiornare il programma qualora la percentuale cambiasse.

4.4 Variabili

Le variabili sono entità che consentono di memorizzare, di recuperare ed eventualmente modificare un valore di un certo tipo di dato.

Le operazioni fondamentali che possono essere svolte su una variabile V sono l'accesso in lettura e quello in scrittura.

• La lettura recupera il valore memorizzato precedentemente in V e lo utilizza in un'espressione.

• La scrittura memorizza in V un nuovo valore, perdendo traccia del valore precedentemente memorizzato.

La proprietà fondamentale di una variabile è la persistenza: tutte le letture di una variabile V effettuate tra due operazioni di scrittura recuperano sempre lo stesso valore.

Le variabili sono presenti in tutti i linguaggi imperativi e sono implementate attraverso la memoria centrale o in casi eccezionali tramite i registri: a ciascuna variabile sono assegnate una o più celle di memoria (o registri) e tutte le operazioni di modifica e di accesso sono svolte mediante analoghe operazioni di scrittura e lettura della memoria.

Le caratteristiche principali delle variabili sono

1. nome

2. tipo

3. zona di visibilità

4. modo di allocazione

Il nome deve identificare univocamente la variabile all'interno dell'insieme delle variabili a cui si può accedere dal programma e quindi non possono coesistere variabili con lo stesso nome. Di norma il nome delle variabili è un identificatore la cui grammatica è stata data nella sezione 3.X.

Page 38: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Il tipo indica quali dati possono essere memorizzati nella variabile. Esistono linguaggi tipizzati dinamicamente in cui le variabili non hanno un tipo fissato, ma la maggior parte dei linguaggi compilati sono tipizzati staticamente, per cui ogni variabile è associata ad un tipo di dato e può contenere solo dati di quel tipo.

La zona di visibilità denota la parte di programma in cui una variabile può essere usata. Si distinguono variabili globali, che sono visibili in tutto il programma, e variabili locali, che sono visibili in una parte ristretta del programma, di solito delimitata in qualche modo.

L'allocazione è la modalità con cui viene gestita la parte di memoria associata ad una variabile. Esistono tre modalità principali: statica, automatica e dinamica.

Una variabile allocata staticamente resterà presente in memoria durante tutta l'esecuzione del programma. Si tratta della modalità più consona per le variabili globali, anche se in alcuni linguaggi, tra cui C++, è possibile avere variabili locali statiche.

Una variabile X ad allocazione automatica è creata quando la parte di programma in cui X è visibile inizia ad essere eseguita ed è eliminata dalla memoria quando tale parte finisce di essere eseguita. La creazione e la sua eliminazione sono svolte automaticamente dall'ambiente di esecuzione. Si tratta della modalità più adatta a gestire le variabili locali, in quanto permette di avere in memoria solo le variabili a cui si può accedere eliminando quelle che invece non sono più accedibili. Daremo maggiori dettagli nel capitolo 11.

Infine nell'allocazione dinamica è il programmatore attraverso delle opportune istruzioni a creare ed eventualmente ad eliminare le variabili dalla memoria tramite delle opportune istruzioni. Comunque in alcuni linguaggi moderni, ad esempio in Java, la eliminazione delle variabili allocate dinamicamente è svolta dall'ambiente di esecuzione, mediante un meccanismo chiamato garbage collection. L'allocazione dinamica richiede l'uso dei puntatori (o dei riferimenti) e la sua implementazione in C++ sarà descritta nel capitolo 13.

In molti linguaggi le proprietà delle variabili sono stabilite in un'opportuna istruzione di dichiarazione. In tali istruzioni il programmatore elenca tutte le variabili utilizzate, nel programma o in una sua parte, indicandone il nome, il tipo ed altre caratteristiche. Le restanti caratteristiche sono desunte dal traduttore mediante il contesto in cui si trova la dichiarazione. Ad esempio la zona di visibilità in C++, in Pascal o in linguaggi simili, è limitata al blocco in cui si trova la dichiarazione.

La dichiarazione delle variabili fa sì che il traduttore sappia quali sono le variabili presenti nel programma, riuscendo sia ad evitare errori di accesso a variabili inesistenti, sia a poter attribuire correttamente il tipo di ogni espressione. La sintassi per dichiarare variabili in C++ sarà fornita nel prossimo capitolo.

4.5 Espressioni

Le espressioni sono entità sintattiche che denotano valori. Il valore di un'espressione è calcolato mediante un processo di valutazione (che corrisponderà ad istruzioni effettivamente eseguite dalla macchina), a differenza di quanto avviene per le costanti (il cui valore è fisso) e per le variabili (il cui valore è memorizzato).

La grammatica che genera le espressioni è la seguente:

espressione ::= costante | variabile | “(“ espressione “)” |

opUn espressione | espressione opBin espressione |

funzione “(“ espressione { “,” espressione } “)”

opUn ::= “-” | “!” | “*” | ...

Page 39: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

opBin ::= “+” | “-” | “*” | “/” | “%” | “&&” | “||” | “<” | ...

funzione ::= “labs” | “sqrt” | “pow” | “log” | “exp” | ...

Gli operatori si distinguono per

• il numero di argomenti: quelli unari, con un argomento, tipo il – per cambiare di segno, e quelli binari, con due argomenti, come il +, sono quelli più frequenti

• la posizione rispetto agli operandi: quelli unari possono essere prefissi (precedono l'argomento) o postfissi (lo seguono), invece quelli binari sono di solito infissi (si trovano in mezzo ai due operandi)

• la precedenza: si dice che un operatore binario ⊗ ha precedenza su un operatore binario ⊕ se l'espressione x ⊕ y ⊗ z è interpretata come x ⊕ (y ⊗ z). Ad esempio il * ha precedenza sul +. In modo analogo un operatore unario † ha precedenza su un operatore binario ⊕ se l'espressione †x ⊕ y è interpretata come (†x) ⊕ y.

• L'associatività: si dice che un operatore binario ⊕ è associativo a sinistra se l'espressione x ⊕ y ⊕ z è interpretata come (x ⊕ y) ⊕ z. Analogo è il concetto di operatore associativo a destra.

La grammatica sopra elencata è ambigua e per ottenere un unico albero di derivazione per ciascuna espressione sintatticamente corretta si definiscono dei vincoli di precedenza. Ecco la tabella delle precedenza per i principali operatori presenti in C++ ed utilizzati in queste dispense, disposti in ordine decrescente di precedenza

1. operatori unari - ! *2. * / %3. + -4. < > <= >= 5. != ==6. &&7. ||

Dall'albero di derivazione dell'espressione eliminando i simboli non terminali è possibile ricavare un albero che rappresenta in maniera univoca l'espressione stabilendo l'ordine di applicazione delle operazioni e delle funzioni. Tale albero è essenziale per capire il meccanismo di valutazione delle espressioni (sezione 4.7).

Ogni nodo interno dell'albero contiene un'operazione ed è collegato ai suoi operandi. La radice dell'albero corrisponde all'operazione da svolgere per ultima, mentre le foglie corrispondono agli operandi più interni (costanti e variabili).

Ad esempio l'albero che rappresenta l'espressione y*z-3 è

Le parentesi alterano la normale precedenza tra le operazioni: una sottoespressione tra parentesi sarà valutata per prima. Ad esempio l'albero che rappresenta y*(z-3) è

y z

3

-

*

Page 40: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Infatti l'operazione da svolgere per ultima è il *.

4.6 Introduzione alla semantica operazionale: il concetto di stato

La semantica operazionale utilizza il concetto di macchina a stati. Una macchina a stati è un dispositivo ideale caratterizzato da uno stato interno. L'insieme dei possibili stati è chiamato spazio degli stati ed indicato con S. Useremo le lettere greche (σ ,τ,ρ,θ,...) per indicare gli stati.

Uno stato σ dipende dal valore delle variabili presenti nel programma ed è quindi rappresentato da un elenco in cui ad ogni variabile corrisponde il valore che assume.

Ad esempio con le variabili X, Y e Z di tipo int si possono formare un numero finito di stati, tra i quali vi possono essere

ρ= { (X,1),(Y,2),(Z,3) }

τ = { (X,2),(Y,2),(Z,3) }

ζ = { (X,-1),(Y,3),(Z,3) }

θ = { (X,1),(Y,1),(Z,-1) }

Il simbolo σ(X) denota il valore assunto da una qualsiasi variabile X in uno stato σ.

Ad esempio ρ(Y) =2, τ(Z)=3, ζ(X) =-1 mentre θ(X)=1.

Indicheremo con σ[X← v] lo stato che si ottiene da σ attribuendo alla variabile X il valore v, il quale deve appartenere al dominio di X, e lasciando inalterate tutte le altre variabili.

Ad esempio:

ρ[X← 2] = { (X,2),(Y,2),(Z,3) }

mentre:

θ[Z← 5] = { (X,1),(Y,1),(Z,5) }.

Chiaramente, se indichiamo con σ' lo stato σ[X← v], allora σ'(X)=v, mentre, per ogni altra variabile Y diversa da X, accade che σ'(Y)=σ(Y).

y

z 3

-

*

Page 41: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

4.7 Semantica operazionale delle espressioni

Il processo di valutazione di un'espressione e produce un valore che dipende dallo stato corrente dato che, in generale, in e possono comparire delle variabili.

Perciò indicheremo con (σ,e)→ v il fatto che la valutazione dell'espressione e nello stato σ produce come risultato il valore v.

Due regole molto semplici sono:

• se k è una costante, allora (σ,k)→ k

• se X è una variabile, allora (σ,X)→ σ(X)

Per le operazioni aritmetiche si usano le regole:

• se (σ,e1)→ v1 e (σ,e2)→ v2 allora (σ,e1+e2)→ v1+v2

• se (σ,e1)→ v1 e (σ,e2)→ v2 allora (σ,e1-e2)→ v1-v2

• se (σ,e1)→ v1 e (σ,e2)→ v2 allora (σ,e1*e2)→ v1⋅v2

La semantica dell'operatore / è un po' complicata

• se (σ,e1)→ v1, (σ,e2)→ v2, v1 e v2 sono int e v2 è diverso da 0, allora (σ,e1/e2)→ ⌊v1/v2⌋, ovvero il quoziente della divisione tra v1 e v2

• se (σ,e1)→ v1, (σ,e2)→ v2, v1 e v2 non sono tutte e due int e v2 è diverso da 0 allora (σ,e1/e2)→ v1/v2

Il processo di valutazione utilizza un ordine che è strettamente collegato l'albero associato all'espressione: parte dalle foglie, e proseguendo dal basso verso l'alto arriva alla radice. Ogni nodo interno può essere applicato solo quando tutti i suoi operandi sono stati valutati.

Ad esempio per valutare l'espressione 3*(x+1)-(y-3) nello stato ρ= {(x,1),(y,4),(z,7)}, tenendo conto che l'albero dell'espressione è

si hanno i seguenti passi di valutazione

1. (ρ,x)→1

-

-*

+3

x 1

y 3

Page 42: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

2. (ρ,x+1)→2

3. (ρ,3*(x+1))→6

4. (ρ,y)→2

5. (ρ,y-3)→-1

6. (ρ,3*(x+1)-(y-3))→7

Diremo che due espressioni e1 e e2 sono (semanticamente) equivalenti se per ogni stato σ, accade che (σ,e1)→ v se e solo se (σ,e2)→ v per lo stesso valore v.

Ad esempio le due espressioni x+y e y+x sono equivalenti in virtù del fatto che in matematica l'operazione di addizione è commutativa.

4.8 Valutazione pigra

Alcuni linguaggi prevedono per certi operatori una strategia diversa di valutazione, chiamata valutazione pigra (lazy). Esistono infatti delle operazioni che non sempre dipendono dai valori di tutti gli operandi.

In C++ le operazioni && e || sono valutate con un meccanismo di valutazione pigra chiamato corto-circuitazione.

L'idea è che se nell'espressione e1 && e2, se e1 ha valore false, allora non c'è bisogno di valutare e2

in quanto il risultato finale sarebbe comunque false. Se invece e1 ha valore true il risultato finale è esattamente il valore di e2.

Abbiamo quindi le seguenti regole di valutazione:

• se (σ,e1)→ false allora (σ,e1 && e2)→ false (in cui non viene valutato e2)

• se (σ,e1)→ true e (σ,e2)→ v allora (σ,e1 && e2)→ v

In modo analogo se nell'espressione e1 || e2, se e1 ha valore true, allora non c'è bisogno di valutare e2

in quanto il risultato finale sarebbe comunque true. Altrimenti il risultato è il valore di e2.

Le regole in questo caso sono:

• se (σ,e1)→ true allora (σ,e1 || e2)→ true

• se (σ,e1)→ false e (σ,e2)→ v allora (σ,e1 || e2)→ v

Deve essere chiaro che tale strategia di valutazione produce esattamente gli stessi risultati della valutazione usuale (chiamata stretta), solo che la valutazione pigra è più efficiente perché può risparmiare operazioni inutili.

In alcuni casi si possono evitare situazioni di errore. Ad esempio se σ = {(x,1),(y,0),(z,2)} l'espressione x<2 && 3/y==4 ha come risultato false, in quanto (σ,x<2)→false. Ma dato che (σ,3/y) non è definito (è una divisione per zero), il secondo operando di && non sarebbe definito a sua volta e quindi non sarebbe nemmeno definito il risultato finale dell'espressione

x<2 & 3/y==4

in cui & usa la strategia classica di valutazione stretta.

Page 43: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Anche l'operatore condizionale “?” usa una tecnica di valutazione pigra, come si vedrà nella sezione X.

4.9 EserciziScrivere l'albero di valutazione delle seguenti espressioni

4.1. x+y+z

4.2. x+y*z-5

4.3. (x+1)*(y+2)

4.4. labs(a)+b*5

4.5. sin(x+y)/(z+cos(4*x))

Page 44: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

5 Introduzione al linguaggio C++

5.1 Programma di esempio e sintassi di base

Per illustrare la sintassi di un programma in linguaggio C++ partiamo con un esempio semplice: un programma che calcola l'area di un rettangolo, ricevendo in input la base e l'altezza

1 #include <iostream> 2 using namespace std; 3 // programma per il calcolo dell'area di un rettangolo 4 int main() { 5 double base, altezza;

6 cout << “Inserisci la base “;

7 cin >> base;

8 cout << “Inserisci l'altezza “;

9 cin >> altezza;

10 double area;

11 area = base * altezza;

12 cout << “L'area e' “ << area << endl;

13 }

Le righe 5 e 10 sono dichiarazioni di variabili, le righe 6, 8 e 12 sono istruzioni di uscita (visualizzazione di messaggi e risultati sullo schermo), le righe 7 e 9 sono istruzioni di ingresso (acquisizione di dati da tastiera). La riga 11 contiene un'istruzione di assegnamento.

5.1.1 CommentiLa riga 3 è un commento, cioè una parte di programma che viene ignorata dal compilatore. In C++ è possibile inserire commenti in due modi

•commenti monolinea: iniziano con // e finiscono con la fine della riga

•commenti generali: iniziano con /* e finiscono con */, ad esempio /* programma per il calcolo dell'area di un rettangolo */

I commenti in genere sono inseriti in un programma come documentazione interna, ad esempio per descrivere e far capire meglio parti del programma, quali algoritmi e quali metodi sono stati usati, segnalare parti che non funzionano e incomplete, ricordare gli autori del programma, ecc.

Si possono anche far diventare commenti delle parti di programma, in modo da non farle compilare, senza però cancellarle.

5.1.2 Maiuscole e minuscoleIl linguaggio C++, al pari di C, Java e molti altri linguaggi, è case-sensitive, cioè non si ignora la

Page 45: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

differenza tra lettere maiuscole e lettere minuscole. Le parole chiave come int, if, while, ecc. vanno sempre scritte in minuscolo, come gli identificatore predefiniti, ad esempio main o cout. Gli identificatori definiti dal programmatore possono contenere sia lettere maiuscole che minuscole, però devono essere scritti sempre allo stesso modo: x1 e X1 sono considerati identificatori diversi.

5.1.3 Spazi e indentazioneIl compilatore ignora gli spazi all'interno di un programma (per spazi si intendono i caratteri di spaziatura, tabulazione e andata a capo), tranne nel caso in cui si trovano consecutivamente due tra identificatori e parole chiavi. Ad esempio in int x lo spazio tra int e x è obbligatorio.

Di conseguenza gli spazi all'interno di un programma servono in gran parte per migliorare la leggibilità del codice. Infatti è perfettamente possibile scriverea=1;b=a+2;if(a<3)c=b-1;else c=x+y;z=4;

anche se è completamente illeggibile.

Di conseguenza si adottano alcune convenzioni “stilistiche”.

Per prima cosa in ogni linea si scrive una singola istruzione e se un'istruzione è più lunga della larghezza media di un monitor è buona norma andare a capo di modo che sia visibile tutta contemporaneamente.

Le istruzioni all'interno dei costrutti, come le istruzioni composte (blocchi, strutture condizionali e iterative) e i corpi delle funzioni, sono scritte tutte incolonnate, ma più a destra rispetto alle altre.

Tale accorgimento si chiama indentazione e migliora la leggibilità del programma perchè così si vede a colpo d'occhio che le istruzioni sono “interne” al costrutto in questione. L'indentazione deve crescere all'aumentare della profondità di annidamento, cioè quando ci sono costrutti dentro altri costrutti, come nel caso di

if(x=3) {

a=4;

while(c<1) {

c *= 7;

b++;

}

}

E' poi utile aggiungere talvolta degli spazi tra gli operatori e gli operandi di un'espressione, ancora al fine di aumentare la leggibilità, come in

area = base * altezza;

invece di

area=base*altezza;

5.1.4 Struttura di un programmaIn generale un programma “elementare” sarà costituito da una parte iniziale (contenente alcune istruzioni che saranno chiarite in seguito) e da un blocco la cui sintassi è

blocco ::= “{“ { dichiarazione | istruzione } “}”

Nella parte iniziale troviamo

1. le istruzioni #include per “includere” le librerie usate nel programma

Page 46: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

2. l'istruzione using namespace std per evitare di usare il prefisso std:: davanti a molti elementi, ad esempio cout o string

3. La prima riga della definizione della funzione main, ovvero int main( )

In questo capitolo vedremo solo tre tipi di istruzione: assegnamento, ingresso e uscita.

5.2 Dichiarazione di variabili e costanti

Le variabili si dichiarano con la sintassi

dichiarazione_variabili ::= tipo identificatore [ “=” espressione ] { “,” identificatore [ “=” espressione ] } “;”

Tale sintassi permette di dichiarare più variabili dello stesso tipo, potendo per ognuna di esse specificare il valore iniziale, che deve essere un'espressione dello stesso tipo della variabile e deve coinvolgere solo variabili già dichiarate (e inizializzate).

Ad esempio con

int x, y=3, z;

si dichiarano tre variabili di tipo int di nomi x, y e z. La variabile y avrà valore iniziale 3, mentre per le altre due il valore iniziale è indefinito. Ciò in realtà vuol dire che tali variabili hanno un valore “a caso”, non prevedibile a priori.

Le costanti con nome si dichiarano con la sintassi

dichiarazione_costanti ::= “const” tipo identificatore “=” espressione { “,” identificatore “=” espressione } “;”

Ad esempio

const double pi_greco=3.14159;

const int num_regioni=20;

5.3 Semantica operazionale delle istruzioni

Nei linguaggi imperativi le istruzioni cambiano lo stato della macchina. Nella semantica operazionale il significato di un'istruzione è specificato mediante le transizioni di stato.

Una transizione di stato è indicata con la notazione

(σ,c)→ σ'

e significa che eseguendo l'istruzione c nello stato σ la macchina alla fine passa allo stato σ'.

La semantica di una data istruzione c è quindi data da tutte le transizioni di stato associate a c, cioè da tutte le (σ,c)→ σ' in cui compare c.

La semantica operazionale è una semantica che può essere utilizzata per specificare l'andamento di sistemi più complessi dell'esecuzione di semplici programmi. Comunque le regole di definizione delle transizione di stato di un linguaggio imperativo tradizionale fanno sì che per ogni istruzione c e per ogni stato σ esista al più uno stato σ' tale che (σ,c)→ σ'. Tale proprietà è detta determinismo

Page 47: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

in quanto lo stato finale della macchina è univocamente determinato da c e da σ.

Ci sono delle situazioni in cui invece per certi c e σ non esiste alcuno stato σ' tale che (σ,c)→ σ'. Questo accade se l'esecuzione di c nello stato σ non termina, oppure non è definita (ad esempio produce un errore durante l'esecuzione).

Diremo che due istruzioni c1 e c2 sono (semanticamente) equivalenti se per ogni coppia di stati σ e σ', la transizione (σ,c1)→ σ' accade se e solo se accade (σ,c2)→ σ'. In altri termini due istruzioni sono equivalenti se hanno lo stesso comportamento: partendo entrambe da uno stato conducono entrambe allo stesso stato.

In seguito vedremo esempi di istruzioni equivalenti.

5.4 Assegnamento

L'assegnamento è l'istruzione di base di ogni linguaggio imperativo. La sintassi della sua forma più semplice in C++ è

istruzione_assegnamento ::= variabile “=” espressione “;”

La variabile nella parte sinistra è detta l-value ed in generale, come vedremo nei capitoli successivi, può essere sostituita da una altri componenti del linguaggio (elementi di array e di strutture, accesso indiretto a puntatori, riferimenti).

L'espressione nella parte destra è detta r-value e deve essere dello stesso tipo della variabile o di un suo sottotipo, in modo da rispettare il vincolo che una variabile può contenere solo valori appartenenti al proprio tipo di dato.

La sua semantica dell'istruzione X=e è data dalla semplice regola

se (σ,e)→ v allora (σ,X=e)→ σ[X← v]

ovvero l'espressione e è valutata nello stato corrente σ e il valore v che si ottiene serve ad aggiornare il valore di X.

Nello stato prodotto dall'esecuzione di un assegnamento si perde completamente traccia del valore che aveva X in σ. Ciò fa sì che tale operazione sia irreversibile: una volta effettuata, non c'è modo di tornare indietro (a meno che il valore di X sia stato memorizzato in un'altra variabile, si veda a tal proposito l'esempio dello scambio di due variabili).

Il processo di valutazione di e precede l'aggiornamento del valore della variabile in memoria, per cui è perfettamente legittimo che la variabile X compaia anche nell'espressione e.

Ad esempio l'istruzione a=b+a*3 nello stato σ={(a,3),(b,2)} produce lo stato {(a,11),(b,2)} in quanto la valutazione dell'espressione b+a*3 ha come risultato 11. In ogni caso l'assegnamento non può essere confuso con l'uguaglianza: l'istruzione a=b, in cui a e b sono variabili dello stesso tipo, fa sì che a assuma il valore di b, mentre a==b si chiede se i due valori sono uguali.

Perciò (σ, a=b) →{(a,2),(b,2)} mentre (σ, a==b) →false.

Ad ulteriore conferma di questa netta differenza si noti che == è commutativo dato che a==b ha sempre lo stesso risultato di b==a, mentre = non lo è, ad esempio (σ, b=a) →{(a,3),(b,3)}. Infine si noti che l'uguaglianza si può fare tra espressioni generiche dello stesso tipo, mentre nell'assegnamento solo nella parte destra può comparire un'espressione: il confronto a+1==b è legittimo, l'assegnamento a+1=b no.

In C++ esistono anche altre forme di assegnamento. Innanzitutto per ogni operatore binario ⊕ (ad

Page 48: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

esempio ⊕ può essere +, -, *, /, …) esiste un operatore di assegnamento associato della forma ⊕=. L'istruzione

X ⊕= e

in cui X è una variabile ed e è un'espressione dello stesso tipo è equivalente all'istruzione

X =X ⊕ e

Ad esempio partendo dallo stato σ={(a,2), (b,3)} si ottiene rispettivamente

(σ,a += b*4) → {(a,14), (b,3)},

(σ,b *= a+1) → {(a,2), (b,9)},

(σ,b -= a) → {(a,2),(b,1)}.

Le due istruzioni di incremento unitario X += 1 e decremento unitario X -= 1 sono di uso così frequente che possono essere abbreviate in X ++ e X -- rispettivamente.

Ad esempio l'esecuzione di a++ produce lo stato {(a,3),(b,3)} mentre l'esecuzione di b-- produce lo stato {(a,2),(b,2)}.

Degli operatori ++ e - - oltre alla versione postfissa (in cui la variabile è posta prima dell'operatore) esiste anche la versione prefissa (in cui la variabile è posta dopo dell'operatore). Ad esempio ++a e - - b. Se considerate puramente come istruzioni, le versioni prefisse e postfisse sono equivalenti.

In realtà come vedremo nella sezione 5.7, le due versioni differiscono nel risultato che restituiscono, se sono considerate come operatori.

5.5 Istruzioni di Input/OutputLe istruzioni di input/output in un linguaggio consentono di far dialogare la macchina con il modo esterno, dall'interazione con l'utente, al salvataggio e recupero di dati dal disco fino allo scambio di dati via rete.

In C++ si usano due “flussi” predefiniti, chiamati cin e cout, i quali sono associati alla “console” (combinazione tastiera+schermo) dell'utente. E' però possibile svolgere tali operazioni anche su altri flussi associati ad altri dispositivi di input/output.

5.5.1 Istruzione di inputL'istruzione di input è svolta mediante l'operatore “>>” (chiamato estrazione) ed ha la sintassi

istruzione_lettura ::= flusso “>>” variabile { “>>” variabile } “;”

ove il flusso per usare la tastiera è appunto “cin”.

L'istruzione di lettura corrisponde ad un'istruzione di assegnamento in cui il valore da assegnare alla variabile è digitato dall'utente. La sua semantica è infatti

se l'utente digita il valore v, allora (σ,cin << X)→ σ[X← v]

Ovviamente v deve appartenere al dominio di X. Si noti che con questo modello di semantica tale istruzione non si può considerare deterministica, in quanto il suo esito dipende da un parametro esterno (il dato digitato dall'utente). In molti testi tale problema si risolve arricchendo lo stato della macchina con il flusso di dati disponibili in ingresso.

Page 49: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Si noti infine che un'istruzione di lettura da tastiera produce una pausa nell'esecuzione del programma fino a che l'utente non ha finito di digitare il dato (e premuto il tasto invio).

5.5.2 Istruzione di outputL'istruzione di output è svolta mediante l'operatore “<<” (chiamato inserzione) ed ha la sintassi

istruzione_scrittura ::= flusso “<<” ( manipolatore | espressione ) { “<<” ( manipolatore | espressione ) } “;”

manipolatore ::= “endl”

ove il flusso per scrivere sullo schermo è “cout”

L'istruzione di output scrive sullo schermo i risultati delle espressioni o modifica la modalità di visualizzazione tramite i manipolatori. L'unico manipolatore che vedremo in questo capitolo è endl che manda il cursore all'inizio della riga nuova: i dati sono infatti scritti sullo schermo in maniera consecutiva, anche con più istruzioni.

Ad esempio

cout << “A” << “B”;

cout << “C”;

scrive ABC sullo schermo, mentre

cout << “A” << “B” << endl;

cout << “C” << endl;

scrive sullo schermo le due righe

AB

C.

Nel modello di semantica qui utilizzato l'istruzione di output non cambia lo stato e quindi è equivalente ad un'istruzione vuota. Per modellare in maniera efficace l'effetto dell'istruzione di output occorrerebbe inserire nello stato il flusso di dati prodotto in uscita.

5.6 Sequenza di istruzioni e blocchiUn blocco è composto, come abbiamo visto nella sezione 5.1.4, da dichiarazioni e istruzioni. Se eliminiamo le dichiarazioni quello che si ottiene è una sequenza di istruzioni.

La semantica della sequenza è molto semplice. Iniziamo con la semantica di una sequenza formata da due istruzioni c1 e c2: la regola per ottenere la transizione di stato è

•se (σ,c1)→ σ' e (σ',c2)→ σ'' allora (σ,{ c1 c2 })→ σ''

ovvero prima viene eseguita nello stato iniziale σ l'istruzione c1 producendo uno stato intermedio σ' poi, in questo nuovo stato viene eseguita l'istruzione c2 ottenendo lo stato finale σ''.

La sequenzialità è proprio dovuta al fatto che c2 è eseguita non nello stato iniziale σ, ma nello stato prodotto da c1.

Ad esempio partendo dallo stato σ={(a,3),(b,7)}{

a=a+2;

Page 50: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

b=a-1;

}

si ottiene lo stato {(a,5),(b,4)} perché quando si esegue la seconda istruzione a ha già assunto il valore 5.

Più in generale per una sequenza di N istruzioni c1, c2, ..., cN la regola sarà

se (σ,c1)→ σ1, (σ1,c2)→ σ2, ..., (σN-1,cN)→σΝ allora (σ,{ c1 c2 ... cN})→ σN

Ad esempio per scambiare il valore di due variabili intere a e b si può usare il seguente codice

{

int c;

c=a;

a=b;

b=c;

}

5.7 Effetti collateraliIn queste dispense, per motivi esclusivamente didattici, abbiamo volutamente trascurato un aspetto importante delle istruzioni di assegnamento: infatti queste istruzioni possono essere utilizzate all'interno di un'espressione perché restituiscono anche un risultato, oltre che modificare lo stato.

L'operatore di assegnamento = produce come risultato il valore assegnato alla variabile. Ad esempio eseguendo l'istruzione

b=4*(a=3);

si ottiene il duplice effetto che a assume il valore 3 e b il valore 12.

Tale istruzione equivale a

a=3;

b=4*a;

Un utilizzo sensato è l'assegnamento multiplo di uno stesso valore a più variabili. Anziché scrivere

a=3;

b=3;

c=3;

si può scrivere in maniera più compatta

a=b=c=3;

Anche gli operatori misti del tipo +=, -=, ecc. restituiscono il valore assegnato.

Ad esempio eseguendo nello stato {(a,2),(c,3)} l'istruzione

b=4*(a+=c);

si ottiene lo stato {(a,5),(b,20),(c,3)}.

Page 51: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Gli operatori unari ++ e – hanno risultati diversi a seconda se sono prefissi o postfissi.

Il risultato di ++a è il nuovo valore di a, mentre quello di a++ è il vecchio valore di a. Per cui eseguendo nello stato {(a,4),(b,6)} le istruzioni

c=a++;

d=++b;

si ottiene lo stato {(a,5),(b,7),(c,4),(d,7)}.

La stessa cosa vale per l'operatore --.

Si sconsiglia vivamente di usare tale caratteristica degli operatori di assegnamento perché rende il programma poco leggibile senza per altro migliorarne sensibilmente l'efficienza.

In alcuni casi addirittura si creano situazioni di ambiguità: se a vale 3, l'espressione a - (++a) potrebbe valere -1 (ottenuto da 3-4) o 1 (ottenuto da 4-3) a seconda se il primo operando della sottrazione è valutato prima o dopo del secondo operando.

Si noti inoltre che per specificare la semantica di valutazione delle espressioni sarebbe necessario definire come risultato della valutazione una coppia (valore, nuovo stato) e introdurre il concetto di ordine di valutazione.

5.8 Esercizi

5.1.Scrivere un programma che legge i coefficienti a e b di un'equazione di primo grado ax=b e ne scrive la soluzione (supporre che a sia diverso da zero)

5.2.Scrivere un programma che legge i coefficienti a, b e c di un'equazione di secondo grado ax2+bx+c=0 e ne scrive le soluzioni (supporre che il delta sia maggiore o uguale a zero)

5.3.Scrivere un programma che legge il raggio r di una circonferenza e ne calcola l'area e la lunghezza

5.4.Scrivere un programma che legge il capitale C, il tasso di interesse i ed il tempo in anni t di un prestito e calcola il montante sia in regime di capitalizzazione semplice Ms=C(1+it), sia quello in regime di capitalizzazione composta Mc=C(1+i)t.

5.5.Dimostrare che la parte di programma che scambia due variabili a e b dello stesso tipo è corretta facendo vedere che partendo da uno stato in cui a ha come valore A e b ha come valore B si arriva ad uno stato in cui a ha come valore B e b ha A.

5.6.Scrivere un programma che legge tre variabili intere a, b e c e “ruota” i valori, in modo che b assuma il valore di a, c quello di b e a quello di c.

5.7.Scrivere un programma che legge le lunghezze dei tre lati di un triangolo a,b,c e ne calcola il perimetro e l'area S, quest'ultima tramite la formula di Erone S=√p(p-a)(p-b)(p-c), in cui p è il semiperimetro.

Page 52: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

6 Programmazione strutturata e strutture di controllo condizionali

6.1 Programmazione strutturata e teorema di Jacopini-Bohm

L'esecuzione sequenziale è la forma più semplice di struttura di un programma: eseguire una dopo l'altra una sequenza di istruzioni. Tale struttura è presente anche nei L.M.

L'esecuzione sequenziale può essere alterata nel L.M. mediante le istruzioni di salto incondizionato e condizionato. I salti incondizionati corrispondono alle frecce (non tra nodi consecutivi) in un diagramma di flusso, mentre i salti condizionati sono simili ai nodi romboidali.

Sequenza e salti si ritrovano anche nei primi linguaggi di programmazione, ad esempio Fortran. L'istruzione di salto incondizionato si chiama di solito GOTO e consente di proseguire l'esecuzione in un punto diverso del programma, normalmente indicato tramite un nome (etichetta) che deve comparire anche nell'istruzione GOTO. Un salto condizionato si ottiene mediante un'istruzione del tipo

IF condizione GOTO etichetta

Ad esempio un programma per calcolare il MCD si potrebbe scrivere in uno pseudo-Fortran

READ A,B

1 IF A=B GOTO 3

IF A<B GOTO 2

AA-B GOTO 1

2 BB-A GOTO 1

3 WRITE A

END

in cui 1,2,3 sono le etichette delle istruzioni destinazione dei salti.

L'uso eccessivo dei GOTO crea programmi con strutture complicate, difficili da seguire nel loro svolgimento e da capire, come si può già intuire nell'esempio precedente.

Già a partire dagli anni '60 furono introdotte nuove forme di struttura dei programmi, come quelle condizionali e quelle iterative. Ma comunque il GOTO è continuato ad essere ampiamente utilizzato per molti anni: uno dei problemi fondamentali è che i diagrammi di flusso portano naturalmente a pensare al GOTO.

Nel 1968 Dijkstra scrisse un articolo in cui criticava aspramente l'uso del GOTO nella programmazione mettendo in evidenza una serie di difetti e di problemi relativi alla presenza dei salti nei programmi. Tale articolo ebbe un'influenza enorme nello sviluppo successivo della programmazione, soprattutto diede importanza all'idea della programmazione strutturata, che poi ha avuto il sopravvento sulle altre forme di programmazione. In particolare la programmazione strutturata richiede l'uso esclusivo delle strutture di programmazione di base, eliminando completamente il GOTO o relegandolo al ruolo di “soluzione estrema”, cioè nei casi in cui non si riesce a fare altrimenti.

Ma è veramente possibile programmare senza usare il GOTO ?

Page 53: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

La risposta è sì, infatti nel 1966 C. Bohm e G. Jacopini dimostrarono matematicamente che ogni diagramma di flusso può essere riscritto in maniera strutturata, usando, oltre alla sequenza, solo due altre forme di organizzazione, mostrata nella figura sottostante.

Si noti che le tre forme organizzative hanno un unico punto di ingresso (segnato con un pallino nero) e un unico punto di uscita (pallino bianco). Quindi possono essere composte creando diagrammi arbitrariamente complessi, in cui non si hanno mai archi che si incrociano. Questa proprietà è fondamentale, in quanto consente di trattare tali parti di diagramma come se fossero istruzioni di base: ciò dà origine al concetto di istruzione composta o strutturata.

Mentre la prima forma è una struttura sequenziale, la seconda forma corrisponde ad una struttura condizionale, mentre la terza ad una iterativa.

In conclusione la programmazione strutturata chiede di evitare il più possibile il ricorso all'istruzione GOTO e al suo posto di utilizzare i tre costrutti fondamentali di:

1.sequenza

2.scelta

3.iterazione

Questi costrutti sono essenzialmente istruzioni composte, cioè istruzioni che al loro interno contengono altre istruzioni. Questo concetto (chiamato annidamento) può portare alla creazione, perfettamente legittima e sensata, di strutture arbitrariamente complesse, ad esempio una sequenza formata da un'istruzione elementare, una struttura condizionale a sua volta composta da un ciclo a

Page 54: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

sua volta composta da ecc.

Nei linguaggi moderni, sono supportate diverse forme di strutture condizionali e iterative, comunque il GOTO è tuttora presente. Nei linguaggi più recenti, ad esempio Java, il GOTO è stato addirittura eliminato.

In questo capitolo saranno introdotte le strutture condizionali presenti in C++, mentre nel capitolo successivo si parlerà delle strutture iterative.

6.2 Sequenza e istruzioni composteLa sequenza in C++ si ottiene mediante il costrutto chiamato blocco, già visto nella sezione 5.6. L'unica cosa da notare è che un blocco può essere considerato come un'unica istruzione, poiché tra le alternative di istruzione vi è proprio blocco. La produzione completa di istruzione è infatti

istruzione ::= blocco | istruzione_if | istruzione_switch | istruzione_while | istruzione_do_while | istruzione_for | “break” | “continue” | istruzione_return | espressione

Come vedremo in questo e nei prossimi capitoli, le istruzioni if, switch, while, do-while e for contengono al loro interno altre istruzioni e quindi possono essere viste come istruzioni composte.

Gli unici quattro tipi di espressione che ha senso usare come istruzione sono

1. istruzione di assegnamento

2. istruzione di input

3. istruzione di output

4. chiamata di funzione

Infatti sono le uniche tipologie di espressione che possono avere un effetto (sullo stato della macchina o sull'ambiente esterno).

6.3 Istruzione if-elseLa struttura condizionale più semplice è quella binaria, che si basa su una condizione, cioè su un'espressione di tipo bool, il cui risultato può essere soltanto vero o falso. La forma sintattica presente nella maggioranza dei linguaggi di programmazione è if-then-else. In C++ la parola chiave then non si usa, comunque la struttura è praticamente identica.

La sintassi dell'istruzione if-else è

istruzione_if ::= “if” “(“ espressione “)” istruzione [ “else” istruzione ]

Quindi esistono due forme, una con “else” ed una senza “else”. Iniziamo ad analizzare la prima forma.

La prima istruzione interna all'if viene chiamata “ramo then”, mentre la seconda “ramo else”.

La semantica dell'istruzione if(e) c1 else c2 è data dalle due regole

1. se (σ,e)→ true e (σ,c1)→ σ' allora (σ, if(e) c1 else c2)→ σ',

2. se (σ,e)→ false e (σ,c2)→ σ'' allora (σ, if(e) c1 else c2)→ σ''.

In termini informali, la condizione e è valutata, se è vera viene eseguita l'istruzione c1, altrimenti viene eseguita l'istruzione c2.

Un'istruzione if-else può essere descritta anche in termini del seguente diagramma di flusso

Page 55: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Ad esempio con

if(a>b)

max=a;

else

max=b;

si ottiene nella variabile max il più maggiore tra a e b.

Infatti nel caso in cui a sia maggiore di b, viene eseguita l'istruzione max=a che fa assumere alla variabile max il valore di a. Nel caso contrario, ovvero in cui b sia maggiore o uguale ad a, viene eseguita l'istruzione max=b che fa assumere alla variabile max il valore di b.

In entrambi i casi nella variabile max sarà memorizzato il più grande tra i due valori di a e b. Nel caso in cui tali valori fossero uguali, anche max assumerà questo valore.

Nella sintassi è prevista che una sola istruzione sia eseguita sia quando la condizione è vera, sia quando è falsa. Se in uno o in entrambi i casi occorre eseguire più istruzioni, è obbligatorio racchiudere le istruzioni in un blocco (cioè tra parentesi graffe). Ciò è accettato perché come abbiamo visto nel capitolo precedente, un blocco è considerato come una singola istruzione (seppur composta).

Per cui

if(a>b) {

max=a;

c=1; }

else {

max=b;

c=2; }

Se si omettono le parentesi graffe nel ramo “then” si ottiene un errore di sintassi

if(a>b)

max=a;

c=1;

Page 56: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

else {

max=b;

c=2; }

in quanto l'istruzione c=1 è considerata al di fuori dell'if, che quindi è ritenuta concluso e la successiva parte else non è attribuita a nessun if.

L'omissione delle graffe nel ramo “else” produce invece un programma sintatticamente corretto ma errato

if(a>b) {

max=a;

c=1; }

else

max=b;

c=2;

in quanto l'istruzione c=2 è considerata esterna all'if ed eseguita in ogni caso.

Per essere maggiormente sicuri ed evitare errori non è errato usare in ogni caso le parentesi graffe nei rami then ed else, anche se composti da singoli istruzioni.

In conclusione, la forma completa dell'istruzione if si usa in tutti quei casi in cui si devono avere due insiemi di istruzioni da eseguire in alternativa.

Invece la forma senza else si usa nei casi in cui si devono eseguire una o più istruzioni nel caso in cui la condizione è vera, ma non si deve eseguire niente se questa è falsa. In altri termini tali istruzioni sono “in più” rispetto a quelle che devono essere eseguite comunque.

La semantica di if(e) c1 si semplifica in

1.se (σ,e)→ true e (σ,c1)→ σ' allora (σ, if(e) c1)→ σ'

2.se (σ,e)→ false allora (σ, if(e) c1)→ σ

in cui la differenza è proprio che se e è falsa, non accade nulla e lo stato rimane inalterato.

Il diagramma di flusso corrispondente è

Ad esempio dopo aver letto tre variabili intere a,b,c per contare quante sono positive si può usare una variabile contatore, che è inizializzata a 0:

cin >> a >> b >> c;

int conta=0;

Page 57: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

if(a>0)

conta++;

if(b>0)

conta++;

if(c>0)

conta++;

cout << “hai inserito “ << conta << “ num. positivi\n”;

Si noti che un'istruzione if(e) c1 else c2 è equivalente a due istruzioni if senza else

if(e) c1;

if(!e) c2;

nel caso in cui c1 non alteri il valore della condizione e.

Ad esempio per calcolare il massimo di due numeri si può scrivere anche

if(a>b)

max=a;

if(a<=b)

max=b;

L'uso di tale forma è comunque da ritenersi una soluzione peggiore rispetto a quella che utilizza if-else, perché oltre ad essere più lunga, richiede di valutare due condizioni opposte.

6.4 If annidati e costrutto if-else-ifE' possibile inserire un'istruzione if all'interno un'altra if, dato che istruzione_if è una delle possibili alternative di istruzione.

Si noti che quando si mette un if dentro il ramo then di un'altra if, come

if(e1) {

...

if(e2)

c1;

...

}

l'istruzione c1 sarà eseguita quando sia e1 che e2 sono vere.

Mentre se la seconda if è nel ramo else, come in

if(e1) {

...

}

else {

...

if(e2)

Page 58: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

c1;

...

}

l'istruzione c1 è eseguita quando e1 è falsa e e2 è vera.

Ad esempio

if(a>0) {

c=1;

if(b>a)

d++; // eseguita se a>0 e b>a

}

else {

c=2;

if(b>a)

d--; // eseguita se a<=0 e b>a

}

L'annidamento degli if presenta un problema di sintassi della seguente situazione

if(e1)

if(e2)

c1

else

c2

infatti la grammatica descritta in queste dispense è ambigua e l'istruzione c2 potrebbe essere intesa come ramo “else” dell'if più interno o come ramo “else” dell'if più esterno (considerando che l'if più interno è nella forma breve senza else). Questa ambiguità è risolta in C++ poiché vige la regola che la parte else è sempre relativa all'ultimo if ancora aperto e quindi è valida la prima interpretazione.

Ad esempio in questa parte di codice che calcola il massimo di tre numeri interi x,y,z

if(x>y)

if(x>z)

max=x;

else // cioè se x>y ma z>=x

max=z;

...

Se invece si volesse avere un associazione if-else diversa (corrispondente all'altra interpretazione) bisogna racchiudere l'if più interno in un blocco, come in

if(a>3) {

if(b<5)

c++;

}

Page 59: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

else // cioè se a<=3

c--;

Un annidamento molto utilizzato è la costruzione if-else-if. A differenza di alcuni linguaggi in cui è presente proprio un costrutto if-then-else if-else distinto da if-then-else, in C++ si tratta solo di un particolare utilizzo del costrutto if-else, in cui si mette ogni if nell'else di quello precedente.

La costruzione ha la sintassi

if(e1)

c1

else if(e2)

c2

...

else if(en)

cn

else

c0

La semantica operazionale di tale istruzione, indicata con I, è

1. se (σ,e1)→ true e (σ,c1)→ σ' allora (σ, I)→ σ'

2. se (σ,e1)→ false, (σ,e2)→ true e (σ,c2)→ σ' allora (σ,I)→ σ'

3. ...

4. se (σ,e1)→ false, (σ,e2)→ false,.., (σ,ei-1)→ false, (σ,ei)→ true e (σ,ci)→ σ' allora (σ,I)→ σ'

5. ...

6. se (σ,e1)→ false, (σ,e2)→ false, (σ,en)→ false e (σ,c0)→ σ' allora (σ,I)→ σ'

In altri termini, sono valutate una dopo l'altra le condizioni e1,...,en fino a che non se ne trova una vera, in tal caso è eseguita l'istruzione associata (se ei è vera è eseguita ci). Se invece sono tutte false è eseguita l'istruzione c0.

Il comportamento di tale istruzione può essere esemplificato anche dal seguente diagramma di flusso

Page 60: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

La costruzione if-else-if si usa per creare più percorsi in alternativa fra di loro. Ad esempio per memorizzare il segno (+1,-1 o 0) di un numero reale x si può usare il seguente codice

if(x>0)

segno = +1;

else if(x<0)

segno = -1;

else // né positivo, né negativo

segno = 0;

Un altro esempio di if-else-if è il seguente: leggere da tastiera la temperatura odierna T e scrivere sullo schermo un commento secondo il seguente schema

Page 61: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Temperatura Commento

< 0 Molto freddo

Da 0 a 10 Freddo

Da 10 a 20 Fresco

Da 20 a 30 Caldo

> 30 Molto caldo

Il codice è

cin >> temp;

if(temp<0)

cout << “molto freddo\n”;

else if(temp<10)

cout << “freddo\n”;

else if(temp<20)

cout << “fresco\n”;

else if(temp<30)

cout << “caldo\n”;

else

cout << “molto caldo\n”;

Si noti che usando if-else-if le condizioni da controllare si semplificano. Ad esempio nel secondo if non c'è bisogno di controllare che temp sia compresa tra 0 e 10, ma solo che sia minore di 10 gradi. Infatti questo if è nell'else del primo if e già si sa che temp>= 0.

6.5 Istruzione switchL'istruzione switch è un'istruzione condizionale “a più vie”. La sua sintassi è molto generale, ma un suo uso sensato è descritto dalle seguenti produzioni

istruzione_switch ::= “switch” “(“ espressione “)” “{“ { clausola_case } [ clausola_default ] “}”

clausola_case ::= “case” costante “:” { “case” costante “:” } { istruzione } “break” “;”

clausola_default ::= “default” “:” { istruzione }

L'espressione associata allo switch deve essere di tipo intero, compreso char e enum (quest'ultimo sarà introdotto nel capitolo 9). Non sono ammessi valori di altri tipi (reali, stringhe, ecc.) Queste restrizioni si ritrovano anche nelle istruzioni simili alla switch presenti in altri linguaggi (ad esempio il costrutto case del Pascal).

Fornire la semantica operazionale dello switch è molto complicato, perciò preferiamo dare una semantica informale:

1. l'istruzione switch è composta da un'espressione, una serie di clausola_case ed eventualmente una clausola_default finale

2. Ogni clausola_case è composta da un elenco di costanti e da una serie di istruzioni concluse da break. Ogni clausola_case deve far riferimento a costanti distinte.

3. La clausola_default è composta solo da una serie di istruzioni

Page 62: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

4. L'espressione viene valutata, sia v il suo valore

5. Se esiste una clausola_case in cui è presente il valore v come costante, sono eseguite tutte le istruzioni in essa fino a break

6. In caso contrario, se esiste la clausola_default, sono eseguite le sue istruzioni

7. Se nessuna delle precedenti situazioni si verifica, non accade nulla

Un esempio di switch può essere dato dal seguente codice che scrive sullo schermo uno, due o tre se n è compreso tra 1 e 3, altrimenti un messaggio di errore:

switch(n) {

case 1:

cout << “uno”;

break;

case 2:

cout << “due”;

break;

case 3:

cout << “tre”;

break;

default:

cout << “errore: numero non compreso tra 1 e 3\n”;

}

Se non si mettono i break, sono eseguite tutte le istruzioni delle clausole seguenti, fino ad incontrare break o la fine dello switch.

Ad esempio in

switch(n) {

case 1:

cout << “uno”;

// break eliminato

case 2:

cout << “due”;

break;

case 3:

cout << “tre”;

break;

default:

cout << “numero non compreso tra 1 e 3\n”;

}

nel caso in cui n=1, sarà scritto sullo schermo sia “unodue”.

Un altro esempio di switch è dato dal codice che stabilisce quanti giorni ci sono in un mese:

Page 63: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

switch(mese) {

case 4: case 6: case 9: case 11:

// aprile, giugno, settembre o novembre

num_giorni=30;

break;

case 2: // febbraio

num_giorni=28;

break;

default: // gli altri mesi

num_giorni=31;

}

Un'istruzione switch è equivalente ad un costrutto if-else-if. Ad esempio lo switch dell'esempio precedente si può tradurre in

if(mese==4 || mese==6 || mese==9 || mese==11)

// aprile, giugno, settembre o novembre

num_giorni=30;

else if(mese==2) // febbraio

num_giorni=28;

else // gli altri mesi

num_giorni=31;

In particolare si noti che uno switch su una variabile x è equivalente ad un if-else-if in cui le condizioni sono del tipo x==v, con v costante.

6.6 Operatore condizionaleIn C++ è presente un operatore condizionale che è l'equivalente per le espressioni dell'istruzione if.

La sua sintassi è

espressione “? “ espressione “:” espressione

Si tratta quindi dell'unico caso di operatore ternario (cioè con tre argomenti) presente in C++.

La semantica di valutazione dell'espressione e1 ? e2 : e3 è molto simile a quella di esecuzione dell'istruzione if-else

1. se (σ,e1)→ true e (σ,e2)→ v allora (σ, e1 ? e2 : e3)→ vovvero, se e1 è vera allora il risultato è quello di e2

2. se (σ,e1)→ false e (σ,e3)→ u allora (σ, e1 ? e2 : e3)→ uovvero, se e1 è falsa allora il risultato è quello di e3.

La strategia di valutazione è quindi di tipo pigro, in quanto dei due argomenti e2 e e3 soltanto uno viene valutato, a seconda del valore di e1.

Ad esempio per calcolare il massimo tra due numeri reali a e b si può scrivere

max = a>b ? a : b;

Page 64: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Oppure per calcolare il segno di un numero reale x si può scrivere

segno = x>0 ? 1 : (x==0 ? 0 : -1);

Gli operatori && e || possono essere emulati usando l'operatore condizionale, come richiesto dall'esercizio 6.x.

6.7 Esercizi6.1.Scrivere un programma che legge da tastiera due numeri interi e indica se sono uguali, se è più grande il primo o il secondo.

6.2.Scrivere un programma che trova il più grande e il più piccolo di due numeri reali letti da tastiera.

6.3.Scrivere un programma che calcola il più grande tra tre numeri reali letti da tastiera.

6.4.Scrivere un programma che calcola il più piccolo tra quattro numeri reali letti da tastiera.

6.5.Scrivere un programma che calcola la mediana tra tre numeri reali letti da tastiera, nell'ipotesi che siano tutti diversi (la mediana è l'elemento intermedio tra il minimo e il massimo).

6.6.Scrivere un programma che legge da tastiera tre numeri reali e li scrive in ordine crescente.

6.7. Scrivere un programma che legge da tastiera un anno e scrive se tale anno è bisestile. Si ricordi che un anno è bisestile se è divisibile per 4 e se non è divisibile per 100 oppure è divisibile per 400, ad esempio sono bisestili 1996, 2000 e 2004, ma non è bisestile 1900.

6.8.Scrivere un programma che risolve un'equazione di secondo grado ax2+bx+c=0 tenendo conto del valore del delta.

6.9.Verificare che le istruzioni if(e) c1 else c2 e if(!e) c2 else c1 sono equivalenti.

6.10.Definire gli operatori && e || tramite l'operatore condizionale “?”.

6.11.Scrivere un programma che legge da tastiera tre numeri reali a,b,c, verifica che possano essere i lati di un triangolo (deve essere soddisfatta la diseguaglianza triangolare a<=b+c per tutti e tre i lati) ed infine scrive sullo schermo il tipo di triangolo: equilatero (tre lati uguali), isoscele (due lati uguali) o scaleno (tre lati diversi).

6.12.Scrivere un programma che legge da tastiera un primo numero reale x, un carattere op (che può essere solo '+', '-', '*', '/') ed un secondo numero reale y e calcola il risultato dell'espressione corrispondente. Ad esempio leggendo x=3, op=+ e y=4 deve scrivere 7 (3+4), mentre con x=3, op=* e y=7 il risultato è 21.

6.13.Scrivere un programma che legge da tastiera un numero naturale n compreso tra 1 e 99 e lo scrive sullo schermo a parole. Ad esempio se n=75 deve scrivere la stringa settantacinque.

6.14.Scrivere un programma che legge da tastiera una data, suddivisa in giorno, numero del mese e anno, e verifica se è una data esistente.

6.15.Scrivere un programma che legge da tastiera un numero n compreso tra 1 e 99 e lo scrive sullo schermo in numerazione romana. Ad esempio se n=78 deve scrivere la stringa LXXVIII.

Page 65: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

7 Strutture di controllo iterative

7.1 Generalità sull'iterazioneI costrutti iterativi sono importanti nei linguaggi imperativi perché permettono l'esecuzione ripetuta di un gruppo di istruzioni (detta ciclo).

Esistono due tipologie di iterazione: l'iterazione limitata e l'iterazione non limitata.

Nell'iterazione limitata le istruzioni da ripetere sono eseguite un numero finito e fissato di volte calcolato al momento di inizio del ciclo. Il tipico esempio di tale costrutto è il ciclo for del linguaggio Pascal. Ad esempio

for i:=1 to 10 do C

esegue 10 volte l'istruzione C, la prima volta con la variabile i che assume il valore 1, la seconda con i pari a 2, …, l'ultima volta con i pari a 10.

Tale istruzione di ciclo termina sempre (a patto che l'esecuzione di C termini sempre).

Per utilizzare un ciclo ad iterazione limitata bisogna rispondere alla domanda “quante volte le istruzioni vanno ripetute ?”.

Nell'iterazione non limitata le istruzioni da ripetere sono eseguite ripetutamente fino che una condizione prestabilita non diventa falsa (o non diventa vera, a seconda dei linguaggi e dei costrutti considerati). Tale tipo di iterazione non pone un limite superiore al numero di iterazioni e vi possono essere casi in cui non termina mai. Ad esempio in Pascal

while B do C

esegue l'istruzione C tante volte fino a quando la condizione B non diventa falsa. Se B resta sempre vera il ciclo non termina mai.

Per utilizzare un ciclo ad iterazione non limitata bisogna rispondere alla domanda “quando termina (o continua) il ciclo ?”.

Le istruzioni di iterazione non limitata sono potenzialmente pericolose perché un programma che ne fa uso potrebbe non terminare e quindi violare uno dei requisiti di base di un algoritmo.

D'altro canto tale forma di istruzione è indispensabile, esistono infatti molti esempi in cui non è possibile prevedere in anticipo quante iterazioni è necessario compiere. Esiste addirittura una funzione matematica (funzione di Ackermann) che non può essere calcolata usando solo cicli ad iterazione limitata, ma necessita almeno di una o più cicli ad iterazione illimitata.

7.2 Istruzione whileL'istruzione while ha la sintassi

istruzione_while ::= “while” “(“ espressione “)” istruzione

La semantica operazionale dell'istruzione while(e) c è data dalle due regole

1.se (,e)→ false allora (, while(e) c)→

2.se (,e)→ true, (,c)→ '' e ('', while(e) c)→ ' allora (, while(e) c)→ '

In termini informali, la condizione e è valutata, se è falsa (ciò può avvenire anche subito alla prima iterazione) il ciclo termina, altrimenti l'istruzione c è eseguita e il ciclo continua.

L'andamento può anche essere illustrato dal seguente diagramma di flusso

Page 66: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Quindi il ciclo while ripete le istruzioni fintantoché la condizione del ciclo è vera e termina quando diventa falsa. Perciò il ciclo while può essere inteso come raggiungimento di un obbiettivo:

1. la condizione è l'opposto dell'obbiettivo da raggiungere

2. le istruzioni eseguite ripetutamente fanno raggiungere (prima o poi) l'obbiettivo

3. appena la condizione diventa falsa, l'obbiettivo è raggiunto ed il ciclo termina.

Come per l'istruzione if, nell'istruzione while può essere ripetuta una sola istruzione. Per ripetere più istruzioni è necessario racchiuderle in un blocco.

Per determinare qual è lo stato finale a cui arriva il ciclo while, si ponga

0=

e per ogni intero i sia (i,c)→ i+1.

Se j è il più piccolo indice in cui (j,e)→ false allora (, while(e) c)→ j.

Ad esempio nel ciclo

r=a;

q=0;

while(r>=b) {

r -= b;

q++;

}

partendo dallo stato ={(a,23),(b,7),(r,23),(q,0)} all'inizio del ciclo while, si avrà il seguente andamento

1. (, r>=b)→ true, (, { r -= b; q++; })→ '={(a,23),(b,7),(r,16),(q,1)}

2. (', r>=b)→ true, (', { r -= b; q++; })→ ''={(a,23),(b,7),(r,9),(q,2)}

3. ('', r>=b)→ true, ('', { r -= b; q++; })→ '''={(a,23),(b,7),(r,2),(q,3)}

4. (''', r>=b)→ false

Lo stato finale sarà quindi . '''

Questo ciclo while in sostanza calcola in r il resto della divisione di a per b e in q il quoziente.

Page 67: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Se il ciclo non termina mai, non si ha alcun stato finale, ovvero per nessuno stato ' accade che (, while(e) c)→ '. Ciò è conforme con quanto abbiamo visto per la semantica operazionale.

Ad esempio il seguente ciclo non termina mai se il valore iniziale di a fosse dispari

q=0;

while(a!=0) {

a -= 2;

q++;

}

E' quindi importante assicurarsi che le istruzioni contribuiscano alla terminazione del ciclo garantendo che prima o poi la condizione diventi falsa. Senza questa garanzia il ciclo non è utile.

Uno dei casi in cui il ciclo while è indispensabile è quando il valore della condizione è influenzato direttamente dall'utente: ad esempio si vuole sommare una serie di numeri interi letti da tastiera fino a che l'utente non inserisce 0. Il classico metodo per calcolare una somma (senza memorizzare tutti i valori da sommare, cosa praticamente impossibile senza usare array, liste o simili) è quello di usare una varabile accumulatore che deve essere inizializzata a 0 e su cui si aggiungono di volta in volta, mediante l'operatore +=, i dati.

somma=0;

cout << “Inserisci i numeri da sommare (0 per terminare) “;

cin >> dato;

while(dato != 0) {

somma += dato;

cin >> dato;

}

cout << “La somma e' “ << somma << endl;

Un altro caso adatto all'uso del ciclo while è quello in cui si ricerca un elemento con determinate caratteristiche.

Ad esempio cerchiamo la parte intera r della radice quadrata di un numero intero n. Il numero r gode della proprietà che r2 <= n < (r+1)2. Allora impostiamo un ciclo la cui condizione è la negazione della proprietà da ottenere.

Perciò

r=0;

while(r*r > n || n>=(r+1)*(r+1) )

r++;

In realtà partendo dal basso è sufficiente controllare solo se n>=(r+1)*(r+1).

Come ulteriore esempio calcoliamo la somma delle cifre decimali di un numero intero n. Il resto della divisione di n per 10 corrisponde all'ultima cifra di n. Se si applica ripetutamente questo metodo ai quozienti così ottenuti si ottengono le altre cifre:

somma=0;

while(n!=0) {

somma += n%10;

Page 68: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

n /= 10;

}

7.3 Istruzione do-whileNel ciclo while la condizione è controllata all'inizio di ogni iterazione. Ciò comporta che il ciclo potrebbe concludersi subito, senza alcuna iterazione, quando la condizione è falsa già all'inizio.

In alcuni casi si ha la necessità che le istruzioni del ciclo siano eseguite comunque almeno una volta. A tal scopo esiste l'istruzione do-while, la quale ha la sintassi

istruzione_do_while ::= “do” istruzione “while” “(“ espressione “)” “;”

La semantica è data dalle due regole

1.se (σ,c)→ σ' e (σ',e)→ false allora (σ, do c while(e))→ σ'

2.se (σ,c)→ σ'', (σ'',e)→ true e (σ'', do c while(e))→ σ' allora (σ, do c while(e))→ σ'

In altri termini, c è eseguita ed e è valutata, se è falsa il ciclo termina, altrimenti il ciclo continua.

Un diagramma di flusso che illustra tale comportamento è

La differenza tra while e do-while è che nel ciclo while la condizione è controllata prima di ogni iterazione, mentre nel do-while è controllata dopo. Quindi nel secondo tipo di ciclo vi è comunque la garanzia che almeno una volta le istruzioni sono eseguite.

Per il resto le due istruzioni si comportano allo stesso modo:

1. il ciclo termina quando la condizione diventa falsa (quindi può essere la negazione dell'obbiettivo da raggiungere)

2. il ciclo può non terminare mai

3. non esiste un massimo numero di iterazioni

Come primo esempio del ciclo do-while risolviamo il problema di leggere da tastiera un numero intero assicurandoci che sia positivo.

do {

Page 69: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

cout << “Inserisci un numero positivo “;

cin >> numero;

} while(numero<=0);

Tale problema può essere risolto anche con un ciclo while, leggendo il numero anche prima del ciclo:

cout << “Inserisci un numero positivo “;

cin >> numero;

while(numero<=0) {

cout << “Non va bene, deve essere positivo, inseriscilo nuovamente “;

cin >> numero;

}

Un altro esempio è quello di calcolare la radice quadrata di un numero reale x non negativo mediante il metodo di Newton:

Se r è un'approssimazione di √x, allora 12r x

r è un'approssimazione migliore. Applicando

ripetutamente questa formula si può trovare un'approssimazione arbitrariamente vicina a √x.

Un obbiettivo potrebbe essere quello di trovare r in modo tale che |r2-x| sia minore di una quantità molto piccola ε, ad esempio 10-7.r=x;

epsilon= 1e-7;

do

r=0.5*(r+x/r);

while(fabs(r*r-x) >= epsilon);

cout << “la radice di “ << x << “ e' all'incirca “ << r << endl;

7.4 Istruzione forL'istruzione for è una forma potenziata dell'istruzione while, appositamente introdotta per simulare le istruzioni di iterazione limitata.

La forma generale del ciclo for ha la seguente sintassi

istruzione_for ::= “for” “(“ [ espressione] “;” [ espressione] “;”[ espressione] “)” istruzione

In pratica l'unica possibilità sensata per le espressioni che compaiono al primo e al terzo posto tra parentesi sono le istruzioni di assegnamento, per cui una sintassi consigliata è

istruzione_for ::= “for” “(“ [ istruzione_assegnamento ] “;” [ espressione] “;”[ istruzione_assegnamento ] “)” istruzione

L'istruzione for(e1;e2;e3) c in cui e2 è un'espressione di tipo bool, e1 e e3 sono istruzioni di assegnamento e c è un'istruzione è equivalente al frammento di codice

e1

while(e2) {

Page 70: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

c

e3 }

ovvero al diagramma di flusso

Ora vedremo l'uso di for nell'iterazione limitata. C'è comunque da notare che altri impieghi del for esistono, anche se meno frequenti.

7.5 Iterazione limitata con il ciclo forLa forma più utilizzata di iterazione limitata è quella in cui una variabile intera i viene fatta variare in un intervallo discreto {A, A+1, ..., B-1, B} e per ognuno di questi valori è eseguita un'istruzione C.

In C++ tale ciclo è esprimibile (oltre che con un ciclo while) anche con il ciclo

for(i=A; i<=B; i++) C

in cui i è una variabile intera, chiamata indice del ciclo, e A e B sono espressioni intere, detti estremi inferiore e superiore. E' meglio che B sia una variabile o una costante, altrimenti B deve essere valutata ad ogni iterazione.

Perché tale istruzione sia realmente un'iterazione limitata deve essere verificato il seguente vincolo: l'istruzione C non deve modificare la variabile i né alterare il valore dell'espressione B.

Infatti se C modificasse i o B, il ciclo potrebbe non terminare mai.

L'istruzione C viene eseguita B-A+1 volte, nel caso in cui A<=B, altrimenti non viene eseguita per niente. Ogni esecuzione di C è svolta con un valore diverso di i: la prima volta i vale A, la seconda volta i vale A+1, ecc. l'ultima volta i vale B. Alla fine del ciclo i vale B+1.

Un diagramma di flusso che illustra il comportamento del for su intervallo è

Page 71: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Non forniamo le regole della semantica operazionale perché sono prolisse e non aggiungono niente alla comprensione dell'istruzione. Tra l'altro un ciclo for si può sempre scrivere come ciclo while, aggiungendo le due istruzioni interne e1 e e3.

Come primo esempio di ciclo for facciamo vedere il suo uso come “pura” ripetizione: calcolare xn, ove x è un numero reale e n è un numero intero positivo, mediante moltiplicazioni successive:

potenza=1;

for(i=1;i<=n;i++)

potenza *= x;

cout << x << “ elevato alla “ << n << “fa “ << potenza << endl;

In tale esempio l'istruzione da ripetere non usa l'indice i.

Nella stragrande maggioranza degli usi di for invece l'istruzione utilizza il valore dell'indice. Ad esempio per calcolare la somma dei numeri naturali da 1 a n:

somma=0;

for(i=1;i<=n;i++)

somma += i;

cout << “la somma dei numeri da 1 a “ << n << “ è “ << somma << endl;

In maniera del tutto analoga si può calcolare il fattoriale di un numero intero n, indicato con n!, che è il prodotto dei numeri da 1 a n:

fattoriale=1;

for(i=1;i<=n;i++)

Page 72: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

fattoriale *= i;

cout << n << “!=“ << fattoriale << endl;

Un altro esempio di ciclo for è dato dal calcolo della successione di Fibonacci. La successione inizia con 1, 1 e poi prosegue con la legge che ogni elemento è la somma dei due elementi precedenti. I primi elementi della successione sono 1, 1, 2, 3, 5, 8, 13, 21, 34.

Per scrivere l'ennesimo numero di Fibonacci, con n>=2, ci conserviamo in due variabili intere l'ultimo e il penultimo numero calcolati di volta in volta

int ultimo=1, penultimo=1;

for(i=2; i<n; i++) {

int nuovo=ultimo+penultimo;

penultimo=ultimo;

ultimo=nuovo;

}

cout << ultimo << endl;

7.6 Indici definiti all'interno del forPer motivi didattici in queste dispense non utilizzeremo una caratteristica di C++ (come di altri linguaggi, ad esempio Java e Ada): la possibilità di definire l'indice come variabile locale al ciclo for stesso.

La variabile indice viene dichiarata in e1, cioè l'espressione quella normalmente usata come inizializzazione. Ovviamente l'indice deve anche essere inizializzato.

L'indice può essere utilizzato dalle istruzioni del ciclo, ma una volta che questo è terminato il ciclo, la variabile non esiste più.

Ad esempio per scrivere i quadrati dei primi 10 numeri naturali

for(int i=1;i<=10;i++) {

cout << i << “ “ << i*i*;

}

// qui non la variabile i non si può più usare

Tale caratteristica è utile se si vuole avere un indice solo locale al ciclo, anche con lo stesso nome di una variabile già esistente (si veda la sezione 11).

7.7 Cicli for annidati

E' possibile inserire un ciclo for dentro ad un altro (come del resto è possibile inserire un qualsiasi tipo di ciclo in un altro ciclo, anche di tipo diverso).

In tale situazione esiste un ciclo esterno e un ciclo interno. I due cicli devono usare ovviamente indici diversi.

Un primo esempio è la visualizzazione della tabellina pitagorica

for(i=1; i<=10; i++) { // ciclo esterno

for(j=1; j<=10; j++) // ciclo interno

Page 73: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

cout << i*j << “ “;

cout << endl;

}

Si noti che il ciclo interno è eseguito 10 volte, in ognuna delle quali l'istruzione interna è eseguita 10 volte, per un totale di 100 volte. Ogni volta che il ciclo interno è ripetuto riparte sempre da 1 e arriva a 10.

Il ciclo esterno è ovviamente eseguito una sola volta con 10 iterazioni.

Si scrivono la progressione dei valori di i e di j

(1,1),(1,2),....,(1,10), (2,1),(2,2),...,(2,10),....,(9,1),(9,2),...,(9,10), (10,1),(10,2),...,(10,10)

si osserva che il valore di i aumenta di 1 solo quando il valore di j è passato da 1 a 10.

Non necessariamente il ciclo interno deve avere estremi fissi. Si può anche avere situazioni in cui gli estremi dipendono dal valore dell'indice del ciclo esterno.

Ad esempio, per calcolare il numero e (la base dei logaritmi naturali) si può usare la formula

∑k=0

n 1k ! in cui n è un numero intero che determina la precisione del risultato (tanto maggiore è

n, tanto migliore sarà l'approssimazione ad e)

somma=0;

for(k=0; k<=n; k++) {

// calcolo il fattoriale di k

fattoriale=1;

for(i=1;i<=k;i++)

fattoriale *= i;

somma += 1/fattoriale;

}

cout << “e e' all'incirca “ << somma << endl;

7.8 Varianti del ciclo for standardLa struttura del ciclo for può essere modificata in tanti modi. Alcuni delle modifiche più comuni sono descritte in questa sezione e queste comunque garantiscono la limitatezza del ciclo. Una forma molto diversa di for, ma sempre limitata, sarà illustrata nel capitolo 14.

7.8.1 Estremo esclusoIn alcune situazioni si vuole che il ciclo non arrivi all'estremo B, ma si fermi a B-1. Ovviamente si può scrivere

for(i=A;i<=B-1;i++) C

ma si può anche scrivere

for(i=A;i<B;i++) C

Infatti quando i è uguale a B, il ciclo termina.

Page 74: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

In questo modo si evita, tra l'altro, che B-1 sia valutato più volte.

Questo variante è utile quando si lavora con gli array (sezione 9.X).

7.8.2 Ciclo all'indietroNel ciclo for standard l'indice si muove nella direzione crescente, da A a B, con B>=A. Per andare nella direzione opposta, ovvero da B ad A, il ciclo diventa

for(i=B; i>=A; i--) C

Ovviamente, in tal caso è indispensabile che l'istruzione C non alteri il valore dell'estremo A (oltre che l'indice i).

Ad esempio per scrivere sullo schermo i numeri da 10 a 1:

for(i=10; i>=1; i--)

cout << i << “ “;

7.8.3 Ciclo a passo non unitarioIl ciclo for standard avanza a passo unitario, ovvero l'indice assume tutti i valori compresi tra A e B. Può essere utile talvolta avanzare a passo maggiore di uno, per esempio uno sì e uno no: A, A+2, A+4,.... L'ultimo elemento sarà B, se A e B hanno la stessa parità (sono entrambi pari o entrambi dispari), oppure B-1.

In generale per far avanzare i con passo D (meglio se è una costante o una variabile) si usa

for(i=A;i<=B;i+=D) C

con il vincolo che l'istruzione C non deve alterare neanche D.

Per stampare i numeri dispari tra 1 e 11:

for(i=1; i<=11; i+=2)

cout << i << “ “;

Si noti che è equivalente anche

for(i=1; i<=12; i+=2)

cout << i << “ “;

7.8.4 Condizione aggiuntivaLa condizione del for può essere ristretta congiungendola con (cioè aggiungendo in AND) un' ulteriore condizione E

for(i=A; i<=B && E; i++) C

In tal caso il ciclo termina

1. quando i supera B (fine normale del ciclo), oppure

2. quando E è falsa (fine anticipata del ciclo)

Tale forma si usa se si vuole avere un ciclo che può terminare prima del previsto. Esempi di tali situazioni si trovano nei problemi di ricerca di elementi all'interno di intervalli, in cui la ricerca può fallire.

Ad esempio per controllare se un numero intero n è primo (ovvero non ha divisori propri, cioè compresi tra 2 e n-1) si può usare un ciclo for che si interrompe appena si trova un eventuale divisore proprio di n.

Page 75: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

bool trovato=false;

for(i=2; i<n && trovato==false; i++) {

if(n%i==0) // i divide n, è un divisore proprio

trovato=true;

}

if(trovato==false)

cout << n << “ è primo\n”;

else

cout << n << “ non è primo\n”;

Si noti che se il numero non è primo, viene trovato un divisore proprio, trovato diventa true e il ciclo si interrompe. Al posto delle condizioni trovato==false è possibile scrivere ! trovato.

7.9 Istruzioni break e continueL'istruzione break serve ad interrompere anticipatamente un ciclo (while, for o do-while), portando l'esecuzione del programma all'istruzione immediatamente successiva al ciclo.

L'unico uso sensato di break è all'interno di un if (altrimenti il ciclo sarebbe interrotto alla prima iterazione). Riprendendo l'esempio precedente, ma utilizzando break si ha

bool primo=true;

for(i=2; i<n; i++)

if(n%i==0) {

primo=false;

break; }

if(primo)

cout << n << “ è primo\n”;

else

cout << n << “ non è primo\n”;

E' importante notare che, con un alcuni artifici, ogni break si può eliminare. Ad esempio inserendo una ulteriore condizione del ciclo for si ottiene un codice equivalente.

Essenzialmente break è un'istruzione di salto. Poiché non permette al programmatore di saltare in un punto arbitrario del programma ma conduce allo stesso punto in cui l'esecuzione arriverebbe comunque (a ciclo finito), è perfettamente tollerata nella programmazione strutturata. Comunque il suo uso eccessivo denota problemi di struttura del programma.

Un'istruzione di uso poco frequente è l'istruzione continue. A differenza di break, continue interrompe solo l'iterazione corrente, ma non il ciclo, il quale continua con l'iterazione successiva. Anche la continue deve stare all'interno di un if, altrimenti tutte le iterazioni sarebbe troncate.

Ad esempio per scrivere tutti i numeri da 1 a 10, eccetto 6, si può scrivere

for(i=1; i<=10; i++) {

if(i==6) continue;

cout << i << “ “;

Page 76: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

}

L'istruzione continue è facilmente sostituibile con un'if

for(i=1;i<=10;i++) {

if(i!=6)

cout << i << “ “;

}

Si noti che non è invece possibile alterare la condizione del for:

for(i=1;i<=5 || (i>=7 && i<=10);i++)

cout << i << “ “;

così il programma scrive solo i numeri da 1 a 5, nonostante la condizione sia vera anche per i numeri interi compresi tra 7 e 10.

7.10 Esercizi

7.1.Implementare l'algoritmo di Euclide per il calcolo del massimo comun divisore.

7.2.Implementare la versione veloce dell'algoritmo di Euclide: ad ogni passo calcolare il resto della divisione di a per b, porre a uguale a b e b uguale al resto; continuare fino a che b non diventa 0, a quel punto il MCD è a.

7.3.Leggere da tastiera un numero intero n e trovare la più piccola potenza del due che sia maggiore di n

7.4.Calcolare il numero delle cifre decimali di un numero intero letto da tastiera

7.5.Convertire un numero intero n in base 2 (scrivendo però le cifre al contrario). Si noti che scrivere le cifre nell'ordine inverso è molto più semplice che scriverle nel verso corretto: il resto della divisione di n per 2 è l'ultima cifra binaria e applicando lo stesso procedimento ai successivi quozienti delle divisioni per 2 si ottengono le altre cifre binarie.

7.6. Implementare il gioco della morra cinese. Il computer e l'utente scelgono tra pugno, carta e forbici. Il programma deve proclamare l'esito. Il gioco va avanti per un numero massimo di turni, oppure quando uno dei due giocatori ha ottenuto più di N vincite. Per rendere più interessante il gioco il computer dovrebbe scegliere a caso (vedere l'esercizio successivo).

7.7.Implementare il seguente gioco. Il computer sceglie a caso un numero tra 1 e 100 (mediante l'istruzione numero=1+lrand()%100) e l'utente lo deve indovinare tramite dei tentativi. Ad ogni tentativo l'utente scrive un numero ed il computer deve solo rispondere se il numero scritto è maggiore, minore o uguale a quello da lui scelto. In quest'ultimo caso il gioco termina e il computer deve indicare al giocatore il numero di tentativi usati. Per far funzionare correttamente il generatore di numeri casuali la prima istruzione del programma deve essere srand(time(0)).

7.8.Ampliare la soluzione dell'esercizio precedente, definendo un numero massimo di tentativi, oltre i quali il gioco termina e il computer svela il numero scelto.

7.9.Invertire i ruoli del gioco dell'esercizio 7.7: l'utente sceglie un numero tra 1 e 100 e il computer lo deve indovinare. Inventare una strategia da far utilizzare al computer.

7.10.Calcolare la somma di n numeri reali immessi da tastiera.

7.11. Calcolare l'elemento più grande di n numeri reali immessi da tastiera (usare l'algoritmo M2 descritto nel capitolo 1).

7.12. Calcolare contemporaneamente l'elemento più grande e quello più piccolo di n numeri reali

Page 77: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

immessi da tastiera.

7.13. Calcolare il secondo elemento più grande di n numeri reali immessi da tastiera.

7.14.Calcolare il coefficiente binomiale nk di due numeri naturali n e k, con 0<=k<=n, definito

da n !

k ! n−k !

7.15.Visualizzare tutti i numeri primi compresi tra 2 e n (dato letto da tastiera)

7.16. Visualizzare i primi n numeri primi (ove n è letto da tastiera)

7.17. Visualizzare le prime n righe del triangolo di Tartaglia. Le righe sono numerate da 0 a n-1. La riga i-esima ha (i+1) colonne, numerate da 0 a i. La prima riga è la numero 0 e ha solo la colonna 0, La seconda riga è la numero 1 e ha le colonne 0 e 1. La terza è la numero 2 e ha le colonne 0, 1 e 2.

ecc. L'elemento nella riga i e colonna j è il coefficiente binomiale ij

7.18.Calcolare ex mediante la seguente somma ∑h=0

N xh

h!usando queste due strategie: N fissato

oppure continuare a sommare fino a che il termine da aggiungere diventa più piccolo di un certo ε.

Page 78: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

8 Correttezza dei programmi

8.1 Il problema della correttezza e l'approccio mediante testVerificare che un programma funzioni correttamente è estremamente importante per ovvi motivi. E' chiaro infatti che un programma che non risponde come dovrebbe perde la sua utilità.

Esistono essenzialmente due approcci per la verifica della correttezza: un approccio empirico mediante i test e un approccio formale tramite le dimostrazioni matematiche.

Sui test ci dilungheremo poco: il lettore interessato può consultare un qualsiasi testo di ingegneria del software. L'idea è semplice e facilmente realizzabile (almeno sulla carta): svolgere una serie di test in cui il programma è eseguito con degli input fissati e controllare se l'output del programma corrisponde a quello atteso. Chiaramente (e qui sta la difficoltà di questo approccio) è indispensabile trovare una serie di test significativi, cioè che siano rappresentativi di tutti i possibili input.

Per scegliere i test da effettuare si può seguire un approccio open box, in cui si tiene conto della struttura del programma: ad esempio si individuano una serie di test che vanno a coprire tutte le possibili condizioni, prevedendo per ognuna di esse sia input in cui sono vere, sia input in cui sono false.

L'alternativa è scegliere uno approccio di tipo black box, in cui si tiene conto solo delle tipologie di istanze del problema da risolvere, ad esempio dividendo il dominio dei possibili input in intervalli o classi di equivalenza e scegliendo dei test per ognuno di essi.

Un evidente limite dell'uso dei test è che anche se un programma supera positivamente una serie molto grande di test, comunque non vi è la certezza che il programma sia corretto. Infatti, come diceva Dijkstra, i test possono solo evidenziare la presenza di errori di programmazione, ma non garantire la loro assenza.

Il secondo approccio tenta invece di dimostrare matematicamente che il programma è corretto. Da un lato una dimostrazione matematica è inoppugnabile e consente di sapere con certezza che il programma è corretto. D'altra parte sono necessari un'analisi formale del comportamento del programma e l'utilizzo di tecniche di dimostrazione, le quali non sono, per programmi di media grandezza, possono risultare molto complesse. C'è comunque da dire esistono tool di sviluppo e ambienti di programmazione che consentono di verificare automaticamente la correttezza dei programmi. Infatti la verifica automatica è estremamente importante nella produzione industriale del software.

Per semplificare il problema della correttezza si distinguono due aspetti concorrenti: la correttezza parziale e la terminazione.

• Un programma si dice parzialmente corretto se partendo dall'input I, nel caso in cui termina, produce il risultato associato ad I.

• La terminazione invece si chiede se, appunto, un programma termina sempre.

8.2 Introduzione alla semantica assiomaticaUn metodo molto semplice per controllare la correttezza parziale di un programma è fornito dalla semantica assiomatica, di cui daremo un breve cenno, la quale è un modello alternativo alla semantica operazionale per spiegare il significato delle istruzioni.

Lo strumento fondamentale di tale modello è la tripla di Hoare, definita da una coppia di condizioni P e Q e un'istruzione C. P è chiamata precondizione di C e Q è chiamata postcondizione di C.

Page 79: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Classicamente la tripla si indica con {P}C{Q}, perché in Pascal i commenti sono delimitati dalle parentesi graffe, in queste dispense useremo però la notazione /* P */ C /* Q */.

P e Q sono condizioni che possono riguardare le variabili del programma ed eventuali dati (ad esempio i dati in input) ed utilizzare operazioni e funzioni matematiche, anche non presenti nel linguaggio, purché siano ragionevoli.

Si dirà che una tripla /* P */ C /* Q */ è valida se per ogni stato σ in cui è vera la precondizione P, se l'esecuzione di C termina, si ottiene uno stato σ' in cui è vera la postcondizione Q.

Ad esempio la tripla

/* x>y */ z=x-y; /* z>0 */

è valida se x,y,z sono variabili numeriche dello stesso tipo. Infatti in ogni stato in cui x è maggiore di y, se si assegna a z la differenza tra x e y, nello stato risultato z ha un valore positivo.

Anche la tripla

/* a=A and b=B */ { c=a; a=b; b=c; } /* a=B and b=A */

è valida per a,b,c variabili dello stesso tipo T e per ogni coppia di valori A e B di tipo T. Infatti partendo da uno stato in cui a vale A e b vale B, si perviene ad uno stato finale in cui a e b hanno i valori scambiati. Per inciso c vale A, ma ciò non importa in quanto il valore di C non è citato nella postcondizione.

Invece la tripla

/* x >= 0 */ x--; /* x >=0 */

non è valida, infatti se si parte con uno stato in cui x=0 si arriva ad uno stato ove x=-1.

8.3 Invarianti di ciclo e correttezza parziale

Dato un ciclo while(e) C si dice che la condizione I è un invariante di ciclo se la tripla

/* I and e */ C /* I */

è valida. Ciò significa che ogni volta che il ciclo effettua una iterazione in uno stato in cui la condizione I è vera, allora alla fine dell'iterazione si arriva ad uno stato in cui la condizione I è ancora verificata.

Vale il seguente risultato matematico, detto teorema dell'invariante:

Se I è un invariante del ciclo while(e) C, allora la tripla

/* I */ while(e) C /* I and not e */

è valida.

Ciò significa che eseguendo il ciclo in uno stato σ in cui è vero I, se il ciclo termina si perviene in uno stato σ' in cui I è vera ed e è falsa.

Il teorema, di cui non forniamo una dimostrazione formale, può servire a dimostrare la correttezza parziale di un programma contenente cicli while. La tecnica è quella di trovare una condizione I tale che

1.I sia un invariante di ciclo

2.I sia verificato all'inizio del ciclo while

3.la condizione composta I and not e implica che il programma è corretto

Page 80: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Come primo esempio mostriamo che il seguente programma

i=1;

s=0;

while(i<=n) {

s += i;

i++;

}

produce come risultato nella variabile s la somma dei numeri interi da 0 a n.

L'invariante di ciclo I utile è “la variabile s contiene la somma dei numeri da 0 a i-1”, in formule

s=∑ j=0

i−1j .

Innanzitutto I è un invariante di ciclo, se infatti se σ è un qualunque stato che soddisfa I e la condizione del ciclo while, ovvero in cui i è minore o uguale a n e s contiene la somma di tutti i numeri da 0 a i-1 (ad esempio i=4 e s=0+1+2+3=6), l'esecuzione delle due istruzioni interne al ciclo produce lo stato σ' in cui s è stato incrementato di i e i a sua volta di 1 e quindi I è ancora verificato (nell'esempio i=5 e s=0+1+2+3+4=10).

All'inizio del while I è verificato, dato che s=0.

Per il teorema dell'invariante alla fine del ciclo I è ancora vera, ma la condizione i<=n è falsa. E' facile capire che i adesso vale n+1 e quindi s è uguale alla somma di tutti i numeri da 0 a n.

Proviamo ora che il seguente metodo (chiamato square-and-multiply) per calcolare xn, ove x è un numero reale e un numero intero non negativo, è parzialmente corretto.

p=1;

while(n>0) {

if(n%2!=0)

p *= x;

x=x*x;

n /= 2;

}

Questo metodo ad ogni passo dimezza l'esponente e fa il quadrato della base: questa operazione lascia inalterato il risultato solo se l'esponente è pari, se è dispari si “perde” un unità che viene recuperata moltiplicando P per X.

Ad esempio volendo calcolare 311, si svolgono i seguenti passi

1. x=3, n=11, p=1

2. x=9, n=5, p=3

3. x=81, n=2, p=27

4. x=6561, n=1, p=27

5. x=43046721, n=0, p=177147

A questo punto il ciclo si ferma è il risultato è veramente 311=177147.

Page 81: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Indicando con X e N i valori iniziali di x e n, dobbiamo dimostrare che alla fine p=XN.

L'invariante “utile” è p xn=XN.

Mostriamo che è un invariante del ciclo while. Sia sigma uno stato {(p,A),(x,Y),(n,M)} in cui vale l'invariante. A,Y e M sono i valori delle variabili p,x e n. Perciò A YM=XN.

Se M è dispari, viene eseguita l'istruzione p *= x. Per cui nel nuovo stato p assume il valore AY. Lo stato sigma' ottenuto eseguendo tutte le istruzioni del blocco sarà {(p,AY),(x,Y2),(n,(M-1)/2)}.

Ma allora p xn=AY Y2(M-1)/2=A Y YM-1=A YM=XN.

Se invece M è pari lo stato sigma' sarà {(p,A),(x,Y2),(n,M/2)} e ancora sarà verificato l'invariante dato che p xn=A Y2M/2=A YM=XN.

L'invariante è vero all'inizio del ciclo, in quanto p=1, x=X e n=N.

Usando quindi il teorema dell'invariante avremo che alla fine del programma p xn=XN e n=0. Ma ciò implica che p=XN.

8.4 TerminazioneLa terminazione di un ciclo while(e) C si può dimostrare matematicamente utilizzando la tecnica del peso. Una quantità intera W che ha la seguenti proprietà

1. all'inizio del ciclo W assume un valore positivo

2. ad ogni iterazione W diminuisce

3. se W<=0 allora il ciclo termina, ovvero W<=0 implica la negazione di e.

si chiama peso del ciclo while.

Vale il seguente ovvio risultato:

Se un ciclo while possiede un peso W, allora termina sempre.

Un ciclo for del tipo

for(i=A;i<=B;i++) C

possiede, sotto le ipotesi viste nella sezione 7.X, ovviamente il peso B+1-i. Infatti, all'inizio vale B+1-A, ad ogni passo il peso diminuisce, ed infine quando i diventa uguale a B+1, il peso diventa 0 e il ciclo termina.

Nell'esempio dell'algoritmo square-and-multiply il peso è semplicemente n. Infatti n all'inizio è positivo, diminuisce ad ogni passo (si dimezza) e quando diventa 0 il ciclo termina.

In generale, comunque, è difficile trovare un peso per un generico ciclo e per molte situazioni non esiste un ciclo.

8.5 Considerazioni finali e problema della fermataIl problema della terminazione non è facilmente risolubile, infatti non è possibile trovare delle regole “meccaniche” generali che per qualsiasi programma permettano di verificare se un programma termina oppure no, anche a partire da un input fissato.

Verificare che un programma termina è facile, almeno in teoria: basta eseguire il programma e constatare che effettivamente sia terminato. E' vero però che il programma potrebbe terminare dopo un tempo arbitrariamente grande.

Tale approccio ovviamente non funziona per verificare che un programma non termina: non ci si può accorgere della non terminazione (a meno che il programma palesemente ripete le stesse operazioni, tipo scrivere ripetutamente un messaggio sullo schermo).

Page 82: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

La mancanza di criteri sulla non terminazione dipendono strettamente dal cosiddetto teorema della fermata, dimostrato da Turing nel 1936, il quale afferma che un computer non è in grado di prevedere se un qualsiasi programma termina oppure no. In termini più precisi non esiste un programma H che ricevendo in input un programma P e l'input I per P produce come risultato l'esito di P su I (si ferma o non si ferma).

Quindi qualsiasi criterio di terminazione può essere solo parziale. Altrimenti se esistesse un criterio valido per qualsiasi programma basterebbe tradurre tale criterio in programma e si violerebbe il teorema della fermata.

Il risultato ha anche un risvolto interessante, in quanto la terminazione è un problema che un computer non può risolvere (è un problema indecidibile). Si potrebbe pensare che qualsiasi problema computazionale possa essere risolto mediante un algoritmo, ma il teorema della fermata dimostra che questa impressione non è vera. In realtà esistono molti altri problemi non risolubili con un computer.

8.6 Esercizi8.1. Dimostrare la correttezza parziale del programma che calcola il fattoriale di un numero intero

8.2. Dimostrare la correttezza parziale del programma che controlla se un numero intero è un numero primo

8.3. Dimostrare la correttezza parziale del programma che calcola il massimo di N numeri reali letti da tastiera

8.4. Dimostrare la terminazione del programma che controlla se un numero intero è un numero primo

Page 83: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

9 Tipi di dato strutturati

9.1 GeneralitàUn tipo di dato strutturato (o composto) T è ottenuto come aggregazione di altri dati, chiamati elementi di T. Gli elementi possono essere sia tipi elementari che tipi strutturati a loro volta.

In ogni linguaggio si può accedere ai singoli elementi di un dato strutturato mediante un'operazione che genericamente chiameremo selezione. In alcuni linguaggi è anche possibile creare dati strutturati mediante operazioni di aggregazione tra dati.

In C++ e in molti linguaggi di programmazione esistono due forme di base di dati strutturati: gli array e i record.

Gli array sono una forma di aggregazione omogenea, ovvero tutti gli elementi che ne fanno parte appartengono allo stesso tipo di dato.

I record, che in C++ si chiamano strutture, sono una forma di aggregazione eterogenea, in cui gli elementi possono essere di tipo diverso.

Gli array non sono un caso particolare dei record, perché, come vedremo, la selezione di un elemento di un array avviene in modo completamente diverso (e più versatile) rispetto a quanto avviene nei record.

I linguaggi differiscono notevolmente nelle operazioni supportate nei tipi strutturati. Esistono linguaggi con un esteso insieme di operazioni per gli array e record, come ad esempio Fortan 90 e APL, mentre il C++, come vedremo, consente pochissime operazioni di base su tali dati.

9.2 Gli arrayUn array, come si è già visto, è un aggregazione di dati omogenei per tipo. Per dichiarare una variabile di tipo array bisogna specificare il tipo T ed il numero n (detto dimensione dell'array) degli elementi che ne fanno parte.

La sintassi in C++ per dichiarare una singola variabile di tipo array è

tipo identificatore “[“ costante_intera_senza_segno “]”

Ad esempio

int a[10];

dichiara una variabile a di tipo array avente 10 elementi di tipo int.

La dimensione di un array deve essere una costante (anche con nome), perché deve essere nota a tempo di compilazione.

Negli ultimi standard di C e C++ è consentito dichiarare array la cui dimensione è specificata da una variabile (o in generale da un'espressione). Ovviamente si prende in considerazione il valore che assume la variabile o l'espressione durante l'esecuzione del programma nel momento in cui l'array viene allocato in memoria.

Ad esempio è possibile scrivere

int n;

cout << “inserisci il numero di elementi “;

cin >> n;

int a[n];

Page 84: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

ma non è assolutamente possibile scrivere

int n,a[n];

cout << “inserisci il numero di elementi “;

cin >> n;

in cui n assume un valore dopo che è già stato definito l'array, il quale avrà una dimensione imprevedibile.

Si tratta comunque di una caratteristica che non è supportata da tutti i compilatori e che per il momento sconsigliamo di utilizzare, tra l'altro tale proprietà è garantita in maniera pienamente standardizzata anche dagli array dinamici (capitolo 13).

La dimensione di un array è comunque fissa: una volta che l'array è stato dichiarato non è più possibile aumentare o diminuire il numero di elementi. Il motivo è che l'array per poter funzionare correttamente deve essere memorizzato in una zona consecutiva di memoria. Per aumentare la dimensione di un array sarebbe necessario che la zona di memoria successiva a quella occupata dall'array fosse libera, ma questa è solitamente occupata da altre variabili.

Le variabili array si possono inizializzare con la sintassi

tipo identificatore “[“ costante_intera_senza_segno “]” “=” “{“ costante { “,” costante } “}”

Ad esempio

int v[5]={4,5,7,9,-1};

Ogni elemento di un array è identificato con un numero intero, chiamato indice, compreso tra 0 e n-1. Ad esempio per v abbiamo

indice 0 1 2 3 4

elemento 4 5 7 9 -1

Il tipo di dato array ha come dominio l'insieme di tutte le n-uple di elementi di T. Matematicamente questo insieme si scrive come Tn.

L'unica operazione supportata dal C++ per gli array è la selezione. Non è possibile svolgere direttamente nel linguaggio qualsiasi altra operazione, quali assegnamento, confronto, lettura da tastiera, scrittura sullo schermo, ecc. Tutte queste operazioni devono essere implementate dal programmatore, come vedremo nella sezione successiva.

La mancanza di operazioni di qualsiasi tipo ha anche come effetto che gli array possono essere trattati esclusivamente tramite variabili: non esiste nemmeno la possibilità di avere array costanti anonimi, come invece esiste per i tipi elementari (ad esempio la costante 5 di tipo int).

La selezione di un elemento di un array avviene mediante l'operazione di indicizzazione, la quale ha come operandi l'array e un'espressione intera. La sua sintassi è

nome_array “[“ espressione_intera “]”

Se l'array V ha dimensione n ed e è un'espressione intera, il cui valore è il numero intero i, l'indicizzazione V[e] è valida se i è compreso tra 0 e n-1 e il suo risultato è un riferimento all'elemento i-esimo del vettore V, cioè consente di accedervi sia in lettura che in scrittura.

L'indicizzazione può essere usata per accedere in lettura a tale elemento e in tal caso deve comparire in un'espressione, in cui si usa il valore dell'elemento. Ad esempio

Page 85: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

cout << v[3]

scriverà 9 sullo schermo.

L'accesso può anche essere in scrittura. In tal caso l'indicizzazione deve comparire come l-value di un assegnamento (o in contesti assimilabili).

Ad esempio dopo l'istruzione

v[2]=8;

v diventerà

indice 0 1 2 3 4

elemento 4 5 8 9 -1

Il controllo che l'espressione e dell'indicizzazione V[e] abbia un valore compreso tra 0 e n-1 non viene in genere svolto in C++, lasciandolo come eventuale compito al programmatore, il quale di solito lo assolve cercando a priori di far sì che l'indice sia compreso nell'intervallo giusto.

L'operazione di indicizzazione è molto veloce (dello stesso ordine di grandezza del tempo di accesso ad una variabile tradizionale) e soprattutto indipendente dal valore dell'indice: accedere al primo, all'ultimo o a qualsiasi elemento di un array richiede sempre lo stesso tempo. Questo è possibile perché si può calcolare con una semplice formula matematica l'indirizzo in memoria di un qualsiasi elemento di un array. Infatti se V è un array di tipo T e se i dati di T occupano s byte l'uno, l'indirizzo dell'i-esimo elemento di V è

a+s i,

ove a è l'indirizzo di inizio dell'array (indirizzo del primo elemento).

Gli array, in conclusione, si possono utilizzare in varie situazioni.

Un primo caso è dato dagli array la cui dimensione è fissa ed esplicita. Ad esempio per memorizzare la superficie di ogni regione italiana occorre usare un array del tipo

double superficie[20];

in cui si associa un indice ad ogni regione. Una possibilità è per ordine da nord a sud: 0=Valle d'Aosta, 1=Piemonte, …, 18=Sicilia, 19=Sardegna

superficie[18]=25708;

In altre situazioni non esiste una dimensione nota a priori. La gestione di una collezione di elementi, il cui numero può variare nel tempo, può essere svolta mediante un array a patto di usare come dimensione dell'array il massimo numero di elementi che saranno presenti nella collezione. Tale dimensione va intesa come dimensione fisica o capacità dell'array ed è fissa. Il numero di elementi presenti è invece da intendersi come dimensione logica dell'array e potendo cambiare nel tempo deve essere memorizzato in una variabile. Ovviamente l'array non cambia realmente di dimensione e tutti gli elementi inutilizzati sono comunque in memoria.

Ad esempio per gestire i soci di un'associazione è possibile immaginare due operazioni: l'iscrizione di un nuovo socio, che inserisce il nominativo del socio nell'array e aumenta la variabile num_soci, e l'uscita di un socio dall'associazione, che elimina il socio dall'array e diminuisce num_soci.

9.3 Implementazione delle operazioni elementari sugli arrayNel trattamento degli array riveste particolare importanza l'uso del ciclo for. Infatti per svolgere la stessa operazione su tutti gli elementi di un array a di n elementi si può usare un indice che viene

Page 86: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

fatto variare mediante un ciclo for sull'intervallo 0,..., n-1 :

for(i=0; i<n; i++) {

// esegui l'operazione su a[i]

}

Useremo questo schema per implementare alcune operazioni di base sugli array. Negli esempi a sarà un array di n numeri interi.

9.3.1 RiempimentoPer riempire gli elementi di un array, ad esempio con valore costante 0

for(i=0; i<n; i++)

a[i]=0;

o con un valore calcolato in funzione di i, ad esempio i2

for(i=0; i<n; i++)

a[i]=i*i;

9.3.2 CopiaPer copiare un array a in un array b di uguale dimensione

for(i=0; i<n; i++)

b[i]=a[i];

Questa operazione è in sostanza l'equivalente di un assegnamento.

9.3.3 Lettura da tastieraPer leggere gli elementi di a da tastiera

for(i=0; i<n; i++)

cin >> a[i];

9.3.4 Scrittura su schermoPer scrivere gli elementi di a su una riga dello schermo

for(i=0; i<n; i++)

cout << a[i] << “ “;

Se al posto di “ “ si scrive endl si dispongono gli elementi in colonna.

9.3.5 Sommatoria e calcolo del massimoPer calcolare la somma degli elementi di a

somma=0;

for(i=0; i<n; i++)

somma += a[i];

Per trovare il massimo elemento di a si usa un procedimento simile a quello visto nel capitolo 1. La variabile max è inizializzata con il primo elemento, così il ciclo può partire da 1.

Page 87: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

max=a[0];

for(i=1;i<n;i++)

if(a[i]>max)

max=a[i];

In maniera alternativa si può inizializzare max con il più piccolo valore possibile e usare un ciclo che parte da 0:

max=-2147483648; // è -231 il più piccolo valore int a 32 bit

for(i=0;i<n;i++)

if(a[i]>max)

max=a[i];

In realtà è sufficiente che max sia inizializzato con un valore che sicuramente è più piccolo di ogni elemento di a.

9.3.6 Conteggio ed esistenzaPer contare quanti elementi di a godono di una determinata proprietà (nell'esempio essere pari) si usa un contatore

conta=0;

for(i=0; i<n; i++)

if(a[i]%2==0)

conta++;

Per controllare se esiste un elemento che gode di una proprietà (nell'esempio sempre essere pari), si può usare una variabile bool e l'istruzione break

bool trovato=false;

for(i=0;i<n;i++)

if(a[i]%2==0) {

trovato=true;

break;

}

Per controllare se tutti gli elementi godono della proprietà è sufficiente controllare che non esiste un elemento che gode della proprietà contraria (essere dispari)

bool tutti=true;

for(i=0;i<n;i++)

if(a[i]%2!=0) { // trovato un elemento dispari

tutti=false;

break;

}

Page 88: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

9.3.7 FiltroPer copiare in un array b (di dimensione n) solo gli elementi di a che godono di una certa proprietà (nell'esempio sempre essere pari)

j=0;

for(i=0; i<n; i++)

if(a[i]%2==0) {

b[j]=a[i];

j++;

}

In questa operazione gli elementi di a sono copiati in posizioni consecutive in b senza lasciare spazi vuoti.

Ad esempio se A contiene gli elementi (5,4,2,8,7,9,10,1), b conterrà (4,2,8,10) mentre le altre posizioni saranno vuote (o meglio inalterate). Gli elementi effettivamente inseriti in b sono j, in questo caso 4.

9.4 Array multidimensionaliGli array multidimensionali sono una generalizzazione degli array visti fino ad adesso, che sono appunto monodimensionali. Tratteremo solo gli array bidimensionali (che chiameremo per brevità matrici), comunque le caratteristiche di array a più di due dimensioni sono molto simili.

Una matrice ha due dimensioni e viene rappresentata graficamente mediante una tabella, in cui la prima dimensione rappresenta il numero di righe e la seconda il numero di colonne. Ogni elemento è identificato dal numero di riga e di colonna (una sorta di coordinate cartesiane).

indici delle colonne

indici delle righe 0 1 2 3

0 7 8 -5 2

1 5 2 1 9

2 4 -3 -1 0

Una matrice si dichiara con la sintassi

tipo identificatore “[“ costante_intera_senza_segno “]” “[“ costante_intera_senza_segno “]”

Ad esempio con

int a[3][4];

si dichiara una variabile a, matrice di interi di 3 righe e 4 colonne.

Per accedere agli elementi di una matrice si usa l'operazione di indicizzazione che opera con due indici. Ad esempio l'istruzione

r = a[1][2];

assegna alla variabile r il valore 1.

Per gestire una matrice a di n righe e m colonne si usa un doppio ciclo for

Page 89: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

for(i=0;i<n;i++) {

for(j=0;j<m;j++) {

// fai qualcosa con a[i][j]

}

}

Ad esempio per scrivere sullo schermo il contenuto della matrice afor(i=0; i<3; i++) {

for(j=0; j<4; j++)

cout << a[i][j] << “ “;

cout << endl;

}

Tutte le altre operazioni elementari viste per gli array monodimensionali possono essere facilmente modificate per lavorare su array bidimensionali, inserendo sempre un doppio ciclo for.

Gli array bidimensionali sono in realtà visti dal C++ come array di array. Ad esempiodouble b[3][2];

dichiara un array b di 3 elementi (le righe) che a loro volta sono array di 2 double ciascuno.

Per cui

b[0] 3 -2b[1] 7 4b[2] -1 0

Ciò spiega la notazione a[i][j] che in altri linguaggi, come in Pascal, è invece a[i,j]. Infatti a[i][j] andrebbe interpretato come (a[i])[j]. Ovvero con a[i] si seleziona la riga e con il secondo indice j si seleziona l'elemento all'interno della riga.

9.5 Le stringheLe stringhe, come abbiamo visto nel capitolo X, possono essere considerate sia come tipi elementari, sia come tipi strutturati. Le stringhe in C++ possono essere viste come array illimitati di char.

Le operazioni supportate dal linguaggio sono tante, tra le più importanti troviamo

1. assegnamento con =

2. confronto con <, >, >=, <=, == e !=una stringa s è minore di una stringa t se s precede t nell'ordine lessicografico: se s è un prefisso di t (ovvero se t inizia con s, ad esempio “ba” < “bar”) oppure se nella prima posizione in cui s e t differiscono, s presenta un carattere che viene di quello di t nell'ordine ASCII, ad esempio “carota” < ”carta” dato chec a r o t a

Page 90: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

| | | c a r t ae 'o' < 't'

3. concatenazione con +, ad esempio “car”+”te” ha come risultato “carte”

4. lunghezza con il metodo length, ad esempio se s vale “informatica”, s.length() ha come risultato 11

5. estrazione di sottostringa con il metodo substr, in cui si indica la posizione del primo carattere e il numero di caratteri da estrarre, ad esempio s.substr(2,5) ha come risultato “forma”

6. lettura da tastiera e scrittura sullo schermo come gli altri tipi di dato

Combinando length e substr si possono estrarre parti significative di una stringa. Ad esempio s.substr(s.length( )-1,1) restituisce una stringa formata dal solo ultimo carattere di s (nell'esempio “a”), mentre s.substr(0,s.length( )-1) restituisce una stringa uguale a s tranne l'ultimo elemento (nell'esempio “informatic”).

E' comunque possibile accedere, sia in lettura, sia in scrittura, all'i-esimo elemento di una stringa mediante l'indicizzazione, come se la stringa fosse un array. Ad esempio s[3] vale 'o'. Mentre s[10]='o' fa sì che s diventi “informatico”.

Si noti che le stringhe sono implementate in C++ mediante una classe.

9.6 Le strutture (record)Una struttura è un aggregazione di dati eterogenei per tipo. Per usare un tipo struttura bisogna elencare il nome ed il tipo di ciascuno degli elementi della struttura, detti campi.

La sintassi per indicare un tipo struttura è

tipo_struttura ::= “struct” [ identificatore ] “{“

campi_stesso_tipo “;” { campi_stesso_tipo “;” }

“}”

campi_stesso_tipo ::= tipo identificatore {, identificatore }

Ad esempio per dichiarare una variabile di nome p e di tipo struttura, avente i campi nome, cognome, età ed altezza si scrive

struct {

string nome, cognome;

int eta;

double altezza;

} p;

Si possono dichiarare sia più variabili aventi stessa struttura e anche array di strutture, come vedremo nella sezione successiva.

Per inizializzare una variabile di tipo struct si usa una sintassi simile a quella vista per inizializzare una variabile di tipo array:

struct {

string nome, cognome;

int eta;

Page 91: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

double altezza;

} q={“Mario”, “Rossi”, 35, 1.80};

E' possibile dare un nome alla struttura, in tal caso si può usare successivamente tale nome per dichiarare variabili di tale tipo di struttura.

Ad esempio

struct persona {

string nome, cognome;

int eta;

double altezza;

};

A questo punto nel sistema dei tipi utilizzabili in C++ è stato aggiunto il tipo persona. Si noti che in tale caso è obbligatorio chiudere la definizione con il punto e virgola.

A questo punto è possibile dichiarare delle variabili di tipo persona persona p1,p2;

La selezione di un campo di una variabile di tipo struttura avviene con la notazione punto: ad esempio p1.nome è un riferimento al campo nome della variabile p1.

Come per gli elementi di un array, anche per i campi di una struttura la selezione permette di leggere e di scrivere il valore in essi contenuto.p1.nome=”Marco”;

cout << p1.cognome;

La differenza più evidente tra la selezione degli elementi di un array e quelli prevista per le strutture è che nella prima l'indice può essere il risultato di un'espressione, cioè noto a tempo di esecuzione, mentre nella seconda il nome del campo è “costante”, cioè invariabile. Ciò è necessario perché avendo una struttura campi di tipo diverso un'eventuale indicizzazione non sarebbe un'operazione con un tipo definito. Si immagini p1[i]: è una stringa, un numero intero o un numero reale ?

Il dominio del tipo struct è il prodotto cartesiano dei domini dei rispettivi campi

Il C++ offre un minimo di operazioni in più rispetto agli array, infatti oltre alla selezione, è possibile anche effettuare assegnamenti su variabili di tipo struct e definire funzioni il cui risultato è di tipo struct, come vedremo nel capitolo 11.

L'assegnamento tra struct equivale ad un assegnamento tra campi corrispondenti.

Ad esempiop1=p2;

equivale a p1.nome=p2.nome;

p1.cognome=p2.cognome;

p1.eta=p2.eta;

p1.altezza=p2.altezza;

Page 92: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

9.7 Array di struttureArray e strutture si possono comporre in modo arbitrario.

Ad esempio una struct può contenere altre struct e array, come nel seguente caso

struct studente {

int matricola;

string nome, cognome;

struct data {

int giorno, mese, anno;

} data_nascita;

int voti[num_materie];

int num_esami;

};

Per accedere ai dati di una variabile s di tipo studente si userà, ad esempio, s.data_nascita.anno o s.voti[5].

Un'utile composizione tra array e strutture è data dagli array di strutture, ovvero dagli array i cui elementi sono strutture.

Partendo dalla struttura

struct persona {

string nome, cognome;

int eta;

double altezza;

};

è possibile dichiarare un array di elementi di tipo personapersona p[100];

La variabile p è rappresentabile come una tabella, con colonne identificate dai nomi dei campi della strutture e le righe dagli indici dell'array

indice nome cognome eta altezza0 Mario Rossi 39 1,781 Lucia Verdi 32 1,7...99 Marisa Gialli 44 1,67

Per operare con gli elementi di p si deve specificare l'indice (della riga) e il nome del campo, ovvero della colonna. Ad esempiocout << p[1].cognome;

scrive Verdi sullo schermo.

Page 93: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Gli array di strutture sono particolarmente interessanti perché consentono di memorizzare l'equivalente di una tabella (ovvero di una relazione di un database relazionale), seppur con la limitazione del numero massimo di elementi. Alcuni esercizi di questo capitolo saranno dedicati proprio a questo tipo di impiego.

9.8 Le unioniLe unioni sono una variante particolarissima delle struct. Infatti solo un campo alla volta di una union può contenere un valore. In altri termini appena si assegna un valore ad un campo di una union, tutti gli altri campi perdono di significato e non possono essere utilizzati.

La sintassi di union è praticamente identica a quella di struct.

Ad esempio dichiarando una variabile

union {

int a;

double b;

string c;

} u;

non si può dare contemporaneamente un valore a due (o a tre) dei campi: il secondo assegnamento distruggerebbe il primo. Pertanto è come se u avesse (a scelta) solo uno tra i possibili campi a,b e c.

Ad esempio

u.a=10;

cout << u.a << endl;

cout << u.b << endl; // illegale

cout << u.c << endl; // illegale

u.b=4.3;

cout << u.a << endl; // ora è illegale

cout << u.b << endl;

cout << u.c << endl; // illegale

u.c=”abc”;

cout << u.c << endl;

Il motivo di tale comportamento è che tutti i campi di una unione usano la stessa zona di memoria.

Una variabile di tipo unione è utile in situazione estremamente particolari. Ad esempio si usa quando si vuole avere una variabile che può assumere valori appartenenti a tipi diversi e incompatibili tra di loro: infatti il dominio di un tipo union è l'unione dei domini dei campi che ne fanno parte.

Un'altra situazione in cui ha senso usare una union è quella di simulare i record con varianti, che sono presenti in altri linguaggi, ad esempio in Pascal.

Ad esempio se vogliamo avere dati diversi per uomini e per donne si possono definire

struct persona {

string nome, cognome;

int eta;

Page 94: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

double altezza;

char sesso; // M oppure F

union {

// dati per gli uomini

struct {

bool

} dati_uomo;

// dati per le donne

struct {

} dati_donna;

};

};

%%% completare l'esempio

Sta al programmatore di usare i campi ... solo quando sesso è M o i campi ... quando sesso è F.

9.9 Creazione di tipi di datiL'insieme dei tipi di un linguaggio di programmazione è, almeno in quelli più moderni, estensibile: il programmatore può definire nuovi di tipi di dato. Il metodo migliore è fornito dalla programmazione orientata agli oggetti: creare un tipo di dato mediante la definizione di una opportuna classe. Tale soluzione ha l'enorme vantaggio che oltre al dominio, si possono effettivamente definire anche le operazioni del nuovo tipo di dato.

Volendo rimanere all'interno delle possibilità dei linguaggi tradizionali, possiamo elencare tre modalità di definizione di nuovi tipi di dati

1. per enumerazione

2. per costruzione

3. come sottotipo (restrizione) di un tipo già esistente

Poiché il C++ non ha il terzo modo, ne daremo un breve cenno facendo riferimento ad altri linguaggi. In Pascal, ad esempio, è possibile creare un sottotipo dei numeri interi specificando un intervallo discreto, ad esempio il tipo voto_universitario che ammette valori interi solo tra 18 e 30.

9.9.1 Tipi di dati enumerativi

Il linguaggio C++ consente la creazione di nuovi tipi di dati per enumerazione. Un tipo siffatto ha un dominio finito i cui elementi sono elencati nella definizione.

La sintassi prevede

def_tipo_enum ::= “enum” identificatore “{“ identificatore { “,” identificatore } “}” “;”

Ad esempio

enum colori_semaforo { rosso, giallo, verde };

enum giorni_settimana { lun, mar, mer, gio, ven, sab, dom };

Page 95: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Dichiarando una variabile di tipo colori_semaforo con

colori_semaforo c;

è possibile poi svolgere le operazioni

c=verde;

oppure

if(c==giallo)

cout << “il semaforo è giallo\n”;

Le operazioni sensate su un tipo enumerativo sono il confronto (== e !=, ma anche <, <=, >, >= considerando l'ordine in cui sono elencati), compreso il suo uso nello switch, e l'assegnamento.

Ad esempio rosso < giallo < verde.

In realtà, essendo gli enum implementati in C++ come numeri interi, tutte le operazioni definite sugli int sono possibili sugli enum. Per tenere conto dei risultati bisogna ricordare che i numeri interi usati partono da 0 (salvo indicazione esplicita). Per cui ad esempio lun è 0, mar è 1, ..., dom è 6.

Perciò

giorni_settimana g=lun;

cout << g << endl; // scrive 0

g++; // g ora vale mar

9.9.2 Nuovi tipi per costruzioneTramite l'istruzione typedef è possibile creare un sinonimo per un tipo di dato definito mediante array, struct, union, puntatori. Tale uso rende più compatta e più leggibile la notazione e in qualche modo rende parametrico il programma e quindi più facilmente modificabile.

La sintassi di typedef è un po' particolare e prevede che il nuovo tipo di dato sia dichiarato come se fosse una variabile del tipo del quale si vuole definire il sinonimo. L'unica differenza è la presenza della parola chiave typedef.

Alcuni esempi possono chiarire meglio la sintassi:

typedef struct { int x,y; } punto;

typedef int vettore[10];

typedef unsigned int naturale;

Quando il nuovo tipo è usato per dichiarare una variabile, la dichiarazione che produce è quella che si ottiene prendendo la definizione del tipo di dato e mettendo la variabile al posto del tipo.

La prima istruzione definisce punto come un tipo di dato struct, avente le coordinate intere x e y.

Per cui sono equivalenti

punto p;

estruct {

Page 96: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

int x,y;

} p;

La seconda definisce vettore come tipo di dato array di 10 interi, quindi sono equivalenti

vettore v;

e int v[10];

La terza crea un sinonimo di unsigned int, per cui

naturale i,j;

e' come se fosse

unsigned int i,j;

Il principale scopo dell'utilizzo di typedef è quello di semplificare la definizione di variabili dello stesso tipo. Inoltre è possibile anche parametrizzare un programma, alcuni esempi di questa possibilità saranno visti con gli algoritmi di ordinamento (capitolo 10) e con le liste (capitolo 14).

9.10 Esercizi9.1. Leggere da tastiera un array di N numeri reali e calcolare la media aritmetica.

9.2.Leggere da tastiera un array di N numeri reali e calcolare la differenza tra il massimo e il minimo elemento.

9.3.Leggere da tastiera un array di N numeri reali e calcolare la varianza, ovvero la somma dei quadrati delle differenze tra ogni elemento e la media, il tutto diviso per N.

9.4.Leggere da tastiera un array di N numeri interi e copiarlo in un altro array al contrario.

9.5.Leggere da tastiera un array di N numeri interi e rovesciarlo, ad esempio da 1,7,6,4,5 ottenere 5,4,6,7,1.

9.6.Leggere da tastiera un array di N numeri interi e controllare se è palindromo (ovvero se uguale al suo contrario, come 1,2,3,2,1).

9.7.Leggere da tastiera un array di N numeri interi e controllare se vi sono elementi duplicati.

9.8.Leggere da tastiera un array di N numeri interi e controllare se è ordinato in senso crescente.

9.9.Leggere da tastiera un array di N numeri interi e controllare se è ordinato in uno dei due versi (crescente o decrescente).

9.10.Leggere da tastiera due array A e B di N numeri interi e copiare in un array C di 2N numeri interi gli elementi di A e B alternati: ad esempio da 1,2,4,7 e 5,3,2,9 ottenere 1,5,2,3,4,2,7,9.

9.11.Leggere da tastiera un array di N numeri interi, tutti compresi tra 0 e K-1, e trovare l'elemento più frequente.

9.12.Leggere da tastiera un array di N numeri interi ordinato e trovare l'elemento più frequente.

9.13.Leggere da tastiera un array di N numeri interi e controllare se esistono almeno K elementi consecutivi uguali

9.14.Leggere da tastiera due array A e B di N numeri reali e calcolare il loro prodotto scalare, definito dalla somma dei prodotti di ogni elemento di A per il corrispondente elemento di B.

9.15.Leggere da tastiera un array A di N+1 numeri reali, rappresentanti i coefficienti del polinomio

Page 97: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

p(x)=a0+a1 x +a2 x2+ … an xn e un numero reale z e calcolare p(z), ossia il valore del polinomio in z.

9.16.Leggere da tastiera un numero intero N e scriverlo in base 2. Suggerimento: trovare le cifre binarie tramite i resti delle divisioni per 2 e memorizzarle in un array, poi scrivere l'array al contrario.

9.17.Generalizzare l'esercizio precedente ad una base generica B.

9.18.Leggere da tastiera un numero intero N e memorizzare in un array i suoi divisori propri (cioè non considera né 1 né N stesso).

9.19.Leggere da tastiera un numero intero N e memorizzare in un array di coppie di interi i suoi divisori propri con i relativi esponenti. Ad esempio con N=450=21 32 52 si ottiene ((2,1),(3,2),(5,2)).

9.20.Definire un tipo che consente di memorizzare i dati di una città: nome, abitanti, superficie, nome del sindaco, elenco dei 5 luoghi più famosi.

9.21.Definire un tipo che consente di memorizzare i dati di N squadre di un torneo: per ognuna nome, punti in classifica, numero di vittorie, pareggi e sconfitte, numero di goal segnati e subiti.

9.22.Definire un tipo di dato che consente di memorizzare le tipologie di supporto multimediale (musicassette, videocassette, cd, dvd, ecc.).

9.23. Scrivere un programma che gestisce una rubrica telefonica. La rubrica può contenere al massimo N elementi composti da un nominativo e da un numero di telefono. Il programma fa svolgere in modo continuativo, a scelta dell'utente, una delle seguenti due operazioni: inserimento di un nuovo elemento e ricerca di un numero tramite il nominativo.

Page 98: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

10 Algoritmi elementari

In questo capitolo illustreremo alcuni dei più importanti algoritmi operanti su array: gli algoritmi di ricerca e di ordinamento.

10.1 Algoritmi di ricerca

Il problema della ricerca in un array è semplicemente quello di trovare, se esiste, all'interno di un array A di n elementi di tipo T ne esiste uno che è uguale ad un certo dato x, ovviamente dello stesso tipo T.

Questa definizione del problema non è però completamente specificata, in quanto se esistono più elementi di A che sono uguali a x non si sa cosa deve accadere. Una formulazione più precisa richiede che in tali situazioni, il risultato della ricerca è il primo elemento di A che è uguale a x.

Esistono svariati algoritmi di ricerca su array e su altre strutture dati. Per gli array l'unico algoritmo che funziona sempre è l'algoritmo di ricerca lineare. Se l'array è ordinato, ovvero gli elementi di A sono disposti in modo crescente (o decrescente), allora è utilizzabile anche l'algoritmo di ricerca binaria, che è nettamente superiore a quello di ricerca lineare.

Avendo a disposizione ulteriori informazioni sul contenuto dell'array si potrebbero usare algoritmi ancora più veloci, ad esempio quello per interpolazione, ma non tratteremo questo argomento.

10.1.1 Algoritmo di ricerca lineare

L'algoritmo di ricerca lineare è semplicemente un ciclo che parte dall'inizio dell'array A e si ferma o quando trova un elemento uguale a x oppure quando, avendo confrontato tutti gli elementi, può concludere che non esiste in A l'elemento cercato.

In questo algoritmo T sarà un qualsiasi tipo che consenta l'uso dell'operatore ==.

Una semplice implementazione è con un ciclo for e un break

bool trovato=false;

for(i=0; i<n; i++)

if(a[i]==x) {

trovato=true;

break; }

// a fine ciclo

if(trovato)

cout << “l'elemento si trova nella posizione “ << i << endl;

else

cout << “l'elemento non è presente nell'array\n”;

Il lettore è invitato a pensare una implementazione che non usa il break.

Si tratta di un algoritmo molto semplice che, nel caso peggiore, ovvero se l'elemento è nell'ultima

Page 99: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

posizione o non c'è, effettua n confronti.

10.1.2 Algoritmo di ricerca binaria

L'algoritmo di ricerca binaria sfrutta l'ipotesi che l'array sia ordinato in senso crescente, questo può accadere se l'array è creato o inserito dall'esterno in tale ordine, oppure se è stato ordinato mediante un algoritmo di ordinamento.

In questo algoritmo T sarà invece un qualsiasi tipo ordinabile, cioè che consente l'uso degli operatori di confronto <,<=,>,>=,!=, ==.

Nella ricerca binaria la ricerca viene svolta su una parte dell'array, detta parte valida, delimitata dai due indici s e d. All'inizio la parte valida corrisponde all'intero array, cioè s=0 e d=n-1.

Ad ogni passo la parte valida del vettore viene suddivisa in due parti uguali, prendendo come

separatore l'elemento centrale di posto m= sd2 . Le parti saranno chiamate parte sinistra,

composta dagli elementi di posto s, s+1, ..., m-2, m-1, e parte destra, composta dagli elementi di posto m+1, m+2, ..., d-1, d.

Se x è minore dell'elemento centrale, la ricerca continua nella parte di sinistra, in quanto ogni elemento della parte destra, essendo maggiore o uguale dell'elemento centrale, è sua volta maggiore di x; quindi si può escludere la presenza di x nella parte destra concentrandosi solo sulla parte sinistra.

Diversamente, se x è maggiore dell'elemento centrale, la ricerca continua nella parte di destra.

Infine se x è uguale all'elemento centrale, la ricerca termina con successo.

La ricerca termina con insuccesso quando la parte valida non può essere più ulteriormente suddivisa, cioè quando è composta da un solo elemento.

int s=0,d=n-1,m;

bool trovato=false;

do {

m=(s+d)/2;

if(a[m]==x)

trovato=true;

else if(a[m]>x)

d=m-1;

else

s=m+1;

} while(s<=d && !trovato);

Il ciclo termina quando trovato diventa true (e quindi x è presente in a), oppure quando s>d. Questa condizione diventa vera nel caso in cui x non è presente in a, infatti continuando a dividere l'array in due parti uguali, di cui se ne prende solo una, prima o poi si arriva a trattare con parti valide composte da uno o due elementi.

Page 100: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Nel caso di parti con un solo elemento, le variabili s,d,m hanno tutte lo stesso valore, quindi al passo successivo si avrebbe d=m-1=s-1 oppure s=m+1=d+1 e quindi diventa vera la condizione s>d.

Nel caso di parti con due elementi, m coincide con s e quindi potrebbe accadere che al passo successivo rimane un solo elemento, e quindi si ricade nel caso precedente, oppure il ciclo termina perché d=m-1=s-1.

L'algoritmo della ricerca binaria effettua, nel caso peggiore, O(log2 n) operazioni, in quanto log2 n è il numero massimo di volte in cui un array di n elementi può essere spezzato in due parti uguali (dimezzato) fino ad arrivare ad un solo elemento. Il caso peggiore può verificarsi sia nella ricerca con successo, sia nella ricerca con insuccesso.

10.2 Algoritmi di ordinamentoGli algoritmi di ordinamento ridispongono gli elementi di un array di n elementi di tipo T in modo crescente o decrescente, secondo una qualsiasi relazione d'ordine. Esistono svariati metodi di ordinamento, tra quelli più semplici vi sono i tre presentati nelle prossime sezioni.

E' comunque importante sapere che questi algoritmi non sono i migliori possibili, perchè usano un numero quadratico di operazioni. Esistono invece altri metodi, come il merge-sort o il quicksort (quest'ultimo però solo nel caso medio, anche se nella pratica è quello più usato), che necessitano di un numero dell'ordine di O(n log2n) operazioni.

Negli algoritmi che descriveremo T sarà ancora un tipo ordinabile, cioè che consente l'uso degli operatori di confronto <,<=,>,>=,!=, ==.

T sarà indicato genericamente con l'identificatore tipo, che potrà essere definito mediante un'opportuna typedef, ad esempio

typedef int tipo;

10.2.1 Algoritmo di ordinamento a bolle (Bubblesort)

L'ordinamento a bolle è molto semplice, ma non molto efficiente. Si basa sul seguente principio: se tra due elementi consecutivi, il primo è maggiore del secondo, allora questi due elementi sono sicuramente fuori posto e, come minimo, bisogna scambiarli fra di loro.

Questo controllo deve essere ripetuto più volte, infatti l'effetto di una singola passata che controlla ogni elemento con il precedente e in caso sfavorevole li scambia è solamente quello di garantire che l'elemento più grande sia messo al posto giusto, cioè all'ultimo posto dell'array.

Ad esempio partendo con l'array

3 4 7 9 11 8 5 2una singola passata porta l'array alla seguente situazione

3 4 7 9 8 5 2 11in cui solo 11 è al posto giusto.

Una seconda passata sarà in grado di portare il secondo elemento più grande al penultimo posto.

Page 101: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Si noti che il confronto tra il penultimo e l'ultimo è inutile in quanto l'ultimo è il più grande.

Nel nostro esempio si avrà

3 4 7 8 5 2 9 11in cui anche 9 è adesso al posto giusto.

In generale occorreranno n-1 passate, ognuna delle quali controllerà sempre meno elementi, in quanto gli elementi messi al posto giusto da ogni passata precedente non vanno più spostati (né confrontati):

int i,j;

for(i=1;i<=n-1;i++) {

for(j=0;j<n-i;j++) {

if(a[j]>a[j+1]) {

tipo temp=a[j];

a[j]=a[j+1];

a[j+1]=temp; }

}

}

Il numero di confronti è fisso e pari a (n-1)+(n-2)+...+1, che è quadratico in n. Il numero di scambi nel caso peggiore è anch'esso quadratico in n.

L'algoritmo può essere migliorato osservando che, se dopo un'intera passata, corrispondente ad un intero ciclo for interno, non si sono fatti scambi, allora l'array è già ordinato e si può uscire dal ciclo esterno (che diventa un ciclo do-while).

int i=1,j;

bool scambia;

do {

scambia=false;

for(j=0;j<n-i;j++) {

if(a[j]>a[j+1]) {

tipo temp=a[j];

a[j]=a[j+1];

a[j+1]=temp;

scambia=true; }

}

i++;

}while(scambia);

Page 102: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

10.2.2 Algoritmo di ordinamento per selezioneQuesto secondo algoritmo di ordinamento si basa su questo semplice principio: l'elemento più piccolo in un array ordinato deve stare al primo posto, il secondo elemento più piccolo deve stare al secondo posto, e così via.

Perciò il procedimento è il seguente. Si trova l'elemento più piccolo dell'array e lo si scambia di posto con il primo elemento.

Poi si trova il secondo elemento più piccolo e lo si scambia con il secondo elemento dell'array. E' facile trovare il secondo elemento più piccolo, in quanto sarà l'elemento minore dell'array a partire dalla seconda posizione, dato che la prima adesso contiene già l'elemento complessivamente più piccolo.

Generalizzando si ottiene il seguente codice

int i,j;

for(i=0;i<n-1;i++) {

// trova l'elemento più piccolo

// a partire dall'elemento i-esimo

int jmin=i;

for (j=i+1;j<n;j++) {

if (a[j]<a[jmin]) {

jmin=j; }

}

// dopo aver trovato il minimo corrente

// lo scambio con l'elemento al posto i

tipo temp=a[i];

a[i]=a[jmin];

a[jmin]=temp;

}

Si noti che anche nel caso dell'ordinamento per selezione un volta che il penultimo elemento è stato messo al posto giusto, anche l'ultimo elemento sarà stato sistemato, e quindi il ciclo for più esterno può terminare dopo n-1 passi.

Ad esempio partendo da

3 4 7 9 8 5 2 11si ottiene al primo passo

2 4 7 9 8 5 3 11e al secondo passo

2 3 7 9 8 5 4 11

Page 103: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

E' uno degli algoritmi più efficienti, perché fa solo n-1 scambi. Purtroppo anche questo algoritmo ha bisogno di un numero quadratico di confronti.

10.2.3 Algoritmo di ordinamento per inserzioneUn ulteriore algoritmo di ordinamento è quello per inserzione. Si tratta di un algoritmo basato sul metodo che usa un giocatore di carte (ad esempio a scala quaranta, ramino, ecc.) per ordinare le carte che ha in mano. Il procedimento si basa sullo spostamento delle carte in modo da creare una zona già ordinata che parte dalla mano sinistra: ogni carta della zona non ordinata è inserita nel posto che le compete all'interno della zona già ordinata.

Ad esempio avendo già disposto in ordine un asso di cuori, un tre di picche, un cinque ed un sei di fiori, il posto per un quattro di quadri è fra il tre ed il cinque. Ogni volta che si inserisce una carta in mezzo alle altre, avviene uno scorrimento delle carte di cui il giocatore non si rende conto (fondamentalmente perché le carte scivolano facilmente).

Per trasformare questo procedimento in un algoritmo funzionante su un array bisogna proprio esplicitare l'idea di far scorrere gli elementi dell'array per far posto all'elemento da inserire.

int i,j;

for(j=1;j<n;j++) {

tipo temp=a[j];

// sistemare a[j] nella parte a[0..j-1] già ordinata

i=j-1;

while(i>=0 && a[i]>temp) {

a[i+1]=a[i];

i--;

}

a[i+1]=temp;

}

Si notino alcune caratteristiche dell'algoritmo. Innanzitutto il primo elemento non viene inizialmente toccato: la parte già ordinata alla partenza è proprio costituita dal solo primo elemento.

L'algoritmo poi sistema tutti gli altri elementi nella parte già ordinata dell'array. L'elemento da sistemare è memorizzato nella variabile temp e l'algoritmo cerca il punto corretto in cui l'elemento da sistemare deve essere inserito nella parte già ordinata dell'array.

Quindi inizia un ciclo che scandisce la parte già ordinata iniziando dal fondo e che si ferma quando trova un elemento minore o uguale a quello da inserire, ogni volta ricopiando l'elemento controllato di una posizione verso destra, in modo da creare lo spazio dell'inserimento.

Ad esempio partendo dall'array

3 4 7 9 8 5 2 11

con i=4, si deve inserire 8 tra 7 e 9, ottenendo

3 4 7 8 9 5 2 11

Page 104: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

La ricerca termina anche quando tutto l'array è stato scandito, cioè quando l'elemento da inserire è minore di tutti gli elementi della parte già ordinata.

Anche tale algoritmo ha un costo quadratico in n sia in termini di confronti che di assegnamenti.

10.3 Esercizi10.1. Adattare la ricerca binaria al caso di array ordinati in senso decrescente

10.2. Adattare un algoritmo di ordinamento al caso di array di strutture, ordinati in base ad un campo, ad esempio ordinare un array di persona in base all'età

10.3. Adattare un algoritmo di ordinamento al caso in cui gli elementi da ordinare sono squadre nazionali olimpiche, cioè strutture con i campi nome della nazione, numeri di medaglie d'oro, medaglie d'argento e medaglie di bronzo. La relazione d'ordine tra squadre è di preferire quelle con più ori, a parità, più argenti e ulteriore parità più bronzi.

Page 105: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

11 Programmazione modulare e funzioni

11.1 Generalità sui sottoprogrammi

Uno degli strumenti più importanti nella programmazione per la creazione di programmi significativi è rappresentato dalla possibilità di definire e eseguire dei sottoprogrammi.

L'idea è in sostanza quella di suddividere un programma in unità indipendenti o scarsamente dipendenti l'una dall'altra, chiamate sottoprogrammi, ognuna delle quali è identificata da un nome ed è dotata di proprie variabili, costanti, tipi di dati, ecc. e di un corpo, cioè di un insieme di istruzioni da eseguire.

Un sottoprogramma può essere eseguito a seguito di una particolare istruzione, detta chiamata di sottoprogramma, la quale sospende l'esecuzione della parte di programma in corso e fa partire l'esecuzione del corpo del sottoprogramma.

Alla fine dell'esecuzione del sottoprogramma, l'esecuzione riparte da dove si era fermata precedentemente e cioè con l'istruzione immediatamente successiva alla chiamata.

Alcune considerazioni sull'utilità dei sottoprogrammi saranno descritte nell'ultima sezione di questo capitolo.

11.2 FunzioniI sottoprogrammi in C++ si chiamano funzioni anche in quei casi in cui gli altri linguaggi usano il termine procedura. In generale per funzione si intende un sottoprogramma che restituisce un risultato esplicito di un determinato tipo.

In C++ le funzioni devono essere definite al di fuori di main, prima o dopo. Nel secondo caso occorre però dichiarare la funzione prima o dentro main come vedremo nella sezione X.

In C++ non è quindi possibile definire funzioni annidate, cioè una funzione dentro ad un'altra. Tale possibilità è presente in alcuni linguaggi, ad esempio in Pascal, ma in C e in C++ non è stata inserita per motivi di efficienza, come vedremo nella sezione X.

11.2.1 Definizione di una funzioneUna funzione in C++ è definita mediante la seguente sintassi

definizione_funzione ::= tipo identificatore “(“ [lista_parametri] “)” blocco

lista_parametri ::= parametro { “,” parametro }

parametro ::= [ “const” ] tipo [ “&” ] identificatore

Se per un parametro è dato solo il tipo e il nome (identificatore), il parametro è passato per valore (vedere sezione 11.X)

Se è indicato il simbolo &, il parametro è passato per riferimento (vedere sezione 11.X)

Se infine è indicato sia const che &, il parametro è passato per riferimento costante (vedere sezione 11.X).

Page 106: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Ad esempio con la definizione

double potenza(double base, int esponente) {

double prodotto=1;

int i;

for(i=1;i<=esponente;i++)

prodotto *= base;

return prodotto;

}

si definisce una funzione che

• si chiama potenza

• ha due argomenti, base di tipo double e esponente di tipo int, entrambi passati per valore (poiché non c'è il &)

• restituisce come risultato un valore di tipo double

• ha due variabili locali, prodotto di tipo double e i di tipo int

Si noti che la definizione di una funzione, di per sé, non ne causa la sua esecuzione: una funzione, per essere eseguita, deve essere chiamata.

Dalla sintassi appena introdotta si capisce che main è una funzione, di tipo int e può avere zero oppure due argomenti. Noi useremo sempre la versione con zero argomenti; per sapere a cosa serve la versione con due argomenti rimandiamo il lettore interessato ad un qualsiasi manuale del linguaggio.

La funzione main ha la particolarità che non è mai chiamata esplicitamente, solo implicitamente dall'ambiente di esecuzione o dal sistema operativo per iniziare l'esecuzione del programma.

11.2.2 Chiamata di funzione

Una funzione viene chiamata con la seguente sintassi

chiamata_funzione ::= identificatore “(“ [lista_argomenti] “)”

lista_argomenti ::= argomento { “,” argomento }

Una chiamata ad una funzione f di tipo T può essere svolta soltanto all'interno di un'espressione in cui sia richiesto un valore di tale tipo. Gli argomenti della chiamata devono corrispondere per numero, tipo e posizione con i parametri della funzione chiamata.

Ad esempio sono chiamate valide alla funzione potenza

• r=potenza(3.1,4) se r è una variabile double

• r=3*potenza(a, n) se anche a è una variabile double e n una variabile int

• r=a+potenza(a+b, n-2) se anche b è una variabile double

Non sono chiamate valide

Page 107: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

• r=potenza(3.1) perché c'è un solo argomento

• r=potenza(3.1,2,1) perché ci sono tre argomenti

• r=potenza(3.1,”abc”) perché il secondo argomento è una stringa e non un int

• s=potenza(3.1,4) se s è una variabile di tipo string

11.2.3 Istruzione returnL'istruzione return ha un duplice effetto in una funzione f: indica qual è il risultato di f e fa terminare la sua esecuzione.

La sua sintassi è

istruzione_return ::= “return” espressione “;”

L'espressione nell'istruzione return deve essere dello stesso tipo della funzione: il valore di tale espressione sarà il risultato restituita dalla chiamata della funzione.

Pertanto è evidente che una funzione non può restituire come risultato né un array (non esistono espressioni di tipo array), né più di un risultato.

Anzi la presenza di più istruzioni return consecutive non ha senso e può essere rilevata come errore da parte di qualche compilatore

int funzione(int n) {

return 2*n;

return 3*n; // questa istruzione non sarà mai eseguita

}

Invece è perfettamente plausibile la presenza di più return in percorsi diversi di una funzione. Ad esempio per calcolare il più grande di due numeri interi si può scrivereint massimo(int a,int b) {

if(a>b)

return a;

else

return b;

}

o addirittura , senza usare elseint massimo(int a,int b) {

if(a>b)

return a;

return b;

}

Questa seconda soluzione è equivalente alla prima, in quanto return termina l'esecuzione della funzione. Comunque ha una leggibilità minore (non è chiarissimo a prima vista in quali casi restituisce b).

Page 108: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

11.3 Semantica della chiamata e passaggio dei parametri per valore

La chiamata di funzione ha la seguente semantica. Supponiamo che la funzione g è in esecuzione e che sta per chiamare la funzione f con argomenti a1,..., an. La funzione f avrà come parametri p1,...,pn

i quali corrisponderanno per tipo ai rispettivi argomenti.

Accadono in sequenza i seguenti eventi:

1.sono allocate in memoria tutte le variabili locali di f, compresi i parametri, mediante la creazione sullo stack del record di attivazione di f (vedere sezione 11.X);

2.gli argomenti a1,..., an sono valutati e ogni valore è memorizzato nel corrispondente parametro;

3.l'esecuzione di g è sospesa e l'indirizzo dell'istruzione corrente è salvato come indirizzo di ritorno nel record di attivazione;

4.inizia l'esecuzione di f;

5.l'esecuzione di f termina con la prima istruzione return eseguita ed il risultato di f è il valore dell'espressione associata alla return;

6.il record di attivazione di f è eliminato dallo stack, così che tutte le variabili locali e i parametri di f scompaiono dalla memoria;

7.l'esecuzione di g riprende dal punto in cui si era fermata e potrà utilizzare il risultato di f.

I punti 3 e 4 sono svolti, di solito, da un'unica istruzione del linguaggio macchina, la chiamata di subroutine, la quale memorizza l'indirizzo dell'istruzione corrente (il contenuto del registro PC) e poi salta ad un'altra istruzione (la prima del sottoprogramma). Il punto 7 è svolto anche dall'istruzione inversa, il ritorno da subroutine, il quale ripristino il precedente contenuto del registro PC.

Un esempio completo di esecuzione di una funzione è il seguente

double potenza(double base, int esponente) {

double prodotto=1;

int i;

for(i=1;i<=esponente;i++)

prodotto *= base;

return prodotto;

}

int main() {

int n=5;

double x=4;

double r=potenza(3,5);

double s=potenza(x,3);

cout << r+s;

}

La prima chiamata della funzione potenza avviene con gli argomenti 3 e b, in cui il secondo vale 5.

Page 109: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

L'esecuzione della funzione potenza parte con base inizializzato con il valore 3 ed esponente con il valore 5. Il risultato restituito e memorizzato in r sarà 243.

La seconda chiamata ha come argomenti x, che vale 4, e 3, i quali serviranno per inizializzare base ed esponente. Il risultato questa volta sarà 64, che sarà memorizzato in s.

Se l'esecuzione di una funzione non termina con una return, ma con la fine del corpo, il risultato della funzione è imprevedibile.

11.3.1 Caratteristiche del passaggio per valoreLa modalità di associazione tra argomento e parametro si chiama passaggio. La semantica vista precedentemente è relativa al passaggio per valore, che è il passaggio per default per tutti i parametri, ad esclusione degli array.

Le caratteristiche del passaggio per valore sono :

1.l'argomento può essere una qualsiasi espressione dello stesso tipo del parametro

2.il parametro è a tutti gli effetti una variabile locale della funzione

3.l'argomento viene valutato al momento della chiamata

4.il valore così trovato è usato per inizializzare il parametro prima che la funzione chiamata inizi ad essere eseguita.

Il passaggio per valore quindi comporta che il valore l'argomento è copiato nel rispettivo parametro, infatti il termine scientifico completo per questa tipologia di passaggio è per copia-valore.

Il parametro, essendo una copia indipendente dell'argomento, può essere modificato dalla funzione senza che l'argomento corrispondente (ammesso che sia una variabile) subisca variazioni.

Infatti nel seguente esempio

int funzione(int n) {

n++;

return 2*n;

}

int main() {

int a=4;

cout << funzione(a) << endl; // scrive 10

cout << a << endl; // scrive 4

}

la variabile a rimane inalterata, nonostante che il parametro n della funzione passi da 4 a 5.

11.4 Regole di visibilitàLa presenza delle funzioni, ma il problema si porrebbe anche semplicemente usando blocchi annidati, richiede la definizione precisa delle regole di visibilità degli elementi di programma (variabile, costante, tipo di dato, ecc.) definiti dal programmatore.

Le regole principali che vigono in C++, ma anche in molti altri linguaggi di programmazione, sono:

1.un elemento del programma definito all'interno di un blocco B è locale e quindi utilizzabile solo in B e in tutti i blocchi contenuti in B, a partire dal punto in cui è stato dichiarato in poi;

Page 110: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

2.un elemento del programma definito all'esterno di qualsiasi blocco è globale e quindi utilizzabile in tutto il programma, dal punto in cui è stato dichiarato in poi;

3.se un elemento definito in un blocco B ha lo stesso nome di un elemento definito in un blocco più esterno a B, in B e in tutti i blocchi contenuti in B è accessibile solo questo nuovo elemento e non è più accessibile il vecchio elemento.

Ad esempio

int main() {

int a,b,c;

{

int d,e;

// qui sono visibili a,b,c,d,e

}

{

int x,y;

double c;

// qui sono visibili a,b,c,x,y

// c è un'altra variabile

}

// qui sono visibili a,b,c

}

Come caso particolare si noti che ogni elemento definito all'interno di una funzione non è visibile in altre funzioni. Ciò accade anche per gli elementi definiti in main, che non sono visibili nelle funzioni. Solo gli elementi globali, cioè definiti al di fuori delle funzioni, possono essere condivisi da tutte le funzioni.

Ad esempio

int x; // variabile globale

int funz1() {

int a;

double k;

// qua sono visibili a,k,x

// a è una variabile diversa da quella definita in main

}

int funz2() {

int b,c;

char x;

// qua sono visibili b,c,x

Page 111: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

// b è una variabile diversa da quella definita in main

// x è una variabile diversa da quella globale

}

int main() {

int a;

double b;

// qua sono visibili a,b,x

// a è una variabile diversa da quella definita in funz1

// b è una variabile diversa da quella definita in funz2

}

11.5 Funzioni void

Una funzione può avere una qualche utilità senza per questo dover produrre un risultato esplicito. Ad esempio una funzione potrebbe scrivere dati sullo schermo (o inviarli ad altre periferiche o memorizzarli su file) oppure potrebbe modificare variabili globali.

In tali casi è possibile dichiarare che il tipo della funzione è void. Tale tipo si usa per indicare l'assenza di valori. Sarà usato anche per indicare un puntatore non tipizzato (capitolo 13).

Le funzioni void, che in altri linguaggi si chiamano procedure, differiscono sostanzialmente per il contesto in cui possono essere chiamate. Infatti la chiamata ad una funzione void può essere trattata alla stregua di un'istruzione elementare e non può essere inserita all'interno di un'espressione, dato che non restituisce alcun valore.

Un esempio di funzione void è il seguente

void scrivi_tabella_quadrati(int n) {

int i;

for(i=1;i<=n;i++)

cout << i << “ “ << i*i << endl;

}

int main() {

scrivi_tabella_quadrati(7);

}

In questo esempio si usa una funzione per scrivere sullo schermo una tabella dei numeri da 1 a 7 e dei corrispondenti quadrati.

Una funzione void non restituendo un risultato, non ha in generale bisogno dell'istruzione return. Comunque è possibile usare tale istruzione, senza espressione, per chiudere anticipatamente l'esecuzione della funzione.

Page 112: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Ad esempio

void funzione(int n) {

if(n==0)

return;

}

11.6 Passaggio per riferimento

Il passaggio dei parametri può essere svolto anche per riferimento. Dal punto di vista sintattico è necessario indicare la & davanti al nome del parametro.

Per specificare la semantica, per ora ci accontentiamo di dire che

1.l'argomento può essere una qualsiasi variabile dello stesso tipo del parametro

2.il parametro viene “collegato” in qualche modo all'argomento, quindi non è una variabile locale indipendente

3.ogni accesso, sia in lettura, sia in scrittura, che la funzione effettua sul parametro viene svolto in realtà sull'argomento corrispondente

Una spiegazione più realistica di ciò che avviene nel passaggio per riferimento sarà data nel capitolo 13.

Come primo esempio di utilizzo del passaggio per riferimento vediamo una funzione che scambia due variabili intere:

void scambia(int &x,int &y) {

int z=x;

x=y;

y=z;

}

int main() {

int a=3, b=4;

cout << a << “ “ << b << endl;

scambia(a,b);

cout << a << “ “ << b << endl;

}

La prima istruzione di scrittura scrive 3 4, mentre la seconda, dopo la chiamata della funzione, scriverà 4 3. Infatti al momento della chiamata, il parametro x sarà collegato all'argomento a e y a b.

La funzione scambiando x con y, in realtà sta scambiano a e b (anche se non vi può accedere direttamente).

Si noti che usando il passaggio per valore, x e y sarebbero scambiati, ma a e b rimarrebbero inalterati.

Il collegamento tra x e a, come quello tra y e b, è solo momentaneo. In quest'altro programma,

Page 113: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

infatti, scambia è chiamata due volte e ogni volta sarà eseguita collegando x e y ad argomenti diversi.

void scambia(int &x,int &y) {

...

}

int main() {

int a=3, b=4, c=5, d=7;

scambia(a,b);

cout << a << “ “ << b << endl;

scambia(c,d);

cout << c << “ “ << d << endl;

}

Il passaggio per riferimento è utile nel caso in cui la funzione debba restituire “più risultati”. Ad esempio la risoluzione dell'equazione di secondo grado ax2+bx+c=0 produce tre risultati: x1,x2 (le due eventuali soluzioni) e ns, il numero di soluzioni (0, 1, 2). L'idea è quella che la funzione ha come parametri passati per valore i tre coefficienti a,b e c. Mentre ha come parametri passati per riferimento x1, x2 e ns, di modo che i dati che la funzione vi memorizza possano essere ripresi da chi richiama la funzione.Void risolvi_eq_2_grado(double a,double b,double c,

int &ns, double &x1,double &x2) {

double delta=b*b-4*a*c;

if(delta>0) { // due soluzioni

double r=sqrt(delta);

ns=2;

x1=(-b-r)/(2*a);

x2=(-b+r)/(2*a); }

else if(delta==0) { // una soluzione

ns=1;

x1=-b/(2*a); }

else // nessuna soluzione reale

ns=0;

}

Una possibile chiamata di tale funzione èint main() {

double s1,s2,nr;

risolvi_eq_2_grado(1,-3,2,nr,s1,s2);

cout << “l'equazione ha “ << nr << “ soluzioni\n”;

switch(nr) {

Page 114: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

case 2:

cout << s1 << “ “ << s2 << endl;

break;

case 1:

cout << s1 << endl;

break;

}

}

E' importante ricordare che per usare il passaggio per riferimento l'argomento deve essere una variabile, cioè un oggetto modificabile. Non può essere né una costante, né il risultato di un'espressione.

11.7 Parametri di tipo strutturatoI parametri di tipo strutturato (array, struct, ecc.) devono essere trattati separatamente.

11.7.1 Parametri di tipo array

In C++, per motivi di efficienza, gli array sono sempre passati per riferimento: non è possibile passare un array per valore.

La sintassi per definire un parametro di tipo array unidimensionale è

[“const”] tipo identificatore “[“ [costante_intera ] “]”

Quindi è possibile indicare che il passaggio avviene anche per riferimento costante (se c'è const) oppure solo per riferimento (senza const). Non si deve mettere il simbolo &, dato che è implicito che si usa il passaggio per riferimento. Infine la dimensione dell'array è facoltativa, visto che il compilatore la ignora, sono però obbligatorie le parentesi quadre.

L'argomento corrispondente ad un parametro di tipo array deve essere solo il nome dell'array (e niente altro).

Ad esempio, una funzione che somma gli elementi di un array di 10 int è

int somma_array(int a[10]) {

int i, somma=0;

for(i=0;i<10;i++) {

somma += a[i]; }

return somma;

}

ma in maniera più generale e con piccole modifiche è possibile scrivere una versione della stessa funzione che calcola la somma di un array di int con una qualsiasi dimensione, purché questa passata come parametro:

int somma_array(int a[],int n) {

int i, somma=0;

for(i=0;i<n;i++) {

somma += a[i]; }

Page 115: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

return somma;

}

Esempi di chiamata a tale nuova funzione sonoint main() {

int vett1[10], vett2[5];

...

cout << somma_array(vett1,10) << endl;

cout << somma_array(vett2,5) << endl;

}

Ciò è possibile, in prima approssimazione, perché il parametro a non è un nuovo array, ma si può pensare che nella prima chiamata coincide con vett1 ed ha 10 elementi, mentre nella seconda chiamata coincide con vett2 ed ha 5 elementi.

Una funzione che ha un parametro di tipo array può modificare l'argomento corrispondente, in quanto il passaggio avviene per riferimento. Ad esempio ecco un'implementazione del bubble-sort tramite una funzione:

void bubble_sort(int a[],int n) {

int i,j;

for(i=0;i<n-1;i++) {

for(j=0;j<n-1-i;j++) {

if(a[j]>a[j+1]) {

int temp=a[j];

a[j]=a[j+1];

a[j+1]=temp; }

}

}

}

Non tratteremo il caso di parametri di tipo array multidimensionale: si rimanda alla consultazione di un manuale per conoscere le differenze esistenti con quelli unidimensionali.

11.7.2 Passaggio per riferimento costantePer indicare che una funzione come somma_array non modifica né deve modificare il parametro e quindi l'argomento, si può usare il passaggio per riferimento costante.

int somma_array(const int a[],int n) {

int i, somma=0;

for(i=0;i<n;i++) {

somma += a[i]; }

return somma;

}

Page 116: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

In questo modo il compilatore controlla che effettivamente la funzione non accede in scrittura al parametro.

Ha senso usare il passaggio per riferimento costante in tutti i casi in cui si vuole avere la certezza che una funzione non alteri l'argomento di un parametro passato per riferimento.

C'è inoltre da dire che l'argomento di un parametro passato per riferimento costante potrebbe essere anche il risultato di un'espressione. Ovviamente questo non può accadere per gli array (non esistono espressioni di tipo array), ma potrebbe accadere con tipi elementari, come numeri interi o reali. Non approfondiremo ulteriormente questa caratteristica.

11.7.3 Parametri di tipo structPer i parametri di tipo struct si hanno le stesse possibilità dei parametri di tipo non strutturato, ovvero per valore, per riferimento o per riferimento costante.

Ad esempio una volta definita una struttura per le frazioni

struct frazione {

int num, den;

};

si possono definire sia una funzione che scrive una frazione sullo schermo

void scrivi_frazione(frazione f) {

cout << f.num << "/" << f.den << endl;

}

sia una funzione che semplifica una frazione (avendo a disposizione una funzione che calcola il massimo comun divisore)

void semplifica_frazione(frazione &f) {

int m=mcd(f.num,f.den);

f.num /= m;

f.den /= m;

}

In realtà per motivi di efficienza è vivamente consigliato passare sempre i parametri di tipo strutturato per riferimento, per cui anche la funzione scrivi_frazione andrebbe scritta come

void scrivi_frazione(frazione &f) {

cout << f.num << "/" << f.den << endl;

}

o meglio ancora, con riferimento costante

Page 117: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

void scrivi_frazione(const frazione &f) {

cout << f.num << "/" << f.den << endl;

}

Negli ultimi standard di C e C++ una funzione può anche restituire un dato struct come risultato.

Ad esempio ecco una versione di semplifica_frazione che restituisce come risultato una copia semplificata del proprio argomento

frazione semplifica_frazione(const frazione &f) {

int m=mcd(f.num,f.den);

frazione f1;

f1.num = f.num / m;

f1.den = f1.den /m;

return f1;

}

11.8 Intento e passaggioI parametri di una funzione possono essere classificati in base all'intento, cioè all'uso che il programmatore vuole farne. Si possono elencare tre intenti:

1.solo ingresso: il parametro serve alla funzione esclusivamente come dato (come valore iniziale)

2.sola uscita: il parametro serve alla funzione esclusivamente per far avere un risultato all'esterno

3.ingresso/uscita: il parametro serve alla funzione per leggere e successivamente modificare l'argomento

Negli esempi precedenti,

1. i parametri base ed esponente della funzione potenza hanno intento di solo ingresso;

2. i parametri ns, x1 e x2 di risolvi_eq_2_grado hanno intento di sola uscita

3. i parametri x e y di scambia hanno intento di ingresso/uscita.

Per i parametri di tipo elementare la corrispondenza tra intento e metodo di passaggio è molto semplice

• solo ingresso, è consigliato il passaggio per valore, è possibile anche il passaggio per riferimento costante

• sola uscita o ingresso/uscita, è obbligatorio il passaggio per riferimento

Queste due regole servono a capire quale tipo di passaggio si deve usare per ogni parametro.

Per i parametri di tipo strutturato la situazione è un po' diversa: la corrispondenza all'intento

Page 118: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

• solo ingresso, è consigliato il passaggio per riferimento costante

• sola uscita o o ingresso/uscita, è obbligatorio il passaggio per riferimento

Mentre per gli array è ovvio, avendosi solo il passaggio per riferimento, per le struct non è conveniente usare il passaggio per valore, per motivi che saranno chiari nel prossimo capitolo.

11.9 Definizione e dichiarazione di funzioni

Abbiamo per ora visto la modalità più semplice di uso di una funzione: prima si definisce una funzione e poi la si richiama. In molte situazioni si vuole usare l'ordine inverso, ad esempio perché così main si trova all'inizio del sorgente.

In realtà il C++ richiede che per richiamare una funzione questa debba essere dichiarata e non è necessario che sia definita.

La differenza tra dichiarare e definire una funzione è che nella dichiarazione si deve specificare solo il prototipo, che essenzialmente è la prima parte della definizione: nome della funzione, tipo ed elenco dei parametri (anche senza il nome).

E' indispensabile, però, che la funzione sia definita successivamente.

Ad esempio anziché scrivere

// definizione di fattoriale

int fattoriale(int n) {

int i,f=1;

for(i=1;i<=n;i++)

f *= i;

return f;

}

int main() {

int n;

cout << “inserisci un numero “;

cin >> n;

cout << n << “!=”<< fattoriale(n) << endl;

}

si può scrivere

int fattoriale(int); // prototipo di fattoriale

int main() {

int n;

cout << “inserisci un numero “;

cin >> n;

cout << n << “!=”<< fattoriale(n) << endl;

}

// definizione di fattoriale

Page 119: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

int fattoriale(int n) {

int i,f=1;

for(i=1;i<=n;i++)

f *= i;

return f;

}

Si noti che nel prototipo non è stato indicato il nome dell'argomento.

Si può anche dichiarare fattoriale dentro main:

int main() {

int fattoriale(int); // prototipo di fattoriale

int n;

cout << “inserisci un numero “;

cin >> n;

cout << n << “!=”<< fattoriale(n) << endl;

}

// definizione di fattoriale

...

In realtà il C++ si accontenta anche che la definizione di una funzione sia data in un altro sorgente, a patto però che i due file “oggetto”, ottenuti dalla compilazione dei due sorgenti sia collegati insieme durante la fase di linking. In alternativa è anche sufficiente che la funzione sia stata già compilata ed abbiamo a disposizione solo il file oggetto (o la libreria che li contiene). Non approfondiremo ulteriormente questo argomento.

11.10 Record di attivazioneLe variabili locali, cioè quelle dichiarate all'interno di un blocco (sia quelli “anonimi”, sia quelli usati nella definizione delle funzioni) sono allocate in una parte di memoria organizzata a stack, chiamata stack di sistema.

Uno stack (o pila) è una struttura informativa che consente di memorizzare degli elementi e di eliminarli nell'ordine inverso in cui sono stati inseriti. Tale politica si chiama LIFO (Last In First Out, ovvero l'ultimo entrato è il primo ad uscire). Ad esempio in uno stack di caratteri inserendo gli elementi A, B e poi C, il primo ad essere eliminato è C. Se a questo punto si inseriscono gli elementi D e E, quattro successive eliminazioni estrarranno gli elementi E, D, B ed infine A. Vedremo un'implementazione delle pile mediante liste puntate nel capitolo 13.

Ogni elemento dello stack di sistema si chiama record di attivazione e contiene tre parti:

1. l'indirizzo di ritorno, solo per i blocchi associati alle funzioni: questo è l'indirizzo a cui deve ripartire l'esecuzione quando la funzione chiamata termina;

2. l'indirizzo del record di attivazione precedente: i record sono collegati mediante la cosiddetta catena dinamica e questo indirizzo serve ad eliminare il record;

3. spazio per le variabili locali ed i parametri, quest'ultimi solo per i blocchi associati alle funzioni.

Abbiamo visto nella sezione 11.X che ogni volta che una funzione viene chiamata, è creato un record di attivazione sullo stack e ogni volta che una funzione termina viene eliminato il suo record

Page 120: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

di attivazione.

Mostriamo ora il funzionamento dello stack in questo esempio, in cui l'indirizzo di ritorno è semplicemente indicato con la funzione a cui si deve ritornare e la catena dinamica è rappresentata mediante una freccia che indica il record precedente:

void funz1(int n) {

int c;

}

void funz2(int a,double b) {

char c;

...

funz1(a);

...

}

void funz3(int x,int y) {

double k;

...

funz2(k,x);

...

funz1(y);

...

}

int main() {

int a,b;

...

funz3(a,b);

...

}

All'inizio abbiamo nello stack solo il R.A. (record di attivazione) di main:

R.A. di main

ind.rit.=sistema operativo

cat.din.

a=

b=

Non scriveremo il valore delle variabili.

Page 121: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

poi main chiama funz3 e lo stack diventa

R.A. di funz3

ind.rit=main

cat.din.

x=

y=

k=

R.A. di main

ind.rit=sistema operativo

cat.din.

a=

b=

poi funz3 chiama funz2 e lo stack diventa

R.A. di funz2

ind.rit.=funz3

cat.din.

a=

b=

c=

R.A. di funz3

ind.rit.=main

cat.din.

x=

y=

k=

R.A. di main

ind.rit=sistema operativo

cat.din.

a=

b=

Page 122: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

funz2 a sua volta chiama funz1 e lo stack diventa

R.A. di funz1

ind.rit.=funz2

cat.din.

n=

c=

R.A. di funz2

ind.rit.=funz3

cat.din.

a=

b=

c=

R.A. di funz3

ind.rit.=main

cat.din.

x=

y=

k=

R.A. di main

ind.rit=sistema operativo

cat.din.

a=

b=

A questo punto sia funz1 che funz2 terminano e lo stack torna ad avere come elemento più in alto il R.A. di funz3. Questa funzione chiama funz1 e quindi si avrà questa situazione sullo stack

R.A. di funz1

ind.rit=funz3cat.din.

n=

c=

R.A. di funz3

ind.rit=main

cat.din.

x=

y=

k=

R.A. di main

ind.rit=sistema operativo

cat.din.

Page 123: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

a=

b=

Alla fine tutte le funzioni termineranno e lo stack si svuoterà progressivamente.

E' interessante vedere che funz1, essendo richiamata in contesti diversi, sarà in posizioni diverse nello stack, nella prima chiamata aveva il quarto R.A. a partire dal basso, nella seconda il terzo. Ciò comporta che nelle due chiamate le variabili saranno allocate in zone diverse della memoria.

11.11 Considerazione finali sull'uso delle funzioni

La programmazione mediante l'uso di sottoprogrammi presenta notevoli vantaggi rispetto alla creazione di programmi monolitici, cioè composti dal solo main.

Innanzitutto il programma è suddiviso in piccole parti, ognuna delle quali svolge un compito limitato, perciò diventa più leggibile e più comprensibile. Ognuna di queste parti può essere verificata o testata separatamente e in generale è più semplice mantenerle e modificarle, piuttosto che modificare un intero programma.

I sottoprogrammi possono far uso di parametri, rendendo il risultato che essi producono dipendente dagli argomenti definiti nella chiamata. Ad esempio anziché avere un sottoprogramma specifico che opera su dati fissi è possibile creare un sottoprogramma più generale che opera su dati che saranno specificati al momento della chiamata.

Un sottoprogramma può essere chiamato più volte, al prezzo di una singola definizione, e quindi può far risparmiare molte ripetizioni di codice. Ad esempio se una serie di istruzioni occorre più volte in un programma, può essere conveniente definire un sottoprogramma che ha tali istruzioni nel corpo, di modo che ogni volta che si vuole eseguire tali istruzioni, serve solo effettuare la chiamata a tale sottoprogramma.

L'utilizzo di sottoprogrammi può servire a creare una sorta di linguaggio di livello più alto, in cui si possono usare i sottoprogrammi come istruzioni aggiuntive. Questa caratteristica è visibile ad esempio quando si lavora con librerie di sottoprogrammi, in cui si trattano tali sottoprogrammi come se fossero primitive del linguaggio.

Infine, la suddivisione in sottoprogrammi rende più agevole il riutilizzo del software, cioè la possibilità di riusare una componente già definita e usata precedentemente, piuttosto che riusare una parte di un programma intero.

11.12 Esercizi11.1. Definire una funzione che calcola il massimo comun divisore di due numeri interi.

11.2. Definire una funzione che calcola il fattoriale di un numero intero

11.3. Definire una funzione che calcola il coefficiente binomiale di due numeri interi, sia come funzione a se stante, sia usando la funzione dell'esercizio precedente.

11.4. Definire una funzione che calcola il massimo di un array di n numeri reali (n è un parametro).

11.5. Definire una funzione che calcola contemporaneamente, sia il massimo comun divisore che il minimo comune multiplo di tre numeri interi, restituendoli in due parametri.

Page 124: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

11.6.Definire una funzione che ordina un array di N numeri reali mediante l'algoritmo di selezione.

11.7. Definire una funzione che, dato un numero intero n, conta quanti sono i numeri primi minori o uguali a n, servendosi di un'ulteriore funzione che controlla se un numero è primo.

11.8.Definire una funzione void che scrive sullo schermo un numero intero in base 2 (vedere l'analogo esercizio sugli array).

11.9. Definire una funzione che scrive sullo schermo le prime n righe del triangolo di Tartaglia, avvalendosi di due funzioni che calcolano, rispettivamente, il fattoriale e il coefficiente binomiale.

11.10. Definire una funzione che scrive a parole sullo schermo un numero intero compreso tra 1 e 999. Suggerimento: definire una funzione che scrive un numero tra 1 e 9, una che scrive un numero tra 10 e 19, una che scrive le decine tra 20 e 90.

11.11.Estendere l'esercizio precedente al caso dei numeri tra 1 e 999.999.

11.12.Definire una funzione che calcola il seno di un numero reale x mediante la formula di Taylor.

11.13. Definire una funzione che calcola la derivata prima di un polinomio p di grado n. I coefficienti di p sono in un parametro array di n+1 numeri reali. La derivata, essendo un polinomio di grado n-1, avrà n coefficienti, i quali dovranno essere memorizzati in un parametro array di n numeri reali.

11.14.Definire un programma che consente di svolgere le operazioni di addizione, sottrazione, moltiplicazione e divisione tra frazioni, implementando ogni operazione mediante funzioni.

Page 125: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

12 Ricorsione

12.1 Definizioni ricorsiveLa ricorsione è uno degli strumenti più potenti all'interno della programmazione. Ma al contempo è anche uno degli strumenti più difficili da utilizzare. Alcune considerazioni sulla sua utilità saranno tratte alla fine del capitolo.

In matematica è possibile definire una funzione f in maniera ricorsiva, cioè in cui f compare all'interno della propria definizione. Il classico esempio è la funzione fattoriale, che ricordiamo è definita, per un numero naturale n, ovvero il prodotto di tutti i numeri naturali da 1 a n. Tale funzione si indica con n!.

Indicando con f la funzione che ad ogni numero naturale n associa il numero n! si osserva che

f(n)=n f(n-1) per ogni n>0

Infatti f(n)=n!=1 2 3 ... (n-1) n= [1 2 3 ... (n-1)] n=(n-1)! n= n f(n-1).

Inoltre vale f(0)=1 in quanto il fattoriale di 0 è per definizione 1. Inoltre si ha che f(1) che deve valere 1, corrisponde a 1 f(0).

Una definizione ricorsiva di f è quindi

1. f(n)=1, se n=0

2. f(n)=n (f-1), se n>0.

La prima legge è detta legge del caso base, mentre la seconda è detta regola ricorsiva.

Tale definizione consente di calcolare “effettivamente” il valore di f per qualsiasi numero n. Ad esempio

f(4)=4 f(3)

f(3)=3 f(2)

f(2)=2 f(1)

f(1)=1 f(0)

f(0)=1

f(1)=1 1=1

f(2)=2 1=2

f(3)= 3 2=6

f(4)=4 6=24

Per vedere che tale definizione f corrisponde veramente alla funzione fattoriale si può usare il principio di induzione matematica. Questo principio dice che per dimostrare che una proprietà P vale per ogni numero naturale n è sufficiente dimostrare che

1.P è vera per n=0

Page 126: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

2.Per ogni numero naturale n>0, nell'ipotesi in cui P sia vera per n-1, allora P è vera anche per n

Applicando il principio di induzione facciamo quindi vedere che la proprietà

P: f(n)=n!

è vera per ogni numero naturale n.

Se n=0, ovviamente f(0)=1=0!

Sia allora n un qualsiasi numero naturale diverso da 0. Supponiamo che P è vera per n-1, ovvero che f(n-1)=(n-1)!. Allora f(n)=n f(n-1)=n (n-1)!=n! e quindi P è vera anche per n.

In alcune situazioni serve una formulazione diversa del principio di induzione: per dimostrare che una proprietà P vale per ogni numero naturale n è sufficiente dimostrare che

per ogni numero naturale n, nell'ipotesi in cui P è vera per ogni numero naturale minore di n, allora P è anche per n.

In tale formulazione è richiesto, tra l'altro, di dimostrare che P è vera per n=0, dato che non esistono numeri naturali minori di 0 e quindi l'ipotesi “P è vera per ogni numero naturale minore di n” quando n è 0 è banalmente verificata.

12.2 Ricorsione nei linguaggi di programmazioneIn C++ e nei linguaggi moderni è possibile definire funzioni ricorsive, ovvero funzioni che hanno nel loro una o più chiamate a se stesse.

Ogni volta che una funzione chiama se stessa viene creato un nuovo record di attivazione sullo stack. Infatti ogni chiamata deve agire su nuove versioni delle variabili locali e dei parametri.

Quindi la ricorsione è resa possibile, fondamentalmente, dall'allocazione automatica delle variabili. Infatti in tutti quei linguaggi che allocano le variabili solo staticamente (ad esempio, le vecchie versioni di Fortran, Cobol e Basic) ogni eventuale chiamata ricorsiva opererebbe comunque sulle stesse variabili, vanificando il corretto funzionamento della ricorsione.

Un aspetto importante da notare è che l'occupazione dello stack cresce con il numero di chiamate ricorsive. Quindi una funzione ricorsiva ha bisogno di una quantità di memoria che dipende dal numero di tali chiamate: vi potrebbe essere il caso di funzioni che non possono richiamarsi ricorsivamente così tante volte a causa della mancanza di memoria.

Esistono anche situazioni in cui due funzioni f e g si richiamano a vicenda, ovvero f chiama g e g chiama f. Tali funzioni sono dette mutuamente ricorsive. Non tratteremo tale argomento.

12.3 L'esempio del fattorialeForniamo ora un esempio di funzione ricorsiva in C++ basato sull'esempio fornito nella prima sezione: la funzione fattoriale.

int fattoriale(int n) {

if(n==0)

return 1;

else {

Page 127: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

int ric=fattoriale(n-1);

int ris=n*ric;

return ris;

}

}

Tale funzione è stata volutamente scritta in modo “prolisso” per mettere meglio in evidenza i valori delle chiamate ricorsive e i risultati.

Simuliamo ora cosa accade quando la funzione fattoriale è richiamata con argomento n=4.

Tale chiamata crea sullo stack il record di attivazione (per brevità non mostriamo né l'indirizzo di ritorno né l'indirizzo del record precedente)

n=4

ric=?

ris=?

A questo punto la funzione chiama se stessa con argomento n=3, creando un nuovo record di attivazione

n=3

ric=?

ris=?

n=4

ric=?

ris=?

Continuando così le chiamate successive arrivano fino a n=0 e sullo stack troviamo

n=0

ric=?

ris=?

n=1

ric=?

ris=?

n=2

ric=?

ris=?

n=3

ric=?

Page 128: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

ris=?

n=4

ric=?

ris=?

Quest'ultima chiamata termina subito restituendo come risultato 1. Tale valore è immagazzinato nella variabile ric della penultima chiamata (quella con n=1), che adesso ritorna ad essere eseguita. La variabile ris conterrà 1

n=1

ric=1

ris=1

n=2

ric=?

ris=?

n=3

ric=?

ris=?

n=4

ric=?

ris=?

Il valore di ris, 1, sarà restituito alla chiamata precedente (n=2). Prima che questa termini, ecco il contenuto dello stack

n=2

ric=1

ris=2

n=3

ric=?

ris=?

n=4

ric=?

ris=?

Di seguito riportiamo i successivi contenuti dello stack quando mano a mano le altre due chiamate

Page 129: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

ricorsive terminano

n=3

ric=2

ris=6

n=4

ric=?

ris=?

ed infine

n=4

ric=6

ris=24

Quindi l'ultima chiamata a terminare (che nella logica LIFO è la prima ad essere svolta) restituirà correttamente come risultato il valore 24.

12.4 Programmazione ricorsive ed altri esempi di funzioni ricorsiveUna definizione sensata di una funzione ricorsiva quindi deve prevedere

1. uno o più casi base, in cui la funzione non richiama sé stessa;

2. uno o più regole ricorsive, che indicano quando e come la funzione deve richiamare se stessa e in che modo utilizzare ciò che le chiamate ricorsive producono;

3. i casi base e le regole ricorsive sono inserite all'interno di una struttura di controllo condizionale (if, switch o più comunemente if-else-if);

4. la garanzia che partendo da una qualsiasi combinazione ammissibile di argomenti, dopo un numero finito di chiamate ricorsive si perviene sempre ad un caso base.

La grande difficoltà che si riscontra quando si affronta un problema in modo ricorsivo è proprio quella di trovare delle regole ricorsive che da un lato coprano tutti i casi possibili, sfruttando i risultati (o gli effetti) delle chiamate ricorsive, dall'altro garantiscano che prima o poi si arrivi ad un caso base. L'importanza di questi ultimi è quindi fondamentalmente, perché garantisce la terminazione della ricorsione.

Ragionare e programmare in modo ricorsivo è difficile all'inizio, ma può portare a soluzioni molto eleganti e compatte, oltre che suggerire metodi che con l'iterazione sarebbero molto difficili da immaginare.

Nelle sottosezioni successive vedremo alcuni esempi di funzioni ricorsive.

Page 130: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

12.4.1 Elevamento a potenza – metodo lento

Per calcolare xn tramite la ricorsione, si può usare la seguente definizione ricorsiva:

xn=1, se n=0

xn=x xn-1, se n>0

E' facile controllare, mediante il principio di induzione, che questa definizione ricorsiva della funzione di elevamento a potenza è corretta. La sua implementazione in C++ è una semplice trascrizione:

double potenza(double base, int esponente) {

if(esponente==0)

return 1;

else {

double ric=potenza(base,esponente-1);

double ris=ric*base;

return ris;

}

}

In modo più compatto si potrebbe scrivere

double potenza(double base, int esponente) {

if(esponente==0)

return 1;

else

return base * potenza(base,esponente-1);

}

12.4.2 Elevamento a potenza – metodo veloce

Il metodo di elevamento a potenza visto nella sezione 8.X può condurre facilmente alla seguente definizione ricorsiva

xn=1, se n=0

xn=(x2)n/2, se n>0 ed è pari

xn=x(x2)n-1/2, se n è dispari

La correttezza di tale definizione può essere provata mediante la seconda versione del principio di induzione.

In C++ ciò diventa

Page 131: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

double potenza(double base, int esponente) {

if(esponente==0)

return 1;

else if(esponente % 2 == 0) { // pari

double ric=potenza(base*base,esponente/2);

return ric; }

else { // dispari

double ric=potenza(base*base,esponente/2);

return base*ric;

}

}

12.4.3 Scrittura di un numero in binario

Un esempio estremamente elegante di funzione void ricorsiva è quella che scrive un numero naturale in base 2.

void scrivi_binario(int num) {

if(num<=1) // 0 o 1

cout << num;

else {

scrivi_binario(num/2);

cout << num % 2; // ultimo bit

}

}

A prima vista non è chiaro il suo funzionamento, ma un esempio potrà far capire qual è la logica utilizzata. Prendendo da 13, che in binario è 1101, si avranno le seguenti chiamate ricorsive

scrivi_binario(13)

scrivi_binario(6)

scrivi_binario(3)

scrivi_binario(1)

quest'ultima scrive sullo schermo 1

poi la precedente scrive 1 (perché è il resto della divisione di 3 per 2)

poi la precedente scrive 0 (perché è il resto della divisione di 6 per 2)

poi la precedente scrive 1 (perché è il resto della divisione di 13 per 2)

quindi si forma 1101 sullo schermo.

Con la seconda versione del principio di induzione è possibile vedere che tale procedimento è corretto.

Page 132: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

12.4.4 Calcolo della successione di Fibonacci

Il calcolo dei numeri di Fibonacci è già stato affrontato con un algoritmo iterativo, basato sul ciclo for. Il problema ammette anche un'ovvia soluzione ricorsiva in quanto, indicando con Fn l'n-esimo numero di Fibonacci, si ha

F0=F1=1

Fn=Fn-1+Fn-2, se n>1

La traduzione in C++ di tale definizione ricorsiva è

int fibonacci(int n) {

if(n==0 || n==1)

return 1;

else {

int ric1=fibonacci(n-1), ric2=fibonacci(n-2);

return ric1+ric2;

}

}

Tale funzione ha due chiamate ricorsive: questa situazione è detta ricorsione binaria. Per dimostrare che la definizione ricorsiva è corretta serve la seconda versione del principio di induzione.

E' comunque doveroso notare che la versione ricorsiva è particolarmente inefficiente. Infatti per calcolare fibonacci(5) si deve calcolare fibonacci(4) e fibonacci(3). Per calcolare fibonacci(4) serve calcolare fibonacci(3) e fibonacci(2). Già a questo punto si vede una anomalia: fibonacci(3) è calcolato due volte. Ma basta andare avanti e vedere che la situazione peggiora: fibonacci(2) è chiamato 3 volte e addirittura fibonacci(1) ben 5 volte.

Dato che ogni volta il calcolo è svolto tutto, partendo dai casi base, si ha un enorme perdita di tempo per ricalcolare più volte lo stesso risultato. La soluzione iterativa non presenta questo problema.

12.5 Ricorsione sugli arrayPer usare la ricorsione sugli array, occorre notare che un array non può essere modificato, quindi la ricorsione può agire solo sul parametro che rappresenta la dimensione ed operare su porzioni dell'array.

Presenteremo tre modalità diverse di svolgere la ricorsione su un array: a prefisso, a suffisso e binaria. Comunque in realtà se ne possono immaginare molte altre.

12.5.1 Ricorsione a prefisso

Nella ricorsione a prefisso si usa la seguente regola ricorsiva: la funzione f che opera su un array A di n elementi è definita in termini di se stessa calcolata sui primi n-1 elementi di A (cioè tutto l'array tranne l'ultimo). Tale nuova porzione è chiamata prefisso dell'array.

Il caso base è dato dall'array con un solo elemento (n=1) o vuoto (n=0).

Page 133: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Ad esempio per sommare gli elementi di un array di n numeri interi si calcola la somma dei primi n-1 elementi, mediante una chiamata ricorsiva, e al risultato ottenuto si addiziona l'ultima elemento.

Il caso base è rappresentato da array con un solo elemento, la cui somma è l'elemento stesso.

int somma_prefisso(int a[],int n) {

if(n==1) {

return a[0]; }

else {

int ric=somma_prefisso(a,n-1);

return ric+a[n-1];

}

}

12.5.2 Ricorsione a suffisso

Il ragionamento si svolge in direzione opposta: la funzione f che opera su un array A di n elementi è definita in termini di se stessa calcolata sugli ultimi n-1 elementi di A (cioè tutto l'array tranne il primo). Tale nuova porzione è chiamata suffisso dell'array.

Per ottenere ciò, il modo più elementare è quella di utilizzare un parametro s che specifica l'indice del primo elemento da considerare, che all'inizio sarà 0.

Il caso base si avrà ancora per array di un solo elemento (s=n-1) o vuoto (s=n).

La somma di un array mediante questo schema ricorsivo sarà

int somma_suffisso(int a[],int s,int n) {

if(s==n-1)

return a[n-1];

else {

int ric=somma_suffisso(a,s+1,n);

return ric+a[s];

}

}

La funzione dovrà essere richiamata con somma_suffisso(a,0,n).

12.5.3 Ricorsione “binaria”Nella ricorsione binaria la funzione è definita ricorsivamente tramite il valore che assume su due porzioni dell'array, che in alcuni problemi devono essere disgiunte. Le due porzioni possono essere bilanciate (avere lo stesso numero di elementi o differire di un'unità) oppure sbilanciate.

Vediamo la somma di un array mediante la ricorsione binaria di tipo bilanciato. L'idea è quella di dividere l'array a metà ottenendo due parti A1 e A2, se l'array ha un numero dispari di elementi ciò non sarà esattamente possibile: A1 e A2 avranno quasi la stessa dimensione. Poi si richiama

Page 134: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

ricorsivamente la funzione su A1 e su A2 e alla fine si addizioneranno i due risultati ottenuti.

Il caso base è dato sia da array vuoti, che per convenzione avranno somma 0, che per array formati da un solo elemento.

La porzione di array da sommare è delimitata da due indici, esattamente come nella ricerca binaria (sezione 10.x).

int somma_binaria(int a[], int s,int d) {

if(s>d) // array vuoto

return 0;

else if(s==d) // array con un solo elemento

return a[s];

else {

int m=(s+d)/2;

int ric1=somma_binaria(a,s,m);

int ric2=somma_binaria(a,m+1,d);

return ric1+ric2;

}

}

La presenza di due casi basi è indispensabile. Per capire il motivo si legga la spiegazione del funzione della ricerca binaria, perché si comporta esattamente allo stesso modo.

La funzione sarà richiamata con somma_binaria(a,0,n-1).

12.5.4 Altri esempi di ricorsione sugli array

In nessun problema è consigliato l'utilizzo di uno schema rigido di applicazione della ricorsione. Un esempio in cui è indispensabile uscire dagli schemi precedentemente illustrati è nell'implementazione ricorsiva della ricerca lineare di un elemento in un array. Useremo la tecnica a suffisso, ma con importanti varianti.

L'idea è quella che per cercare un elemento x in un array A di n elementi, la prima cosa da controllare è se il primo elemento di A è uguale a x. In tal caso la ricerca è finita e non c'è bisogno della ricorsione: questo è un caso base. L'altro caso base è dato dall'array vuoto, in cui si sa che l'elemento cercato non c'è.

La regola ricorsiva dice semplicemente che se l'elemento non è il primo, allora può stare nel suffisso.

In C++ un'implementazione potrebbe essere

int cerca(int a[],int x,int s,int n) {

if(s==n) // array vuoto

return -1; // l'elemento non c'è

else if(a[s]==x)

return s;

else

Page 135: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

return cerca(a,x,s+1,d);

}

La funzione cerca restituisce -1, se l'elemento cercato non è presente nell'array, o la posizione più a sinistra in cui compare.

12.6 Confronti tra ricorsione ed iterazioneIn conclusione possiamo tracciare un confronto tra ricorsione ed iterazione.

Innanzitutto deve essere chiaro che dal punto di vista della risoluzione di problemi computazionali sono completamente equivalenti: ogni algoritmi ricorsivo può essere ricondotto ad un algoritmo iterativo ed ogni algoritmo iterativo può essere riscritto in maniera ricorsiva.

Il primo passaggio è comunque un po' più complicato del secondo, in quanto potrebbe addirittura comportare la simulazione di uno stack dei record di attivazione.

In molti problemi le soluzioni iterative sono più efficienti, perché la ricorsione “paga” il prezzo computazionale della creazione e distruzione dei record di attivazione ad ogni chiamata ricorsiva. Il prezzo è in termini di tempo e soprattutto di memoria. Molte soluzioni iterative consumano una quantità fissa di memoria, in quelle ricorsive la memoria cresce con il numero di chiamate.

Esistono problemi, come il calcolo dei numeri di Fibonacci, che si risolvono meglio con soluzioni iterative. Ma esistono anche problemi che si risolvono in maniera più semplice e talvolta più efficiente con tecniche ricorsive.

Un esempio lampante è la funzione che scrive un numero in base 2: la versione ricorsiva è molto più compatta di quella iterativa, che invece deve usare un array per memorizzare le cifre che si formano, mediante i resti delle divisioni per 2, al contrario di come devono essere visualizzate sullo schermo.

Altri esempi, che non verranno illustrati, sono gli algoritmi di ordinamento ricorsivi merge-sort (è dei migliori algoritmi dal punto di vista teorico) e quick-sort (che è molto utilizzato, anche se teoricamente non è buono come il merge-sort o altri) e gli algoritmi che operano sugli alberi, ad esempio quelli che visitano un albero binario.

Per concludere bisogna dire che un guadagno notevole in termini di efficienza si ha quando la ricorsione è “in coda” (tail recursion), cioè quando la chiamata ricorsiva è l'ultima operazione svolta dalla funzione.

Un esempio di funzione con ricorsione in coda è cerca. Si noti che nemmeno fattoriale è ricorsiva in coda, perché, dopo la chiamata, la funzione calcola il prodotto tra il risultato e n.

Una versione della funzione fattoriale che sia ricorsiva in coda è

int fattoriale_coda(int n,int f) {

if(n==0)

return f;

else

return fattoriale_coda(n-1,n*f);

}

che va chiamata con fattorale_coda(n,1).Se una funzione è ricorsiva in coda non ha bisogno ogni volta di un nuovo record di attivazione: può usare sempre lo stesso. Molti compilatori sono in grado di riconoscere questa situazione e di compilare in maniera efficiente le funzioni ricorsive in coda, in modo da diminuire in maniera considerevole il tempo di esecuzione.

Page 136: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

12.7 Esercizi12.1.Definire una funzione ricorsiva che calcola la somma dei primi n numeri naturali.

12.2.Definire una funzione ricorsiva che calcola la somma dei quadrati dei primi n numeri naturali.

12.3.Definire una funzione ricorsiva che scrive sullo schermo un numero naturale n in base b (supporre che b<=10).

12.4.Definire una funzione ricorsiva che calcola il numero di bit di un numero naturale n.

12.5.Definire una funzione ricorsiva che calcola il peso di Hamming, cioè il numero di bit '1' quando n è scritto in base 2, di un numero naturale n

12.6.Definire una funzione ricorsiva che calcola la somma delle cifre decimali di un numero naturale n.

12.7.Definire una funzione ricorsiva che calcola (e non scrive sullo schermo) il contrario di un numero naturale n. Ad esempio se n=67189 la funzione deve restituire 98176.

12.8.Implementare in maniera ricorsiva la ricerca binaria.

12.9.Definire una funzione ricorsiva che calcola il massimo di un array di numeri reali.

12.10.Definire una funzione ricorsiva che calcola il media di un array di numeri reali.

12.11.Implementare in maniera ricorsiva l'algoritmo di ordinamento per selezione.

12.12.Definire una funzione ricorsiva che conta quanti elementi di un array di interi sono divisibili per un numero intero D.

12.13.Definire una funzione ricorsiva che somma gli elementi di un array di interi che sono maggiori di un numero intero D.

12.14.Definire una funzione ricorsiva che controlla se ci sono due elementi consecutivi uguali in un array di interi.

12.15.Definire una funzione ricorsiva che calcola contemporaneamente il minimo ed il massimo di un array di interi.

12.16.Definire una funzione ricorsiva che calcola la media aritmetica di un array di interi.

12.17.Definire una funzione ricorsiva che calcola la media geometrica di un array a di n numeri

interi, definita dalla formula ∏i=0

n−1ai

1/n.

12.18.Definire una funzione ricorsiva che calcola la media armonica di un array a di n numeri interi,

definita dalla formula n

∑i=0

n−1 1ai

.

Page 137: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

13 Puntatori e variabili dinamiche

13.1 Il tipo di dato puntatore

Il tipo di dato puntatore serve ad operare con gli indirizzi in memoria delle variabili.

Le variabili di tipo puntatore, dette comunemente puntatori, sono utilizzate essenzialmente per tre motivi:

1.implementazione del passaggio per riferimento;

2.gestione delle variabili ad allocazione dinamica;

3.implementazione delle strutture dati dinamiche.

Molti linguaggi supportano il tipo di dato puntatore, ma in C++ e C è possibile svolgere delle operazioni aritmetiche su di essi, che non sono presenti negli altri linguaggi.

In alcuni altri linguaggi ad esempio in Java, al posto dei puntatori si possono usare i riferimenti. I riferimenti consentono di svolgere alcune delle operazioni previste per i puntatori, ma in modo semplificato. D'altro canto i riferimenti non sono puntatori e quindi mancano di alcune operazioni. Anche in C++ è possibile usare i riferimenti, seppur in maniera ridotta rispetto a Java. Non approfondiremo ulteriormente questo argomento.

13.1.1 Dominio e dichiarazione di puntatori.

In C++ esistono due “tipi”di puntatori: i puntatori generici e i puntatori tipizzati.

I puntatori generici possono gestire indirizzi di variabili di qualsiasi tipo.

I puntatori tipizzati fanno riferimento ad un tipo di dato T e possono gestire solo indirizzi di variabili di tipo T.

Una variabile di tipo puntatore generico si dichiara con il tipo void *, mentre un puntatore associato al tipo T si dichiara con T*.

Ad esempio con

void *p;

int *q;

double *r,*s;

si dichiara un puntatore generico p, un puntatore a int q e due puntatori a double r e s.

Si noti che

double *r,s;

dichiara che s è una variabile double, e non un puntatore.

Un valore particolare per le variabili puntatore, sia per i puntatori generici, che quelli tipizzati, è 0, che in tale ambito è chiamato anche valore nullo.

Page 138: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

13.1.2 Operazioni supportate

Le operazioni supportate sono l'assegnamento, il confronto e l'accesso indiretto (quest'ultima solo per i puntatori tipizzati).

L'assegnamento tra puntatori è possibile solo se i due puntatori sono dello stesso tipo.

Ad esempio

int *p, *q;

double *r, *s;

p=q;

r=s;

p=r; // illegale

E' possibile assegnare sempre un puntatore tipizzato ad una variabile di tipo puntatore generico:

void *g=p;

E' possibile assegnare ad una variabile puntatore di tipo T l'indirizzo di una variabile dello stesso tipo T, mediante l'operatore unario &. Il puntatore si dice che è collegato alla variabile, o che punta ad essa.

Ad esempio

int a=7, *p;

p=&a; // ora p è collegato ad a

Graficamente si può rappresentare tale situazione con

E' possibile che più puntatori siano collegati alla stessa variabile, ma non che un puntatore sia collegato a più variabili: ogni assegnamento fa perdere traccia del precedente collegamento.

Puntatori non inizializzati o contenenti 0 non sono collegati a niente.

Il confronto può avvenire per uguaglianza (==) o disuguaglianza (!=) e consiste nel controllare se i due indirizzi coincidono oppure sono diversi.

L'accesso indiretto è un'operazione associata all'operatore unario * consente di accedere alla variabile collegata al puntatore, sia in lettura, sia in scrittura. Per cui se p è un puntatore collegato alla variabile x, *p si comporta come se fosse x.

Ad esempio

int a=7, *p;

p=&a; // p è collegato ad a

cout << *p << endl; // scrive 7

*p=8; // è come se fosse a=8

cout << a << endl; // scrive 8

p a

7

Page 139: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

int b=11, *q;

p=&b; // ora p è collegato a b

cout << *p << endl; // scrive 11

*p++; // è come se fosse b++

cout << b << endl; // scrive 12

q=p; // anche q è collegato a b

cout << *q << endl; // scrive 12

*q=*p+1; // è come se fosse b=b+1

cout << b << endl; // scrive 13

Per effettuare assegnamenti tra puntatori di tipo diverso o assegnare un puntatore generico ad una variabile puntatore tipizzato occorre un cast, il quale forza la conversione di tipo:

p=(int*) g;

q=(int*) r;

Quest'ultima operazione non ha senso e anche se il compilatore la traduce, produrrà un codice il cui comportamento non è affidabile. Infatti q conterrà l'indirizzo di una variabile double, però l'accesso indiretto gestisce la variabile come fosse di tipo int e quindi il risultato sarà imprevedibile.

L'accesso indiretto ad un campo di un record si può abbreviare con l'operatore ->.

Ad esempio

struct punto {

int x,y;

};

punto q, *p;

q.x=3;

q.y=8;

p=&q;

cout << p->x << “ “ // p->x è come se fosse (*p).x

<< p->y << endl; // p->y è come se fosse (*p).y

13.2 Aritmetica dei puntatoriAltre tre operazioni definite sui puntatori sono l'addizione e la sottrazione di un puntatore con un numero intero e la sottrazione tra due puntatori. Perché queste operazioni abbiano senso, i puntatori coinvolti devono essere collegati ad elementi di un array.

Se p è collegato all'elemento a[i], allora p+k è un puntatore collegato all'elemento a[i+k], ammesso che tale elemento esista, ovvero se i+k<n.

Ad esempio con

int v[7];

int *p=&v[1];

Page 140: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

int *q=p+3;

avremo

ovvero, che il puntatore q è collegato a v[4].

Il risultato della differenza tra un puntatore ed un numero si ottiene con un procedimento simile: p-k è collegato all'elemento a[i-k], ovviamente ciò è possibile se i-k>=0. Ad esempio con

int *r=p-1;

il puntatore r è collegato a v[0].

Infine se p è collegato a a[i] e q è collegato a a[j], p-q ha come risultato i-j, cioè la distanza tra l'elemento collegato a p e quello collegato a q.

L'accesso indiretto mediante il puntatore p+k, che si dovrebbe fare con *(p+k), si può abbreviare con la notazione p[k], cioè trattare p come se fosse un array.

Per cui l'istruzione p[2]=4 ha lo stesso effetto di v[3]=4.

D'altra parte si noti che è possibile “assegnare un array ad un puntatore”

int *s=v;

In realtà questo assegnamento attribuisce al puntatore s l'indirizzo del primo elemento di v, cioè in questo contesto v equivale a &v[0].

13.3 Passaggio per riferimento e puntatori

Come abbiamo visto nel capitolo 11, in C++ una funzione che scambia due variabili intere deve usare il passaggio per riferimento:

void scambia(int &x, int &y) {

int temp=x;

x=y;

y=temp; }

int main( ) {

int a=3, b=7;

scambia(a,b);

...

}

Nel linguaggio C, in cui non esiste il passaggio per riferimento, si devono usare i puntatori (passati per valore) e gli argomenti della chiamata sono gli indirizzi delle corrispondenti variabili:

p p+3

v

Page 141: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

void scambia(int *x, int *y) {

int temp=*x;

*x=*y;

*y=temp; }

int main( ) {

int a=3, b=7;

scambia(&a,&b);

...

}

Tale codice è perfettamente legale e funzionante anche in C++, ovviamente è più scomodo da usare e per questo è preferibile usare il passaggio per riferimento.

In realtà, però, quello che avviene in C++ è esattamente quello che avviene in C, ovvero nel passaggio per riferimento viene estratto l'indirizzo degli argomenti che è usato per inizializzare i rispettivi parametri. Questi ultimi sono trattati come puntatori: ogni accesso al parametro viene “dirottato”, mediante l'operazione di accesso indiretto, al corrispondente argomento.

Analogamente un array è sempre passato per riferimento, ma quello che avviene è che un parametro p di tipo array è in realtà trattato come un puntatore che contiene l'indirizzo del primo elemento dell'argomento a, ed ogni accesso ad un elemento p[i] è in realtà eseguito su a[i].

E' addirittura possibile definire il parametro p come puntatore, anziché come array (monodimensionale), infatti è possibile usare nella funzione la notazione p[i], grazie all'aritmetica dei puntatori.

Ad esempio sono equivalenti le due definizioni di funzione

double somma(double a[], int n) { ...}

edouble somma(double *a, int n) { ...}

Analogamente l'argomento di un parametro array non deve essere necessariamente tutto un array, ma può essere l'indirizzo di un elemento: il parametro serve ad accedere alla parte di array che inizia da tale elemento.

Per cui

int main() {

double v[10];

// calcolo la somma di tutto l'array

cout << somma(v,10) << endl;

// calcolo la somma da v[4] a v[9]

cout << somma(&v[4],6) << endl;

// calcolo la somma da v[4] a v[7]

cout << somma(&v[4],4) << endl;

}

Page 142: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

A questo punto deve essere chiaro qual è il motivo del fatto che gli array sono sempre passati per riferimento e che anche per le struct è consigliato di usare tale tipo di passaggio. Infatti nel passaggio per riferimento, viene copiato solo l'indirizzo dell'argomento nel corrispondente parametro. Quindi si ha un notevole risparmio di tempo rispetto ad un eventuale passaggio per valore in cui l'array o la struct devono essere copiati per intero. Inoltre si ha un vantaggio anche in termini di occupazione di memoria, poiché un puntatore occupa un numero esiguo di byte.

13.4 Allocazione dinamica

In C++, al pari di altri linguaggi, è possibile utilizzare una terza modalità di allocazione delle variabili: l'allocazione dinamica. In tale tipo di allocazione è il programmatore a decidere la vita della variabile mediante opportune istruzioni di allocazione e deallocazione.

Le variabili così allocate, dette dinamiche, hanno particolarità: non hanno un nome e quindi non possono essere trattate come le altre variabili. E' possibile operare con esse solo attraverso i puntatori. D'altro canto le variabili dinamiche possono vivere al di là delle regole di visibilità viste per le variabili tradizionali: è possibile creare una variabile dinamica x in una funzione f e, anche se f è terminata, continuare ad usare x, purché si abbia a disposizione il suo indirizzo.

L'operazione per allocare una variabile è new. La sua sintassi è

“new” tipo

in cui tipo è un qualsiasi tipo di dato. Per gli array si veda la sezione successiva.

Tale operazione restituisce come risultato l'indirizzo in memoria della variabile allocata, che quindi di solito viene memorizzato in una variabile di tipo puntatore.

Ad esempio

int *p;

p=new int;

si crea una nuova variabile dinamica di tipo int il cui indirizzo è memorizzato nel puntatore p.

La zona di memoria da dedicare a questa nuova variabile è cercata all'interno di un'area della memoria centrale chiamata heap, separata dallo stack di sistema. La gestione dello heap è comunque completamente diversa da quello dello stack, in quanto non ha più senso la politica LIFO. Infatti ogni variabile dinamica ha una vita indipendente dalle altre e quindi a seguito di allocazioni e di deallocazioni di variabili dinamiche si possono creare degli spazi vuoti all'interno dello heap, che devono essere gestiti in modo da sprecare spazio il meno possibile.

La variabile dinamica creata nell'esempio precedente può essere utilizzata solo tramite il puntatore p o qualsiasi altro puntatore a cui è stato assegnato lo stesso indirizzo.

Ad esempio

int *q=p;

*p=8; // assegna alla variabile dinamica il valore 8

cout << *q << endl; // scrive 8

p

Page 143: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

L'istruzione delete dealloca una variabile, restituendo al gestore dello heap la zona di memoria ad essa dedicata. L'argomento di delete è qualsiasi puntatore che contiene l'indirizzo della variabile da deallocare. Ad esempio

delete p;

Adesso p e q puntano ad una zona che non è più dedicata alla variabile dinamica, se si tenta di usare *p o *q non è prevedibile cosa può succedere (questa situazione è chiamata dangling pointer).

13.5 Array dinamici

Tramite new è possibile creare anche array dinamici, specificando la dimensione tra parentesi quadre. La sintassi in tal caso è

“new” tipo “[“ espressione_intera “]”

Si noti che la dimensione di un array dinamico può essere anche data dal risultato di un'espressione intera, a differenza di quanto si può fare, secondo gli standard più diffusi, con gli array tradizionali, la cui dimensione deve essere nota a tempo di compilazione.

L'operazione restituisce come risultato l'indirizzo del primo elemento dell'array che può essere memorizzato in una variabile puntatore dello stesso tipo e usato, grazie all'aritmetica dei puntatori, con la stessa sintassi di un array tradizionale.

Ad esempio

int i,n;

cout << “quanti elementi ? “;

cin >> n;

double *p=new double[n];

// uso p come se fosse un array

for(i=0;i<n;i++)

cin >> p[i];

E' importante notare che un array dinamico comunque ha una dimensione fissa, che non si può aumentare né diminuire. Un'ulteriore new non farebbe altro che creare un nuovo array, ma non modificare la dimensione di quello già esistente.

Per deallocare un array dinamico si usa una variante di delete, che richiede il simbolo [] davanti al puntatore collegato all'array. Nel nostro esempio

delete[] p;

13.6 Esercizi13.1. Definire una funzione che, senza usare il passaggio per riferimento, calcola le soluzioni di un'equazione di secondo grado, a partire dai coefficienti a,b e c.

13.2. Definire una funzione che dato N crea un array A di N elementi interi, li legge da tastiera e restituisce in un parametro di uscita l'indirizzo del primo elemento di A.

13.3. Definire una funzione che dato un array A di N elementi interi, in cui anche N è un

Page 144: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

parametro, ne crea una copia su un array dinamico C e restituisce l'indirizzo del primo elemento di C.

13.4. Definire una funzione dati due array A di N elementi interi e B di M elementi interi, in cui anche N e M sono parametri, crea un array dinamico C di dimensione N+M, copia nei primi N elementi di C gli elementi di A e negli altri elementi di C gli elementi di B. Infine restituisce l'indirizzo del primo elemento di C.

13.5. Definire una funzione che dato l'indirizzo di un array A di N elementi interi, in cui anche N è un parametro, crea un array dinamico C di N+1 elementi, copia gli elementi di A in C (lasciando libera l'ultima cella di C), cancella l'array A e memorizza su A l'indirizzo di C. Suggerimento: A deve essere un puntatore passato per riferimento. Nota: questa funzione effettua una sorta di ridimensionamento di A, aumentando la dimensione di 1.

13.6. Estendere la soluzione dell'esercizio precedente in modo che l'array C abbia dimensione M (con M>N), in cui anche M è un parametro.

13.7. Definire una funzione che dati l'indirizzo di un array A di N elementi interi, la dimensione N di A e I, un numero intero, crea un array dinamico C di N-1 elementi, copia gli elementi di A in C, tranne la posizione I, cancella l'array A e memorizza su A l'indirizzo di C. Nota: questa funzione cancella un elemento da un array, ridimensionandolo.

Page 145: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

14 Liste ed altre strutture dati elementari

14.1 Array e listeGli array sono una struttura dati molto utilizzata, ma poco versatile. In particolare sono evidenti due difetti:

1. L'array ha una dimensione fissa: una volta che è stato dimensionato non è possibile aumentare, né diminuire il numero di elementi. Per usare un array in situazioni dinamiche è necessario dimensionarlo con il massimo numero di elementi previsti.

2. L'array ha una struttura rigida, in cui ogni elemento ha una posizione “fisica” dipendente dall'indice. Per spostare, inserire od eliminare un elemento all'interno di un array bisogna muovere molti elementi già presenti per far posto al nuovo elemento o riassorbire lo spazio lasciato dal vecchio elemento.

Dei due difetti il primo è molto grave, in quanto richiede da un lato di stimare il massimo numero di elementi, dall'altro quello di usare tale dimensione, creando elementi inutilizzati per qualche periodo di tempo.

Le liste consentono di ovviare a questi difetti. Una lista, infatti è un insieme omogeneo di elementi, chiamati nodi, i quali contengono un'informazione, chiamata chiave, e dei collegamenti ai nodi “vicini”. La forma più semplice di lista è quella unidirezionale, in cui ogni nodo è collegato con il nodo successivo, e l'ultimo nodo non è collegato a niente.

Graficamente avremo

Il secondo difetto è superato poiché per inserire, eliminare o spostare elementi in una lista è sufficiente aggiornare i collegamenti. Ad esempio ecco come si inserisce 8 tra 4 e 5

semplicemente collegando il 4 con l'8 e l'8 con il 5. Per cancellare il 7 dalla lista di partenza basta collegare l'1 con il 4:

Il primo difetto può essere superato se si usano le liste puntate, cioè si implementano tramite variabili dinamiche e nodi, come vedremo nella sezione successiva.

7 4 5 3 101

7 4 5 3 101

8

7 4 5 3 101

Page 146: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

14.2 Implementazione delle liste puntate unidirezionaliUna lista puntata è realizzata con due concetti visti nel capitolo precedente. I nodi sono variabili dinamiche (di tipo struct). Il collegamento tra un nodo N e il nodo successivo N' è semplicemente implementato con un puntatore contenuto in N che contiene l'indirizzo di N'. Per l'ultimo elemento, che non è collegato a niente, si usa 0 come valore da inserire nel puntatore di collegamento.

Per gestire una lista, nella maggiore parte delle situazioni, è sufficiente mantenere, in una variabile puntatore, l'indirizzo del primo elemento della lista, che verrà chiamato testa. In alcune circostanze servirà anche l'indirizzo dell'ultimo elemento, chiamato coda.

Una lista vuota è rappresentata semplicemente usando 0 come indirizzo dell'inesistente primo elemento.

14.2.1 Strutture datiPer utilizzare una lista è necessario definire il tipo di dato nodo, che sarà una struct con due campi, la chiave key e il puntatore al nodo successivo next. Per semplificare la notazione, useremo il tipo di dato pnodo, come sinonimo di puntatore a nodo. Per rendere più generale il codice, useremo il tipo di dato tipo per specificare il tipo di dato della chiave, che per esempio è stato associato ad int.

typedef struct nodo* pnodo;

typedef int tipo;

struct nodo {

tipo key;

pnodo next;

};

Una volta definite le strutture dati, iniziamo a vedere le operazioni elementari che si possono svolgere sulle liste. Esse saranno presentate come funzioni, che avranno tra i parametri, la testa della lista su cui trattare.

14.2.2 Inserimento all'inizio

La prima operazione che vediamo è l'inserimento di un nuovo elemento all'inizio di una lista. Oltre alla testa, come parametri avrà la chiave dell'elemento da inserire. Tale operazione ovviamente modifica la testa della lista, che diventa l'indirizzo del nuovo elemento, per cui tale parametro sarà passato per riferimento.

void ins_inizio(pnodo &testa, tipo x) {

pnodo nuovo=new nodo;

nuovo->key=x;

nuovo->next=testa;

testa=nuovo;

}

Page 147: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

14.2.3 Inserimento in fondoIn maniera speculare è possibile inserire anche un elemento in fondo ad una lista. Per snellire il compito di tale funzione, oltre alla testa, sarà fornito anche la coda. Entrambe potrebbero essere modificate, poiché se la lista è vuota, dopo l'inserimento dell'elemento, testa e coda coincidono con il nuovo elemento. Mentre se la lista non è vuota, solo la coda sarà modificata, che diventerà il nuovo elemento inserito.

Senza il parametro coda, la funzione dovrebbe scandire la lista, nel modo esposto nella sottosezione 14.2.X.

void ins_fine(pnodo &testa, pnodo &coda, tipo x) {

if(testa==0) { // lista vuota

ins_inizio(testa,x);

coda=testa; }

else {

pnodo nuovo=new nodo;

nuovo->key=x;

nuovo->next=0;

coda->next=nuovo;

coda=nuovo;

}

}

14.2.4 Eliminazione del primo elemento

E' molto semplice eliminare il primo elemento: la lista inizierà dal secondo. In questa funzione e in tutte le altre funzioni di eliminazione il nodo non viene distrutto, ma viene solo scollegato dalla lista e il suo indirizzo restituito come risultato. E' poi possibile applicare senza problemi l'operatore delete al nodo eliminato dalla lista.

Nel caso che la lista è vuota non accade niente e il risultato è 0.

pnodo elim_inizio(pnodo &testa) {

if(testa==0)

return 0;

else {

pnodo p=testa;

testa=testa->next;

return p;

}

}

Page 148: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

14.2.5 Lettura da tastiera

Per leggere una lista da tastiera bisogna leggere un elemento alla volta e inserirlo in fondo alla lista, che inizialmente dovrà essere vuota. Forniamo due versioni, nella prima la lettura va avanti fino a che l'utente non risponde no alla domanda “Ancora (s/n) ?”.

void leggi_lista(pnodo &testa) {

// prima versione

testa=0;

pnodo coda;

char risp;

cout << “elemento ? “;

do {

int x;

cin >> x;

ins_fine(testa, coda, x);

cout << “Ancora (s/n) ? “;

cin >> risp;

} while(risp=='s');

}

Nella seconda versione, la funzione chiede anticipatamente quanti elementi si vuole inserire. Si tratta probabilmente di una modalità più comoda da usare.

void leggi_lista(pnodo &testa) {

// seconda versione

testa=0;

pnodo coda;

int i,n;

cout << “quanti elementi ? “;

cin >> n;

cout << “inserisci gli elementi\n“;

for(i=1;i<=n;i++) {

tipo x;

cin >> x;

ins_fine(testa, coda, x);

}

}

In entrambi i casi la funzione modifica il proprio argomento restituendovi l'indirizzo della testa

Page 149: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

della lista letta.

14.2.6 Inserimento dopo un dato nodo

Inserire un nuovo nodo dopo un nodo N è molto semplice. Basta solo inserirlo tra N e il successivo di N.

void ins_dopo(pnodo p, tipo x) {

pnodo nuovo=new nodo;

nuovo->key=x;

nuovo->next=p->next;

p->next=nuovo;

}

14.2.7 Inserimento prima di un dato nodo

L'inserimento di un nuovo nodo prima di un nodo N è un'operazione apparentemente difficile, in quanto il nuovo nodo andrebbe inserito tra N e il predecessore di N. Ma quest'ultima informazione non è facilmente reperibile. Il trucco per svolgere tale operazione è il seguente: viene inserito un nodo con la stessa chiave di N dopo N, con la funzione ins_dopo. A questo punto alla chiave di N viene assegnata la chiave del nodo da inserire.

void ins_prima(pnodo p, tipo x) {

ins_dopo(p, p->key);

p->key=x;

}

Ad esempio per inserire 9 prima di 5

si ottiene inizialmente

e alla fine la lista

7 4 5 3 101

7 4 5 3 101

5

Page 150: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

14.2.8 Eliminazione dopo un dato nodo

Eliminare il nodo successivo ad un nodo N è semplice, basta collegare N al nodo successivo ancora.

pnodo elimina_dopo(pnodo p) {

pnodo q=p->next;

p->next=q->next;

return q;

}

14.3 Scansione ed altre operazioni di base

Per ora abbiamo visto operazioni che effettuano un numero fisso di istruzioni sugli elementi di una lista. Per svolgere altre operazioni, come ad esempio scrivere il contenuto di una lista sullo schermo, bisogna effettuare un ciclo che passa per tutti gli elementi di una lista. Questa operazione sarà chiamata scansione e verrà introdotto nella prossima sottosezione.

14.3.1 Scansione di una lista

E' importante poter scorrere una lista in modo da poter effettuare delle operazioni su tutti i suoi elementi. La richiesta è analoga a ciò che si fa in un array mediante un ciclo for.

Anziché usare un indice, però, bisogna usare un puntatore p che deve attraversare la lista. E' facile vedere l'istruzione

p=p->next

sposta il puntatore “in avanti” di un elemento, cioè lo fa passare all'elemento successivo.

Ad esempio se p punta all'elemento 5

eseguendo l'istruzione p=p->next, p adesso punterà all'elemento 3

7 4 9 3 101

5

7 4 5 3 101

p

Page 151: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Quindi per scorrere una lista occorrerà

1. inizializzare p con la testa della lista

2. per passare al successivo, eseguire p=p->next

3. finire quando la lista è finita, e ciò accade quando p diventa 0, infatti se si esegue p=p->next quando p punta all'ultimo elemento, p assumerà proprio il valore 0.

Per cui avremo

p=testa;

while(p!=0) {

// fai qualcosa con p o con p->key

p=p->next;

}

o meglio, ricordando la forma generica del ciclo for, in maniera più compatta:

for(p=testa; p!=0; p=p->next) {

// fai qualcosa con p o con p->key

}

Questa forma di ciclo for può essere vista come un tipo di iterazione limitata, in quanto la lista ha un numero finito di elementi. Un'istruzione simile è presente in alcuni recenti linguaggi (Java, C#, ecc.) e viene chiamata for each.

14.3.2 Scrittura sullo schermo

Per scrivere le chiavi di una lista si usa semplicemente il ciclo definito nella sottosezione predecente.

void scrivi_lista(pnodo testa) {

pnodo p;

for(p=testa; p!=0; p=p->next)

cout << p->key << “ “;

}

Se al posto di “ “ si usa endl, le chiavi sono scritte in colonna, anziché in riga.

7 4 5 3 101

p

Page 152: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

14.3.3 Calcolo della lunghezza

Per calcolare il numero di elementi presenti in una lista basta semplicemente contare quante iterazioni compie il ciclo di scorrimento.

int lunghezza(pnodo testa) {

pnodo p;

int conta=0;

for(p=testa; p!=0; p=p->next)

conta++;

return conta;

}

14.3.4 Somma degli elementi di una listaPer calcolare la somma degli elementi (nell'ipotesi che siano numeri) si usa una scansione che addiziona alla variabile somma la chiave di ogni elemento.

int somma_elementi(pnodo testa) {

pnodo p;

int somma=0;

for(p=testa; p!=0; p=p->next)

somma += p->key;

return somma;

}

14.3.5 Ricerca di un nodo avente una data chiaveNelle liste la ricerca binaria non è efficiente (si veda il problema del calcolo dell'ennesimo elemento). Implementiamo quindi solo la ricerca lineare. Se trova un nodo avente chiave uguale all'elemento da cercare x, restituisce l'indirizzo del nodo. Altrimenti va avanti, e se la lista finisce, l'elemento non c'è e restituisce come risultato 0.

pnodo cerca(pnodo testa, tipo x) {

pnodo p;

for(p=testa; p!=0; p=p->next)

if(p->key==x)

return p;

return 0;

}

14.3.6 Eliminazione di un nodo avente una data chiave

Page 153: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

Si tratta dell'operazione più complicata tra quelle che abbiamo visto. Per eliminare un nodo avente una chiave uguale a x, bisogna trovare il nodo in questione. Se non c'è o la lista è vuota non accade nulla e il risultato è 0. Altrimenti bisogna “scavalcare” il nodo N da eliminare: l'elemento precedente a N deve essere collegato al nodo successivo a N. Per rendere possibile questa operazione è necessario che, quando si cerca l'elemento x, si deve ricordare anche l'indirizzo del nodo precedente. Poi una volta trovato il nodo, si usa la funzione elim_dopo sull'indirizzo del nodo precedente. Usando questa strategia, il primo nodo va eventualmente eliminato con un altro sistema (elim_inizio).

pnodo elimina(pnodo &testa, tipo x) {

if(testa==0) // lista vuoto, x non c'è

return 0;

else if(testa->key==x) // primo elemento uguale a x

return elim_inizio(testa);

else {

bool trovato=false;

pnodo p,q;

for(p=testa; p!=0; p=p->next) {

if(p->key==x) {

trovato=true;

break; }

// q è mantenuto un nodo “indietro” rispetto a p

q=p; }

if(trovato==false) // x non c'è

return 0;

return elim_dopo(q);

}

14.3.7 Copia di una lista

Copiare una lista è semplice se si usa ins_fine.

pnodo copia(pnodo testa) {

pnodo testa2=0, coda2, p;

for(p=testa; p!=0; p=p->next)

ins_fine(testa2,coda2,p->key);

return testa2;

}

Con ins_inizio al posto di ins_fine si crea una copia rovesciata della lista di partenza.

Page 154: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

14.3.8 Ultimo elemento ed ennesimo elemento

Per accedere all'ultimo nodo di una lista bisogna scorrere tutta la lista fino ad arrivare al nodo che ha non successori:

pnodo ultimo(pnodo testa) {

if(testa==0) //la lista vuota non ha un ultimo elemento

return 0;

pnodo p;

for(p=testa; p->next!=0; p=p->next) { }

return p;

}

Come generalizzazione di questa funzione, vediamo una funzione che accede all'ennesimo elemento di una lista, in cui n è un parametro (con n=1 si intende la testa della lista). La funzione deve gestire correttamente anche il caso in cui la lista non ha almeno n elementi, restituendo 0.

pnodo ennesimo(pnodo testa, int n) {

if(testa==0)

return 0;

pnodo p=testa;

int i;

for(i=1;i<n;i++) {

p=p->next;

if(p==0) // la lista è finita prima del previsto

return 0;

}

return p;

}

14.4 Liste e ricorsionePer usare la ricorsione, il campo next di un nodo N può essere interpretato come l'indirizzo della testa di una lista, formata da tutti i nodi successivi a N.

Innanzitutto è possibile fornire una definizione ricorsiva di lista: una lista di elementi di tipo T è vuota oppure è formata da un nodo che ha come chiave un valore di tipo T e che è collegato, mediante il campo next, ad una lista di elementi di tipo T.

A partire da questa definizione è possibile definire una funzione ricorsiva su una lista con il seguente schema:

• il caso base è la lista vuota o (in taluni casi) la lista formata da un solo elemento;

• la regola ricorsiva è, partendo dalla testa della lista, applicare la funzione al campo next (che è appunto l'indirizzo di una lista più corta) e di utilizzare il risultato ottenuto, combinandolo anche con il campo key.

Page 155: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

14.4.1 Calcolo della lunghezza

Il calcolo della lunghezza di una lista è un esempio di ricorsione molto semplice. Nel caso base, la lista è vuota e ha 0 elementi. La regola ricorsiva è banale: se la lista che inizia da next ha L elementi, la lista complessiva ne uno in più.

int lunghezza(pnodo testa) {

if(testa==0) // lista vuota

return 0;

else {

int ric=lunghezza(testa->next);

return 1+ric;

}

}

14.4.2 Scrittura sullo schermo

Per scrivere le chiavi di una lista, la regola ricorsiva è banale: si scrive la chiave del primo elemento e si richiama la funzione su next. Nel caso base, ovvero quando la lista è vuota, non si scrive nulla.

void scrivi_lista(pnodo testa) {

if(testa!=0) {

cout << testa->key << “ “;

scrivi_lista(testa->next);

}

}

Si noti che invertendo l'ordine delle istruzioni la lista sarà scritta sullo schermo al contrario.

14.4.3 Eliminazione di un nodo avente una data chiave

In questo caso la ricorsione ha un effetto positivo nella lunghezza della funzione. In pratica quelli che nella versione iterativa erano visti come casi particolari (lista vuota ed eliminazione del primo elemento) ora diventano casi basi. Al resto ci pensa la ricorsione.

pnodo elimina(pnodo &testa, tipo x) {

if(testa==0) // lista vuoto, x non c'è

return 0;

else if(testa->key==x) // primo elemento uguale a x

return elim_inizio(testa);

Page 156: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

else

return elimina(testa->next, x);

}

Per essere convinti che la funzione è corretta, si provi ad eseguirla manualmente nel caso della cancellazione del secondo elemento.

14.4.4 Copia di una lista

La copia di una lista in modo ricorsivo avviene creando un nuovo nodo, la cui chiave contiene la chiave della testa e il cui campo next è collegato ad una copia della lista dal secondo elemento in poi.

pnodo copia(pnodo testa) {

if(testa==0)

return 0;

else {

pnodo nuovo=new nodo;

nuovo->key=testa->key;

nuovo->next=copia(testa->next);

return nuovo;

}

}

14.5 Cenni ad altre strutture dati dinamiche

Mediante i puntatori è possibile definire molte altre strutture dati, come ad esempio varie forme di alberi. Tra le strutture elementari che si possono implementare con le liste troviamo le pile e le code.

14.5.1 Pile

Una pila (o stack) è una struttura dati omogenea sulla quale è possibile svolgere due operazioni

1. inserimento di un nuovo elemento (push)

2. eliminazione dell'ultimo elemento inserito (pop)

Ad esempio se in una pila di interi si inseriscono nell'ordine gli elementi 4, 7 e 3, una prima eliminazione toglie l'elemento 3, mentre la seconda toglie 7. Se a questo punto si inserisce 8, altre due eliminazioni tolgono, nell'ordine, 8 e 4. A questo punto la pila sarà vuota.

Una pila può essere implementata, oltre che con un array, anche con una lista. Innanzitutto è

Page 157: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

sufficiente conservare solo l'indirizzo del primo elemento della lista, che sarà l'elemento in cima alla pila (elemento top). L'operazione push è quindi semplicemente ins_inizio

void push_pila(pnodo &top, tipo x) {

ins_inizio(top,x);

}

mentre pop è equivalente a elim_inizio

pnodo pop_pila(pnodo &top) {

if(top==0) // la pila è vuota

return 0;

return elim_inizio(top);

}

14.5.2 CodeUna coda è una struttura dati omogenea sulla quale è possibile svolgere due operazioni

1. inserimento di un nuovo elemento (enqueue)

2. eliminazione del primo elemento inserito (dequeue)

A differenza della pila, nella coda gli elementi sono eliminati nello stesso ordine in cui sono stati inseriti. Ad esempio inserendo in una coda di interi gli elementi 3, 4 e 8, il primo ad essere eliminato è il 3. Poi il 4. Se a questo punto si inserisce 7, le altre due eliminazioni estrarranno 8 e 7, rispettivamente. A questo punto la coda è vuota.

Per implementare una coda mediante una lista è necessario tenere traccia sia dell'indirizzo del primo elemento, sia di quello dell'ultimo. Entrambi possono essere memorizzati in una struct:

struct coda {

pnodo primo;

pnodo ultimo;

}

L'operazione di inserimento avviene in fondo alla lista mediante la funzione ins_fine

void ins_coda(coda &c, tipo x) {

ins_fine(c.primo,c.ultimo,x);

}

L'operazione di estrazione avviene all'inizio della lista mediante la funzione elim_inizio

pnodo elim_coda(coda &c) {

Page 158: DISPENSE DEL CORSO DI PROGRAMMAZIONE I CON …baioletti/.../materiale/dispense-progr1-c++.pdf · La programmazione è la scienza che ha come oggetto di studio le metodologie e gli

if(c.primo==0) // la coda è vuota

return 0;

return elim_inizio(c.primo);

}

14.6 Esercizi14.1. Definire una funzione che calcola la media degli elementi di una lista di interi

14.2. Definire una funzione che calcola contemporaneamente il minimo ed il massimo di una lista di interi

14.3. Definire una funzione che crea una copia di una lista di interi, ma solo inserendovi gli elementi positivi

14.4. Definire una funzione che crea una copia di una lista di interi, ma solo prendendo gli elementi dalla posizione M in poi

14.5. Generalizzare l'esercizio precedente copiando gli elementi dalla posizione M alla posizione N.

14.6. Definire una funzione che controlla se tutti gli elementi di una lista di numeri interi sono divisibili per D.

14.7. Definire una funzione che concatena due liste di interi (attacca la seconda alla fine della prima).

14.8. Definire una funzione che controlla se in una lista di interi ci sono elementi duplicati.

14.9. Definire una funzione che controlla se una lista di interi è ordinata in senso crescente.

14.10. Definire una funzione che elimina i primi N elementi di una lista di interi.

14.11. Definire una funzione che dealloca tutti gli elementi di una lista di interi (attenzione: non si può usare il metodo di scansione della lista così com'è).

14.12. Definire una funzione che elimina l'elemento più grande di una lista di interi.

14.13. Definire una funzione che elimina l'ultimo elemento di una lista di interi.

14.14. Definire una funzione che scambia in una lista di interi il primo elemento con quello più piccolo (scambiare le chiavi e non i nodi !).

14.15. Definire una funzione che ordina una lista, usando un adattamento dell'algoritmo di ordinamento per selezionare (scambiare le chiavi e non i nodi !).

14.16. Definire una funzione ricorsiva che cerca in una lista di interi la presenza di un elemento uguale a X.

14.17. Definire una funzione ricorsiva che conta in una lista di interi il numero di elementi negativi.

14.18. Definire una funzione che calcola l'intersezione di due liste di interi, cioè una lista che ha come elementi solo quelli che appartengono sia alla prima che alla seconda lista. Suggerimento: creare una copia della prima lista, ma solo degli elementi che appartengono anche alla seconda lista.

14.19. Definire una funzione che calcola la differenza di due liste di interi, cioè una lista che ha come elementi solo quelli che appartengono alla prima ma non alla seconda lista.

14.20. Definire una funzione che date due liste di interi calcola la lista di tutti i possibili prodotti tra ogni elemento della prima lista ed ogni elemento della seconda lista.