Sei sulla pagina 1di 134

Dario Olianas A.A.

2012/2013

Appunti di Introduzione alla Programmazione


(IP)
1. Introduzione ai concetti base (prima lezione)
2. Macchina di von Neumann
2.1 Programmazione della macchina di von Neumann
2.2 Bootstrap
2.3 Estensione procedurale
3. Codifica delle informazioni
3.1 Rappresentazione di interi con segno
3.1.1 Rappresentazione in modulo e segno
3.1.2 Rappresentazione in complemento a 1
3.1.3 Rappresentazione in complemento a 2
3.2 Rappresentazione di numeri razionali
3.2.1 Rappresentazione in virgola fissa (fixed point)
3.2.2 Rappresentazione in virgola mobile (floating point)
4. Macchina convenzionale a stack
5. Principi di programmazione
5.1 Istruzioni di I/O
5.2 Variabili e tipi
5.2.1 Tipi base
5.2.2 Dichiarazione di variabili
5.2.3 Assegnazione
6. Controlli di flusso
6.1 L’istruzione condizionale if
6.1.1 Istruzioni condizionali concatenate (if-else if)
6.2 L’istruzione switch
6.3 Cicli
6.3.1 Ciclo for
7. Struttura dei programmi C/C++
7.1 Sintassi di una funzione
7.2 Main
7.3 Programma che fa la somma di due numeri e stampa il risultato in output
7.4 Utilizzo di librerie
8. Esercizi di programmazione sui cicli
8.1 Operatore condizionale
9. Funzioni
9.1 Definizione e chiamata di funzioni
9.2 Esempio di funzione per l’elevamento a potenza
9.3 Istruzioni di uscita
9.4 Passaggio di parametri
9.4.1 Modi di passaggio di parametri in C++
10. Memoria
10.1 Casting
10.2 Gestione della memoria nelle chiamate a funzione
10.3 Operatori di dereferenziazione
11. Array
11.1 Array come parametri di funzioni
11.2 Operazioni sugli array
11.2.1 Inserimento in coda
11.2.2 Eliminazione di un elemento in coda
11.2.3 Inserimento in ordine
12. Algoritmi di ordinamento
12.1 Insertion sort
12.2 Selection sort
12.3 Bubble sort
12.4 Ricerca per bisezione in un’array ordinato
13. Complessità computazionale
13.1 Analisi di complessità dell’algoritmo di insertion sort
13.2 Proprietà degli ordini di complessità
13.3 Tabella degli ordini di complessità in ordine crescente
13.4 Stima della complessità dell’algoritmo di insertion sort
13.4.1 Regola dell’operazione dominante
13.5 Stima della complessità dell’algoritmo di selection sort
13.6 Stima della complessità dell’algoritmo di ricerca binaria
13.7 Stime di complessità di altri algoritmi
14. Stringhe (del C)
15. Array bidimensionali (tabelle)
16. Il tipo string
16.1 Operare sulle stringhe e funzioni del tipo string
17. Flussi (stream) e operazioni sui file
17.1 Operare sui file
17.2 Esercizio sui file
17.3 Text processing e gestione bufferizzata
17.4 Stringstream
18. Visibilità degli identificatori (scope) e namespace
18.1 Regole di scope
18.1.1 Principali scope
18.2 Namespace
19. Progettazione di tipi di dato
19.1 Stack
19.2 Queue
20. Gestione dinamica della memoria
20.1 Puntatori
20.2 Gestione dello heap
20.2.1 Memory leak
20.3 Array dinamici
21. Vector
21.1 Primitive del tipo vector
21.2 Queue usando vector e template
22. Liste
22.1 Definizione del tipo cell
22.2 Inserimento in testa ad una lista
22.3 Inserimento in coda ad una lista
22.4 Inserimento in mezzo ad una lista
22.5 Copia di liste
22.6 Stack e queue implementati con liste
22.6.1 Stack implementato con lista
22.6.2 Queue implementata con lista
22.7 Tipo di dato insieme
22.7.1 Inserimento ordinato
22.7.2 Cancellazione
22.7.3 Unione
22.8 Liste circolari e liste doppie
22.8.1 Inserimento e cancellazione da liste doppie
22.8.2 Liste circolari (semplici e doppie)
23. Ricorsione
23.1 Principio di induzione
23.2 Definizione induttiva
23.2.1 Definizione induttiva di lista
23.3 Esempi di funzioni ricorsive
23.3.1 Funzione ricorsiva per il calcolo del fattoriale
23.3.2 Funzione ricorsiva per il calcolo dei coefficienti binomiali
23.4 Liste implementate ricorsivamente
23.4.1 Inserimento in liste implementate ricorsivamente
23.4.2 Rovesciamento di liste
23.5 Approccio divide et impera
23.5.1 Mergesort (approccio divide et impera applicato al problema del sort)
24. Complessità degli algoritmi ricorsivi
24.1 Analisi tramite albero della ricorsione
24.2 Complessità dell’algoritmo ricorsivo di ricerca binaria
24.3 Analisi degli algoritmi mergesort e coefficienti binomiali
25. Ricorsione mutua e ricorsione ciclica
25.1 Generazione del codice Gray
26. Parsing, linguaggi formali e grammatiche
26.1 Grammatica generativa
26.2 Parsing di espressioni aritmetiche (semplificate)
27. Incapsulamento delle informazioni nei tipi di dato (information hiding)
27.1 Struct e class
27.2 Campi e metodi
27.3 Esempio: tipo di dato orologio
27.4 Costruttori e distruttori

Laboratori:
A Laboratorio 1
B Laboratorio 4
B.1 Direttive per il preprocessore
B.2 Operatore virgola
B.3 Operatore condizionale
B.4 Generazione di numeri pseudocasuali

C Laboratorio 5 (codice modulare)


1. Introduzione ai concetti base (prima lezione)
Approcci alla progettazione:

• top-down
• bottom-up

L'approccio top-down parte da un livello di astrazione alto e scompone il problema in parti più
semplici da risolvere, che poi vanno collegate. L'approccio bottom-up ragiona al contrario:
partendo dalle componenti base si arriva a risolvere il problema principale.
L'approccio top-down è quello di un ingegnere che progetta un palazzo, l'approccio bottom-up è
quello del muratore che lo costruisce.
La chiave di entrambi gli approcci è di avere diversi livelli di astrazione.

Programmazione strutturata: funziona per livelli di astrazione diversi e per ogni livello assume di
disporre degli strumenti per risolvere i sottoproblemi dei livelli sottostanti. Ad esempio, per
scrivere non serve sapere come si fabbrica una penna o un foglio di carta, sono cose che si hanno e
sono date per scontate.

Concetti di input/output: ogni pezzo di software:

• riceve dati in input


• produce dati in output

L'input/output è alla base del concetto di astrazione: se ho una scatola nera che svolge un
determinato compito non importa che io ne sappia il funzionamento, se le fornisco un certo input
otterrò l'output corrispondente. Se invece non ho la scatola nera che mi serve devo scendere al
livello di astrazione inferiore e progettare un sistema che svolga il compito richiesto.

Concetti chiave:

• macchina virtuale
• linguaggio/alfabeto/comandi
• interpretazione
• estensione procedurale

Si usa il termine macchina virtuale perché non è riferito per forza ad una macchina fisica: una
macchina vituale è qualunque cosa, software o hardware, che riceva un input e produca un
output. È la scatola nera di prima.
Il linguaggio è lo strumento con il quale comandiamo la macchina virtuale.
Una macchian virtuale può anche servirsi di altre macchine virtuali per eseguire compiti più
complessi: in questo caso si parla di estensione procedurale. Vedendo una pizzeria come una
macchina virtuale, la macchina virtuale cameriere prende l'ordinazione, ma deve utilizzare la
macchina virtuale pizzaiolo per avere una pizza da portare. A sua volta il pizzaiolo deve servirsi di
chi ha prodotto gli ingredienti.
I calcolatori sono strutturati su diversi livelli di astrazione:

• Linguaggi di alto livello: linguaggi formali e non ambigui, tuttavia "facilmente"


