Skip to content

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.

  1. Carichiamo in memoria dei dati che provengono da un file di misure
  2. 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)
dove

  • 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 di argc elementi che contiene gli array di caratteri passati da riga di comando. Quindi argv[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;
Uso di 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();
che restituisce 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;
}
la chiamata nel main viene effettuata
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;
}
la chiamata nel main viene effettuata
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 in esercizio1.2.cpp sia in funzioni.cpp tramite il solito #include "funzioni.h"
  • Compiliamo separatamente esercizio1.2.cpp e funzioni.cpp utilizzando un Makefile

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
Ovviamente possiamo compilare il tutto con

g++ esercizio1.2.cpp funzioni.cpp -o esercizio1.2
ma possiamo farlo in maniera più efficace. La struttura/sintassi del Makefile è la seguente:
target: dipendenze
[tab] system command
Nel nostro caso
esercizio1.2: funzioni.cpp esercizio1.2.cpp funzioni.h
[tab] g++ funzioni.cpp esercizio1.2.cpp -o esercizio1.2
lanciando il comando 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) {...}
Le due funzioni hanno lo stesso nome, ma ovviamente ci si aspetta che facciano cose diverse. Si noti che per poter fare l'overloading di una funzione non basta che soltanto il tipo restituito dalla funzione sia differente, ma occorre che siano diversi i tipi e/o il numero dei parametri passati alla funzione.

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 . In generale i manipolatori modificano lo stato di uno stream (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)
Ad esempio
cout <<setw(5) <<"0.132" <<setw(5) <<"234" <<endl
cout <<setw(5) <<"10" <<setw(5) <<"12" <<endl
stampa i numeri in due colonne allineate

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.