Laboratorio di Calcolo 2

proff. A. Andreazza, D. Galli, E. Spoletini, G. Tiana, A. Vairo

Università degli Studi di Milano

Anno Accademico 2005/2006

Lezione 2

Introduzione

Scopo di questa sessione di laboratorio e' di fare pratica con la definizione di funzioni e delle tecniche per la misura dell'efficienza di diversi algoritmi. Infine verranno dati alcuni suggerimenti su alcuni approcci alla gestione di progetti in C++, tipici nei sistemi UNIX.

Questa sessione prevede:

  1. scrittura di un programma per la lettura ed il riordinamento di un vettore
  2. scrittura di una funzione per il riordinamento di un generico vettore
  3. valutazione del tempo di calcolo impiegato in funzione della dimensione del vettore
  4. paragone del vostro algoritmo di ricerca con altri algoritmi
  5. compilazione separata e makefile

L'esercizio

L'esercizio consiste nello scrivere un programma che legge dei numeri reali (max 100000), li immagazzina in un vettore, chiama una funzione che riordina il vettore ed infine stampa il vettore riordinato. La funzione dovra` essere scritta in un file a parte, in modo che si possa facilmente sostituire e compilare separatamente.

Poi dovrete sperimentare il programma sui dei file di diversa dimensione per verificare come il tempo impiegato per il riordinamento varia con la dimensione effettiva del vettore e stimare quindi l'ordine di complessita` dell'algoritmo.
Per chi ha tempo si potra' anche confrontare diversi algoritmi.

Infine vedremo come organizzare la compilazione nel caso di progetti complessi.

main

Scriveremo il main in un file ordinamenti.cxx.

La strutture del main potrebbe essere la seguente:


#include <iostream>

void ordina( int , float[] );

int main() {
   /* Dichiarazione delle variabili e dimensionamento del vettore di float */
   while ( cin >> v[i] ) { /* lettura dei dati ed inserimento nel vettore */
   }
   ordina(...); /* invocazione della funzione di ordinamento */
   /* stampa con del vettore dopo l'ordinamento */
   return 0; /* Bisogna fornire un valore di ritorno */
}
La seconda riga e' una dichiarazione della funzione ordina. Con essa indichiamo al compilatore che ordina e' una funzione che ha come primo argomento un intero e come secondo argomento un vettore di float. Essa restituisce un valore intero. Una dichiarazione dice come una funzione appare, non come e' fatta. Il compilatore ha bisogno solo di questa informazione, per il momento.

Per una dichiarazione sono importanti solo i tipi degli argomenti e del valore di ritorno. Tuttavia dare un nome agli argomenti puo` aiutare l'utente a capire cosa attendersi dalla funzione. Ad esempio la riga potrebbe essere anche scritta esplicitamente come:
int ordina(int NumeroDiComponenti, float vettore[]);

ed in tal caso si capisce cosa indicano entrambi gli argomenti. Ma anche la piu' semplice:
int ordina(int N, float v[]);

e' gia' abbastanza autoesplicativa.

Una volta scritto il programma principale possiamo provare a compilarlo. Non possiamo pero' fornire un eseguibile, perche' ci manca un'implementazione della funzione ordina (ovvero qualcosa che dica al compilatore come tale funzione e' fatta). Quindi dobbiamo fermarci allo stadio di semplice compilazione del g++:
g++ -c ordinamenti.cxx
che crea un file oggetto, ordinamenti.o.

Una costruzione importante

Nel programma dovrete essere in grado di leggere un numero indeterminato di righe dallo standard input o da un file. Per fare questo si puo' usare un ciclo while del tipo:

while ( cin>>v[i] ) {
/* codice da ripetere per ogni riga */
Analizziamo per un momento cosa vuol dire questa espressione.

In C++ tutto e' una funzione ed ogni funzione ha un suo valore di ritorno (al limite di tipo void).

Quando usiamo l'operatore >> in realtà stiamo invocando una funzione che effettua le seguenti operazioni:

  1. legge da cin un valore e lo inserisce nella variabile v[i];
  2. restituisce come valore di ritorno cin, oppure 0 se l'operazione di lettura fallisce.

Quindi, dopo aver valutato l'espressione cin<<v[i], il programma riceve il valore di ritorno che, se la lettura e` riuscita e` cin, diverso da 0 e quindi vero, secondo le convezioni del C. Se la lettura non e` riuscita, invece e` 0, quindi falso.

Ora, noi vogliamo andare avanti a leggere il file fin tanto che e` possibile, e, quando la condizione diventa falsa perche' si e` raggiunta la fine del file o si e` incontrato un errore, terminare la lettura e proseguire con le operazioni successive del programma.

