Documenti di Didattica
Documenti di Professioni
Documenti di Cultura
2012/2013
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
• 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.
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:
ibrido hardware-firmware
• Logica circuitale {
• Elettronica {
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
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.
La macchina di von Neumann ha quattro stati, che vengono ripetuti in ciclo finché non viene
eseguita l'istruzione 8 (arresto):
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.
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'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)
• 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.
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.
• 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
Sequenza di input:
Output: l'output di questo programma è la messa in esecuzione del programma ricevuto in input
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 //" " " "
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.
In A:
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.
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})
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}
Codifica e decodifica servono per poter rappresentare qualunque dominio finito in simboli
comprensibili alla macchina.
Codifica posizionale a lunghezza fissa
Se X ha M elementi per codificarli in binario dovrò usare codici di almeno log2M cifre.
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.
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.
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.
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.
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 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:
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.
• 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
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.
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)
• 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.
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
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.
• singoli caratteri
• stringhe di caratteri
• numeri
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.
• 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.
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.
Esempio:
int a,b,c;
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.
Sintassi:
if (<exp1>)
<blocco1>
else if(<exp2>)
<blocco2>
else if.....
Sintassi:
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>
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>)
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.
Semantica: ripeti <blocco> con <var> che assume in sequenza i valori <val1>, <val2>,....,<valn>
a=a+1;
In C:
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>
}
7. Struttura dei programmi C/C++
<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.
int main()
float a,b;
cin>>b;
return 0;
Variante:
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.
Programma che fa la somma di due numeri finché non gli viene dsto uno zero in input:
#include <iostream>
int main()
int a,b;
while(true)
cout<<"Dammi un numero\n";
cin>>a;
if(a==0) break;
cin>>b;
if(b==0) break;
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>
int main()
int a,b;
cout<<"Dammi un numero\n";
cin>>a;
if(a==0) break;
cin>>b;
if(b==0) break;
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;
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.
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>
int main()
int a,b;
while(true)
cout<<"Dammi un numero\n";
cin>>a;
if(a==0) break;
cin>>b;
if(b==0) break;
cout<<"Ciao!\n";
return 0;
if(a%2==0) cout<<a+b<<endl;
else cout<<a*b<<endl;
.
Sintassi:
9. Funzioni
In C/C++:
| | |
Nel momento in cui scrivo questo codice sto definendo una funzione.
Esempio:
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.
float acc=1.0;
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.
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.
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.
• 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:
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.
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.
10. Memoria
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:
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.
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.
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.
Esempio:
float v[MAXN];
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.
.
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.
oppure
Codice:
if(n==MAXN) return 1;
a[n]=x;
return 0;
}
11.2.2 Eliminazione di un elemento in coda
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.
Passaggi:
1. scandisco dal fondo finché trovo elementi maggiori di x, spostandoli di una posizione in
avanti ogni volta
2. inserisco x e incremento n
if(n==MAXN) return 1;
int i=0;
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):
Un'altra implementazione:
int i;
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 .
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
• 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:
while(sx<=dx)
m<-(sx+dx)/2;
if(x>a[m]) sx<-m+1;
else dx<-m-1
return trovato;
13. Complessità computazionale
Codice dell'algoritmo:
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:
i++; //costo n
}
return false; //costo 1
T(n)=3(n+1)
for i=1,2,....,n-1
temp<-a[i];
j<-i;
a[j+1]<-a[j];
j+;
a[j+1]<-temp;
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)
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).
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];
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).
posmin=i;
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
while(s<=d)
m=(s+d)/2;
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).
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)
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)
int m=n/2;
{
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).
int j;
if(s[i+j]!=s1[j]) break;
return false;
{
int minIndex; int minValue; int buf;
minIndex=i;
minValue=v[i];
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
• 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.
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:
Orientamento tabella:
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:
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.
Serviranno:
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.
• 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
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.
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.
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.
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:
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:
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.
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.
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
q.a[(q.fs+q.sz)%MAX]=x;
q.sz++;
return OK;
Error dequeue(Queue& q)
{
q.fs=(q.fs+1)%MAX;
q.sz--;
return OK;
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.
Esempio:
int * p;
p=new int;
Stato della memoria dopo aver eseguito allocazione e assegnazione
struct Stack
elem n;
elem * a;
s.n=0; s.c=MINCAPACITY;
s.a=new elem[s.c];
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.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.
L'operatore at controlla se il valore specificato è compreso nelle dimensioni del vector, l'operatore
[] no.
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.
• è 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:
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
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.
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.
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.
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
22.7.2 Cancellazione
Casi possibili:
22.7.3 Unione
Posso implementarla in diversi modi:
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.
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 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:
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;
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.
Il principio di induzione si può usare non solo come strumento di dimostrazione ma anche come
strumento per definire degli oggetti.
• base: 1!=1
• passo: per ogni n>1 n!=n*(n-1)!
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.
int fatt(int n)
if(n==0) return 1;
return n*fatt(n-1);
Questa funzione è più complessa di quella per il calcolo del fattoriale perché ha due chiamate
ricorsive.
23.4 Liste implementate ricorsivamente
Possiamo scrivere una funzione ricorsiva member (list l, elem x) per la ricerca all'interno
della lista:
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.
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++
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
l1=rest(l);
reverse(l1);
append(l1,first(l));
}
Implementazione in C++:
void reverse(list& l)
list aux=l->next;
reverse(aux);
l->next->next=l;
l->next=NULL;
l=aux;
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:
Implementazione in C++:
int med=(inf+sup)/2;
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:
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.
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)
mergesort(seq1);
mergesort(seq2);
k++;
while(i<p) {
while(j<q) {
if(inf>=sup) return;
ms(s,inf,med);
ms(s,med+1,sup);
merge(s,inf,med,sup);
ms(s,0,s.size())
k++;
printlist (list l)
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).
Chiamata:
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
int med=(inf+sup)/2;
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ì:
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
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
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.
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.
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:
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
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);
else
w[k]='0';
gd(k+1,w);
w[k]='1';
gr(k+1,w);
}
gr(int k, char w[] IN-OUT)
else
w[k]='1';
gd(k+1,w);
w[k]='0';
gr(k+1,w);
Dimostrazione:
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:
Σ: 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)
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:
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:
struct/class nome {
public:
private:
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
È 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).
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::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;
}
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
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
#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.
t=clock();
s=t/CLOCKS_PER_SEC;
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