Lezione 1
In questa prima lezione proviamo a rinfrescarci la memoria sulla programmazione procedurale lavorando direttamente su un caso concreto: abbiamo a disposizione un file in cui sono immagazzinati i valori ottenuti da una certa misura e vogliamo scrivere un codice per farci sopra una analisi statistica minimale.
- Carichiamo in memoria dei dati che provengono da un file di misure
- Calcoliamo media, varianza e mediana del campione
Il calcolo della mediana in particolare richiede che il set di dati sia ordinato e quindi ci obbliga a fare un pò di esercizio aggiuntivo.
Che dati vogliamo guardare ?
In questo caso guardiamo un file 1941.txt che contiene le differenze tra la temperatura stimata ogni giorno dell'anno 1941 dal modello di re-analisi ERA5 e la media delle temperature stimate dallo stesso modello dal 1941 a 2023 per quel giorno nell'area di Milano. Questi dati possono essere scaricati dal sito https://open-meteo.com/. Nelle prossime lezioni dovremo imparare ad aprire tutti i files, uno per ogni anno di misure.
In questa lezione lavoreremo con questi ingredienti:
- Tipo di dato da leggere è constituito da numeri
double
immagazzinati in un file 1941.txt. - Tipo di contenitore di dati è un array (dinamico) del C.
- Operazioni sui dati vengono svolte mediante funzioni.
Prima di incominciare a scrivere il codice potrebbe essere utile ripassare rapidamente alcuni elementi base del linguaggio :
Passaggio di inputs da linea di comando
È possibile passare al programma degli input direttamente da linea di comando nel momento in cui si esegue il programma :
./programma <input1> <input2>
Per fare questo nella dichiarazione del main
bisogna aggiungere due argomenti:
main(int argc, char **argv)
argc
è il numero di argomenti presenti sulla riga di comando. Il valore di argc è sempre maggiore di 0 poiché il primo argomento è il nome del programma.argv
è un array diargc
elementi che contiene gli array di caratteri passati da riga di comando. Quindiargv[0]
è il nome del programma,argv[1]
il primo argomento, etc..
Se da riga di comando passiamo un numero, esso verrà passato tramite argv
come un array di caratteri. Per convertire una array di caratteri in un numero intero si usa la funzione atoi(char*)
(che è contenuta in <cstdlib>
):
int = atoi(argv[1]);
La funzione corrispondente per convertire un array di caratteri in un numero double
è atof(char*)
, anch'essa disponibile in <cstdlib>
.
Uso di cin
e cout
L'output su schermo e l'input da tastiera sono gestiti in C++ usando gli oggetti cin
e cout
, che sono definiti nella libreria iostream
:
#include <iostream>
L'uso di cout
è molto semplice :
double a = 10;
cout <<"A = " << a <<endl;
cin
:
double a;
cin >> a;
Warning
ATTENZIONE se a
è una variabile int
e da tastiera viene digitato 2.34
, il valore di a
sarà convertito a 2
. Se digitate a schermo pippo
, non sarà possibile convertirlo in un numero, ed il valore di a
rimarrà inalterato.
Allocazione dinamica della memoria
L'allocazione dinamica della memoria consente di decidere al momento dell'esecuzion (runtime) quanta memoria il programma deve allocare. In C++ l'allocazione (e la de-allocazione) dinamica della memoria viene effettuata con gli operatori new
e delete
:
Il comando
double *x = new double[N];
crea un puntatore x a una zona di memoria di N double
(cioè a un array di double
con N elementi)
Il comando
delete[] x;
de-alloca la memoria. Ciò vuol dire che un tentativo di accedere agli elementi di x
dopo il comando delete
risulterà in un errore di segmentation violation
.
È estremamente importante ricordarsi di de-allocare la memoria. Infatti in programmi complessi che utilizzano molta memoria (o in cicli che continuano ad allocare memoria), l'assenza della de-allocazione può portare a consumare progressivamente tutta la memoria RAM della macchina (memory leak), causando un blocco del sistema.
Nel caso si allochino array (come nel nostro caso), la presenza delle parantesi []
dopo delete
indica che bisogna de-allocare tutta la zona di memoria. Il comando
delete x;
crea un memory leak, perché libera il puntatore all'array ma non il suo contenuto.
Lettura e scrittura da file con fstream
L'input e l'output da files è gestito in C++ dalla libreria fstream
. I principali oggetti sono ifstream
(input file stream) e ofstream
(output file stream). Gli stream vengono dichiarati e inizializzati come:
#include <fstream>
using namespace std;
ifstream inputFile("nomeInput.txt")
ofstream outputFile("nomeOutput.txt")
Per controllare che il file sia stato aperto con successo si può usare il seguente codice
if(!inputFile){
cout <<"Error ...." <<endl; //stampa un messaggio
return -1; //ritorna un valore diverso da quello usuale
}
L'utilizzo degli stream per scrivere su un file di output o per caricare da un file di input è uguale all'uso di cin
e cout
inputFile >> a;
outputFile << a <<endl;
Un metodo estremamente utile di ifstream
è
inputFile.eof();
true
se si è raggiunta la fine del file e false
altrimenti.
Dopo l'utilizzo del file è buona norma chiuderlo con il metodo close()
inputFile.close();
outputFile.close();
ESERCIZIO 1.0 - Primo codice per analisi :
Come punto di partenza possiamo scrivere un unico codice che legga dati da file, li immagazzini in un array dinamico, calcoli la media, la varianza e la mediana dei dati raccolti. Scriviamo su un file di output i dati riordinati in ordine crescente. Il numero di elementi da caricare e il nome del file in cui trovare i dati sono passati da tastiera nel momento in cui il programma viene eseguito. Cerchiamo di costruire il codice passo passo.
Struttura del programma
#include <iostream>
#include <fstream>
#include <cstdlib>
using namespace std;
int main ( int argc, char** argv ) {
if ( argc < 3 ) {
cout << "Uso del programma : " << argv[0] << " <n_data> <filename> " << endl;
return -1 ;
}
int ndata = atoi(argv[1]);
double* data = new double[ndata];
char * filename = argv[2];
// [ ... 1) leggi dati da file e caricali nel c-array data ... ]
for ( int k = 0 ; k < ndata ; k++ ) cout << data[k] << endl; // dopo averli caricati visualizzo
// [ ... 2) calcolo la media e la varianza degli elementi caricati ... ]
cout << "Media = " << media << " Varianza = " << endl; //
// calcola la mediana : prima creo una copia del vettore di partenza
double * vcopy = new double[ndata];
for ( int k = 0 ; k < ndata ; k ++ ) vcopy[k] = data[k];
// [ ... 3) poi riordino gli elementi del vettore copia dal minore al maggiore ... ]
for ( int k = 0 ; k < ndata ; k++ ) cout << vcopy[k] << endl; // dopo averli riordinati visualizzo
// [ ... 4) poi prendo il valore centrale ( ndata dispari ) o la media tra i due centrali
// (ndata pari) dell'array ordinato ... ]
cout << "Mediana = " << mediana << endl; // stampa la mediana calcolata
// visualizzo l'array originale [......]
for ( int k = 0 ; k < ndata ; k++ ) cout << data[k] << endl;
// [ ... 5) scrivo i dati riordinati su un file ... ]
delete [] vcopy ;
delete [] data;
return 0;
}
Provate ad implementare le parti mancanti. Se non ci riuscite sbirciate pure sotto.
1. Caricamento di elementi da file
ifstream fin(filename);
if ( !fin ) {
cout << "Cannot open file " << filename << endl;
exit(33);
} else {
for ( int k = 0 ; k < ndata ; k++ ) {
fin >> data[k] ;
if ( fin.eof() ) {
cout << "End of file reached exiting" << endl;
exit(33) ;
}
}
}
2. Calcolo della media e della varianza
// calcolo la media degli elementi caricati
double media = 0;
for ( int k = 0 ; k < ndata ; k++ ) {
media += data[k] ;
}
media = media / double (ndata) ;
cout << "Valore medio del set di dati caricato " << media << endl;
// calcolo della varianza degli elementi caricati
double varianza = 0;
for ( int k = 0 ; k < ndata ; k++ ) {
varianza += ( data[k]- media ) * ( data[k] - media ) ;
}
varianza = varianza / double (ndata) ;
cout << "Varianza del set di dati caricato " << varianza << endl;
3. Riordino elementi di un array
Esistono vari tipi di algoritmi di riordinamento con prestazioni molto diverse. Qui implementiamo uno dei più semplici ( ma anche dei più lenti ) che viene chiamato simple sort. Sentitevi liberi di implementare algoritmi più raffinati.
// - prima riordino gli elementi del vettore dal minore al maggiore
// devo farne una copia in modo che il vettore originale resti
// inalterato
double * vcopy = new double[ndata];
for ( int k = 0 ; k < ndata ; k ++ ) vcopy[k] = data[k];
int imin = 0;
double min = 0;
for (int j=0; j<ndata-1; j++) {
imin = j;
min=vcopy[imin];
for (int i=j+1; i<ndata; i++) {
if ( vcopy[i]<min ) {
min = vcopy[i];
imin = i;
}
}
double c=vcopy[j];
vcopy[j]=vcopy[imin];
vcopy[imin]=c;
}
4. Calcolo della mediana
// poi prendo il valore centrale ( ndata dispari ) o la media tra i due centrali
// (ndata pari) dell'array ordinato
double mediana = 0;
if ( ndata%2 == 0 ) {
mediana = (vcopy[ ndata/2 -1 ] + vcopy[ ndata/2 ] ) /2.;
} else {
mediana = vcopy[ndata/2];
}
cout << "Mediana = " << mediana << endl;
5. Scrittura elementi su file
ofstream fout("output_file.txt");
for ( int k = 0 ; k < ndata ; k++ ) fout << vcopy[k] << endl;
fout.close();
-
Compiliamo il programma invocando al solito g++:
g++ esercizio1.0.cpp -o esercizio1.0
-
Eseguiamo il programma :
./esercizio1.0 365 1941.txt
Question
Quanti elementi contiene il file 1941.txt ? Cosa succede se tento di leggere 1000000 di elementi ?
ESERCIZIO 1.1 - Codice di analisi con funzioni:
Vogliamo ora riorganizzare il codice precedente per renderlo più modulare e facilmente riutilizzabile. Per capirci meglio: il calcolo della media è una operazione generale che può essere immaginata come un blocco di codice che accetta in input un array di dati e una dimensione e restituisce un valore ( la media appunto ). Se in uno stesso codice principale dobbiamo calcolare più volte la media di array di dati diversi non vogliamo ripetere più volte il frammento di codice relativo. Lo stesso vale per la lettura di un set di dati da un file o per il calcolo della mediana. Il codice dovrebbe avere quindi una struttura del tipo
- Dichiarazione di tutte le funzioni che verranno utilizzate.
- Programma vero e proprio
int main() {....}
in cui le funzioni vengono utilizzate. - Al termine del programma principale l'implementazione di tutte le funzioni dichiarate.
Dal momento che abbiamo deciso di spezzare il codice in funzioni proviamo a fare uso di una funzione dedicata che scambi tra loro due elementi di un array. In questo caso ripassiamo prima rapidamente come funziona il passaggio di dati in una funzione.
Funzioni con argomenti by reference e by value (e by pointer)
Il passaggio di valori a una funzione può avvenire by value, by reference o by pointer. Ad esempio vogliamo scrivere una funzione che scambi di posto il contenuto di due variabili. Abbiamo tre possibilità:
- By Value
void scambiaByValue(double a, double b) {
double c=a;
a=b;
b=c;
}
la chiamata nel main viene effettuata
double a = 5;
double b = 4;
scambia (a,b);
Failure
Non vengono scambiate !
- By Reference
void scambiaByRef(double &a, double &b) {
double c=a;
a=b;
b=c;
}
double a = 5;
double b = 4;
scambia (a,b);
Success
- By Pointer
void scambiaByPointer(double *a, double *b) {
double c=*a;
*a=*b;
*b=c;
}
double a = 5;
double b = 4;
scambia (&a,&b);
Success
Vediamo nel dettaglio come fare :
Struttura del programma
Il codice principale potrebbe essere strutturato nel modo seguente :
#include <iostream>
#include <fstream>
#include <cstdlib>
using namespace std;
double CalcolaMedia( double * , int ) ;
double CalcolaVarianza( double * , int );
double CalcolaMediana ( double [] , int ) ;
double * ReadDataFromFile ( const char* , int ) ;
void Print ( const char* , double * , int ) ;
void scambiaByValue(double , double ) ;
void scambiaByRef(double &, double &) ;
void scambiaByPointer(double *, double *) ;
void selection_sort( double * , int );
int main ( int argc , char** argv) {
if ( argc < 3 ) {
cout << "Uso del programma : " << argv[0] << " <n_data> <filename> " << endl;
return -1 ;
}
int ndata = atoi(argv[1]);
char * filename = argv[2];
// uso una funzione per leggere gli elementi da un file
double * data = ReadDataFromFile ( filename, ndata ) ;
// [... aggiungo dei cout per visualizzare il contenuto del vettore ...]
// uso una funzione per calcolare la media e la varianza
cout << "Media = " << CalcolaMedia( data , ndata ) << endl;
cout << "Varianza = " << CalcolaVarianza( data , ndata ) << endl;
// uso una funzione per calcolare la mediana
cout << "Mediana = " << CalcolaMediana(data, ndata) << endl;
// [ ... aggiungo dei cout per controllare il vettore riordinato ]
// Scrivo i dati riordinati su file
Print( "fileout.txt", data, ndata ) ;
return 0;
}
// legge <size> elementi da <Filename> e restituisce un array
double * ReadDataFromFile ( const char* Filename , int size ) {
double * data = new double[size];
// [ ... ]
return data;
}
void Print ( const char* Filename, double * data, int size ) {
// [ ... ]
}
// calcola la media di <size> elementi dell'array <data>
double CalcolaMedia( double * data , int size ) {
// [ ... ]
return media ;
}
// calcola la varianza di <size> elementi dell'array <data>
double CalcolaVarianza( double * data , int size ) {
// [ ... ]
return media ;
}
// funzioni per scambiare di posto due elementi, utile per il riordinamento
void scambiaByValue(double a, double b) {
double c=a;
a=b;
b=c;
}
void scambiaByRef(double &a, double &b) {
double c=a;
a=b;
b=c;
}
void scambiaByPointer(double *a, double *b) {
double c=*a;
*a=*b;
*b=c;
}
// algoritmo di riordinamento di un array ( selection_sort )
void selection_sort( double * vec , int size) {
for (int j=0; j<size-1; j++) {
int imin = j;
double min=vec[imin];
for (int i=j+1; i<size; i++) {
if ( vec[i]<min ) {
min = vec[i];
imin = i;
}
}
scambiaByRef(vec[j],vec[imin]);
}
}
// Calcolo della mediana di un array <vec> di dimensione <size>. Prima si crea
// una copia dell'array, lo riordina e calcola la mediana
double CalcolaMediana ( double vec[] , int size ) {
double * vcopy = new double [size];
for ( int i = 0 ; i < size ; i++ ) vcopy[i] = vec[i];
selection_sort( vcopy , size );
double mediana = 0;
if ( size %2 == 0 ) {
mediana = ( vcopy[ size /2 -1 ] + vcopy[ size/2 ] ) /2.;
} else {
mediana = vcopy[size /2];
}
delete [] vcopy;
return mediana;
}
Il main è ora decisamente più compatto e leggibile. Quasi tutte le principali funzionalità del codice sono state scorporate in un opportuno set di funzioni.
- Come nel caso dell'esercizio precedente compiliamo il programma invocando al solito g++:
g++ esercizio1.1.cpp -o esercizio1.1
- Eseguiamo il programma :
./esercizio1.1 365 1941.txt
ESERCIZIO 1.2 - Codice di analisi con funzioni esterne e Makefile:
In questo esercizio terminiamo il processo di riorganizzazione dell'esercizio 1.0. Procederemo in questo modo:
- Tutte le dichiarazioni di variabili che abbiamo messo in testa al programma le spostiamo in un file separato
funzioni.h
. - Tutte le implementazioni delle funzioni in coda al programma le spostiamo in un file separato
funzioni.cpp
. - Ricordiamoci di includere il file
funzioni.h
sia inesercizio1.2.cpp
sia infunzioni.cpp
tramite il solito#include "funzioni.h"
- Compiliamo separatamente
esercizio1.2.cpp
efunzioni.cpp
utilizzando unMakefile
Prima di incominciare rivediamo rapidamente come si scrive un Makefile
:
Makefile
Vogliamo creare un Makefile
che ci permetta di compilare il nostro programma quando questo è
composto/spezzato in diversi file sorgente. Supponiamo di avere un codice spezzato in
esercizio1.2.cpp funzioni.cpp funzioni.h
g++ esercizio1.2.cpp funzioni.cpp -o esercizio1.2
Makefile
è la seguente:
target: dipendenze
[tab] system command
esercizio1.2: funzioni.cpp esercizio1.2.cpp funzioni.h
[tab] g++ funzioni.cpp esercizio1.2.cpp -o esercizio1.2
make
viene eseguito il primo target che quindi lancia una compilazione.
Possiamo scrivere un Makefile
più sofisticato esplicitando le dipendenze in modo che le parti di codice vengano compilate separatamente. In questo caso il Makefile
diventa
esercizio1.2: esercizio1.2.o funzioni.o
g++ esercizio1.2.o funzioni.o -o esercizio1.2
esercizio1.2.o: esercizio1.2.cpp funzioni.h
g++ -c esercizio1.2.cpp -o esercizio1.2.o
funzioni.o: funzioni.cpp funzioni.h
g++ -c funzioni.cpp -o funzioni.o
ESERCIZIO 1.3 - Overloading di funzione (da consegnare):
Aggiungete alla vostra libreria di funzioni una funzione void Print(double *, int)
che permetta di scrivere gli elementi di un array a video. Questo è possibile grazie all'overloading (funzioni con stesso nome, ma con argomenti differenti).
Overloading di funzioni
L'overloading delle funzioni è una funzionalità specifica del C++ che non è presente in C. Questa funzionalità permette di poter utilizzare lo stesso nome per funzioni diverse (cioè che compiono operazioni diverse) all'interno dello stesso programma, a patto però che gli argomenti forniti alla funzione siano differenti. In maniera automatica, il compilatore sceglierà la funzione appropriata a seconda del tipo di argomenti passati. In pratica:
void Print(double * data int ndata) {...}
void Print(const char * filename, double * data , int ndata) {...}
Approfondimenti
Formattazione dell'output
La C++ Standard Library permette di manipolare la formattazione dell'output utilizzando i manipolatori, alcuni dei quali sono dichiarati nell'header cout, cin, ofstream, ifstream
...).
I manipolatori che ci serviranno per modificare l'output di numeri floating-point sono:
fixed
: stampa i numeri senza l'uso di esponenti, ove possibile
scientific
: stampa i numeri utilizzando gli esponenti
setprecision(int n)
: stampa n cifre dopo la virgola
Esempio:
cout << "double number: " << setprecision(4) << double_number;
Utili per stampare i dati in una tabella sono
setw(int n): imposta la larghezza di un campo ad n
setfill(char c): usa c come carattere di riempimento (quello di default è lo spazio)
cout <<setw(5) <<"0.132" <<setw(5) <<"234" <<endl
cout <<setw(5) <<"10" <<setw(5) <<"12" <<endl
Implementazione migliorata
In generale l'implementazione di algoritmi in una funzione può avvenira in diversi modi. Proviamo a vedere alcune possibili varianti per le funzioni relative al calcolo della media e della varianza:
double CalcolaMedia( double * data , int size ) {
if ( size == 0 ) return accumulo;
double accumulo = 0;
for ( int k = 0 ; k < size ; k++ ) {
accumulo += data[k] ;
}
return 1. / static_cast<double> (size) * accumulo ;
}
double CalcolaMedia( double * data , int size ) {
double accumulo=0.;
if ( size == 0 ) return accumulo;
for ( int k = 0 ; k < size ; k++ ) {
accumulo = static_cast<double>(k)/static_cast<double>(k+1)*accumulo +
1./static_cast<double>(k+1)* data[k] ;
}
return accumulo;
}
La prima funzione implementa il calcolo in modo intuitivo. La seconda è meno ovvia ma se ci pensate ha lo stesso effetto con il grosso vantaggio di non conservare la somma di tutti i valori che potrebbe diventare troppo grande.
double CalcolaVarianza( double * data , int size ){
double result=0.;
if ( size == 0 ) return result;
double media = CalcolaMedia( data , size );
for ( int i = 0 ; i < size ; i++ ) {
result += ( media-data[i] ) * ( media - data[i] );
}
return 1./static_cas<double>size * result ;
}
double CalcolaVarianza( double * data , int size )
{
double result=0.;
if ( size == 0 ) return result;
double sumx = 0;
double sumx2 = 0;
for ( int i = 0 ; i < size ; i++ ) {
sumx += data[i] ;
sumx2 += data[i] * data[i] ;
}
result = 1./static_cast<double>( size ) * ( sumx2 - (sumx*sumx/static_cast<double>(size ))) ;
return result;
}
double CalcolaVarianza ( double * data , int size )
{
double result=0.;
if ( size == 0 ) return result;
double old_average, average=0.;
for ( int i = 0 ; i < size ; i++ ) {
old_average = average;
average = static_cast<double>(i)/static_cast<double>(i+1)*average +
1./static_cast<double>(i+1)*data[i];
result = 1./static_cast<double>(i+1) *
(static_cast<double>(i) * result + data[i]*data[i] +
static_cast<double>(i) * old_average*old_average ) -
average*average ;
}
return result;
}
Nel caso della varianza la prima implementazione richiede una chiamata alla funzione CalcolaMedia()
mentre la seconda no. La terza infine implementa il calcolo nello stesso modo visto per la media, ovvero evitando di immagazzinare somme troppo elevate. In generale è sempre consigliato usare funzioni e algoritmi da librerie ufficiali ( standard library, boost ) che vi garantiscono un livello di prestazioni ottimale.