La costruzione mostrata quindi fa quello di cui abbiamo bisogno ma con una struttura abbastanza compatta, utile nel caso di letture di dati molto semplici.
Come esercizio, provate a scrivere lo stesso ciclo in una forma equivalente, magari con piu` istruzioni, ma piu` leggibile, usando i vari metodi degli stream (eof(), good(), bad()...).
E' molto utile avere bene in mente come usare questa costruzione e strutture affini, dato che risultano utilizzabile nel 90% dei temi d'esame.

Nota Bene:

Per generare un carattere di fine del file dal terminale, bisogna premere la combinazione di tasti Ctrl-D.

La funzione ordina

Per prima cosa osserviamo che un'operazione molto frequente in un algoritmo di ordinamento sono degli scambi di posizione di alcuni elementi del vettore. Quindi e` utile realizzare una funzione che effettui questa operazione. Essa puo` essere messa in un file a parte. Questo perche' dopo andremo ad utilizzare diversi algoritmi di riordinamento e, avere la funzione in un file separato ci permettera` piu` facilmente di riutilizzare il codice: creiamo un file scambia.cxx, contenente la seguente funzione:

void scambia( float& a, float& b ) {
  float temp;
  temp=a;
  a=b;
  b=temp;
}
Si noti che gli argomenti della funzione sono dei riferimenti a float e non semplicemente dei float. Questo perche' abbiamo bisogno che lo scambio venga realizzato sui dati originali e non sulla copia locale prodotta dalla funzione. Anche questa puo` essere precompilata con:

g++ -c scambia.cxx

Usando l'approccio di tenere funzioni separate, conviene definire un header file scambia.h con la dichiarazione della funzione:


void scambia( float&, float&);
A questo punto possiamo passare all'implementazione della funzione ordina. Scriviamola in un file che si chiami algoritmo.cxx. La sua struttura sara` del tipo:

#include "scambia.h"

void ordina(int N, float v[]) {
 /*
  * codice e manipolazione di v
  */
}
A questo punto, all'interno della funzione, N e v sono oggetti noti, con il loro corretto valore e non c'e` bisogno di inizializzarli ne' di dichiarli. Gli elementi del vettore v possono essere indirizzati nel modo usuale: v[0], v[1], ... v[N-1]. Nella vostra funzione ci saranno probabilmente dei cicli for per accedere agli elementi di v:
  for (int i=0; i<N; i++) { ... }
e degli scambi dove opportuno.

Anche questo file, da solo non puo` dare un eseguibile, quindi anche in questo caso bisognera` fermare il g++ dopo la compilazione:
g++ -c algoritmo.cxx
che crea un file algoritmo.o.

La costruzione dell'eseguibile

Per costruire l'eseguibile bisogna mettere insieme tutte le parti. Al g++ e` possibile fornire direttamente dei file oggetto. Il g++ in tal caso effettua solo l'ultimo stadio della compilazione, ovvero l'invocazione del linker
g++ -o algoritmo ordinamenti.o scambia.o algoritmo.o
Potremmo anche rifare la compilazione dall'inizio con
g++ -o algoritmo ordinamenti.cxx scambia.cxx algoritmo.cxx
ma questo e' un po' uno spreco.

Test del programma

Una volta riusciti a compilare il programma senza errori, procedete a verificarne la funzionalita` inserendo manualmente dei valori e controllando che questi vengano ordinati correttamente.
Quando siete sicuri che il programma funziona potete fargli leggere dei dati da file potete scaricare file gia` pronti con 10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000, e 100000 valori.

