Scopo di questa sessione di laboratorio è di ripassare l'utilizzo del compilatore e delle chiamate a funzioni. Verrà data particolare enfasi allo strutturare i programmi in modo da separarne le varie componenti e favorirne il riutilizzo.
Verificheremo anche l'ordine di complessità di alcuni algoritmi di ordinamento.
In questa sessione impareremo:
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 che effettua il riordinamento dovrà essere scritta in un file a parte, in modo che si possa compilare separatamente e costituire la base di una libreria che contenga diversi algoritmi di ordinamento.
Poi dovrete sperimentare il programma sui dei file
di diversa dimensione e misurare il tempo impiegato per il
riordinamento di vettori dimensione diversa. Dalla dipendenza osservata,
si può stimare l'ordine di
complessità dell'algoritmo, ovvero con che legge il tempo di esecuzione
aumenta all'aumentare della dimensione del problema. Possiamo anticipare che
gli algoritmi di ordinamento più elementari di solito hanno ordine di
complessità N2, mentre quelli più performanti hanno ordine
NlogN.
Libreria di algoritmi di ordinamento
Ogni volta che dobbiamo creare un insieme di funzioni che vogliamo poter
riutilizzare, ci conviene separare il codice in due parti:
#include "ordinamenti.h"
void scambia( double a, double b ) {
double temp;
temp=a;
a=b;
b=temp;
}
da inserirsi nel file ordinamenti.cxx.
Mentre nell'header file va messa la sua dichiarazione:
void scambia( double, double);che indica come la funzione scambia richieda come argomenti due numeri double, ma non restituisce alcun valore.
Per vedere come si comporta, dobbiamo costruire un programma principale per provare questa funzione, ad esempio un file provascambia.cxx contenente un programma principale molto semplice:
#include <iostream>
#include "ordinamenti.h"
using namespace std;
int main() {
double a, b;
cout << "Primo numero?" << endl;
cin >> a;
cout << "Secondo numero?" << endl;
cin >> b;
scambia(a,b);
cout << "Primo numero: " << a << endl;
cout << "Secondo numero: " << b << endl;
return 0;
}
Si noti che, per poter utilizzare la funzione scambia nel main,
abbiamo dovuto inserire la sua dichiarazione attraverso la direttiva
#include "ordinamenti.h"
A questo punto si può compilare il programma ed eseguirlo:
g++ -o provascambia provascambia.cxx ordinamenti.cxx ./provascambiae... scoprire che non funziona!
Il motivo di tale comportamente è che la funzione scambia ha come argomento due valori, copie iniziali del contenuto delle variabili originali. Questi vengono scambiati all'interno della funzione, ma le variabili orginali non vengono intaccate.
Se vogliamo scambiare effettivamente le variabili originali, dobbiamo passare invece dei valori, delle referenze alle locazioni di memoria in cui questi valori sono immagazzinati. Questo si puo` fare in C++ in due modi:
| usando dei puntatori | usando dei riferimenti |
|---|---|
| Sorgente | |
void scambia( double* a, double* b ) {
double temp;
temp=*a;
*a=*b;
*b=temp;
}
|
void scambia( double& a, double& b ) {
double temp;
temp=a;
a=b;
b=temp;
}
|
| Header | |
void scambia( double*, double*); |
void scambia( double&, double&); |
| Invocazione da provascambia.cxx | |
scambia(&a,&b); |
scambia(a,b); |
Si noti la piccola differenza di sintassi tra le due implementazioni: esse sono praticamente equivalenti, ma l'utilizzo dei riferimenti invece che dei puntatori ha una forma molto più simile a quella del normale passaggio di una variabile. L'utilizzo dei puntatori inoltre richiede di cambiare la sintassi del programma principale, usando l'operatore & (indirizzo di). Questa modifica non è invece necessaria se si usano dei riferimenti.
In generale è meglio passare riferimenti e limitarsi ai puntatori solo nel caso si voglia accedere a di vettori (come per l'algoritmo di ordinamento). Nel seguito scegliete una delle due sintassi e proseguite l'esercizio, utilizzandola in maniera coerente.
void ordina(int, double*);Con essa indichiamo al compilatore che ordina è una funzione che ha come primo argomento un intero e come secondo argomento un puntatore a double. Essa non restituisce alcun valore (void).
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:
void ordina(int NumeroDiComponenti, double *vettore);ed in tal caso si capisce cosa indicano entrambi gli argomenti. Ma anche la più semplice:
void ordina(int N, double v[]);è già abbastanza autoesplicativa (si noti che dal punto di vista del linguaggio C++ i tipi double* e double[] sono la stessa cosa).
A questo punto possiamo passare all'implementazione della funzione ordina, scrivendola nel file ordinamenti.cxx. La sua struttura sarà del tipo:
void ordina(int N, double *v) {
/*
* codice e manipolazione di v
*/
}
All'interno della funzione, N
e v
sono oggetti noti, con il loro corretto valore e non c'è bisogno
di inizializzarli né di dichiarli. Gli elementi del vettore v
possono essere indirizzati nel modo usuale: v[0],
v[1],
...v[N-1]. Nella nostra
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.
La strutture del main potrebbe essere la seguente:
#include <iostream>
#include "ordinamenti.h"
using namespace std;
int main() {
/* Dichiarazione delle variabili e dimensionamento del vettore di double */
while ( cin >> v[i] ) {
/* lettura dei dati ed inserimento nel vettore, terminando il ciclo
alla fine dei dati in ingresso */
i++;
}
ordina(...);
/* stampa del vettore dopo l'ordinamento */
return 0; /* Bisogna fornire un valore di ritorno */
}
while ( cin >> v[i] ) {
...
}
Analizziamo per un momento come funziona questa costruzione.
In C++ tutto è una funzione, anche la semplice espressione
cin >> v[i]è una funzione che ritorna un riferimento all'oggetto cin. Tale riferimento diventa falso se nell'operazione di lettura si incontra un errore o la fine del file.
Nota Bene: l'oggetto cin sa di aver raggiunto la fine del file solo dopo aver tentato di leggere l'oggetto successivo all'ultimo. Quindi l'uscita dal ciclo deve essere fatta immediatamente dopo la lettura, prima di fare ogni operazione che assuma che la lettura sia stata effettuata correttamente (come aumentare di uno il numero di oggetti letti per esempio).
È molto utile avere bene in mente come usare questa costruzione e strutture affini, dato che compaiono spesso nei temi d'esame.
A questo punto compiliamo il programma:
g++ -o provaordinamenti provaordinamenti.cxx ordinamenti.cxxe verifichiamo che funzioni correttamente la parte di input/output: facendo girare il programma, inseriamo un po' di dati e vediamo che al termine dell'input vengono stampati correttamente sullo schermo, sopo l'ordinamento.
Nota Bene:
Per generare un carattere di fine del file dal terminale,
bisogna
premere la combinazione di tasti Ctrl-D.
Misura del tempo di esecuzione
Una volta riusciti a compilare il programma senza errori e
verificatane
la funzionalità, inserendo manualmente dei valori e
controllando che questi vengano ordinati correttamente,
Si può procedere alla fase finale dell'esercizion..
Quando siete sicuri che il programma funziona, potete fargli leggere dei dati da file. Potete trovare dei file già pronti in /home/comune/lab2/datafiles, con il nome fileN.dat, dove N indica il numero di elementi nel file.
Per far leggere i file al programma, non è necessario modificarlo, ma basta ricordarsi l'utilizzo degli operatori di ridirezione > e <:
time ./provaordinamenti < file10000.dat > file10000ord.datun possibile output è:
./provaordinamenti < file10000.dat > file10000ord.dat 0.92s user 0.00s system 99% cpu 0.926 totalSono indicati in secondi, sia il tempo totale (0.926 total) che quello effettivo di CPU usato dall'utente (0.92s user). Questi possono essere molto diversi, visto che la CPU del calcolatore potrebbe essere impegnata a fare altro oltre ad eseguire il vostro programma.
Nota bene: il numero a cui siamo interessati è il tempo allocato all'utente (user).
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.
Provate a mettere le coppie di valori numero di punti e tempo di esecuzione in un file, e poi utilizzare i valori raccolti per disegnare un grafico di ROOT e poi provare a fare un fit polinomiale.
Il seguente programma permette di costruire e disegnare un grafico, sia fornendo i punti ad uno ad uno da terminale, che leggendo i dati in un file:
#include <iostream>
#include "TApplication.h"
#include "TGraph.h"
using namespace std;
// Questo programma crea un grafico leggendo delle
// coppie di valori x ed y dallo standard input o
// da un file.
int main(int argc, char* argv[]) {
TApplication myApp("myApp",0,0);
TGraph *myGraph;
if ( argc==1 ) {
// nessun argomento da linea di comando:
// inserire i punti dallo standard input.
// (Ctrl-D per terminare)
double x, y;
int i=0;
myGraph = new TGraph();
while ( cin >> x >> y ) {
myGraph->SetPoint(i,x,y);
i++;
}
} else if ( argc==2 ) {
// come primo argomento viene dato il nome di un file:
// crea il grafico in base ai dati nel file.
myGraph = new TGraph(argv[1]);
} else {
// se il numero di argomenti è diverso da 1 o 2,
// stampa l'informazione su come usare il programma.
cerr << "Usage: " << argv[0] << " [nomefile]" << endl;
return -1;
}
myGraph->Draw("AL*");
myApp.Run();
return 0;
}
Svolgete i seguenti punti:
Nota bene: per avere un riassunto degli oggetti principali di ROOT e dei relativi metodi,
potete consultare la tabella riassuntiva
Compilazione parziale
Nella maggior parte degli esercizi dell'anno passato, si procedeva scrivendo tutto in un file e compilando direttamente l'eseguibile, ma l'aver tenuto separati i file, ci permette di usare una strategia diversa per arrivare a costruire un eseguibile.
Per fare questo però ci conviene ripassare come funziona la compilazione di un programma.
Il preprocessore di fatto effettua delle sostituzioni
letterali
all'interno del programma, definite da alcune direttive, riconoscibili
perche' iniziano con un #.
La direttiva #include inserisce nel file in corso di
compilazione il testo di un altro file, di solito un file che contiene
le dichiarazioni delle funzioni da usare (come iostream
per esempio).
Invece una direttiva
#define PIGRECO 3.1415196
sostituisce tutte le sequenze di caratteri P,I,G,R,E,C,O
incontrate nel testo con la sequenza di caratteri 3,.,1,4,1,5,1,9,6,
e risulta un modo pratico di inserire delle costanti.
Il #define
puo' anche venire usato per definire delle sostituzioni contenti
delle parti variabili, ad esempio
#define
FUNC(A) sin(A)/A
sostituisce espressioni come FUNC(xyx)
con sin(xyz)/xyz.
Questa è una direttiva molto utile, ma anche subdola: FUNC(x+y)
da' il risultato che vi aspettate? Come fare per avere il
risultato corretto?
Il compilatore trasforma il codice che abbiamo scritto in
una
sequenza di istruzioni comprensibili per la macchina (file oggetto). A
volte può essere utile terminare la compilazione dopo questa
fase,
utilizzando l'opzione -c del compilatore:
g++ -c -o nomefileoggetto nomefilesorgente
Se il compilatore non riesce a capire che istruzioni generare
perché
il file sorgente non rispetta le regole del C++, esso fornisce un errore
di sintassi e non crea il file oggetto. Il testo degli errori di
sintassi
è molto esplicativo. In più il compilatore dice sempre a
quale riga del file sorgente è presente un errore. Tranne nel
caso
in cui ci si sia dimenticati un ; al termine di una riga, nel qual caso
l'informazione può anche risultare piuttosto criptica.
Il linker si preoccupa di aggiungere l'informazione di tutte
le funzioni utilizzate dal file oggetto e non implementate
esplicitamente
al suo interno. Il linker viene chiamato se si invoca il g++ senza
l'opzione
-c:
g++ -o nomeeseguibile [lista file sorgente]
[lista file oggetto] -lfilelibreria1 -lfilelibreria2...
Il comando g++
prevede molte opzioni, che si possono mettere tutte prima del -o. Una particolarmente consigliata
e' l'opzione -Wall che scrive un sacco di
messaggi
su tutte le cose che, sebbene perfettamente legali in C++, suonano un po'
strane al compilatore. Questi messaggi chiamati warnings sono
molto
utili per mettere in evidenza al momento della compilazione molti errori
di logica.
Anziché costruire direttamente l'eseguibile come indicato in precedenza, potremmo svolgere singolarmente i passi indicati, creando dapprima tutti i file oggetto e poi facendone il link:
g++ -c ordinamenti.cxx g++ -c provaordinamenti.cxx g++ -o provaordinamenti provaordinamenti.o ordinamenti.o
Qual è il vantaggio che abbiamo ottenuto nel dividere il programma in diversi file e compilarli separatamente?
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 più, dopo la modifica, non dobbiamo ricompilare tutto, ma ricreare il file oggetto solo della funzione modificata e ripetere la procedura di link che solitamente è molto più veloce della compilazione.
Infine, se un nostro amico ci chiede di provare la nostra funzione, non dobbiamo passargli tutto il programma, ma solo il file che la contiene.
Questi vantaggi però hanno come prezzo una maggiore complessità della procedura di compilazione.
Con due 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. Se qualcuno di voi ha provato a compilare a casa il sorgente di ROOT, si sara` reso conto che la compilazione intera del pacchetto richiede parecchie decine di minuti, mentre ricompilare solo un file dopo una modifica è questione di decine di secondi.
Per ovviare almeno parzialmente agli svantaggi della più complessa procedura di compilazione, la soluzione standard e` quella di utilizzare un makefile.
Il makefile è un normale file di testo (potete crearlo con nedit, l'importante è che abbia esattamente questo nome). Esso contiene l'informazione di cosa è necessario fare per compilare ogni parte del programma, codificata in un insieme di regole per creare dei file a partire da altri. In pratica potete pensarlo come una sorta di blocco di appunti in cui vi scrivete come si fa a produrre il risultato finale attraverso tutti i passi intermedi.
Nel nostro caso il makefile sarà fatto così:
ordinamenti.o : ordinamenti.cxx g++ -c ordinamenti.cxx provaordinamenti.o : provaordinamenti.cxx g++ -c provaordinamenti.cxx provaordinamenti : provaordinamenti.o ordinamenti.o g++ -o provaordinamenti provaordinamenti.o ordinamenti.oEsso è 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.
Una volta scritto il makefile, il comando
make target
di fatto va a leggere il nostro "blocco degli appunti" ed esegue le
istruzioni che gli abbiamo dato:
Nel nostro caso, se dopo aver compilato provaordinamenti,
modificassimo una riga di ordinamenti.cxx, allora per
ricreare
la nuova versione di provaordinamenti, basterà fare:
make provaordinamenti
ed il make provvederà a ricompilare
provaordinamenti.o, ma non ordinamenti.o
perché non è 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.
Per confrontare il vostro algoritmo con altri, potete scaricare qui di seguito alcuni file contenenti diverse implementazioni di algoritmi di ordinamento:
Queste si possono includere in ordinamenti.cxx e bisonga poi inserire la dichiarazione delle funzioni in ordinamenti.h. A questo punto è possibile creare diverse versioni del nostro programma principale ceh usano i diversi algoritmi. Non è necessario cancellare niente nel makefile, basta aggiungere dei nuovi target per compilare i nuovi file sorgente. Creiamo un file provaquicksort.cxx, identico a provaordinamenti.cxx, eccetto per il fatto che che usa la funzione quicksort. Per creare l'eseguibile, basterà aggiungere al makefile le seguenti linee:
provaquicksort.o : provaquicksort.cxx
g++ -c provaquicksort.cxx
provaquicksort : provaquicksort.o ordinamenti.o
g++ -o provaquicksort provaquicksort.o ordinamenti.o
Nota Bene: l'implementazione che viene fornita
di questi algoritmi assume che
scambia sia nella forma che usa dei riferimenti.
Se avete scelto l'opzione di usare dei puntatori, ricordatevi di
modificare opportunamente la chiamata a scambia.
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'è nessun output, se
differiscono,
la lista delle differenze viene presentata.
Qual è il programma più efficiente? Dovreste accorgevi che quicksort ha una complessità pari a NlogN.
Come si compara il vostro algoritmo a bubblesort o a simple che sono entrambi di complessità N2?
Potrebbe essere un lavoro interessante modificare il programma per fare i grafici in modo che legga un numero arbitrario di file e poi disegni i grafici sovrapposti, in modo da poterli confrontare visivamente.
Provate a sviluppare una soluzione, se non ci riuscite, studiate questo esempio, che mostra anche come cambiare i marker ed i colori dei grafici e settare la scala di grafici in base al valore massimo e minimo dei vari istrogrammi. Se avete fatto tutto correttamente il grafico risultante dovrebbe essere come:
dove la riga nera è l'algoritmo simple, quella rossa
bubblesort, quella verde quicksort.
Appendice
Strutture
Molto spesso ci si trova ad avere dati tra di loro collegati che devono
essere manipolati insieme. Per fare questo, il C++ eredita dal C dei tipi
di dati denominati strutture. Le strutture permettono di
aggregare insieme dei dati e di costruire facilmente delle funzioni
per manipolarli.
Come esempio considereremo il caso in cui abbiamo un file contenente
delle coppie di numeri matricola di studenti ed il loro voto d'esame.
Un file di questo tipo si trova in
/home/comune/lab2/datafiles/listaesami.txt.
Un tipo struttura viene definita usando la keyword struct,
dandole un nome e fornendo tra
parentesi graffe la lista dei dati che la compongono:
struct studente {
int matricola;
int voto;
};
La definizione di una struttura viene di solito posta all'interno di
un header file contenente anche la lista delle funzioni che
operano su tale struttura.
Dopo che è stata definita, la nuova struttura può essere considerata semplicemente un nuovo tipo di dato e si possono istanziare strutture singole, puntatori a struttura, vettori di strutture con una sintassi identica a quella dei tipi didati predefiniti:
studente a; studente *b; studente c[10000];Per accedere ai dati di una struttura si usa l'operatore "punto" ., mentre per accedere ai dati di un puntatore a struttura, si usa l'operatore "freccia" ->. È inoltre definito l'operatore di assegnazione = tra strutture, che consiste di una copia membro a membro degli elementi. In pratica l'istruzione
a = *b;è eqivalente a scrivere:
a.matricola = b->matricola; a.voto = b->voto;Consideriamo la funzione scambia per la struttura studente, nella sua doppia versione, usando riferimenti ed usando puntatori:
| usando dei puntatori | usando dei riferimenti |
|---|---|
void scambia( studente* a, studente* b ) {
studente temp;
temp=*a;
*a=*b;
*b=temp;
}
|
void scambia( studente& a, studente& b ) {
studente temp;
temp=a;
a=b;
b=temp;
}
|
Nota Bene il C++ distingue le funzioni in base al nome ed agli argomenti quindi le due funzioni scambia che abbiamo definito (per double e per studente) possono convivere nello stesso file header e nello stesso file sorgente.
Un esempio di funzione che utilizza l'algoritmo di quicksort per ordinare gli studenti in ordine decrescente di voto e, per studenti con lo stesso voto, in ordine crescente di matricola, è la seguente:
void OrdinaPerVoto( int N, studente v[]) {
int i, j;
studente separator;
if (N<2) return;
separator = v[N/2];
i=0;
j=N-1;
while (j>i) {
while ( v[i].voto>separator.voto ||
(v[i].voto==separator.voto && v[i].matricola<separator.matricola)
) i++;
while ( v[j].voto<separator.voto ||
(v[j].voto==separator.voto && v[j].matricola>separator.matricola)
) j--;
if (i<j) {
scambia( v[i], v[j] );
i++;
}
}
OrdinaPerVoto(i,v);
OrdinaPerVoto(N-i,v+i);
}
L'unica piccola difficoltà è che ora, dal file bisogna leggere tutte le componenti di ogni struttura.
Successivamente scrivere una funzione che riordini il file, semplicemente
in ordine cresente di numero di matricola.
ROOT in interattivo
Una delle caratteristiche di ROOT è che non consiste solo di una libreria, ma dispone di un interprete interattivo che permette di eseguire direttamente istruzioni in C++.
Per accedere a ROOT in modo interattivo, date da terminale il comando
root
e, dopo una schermata di initializzazione, vi viene dato un
prompt dei comandi. Il programmino che è stato fatto per fare il
grafico ed il fit dello stesso, può essere sostituito da poche istruzione
date a questo prompt. Se indichiamo con nomeFile il nome del
file in cui avete scritto le coppie di punti da graficare, potete fare
il grafico, disegnarlo e farne il fit con un polinomio di secondo grado
dando semplicemente le seguenti istruzioni:
TGraph *myGraph=new TGraph("dati.dat")
myGraph->Draw("AL*")
myGraph->Fit("pol2")
Per uscire da ROOT al termine del lavoro, dare il comando:
.q(si noti il carattere punto prima della q)