comprensibili dall'uomo.
• Assembler e librerie: un linguaggio fatto di poche e semplici istruzioni, inadatto per
svolgere compiti complessi (anche un programma molto semplice in linguaggio di alto
livello richiede migliaia di istruzioni assembler
• Kernel (nucleo del sistema operativo): il programma che gestisce tutti gli altri programmi e
si interfaccia direttamente con l'hardware
• Macchina convenzionale
• Microarchitettura {

ibrido hardware-firmware

• Logica circuitale {
• Elettronica {

questi ultimi due livelli riguardano solo l'hardware

• Fisica dello stato solido {


2. Macchina di von Neumann
Composta da RAM e Control Unit. La RAM serve a conservare informazioni per due scopi: o dati
che il calcolatore sta elaborando o istruzioni da eseguire. È organizzata in forma di vettore: un
elenco di valori dello stesso tipo in sequenza numerata. Nel nostro modello di macchina di von
Neumann la RAM è composta da 1000 elementi numerati da 0 a 999 e ciascuna cella di memoria
contiene un numero intero in base 10 di massimo 4 cifre. Si chiama Random Access Memory
perché si può accedere direttamente a qualunque cella di memoria. Sulla RAM possono essere
svolte solo due operazioni: lettura e scrittura.

Control Unit
La control unit è composta da:

• ALU (Arithmetic Logic Unit): l'unità che si occupa di calcoli e operazioni logiche. Nel caso
della macchina di von Neumann sa fare solo somme e sottrazioni
• Decoder: si occupa di decodificare le istruzioni
• Registri PC, IR, ACC

I registri sono come celle di memoria: possono contenere numeri di massimo 4 cifre.
Istruzioni della macchina di von Neumann

0 (con parametro N): somma due numeri


1 (con parametro N): sottrae due numeri
2 (senza parametro): leggi un numero da input e mettilo in ACC
3 (senza parametro): scrivi un numero in output (il numero presente in quel momento
nell'accumulatore)
4 (con parametro N): scrivi un numero nella cella di memoria N
5 (con parametro N): leggi un numero dalla cella di memoria N
6 (con parametro N): salto incondizionato
7 (con parametro N): salto condizionato
8 (senza parametro): arresto

L'accumulatore contiene i risultati delle operazioni svolte dalla ALU o i numeri letti dall'input.
Come le celle di RAM può contenere un solo valore alla volta.

Ciclo di funzionamento della MVN

La macchina di von Neumann ha quattro stati, che vengono ripetuti in ciclo finché non viene
eseguita l'istruzione 8 (arresto):

1. reset: scrive 0 nel program counter


2. fetch: prende un'istruzione dalla cella di RAM indicata dal PC e la scrive nell'IR, incrementa
di 1 il valore del PC IR <- RAM[PC]; PC++
3. decode: l'istruzione presente in IR viene interpretata dal decoder
4. execute: l'istruzione viene eseguita

A meno che non vengano eseguite istruzioni di salto, una volta eseguita l'istruzione si ritorna alla
fase di fetch, che prenderà l'istruzione successiva a quella appena eseguita (poiché il PC è stato
incrementato di 1).

Istruzioni di salto
Le istruzioni di salto consentono di modificare l'esecuzione sequenziale del programma,
modificando il valore del program counter non con l'indirizzo di memoria dell'istruzione successiva
ma di un'altra istruzione. L'istruzione 6 esegue sempre il salto, l'istruzione 7 permette di fare una
scelta. Infatti, l'istruzione 7 esegue il salto all'indirizzo di memoria N (scrive N in PC) solo se il
valore contenuto in ACC è 0. Combinando le istruzioni 6 e 7 si può realizzare un ciclo: si può far
rieseguire una stessa parte di codice finché l'accumulatore non contiene 0.

Struttura delle istruzioni


La cifra più a sinistra è il codice che identifica l'istruzione, le altre 3 cifre sono il parametro (quando
previsto dal tipi di istruzione). Le istruzioni che non prevedono parametro sono sempre scritte
come 2000, 3000, 8000. Ad esempio l'istruzione 0873 somma il valore contenuto all'indirizzo 873
con l'ACC, l'istruzione 6221 salta sempre all'indirizzo 221 (PC <- 221).
La macchina di von Neumann può eseguire codice automodificante, dato che non c'è distinzione
tra la memoria dati e la memoria istruzioni.
2.1 Programmazione della macchina di von Neumann
Programma per la somma di due numeri:

2000 chiede in input un valore da sommare e lo mette in accumulatore


4006 salva nella cella di memoria 6 il valore precedentemente ricevuto
2000 chiede in input il secondo valore da sommare e lo mette in accumulatore
0006 somma il valore contenuto nella cella di memoria 6 con quello presente in accumulatore
3000 manda in output il risultato
8000 arresto

Sostituendo l'istruzione 4006 con 4000, risparmieremmo una cella di RAM perché si userebbe lo
spazio della prima istruzione del programma (che ormai non serve più) invece di usare un'altra
cella di memoria. Bisognerebbe cambiare anche la 0006 con 0000. Il programma potrebbe però
essere eseguito solo una volta.

Si può modificare il programma per fare somme visto nella lezione precedente in modo che possa
essere utilizzato più di una volta, facendolo continuare a leggere coppie di numeri e scrivere la
somma in output finché non gli viene dato in input uno 0. Per fare questo abbiamo bisogno di
un ciclo, cioè un blocco di istruzioni che vengono eseguite ripetutamente.

Un ciclo ha sempre bisogno di:

• un'istruzione che lo faccia ricominciare ogni volta (nella MVN sarà una 6 che riporta il PC
all'inizio del ciclo)
• una condizione di arresto che decide quando il ciclo è finito (nella MVN sarà una 7)

Nei linguaggi di alto livello si usano solo i salti condizionati.


Schema di funzionamento del programma:

• leggo un dato
• se il dato è 0, scrivo zero in output e termino
• altrimenti:
o leggo un altro dato
o se è 0 scrivo zero in output e termino
o altrimenti:
 sommo i due addendi
 scrivo il risultato in output
 torno all'inizio del ciclo

In questo programma non si possono usare celle contenenti istruzioni per scriverci dati, poiché le
istruzioni devono poter essere rieseguite e quindi non vanno sovrascritte.

2000 leggi valore da input


7009 se è 0 vai salta
4008 salva in RAM il valore ricevuto prima
2000 leggi il secondo addendo
7009 se è 0 salta
0008 somma
3000 manda in output il risultato
6000 torna all'inizio del ciclo
xxxx cella per memorizzare il secondo addendo
3000 manda in output l'ACC
8000 arresto

La MVN non ha nativamente un'istruzione per la moltiplicazione, ma può essere implementata con
un programma, che per fare a*b somma a con se stesso b volte.

Programma che fa una sola moltiplicazione, poi termina


Schema di funzionamento:

• leggo un numero
• se è 0 scrivo 0 in output e termino
• altrimenti:

o leggo un altro numero
o se è 0 scrivo 0 in output e termino
o altrimenti:
o
 scrivo un ciclo che giri b volte e sommi a con se stesso, accumulando in una
cella di memoria xxx la somma parziale
 scrivo il valore contenuto nella cella xxx in output
 termino

0 2000
1 7019
2 4016 in 16 si accumula il risultato parziale della moltiplicazione
3 4015 15 contiene sempre il valore di a
4 2000
5 7019
6 1014 (in 14 sarà salvato un 1 per decrementare ad ogni ciclo l'ACC)
7 7018(controlla se è finito il ciclo)
8 4017 (17 contiene il contatore)
9 5016
10 0015
11 4016 le istruzioni 9, 10 e 11 realizzano l'operazione RAM[16] <- RAM[16] + RAM[15]
12 5017
13 6006 torna all'inizio del ciclo
14 0001
15 a
16 risultato
17 contatore
18 5016
19 3000
20 8000
2.2 Bootstrap

Il programma di bootstrap si occupa di caricare in memoria un programma specificato da input


un'istruzione alla volta e mandarlo in esecuzione. Il programma deve contenere una sola istruzione
8000 alla fine.

Sequenza di input:

• indirizzo della prima istruzione


• sequenza di istruzioni del programma
• istruzione 8000 (arresto)

Output: l'output di questo programma è la messa in esecuzione del programma ricevuto in input

Criticità (Non controllate):

• il programma caricato non si dece sovrapporre al codice del bootstrap


• il programma deve stare in memoria
• il programma deve contenere una sola istruzione 8000 alla fine, perché questo è il segnale
che il programma è finito

Chiameremo xxx l'indirizzo della prima istruzione del programma che vogliamo caricare.
Il programma di bootstrap deve essere automodificante perché ad ogni ciclo deve modificare
l'indirizzo in cui verrà scritta l'istruzione data in input.
Per controllare quando riceviamo l'8000, visto che le istruzioni di salto possono controllare solo se
in ACC c'è 0, sottrarremo 7999 ad ogni istruzione ricevuta: se il risultato è 0 abbiamo inserito in
input un numero più piccolo di 8000 (la MVN usa solo numeri positivi, quando il risultato di una
sottrazione è negativa da 0), se viene 1 abbiamo ricevuto in input 8000. Per controllare se
abbiamo ricevuto 8000 e non un numero più grande, poiché non possiamo farlo direttamente,
dobbiamo togliere ancora 1 al risultato della prima sottrazione: se viene 0 l'istruzione era 8000.

Codice:

0 2000
1 0015 //somma 4000 (contenuto nella cella 15) al valore ricevuto in input, per poter poi salvare
4xxx all'indirizzo 6
2 4006
3 0016
4 4010
5 2000
6 4000+i //all'inizio dell'esecuzione i=xxx
7 1017
8 7011
9 1018
10 7xxx //istruzione per caricare il programma
11 5006
12 0018
13 4006
14 6005
15 4000 //dato, non istruzione
16 3000
17 7999 //serve per il controllo (dato)
18 0001 //" " " "

2.3 Estensione procedurale

L'estensione procedurale serve ad utilizzare un pezzo di codice (ad esempio un programma per
fare le moltiplicazioni) come se fosse un'istruzione (o meglio, un piccolo gruppo di istruzioni). È un
programma che, invece di essere utilizzato direttamente dall'utente, viene utilizzato da un altro
programma. Per questo avrà bisogno dei dati su cui operare e dell'indirizzo a cui deve ritornare
una volta finito il compito
Parametri: dati che il programma chiamante deve passare all'estensione procedurale per
consentirne il funzionamento
Valori di ritorno: dati che l'estensione procedurale restituisce al programma chiamante come
risultato del calcolo
Indirizzo di ritorno: dato che il programma chiamante deve passare all'estensione procedurale per
dire il punto da cui il programma deve continuare una volta terminata l'estensione procedurale.
Estensione procedurale per moltiplicazioni
Vogliamo che si comporti come l'istruzione ACC <- ACC*RAM[N], quindi il valore di ritorno sarà
nell'accumulatore. Gli operandi (parametri) staranno uno nell'accumulatore e l'altro in RAM[N].
Quindi il codice dell'estensione procedurale dovrà contenere una locazione di memoria in cui il
programma chiamante dovrà scrivere l'indirizzo in cui si trova l'operando (N). Il programma
chiamante deve sapere qual'è la cella riservata al parametro. Riserviamo quindi la prima cella
dell'estensione per l'indirizzo dell'operando, la seconda per l'indirizzo di ritorno.

100 locazione riservata per N


101 locazione riservata per l'indirizzo di ritorno yyy
102 5000
103 6000
104 locazione riservata per il moltiplicando
105 locazione riservata per il moltiplicatore
106 locazione riservata per il risultato
107 0001
il codice dell'estensione procedurale comincia da qui
108 4104 salva in RAM il moltiplicando
109 5101
110 0103
111 4130
112 5100
113 0102
114 4115
115 5aaa

In A:

5(yyy-2) scrive l'indirizzo di ritorno in ACC


4101 passa l'indirizzo di ritorno all'estensione procedurale
5(yyy-1) mette in ACC il moltiplicatore
4100 mette il moltiplicatore in RAM[100]
5bbb mette in ACC il moltiplicando
6108 salta all'estensione procedurale
yyy indirizzo di ritorno (due caselle dopo questa)
aaa indirizzo della cella che contiene il moltiplicatore
3. Codifica delle informazioni

I calcolatori non usano una codifica in base 10 come noi, ma usano la codifica in base 2 (binaria)
perché è più semplice distinguere due livelli di tensione rispetto a 10.

1001101 = 2^0 + 2^2 + 2^3 + 2^6 = 1+4+8+64 = 77

Un calcolatore non deve saper codificare solo numeri, ma anche testi, immagini, video ecc.

Codifica (e decodifica)

X = insieme di valori che vogliamo rappresentare (nel caso della MVN sono i numeri da 0 a 9999)

A = alfabeto dei simboli utilizzabili per la codifica (nella notazione decimale A={0,....,9}, binario
A={0,1})

A* = insieme di tutte le possibili sequenze (stringhe) di simboli di A

Funzione di codifica

Una funzione di codifica E è una funzione matematica che preso un elemento in X restituisce una
stringa di elementi di A* che rappresentano ciò che si vuole codificare.

E: X -----> A*

Una funzione di codifica è sempre iniettiva, cioè se x è diverso da y allora E(x) è diverso da E(y).

Funzione di decodifica

D: A* ------> X u {errore}

La funzione di decodifica ritorna da una stringa di elementi di A* a un elemento dell'insieme di


valori da rappresentare. L'errore è quello a cui portano le stringhe che non codificano niente.

Codifica e decodifica servono per poter rappresentare qualunque dominio finito in simboli
comprensibili alla macchina.
Codifica posizionale a lunghezza fissa

I calcolatori usano codifiche a lunghezza fissa: 1 rappresentato in una codifica ad 8 bit


sarà 00000001.

Con numeri binari di n cifre rappresentiamo fino a 2^n elementi diversi.

Se X ha M elementi per codificarli in binario dovrò usare codici di almeno log2M cifre.

Somma di numeri interi positivi

Se usiamo una codifica ad 8 bit e sommiamo due numeri che danno come risultato un numero di 9
bit abbiamo un risultato non rappresentabile con la nostra codifica, e siamo in una situazione
chiamata di overflow. Se tagliamo il bit in eccesso ottenuamo un risultato sbagliato, quindi in
genere i calcolatori hanno un sistema per segnalare quando si è andati in overflow.

Rappresentazione dei caratteri (non c'è nelle dispense)

X = lettere dell'alfabeto U cifre decimali U segni di punteggiatura U simboli matematici U altri


caratteri speciali (spazio, ritorno a capo....)

Sistemi diversi possono usare codifiche diverse: ad esempio su DOS/Windows il ritorno a capo è
composto da due caratteri (carriage return; new line) su Linux solo da uno e questo può causare
problemi di incompatibilità in programmi che non gestiscono entrambe le codifiche.

Lo standard per le codifiche dei caratteri è definita dalla tabella ASCII, che associa caratteri ai
numeri interi da 0 a 128. Esistono altre codifiche che permettono di rappresentare più caratteri
(tabella ASCII estesa, Unicode (8 e 16 bit)).

In ASCII: A=65, B=66 e così via. Vengono rappresentate prima le maiuscole delle minuscole.
3.1 Rappresentazione di numeri interi con segno
Se stiamo lavorando sui numeri non possiamo mettere un più o un meno davanti per specificare il
segno, poiché abbiamo a disposizione solo i simboli 0 e 1. Servono quindi altre strategie per
utilizzare i numeri con segno.

3.1.1 Rappresentazione in modulo e segno


Riservo il bit più a sinistra per rappresentare il segno (0 = +; 1 = -) e i rimanenti bit per
rappresentare il modulo del numero. In questo modo in una codifica a 8 bit avrei 7 bit per il
numero e uno per il segno, quindi potrò rappresentare meno numeri (la metà) poiché un bit è
impiegato per rappresentare il segno. In questo modo ho anche due rappresentazioni possibili per
lo zero: 00000000 e 10000000 (sarebbe come dire +0 e -0). Non è una bella codifica perché ha due
possibili codifiche per uno stesso valore, e perché +0 e -0 dal punto di vista matematico non ha
senso. Rende anche più complicata la somma di positivi e negativi, perché bisogna prima
controllare quale dei due addendi ha il modulo più grande.

Difetti della rappresentazione in modulo e segno:

• due rappresentazioni possibili per lo zero (00000000 e 10000000)


• algoritmo di somma farraginoso

3.1.2 Rappresentazione in complemento a 1

I numeri positivi sono rappresentati normalmente, i numeri negativi si codificano prendendo il


modulo del numero e negando tutti i suoi bit.

Esempio di rappresentazione in complemento a 1: -98 su 8 bit. Rappresentiamo prima in binario


+98: 01100010

Ora neghiamo tutti i suoi bit: 10011101 : questo è -98 in binario rappresentato in complemento a
1.

Anche in questo caso il primo bit viene utilizzato per rappresentare il segno: altrimenti non potrei
sapere se un numero è positivo o negativo.

La rappresentazione in complemento a 1 non elimina il problema della doppia rappresentazione


dello 0: infatti 00000000 e 11111111 rappresentano entrambi lo zero (sarebbe come dire +0 e -0).
Somma di numeri in complemento a 1: se voglio calcolare a+b con a e b rappresentati in
complemento a 1:

• se a>=0, b>=0 funziona l'algoritmo standard


• se a<0, b>=0 oppure a>=0, b<0 e a+b<0 l'algoritmo standard funziona ancora : 32-
47= 00100000 + 11010000 = 11110000 che decodificato rappresenta 00001111 cioè -15
• negli altri casi l'algoritmo standard funziona ma restituisce un risultato inferiore di 1 al
risultato corretto.

Quindi un algoritmo di somma per il complemento a 1 deve controllare in quale caso stiamo
operando, e se non è nessuno dei casi in cui l'algoritmo standard è corretto deve aggiungere 1 al
risultato. Non è quindi un algoritmo molto semplice. Per questo è stato introdotto il complemento
a 2.

3.1.3 Rappresentazione in complemento a 2


È uguale alla rappresentazione in complemento a 1 ma aggiungendo 1 al risultato finale della
codifica. I numeri positivi sono rappresentati normalmente, i numeri negativi si codificano
prendendo il modulo del numero, negando tutti i suoi bit e aggiungendo 1 al risultato finale.

Esempio di rappresentazione in complemento a 2: -98 su 8 bit = 10011110.

Decodifica: si negano i bit del numero da decodificare, si aggiunge 1 e si decodifica usando


l'algoritmo standard di decodifica dei numeri binari naturali.

Con la rappresentazione in complemento a 2 l'algoritmo standard di somma per i naturali funziona


per tutte le coppie di interi.

Si elimina anche il problema della doppia rappresentazione dello zero: se infatti 0 è 00000000 ,se
provo a rappresentare -0 dovrò negare tutti i bit e aggiungere 1, ma facendo questo andrò in
overflow e otterrò di nuovo 00000000, quindi c'è solo una rappresentazione dello zero possibile.

Su N bit, quanti codici diversi si possono generare? 2^N

Su N bit, quanti valori diversi possiamo rappresentare? Questo dipende dall'algoritmo di codifica
utilizzato. Nel complemento a 2 abbiamo N-1 bit per il valore e 1 bit per il segno. Abbiamo quindi
2^(N-1) numeri negativi e 2^(N-1)-1 numeri positivi, più lo zero. Abbiamo un numero in meno tra i
positivi perché lo zero viene rappresentato come se fosse un numero positivo, con lo 0 in prima
posizione. In totale abbiamo 2^N valori diversi tra loro.
Eccesso a 2^(N-1)

Se a qualunque numero in complemento a 2 aggiungo 2^(N-1) ottengo una codifica per cui -2^(N-
1) viene rappresentato con 0, -2^(N-1)+1 con 1 e così via. 0 sarà 2^(N-1)

Codifica:

1. sommo 2^(N-1) al numero


2. lo rappresento in binario

La rappresentazione in eccesso a 2^(N-1) è identica a quella in complemento a 2 tranne che per il


primo bit (il bit più a sinistra) che è negato. L'algoritmo di somma funziona lo stesso.

3.2 Rappresentazione di numeri razionali


3.2.1 Rappresentazione in virgola fissa (fixed point)
Non possiamo rappresentare tutti i numeri reali su stringhe finite. Possiamo rappresentare solo un
sottoinsieme finito dei numeri razionali. Rappresenteremo solo i numeri razionali scomponibili in
potenze di 2 compresi tra 2^-K e 2^(H-1) dati K e H. Serviranno N=K+H bit

Sui decimali: 32,875 = 3*10^1 + 2*10^0 + 8*10^-1 + 7*10^-2 + 5*10^-3

In binario: rappresentazione in virgola fissa (per gli interi senza segno)

010110,11 = 2^-2 + 2^-1 + 2^1 + 2^2 + 2^4 = 16+4+2+0.5+0.25= 22,75

In questa rappresentazione è stabilito a priori quanti numeri stanno prima e dopo la virgola, per
questo è detta in virgola fissa.

Nella rappresentazione in virgola fissa ogni numero è del tipo 2^E * M con E,M interi. M (mantissa)
è l'interpretazione del numero come se fosse un intero senza segno, E è l'esponente della potenza
di 2 minore che si ha nel numero (in questo caso 2^-2). La mantissa si codifica volta per volta,
l'esponente è codificato una volta sola poiché essendo una rappresentazione in virgola fissa il
numero di cifre dopo la virgola (e quindi la potenza di 2 minore che si ha in ogni numero) è sempre
la stessa.
La rappresentazione con segno usa il complemento a 2 per la mantissa e per il resto funziona tutto
allo stesso modo.

Se la virgola è molto a destra abbiamo poca precisione sulle cifre decimali, se è molto a destra
abbiamo più precisione sui decimali, ma non possiamo rappresentare numeri molto grandi.
Questo problema si risolve con la rappresentazione in virgola mobile.

3.2.2 Rappresentazione in virgola mobile (floating point)


Servono per avere una rappresentazione che si possa adattare alla scala in uso. Viene memorizzata
nella codifica del numero non solo la mantissa, ma anche l'esponente. Nella rappresentazione
floating point il numero è costituito da:

• 1 bit di segno S
• k bit che codificano E con la convenzione che E sia intero codificato in complemento a 2
• L bit per la mantissa M

I=2^E * M = (-1)^S * 2^E * M


Esempio: I = 5.5

Posso rappresentarlo come coppia (E,M) con E=0 e M = 5.5. Posso anche rappresentarlo con E=1
ed M=2.75 (2.75 *2^1=5.5), o con altre coppie, purché rispettino l'equazione I=2^E*M.

M ha sempre un valore compreso tra 1/2 e 1 (0.5<=M<1) (nello standard IEEE754 1<=M<2)

Con questa convenzione, se scriviamo M in binario avrà sempre la forma 0,1xxxx (xxxx è lungo L
bit). M è un numero fixed point in cui il punto sta sempre tra le prime due cifre e che inizia sempre
per 01, quindi è inutile codificare le prime due cifre, sono sempre uguali. Codifichiamo solo gli L bit
dopo lo 0,1 iniziale.

Esempio: Vogliamo rappresentare il numero -118,625.

S=1 (perché è negativo).

Rappresentiamo 118,625 in virgola fissa: 1110110.101

Ora spostiamo la virgola a sinistra fino a quando non otteniamo un numero del tipo 0.1xxxxxx
(dopo la cifra più a sinistra è come se ci fossero zeri):
0.1110110101, ma non codifico lo 01 iniziale quindi M=11011010100.... (zeri in fondo fino a
raggiungere L bit di lunghezza)

La virgola è stata spostata di 7 posizioni, il numero è stato quindi dimezzato 7 volte. Per tornare al
valore originale lo dovrò raddoppiare 7 volte, ossia moltiplicarlo per 2^7, quindi abbiamo E=7.
Codifica finale: I=2^7 * 0.1110110101 (mantissa rappresentata in binario)

4. Macchina convenzionale a stack


A differenza della MVN, nella macchina convenzionale c'è una separazione netta tra istruzioni e
dati. Le istruzioni di tutti i programmi caricati in memoria stanno sempre in una porzione
predeterminata della RAM.
Non esiste una distinzione netta tra programma ed estensione procedurale, poiché un'EP è un
programma chiamato da un altro programma. Ogni programma è come se fosse un'estensione
procedurale del sistema operativo, poiché ogni programma è lanciato dal SO.
I dati utilizzati dai programmi stanno in un'altra porzione di memoria chiamata stack.
Il primo programma P1 che viene eseguito chiede allo stack di allocare le N celle di memoria che
gli servono, e vengono allocate all'inizio dello stack. Se P1 ha bisogno di P2 come estensione
procedurale P1 non ha ancora terminato la sua esecuzione, quindi è importante che i suoi dati
rimangano nello stack, quindi i dati di P2 verranno allocati dopo P2. Se P2 chiama P3 rimarranno in
memoria i dati di P2 e quelli di P3 saranno allocati ancora dopo. Quando P3 finisce il suo lavoro la
parte di stack assegnata a P3 viene deallocata,perché non ce n'è più bisogno. Se P2 in seguito
chiama un'altra estensione procedurale P4, i suoi dati potranno essere allocati nella parte di stack
che prima era di P3 perché ora è libera.
Quando un programma è in esecuzione sta sempre utilizzando la parte più alta dello stack.
Lo stack è di fatto un area di memoria che si riempie e si svuota a seconda delle esigenze dei
programmi in esecuzione.
Per fare questo la macchina di von Neumann non basta.

Registri aggiuntivi (rispetto alla MVN):

• Base Pointer (o Frame Pointer) (BP o FP): dice dove inizia la zona di stack usata dal
programma in esecuzione
• Stack Pointer (SP): dice qual'è la prima cella libera dello stack

Indirizzamento indicizzato

Rispetto alla MVN cambia anche il modo di allocazione della memoria. Nella MVN ad esempio
l'istruzione 0432 significa ACC <- ACC + RAM[432]. Si parla di indirizzamento diretto poiché
nell'istruzione è contenuto l'indirizzo assoluto della posizione di memoria in cui andiamo ad
operare.
In una macchina convenzionale a stack invece, nell'istruzione è contenuto un numero
chiamato offset ossia un numero che specifica lo spostamento che si deve fare rispetto alla
posizione di memoria presente in BP per ottenere la posizione di memoria su cui operare.
Nel caso di una macchina convenzionale a stack l'istruzione 0432 starebbe a significare ACC <- ACC
+ RAM[BP + 432].Si parla quindi di indirizzamento indicizzato. Abbiamo bisogno
dell'indirizzamento indicizzato perché la memoria dati di P2 non si troverà sempre nella stessa
posizione dello stack, ma dipenderà dal momento in cui viene lanciato e da quali altri programmi
erano in esecuzione.

Lo stack pointer serve quando c'è bisogno di allocare ulteriore memoria. Mentre un programma P
è in esecuzione è possibile allocare ulteriore memoria sullo stack a partire dalla locazione
specificata in SP in poi. Questo serve sia per permettere a P di lanciare un'estensione procedurale,
sia per estendere la memoria a disposizione di P. Per estendere la memoria di P ci sono nella
macchina convenzionale due istruzioni aggiuntive chiamate push e pop. L'istruzione di push ha
l'effetto di incrementare il valore di SP, mettendo quindi a disposizione una posizione di memoria
in più al programma. La pop ha l'effetto opposto: decrementa il valore di SP togliendo sl
programma una posizione di memoria.
Chiamata di un'estensione procedurale
Il programma chiamante deve ricordarsi dove comincia la sua area di memoria, ossia salvare il
valore di FP. Quindi ci facciamo dare un'altra cella di memoria dallo stack e ci salviamo FP. Bisogna
poi comunicare a P2 dove inizia la sua area di memoria (di P2) quindi diamo a FP il valore di SP.

chiamante (P1) chiamato (P2)

RAM[SP]<-FP alla fine, per ritornare a P1:


FP<-SP SP<-FP
SP<-SP+k+2 (k=celle richieste da P2) FP<-RAM[SP]
RAM[SP+1]<-PC PC<-RAM[SP+1]
PC <- i (indirizzo di partenza di P2)

5. Principi di programmazione
Programmazione imperativa: istruisce il computer attraverso una sequenza di comandi. Alla
programmazione imperativa sono seguite la programmazione strutturata, una forma di
programmazione imperativa in cui si cerca di organizzare il codice in blocchi che hanno
un'interfaccia verso il resto del programma ben definita, con ad esempio dei dati che esistono solo
dentro ad alcuni blocchi e vengono scambiati con il resto del programma. Un esempio di
programmazione strutturata è quella che usa le estensioni procedurali. La programmazione
modulare fa programmi composti da moduli chiusi e abbastanza indipendenti tra loro, è una
programmazione strutturata spinta all'estremo

• Programmazione strutturata: Pascal, C++


• Programmazione modulare: Modula2 (evoluzione del Pascal), ADA
• Programmazione object-oriented: Java

Il C non nasce per la programmazione strutturata, è stato progettato per scrivere software a basso
livello come i sistemi operativi. Il C++ è stato invece studiato come linguaggio strutturato, essendo
un'evoluzione del C. Il C++ è un ibrido tra linguaggio strutturato e object-oriented. C e C++ sono
linguaggi di alto livello. I linguaggi di alto livello devono essere tradotti in linguaggio macchina da
un compilatore, che è composto da:

• parser: legge il codice di alto livello e controlla che sia sintatticamente corretto. Se trova
errori ferma la compilazione e avverte il programmatore.
• compilatore propriamente detto: effettua la traduzione in linguaggio macchina, salvo le
chiamate a funzioni di libreria. Le librerie forniscono estensioni procedurali già compilate
che possiamo usare nei nostri comandi come se fossero comandi singoli. Per usarle
dobbiamo conoscerne l'interfaccia di programmazione (cosa si aspettano in input, che
funzione svolgono e cose restituiscono in output). Il compilatore compila solo il codice
scritto dal programmatore e lascia dei riferimenti simbolici quando incontra chiamate alle
librerie.
• linker: risolve i collegamenti simbolici alle librerie e produce il codice macchina eseguibile
finale. Gli errori del linker sono dovuti principalmente a librerie non trovate.

Generalmente non si pensa alle tre fasi e si tratta il compilatore come un programma unico che dal
codice ad alto livello restituisce codice macchina eseguibile.

Pseudocodice: assomiglia ad un linguaggio di alto livello, ha una sintassi informale (basta che si
capisca). Può includere metaistruzioni che lasciano non specificate parti di codice arbitrariamente
complicate.
Sia linguaggio ad alto livello di pseudocodice hanno parole chiave, parole che rappresentano
istruzioni.

Parole chiave: indicano comandi/istruzioni o altre costanti del linguaggio.

5.1 Istruzioni di I/O

• in pseudocodice useremo leggi() e scrivi() . Tra parentesi indicheremo i valori da


leggere o scrivere.
• in C si usa scanf() per leggere e printf() per scrivere.
• in C++ si usa cin>> <destinazione input> per leggere in input e cout<<
<destinazione output> per andare in output.

Valori che si possono scrivere in output:

• singoli caratteri
• stringhe di caratteri
• numeri

Questi valori possono essere specificati:

• come costante (es: cout<<'a'; stampa il carattere a, cout<<7; stampa il numero


7, cout<<"pippo"; stampa la stringa pippo)
• come espressione (es: cout<<7+2; manderà in output 9)

Se i programmi usassero solo espressioni scritte nel codice o costanti farebbero sempre le stesse
cose ad ogni esecuzione. Hanno bisogno di usare dati presi in input che verranno poi memorizzati
in RAM.
Nei linguaggi ad alto livello non si lavora direttamente sulle posizioni di memoria come nella MVN,
ma si lavora sulle variabili, informazioni di un tipo specificati, a cui viene dato un nome che si usa
per fare riferimento ad esse invece di usare l'indirizzo di memoria.
5.2 Variabili e tipi
Nella MVN la RAM è una sequenza di celle numerate atte a contenere numeri interi da 0 a 9999
In un'architettura attuale la RAM è una sequenza di celle numerate atte a contenere k bit (nelle
architetture attualmente in uso k è uguale a 32 o 64 bit). I linguaggi ad alto livello fanno una grossa
astrazione rispetto alla gestione fisica della memoria.
In un linguaggio ad alto livello tipato la RAM è un insieme non ordinato di contenitori ciascuno
atto a contenere valori di un dato tipo tali che a ciascuno possiamo dare un nome, definito dal
programmatore, detto in termini informatici identificatore. Questo nome denota una variabile.
Variabile: cella di memoria di un dato tipo e con un dato nome.

5.2.1 Tipi base

• bool: dato che può assumere solo due valori: vero o falso.
• char: caratteri
• numeri interi:
o int
o short
o long
o unsigned int (numeri interi senza segno)
o byte (intero memorizzato su 8 bit, numeri tra 0 e 255)
• numeri reali/razionali floating point:

o float (precisione singola)
o double (precisione doppia, usano il doppio dei bit rispetto ai float)

All'interno di ciascun tipo c'è permeabilità: un byte può essere convertito in int, un int può essere
convertito in float ecc. Anche un char può essere convertito in int perché nella tabella ASCII ad
ogni carattere corrisponde un numero.
Dichiarare una variabile vuol dire scrivere dentro il codice un'affermazione che stabilisce che da
ora in poi userò una variabile di un certo tipo a cui do un certo nome, e ogni volta che uso quel
nome il programma sa che mi riferisco a quella variabile.

5.2.2 Dichiarazione di variabile


La dichiarazione di una variabile dice che il programma intende usare una cella di memoria di un
certo tipo e le da un nome. In pseudocodice si può usare la notazione alla Pascal var <nome>:
<tipo>, ad esempio var pippo: int.
In C/C++: <tipo> <nome> , ad esempio int pippo;
Il punto e virgola va obbligatoriamente messo alla fine di ogni istruzione e indica che l'istruzione è
finita.
Valore destro di una variabile: è il contenuto della cella
Valore sinistro di una variabile: la cella stessa, vista come contenitore
La sequenza:

int i;
cin>>i; //i è valore sinistro
cout<<i<<endl; //i è valore destro

dichiara la variabile i di tipo intero, legge un valore da input e stampa la variabile, che conterrà il
valore ricevuto, in output.

5.2.3 Assegnazione
L'assegnazione è l'operazione che da un valore a una variabile.
In pseudocodice: <variabile> <- <espressione>
In C/C++: <variabile> = <espressione>
Calcola il valore dell'espressione e lo scrive nella variabile: essendo in questo caso la variabile
trattata come contenitore in cui mettere il risultato dell'espressione la variabile è vista come
valore sinistro.
Se dichiaro una variabile e non ci metto niente dentro è sintatticamente corretto ma può essere
rischioso perché non so cosa c'è in quella variabile. In questo caso il compilatore da uni warning,
ossia un avvertimento che non interrompe la compilazione del programma, ma avverte il
programmatore che potrebbero verificarsi problemi durante l'esecuzione. Per questo è
importante inizializzare sempre le variabili.
Inizializzazione: assegnazione di un valore ad una variabile per la prima volta.
Gli errori da mancata inizializzazione sono particolarmente pericolosi perché ad ogni esecuzione il
programma si comporterà diversamente, dato che i dati che si trovano nella cella non inizializzata
cambiano ogni volta. Per evitare questo alcuni sistemi operativi inizializzano la memoria,
azzerando ogni valore contenuto nello stack allocato da un programma quando questo viene
lanciato.

6. Controlli di flusso

L'esecuzione di un programma di solito non è lineare: servono controlli per poter saltare da un
punto all'altro del codice a seconda dei dati che si stanno elaborando.

Flusso del programma: la sequenza di esecuzione delle istruzioni, non per forza uguale all'ordine
in cui le istruzioni sono scritte nel codice.

Controlli di flusso:

• istruzioni condizionali
• cicli
Nei linguaggi imperativi l'istruzione condizionali primaria è l'if: individua
una condizione (un'espressione booleana) e due blocchi di codice. L'if valuta l'espressione
booleana e a seconda che questa sia vera o falsa esegue un blocco di codice oppure un'altro. A
differenza dell'istruzione 7000 della MVN che può controllare solo una condizione (ACC=0) l'if dei
linguaggi ad alto livello valuta un'espressione booleana arbitrariamente decisa dal
programmatore.

Blocchi di codice: un blocco di codice è una sequenza di istruzioni chiusa tra parentesi graffe o una
singola istruzione. Useremo questa notazione sia in C++ che in pseudocodice.

6.1 Istruzione condizionale if


Sintassi dell'if: if (<exp>) {<blocco1>} else {<blocco2>}

Esempio:

int a,b,c;

. //in questa parte le variabili andranno inizializzate;

if(a>0) b=a; else c=a;

Operatori matematici e logici:

Matematica: C:

= ==

≤ <=

≥ >=

≠ !=

NOT !

AND &&

OR ||
Sia in C che in C++ l'uguale è l'operatore di assegnazione; per chiedersi invece se due valori sono
uguali si usa ==. Se per sbaglio uso il segno = in un'espressione booleana il compilatore non si
accorge dell'errore, compila lo stesso il programma che però non si comporterà come deve: infatti
eseguirà l'assegnazione invece di fare un confronto.

Se dichiaro una variabile booleana b e le assegno un valore vero o falso poi posso usarla come
espressione per il controllo dell'if: l'istruzione if(b) eseguirà il primo blocco di codice se b è vera,
il secondo se è falsa. Non è necessario scrivere if(b==true). Non è un errore, ma non è
necessario, scrivo solo del codice in più. Posso usare l'if anche senza un secondo blocco di codice:
in questo caso se l'espressione vera eseguirà l'unico blocco che abbiamo fornito, se è falsa non fa
niente e prosegue nell'esecuzione del programma.

6.1.1 Istruzioni condizionali concatenate (if-else if)


A volte nell'esecuzione di un programma dobbiamo valutare più di due casi, quindi avremo
bisogno di più istruzioni if concatenate.

Sintassi:

if (<exp1>)

<blocco1>

else if(<exp2>)

<blocco2>

else if.....

else <blocco n>


6.2 L’istruzione switch
L'istruzione switch gestisce lo stesso caso degli if concatenati, ma solo se la condizione da
verificare è molto semplice: valuta il valore di una variabile ed esegue un blocco di codice diverso a
seconda del suo valore. Ha una sintassi più agevole degli if concatenati.

Sintassi:

switch(<exp>) { /*il valore di ritorno dell'espressione dev'essere un


carattere o un intero*/

case <val1>: <blocco1>

case <val2>: <blocco2>

default: <blocco n>

Lo switch, a differenza dell'if, dopo aver valutato l'espressione sceglie un punto di entrata nel
codice a seconda del risultato dell'espressione: alla fine di ogni blocco di codice, se non inseriamo
l'istruzione break per uscire dallo switch, eseguirà anche i blocchi di codice successivi, anche se la
condizione per cui avremmo voluto che venissero eseguiti non si è verificata.
6.3 Cicli
Nella MVN i cicli si possono fare usando istruzioni di salto condizionale e incondizionato (6000 e
7000). Nei linguaggi ad alto livello non si usano salti incondizionati in quanto pericolosi. Possiamo
anche valutare se è il caso di eseguire il ciclo o no, valutando una condizione.

Istruzione while:
L'istruzione while si chiede innanzitutto se bisogna entrare nel ciclo, valutando un'espressione
booleana: se l'espressione è vera entra nel ciclo e lo esegue fintanto che l'espressione rimane
vera.

Sintassi:

while(<exp>) <blocco>

Semantica: valuta l'espressione, se è vera esegue il blocco e torna a valutare l'espressione. È


necessario che il blocco di codice modifichi il valore dell'espressione, altrimenti il ciclo verrà
eseguito all'infinito e il programma andrà in loop.

Do-while

Il do-while esegue sempre il ciclo almeno una volta, la prima, e in seguito si chiede se dobbiamo
proseguire nell'esecuzione o no. È equivalente all'istruzione repeat-until del Pascal, tranne che la
repeat-until esce quando la condizione è vera, la do-while quando è falsa.
Sintassi:

do <blocco> while(<exp>)

Modi per uscire da un ciclo:

inserire nel blocco istruzioni che modificano il valore dell'espressione di controllo (modo standard)

con una o più istruzioni di break, condizionate da un if altrimenti il ciclo uscirà ogni volta in quel
punto. Questo serve quando non dobbiamo eseguire il ciclo un numero intero di volte, ma in certe
condizioni dobbiamo uscire durante l'esecuzione del blocco. In questi casi possiamo anche fare un
ciclo del tipo while(true) , che porta sempre ad un loop infinito a meno che nel blocco di codice
non ci siano istruzioni di break.

Continue

L'istruzione continue serve a saltare la parte di blocco che ancora rimane da eseguire e ritorna
all'inizio del ciclo.

6.3.1 Ciclo for


L'istruzione for ripete un ciclo controllato dal valore di una variabile

Pseudocodice: for <var>=<val1>, <val2>,...,<valn>: <blocco>

Semantica: ripeti <blocco> con <var> che assume in sequenza i valori <val1>, <val2>,....,<valn>

Esempio: for i=1,2,...,n:

a=a+1;

Non è necessario che i valori di i siano in sequenza.

In C:

for(<inizializzazione var>; <confronto var con valore di arresto>;


<incremento variabile>) <blocco>

Esempio: for(i=1; i<=n; i++) a=a+i;


Forma generale:

for(<operazioni di inizializzazione>; <condizioni di continuazione>;


<operazioni a fine ciclo>) <blocco>

Semantica: esegui le operazioni di inizializzazione, finché vale la condizione di continuazione (che


deve sempre essere un'espressione booleana), esegui il blocco, poi esegui le operazioni di fine
ciclo.

Se si vuole usare più di un'istruzione come operazione di inizializzazione o di fine ciclo, le istruzioni
vanno separate da virgola e non da punto e virgola, poiché il punto e virgola segnala la fine delle
operazioni di inizializzazione o di fine ciclo. Comunque è raro che si usino più istruzioni nelle
operazioni dei cicli for.

L'utilizzo del ciclo for è equivalente all'utilizzo di un ciclo while così strutturato:

<operazioni di inizializzazione>

while(<condizione di continuazione>) {

<blocco>

<operazioni di fine ciclo>

}
7. Struttura dei programmi C/C++

Un programma C è un insieme di funzioni (estensioni procedurali). In C le estensioni procedurali


sono chiamate funzioni perché, come le funzioni matematiche, prendono dei valori, li elaborano e
restituiscono dei valori in output.
La funzione main è il programma principale. Tutte le altre funzioni che costituiscono il programma
verranno eseguite solo se chiamate all'interno del main (o da altre funzioni a loro volta chiamate
all'interno del main). La funzione main deve sempre esserci in un programma, a meno che non sia
una libreria, che però per venire eseguita dovrà essere chiamata all'interno del main di un altro
programma.

7.1 Sintassi di una funzione


<tipo> <nome> (<parametri>)

<blocco>

Il nome serve a poter lanciare la funzione facilmente, senza doverne conoscere l'indirizzo in
memoria come avveniva per le estensioni procedurali della MVN. Il blocco è il corpo della
funzione, le istruzioni che questa andrà ad eseguire. I parametri sono i dati che la funzione riceve
in input, il tipo indica il tipo di dato che verrà restituito in output. Se una funzione non restituisce
alcun valore in output sarà una funzione di tipo void. Così come le funzioni non sono obbligate a
restituite valori, possono anche non ricevere parametri: in questo caso tra le parentesi tonde non
verrà indicato niente.

7.2 Main
La funzione main è del tipo int main (). Il valore di ritorno del main serve a segnalare se si sono
verificati errori durante l'esecuzione. Il main può avere anche dei parametri di input int
argc e char* argv. L'argc serve a sapere di quanti parametri ho bisogno, mentre un uso tipico
dell'argv è di fornire il nome del file su cui il programma deve operare.

7.3 Programma che fa la somma di due numeri e stampa in


output il risultato

int main()

float a,b;

cout<<"Dammi il primo addendo\n";


cin>>a;

cout<<"Dammi il secondo addendo\n";

cin>>b;

cout<<"La somma è "<<a+b<<endl;

return 0;

Variante:

cout<<"Dammi il due addendi\n";

cin>>a>>b;

Se questo programma venisse fornito al compilatore così come l'abbiamo scritto, non verrebbe
compilato perché la gestione dell'I/O in C++ è demandata a funzioni di libreria. In C++ la libreria
che contiene le istruzioni di I/O è chiamata iostream. Se non la includiamo nel nostro programma
il compilatore non saprà cosa sono cin e cout e darà errore interrompendo la compilazione.

7.4 Utilizzo di librerie


In C/C++ si fa con la direttiva per il compilatore #include. Sintassi: #include <iostream> .
L'effetto della direttiva include è di prendere il codice della libreria specificata e copiarlo tale e
quale nel nostro programma. Le direttive vengono gestite da una parte del compilatore chiamata
preprocessore, che si occupa solo di interpretare le direttive.
Perché il nostro programma funzioni, dopo l'inclusione delle librerie dobbiamo aggiungere
l'istruzione using namespace std; altrimenti dovremmo scrivere std:: davanti ad ogni
istruzione di libreria.
8. Esercizi di programmazione sui cicli

Programma che fa la somma di due numeri finché non gli viene dsto uno zero in input:

#include <iostream>

using namespace std;

int main()

int a,b;

while(true)

cout<<"Dammi un numero\n";

cin>>a;

if(a==0) break;

cout<<"Dammi un altro numero\n";

cin>>b;

if(b==0) break;

cout<<"Il risultato è "<<a+b<<endl;

cout<<"Ciao!\n";

return 0;

}
Lo stesso programma, realizzato con un ciclo for che gli permette di essere eseguito al massimo 5
volte:

#include <iostream>

using namespace std;

int main()

int a,b;

for(int i=0; i<5; i++)

cout<<"Dammi un numero\n";

cin>>a;

if(a==0) break;

cout<<"Dammi un altro numero\n";

cin>>b;

if(b==0) break;

cout<<"Il risultato è "<<a+b<<endl;

cout<<"Ciao!\n";

return 0;

In questo esempio la variabile i è visibile solo all'interno del ciclo for. Se provo ad usare la variabile
i fuori dal ciclo for, il compilatore mi darà errore. Per poter utilizzare un contatore anche al di fuori
del ciclo, devo dichiararlo prima del ciclo e non tra le parentesi dell'istruzione for. Dovrei fare in
questo modo:
.

int i;

for(i=0; i<5; i++)

Posso realizzate anche un ciclo for infinito, equivalente al while(true), con la sintassi: for(;;).
Se lo uso nel programma di prima, si comporterà come la prima versione in cui termina solo
quando riceve zero in input e non dopo 5 esecuzioni.

Calcolatrice che somma due numeri se il primo è pari, li moltiplica se è dispari:

Per controllare se un numero è pari o dispari userò l'operatore %, che da il resto di una divisione:
se il resto della divisione per 2 è 0, il numero è pari.

#include <iostream>

using namespace std;

int main()

int a,b;

while(true)

cout<<"Dammi un numero\n";

cin>>a;

if(a==0) break;

cout<<"Dammi un altro numero\n";

cin>>b;
if(b==0) break;

if(a%2==0) cout<<"Il risultato della somma è "<<a+b<<endl;

else cout<<"Il risultato della moltiplicazione è "<<a*b<<endl;

cout<<"Ciao!\n";

return 0;

Proviamo a rendere il codice più compatto:

cout<<"Il risultato è: ";

if(a%2==0) cout<<a+b<<endl;

else cout<<a*b<<endl;

8.1 Operatore condizionale


.

cout<<"Il risultato è: "<< (a%2==0) ? a+b : a*b<<endl;

.
Sintassi:

<bool_exp> ? <espressione aritmetica da fare se bool_exp vera> :


<espressione aritmetica da fare se bool_exp è falsa>

9. Funzioni

Le funzioni sono estensioni procedurali di un programma principale. Si collegano al resto


(programma principale o altre funzioni) attraverso un'interfaccia di programmazione costituita da:

• Nome della funzione


• Parametri di I/O della funzione
• Tipo del risultato

In C/C++:

<tipo> <nome> (<parametri>) <blocco>

| | |

prototipo corpo della funzione

Nel momento in cui scrivo questo codice sto definendo una funzione.

9.1 Definizione e chiamata di funzioni


Quando un programma vuole usare una funzione dovrà invocarla attraverso il suo nome e passarle
i parametri richiesti.

Esempio:

Definizione della funzione:

float f(float x, float y) {return x*y;}

Chiamata alla funzione:

float a,b,c;

cin>>a>>b;

c=f(a,b);
Queste linee di codice inizializzano tre variabili a,b,c di tipo float, assegnano un valore ad a e b e
assegnano a c il valore restituito dalla chiamata alla funzione f con parametri a, b.

9.2 Esempio di funzione per l'elevazione a potenza


Definizione:

float f(float x, int n)

float acc=1.0;

for(int i=1; i<=n; i++)

acc=acc*x;

Chiamata:

float a,b;

int q;

cin>>a>>q;

b=f(a,q);

I parametri che sono specificati nella dichiarazione si chiamano parametri formali: vengono
specificati in fase di dichiarazione come lista di dichiarazioni di variabili. Questi parametri
all’interno della funzione vengono usati come se fossero variabili.

Funzioni: funzioni che ritornano un valore


Procedure: funzioni che non ritornano nessun valore (void). Termine preso dal Pascal. void
<nome> (<parametri>)

I passaggi di informazioni tra programma e funzione avvengono attraverso i parametri e il valore di


ritorno.

9.3 Istruzioni di uscita


• exit(int) termina il programma (anche se chiamata dentro una funzione) e restituisce il
parametro intero come risultato del main. E’ una funzione di libreria del C, in C++ non è
necessario usarla perché si comporta allo stesso modo di return 0.
• return termina la funzione void
• return <val> termina la funzione e restituisce il valore <val>, che deve essere dello
stesso tipo della funzione
• break esce da un blocco di codice (come ad esempio un ciclo) senza però terminare la
funzione
• continue salta la parte restante di un ciclo e lo riprende dall'inizio

Quando invochiamo una funzione questa viene trattata come un'espressione, che produce un
valore del tipo specificato, che potrà poi essere assegnato a variabili, usato in espressioni o come
parametro di altre funzioni.

9.4 Passaggio di parametri

Parametri formali: quelli che dichiaro nella definizione della funzione e all'interno di essa si
comportano come se fossero delle variabili.
Parametri attuali: quelli che si usano invocando la funzione. Sono valori variabili o risultati di
espressioni del programma (o funzione) chiamante. Esistono solo dal momento in cui la funzione
viene invocata.

Esempio: in fase di definizione della funzione float f(float x, int n) x ed n sono i


parametri formali. Quando f viene invocata con f(r,4) la variabile r e la costante 4 sono i
parametri attuali.

Modalità di passaggio dei parametri:

• Modo IN: Parametro in input alla funzione viene usato solo come valore destro: alla
funzione interesserà solo il valore iniziale del parametro e non andrà a modificare il valore
del corrispondente parametro attuale nel chiamante.
• Modo OUT: parametro in output che la funzione andrà a modificare ma alla quale non
interesserà il suo valore iniziale. Lo usa solo come valore sinistro, come scatola in cui dovrà
mettere qualcosa ma in cui non c'è niente all'inizio. Non è implementato nel C/C++.
• Modo IN-OUT: parametro in I/O usato sia come valore destro che come valore sinistro
(viene usato il valore iniziale e viene modificato per poi restituirlo al chiamante.

Un parametro IN può essere modificato dalla funzione durante la sua esecuzione, ma questo non
si rifletterà sul chiamante che non vedrà mai gli effetti della modifica. Il parametro infatti è solo in
input e non verrà restituito alla funzione.
Se il parametro è IN il corrispondente parametro attuale può essere una variabile o un
espressione, se è OUT o IN-OUT il parametro attuale deve essere una variabile: non si può usare
un'espressione come valore sinistro!
Esempi:

int f(IN int x) {x=x*x; return x;}

In una funzione così strutturata, il parametro attuale nel chiamante non sarà modificato, perché
viene modificato solo all'interno della funzione e poi restituito come valore di ritorno della
funzione, senza nessun effetto sul parametro attuale.

Alla chiamata della funzione, il parametro attuale n vale 7 e viene copiato nella variabile x interna
alla funzione.
Alla fine dell'esecuzione della funzione, il parametro x interno ad essa è stato modificato e
restituito come valore di ritorno, ma la variabile n all'interno del main, usata come parametro
attuale, non è stata in alcun modo modificata.

9.4.1 Modi di passaggio in C++

• Passaggio per valore (IN): <tipo> <nome> (<tipo> <nome>)


• Passaggio per riferimento (IN-OUT): <tipo> <nome> (<tipo>& nome)
• Passaggio per riferimento costante (IN): <tipo> <nome> (const <tipo>& nome)

Nel passaggio per valore all'interno della funzione viene allocata una variabile per contenere il
parametro, e al momento dell'invocazione il parametro attuale viene copiato all'interno di questa
variabile. Quindi ogni modifica del parametro formale (la variabile interna) all'interno della
funzione non ha effetti sul parametro attuale.

Il passaggio per riferimento all'atto della chiamata crea un riferimento simbolico tra il parametro
formale e il parametro attuale in modo che qualunque operazione eseguita dalla funzione sul
parametro formale abbia effetto immediato sul parametro attuale. Nella macchina di von
Neumann tutti i passaggi avvengono per riferimento.

Il passaggio per riferimento costante è identico a quello per riferimento ma il compilatore


controlla che il parametro formale non venga mai modificato dalla funzione. Viene usato per
risparmiare tempo e memoria nel caso di passaggio di valori molto grandi che non necessitano di
modifiche da parte della funzione: infatti se venisse passato per valore verrebbe copiato nella
memoria della funzione, con conseguente spreco di tempi e memoria. Così posso invece
risparmiare facendo usare alla funzione direttamente la porzione di memoria in cui si trova il
valore da leggere.

10. Memoria

• Istruzioni: le istruzioni che compongono il programma


• Variabili globali: dichiarate fuori da qualunque funzione, incluso il main. Sono visibili in
ogni punto del programma
• Heap: memoria allocata dinamicamente, secondo necessità
• Stack: area di memoria in cui vengono allocate le variabili dichiarate nelle funzioni e
deallocate quando la funzione che le aveva generate termina.
10.1 Casting

Casting: cambio “al volo” del tipo di un’espressione. Il casting esplicito si fa scrivendo (<tipo>)
<exp>: valuta l’espressione e interpreta il risultato come se fosse del tipo specificato.

Esempio:

int n, float f, char c;

n=(int)f/c;

Questo pezzo di codice prende il valore di f, lo divide per il codice ASCII del carattere contenuto in
c e tratta il risultato come se fosse un intero. Con questa operazione vengono quindi troncati gli
eventuali decimali.

10.2 Gestione della memoria nelle chiamate a funzione


Percorso di chiamata:

1. Preparazione della memoria per parametri e risultato


2. Preparazione della memoria privata della funzione chiamata
3. Passaggio dei parametri
4. Esecuzione della funzione
5. Terminazione e restituzione del risultato sullo stack
6. Restituzione della memoria privata della funzione allo stack
7. Recupero del valore da parte del chiamante e liberazione della memoria occupata dal
valore

10.3 Operatori di dereferenziazione


Se x è una variabile, &x è il suo indirizzo e può essere usato solo come valore destro. E’ di tipo
indirizzo.

Se y è un indirizzo, *y è la variabile che sta a quell’indirizzo.


11. Array

Un array è un contenitore di variabili dello stesso tipo, ordinate da 0 a n-1 dove n è la dimensione
dell’array. Ciascuna variabile si indirizza attraverso il nome dell’array e l’indice intero che indica la
posizione della variabile all’interno dell’array.

In questo array, per accedere alla posizione 2 si usa la sintassi A[2].

Dichiarazione: un array si dichiara con la sintassi <tipo> <nome> [<dimensione>], ad


esempio int a[10]. In questo modo è come se dichiarassi 10 variabili a[0],a[1],...,a[9] con una
sola istruzione.

La dimensione degli array è statica, viene decisa a compile-time dal compilatore e non può essere
modificata durante l'esecuzione. Dichiarando a[10] in realtà sto dichiarando 10 variabili a[0],
a[1]......a[10]. Nell'allocazione dinamica invece la dimensione degli array viene decisa a run-time
ogni volta che il programma viene eseguito. La dimensione degli array deve essere una costante.

In C++ (ma non in C) si possono dichiarare delle costanti di programma: identificatori a cui è
associato un valore costante.

Sintassi: const <tipo> <nome>=<valore>


Ogni volta che nel programma viene usato il nome della costante il compilatore lo sostituisce con il
suo valore.

Esempio:

const int MAXN=100;

float v[MAXN];

Scomodità nell'utilizzo degli array:

• non posso chiedere la dimensione di un array durante l'esecuzione di un programma:


questo può causare errori perché posso sforare la loro dimensione
• non posso sapere in quante posizioni ho messo dei dati e quante posizioni sono rimaste
libere: per fare questo devo usare una variabile int separata che andrà incrementata e
decrementata in base alle operazioni sull'array. Queste operazioni sono tutte gestite dal
programmatore.
• Hanno dimensione statica e questo comporta spreco di memoria se non lo uso
completamente: se dichiaro un array di dimensione massima n ma poi non lo riempio
tutto, le celle vuote occuperanno comunque memoria.
• Non possono essere restituiti come valori di ritorno di funzioni

11.1 Array come parametri di funzioni


In pseudocodice, tratteremo gli array come normali variabili. In C questo non è possibile perché un
array non è una variabile. Nel C classico gli array sono l'unico caso in cui si può fare un passaggio
per riferimento. Quando passiamo un array come parametro, il passaggio avviene sempre per
riferimento, anche se non abbiamo specificato l’operatore &. Bisogna quindi stare attenti, perché
ogni modifica svolta dalla funzione si rifletterà automaticamente sul parametro attuale nel
chiamante, e non c’è modo per evitarlo.

Esempio:

void f(int a[]) {...} //se scrivo a[] la funzione non sa di che dimensione sarà l'array

Essendo un passaggio per riferimento, se modifichiamo il valore di una o più celle dell'array
all'interno di una funzione, saranno cambiati i valori anche nel parametro attuale all'interno del
programma chiamante.
L'identificatore di un array è una costante di programma che ha come valore l'indirizzo della prima
casella dell'array. Se dichiaro int a[MAXN] a vale l'indirizzo di a[0]: coincide con &(a[0]).
Quindi quando passo un parametro array in realtà sto passando l'indirizzo della sua prima casella.

Usando un array come parametro posso anche dire al programma il numero massimo di celle da
utilizzare, passandoglielo come variabile int. Devo stare attento però che questa variabile non
superi mai la dimensione dell'array, altrimenti faremmo danni imprevedibili.

Esercizio:
Programma che chiede di immettere n valori da tastiera (chiedendo prima n). Il valore massimo di
valori che posso ricevere è 100. Dopo aver letto i valori calcola la media.
Dobbiamo intanto dichiarare una const int MAXN=100. Poi scriviamo una funzione di lettura di
tipo int, che prende come parametri un array a di dimensione MAXN e un intero n, passato per
riferimento, in cui diremo quanti valori ho ricevuto. È passato in modalità OUT poiché è noto in
uscita ma non in ingresso. Servirà poi una funzione in cui calcolo la media che ha bisogno di avere
come parametri l'array a e un intero n che ne dice la dimensione.

Posizione esercizio: es_classe/media.cpp

.
11.2 Operazioni sugli array
Se vogliamo sapere fino a dove un array è pieno dobbiamo tenere una variabile intera che ha
sempre il valore dell'indice della prima casella vuota.

11.2.1 Inserimento in coda


Abbiamo un array a di dimensione MAXN occupato per n posizioni e vogliamo inserire un nuovo
elemento x in coda (dopo tutti gli elementi già presenti). È possibile farlo solo se c'è ancora spazio
nell'array, ossia se n<MAXN. Possiamo implementare l'inserimento in coda come una funzione,
che ha come parametri un array passato in modalità IN-OUT e un intero passato in modalità IN-
OUT che indica la dimensione. Come tipo di ritorno useremo un int per la segnalazione degli errori
(ad esempio se l'array è già pieno). Per far sapere alla funzione la dimensione massima dell'array
posso specificarla nel prototipo scrivendo a[MAXN] invece di a[] oppure passarla come un'altro
parametro IN di tipo int. Nel primo caso la funzione potrà lavorare solo ed esclusivamente su array
della dimensione specificata. Se invece passo la dimensione come parametro potrò usare array di
lunghezza diversa ad ogni chiamata

Prototipi (in pseudocodice):

int in_coda(IN-OUT a: array[MAXN], IN-OUT n: int, IN x: <tipo


dell'array>)

oppure

int in_coda(IN-OUT a: array[], IN-OUT n: int, IN m:int, IN x:<tipo


dell'array>)

Codice:

int in_coda (int a[MAXN], int& n, int x)

if(n==MAXN) return 1;

a[n]=x;

n++; //incrementa n poiché abbiamo aggiunto un elemento

return 0;

}
11.2.2 Eliminazione di un elemento in coda

int out_coda (int a[MAXN], int& n)

if(n==0) return 1;

n--;

return 0;

In realtà questa funzione non cancella niente, poiché non esiste un'operazione di cancellazione
sugli array, si limita a spostare indietro l'indice n di una posizione. Il dato contenuto nella posizione
"cancellata" è ancora lì, ma non viene più considerato parte dell'array dal nostro indice e sarà
sovrascritto alla prossima operazione di inserimento in coda.

La dimensione massima MAXN dell'array non può essere cambiata, dato che l'array è una struttura
statica.

Scambio tra due elementi di un array: ugualmente allo scambio tra due variabili si svolge
servendosi di una variabile ausiliaria.

11.2.3 Inserimento in ordine


Abbiamo un array a contenente n elementi ordinati dal minimo al massimo e vogliamo inserire un
nuovo elemento x al posto giusto.

Passaggi:

1. cerco il punto dove inserire x


2. prendo tutto quello che c'è dopo e lo sposto in avanti di una casella per fare spazio ad x
3. inserisco x e incremento di 1 la variabile che tiene la dimensione
Sugli array però non è possibile fare operazioni in blocco, quindi il passaggio 2 richiederà più
passaggi per essere svolto.

1. scandisco dal fondo finché trovo elementi maggiori di x, spostandoli di una posizione in
avanti ogni volta
2. inserisco x e incremento n

Implementazione: (seguendo il primo esempio)

if(n==MAXN) return 1;
int i=0;

while(i<n && x>a[i]) i++;

Pseudocodice: (da sviluppare dopo la parte di codice di prima)

<sposto tutto da i in poi>

a[i]<-x; n++;

Per spostare tutto in avanti posso partire dall'ultimo elemento, spostarlo di una posizione in
avanti, poi prendere l'elemento precedente, spostarlo di una posizione in avanti e così via, finché
non arriviamo ad i. Per fare questo avremo bisogno di un altro contatore j.

Implementazione (continua):

for(int i=j; j>i; j--) a[j]=a[j-1];

Un'altra implementazione:

int i;

for(i=n; i>0 && a[i-1]>x; i--) a[i]=a[i-1];

a[i]=x; n++;
12. Algoritmi di ordinamento

Sort: mettere in ordine gli elementi di una sequenza (array). Un possibile modo di farlo è creare un
array vuoto, delle stesse dimensioni dell'array che vogliamo ordinare, scandire il primo array e
usare la funzione di inserimento ordinato per inserirli ordinatamente nel secondo array. Un
esempio pratico di questo è in es_classe/sort.cpp .

sort.cpp, linea 27 (funzione void sortV):


Ho copiato l'array ordinato sul vecchio array con un ciclo for perché non è possibile assegnare
direttamente un array a ad un altro array b scrivendo semplicemente a=b, questo perché gli array
non sono variabili.

12.1 Insertion sort


Invece di usare due array come nel caso precedente, usiamo solo un array. Una parte di elementi
all'inizio dell'array è già ordinata, quindi prendo il primo elemento successivo e lo inserisco nella
sua posizione nella parte ordinata. A questo punto ripeto il procedimento con l'elemento
successivo e così via finché non ordino tutto l'array. Alla prima esecuzione dell'algoritmo, quando
ancora l'array è completamente disordinato, consideriamo come parte già ordinata solo la prima
posizione.
L'insertion sort ha lo svantaggio di fare un sacco di assegnazioni, perché deve far scorrere gli
elementi sull'array.

12.2 Selection sort


L'algoritmo di selection sort cerca l'elemento più piccolo e lo mette in prima posizione, poi il
secondo e così via.
Per fare questo deve:

1. scorrere tutto l'array cercando la posizione del più piccolo


2. spostarlo in prima posizione, avendo cura di non sovrascrivere il valore che già ci si trovava
3. ripetere l'operazione con il secondo valore più piccolo e così via

12.3 Bubble sort


Si confronta il primo numero con il successivo, e si ordinano. Se il primo era maggiore del secondo
lo spostiamo in secondo posizione, poi lo confrontiamo con il terzo e così via finché non troviamo
un numero più grande per cui dobbiamo fermarci. Usa una variabile booleana per accorgersi
quando ho finito il lavoro. Quando ho scandito tutto l'array senza aver trovato elementi da
spostare, so di aver finito.

//guarda lo pseudocodice da wiki e sviluppalo in c++. su youtube ci sono pure le animazioni.


12.4 Ricerca in un array ordinato
Si chiama ricerca dicotomica (o per bisezione).
Dato un array a di n elementi e un elemento x da cercare, confronto x con a[n/2] (elemento
centrale di a). Se x>a[n/2] restringo la ricerca alla metà superiore dell'array (intervallo da
a[(n/2)+1] ad a[n-1]), se x<a[n/2] restringo la ricerca alla metà inferiore dell'array (intervallo da
a[0] ad a[(n/2)-1]). Se x=a[n/2] ho trovato l'elemento.

Implementazione:
a array
n elementi
x valore da carcare
sx, dx indici che indicano i limiti sinistro e destro della porzione di a da analizzare

All'inizio sx=0, dx=n-1.


A ogni passo calcolo il punto medio tra i due: m=(sx+dx)/2
Confronto x con a[m]

• se x>m sx=m+1
• se x<m dx=m-1
• se x=m trovato

Se sx è uguale a dx vuol dire che ci è rimasto un solo elemento da analizzare. Se invece sx>dx la
porzione da analizzare è vuota, e quindi l'elemento non era presente nell'array.

Pseudocodice:

sx<-0; dx<-n-1; bool trovato=false;

while(sx<=dx)

m<-(sx+dx)/2;

if(x==a[m]) {trovato=true; break;}

if(x>a[m]) sx<-m+1;

else dx<-m-1

return trovato;
13. Complessità computazionale

Assumiamo di avere un programma P che implementa l'algoritmo di cui vogliamo valutare la


complessità. Abbiamo un input per il programma P, genericamente costituito da una sequenza di
elementi. Analizzeremo il codice per fare una stima del tempo impiegato da P a terminare la sua
esecuzione.
Analisi computazionale: dare una stima del costo (in termini di tempo) di eseguire P su un input
e1,....,en in dipendenza solo di n.
La risposta sarà una funzione T(n), ossia il tempo impiegato dall'algoritmo con n elementi.
Non possiamo misurare direttamente il tempo di esecuzione, poiché calcolatori diversi
impiegheranno tempi diversi ad eseguire lo stesso algoritmo con lo stesso input, perché dipende
dalle prestazioni del calcolatore. Valuteremo il tempo quindi in base al numero di istruzioni che
vengono eseguite. Se riuscissi a tradurre il mio algoritmo in codice macchina potrei contare
direttamente il numero di istruzioni, ma sarebbe una soluzione molto complicata.
Analizzando a livello di linguaggi di alto livello, si assume che le istruzioni base abbiano tutte lo
stesso costo: fare un'assegnazione costa 1, fare un test costa 1, un'operazione di I/O costa 1 così
via.
Assegnamo quindi un costo unitario a tutte le istruzioni semplici del linguaggio (assegnazione, test,
I/O, chiamata di funzione, salto, passaggio di parametro.....). Facendo questo riusciamo a contare i
passi che vengono eseguiti all'interno del nostro algoritmo. Non basta contare le singole istruzioni
però: ad esempio, se c'è un ciclo, le stesse istruzioni verranno eseguite molte volte, quindi
dobbiamo tenere conto anche di quante volte le stesse istruzioni verranno eseguite.

Proviamo ora a calcolare la complessità dell'algoritmo di ricerca sequenziale su un array a di n


elementi.

Codice dell'algoritmo:

for(int i=0; i<n; i++)

if(x==a[i]) return true

return false;

Quanto ci metto ad eseguire queste linee di codice? Dipende dalla posizione di x: se lo trovo subito
lo eseguirò solo una volta, viceversa se x è in ultima posizione il ciclo verrà eseguito n volte.
Quindi, in questi casi in cui il tempo di esecuzione dipende dall'input, valuteremo sempre il tempi
impiegato nel caso peggiore, che in questo caso è x non trovato (dovrò infatti valutare ogni
posizione dell'array prima di poter dire che x non c'è).
Convertiamo il ciclo for in ciclo while:

int i=0; //costo 1


while(i<n) //costo n+1 (il test viene eseguito n volte più la volta in cui fallisce)
{

if(x==a[i]) return true //costo n

i++; //costo n
}
return false; //costo 1

Costo totale: 1+(n+1)+n+n+1= 3n+3

T(n)=3(n+1)

13.1 Analisi di complessità dell’algoritmo di insertion sort


array a di n elementi da ordinare

for i=1,2,....,n-1

temp<-a[i];

j<-i;

while(j>=0 && a[j]>temp)

a[j+1]<-a[j];

j+;

a[j+1]<-temp;

Caso peggiore: array con elementi in ordine decrescente.


Eseguire una volta le istruzioni di una casella ha costo 1. La funzione T(n)si calcola contando quanti box
vengono attraversati in totale. xi= numero di volte che eseguo il test al ciclo i-esimo, sempre uguale ad i+1.
Il costo del while, al passo i-esimo, è 3i+1. Al passo (n-1)-esimo il costo sarà 3n-2, ma il costo va calcolato
per tutte le volte che il ciclo viene eseguito, non solo l'ultima. Il costo del while sarà quindi 3(1+2+3+....+n-
1), cioè 3 volte la sommatoria da 1 ad n-1 di tutti gli i. La sommatoria che va da 1 ad n-1 degli i vale n(n-
1)/2.
Quindi il costo totale del while è 3[n(n-1)/2]+n-1.
Il costo totale della funzione sarà quindi T(n)=1+n+5(n-1)+(3/2)n(n-1)

Ordini di complessità asintotica


Non ci importa contare esattamente quanti passi fa l'algoritmo, ma solo capire quanto velocemente cresce
la funzione costo al variare di n.

g è una funzione, uguale a tutte le funzioni f che soddisfano le condizioni specificate. Da un certo punto n0
in poi f sta sotto a g a meno di una costante moltiplicativa reale positiva c. O(g) sono tutte le funzioni che
vanno al più veloci come g.
Ω (g) indica invece le funzioni veloci almeno quanto g. Θ (g) sono invece le funzioni esattamente veloci
quanto g. Se riusciamo a stimare Ω (g) sappiamo che l'algoritmo ci mette almeno il tempo di un algoritmo
con ordine di complessità g.

Per ogni a, il logaritmo in base a di n appartiene a O(n), ma n non appartiene ad O(log a n). Questo significa
che n è più veloce di logn.
13.2 Proprietà degli ordini di complessità
Riflessività: f appartiene all'ordine di grandezza di se stessa (f ∈ O(f), Ω (f), Θ (f)
Transitività: se f ∈ O(g) e g ∈ O(h), allora f ∈ O(h) (vale anche con Ω e Θ)
f appartiene a O(g) <=> g ∈ Ω (f)
se f ≤ g (da un certo punto in poi) => O(f) ⊆ O(g) e Ω (g) ⊆ Ω (f)

f appartiene a Θ (g) <=> g ∈ Θ (f) <=> Θ (f) = Θ (g)

Proprietà del massimo e della somma


Notazione: f+g è la funzione che manda n in f(n)+g(n)
Proprietà della somma: se f j ∈ O(g j ) per j=1,2 => f 1 +f 2 ∈ O(g 1 +g 2 )
se f j ∈ Ω (g j ) per j=1,2 => f 1 +f 2 ∈ Ω (g 1 +g 2 )
Proprietà del massimo: f+g ∈ Θ (max(f,g))

//dimostrazioni sulle dispense

13.3 Tabella degli ordini di complessità in ordine crescente


Θ (1) è la complessità minima possibile, un algoritmo che
fa un solo passo.
Θ (logn) indipendentemente dalla base del logaritmo
(Θ (log a n)= Θ (log b n) ∀ a,b)
klogn=lognk ∈ Θ (logn) ∀ k.
Θ (logkn) la complessità cresce al crescere di k.
Θ (n) polinomio
Θ (nk) la complessità cresce al crescere di k
Θ (an) con a>1, esponenziale. A differenza dei logaritmi, la
complessità cresce al crescere della base (a). Va più
veloce di qualsiasi polinomio di qualsiasi grado.

Ho un problema P e tre algoritmi A1, A2, A3 che lo risolvono. A1 ha complessità Θ (n), A2 ha complessità Θ
(n5), A3 ha complessità Θ (2n).

input n=10 Input n=60


A1 ∈ Θ (n) 10-5 s 6*10-5 s
A2 ∈ Θ (n5) 0.1 s 13 minuti
A3 ∈ Θ (2n) 10-3 s 366 secoli

Come possiamo vedere, un algoritmo esponenziale regge per input piccoli, ma appena aumentano
non ce la fa più.
13.4 Stima della complessità dell’algoritmo di insertion sort
for(int i=1; i<n; i++)

j=i-1;

temp=a[i];

while(j>=0 && a[i]>temp)

a[j+1]=a[j];

j--;

a[j+1]=temp;

L'inizializzazione del contatore del for viene eseguita solo una volta, il suo contributo è Θ (1).
Il test del for viene eseguito n volte, contributo Θ (n).
Tutto il resto del ciclo for, escluso il while, viene eseguito n-1 volte, contributo Θ (n).
Il controllo del while viene eseguito xi volte, il corpo del while viene eseguito xi-1 volte. Si fa la sommatoria
degli xi per i che va da 0 ad n-1: n(n-1)/2=(n2)/2 - n/2: appartiene all'ordine di grandezza
Teta(n^2). Contributo Θ (n2).

T(a) appartiene a Θ (1+n+n2)= Θ (n2)

13.4.1 Regola dell’operazione dominante


Se troviamo l'istruzione che viene eseguita il maggior numero di volte nel contesto dell'algoritmo allora
basta contare quante volte viene eseguita quell'istruzione, sia l'istruzione f(n), allora la complessità è Θ (f).
Nel precedente caso dell'insertion sort, il while è la parte che viene eseguita più volta infatti la sua
complessità è quella che determina la complessità dell'algoritmo.
13.5 Stima della complessità dell’algoritmo di selection sort
for(int i=0; i<n-1; i++)

posmin=i;

for(j=i+1; j<n; j++)

if(a[j]<a[posmin]) posmin=j;

aux=a[j];

a[j]=a[posmin];

a[posmin]=aux;

L'inizializzazione del contatore del for viene eseguita solo una volta, il suo contributo è Θ (1).
Il test del for viene eseguito n volte, contributo Θ (n).
Tutto il resto del ciclo for principale, escluso il for interno, viene eseguito n-1 volte, contributo Θ (n).
Il for interno viene eseguito n-i-1 volte per ogni i fissato. Per trovare il suo contributo alla complessità
dell'algoritmo dovrò fare la sommatoria degli n-i-1 per i che va da 0 ad n-1. Scompongo la sommatoria in
tre: sommatoria degli n, sommatoria degli i, sommatoria degli 1. La sommatoria degli n da risultato n2, la
sommatoria degli 1 da risultato n, la sommatoria degli i da risultato n(n-1)/2. Contributo Θ (n2 - n)
L'if interno al for viene eseguito una volta ad ogni ciclo, contributo Θ (1).

T(a)= Θ (n2)

Quando abbiamo una sommatoria con un argomento non standard, possiamo fare un cambio di indice. Mi
riconduco alla sommatoria standard chiamando k l'argomento della sommatoria.
13.6 Stima della complessità dell’algoritmo di ricerca binaria

Ricerca binaria: cerco x in un array ordinato a di n elementi

s=0; d=n+1; complessità Θ (1)

while(s<=d)

m=(s+d)/2;

if(x==a[m]) return true;

if(x>a[m]) s=m+1;

else d=m+1;

} complessità del while: Θ (1) per ogni volta che si esegue il ciclo

return false;

Caso peggiore: x non è presente nell'array. Ad un ciclo generico i, rimangono ancora da analizzare n/(2^i)
elementi. A quale ciclo ci rimane solo un elemento? Supponiamo che al ciclo k-esimo ci rimanga solo un
elemento, avremo quindi n/2k=1, quindi n=2k, quindi k=log 2 n
Essendo quindi il costo di ogni iterazione Θ (1), ed essendo reiterato il ciclo log 2 n volte, la complessità di
questa funzione appartiene all'ordine di grandezza Θ (logn).

T(A) appartiene a Θ (logn)

N.B.: Il passaggio di parametri ha costo costante nel caso di passaggio per riferimento, dato che viene
passato solo l'indirizzo. Nel passaggio per valore il costo dipende dalla dimensione del parametro.
13.7 Stime di complessità di altri algoritmi
void p(double v[], int n) costo Θ (1)

for(int i=1; i<n; i++) costo Θ (n) in totale

double x=v[i];

int j=i-1;

while(j>=0 && v[j]=x) costo Θ (1) per ogni esecuzione del while

v[j+1]=v[j];

j--;

v[j+1]=x;

Quante volte entriamo nel while? Per i fissato, nel caso peggiore, quanti giri si fanno?

• i va da 1 ad n-1
• al ciclo i si fanno i test del while

Quindi in totale il test del while si ripete per la sommatoria degli i che vanno da 1 ad n-1, cioè per (n-1)(n-
2)/2= Θ (n2)

void r(char a[], int n)

int m=n/2;

for(int i=0; i<n; i++)

{
char c;

c=a[i];

a[i]=a[n-i-1];

a[n-i-1]=c;

Per calcolare la complessità dobbiamo capire quante volte viene eseguito il for, il cui costo unitario è Θ (1).
Il for viene eseguito (n/2)-1 volte, quindi l'ordine di complessità della funzione è Θ (n).

bool f(char s[], int ns, char s1[], int ns1)

if(ns1<ns) return false;

for(int i=0; i<ns-ns1+1; i++)

int j;

for(j=0; j<ns1; j++)

if(s[i+j]!=s1[j]) break;

if(j==ns1) return true;

return false;

Operazione dominante: test del for interno (j<ns1)


Per i fissato, nel caso pessimo il test del for interno viene ripetuto ns1 volte. Il ciclo esterno si ripete ns-
ns1+1 volte, quindi in totale la complessità sarà Teta((ns-ns1)ns1)=Teta(ns*ns1-ns12). Sembra una
complessità quadratica, ma se ns=ns1 viene Θ (ns), una complessità lineare.

int select(int v[], int n, int x) //x<=n

{
int minIndex; int minValue; int buf;

for(int i=0; i<x; i++)

minIndex=i;

minValue=v[i];

for(int j=i+1; j<n; j++)

if(v[j]<minValue) minIndex=j; minValue=v[j];

buf=v[i]; v[i]=v[minIndex]; v[minIndex]=buf;

return v[x-1];

Operazione dominante: il test del for interno, eseguito per la sommatoria per i che va da 0 a x degli (n-i),
che può essere scomposta nella sommatoria degli n meno la sommatoria degli i, quindi alla fine nx+n-(x2)/2
+ x/2. x2/2, x/2 ed n sono ininfluenti, quindi la complessità della funzione è Θ (nx).
Questo algoritmo trova l'x-esimo valore più piccolo all'interno dell'array.
14. Stringhe (del C)

Le stringhe del C sono array di char. A differenza degli array, in cui se vogliamo saperne la
dimensione dobbiamo avere una variabile int a parte che ne mantiene il valore, la fine di una
stringa è determinata dal carattere ASCII '\0' , carattere nullo. Per questo, una stringa di 10
caratteri ne potrà contenere al massimo 9 perché uno deve essere riservato per il carattere nullo.
Dichiarazione di una stringa: char s[10];
Una stringa può essere inizializzata all'atto della dichiarazione con la sintassi char s[6]={'p',
'i','p','p','o','\0'} . Con questa sintassi si può inizializzare qualunque array di
qualunque tipo. Per le stringhe esiste una sintassi molto più comoda: scrivo le lettere tutte
attaccate racchiuse tra virgolette. char s[6]="pippo". Con questa sintassi non è necessario
includere manualmente il carattere \0. È possibile avere spazi nelle stringhe. Possono essere lette
con cin per un massimo di 30 caratteri. Possono essere scritte in output con cout, cosa che
generalmente non si può fare con gli array (non posso fare cout per stampare un array di numeri,
devo usare un ciclo for).
Funzione che calcola la lunghezza di una stringa: es_classe/slen.cpp

Funzioni di libreria per operare sulle stringhe:

• strcpy(char s1[], char s2[]) : copia s2 in s1. Non si può fare un'assegnazione
diretta sulle stringhe in quanto array.
• int strcmp(char s1[], char s2[]): confronto tra due stringhe in ordine
alfabetico. Restituisce un valore minore di 0 se S1<S2, =0 se sono uguali e maggiore di 0 se
S1>S2.
• int strlen(char s[]): restituisce la lunghezza della stringa.

15. Array bidimensionali (tabelle)

Sono strutturati come tabelle, con r righe e c colonne per un totale di r*c caselle dello stesso tipo,
ciascuna indicizzata da due indici, uno di riga e uno di colonna.
Dichiarazione di un array bidimensionale: <tipo> <nome> [<righe>] [<colonne>];
Accesso ad una casella di un array bidimensionale: <nome> [<riga>][<colonna>];
Es: int T[3][10]; dichiara una tabella di interi di 3 righe x 10 colonne. Per accedere ad una
posizione di userà T[i][j];

Esercizio:
Implementazione del gioco del tris. Ci servirà una tabella di 9 caselle, inizialmente vuota, in cui i
due giocatori andranno a mettere i loro simboli. I giocatori specificano ogni volta la posizione che
vogliono segnare, e il programma deve accorgersi quando uno dei due ha vinto.
Abbiamo bisogno di:

• un array bidimensionale 3x3 di caratteri


• due costanti di programma per i simboli dei giocatori (X e O)
• Inizializzazione della tabella: mettiamo uno spazio in ogni casella
• Funzione di stampa per visualizzare la tabella Prototipo: void stampa(char T[3][3])
• Una funzione di gioco a ciclo che gestisce l'interazione con gli utenti: fa giocare il primo
utente, aggiorna lo stato della tabella, la stampa, controlla se l'utente ha vinto e se non è
così fa giocare il secondo. Dovrebbe anche controllare che i giocatori specifichino mosse
valide: caselle non già occupate ed entro i limiti della tabella. Se nessuno vince, il
programma termina automaticamente quando la tabella è piena (dopo 9 mosse). Può
essere implementata come funzione a parte o direttamente nel main.
• Funzione di controllo vittoria, deve capire se nella tabella c'è una configurazione vincente
(con tre simboli uguali allineati)

Orientamento tabella:

Posizione esercizio: es_classe/tris.cpp


16. Il tipo string

Si comporta come un array di caratteri, nel senso che si può usare l'operatore [] per accedere ad
un carattere e che contiene una sequenza di caratteri terminata da '\0'. A differenza degli array di
char, è una variabile.
Non è una variabile primitiva del linguaggio, è fornita dalla standard library del C++ nel modulo
string.
Differenze rispetto alle stringhe del C:

• si può passare per riferimento


• può essere usata come valore sinistro, quindi ci possiamo fare delle assegnazioni dirette
• si può restituire come risultato di una funzione
• è allocata dinamicamente: quindi non ha una dimensione prefissata e può essere
ridimensionata durante l'esecuzione
• esistono funzioni per operare sulle stringhe

16.1 Operare sulle stringhe e funzioni del tipo string


Se s1, s2 sono stringhe e x è un char:

• s1=s2 assegna ad s1 il valore di s2


• s+=x aggiunge x in coda ad s
• s=s1+s2 mette in s la concatenazione di s1 ed s2
• s1<s2 si chiede quale delle due è minore dell'altra secondo l'ordine alfabetico.
• s="pippo" assegna ad s il valore della stringa costante specificata. Nelle stringhe del C si
può fare solo in fase di inizializzazione, nelle string può essere fatto sempre

Funzioni del tipo string:

• s.size() restituisce un intero che è la lunghezza di s


• s.insert(n,x) inserisce il carattere x alla posizione n-esima di s, spostando gli altri caratteri
in modo da non sovrascrivere il carattere che già vi si trovava
• s[n]=x inserisce il carattere x alla posizione n-esima di s sovrascrivendo il carattere che già
vi si trovava
• s.clear() cancella il contenuto della stringa
17. Flussi e operazioni su file

Un flusso è una sequenza di dati che si può leggere e scrivere solo in modo sequenziale. Su un
flusso non si può leggere e scrivere nello stesso momento, solo un'operazione per volta.
Scrittura: È un contenitore virtualmente illimitato, i dati già scritti/letti non si vedono e quelli che
devono ancora arrivare non si sa quanti saranno. Solo un elemento per volta.
Lettura: vale lo stesso discorso fatto per la scrittura, leggo un dato per volta senza sapere i dati
che ho già letto prima né quelli che ancora devo leggere. Durante la lettura di un file, a segnalarci
che il file è finito troveremo un carattere End of file (EOF) che svolge la stessa funzione del
carattere nullo nelle stringhe: fa da tappo informandoci che il file è finito e dobbiamo
interrompere la lettura.
cin e cout sono dei flussi: flusso di standard input e flusso di standard output. Nella sintassi cin >>
il comando è >>, che indica che va letto qualcosa dal flusso cin. Stessa cosa per la scrittura in
output.

Operazioni fondamentali sui flussi

• aprire il flusso in lettura o in scrittura


• leggere o scrivere un dato
• interrogare lo stato del flusso, che può essere: normale, end of file (solo per la lettura),
anomalo/errore (ad esempio se tento di leggere un file inesistente, o se voglio leggere un
numero ma invece c'è un carattere).
• chiusura del flusso

17.1 Operare sui file


Per leggere o scrivere su file devo associare il file su disco ad una variabile di tipo stream. Da
dentro il programma non vedrò il file fisicamente presente sulla memoria di massa, ma una
variabile stream, sulla quale possiamo leggere scrivere, e queste operazioni si ripercuoteranno sul
vero file.
Ogni volta che apro un file in lettura, lo stream inizierà sempre dall'inizio del file.
In C++ (e nella maggior parte dei linguaggi) l'input/output non è un'operazione primitiva ma è
fornita da librerie. Questo vale anche per i flussi: in C++ la libreria per fare I/O su file è fstream.
Questa libreria fornisce i tipi ifstream e ofstream , due flussi che consentono la lettura (ifstream) e
la scrittura (ofstream) su file. Per poterli usare in un programma devo dichiarare due variabili di
tipi ifstream o ofstream
Dichiarazione: ifstream fi;
Dopo la dichiarazione il flusso esiste, ma non posso ancora utilizzarlo poiché non è ancora stato
associato a nessun file: è come una variabile non inizializzata. L'inizializzazione avviene attraverso
una funzione di libreria chiamata open. Open è una funzione interna alla classe ifstream, quindi si
invoca attraverso la dot notation nome_flusso.open("nome_file")
Inizializzazione (o apertura) del flusso: fi.open("filename")
Se quest'operazione va a buon fine, potremo leggere il file specificato semplicemente leggendo
dallo stream fi.
ATTENZIONE: Se,in scrittura, facciamo fo.open("filename") (dove fo è un ofstream) e
specifichiamo un file esistente, questo verrà cancellato e rimpiazzato con un file vuoto su cui
possiamo iniziare a scrivere dal primo carattere. Per aggiungere in coda ad un file esistente
bisogna usare altri metodi.
Gli stream sono tutti sequenze di caratteri. Possiamo leggere/scrivere singoli caratteri oppure
numeri e stringhe formattate come sequenze di caratteri.
I/O formattato:
si fa con << per la scrittura e >> per la lettura, come abbiamo sempre fatto con cin e cout. Ad
esempio fi>>x legge da fi un valore, dello stesso tipo della variabile x, e lo mette in
x. fo<<x scrive su fo la codifica come stringa di caratteri del valore associato ad x. Quindi se x è un
char scrive x, se x è ad esempio l'intero 785 scriverà i caratteri '7','8','5'. Questa sintassi salta i
separatori: spazi e a capo non vengono letti.

I/O un carattere per volta:


Lettura: Si fa attraverso un'altra funzione interna alla classe ifstream, la
funzione get. Sintassi: <varifstream>.get(<varchar>) Esempio: fi.get(c). A differenza
dell'I/O formattato, la funzione get legge anche i separatori.
Scrittura: si usa la funzione put della classe ofstream. Sintassi:
<varofstream>.put(<varchar>). A differenza della get questa funzione è scarsamente
utilizzata.

Chiusura di uno stream:


Sintassi: <varstream>.close(); Dopo aver chiuso un flusso non posso più operare su di esso
se non lo riapro.

Interrogare lo stato di uno stream:


Possiamo conoscere li stato di uno stream trattandolo come una variabile booleana: se è vero è in
stato normale, se è falso è in stato di errore. Dopo la dichiarazione di un ifstream od ofstream,
questo risulterà falso poiché non è ancora stato associato ad un file: se l'open andrà a buon fine
diventerà vero, se invece provo ad associarlo ad esempio ad un file inesistente rimarrà falso.
Se f è un ifstream aperto, posso sapere se sono arrivato in fondo al file con la funzione
booleana f.eof(); che risulterà vera solo quando siamo arrivati alla fine del file. Un tentativo
di lettura su uno stream in stato di eof lo porterà in stato di errore.

Tornare indietro di una posizione:


Si può fare con l'operazione di unget. Serve ad esempio se sto leggendo il numero, finito il numero
trovo qualcos'altro che non fa parte del numero, ma che mi servirà di nuovo più tardi. Questo
manderà il mio flusso in stato di errore e mi farà anche capire che il numero che stavo leggendo è
finito: posso quindi annullare lo stato di errore con f.clear() e ritornare indietro di una
posizione con f.unget(); in questo modo avrò letto il mio numero e avrò il dato successivo
pronto per essere letto in seguito.
17.2 Esercizio sui file
Esercizio:
File con formato: nome voto1 voto2 voto3 voto4. Il nome dev'essere una stringa senza spazi, i voti
degli interi (0 se esame non sostenuto). Per ogni studente scrivere in output il numero di esami
sostenuti e la media.
Per ogni esame scrivere in output quanti studenti l'hanno sostenuto e la media del punteggio.
In media, quanti esami ha sostenuto uno studente.

Serviranno:

• un contatore per ogni esame


• un accumulatore per la media di ciascuno studente
• un accumulatore per la media di tutti i voti

Dato che non sappiamo ancora allocare dinamicamente gli array, dobbiamo lavorare in streaming,
processando le informazioni linea per linea. Le medie complessive le farò alla fine, servendomi di
accumulatori.

Posizione esercizio: es_classe/media_voti

17.3 Text processing e gestione bufferizzata


I metodi di text processing che conosciamo finora sono:

• leggere/scrivere un carattere alla volta (get/put). È la gestione di testo più a basso livello
che possiamo fare, ma appunto per questo costringe ad assemblare le parole un carattere
alla volta. Massimo del controllo, minimo della facilità.
• Lettura/scrittura formattata con gli operatori >> e << . Dà il massimo della facilità perché si
accorge da solo quando finisce una parola. Dà il minimo del controllo perché saltando
automaticamente i separatori, non posso sapere se e quanti ce ne sono (non posso sapere
se c'era un ritorno a capo o uno spazio, né quanti ce n'erano.

Buffer: è un contenitore di pezzi di sequenza. Se abbiamo a che fare con un qualunque stream
possiamo gestirlo non un elemento alla volta ma un pezzo di stream alla volta. È la gestione
bufferizzata. Avviene usando un livello intermedio, chiamato buffer, in cui viene salvato un pezzo
di stream che poi verrà letto dal nostro programma. Ogni volta che chiediamo di leggere o scrivere
un elemento, questo verrà letto o scritto dal o nel buffer. Se siamo arrivati alla fine del buffer,
questo verrà svuotato (o salvato) e riempito col pezzo successivo. Quando si opera in scrittura, è
importante che il buffer venga scritto sulla destinazione prima di interrompere le operazioni,
altrimenti le modifiche andrebbero perse. Per questo bisogna fare la rimozione software delle
chiavette USB, perché la scrittura di file è bufferizzata. Per lo stesso motivo è importante ricordarsi
di chiudere gli ofstream con .close() nei programmi.
Possiamo usare i buffer in maniera esplicita per processare un testo una linea alla volta, con la
funzione getline(<ifstream>, <string>&) . In questo caso la stringa si comporta da
buffer: la getline copierà una linea del file nella stringa. Questo permette di avere più controllo
rispetto all'input formattato con >>, perché mi permette di sapere quando finisce una linea.

17.4 Stringstream
Posso "trasformare" una stringa in uno stream usando la funzione istringstream is(s). is è
uno stream di caratteri che ha il contenuto della stringa s. Lo stream risultante non avrà il
carattere nullo \0 alla fine. Su questo stream potrò fare tutte le operazioni che si fanno sugli altri
stream: input formattato, input carattere per carattere, funzione .eof() per sapere quando sono
arrivato alla fine. Per usare una stringa come stream di output dovrò usare la
funzione ostringstream os(s).
In generale: se il formato del file prevede che la struttura di ogni linea non sia sempre uguale
(ovvero sia variabile) conviene fare un input bufferizzato e fare il "parsing" di ogni linea.
Modulo della standard library: sstream

18. Visibilità degli identificatori (scope) e namespace

Gli identificatori sono i nomi degli oggetti che usiamo nel programma: ci sono quelli fissi del
linguaggio (if, while, for...) e quelli definiti dal programmatore (variabili, costanti, tipi....). A
seconda di dove un identificatore è stato definito, questo viene o meno riconosciuto a seconda del
punto del programma in cui lo invochiamo.
Un identificatore viene definito in un punto del programma e associato a qualcosa (una variabile,
una funzione, un tipo, una costante). Viene riconosciuto o meno in qualunque punto del
programma seguendo determinate regole di scope.

18.1 Regole di scope


1. un identificatore non si vede prima della sua dichiarazione.
2. un identificatore si vede solo all'interno del suo scope.
3. Annidamento: lo scope globale contiene gli scope di tutte le funzioni. Lo scope di funzione
contiene gli scope di tutti i blocchi (o istruzioni) definiti al suo interno. Un blocco può
contenere al suo interno altri blocchi.Visibilità verso l'esterno: in uno scope A si vedono
tutti gli identificatori visibili nello scope B di livello superiore tranne quelli che vengono
ridefiniti in A, ossia se viene usato lo stesso nome per definire una cosa diversa.
4. Invisibilità verso l'interno: un identificatore non è visibile negli scope esterni a quello in cui
viene dichiarato.

Le regole di scope dipendono dal fatto che gli identificatori vengono associati al loro contenuto
alltraverso una symbol table, organizzata a stack. Ogni volta che il compilatore entra in uno scope
diverso deve creare una nuova symbol table, creata copiando la tabella dello scope precedente, a
cui vengono aggiunti i nuovi identificatori e cambiati quelli che vengono ridefiniti. Una volta che
usciamo dallo scope interno, viene eliminata la sua symbol table e ripristinata quella dello scope
precedente prendendola dalla seconda posizione dello stack.

18.1.1 Principali scope

Scope globale: tutto il programma. Vale per quegli identificatori che sono dichiarati fuori da
qualunque funzione, main incluso.
Scope di una funzione: comprende tutto quello che viene dichiarato nel corpo della funzione, i
parametri formali della funzione e la funzione stessa.
Scope di un blocco: tutto ciò che viene dichiarato tra due parentesi graffe.
Scope di istruzione (for,while,...): tutto quello che viene dichiarato all'interno del blocco
dell'istruzione, incluse le condizioni.

18.2 Namespace
Le regole di scope definite finora valgono anche per il C. Il namespace invece è specifico del C++.
Un namespace è un blocco con un nome e viene usato per fare chiarezza all'interno delle librerie:
in genere una libreria è chiusa interamente in un namespace con lo stesso nome della libreria, per
fare in modo che gli identificatori definiti in essa non interferiscano con altri identificatori di altre
librerie chiamati allo stesso modo.
Sintassi per la definizione: namespace <nome> {...}
Gli identificatori definiti nel blocco del namespace sono visibili fuori dal namespace purché siano
caratterizzati dal nome del namespace secondo la sintassi <nome del namespace>::<nome
identificatore>

Esempio:

namespace myspace {

void pippo();

myspace::pippo();

La libreria standard del C++ ha namespace std: non abbiamo bisogno di usare la sintassi std::
ogni volta che usiamo funzioni della standard library (cin, cout ecc.) perché all'inizio dei programmi
scriviamo sempre using namespace std; : questo fa in modo che il programma riconosca gli
identificatori usati nel namespace std anche se non lo specifichiamo ogni volta. L'effetto
collaterale del bypassare la specifica del namespace è che eventuali funzioni con lo stesso nome di
funzioni del namesace entreranno in conflitto. In questi casi viene data la precedenza alle funzioni
del programma, e se non viene trovata nel programma principale una funzione col nome
specificato si andrà a cercare nel namespace.

19. Progettazione di tipi di dato

Tipo di dato: un tipo di oggetti + le operazioni che posso fare su oggetti di quel tipo. Un tipo di
oggetti è un dominio di cose rappresentabili. Ad esempio il tipo di dato int rappresenta tutti i
numeri interi (rappresentabili in 32 bit), il tipo bool rappresenta vero o falso, eccetera.
19.1 Stack

Possiamo definire un tipo di dato che impila diversi oggetti dello stesso tipo. Operazioni che posso
fare su uno stack:

• inizializzazione di uno stack vuoto


• aggiungo un elemento
• rimuovo un elemento
• chiedere se lo stack è vuoto
• restituire il primo elemento dello stack e rimuoverlo dalla pila
• restituire il primo elemento dello stack senza toglierlo dalla pila

Le operazioni che possiamo fare sui singoli elementi dipendono dal loro tipo, non dal tipo stack. In
uno stack non mi serve mai sapere qualcosa degli elementi che stanno sotto al primo, quindi non
ho bisogno di un'operazione per sapere quanti elementi ci sono nello stack.

Struttura:

• un tipo base per gli elementi dello stack (che chiameremo elem)
• una struttura a pila (possiamo basarla su un array)

Operazioni:

• empty: crea uno stack vuoto


• isEmpty: crea uno stack vuoto
• top: restituisce il primo elemento in cima allo stack (da errore se lo stack è vuoto)
• pop: toglie l'elemento in cima allo stack (da errore se lo stack è vuoto)
• push: aggiunge un elemento allo stack

Se basiamo la struttura a pila su un array, per accedere all'elemento in cima alla pila dobbiamo
accedere all'ultimo elemento inserito nell'array: dobbiamo quindi avere un indice che ci dica
l'indice della prima casella libera.

Posizione esercizio: es_classe/stack_array

Gli stack sono chiamati anche code LIFO (Last In First Out), perché l'ultimo elemento aggiunto è il
primo ad essere utilizzato. Le code classiche, in cui chi arriva prima esce prima, sono
chiamate code FIFO (First In First Out).
Rappresentazione grafica di code LIFO e FIFO.

19.2 Code (queue)


Operazioni su una queue:

• empty: inizializza una coda vuota


• isEmpty: chiede se la coda è vuota
• enqueue: aggiunge un elemento in coda
• dequeue: toglie un elemento dalla coda
• first: restituisce il primo elemento in coda

Possiamo implementare una coda anche su un array circolare: per fare questo abbiamo bisogno di
un array e due interi: uno che dice dove comincia la coda (fs) e uno che dice quanto è lunga la
coda (sz). L'indice dell'ultimo elemento della coda in un array di dimensione MAX è (fs+sz)%MAX :
questa operazione restituisce fs+sz quando fs+sz>MAX e (fs+sz)-MAX quando fs+sz>MAX.
Coda implementata su un array circolare

Funzione enqueue di una coda su array circolare:

Error Enqueue(Queue& q, elem x)

if(q.sz==MAX) return FULL;

q.a[(q.fs+q.sz)%MAX]=x;

q.sz++;

return OK;

Error dequeue(Queue& q)
{

if(q.sz==0) return EMPTY;

q.fs=(q.fs+1)%MAX;

q.sz--;

return OK;

20. Gestione dinamica della memoria


Un grosso limite degli array è che hanno dimensioni statiche: nelle strutture dati che abbiamo
definito nelle lezioni precedenti, dobbiamo stabilire una dimensione massima in fase di
compilazione e non può più essere cambiata.
Se abbiamo bisogno di strutture dinamiche, queste verranno allocate nello heap, un serbatoio di
memoria dinamica a disposizione del programma. Anche lo stack è una porzione di memoria
dinamica, ma per un uso specifico: è gestito dal runtime environment viene usato per allocare le
estensioni procedurali. Lo heap invece è a disposizione del programma per qualunque utilizzo.

Schema della suddivisione della memoria di un programma.


La separazione tra heap e stack non è fissa come quella tra codice e variabili statiche: le
dimensioni di heap e stack infatti possono variare a seconda delle richieste del programma.
Quando si esaurisce la parte di memoria riservata al programma dal SO si verificano condizioni di
errore come lo stack overflow.

20.1 Puntatori
I puntatori sono variabili di programma che puntano a indirizzi di memoria. Un puntatore contiene
due informazioni: l'indirizzo del dato a cui punta e il suo tipo.
Un dato che sta in memoria e il puntatore ad esso associato vengono messi in relazione dagli
operatori * e &. Se ho una variabile x di tipo t allora &x è l'indirizzo di x ed è un puntatore a un
dato di tipo t.
Viceversa se ho un puntatore p che punta a dati di tipo t allora *p è il dato puntato da p. &x è un
valore destro, non posso eseguire assegnazioni su di esso perché non indica un dato, ma un
indirizzo. *p è un valore sia sinistro che destro, perché posso sia scrivere in quella posizione
tramite assegnazione sia usarla come dato.

Variabili puntatore: dichiarazione: <tipo> * <nome var> Esempio: int * p


Ogni volta che dichiariamo una variabile puntatore int * p, nella memoria statica viene allocata
una cella di tipo puntatore adatta a contenere un indirizzo, non un intero. Se sul puntatore eseguo
un'assegnazione del tipo p=&n (dove n è una variabile int) in p verrà messo l'indirizzo di n. In
questo modo ho operato solo sulla memoria statica, lo heap non è stato modificato.
Questo però non è un utilizzo molto furbo dei puntatori: non ha molto senso usare un puntatore
per accedere ad una variabile statica, quando vi posso accedere direttamente. I puntatori di solito
si usano per accedere allo heap.

20.2 Gestione dello heap


Per chiedere memoria allo heap si usa il comando new, che dev'essere associato a un
tipo. Sintassi: new <tipo> Semantica: alloca sullo heap una cella del tipo specificato e restituisci
il suo indirizzo. L'indirizzo restituito da new può essere assegnato ad un puntatore dello stesso tipo
che abbiamo specificato in new.

Esempio:

int * p;

p=new int;
Stato della memoria dopo aver eseguito allocazione e assegnazione

20.2.1 Memory leak


Il memory leak è una situazione in cui un puntatore viene cancellato o sovrascritto senza prima
deallocare l'area di memoria a cui puntava: in questo modo quella memoria sarà sempre
occupata, ma non più accessibile, causando uno spreco di memoria. Per questo è importante fare
sempre la delete quando la memoria indirizzata da un puntatore non serve più. Si fa con delete
[] nome;

20.3 Array dinamici


Si può allocare dinamicamente anche un array di variabili, con la sintassi new
<tipo>[<int>] dove l'intero tra le parentesi quadre è la dimensione dell'array e, diversamente
dagli array statici, può essere una variabile. La scrittura a[i] (dove a è un array dinamico) non è
un puntatore, ma indica proprio il dato contenuto nella posizione i dell'array a ed è dello stesso
tipo dell'array che abbiamo allocato.
Con gli array dinamici posso ripensare le strutture dati stack e queue in modo da non dover
stabilire una dimensione massima, ma da poter stabilire ogni volta la dimensione necessaria. Nel
caso in cui all'inizio dell'esecuzione non so quante posizioni mi servono, posso dichiarare una
dimensione base iniziale piccola e poi ingrandire l'array secondo necessità. L'ingrandimento
avviene allocando un altro array più grande e copiandoci all'inizio l'array attuale. In questo modo
avrò conservato i dati e avrò un array con altre posizioni libere. Questa è un'operazione piuttosto
costosa però, quindi bisogna cercare di farla il meno possibile. Per questo di solito quando è
richiesto un ingrandimento dell'array questo viene raddoppiato.
const int MINCAPACITY=8;

struct Stack

elem n;

elem * a;

elem c; //capacità dell'array

void empty (Stack& s) //costruttore

s.n=0; s.c=MINCAPACITY;
s.a=new elem[s.c];

void push (Stack& s, elem x)

if(s.n==s.c) //se la capacità attuale è esaurita, espando l'array

elem * p=new elem[s.c*2]; //dichiaro un nuovo array di dimensione doppia

for(int i=0; i<s.n; i++) p[i]=s.a[i]; //copio il vecchio array nel nuovo

delete [] s.a;

s.a=p;

s.c*=2 //raddoppio s.c

s.a[s.n]=x;

s.n++;

}
21. Vector
In C++ solitamente non si usano né array statici né array dinamici, ma si usano i vector, un tipo
generico fornito dalla standard template library del C++ (modulo vector). Un vector è un array
dinamico che può essere usato senza dover gestire l'allocazione e la deallocazione di memoria. I
vector sono estensibili a piacere, possiamo chiederne in ogni momento la dimensione e forniscono
controlli per evitare che si tenti di accedere a posizioni che sforano l'array. A differenza degli
array, i vector sono variabili.

Tipo generico: contenitore parametrico rispetto al tipo base. Vale per i tipi contenitore come
vector e array, che possono contenere diversi tipi di dato (int, float, char ecc).

Vector è parte della STL e vive nel namespace std. Per usarlo bisogna includere il modulo vector.

Dichiarazione: vector<<tipo>><nome>(<dimensione>) Esempio: vector<int>V(5);

La dimensione può essere sia una costante sia una variabile.

Accesso alle celle del vector:

• con l'operatore [] come negli array


• operatore at(): si usa in dot notation V.at(n)

L'operatore at controlla se il valore specificato è compreso nelle dimensioni del vector, l'operatore
[] no.

21.1 Primitive del tipo vector


• at(n) : restituisce il contenuto della cella n
• size() : restituisce la dimensione del vector.
• capacity() : quanti elementi ci stanno nel vector senza doverlo ridimensionare
• resize(n) : ridimensiona il vector alla dimensione n.
• reserve(n) : ridimensiona la capacità
• push_back(x) : aggiunge x in fondo al vector. X dev'essere dello stesso tipo del vector.

Tutte queste funzioni sono interne al tipo vector e si usano con la dot
notation V.funzione(parametri), dove V è il vector sul quale stiamo operando.
Usando i vector, definire alcuni tipi di dato già noti (stack, queue..) diventa molto più semplice.
21.2 Queue usando vector e template
Template: costrutto che permette di parametrizzare un tipo di dato da noi definito in base al tipo
che esso contiene. Permette di definire un tipo generico, come i vector, nel quale possiamo
mettere tipi di dato diversi, e al quale dobbiamo specificare ad ogni utilizzo quale tipo vogliamo
metterci dentro.

Schema della copia del vector per il ridimensionamento:

Posizione esercizio: es_classe/queue_vector


22. Liste
Una lista è una sequenza di elementi allocati dinamicamente. Rispetto agli array/vector hanno
alcuni vantaggi e alcuni svantaggi:

• è più costoso accedere ad elementi random, poiché non vi si può accedere direttamente
ma bisogna scandire la lista fino a quel punto.
• è più economico inserire o cancellare elementi in mezzo. Questo perché non c'è bisogno di
spostare tutti gli elementi come negli array, l'inserimento è molto più rapido.

Conviene usare le liste quando abbiamo bisogno di fare molti inserimenti e cancellazioni. Conviene
usare gli array quando abbiamo bisogno di fare molti accessi a posizioni casuali.
Una lista è costituita di celle. Una cella contiene:

• un'informazione, di tipo Elem


• un puntatore alla prossima cella della lista
22.1 Definizione del tipo cell

Definiamo il tipo cella in base ad un tipo generico Elem

typedef ... elem;

struct cell {

elem info;

cell* next;

};

cell* next è un puntatore a un dato di tipo cell: questo è un esempio di definizione ricorsiva,
perché cell è usato nella definizione di se stesso.
Il puntatore alla prima cella è sempre un tipo cell*. Conviene ridefinire il tipo cell* come tipo
list, con una typedef cell* list . Questo perché il punto di accesso a una lista, il puntatore
alla prima cella, è anch'esso di tipo cell*.
Non ci saranno mai variabili statiche di tipo cell in un programma, ma saranno tutte allocate
dinamicamente nello heap tramite puntatori cell* (ridefiniti come tipo list).
Posso avere dei cursori sulle liste, puntatori cell* che puntano a posizioni interne alla lista e
vengono di solito usati per scorrerla. Siamo in fondo alla lista quando il nostro cursore è arrivato al
puntatore nullo, che indica la fine.
Quando dichiaro una variabile list è importante che venga subito inizializzata, altrimenti, come
ogni variabile non inizializzata, conterrà dati casuali. Un puntatore che contiene dati casuali
punterà quindi a una posizione casuale della memoria, che facilmente sarà non allocata o non di
proprietà del nostro programma, e quindi tentare l'utilizzo di quel puntatore provocherà quasi
sicuramente un crash del programma.
Per accedere ai campi di una cell, poiché accediamo alle cell sempre tramite puntatori, si usa la
versione della dot notation per puntatori l->info, dove l è la cella e info il campo a cui vogliamo
accedere. È la stessa cosa di scrivere (*l).info, ma non si usa mai.
22.2 Inserimento in testa ad una lista
1. allocazione di una nuova cella
2. riempimento del campo info della cella
3. aggancio della cella alla testa della lista
4. spostamento della testa sulla nuova cella

Schema dell'inserimento in testa

Codice della funzione di inserimento in testa: void push_front(list& l, Elem


x) in es_classe/list/list.cpp

Questa funzione è efficace anche nel caso in cui gli venga data in input una lista vuota.
22.3 Inserimento in coda ad una lista
Non abbiamo un modo rapido per farlo, dato che non è possibile accedere a celle random ma è
necessario scandire la lista partendo dalla testa. È sempre un'operazione costosa.
Dovrò usare un puntatore cell* cur da usare come cursore per scandire la lista e un
puntatore list aux dove metterò il valore Elem da aggiungere. L'attuale ultimo elemento della
lista dovrà essere fatto puntare ad aux, e aux->next dovrà essere un puntatore nullo poiché
l'elemento puntato da aux è il nuovo ultimo elemento della lista.
Nel caso in cui la lista sia vuota, basterà allocare una nuova cella e farla puntare dalla testa della
lista.

Schema dell'inserimento in coda (nel caso di lista non vuota)

Codice della funzione di inserimento in testa: void push_back(list& l, Elem


x) in es_classe/list/list.cpp
22.4 Inserimento in mezzo ad una lista
Per inserire una nuova cella in una posizione interna alla lista bisogna scorrere fino alla posizione
desiderata con un cursore cell* cur, quindi creare un nuovo puntatore a cella ausiliaria cell* aux,
far puntare aux->next alla cella successiva (cur->next) e far puntare il campo next della cella
precedente (cur) ad aux, come indicato in figura.

Schema del funzionamento dell’inserimento in mezzo

22.5 Copia di liste


In linea di massima, se a e b sono variabili di tipo T, posso copiare a su b con l'assegnazione b=a
Se stiamo usando liste, con l e l1 variabili di tipo list (ossia puntatori a cella, cell*) se facciamo
l'assegnazione l1=l otteniamo due puntatori alla stessa lista. Con questa operazione non abbiamo
fatto una copia della lista, ma abbiamo semplicemente due punti di accesso alla stessa
lista. Apportare delle modifiche su l1 modificherà anche l. Per fare una vera copia dovremmo
scrivere una funzione che scandisce l e copia ogni cella su un'altra lista l1.
Effetto dell'assegnazione l1=l
Funzione di copia: list copia (list l) in es_classe/list/list.cpp

Funzionamento della funzione di copia.

Volendo, si può anche implementare la funzione di copia come funzione void che prende come
parametro una seconda lista, per riferimento, in cui copierà la prima.
22.6 Stack e queue implementati con liste
Posso reimplementare le strutture dati stack e queue basandole su liste invece che su array.
Questo comporta vantaggi e svantaggi:

Vantaggio: non dovrò mai fare costose operazioni di estensione dell'array quando ho raggiunto la
capacità massima, poiché alloco la memoria una cella per volta.
Svantaggio: allocare la memoria una cella per volta è più costoso che allocarla a blocchi

Nel complesso, è più vantaggioso usare gli array, anche se sulla singola operazione è più
vantaggioso usare le liste.

22.6.1 Stack implementato con lista


Posizione: es_classe/stack_list

Schema di funzionamento della funzione Error pop(list&). Il puntatore aux serve per poter
eliminare il primo elemento con una delete [] aux evitando un memory leak. Con la delete su
aux non viene eliminato il puntatore aux, ma il dato da lui puntato. Dopo la delete il puntatore
esisterà ancora, ma sarà inutile perché punterà ad una posizione di memoria non allocata.
22.6.2 Queue implementata su lista
La empty e la isEmpty sono uguali a quelle dello stack, la dequeue è uguale alla pop dello stack, la
first è uguale alla top dello stack. Cambia solo la dequeue.

Posizione: es_classe/queue_list

Rappresentazione di una queue implementata su liste. In questo caso il tipo list non è più un
puntatore a cell, ma una struct contenente due puntatori al primo e all'ultimo elemento della lista.

Dequeue:

Schema di funzionamento della dequeue su una coda con più elementi. Il puntatore aux serve per
poter fare la delete sul primo elemento, come nello stack.
Schema di funzionamento della dequeue su una coda contenente un solo elemento.

22.7 Tipo di dato insieme


Abbiamo un tipo base Elem, e vogliamo definire un tipo di dato che rappresenti un insieme di
Elem. Un insieme non può contenere due elementi uguali
Operazioni base su un insieme:

• creazione di un insieme vuoto


• sapere se l'insieme è vuoto
• sapere quanti elementi ci sono in un insieme
• aggiunta elementi
• cancellazione elementi
• sapere se un elemento x è nell'insieme
• unione, intersezione, differenza

Il tipo di dato costruito con queste primitive si chiama dizionario, perché si comporta come un
dizionario (possiamo aggiungere, rimuovere ma soprattutto cercare parole). È anche il
meccanismo che sta alla base della ricerca nei database.
L'operazione per sapere quanti elementi ci sono è costosa, perché bisogna scandire tutta la lista
fino alla fine. Conviene quindi tenere una variabile int che viene incrementata ogni volta che viene
aggiunto un elemento, in modo da avere sempre memorizzata la dimensione.
Per semplificare l'operazione di ricerca, necessaria per evitare di aggiungere elementi ripetuti,
conviene tenere la lista ordinata. La ricerca può essere implementata scorrendo la lista finché non
troviamo un valore maggiore dell'elemento che vogliamo aggiungere (nel qual caso l'elemento va
aggiusto nella posizione subito precedente) o il puntatore nullo (nel caso in cui l'elemento sia
maggiore di tutti, e quindi va inserito in coda).
22.7.1 Inserimento ordinato

Casi possibili nell'inserimento in ordine

• l vuota: inserimento in testa


• x<primo elemento di l: inserimento in testa.
• x>ultimo elementi di l: inserimento in coda
• esistono due elementi contigui sulla lista, uno <x e l'altro >x: inserisco x tra i due
elementi,in questo modo:

22.7.2 Cancellazione
Casi possibili:

• l vuota: niente da fare, ritorna


• x è il primo elemento: sposto l sull'elemento successivo
• x non è il primo elemento: devo scorrere con un cursore finché:
o arrivo all'ultimo elemento senza aver trovato x: x non è nell'insieme, niente da
fare, ritorno
o l'elemento successivo è >x: x non è nell'insieme, niente da fare, ritorno
o l'elemento successivo è x: lo cancello

Posizione codice: es_classe/set

22.7.3 Unione
Posso implementarla in diversi modi:

1. se ho liste non ordinate, e non voglio cambiare gli insiemi in input:

list union1 (list a, list b)

list l;

l=copia(a);
while(b!=NULL)

insert(l, b->info);

b=b->next;

return l;

2) liste ordinate e riutilizzo delle celle (le liste in input vengono modificate: a conterrà il risultato e
b verrà svuotata). Molto più efficiente della prima, avremo due cursori uno che scandisce a e uno
che scandisce b, fino a trovare l'elemento minore. In un'istante generico avremo un cursore che
scorre a e uno che scorre b, e tutto quello che c'è prima dei cursori è già stato copiato. Ad ogni
ciclo scegliamo il più piccolo del due elementi, lo aggiungiamo alla lista a (quella che conterrà il
risultato) e spostiamo in avanti il cursore della lista in cui abbiamo trovato l'elemento. Nel caso in
cui troviamo due elementi uguali nelle due liste dobbiamo copiarne solo uno e buttare via l'altro.
Invariante di ciclo: durante la scansione gli elementi delle celle già visitate sia in a che in b sono
tutti minori degli elementi puntati dai cursori sia di a che di b.

Scansione parallela delle liste a e b.


Codice: void union2 (list& a, list& b) in es_classe/list/list.cpp
22.8 Liste doppie e liste circolari

Una lista doppia è una lista in cui ogni cella ha un puntatore sia all'elemento successivo sia
all'elemento precedente. Queste liste si possono scorrere in entrambi i sensi e ci sono casi in cui
questo è molto utile. Lo scorrimento avviene sempre tramite cursori.

Lista doppia "semplice", in cui il tipo list è un puntatore a cell

Lista doppia in cui il tipo list è una struct contenente due puntatori alla testa e alla coda della cella.
22.8.1 Inserimento e cancellazione in liste doppie
L’inserimento nelle liste doppie è come nelle liste semplici ma:

1. si può inserire sia prima che dopo l'elemento corrente


2. per inserire è necessario collegare due puntatori in più: il puntatore a elemento
precedente della cella che stiamo aggiungendo e il puntatore a cella precedente della cella
successiva a quella che stiamo aggiungendo.
3. si può cancellare l'elemento corrente. Questo non è possibile nelle liste semplici, bisogna
sempre eliminare l'elemento successivo all'elemento corrente.

Schema dell'inserimento in lista doppia

Parte del codice della funzione di inserimento:

if(cur->prev==NULL) l=aux;

aux->next=cur;

aux->prev=cur->prev;

if(aux->prev!=NULL) aux->prev->next=aux;
Parte del codice della funzione di cancellazione:

if(cur->prev!=NULL) cur->prev->next=cur->next;

else l=cur->next;

if(cur->next!=NULL) cur->next->prev=cur->prev;

delete cur;

Schema della cancellazione da lista doppia

22.8.2 Liste circolari (semplici o doppie)


Le liste circolari sono liste in cui non c'è un ultimo elemento, sono collegato a cerchio. Non
essendoci un ultimo elemento non c'è neanche un primo, c'è solo un entry point, un cursore
permanente che punta all'elemento corrente della lista. Lo svantaggio è che, non essendoci un
ultimo elemento, è più difficile sapere quando abbiamo finito di scandire la lista: per farlo
possiamo usare un cursore temporaneo che scandisce la lista finché non assume il valore
dell'entry point: questo significa che tutti gli elementi sono stati scanditi e che sono tornato al
punto di partenza.
Lista circolare semplice.

Lista circolare doppia

Una lista circolare doppia può essere scandita in senso orario o antiorario a seconda che si usi il
puntatore a cella precedente o a cella successiva.
23. Ricorsione
La ricorsione avviene quando una funzione al suo interno chiama se stessa. Permette di risolvere
molti problemi più facilmente, ma non è indispensabile: infatti ogni problema risolto in maniera
ricorsiva può essere risolto anche iterativamente. Utilizzando la ricorsione è molto facile cadere in
un loop infinito: per questo è importante che nella funzione ci siano condizioni che in certi casi
saltano la chiamata ricorsiva.
Le variabili sono indipendenti per ogni chiamata della funzione: questo perché ogni singola
chiamata di una funzione ricorsiva ha la sua porzione di stack, con le sue variabili.

Chiamate ricorsive.

Stato dello stack durante l'esecuzione della funzione ricorsiva A.


23.1 Principio di induzione
Le affermazioni (predicati, fatti) sono "oggetti" indicizzati sui numeri naturali. Se un'affermazione:

• base: è vera per il valore c


• passo: per ogni n>c, assumendo che sia vera per n-1, allora è vera per n

=> l'affermazione è vera per ogni n>c


Questo è il principio di induzione usato in matematica. La ricorsione ne sfrutta uno un po' diverso:

• base: è vera per il valore c


• passo: per ogni n>c, assumendo che sia vera per ogni k>c, k<n allora è vera per n

=> l'affermazione è vera per ogni n>c.

Il principio di induzione si può usare non solo come strumento di dimostrazione ma anche come
strumento per definire degli oggetti.

23.2 Definizione induttiva


Definisco una serie di oggetti indicizzati sugli interi in questo modo:

• base: specificando l'oggetto corrispondente ad un indice C


• passo: per ogni n>c, specificando l'oggetto corrispondente all'indice n in funzione
dell'oggetto corrispondente all'indice n-1

Alcuni esempi di definizione induttiva:

Fattoriale: Il fattoriale di un numero n è uguale a n*(n-1)*(n-2)*...*1. Può essere definito in


maniera induttiva:

• base: 1!=1
• passo: per ogni n>1 n!=n*(n-1)!

Coefficienti binomiali: un coefficiente binomiale è un oggetto del tipo: (n k)= n!/k!(n-k)!

• base: (n 0)=(n n)=1


• passo: 0<n<k (n k)=(n-1 k-1) + (n-1 k)
23.2.1 Definizione induttiva di lista

• base: la lista vuota (con zero elementi) è una lista


• passo: dato un elemento x e una lista l, allora la concatenazione di x.l è una lista. Una lista
di n>0 elementi è costituita da un elemento x e da una lista l di n-1 elementi.

Con questa definizione distinguiamo tra first, primo elemento della lista, e rest, il resto della lista.
Quando lavoriamo sulle liste distinguiamo sempre il caso in cui stiamo lavorando su una lista vuota
o su una lista che contiene un primo elemento concatenato al resto della lista.
In questo modo possiamo scrivere funzioni ricorsive per lavorare sulle liste.

23.3 Esempi di funzioni ricorsive


23.3.1 Funzione ricorsiva per il calcolo del fattoriale

int fatt(int n)

if(n==0) return 1;

return n*fatt(n-1);

23.3.2 Funzione ricorsiva per il calcolo del coefficienti binomiali

int cbin (int n, int k)

if(k>n) return -1; //errore

if(k==0 || k==n) return 1;

return cbin(n-1, k-1) + cbin(n-1,k);

Questa funzione è più complessa di quella per il calcolo del fattoriale perché ha due chiamate
ricorsive.
23.4 Liste implementate ricorsivamente

Due primitive sulle liste:

• elem first(l) restituisce il primo elemento della lista


• list rest(l) restituisce il resto della lista

Possiamo scrivere una funzione ricorsiva member (list l, elem x) per la ricerca all'interno
della lista:

bool member (list l, elem x)

if(l==NULL) return false;

if(first(l)==x) return true;

return(member(rest(l));

Questa funzione, oltre che a essere più semplice di una funzione iterativa che fa la stessa cosa,
astrae dall'implementazione fisica della lista: infatti tramite le funzioni first e rest non ho bisogno
di conoscere come è costruito il tipo list, perché non ho mai bisogno di accedere ai campi interni
delle celle.

23.4.1 Inserimento in ordine implementato ricorsivamente


Pseudocodice:

void insert (list& l, elem x)

if(l vuota) inserisci x come primo elemento

else if(first(l))>x inserisci x come primo elemento

else(insert(rest(l),x)

}
Questa specifica implementazione è corretta concettualmente, ma implementata in C++ non
funzionerebbe: non è infatti possibile passare per riferimento il valore di una chiamata a funzione,
come si fa in ultima riga.

Implementazione in C++

void insert (list& l, elem x)

if((l==NULL) || (l->info>x))

cell* aux;

aux->info=x;

aux->next=NULL

l=aux;

else(insert(l->next,x)

A differenza dell'algoritmo iterativo, questa funzione agisce direttamente sulla cella corrente, non
su quella successiva: questo perché non utilizza cursori ma agisce direttamente su l: infatti ad ogni
chiamata ricorsiva viene passato come parametro l->next, ossia l'elemento successivo a quello
attualmente processato, non un cursore.
Esercizio: primitiva clear(l) che cancella completamente una lista

23.4.2 Rovesciamento di liste


Pseudocodice:

void reverse (list l IN-OUT)

if(l vuota || l ha un solo elemento) return;

l1=rest(l);

reverse(l1);

append(l1,first(l));
}

Implementazione in C++:

void reverse(list& l)

if((l==NULL) || (l->next==NULL)) return;

list aux=l->next;

reverse(aux);

l->next->next=l;

l->next=NULL;

l=aux;

23.5 Approccio divide et impera


L'approccio divide et impera risolve i problemi, quando si ha a che fare con grandi quantità di dati,
in questo modo:

• dividi i dati in gruppi (fase divide)


• risolvi il problema su ogni gruppo
• combina le soluzioni (fase impera)

Abbiamo già risolto un problema con il metodo divide et impera: l'algoritmo di ricerca binaria.
Questo algoritmo infatti divide in due il contenitore ordinato nel quale vogliamo cercare,
confronta il primo elemento con la nostra chiave di ricerca e se risulta maggiore non considera più
quella parte, concentrandosi sull'altra. Ne avevamo visto però un'implementazione iterativa, ora
vediamo come può essere implementato ricorsivamente:

Pseudocodice:

bool ricbin (elenco v, elem x)

if(v vuoto) return false;


if(x==elemento centrale di v) return true;

if(x<elemento centrale di v) return ricbin(metà sinistra di v, x);

return ricbin(metà destra di v, x);

Implementazione in C++:

bool rb(vector<elem>v, elem x, int inf, int sup)

if(inf>sup) return false;

int med=(inf+sup)/2;

if(x==v[med]) return true;

if(x<v[med]) return ricbin(v,inf,med-1); //chiamo ricbin sulla metà


inferiore

return ricbin(v,med+1,sup); //chiamo ricbin sulla metà superiore

Posso evitare di dover passare direttamente i parametri inf e sup alla funzione quando la uso nel
mio programma, scrivendo un'altra semplice funzione che lo faccia per me:

bool ricbin (vector<elem>v, elem x)

return rb(v,x,0,v.size()-1);

Questa funzione serve esclusivamente a fare la prima chiamata alla funzione ricorsiva ricbin
specificando che bisogna cercare su tutto il vector.

23.5.1 Mergesort (divide et impera applicato al problema del sort)


Affrontando il problema dell'ordinamento con l'approccio divide et impera si riesce a ridurre
notevolmente la complessità del sort, che affrontato iterativamente è sempre un'operazione
molto costosa.
Input: sequenza di elementi disordinata.
Supponiamo di avere un algoritmo capace di ordinare metà del nostro input: dopo averlo utilizzato
ci troveremo in questa situazione:

Posso ottenere la sequenza completamente ordinata facendo una scansione parallela delle due
sequenze parziali.
Assumiamo di essere capaci di ordinare l'intera sequenza: abbiamo una funzione merge (seq1
IN, seq2 IN, seq3 OUT) che prese due sequenze ordinate mi restituisce l'unione, anch'essa
ordinata, delle due sequenze.
Algoritmo mergesort (seq IN-OUT)

if seq ha 0 o 1 elementi return;

seq1=metà sinistra di seq; //fase divide

seq2=metà destra di seq; //fase divide

mergesort(seq1);

mergesort(seq2);

merge(seq1,seq2,seq); //fase impera

i=0; j=0; k=0;


while(i<p && j<q) {

if(x[i]<y[j]) {z[k]=x[i]; i++}

else { z[k]=y[j]; j++;}

k++;

while(i<p) {

z[k]=x[i]; i++; k++;

while(j<q) {

z[k]=y[j]; j++; k++

ms (sequenza s IN-OUT, cursori inf, sup IN)

if(inf>=sup) return;

med=posizione di mezzo tra inf e sup (se la sequenza è un array e inf e


sup sono interi med=(inf+sup)/2

ms(s,inf,med);

ms(s,med+1,sup);

merge(s,inf,med,sup);

mergesort (sequenza s IN-OUT)

ms(s,0,s.size())

merge (sequenza s IN-OUT, cursori inf, med, sup IN)


{

i=inf; j=med+1; k=0;

while(i<=med && j<= sup) {

if(s[i]<s[j]) {z[k]=s[i]; i++; }

else {z[k]=s[j]; j++;}

k++;

while(i<=med) {z[k]=s[i]; k++; i++;}

while(i<=sup) {z[k]=s[j]; k++; j++;}

for(i=inf; i<=sup; i++) s[i]=z[i-inf];

24. Complessità degli algoritmi ricorsivi


Vediamo il codice di un semplice algoritmo con ricorsione in coda: la funzione per stampare una
lista

printlist (list l)

if(l vuota) return;

print(first(l));

printlist(rest(l));

Le prime due righe, assumendo che la funzione print non sia ricorsiva, hanno costo costante Θ (1).
Per conoscere la complessità della chiamata ricorsiva, invece, sembra che dobbiamo già conoscere
la complessità della funzione printlist, che è proprio quello che stiamo cercando di calcolare:
sembra un problema senza uscita.
Dovremo calcolare il costo in funzione del numero di chiamate che vengono eseguite. Dato che la
parte non ricorsiva della funzione ha costo 1, assumendo che vengano eseguite n chiamate
ricorsive, il costo della prima chiamata sarà n-1, quello della seconda n-2 e così via. Il costo
complessivo sarà la sommatoria degli i da 1 a n-1 di n(n-1)/2, cioè una complessità quadratica, Θ
(n2).
Il costo degli algoritmi ricorsivi può essere calcolato partendo dalla complessità della parte non
ricorsiva dell'algoritmo, e calcolando pii il costo complessivo tramite l'equazione T(n)=1+T(n-1).

24.1 Analisi tramite albero della ricorsione


Si basa sui seguenti parametri:

• livello di ricorsione: parte da 0 e cresce di 1 ad ogni chiamata ricorsiva


• dimensione dell'input di ogni chiamata
• numero di chiamate ricorsive nel codice
• complessità del codice non ricorsivo

Chiamata:

Chiamate "figlie" di una chiamata superiore:

Nel caso sulla sinistra, in cui ogni chiamata ha solo un figlio, l'albero della ricorsione è detto palo. È
il caso di un algoritmo che contiene solo una chiamata ricorsiva.
La dimensione dell'input può essere scritta dentro il pallinonche rappresenta la chiamata, cioè il
nodo del nostro albero.
Nel caso del nostro algoritmo per la stampa di liste, la ricorsione termina quando siamo rimasti
con la lista vuota, ossia quando l'input è 0. Il livello di ricorsione sarà n.
Accanto ad ogni nodo possiamo scrivere il costo unitario della chiamata senza tenere conto delle
chiamate ricorsive, che corrisponde al costo del codice non ricorsivo insieme al costo per il
passaggio di parametri. Nel nostro caso infatti, se invece che per riferimento avessimo passato la
lista per valore, ogni chiamata invece che costo 1 avremmo avuto un costo uguale alla dimensione
dell'input.
Si somma poi il costo dei nodi su ogni livello, che nel caso del palo è uguale al costo del singolo
nodo, ma è maggiore nel caso in cui si abbiano più nodi (ossia più chiamate ricorsive) per ogni
livello.
Albero della ricorsione dell’algoritmo printlist
24.2 Complessità dell'algoritmo di ricerca binaria ricorsiva

bool rb(vector<elem>v, elem x, int inf, int sup)

if(inf>sup) return false;

int med=(inf+sup)/2;

if(x==v[med]) return true;

if(x<v[med]) return ricbin(v,inf,med-1); //chiamo ricbin sulla metà


inferiore

return ricbin(v,med+1,sup); //chiamo ricbin sulla metà superiore

Da notare che anche se ci sono due chiamate ricorsive in questo algoritmo, l'albero della
ricorsione è comunque un palo perché o viene eseguite una o viene eseguita l'altra, non vengono
mai eseguite contemporaneamente.
Per calcolarne la complessità procediamo così:

1. calcolare la dimensione dell'input al livello i


2. si vede quanto deve essere la dimensione dell'input all'ultimo livello
3. si calcola il valore dell'ultimo livello k in funzione dei punti 1 e 2

La relazione che lega il livello alla dimensione dell'input, f(i,n), (quello che c'è scritto nei nodi
dell'albero della ricorsione) vale per tutti i livelli. Quindi, in questo caso per il livello i vale la
relazione n/(2i). Al penultimo livello k, f(k,n)=1. Sappiamo che f(k,n) vale n/(2i) per tutti i livelli
dell'albero di ricorsione, quindi:
n/2i=1
n=2i
i=log 2 n

Se la sequenza di input è mantenuta in array/vector, le operazioni non ricorsive hanno costo Θ


(1) a tutti i livelli, e avremo questo albero di ricorsione:
24.3 Analisi degli algoritmi mergesort e coefficienti binomiali
ms (sequenza s, indici inf, sup)

if(sup<=inf) return //n=0-1

m=indice a metà tra inf e sup;

ms(s,inf,m);

ms(s,succ(m),sup);

merge(s,inf,m,sup);

In questo caso ci sono due chiamate ricorsive e vengono sempre eseguite entrambe. Quindi
l'albero della ricorsione avrà questa forma:

Anche in questo caso, essendo la relazione f(i,n)=n/2i, l'ultimo livello k sarà log 2 n
Analisi di complessità dell'algoritmo per il calcolo dei coefficienti binomiali

int cbin (int n, int k)

if(k==0 || k==n) return 1;

return cbin(n-1, k-1) + cbin(n-1,k);

Come nel caso precedente, ci sono due chiamate ricorsive che vengono eseguite sempre. Ogni
nodo ha costo 1, poiché la parte non ricorsiva dell'algoritmo è costituita solo da un if. Per calcolare
la complessità basta quindi calcolare il numero di nodi. Ce ne sono almeno tanti quanti in un
albero completo (tutti i nodi hanno due figli) alto n/2, quindi la complessità è Ω (2(n/2)), quindi
esponenziale. È un algoritmo così inefficiente perché calcola un sacco di volte gli stessi valori.

25. Ricorsione mutua e ricorsione ciclica


Si parla di ricorsione mutua quando abbiamo due funzioni A e B tali che A chiama B e B chiama A.
Ovviamente anche in questo caso ci dev'essere una via d'uscita per cui in alcuni casi la ricorsione
non viene eseguita, altrimenti si entra in un loop infinito. È una situazione comunque più
complessa della ricorsione semplice. Nella ricorsione ciclica invece abbiamo k funzioni A 1 , A 2 , A k
che si chiamano a vicenda. Ad esempio: A chiama B, B chiama C, C chiama A. Questi due tipi di
ricorsione vengono utilizzati per risolvere i problemi più complessi.

25.1 Generazione del codice Gray


Un codice Gray è una sequenza di tutti i numeri (binari) di n bit dati in un ordine tale che due
numeri consecutivi differiscano per un solo bit. Ad esempio, per n=2, 00 01 11 10 è una sequenza
di Gray.

Dobbiamo scrivere una funzione che produca un codice Grey. Chiamiamo Gn il codice Gray su n bit
e GRn il codice Gray rovesciato su n bit.

Def. induttiva: G 1 =0 1 GR 1 =1 0 Base


Passo:

Questo procedimento genera codici Gray perché, se G n-1 e GR n-1 sono codici Gray, ossia ogni
numeri differisce dal precedente per un solo bit, aggiungendo a tutti i numeri della sequenza un 1
o uno 0 davanti continueranno a differire per un solo numero, quindi continuerà ad essere un
codice Gray.

Tesi: La definizione data genera tutte le stringhe di n bit in ordine tale che due stringhe
consecutive differiscano per un solo bit => genera il codice Gray
Dimostrazione per induzione:

Base: G 1 =0 1 che è il codice Gray per un bit

Passo: ipotesi induttiva: G n-1 genera tutte le stringhe di n-1 bit in ordine di Gray e anche GR n-1
genera tutte le stringhe di n-1 bit in ordine di Gray inverso (rovescia G n-1 ).

Una stringa di n bit ha la forma 0 u oppure 1 u dove u è una stringa di n-1 bit. Le sequenza 0 u
sono generate dallo schema 0 | G n-1 mentre le 1 u sono generate dallo schema 1 | G n-1 .

Per definizione l'ultima stringa di G n-1 è uguale alla prima di GR n-1 (poiché per definizione induttiva
GR n-1 è il capovolgimento di G n-1 ) => l'ultima stringa del primo blocco di G n differisce dalla prima
del secondo blocco solo per il primo bit. Quindi è tutto in ordine di Gray.

Algoritmo 1: generiamo una tabella di 2n righe e colonne tale che ogni riga contenga una stringa di
bit e che le stringhe siano in ordine di Gray. Riempiamo la matrice seguendo i due schemi di
generazione di G n e di GR n , in questo modo per n=4:
La porzione di tabella è delimitata da 4 indici.
Scriveremo due funzioni ricorsive, gr e gd, che prendono entrambe in input la tabella su cui
operare e i quattro indici interi. Il numero di bit su cui si lavora è rc-lc+1=n

Posizione codice: es_classe/graycode.cpp

Questo algoritmo però è inefficiente se vogliamo generare codici Gray molto grandi. Per quelli si
può usare quest'altro algoritmo, molto più efficiente ma molto più difficile da capire.

Pseudocodice:

Gray (int n)

int k;

char w[n+1];

w[n]='\0';

gd(0,w);

gd(int k, char w[] IN-OUT)

if(w[k]=='\0') scrivi (w);

else

w[k]='0';

gd(k+1,w);

w[k]='1';

gr(k+1,w);

}
gr(int k, char w[] IN-OUT)

if(w[k]=='\0') scrivi (w);

else

w[k]='1';

gd(k+1,w);

w[k]='0';

gr(k+1,w);

Dimostrazione:

Dimostriamo per induzione che questo algoritmo funziona:

Chiamiamo wk la stringa w[0], w[1],.......,w[k-1]

Tesi: gd(k,w) stampa tutte le stringhe del tipo wk=u dove u sono tutte le stringhe del codice Gray
su n-k bit, nell'ordine di Gray.

Dimostrazione:

Base: se n=1 gd(0,w) genera la sequenza 0 1, gr(0,w) genera la sequenza 1 0

Passo: ipotesi gd(k+1,w) e gr(k+1,w) funzionano


26. Parsing, linguaggi formali e grammatiche
Un parser è un analizzatore di un linguaggio formale in grado di capire se una frase di quel
linguaggio è ben formata o no. È la prima cosa che fa un compilatore: controlla che il codice che gli
viene passato sia sintatticamente corretto, senza occuparsi del significato.
Il parsing di un linguaggio di programmazione è molto complicato: in C++ possiamo realizzare un
parser per espressioni aritmetiche.
Per poter scrivere un parser è necessario capire le grammatiche generative e le grammatiche
libere da contesto. Le grammatiche generative sono insiemi di regole che permettono di generare
tutte le possibili frasi di un linguaggio a partire da un alfabeto e da una frase base (la frase più
semplice del linguaggio.
Le grammatiche libere da contesto sono un particolare tipo di grammatiche generative in cui la
definizione della frase base non dipende dal suo contesto. Questi sono strumenti formali per
definire com'è fatto un linguaggio.
Esempio: linguaggio L={an bn, n>0} dove an=a a a a a a a....a (a ripetuto n volte). Questo linguaggio
contiene tutte le sequenze di a seguite da sequenze di b entrambe della stessa dimensione n. È
facile definire un algoritmo che controlla se una frase data è corretta in questo linguaggio:
controlla che il primo carattere sia a, conta il numero di a, controlla che il primo carattere dopo la
sequenza di a sia b, conta il numero di b e controlla che sia uguale al numero di a.

26.1 Grammatica generativa


G={N,Σ,P,S}

Σ: insieme finito di simboli terminali (alfabeto): elenco di simboli base, non definiti partendo da
altri simboli. Nel caso di prima Σ={a,b}
N: insieme infinito di simboli non terminali. È l'insieme delle frasi valide di quel linguaggio.
S: appartiene a N, simbolo non terminale di partenza (frase base)
P: insieme delle produzioni (regole) che definiscono in modo ricorsivo tutti gli elementi di N in
funzione di Σ (e S). Le regole di P sono tutte della forma N::=(Σ U N)* . L'asterisco finale indica che è
una stringa (sequenza di simboli). Il simbolo di partenza S è la base della ricorsione: quel simbolo
che non ha bisogno della ricorsione per essere definito.
Nel nostro caso: S::=a b | aSb, N={S} . (notazione BNF)

26.2 Parsing di espressioni aritmetiche (semplificate)


Σ:{lettere dell'alfabeto (minuscole), +, *}
exp::= lettera | exp + exp | exp * exp

Grammatica ambigua: ci sono più modi per produrre la stessa stringa. Questa grammatica lo è
perché non c'è la regola per cui le moltiplicazioni vanno fatte prima delle somme. Si può renderla
non ambigua aggiungendo le parentesi:

Σ: {lettere dell'alfabeto minuscole, +, *, (, )}


exp::= lettera | (exp + exp) | exp * exp
In questo modo il + viene eseguito per primo solo se specificato tra parentesi, altrimenti vengono
eseguite prima le moltiplicazioni. Questo però comporta un uso massiccio delle parentesi. Posso
migliorare le cose in questo modo:

Σ: {lettere dell'alfabeto minuscole, +, *, (, )}


fatt::= lettera | (exp)
term::= fatt | fatt * term
exp::= term | term + exp

È un esempio di definizione ciclicamente ricorsiva: il fattore per essere generato richiede


l'espressione, l'espressione richiede il termine, il termine richiede il fattore. Il caso base della
ricorsione è la lettera.

Codice del parser: es_classe/parser.cpp

27. Incapsulamento delle informazioni nei tipi di dato


(information hiding)

L'information hiding è la tecnica per offrire a chi deve usufruire di librerie da noi scritte
un'interfaccia che nasconda la struttura interna dei tipi di dato. Finora abbiamo usato poco e
niente l'information hiding: al massimo abbiamo suddiviso il codice in moduli e header da
includere. In questo modo possiamo fornire librerie già compilate e fornire solo l'header .h da
includere: così chi utilizza la libreria ne conosce l'interfaccia, ma non il codice sorgente.
In un tipo di dato, le due informazioni fondamentali sono:

• come sono fatti i dati che vogliamo rappresentare


• quali operazioni posso svolgerci

In C++ possiamo incapsularle entrambe in un unico contenitore, specificando inoltre quali


informazioni saranno visibili a chi usa il tipo di dato. Le informazioni definite come private possono
essere utilizzate solo ai membri del tipo di dato.

27.1 Struct e class


Ci sono due tipi di contenitori atti a definire tipi di dato: le struct e le class. L'unica differenza tra le
due è che nelle struct i membri sono pubblici per default, nelle classi sono privati. Per il resto sono
la stessa identica cosa. Delle classi si fa un uso massiccio nella programmazione object oriented,
ma possono essere usate anche in programmazione imperativa.
Sintassi:

struct/class nome {

public:

//elenco dei membri pubblici: campi e metodi

private:

//elenco dei membri privati: campi e metodi

Se stiamo usando una struct, i membri definiti senza prima aver messo
un'indicazione public: o private: sono pubblici, in una classe sono privati.
Un tipo di dato in cui tutti i membri sono privati è assolutamente inutile: non ci possiamo eseguire
sopra nessuna operazione. Tipicamente si definiscono come membri privati le variabili interne e
tutto ciò che ha a che fare con la rappresentazione fisica del tipo di dato, mentre nella parte
pubblica vanno le operazioni che possiamo svolgere su quel tipo di dato. Ad esempio in una coda
saranno definite pubbliche le operazioni di enqueue, dequeue, isEmpty ecc, e saranno definiti
come privati l'array, o la lista, nella quale è contenuta la coda, e le eventuali altre variabili che ci
servono per operare su di essa

27.2 Campi e metodi


Campi: variabili interne al tipo di dato
Metodi: funzioni che operano direttamente sui membri della classe (più eventuali altri parametri).
Sono funzioni il cui scopo è operare sul tipo di dato nel quale sono implementate.
A campi e metodi di una struct si accede tramite la dot notation: se ho una classe T, e nel
programma dichiaro una variabile v di tipo T, per accedere a un campo (o invocare una funzione)
su quella variabile dovrò usare la sintassi v.campo o v.metodo(parametri).
I metodi di query sono metodi che non modificano nulla, ma forniscono informazioni sul
contenuto dei campi della classe. Se usiamo campi privati, sono l'unico modo per conoscere il loro
contenuto. I metodi di modifica invece servono a modificare i campi della classe. Tipicamente tutti
i campi di una classe sono privati, e per utilizzarli si usano metodi di query e di modifica. I nomi
standard per questi due metodi sono get e set.

È possibile implementare i corpi dei metodi al di fuori della dichiarazioni della classe. Se vogliamo
implementare un metodo m della classe T al di fuori della dichiarazione di T, nella dichiarazione
del metodo dobbiamo scrivere T::m. Stessa notazione dei namespace. In questo modo possiamo
fornire la dichiarazione della classe, con le dichiarazioni dei campi e i prototipi dei metodi, nel file
header .h, e mantenere il codice dei metodi nel file .cpp (che possiamo rilasciare già compilato, in
modo che chi usa la nostra libreria non possa leggerlo o modificarlo).
27.3 Esempio: tipo di dato orologio
Vediao ora come implementare un tipo di dato che rappresenta l’ora. Quando progettiamo un tipo
di dato tramite classi dobbiamo sempre chiederci cosa vogliamo rappresentare (i campi) e quali
operazioni vogliamo farci sopra (i metodi).

• Cosa voglio rappresentare: ore, minuti, secondi


• Quali operazioni voglio farci sopra:
 imposta ora
 sapere che ore sono
 stampare l'ora corrente
 spostare avanti l'ora di un secondo
 spostare avanti l'ora di un minuto
 spostare avanti l'ora di un'ora
 confrontare due orologi

Sapere che ore sono restituisce il valore corrente di ore, minuti e secondi, stampare l'ora corrente
le manda in output senza restituire niente al programma.

Codice:

class clockType {
public:
void setTime (int,int,int);
void getTime(int&,int&,int&) const; //se un metodo viene dichiarato const vuol dire
che non può modificare i campi della classe. Si usa per i metodi di query. Se nel codice di un
metodo const si modificano i campi della classe, il compilatore da errore.
void printTime() const; //non ha parametri perché deve agire solo con i campi della classe.
void incrementSeconds();
void incrementMinutes();
void incrementHour();
bool equalTime(const clockType&) const; //è una funzione const in quanto non
modifica i campi della classe. Il parametro, di tipo clockType, è passato per riferimento costante.
Questo metodo potrebbe essere implementato anche come funzione esterna alla classe, che fa lo
stesso lavoro prendendo però in input due clockType invece di uno solo.

private:
int hr;
int min;
int sec;
};

Questa è la dichiarazione della classe, quella che va messa nel file header .h. Ora vediamo
l'implementazione dei metodi

void clockType::setTime(int h, int m, int s)


{
if(0<=h && h<24) hr= h;
else hr=0;
if(0<=m && m<60) min=m;
else min=0;
if(0<=s && s<60) sec=s;
else sec=0;
}

void clockType::getTime (int& h, int& m, int& s)


{
h=hr;
m=min;
s=sec;
}

void clockType::printTime()
{
cout<<hr<<':'<<min<<':'<<sec<<endl;
}

void clockType::incrementSeconds()
{
sec++;
if(sec<60) return;
else
{
sec=0;
min++;
}
if(min<60) return;
else
{
min=0;
hr++;
}
if(hr<24) return;
else hr=0;
}

La incrementMinutes e la incrementHours sono uguali, solo che cominciano rispettivamente dal


primo e dal secondo else. Volendo potevamo anche chiamarle in incrementSeconds, invece di
scrivere del codice che verrà poi ripetuto nelle altre funzioni. In questo caso potremmo invocarle
come normali funzioni, senza dot notation o altre indicazioni di scope, poiché stiamo lavorando
all’intern della classe.

bool clockType::equalTime (const clockType& cc) const


{
if(hr!=cc.hr) return false;
if(min!=cc.min) return false;
if(sec!=cc.sec) return false;
return true;
}

Notare che i campi hr, min e sec sono privati: di norma non potrei accedervi direttamente in dot
notation come faccio qui. Ma dato che sto lavorando dentro la classe, i campi sono visibili anche se
appartengono a un parametro di funzione, dato che è dello stesso tipo della classe in cui sto
lavorando.
Stack implementato come classe: es_classe/stack_list
27.4 Costruttori e distruttori
Ci sono metodi particolari, detti costruttori, che servono a creare istanze della classe. Un'istanza è
una variabile del tipo creato dalla classe. Il costruttore viene invocato quando dichiariamo una
variabile del suo tipo, e si occupa di eseguire le operazioni necessarie ad avere una variabile
pronta all'uso (se ad esempio la nostra classe definisce una lista, sarà utile che il costruttore
imposti a NULL la testa della lista vuota).
Il costruttore ha lo stesso nome del tipo di dato in cui è definito, non ha tipo di ritorno e può
richiedere o meno parametri, a seconda delle operazioni che deve fare.
Nell’esempio di classe clockType non abbiamo definito un costruttore, quindi ogni volta che verrà
dichiarata una variabile di tipo clockType verrà usato il costruttore di default, che non svolge
nessuna operazione. Il suo prototipo è void clockType(); il suo corpo è {} (funzione
vuota) ed esiste sempre, anche se non lo dichiaro esplicitamente.
Se voglio che il mio costruttore faccia qualcosa, ad esempio impostare l'orologio sulla mezzanotte
ogni volta che dichiaro una variabile clockType, dovrò scrivermi un costruttore diverso.
Posso avere più di un costruttore, e avranno tutti lo stesso nome, basta che differiscano nei
parametri (questa grazie alla possibilità di overloading delle funzioni che offre il C++). Ad esempio,
potevo usare la setTime come costruttore che prende in input tre parametri interi e inizializza i
campi della nostra variabile clockType con quei valori. In questo modo avrei avuto due costruttori,
il costruttore di default void clockType(); che non inizializza i campi della classe, e il
costruttore void clockType(int,int,int) che inizializza i campi con i valori forniti. Per
utilizzare il costruttore di default basta dichiarare normalmente una variabile clockType t; per
usare invece il nostro costruttore bisogna usare la sintassi clockType t(h,m,s);.

I distruttori sono metodi che vengono invocati quando una variabile di classe cessa di esistere, ad
esempio quando usciamo dallo scope in cui era stata dichiarata. Quando usciamo dallo scope di
una variabile classe vengono invocati automaticamente tutti i distruttori per quella classe. I
distruttori hanno nome di default ~T(), dove T è il nome della nostra classe. Il distruttore di
default, usato quando non abbiamo definito un nostro distruttore, non svolge alcun compito. È
utile usare i distruttori quando usiamo strutture dati dinamiche per evitare memory leak. Nel
nostro esempio di stack ad esempio, se usciamo dallo scope in cui abbiamo dichiarato la variabile
stack, la lista implementata sullo heap non viene eliminata, ma non è neanche più accessibile
perché non abbiamo più puntatori per accedervi. Siamo quindi in memory leak, e per evitare ciò
dobbiamo scrivere un distruttore che deallochi lo spazio che abbiamo occuparo sullo heap.
A. Laboratorio 1

Ogni file sorgente.cpp viene prima pre elaborato dal preprocessore diventando un file sorgente.i
(eliminaziione delle direttive per il preprocessore di tipo #). Le librerie standard del C++ su Linux si
trovano in /usr/include .

Il compilatore prende in input il file sorgente.i (sorgente preprocessato privato delle direttive) e lo
compila trasformandolo in un file oggetto sorgente.o . I file oggetto sono composti dal codice
macchina del programma compilato. In caso di programmi modulari, i file .o sono i singoli moduli
compilati ma non direttamente eseguibile: i vari file oggetto saranno poi collegati dal linker in un
eseguibile unico

Percorso di un file sorgente durante la compilazione per diventare un programma eseguibile.

Il linker svolge anche un altro compito oltre al collegamento dei moduli: fa in modo che i vari
moduli del sorgente non abbiano conflitti per l'occupazione di memoria (ad esempio controlla che
in due moduli non siano state dichiarate due variabili con lo stesso nome che entrerebbero in
conflitto dirante l'esecuzione).
B. Laboratorio 4

B.1 Direttive per il preprocessore

#define <nome> <contenuto>: definisce un contenuto da associare ad un nome. Ad esempio #define N 10


ogni volta che nel codice sorgente trova N lo sostituisce con 10. Come tutte le direttive per il preprocessore
agisce a livello del file di testo contenente il sorgente, non a livello di compilazione o di codice oggetto. È
diverso dalla dichiarazione di una costante appunto oerché agisce sul codice sorgente, non riserva uno
spazio di memoria in cui tenere un valore.

#ifdef <nome> (o #ifndef <nome>): esegue un blocco di codice racchiuso tra l'#ifdef e l'#endif se <nome> è
stato precedentemente definito con #define. Al contrario #ifndef esegue il blocco solo se <nome> non è
stato definito. Queste direttive sono particolarmente utili per il debug e per evitare di includere più volte lo
stesso file. Anche questa operazione è solo a livello del sorgente: quando il simbolo specificato dall'#ifdef
non è stato definito, il preprocessore taglia le parti di codice racchiuse nell'#ifdef e non le passa al
compilatore.

B.2 Operatore virgola


scrivendo a=espr1,espr2; ottengo un'espressione combinata che ha sempre il valore dell'ultima espressione
valutata, in questo caso espr2. La modifica allo stato di un programma apportata dalla valutazione di
un'espressione si chiama effetto collaterale. Se ad esempio scrivo a=++i, 10+1; la variabile i viene
incrementata, ma ad a viene assegnato il valore 11 risultato della seconda espressione.

i++: prima ritorna l'attuale valore di i e poi lo incrementa.


++i: prima incrementa i e poi ne ritorna il valore.
I valori di queste due espressioni sono diversi, ma l'effetto collaterale è lo stesso,

B.3 Operatore condizionale


<espr1> ? <espr2> : <espr3>. Esempio: per trovare il massimo tra due valori.
maxval=V1>V2?V1:V2 Semantica: valuta l'espressione V1>V2: se è vera assegna V1 a maxval,
altrimenti assegna V2.

B.4 Generazione di numeri pseudocasuali


Innanzitutto è necessario includere la cstdlib (libreria del C). La funzione da usare è long int
rand(), restituisce un intero pseudocasuale compreso tra 0 e RAND_MAX (valore specificato
nel codice della funzione attraverso un #define). Questa funzione ha una sequenza ciclica di
numeri memorizzata. Per fare in modo che ad ogni esecuzione non mi restituisca la stessa serie di
numeri, è necessario fornire un seme attraverso la funzione void srand (int seed). Se
uso sempre lo stesso valore come seme però, otterrò sempre la stessa sequenza di numeri: per
avere una sequenza ogni volta diversa posso usare come seme data e ora attuali attraverso la
funzione clock_t clock();
(il tipo di dato clock_t è un tipo di intero). Per poter usare questa funzione devo includere la
libreria ctime. La funzione clock non conta il tempo in secondi, ma in tick, basati sulla frequenza di
clock del processore. La conversione da tick a secondi si effettua attraverso la macro
CLOCKS_PER_SEC.

t=clock();
s=t/CLOCKS_PER_SEC;

C. Laboratorio 5 (codice modulare)


Modulo: composto da due file:

• modulo.h interfaccia o header. Contiene la definizione di costanti e tipi di dato e i prototipi


delle funzioni. Contiene anche eventuali #include ad altri file o librerie
• modulo.cpp contiene il codice sorgente ero e proprio. Dovrà contenere anche un'include
del suo header. In ogni parte del programma possiamo includere solamente l'header, non il
corpo del modulo.

All'interno dell'header possiamo fare solo le dichiarazioni di funzioni e tipi di dato (typedef).
Typedef: serve a dare un nome a tipi di dato complessi o rinominare tipi base. Sintassi: typedef
<vecchio nome> <nuovo nome>, ad esempio typedef unsigned short int byte

Per compilare il codice modulare si usa l’opzione –c del compilatore: si compilano i vari moduli con
la sintassi g++ -c modulo1.cpp , in modo da ottenere per ogni modulo un file di codice
oggetto modulo.o , quindi si uniscono i vari moduli per creare un unico eseguibile con la sintassi
g++ -c main.o modulo1.o modulo2.o –o program

Potrebbero piacerti anche