Per far leggere i file al programma, non e` necessario modificarlo, ma basta ricordarsi l'utilizzo degli operatori di ridirezione > e <:

Quindi una riga come:
nomeprogramma < nomefileinput > nomefileoutput
leggera` il file nomefileinput e scrivera` i risultati nel file nomefileoutput.

Tempo di esecuzione

Per prima cosa si può verificare che il programma funzioni correttamente, inserendo un piccolo numero di dati a mano. Successivamente si possono utilizzare i file file#####.dat forniti sopra. Gli algoritmi di ordinamento di solito hanno un tempo di esecuzione che cresce piu` che linearmente con la quantita` di dati. Anzi, dato il vostro algoritmo, come vi aspettate che il suo tempo di esecuzione cambi con l'aumento dei dati?

time

Il programma time:
time comando
permette di avere un riassunto del tempo utilizzato per eseguire comando. Siccome comando puo` contenere gli operatori di ridirezione, potete leggere l'input da file e scaricare l'output su un altro file. In esempio di uscita di time e`:
./algoritmo < file10000.dat > file10000ord.dat   0.12s user 0.00s system 81% cpu 0.147 total
Sono indicati in secondi, sia il tempo totale (0.147 total) che quello effettivo di CPU usanto dall'utente (0.12s user). Questi possono essere molto diversi, visto che la CPU del calcolatore potrebbe essere impegnata a fare altro oltre ad eseguire il vostro programma.

Verificate come si comporta il tempo di CPU variando il numero di dati da 10000 a 100000 e provate a vedere se il comportamento corrisponde a quanto ci si può attendere intuitivamente. Per fare una previsione, ragionate su come scala il numero di confronti da fare in base alla dimensione del vettore originale. Nella maggior parte degli algoritmi che ci si puo` inventare senza ragionarci troppo su, si vede che questo numero e` O(N2)

gprof

Il difetto di time è che non permette di vedere i dettagli delle singole funzioni, ma a noi non interessa il tempo necessario per eseguire il nostro programma, ma solo quello richiesto dalla funzione ordina (ovvero dal nostro algoritmo di ordinamento). Per avere questo livello di dettaglio (profiling) si può utilizzare il programma gprof, che pero` richiede di modificare l'eseguibile, ponendo l'opzione -pg ad ogni passo della compilazione:
g++ -pg -c ordinamenti.cxx
g++ -pg -c algoritmo.cxx
g++ -pg -c scambia.cxx
g++ -pg -o algoritmo ordinamento.o algoritmo.o scambia.o
Se adesso il programma algoritmo viene eseguito normalmente con
./algoritmo < inputfile > outputfile
oltre al normale file di output, viene creato uno speciale file gmon.out che contiene le informazioni di profiling. Questo file puo` essere interpetato dal comando gprof,
gprof ./algoritmo gmon.out
che crea una serie di tabelle che forniscono per ogni funzione quante volte e` stata chiamata, il tempo impiegato in essa: un esempio di tabella e` riportato qui di seguito:
Flat profile:

Each sample counts as 0.01 seconds.
  %   cumulative   self              self     total           
 time   seconds   seconds    calls  ms/call  ms/call  name    
100.00      0.06     0.06        1    60.00    60.00  ordina(int, float*)
  0.00      0.06     0.00   393323     0.00     0.00  scambia(float&, float&)
  0.00      0.06     0.00        1     0.00     0.00  _GLOBAL__I_main
  0.00      0.06     0.00        1     0.00     0.00  __static_initialization_and_destruction_0(int, int)
La tabella viene riportata all'inizio dell'output di gprof e quindi puo` essere necessario scrollare all'indietro per recuperarla.

La colonna cumulative seconds da` il tempo impiegato nella funzione e nelle sottofunzioni da essa chiamate. La colonna self seconds da` il tempo impiegato solo nella funzione stessa. Sono poi indicate il numero totale di chiamate ed il tempio medio impiegato per rispondere ad una chiamata.

Siccome gprof e` piu` accurato di time, ripetete l'ultimo passo del punto precedente.

Un po' d'ordine: il makefile

Qual e` il vantaggio che abbiamo ottenuto nel dividere il programma in diversi file? Per prima cosa, se dobbiamo cambiare qualcosa nel formato dell'output o nell'algoritmo di ordinamento, ci basta modificare solo quella funzione, senza doverci rileggere tutto il programma. In piu`, dopo la modifica, non dobbiamo ricompilare tutto, ma solo il file modificato. Infine, se un nostro amico ci chiede di provare la nostra funzione, non dobbiamo passargli tutto il programma, ma solo un file.

Questi vantaggi pero` hanno come prezzo una maggiore complessita` della procedura di compilazione.

Con tre soli file, sia i vantaggi che gli svantaggi sono minimi, ma non appena si ha a che fare con un progetto che contiene anche solo una decina di funzioni, entrambi gli aspetti diventano significativi e la tecnica per superare parzialmente gli svantaggi della piu` complessa procedura di compilazione e` quella di utilizzare un makefile.

Il makefile e` un normale file di testo (potete crearlo con nedit, l'importante e`che abbia esattamente questo nome). Esso contiene l'informazione di cosa e` necessario fare per compilare ogni parte del programma, codificata in un insieme di regole per creare dei file a partire da altri. Nel nostro caso il makefile sara` fatto cosi':


ordinamenti.o : ordinamenti.cxx
	g++ -c -pg ordinamenti.cxx

scambia.o : scambia.cxx
	g++ -c -pg scambia.cxx

algoritmo.o : algoritmo.cxx
	g++ -c -pg algoritmo.cxx

algoritmo : ordinamenti.o scambia.o algoritmo.o
	g++ -o algoritmo -pg ordinamenti.o scambia.o algoritmo.o
Esso e` costituito da delle righe che indicano i target, ovvero gli oggetti da construire, seguiti da un carattere di : ed una lista di dipendenze, ovvero file necessari per costruire un certo target.
Dopo ogni riga che indica target e dipendenze, ci sono delle righe di regole che elencano le istruzione necessarie per costruire il target a partire dalle e dipendenze. Queste righe di regole sono identificate dal fatto che iniziano con un carattere di tabulazione (non vanno bene dei normali spazio bianchi: DEVE essere un carattere di tabulazione).

Una volta scritto il makefile, il comando
make target
cerca nella directory corrente tale file, al suo interno cerca il target richiesto ed esegue le regole (possono anche esserci piu` righe di regole per un target, ma una sola riga di target:dipendenze) ad esso associate. Il make controlla anche che i file da cui dipende il target siano aggiornati e, se non lo sono, provvede ad invocare le regole che servono a ricrearli e cosi' via.
Nel nostro caso, se dopo aver compilato algoritmo, modificassimo una riga di ordinamenti.cxx, allora per ricreare la nuova versione di algoritmo, bastera` fare:
make algoritmo
ed il make provvedera` a ricompilare ordinamenti.o, ma non algoritmo.o perche` non e` necessario, e poi a creare di nuovo l'eseguibile.

In tal modo una modifica al codice del programma si propaga nel modo che richiede il minimo sforzo di compilazione.

Provate a costruire il makefile ed a verificare quanto detto.

Altri algoritmi

La creazione del makefile completa la parte obbligatoria dell'esercizio. Chi ha tempo puo` procedere ad un passo successivo, il confronto tra diversi algoritmi: potete scaricare alcuni file contenenti diverse implementazioni della funzione ordina: Modificate il makefile in modo da poter creare degli eseguibili con queste funzioni. Non e` necessario cancellare niente di quello che avete gia` scritto, basta aggiungere dei nuovi target per compilare i file .o degli algoritmi e per creare degli eseguibili con dei nomi adeguati, in modo che lo stesso makefile permetta di costruire diversi eseguibili.

Verificate che l'uscita dei diversi programmi sia identica. Per confrontare due file, basta dare il comando:
diff file1 file2
se i due file sono identici non c'e` nessun output, se differiscono, la lista delle differenze viene presentata.

Qual e` il programma piu` efficiente? Dovreste accorgevi che quicksort ha una complessita` pari a NlogN.
Come si compara il vostro algoritmo a bubblesort o a simple che sono entrambi di complessita` N2?
Riuscite a capire come funziona quicksort?

Relazione

Come al solito, prima di lasciare l'aula, siete pregati di riempire un piccolo formulario con domande relative allo svolgimento dell'esercizio.