Skip to content

Lezione 2

In questa seconda lezione affronteremo gli stessi problemi della prima (lettura di dati da un file, calcolo di media, varianza e mediana) utilizzando però un contenitore di dati di nostra invenzione, idealmente più evoluto del semplice array dinamico del C. A questo proposito nella prima parte della lezione costruiremo la nostra prima classe, la classe Vettore che sostiuirà l'array dinamico del C. Nella seconda parte adatteremo le funzioni già scritte nella lezione scorsa in modo che possano lavorare con oggetti di tipo Vettore invece che con array dinamici del C. Quindi in sintesi :

  • Tipo di dato da leggere è constituito da numeri double immagazzinati nel solito file 1941.txt.
  • Tipo di contenitore di dati è la classe Vettore che scriveremo noi.
  • Operazioni sui dati vengono svolte mediante funzioni che lavorano su oggetti di tipo Vettore.

ESERCIZIO 2.0 - Creazione della classe Vettore :

In questo esercizio proviamo ad implementare una classe che abbia come data membri privati un intero (dimensione del vettore) ed un puntatore a double (puntatore alla zona di memoria dove sono immagazzinati i dati).

La classe dovrà poi implementare:

  • Un costruttore di default, che assegni valori nulli alla lunghezza del vettore ed al puntatore.
  • Un costruttore che abbia come argomento un intero: questo deve creare un vettore di lunghezza pari al valore dell'intero e tutte le componenti nulle (usando un new per allocare la memoria necessaria).
  • Un distruttore: deve chiaramente deallocare con delete[] la zona di memoria allocata con new.
  • Dei metodi per inserire e leggere i valori della componenti: questi metodi devono controllare che l'indice delle componenti richieste sia compatibile con la lunghezza del vettore.

Esempio di header file della classe (Vettore.h):

L'header file della classe iniziale (Vettore.h) potrebbe essere così:

#ifndef __Vettore_h__
#define __Vettore_h__

#include <iostream>

using namespace std;

class Vettore {
 public:

  // costruttori

  Vettore();               // Costruttore di default 
  Vettore(int N);          // Costruttore con dimensione del vettore

  // distruttore

  ~Vettore();              

  // metodi di accesso

  int GetN() const { return m_N;}; // Accede alla dimensione del vettore
  void SetComponent(int, double); // Modifica la componente i-esima
  double GetComponent(int) const; // Accede alla componente i-esima

  // metodi interni

  void Scambia( int , int ) ;

 private:

  int m_N;          // dimensione del vettore
  double* m_v;      // vettore di dati 

};

#endif // __Vettore_h__

Tip

  • Notate l'utilizzo del costrutto #ifndef .... #define .... #endif (include guards) . Queste direttive di preprocessore sono normalmente utilizzate per evitare inclusioni multiple di uno stesso header file che, nel caso specifico, porterebbero ad una doppia dichiarazione della classe Vettore. Immaginate infatti di voler compilare un codice main.cpp insieme ad un file funzioni.cpp e che entrambi i codici sorgente contengano una istruzione #include "Vettore.h": in fase di compilazione il compilatore si lamenterebbe per una doppia dichiarazione della classe Vettore. Con il meccanismo indicato, alla prima inclusione di Vettore.h, viene creata una variabile globale __Vettore_h__. Al secondo tentativo di inclusione l'esistenza della variabile globale forza il compilatore a saltare tutte le righe tra #define .... #endif, di fatto evitando la seconda inclusione del file Vettore.h.
  • Si può ottenere lo stesso effetto inserendo la direttiva #pragma once all'inizio del file Vettore.h: rispetto all'utilizzo delle include guards l'implementazione è più semplice e compatta ma il #pragma once potrebbe non funzionare per qualche (raro) compilatore.

Tip

Notate inoltre l'impementazione in-line del metodo GetN(): i metodi di una classe possono essere anche implementati direttamente nell'header file (.h) e non nel .cpp. L'implementazione inline implica che il compilatore metta una copia della funzione ogni volta che questa viene chiamata: in questo modo il codice diventa più lungo ma vengono ottimizzate le performance in quanto non si deve effettuare una chiamata alla funzione. In genere l'implementazione inline viene effettuata per funzioni brevi ( una o poche righe ).

Tip

Notate l'uso del qualificatore const nella definizione del metodo GetN(): in questo modo ogni istruzione dentro GetN() che tenti di modificare il contenuto della classe verrà segnalata come errore di compilazione. Il metodo GetN() è un metodo di accesso e logicamente non ci aspettiamo che effettui alcuna operazione di modifica del contenuto della classe : in questo caso è buona pratica dichiararlo const al fine di rendere l'utilizzo della nostra classe da parte di eventuali utenti più sicuro.

Esempio di implementazione della classe (Vettore.cpp):

Il file di implementazione della classe iniziale (Vettore.cpp) potrebbe essere così:

#include "Vettore.h"

// costruttore senza argomenti

Vettore::Vettore()  {
  m_N = 0;
  m_v = NULL;
}

// costruttore con dimensione

Vettore::Vettore(int N) {
  if ( N < 0 ) {
    cout << "Errore: la dimensione deve essere positiva " << endl;
    exit (4); // ??
  } else {
    m_N = N;
    m_v = new double[N];
    for ( int k = 0 ; k < N ; k++ ) m_v[k] = 0;
  }
}

// distruttore

Vettore::~Vettore() {
  delete[] m_v;
}

// metodi di accesso

void Vettore::SetComponent(int i, double a) {

  if ( i < m_N ) {
    m_v[i]=a;
  } else {
    cout << "Errore: indice " << i << ", dimensione " << m_N << endl;
    exit (1); // ??
  }

}

double Vettore::GetComponent(int i) const {

  if ( i < m_N ) {
    return m_v[i];
  } else {
    cout << "Errore: indice " << i << ", dimensione " << m_N << endl;
    exit(2); // ?
  }
}

// metodi interni

void Vettore::Scambia(int primo, int secondo) {
  double temp = GetComponent(primo);
  SetComponent(primo,GetComponent(secondo));
  SetComponent(secondo,temp);
}

Programma di test:

Questo semplice programma ci permette di provare le funzionalità della nuova classe Vettore :

#include <iostream>

#include "Vettore.h"

using namespace std;

int main ( ) {

  // costruttore senza argomenti ==>> crea un vettore di dimenione nulla 

  Vettore vnull ;
  cout << "Vettore vnull : dimensione = " << vnull.GetN() << endl;
  for ( unsigned int k = 0 ; k < vnull.GetN() ; k++ ) cout << vnull.GetComponent(k) << " " ;
  cout << endl;

  // construttore con intero : costruisco un OGGETTO di tipo vettore di lunghezza 10

  Vettore v(10);
  cout << "Vettore v : = dimensione = " << v.GetN() << endl;
  for ( unsigned int k = 0 ; k < v.GetN() ; k++ ) cout << v.GetComponent(k) << " " ;
  cout << endl;
  int comp = 3;
  cout << "Componente " << comp << " = " << v.GetComponent(comp) << endl;

  v.SetComponent(comp,-999) ;

  for ( unsigned int k = 0 ; k < v.GetN() ; k++ ) cout << v.GetComponent(k) << " " ;
  cout << endl;

  // anche come puntatore

  Vettore * vp = new Vettore(10);
  cout << "Vettore vp : = dimensione = " << vp->GetN() << endl;
  for ( unsigned int k = 0 ; k < vp->GetN() ; k++ ) cout << vp->GetComponent(k) << " " ;
  cout << endl;    

  delete vp;

  return 0;

}

ESERCIZIO 2.1 - Completamento della classe Vettore :

La classe vettore costruita sopra non è però ancora completa, anzi può essere addirittura pericolosa ! In particolare vogliamo : 1. aggiungere la possibilità di costruire un vettore a partire da un vettore esistente ( costruttore di copia ) 2. aggiungere la possibilità di eguagliare due oggetti di tipo vettore ( operatore di assegnazione ) 3. aggiungere un operatore di accesso rapido alle componenti ([])

Copy constructor:

Il copy constructor (o costruttore di copia) viene utilizzato per creare una copia di un vettore esistente: esso deve accettare in input un vettore e costruirne una copia del vettore argomento.

Il copy constructor viene invocato implicitamente ogni volta che utilizziamo sintassi come:

Vettore a;       // costruttore standard senza argomenti
Vettore b = a;   // copy constructor 
oppure la sintassi equivalente:
Vettore a;     // costruttore standard senza argomenti
Vettore b(a);  // copy constructor 
ed in tutti i casi in cui si passa un oggetto per valore. Il compilatore mette a disposizione un construttore di copia di default che eguaglia i data membri. In questo caso i due puntatori m_v dei due oggetti vettore punterebbero alla stessa area di memoria generando possibili problemi di memory management. In questi casi si deve procedere a fornire al compilatore una implementazione esplicita del costruttore di copia.

  • Il copy constructor viene dichiarato nell'header con una sintassi:
    Vettore(const Vettore& );
    
  • Nell'implementazione dobbiamo assicurarci che l'oggetto costruito abbia la sua zona di memoria riservata:
    // overloading costruttore di copia
    
    Vettore::Vettore(const Vettore& V) {
      m_N = V.GetN();
      m_v = new double[m_N];
      for (int i=0; i<m_N; i++) m_v[i]=V.GetComponent(i);
    }
    

Operatore di assegnazione :

L'operatore di assegnazione viene utilizzato per eguagliare un vettore ad un altro ( entrambi esistenti ).

L'operatore di assegnazione viene invocato implicitamente ogni volta che utilizziamo una sintassi del tipo :

Vettore a ;
// ... riempimento delle componenti di a 
Vettore b ; 
// ... riempimento delle componenti di b

a=b;
oppure la sintassi equivalente
Vettore a ;
// ... riempimento delle componenti di a 
Vettore b ; 
// ... riempimento delle componenti di b

a.operator=(b);

In questo caso a e b sono oggetti della stessa classe, di fatto l'assegnazione non è altro che una abbreviazione per indicare la chiamata ad un metodo della classe di nome operator=: a.operator=(b); Il compilatore fornisce un'implementazione di default di questo operatore, che corrisponde ad un assegnazione membro a membro di tutti i data membri. Ma questo non funzione se alcuni data membri sono puntatori, perché avremmo diversi oggetti che condividono la stessa area di memoria. Dobbiamo quindi realizzare un'implementazione sicura dell'assegnazione, facendo una copia dei dati in una nuova locazione di memoria.

  • L'header file dovrà contenere una dichiarazione:
    Vettore& operator=(const Vettore& )
    
  • Una possibile implementazione è data qui sotto:
    // overloading operatore di assegnazione
    
    Vettore& Vettore::operator=(const Vettore& V) {
      m_N = V.GetN();
      if ( m_v ) delete[] m_v;
      m_v = new double[m_N];
      for (int i=0; i<m_N; i++) m_v[i]=V.GetComponent(i);
      return *this;
    }
    
Il puntatore this

Il puntatore this indica un puntatore all'oggetto cui si sta applicando un metodo. E' particolarmente utile in alcune occasioni, come nel caso dell'operatore di assegnazione, in cui si deve restituire una copia dell'oggetto corrente.

Operatore di accesso []:

Se vogliamo semplificare la codifica dell'accesso alle componenti di un Vettore ( sia in lettura sia in scrittura ) potremmo pensare di fare un overloading dell'operatore di accesso operator[](int). Questo permetterebbe ad esempio di accedere alla seconda componente di un vettore v semplicemente scrivendo

double a = v[1]
Per aggiungere questa funzionalità alla nostra classe Vettore dobbiamo come al solito : * aggiungere la dichiarazione del metodo nell'header file
double& operator[](int);
* aggiungere l'implementazione del metodo nel file di implementazione:
double& Vettore::operator[] (int i) {
  if ( i<m_N ) {
    return m_v[i];
  } else {
    cout << "Errore: indice " << i << ", dimensione " << m_N << endl;
    exit(3); // ??
  }
}

Esempio di codice :

A quensto punto possiamo utilizzare il seguente codice di test che include anche un esempio di utilizzo di copy constructor, operatore di assegnazione e operatore di accesso.

Warning

Questo esempio di codice darebbe problemi di memory corruption senza l'implementazione corretta del copy constructor e dell'operatore di assegnazione ! Infatti avremmo ottenuto due vettori che condividono esattamente la stessa area di memoria : una modifica su un vettore implica che anche l'altro venga modificato. Avendo implementato esplicitamente ed in maniera corretta i due operatori questo problema non si presenta.

#include <iostream>
#include "Vettore.h"

using namespace std;

int main ( ) {

  // costruttore senza argomenti ==>> crea un vettore di dimenione nulla 

  Vettore vnull ;
  cout << "Vettore vnull : dimensione = " << vnull.GetN() << endl;
  for ( unsigned int k = 0 ; k < vnull.GetN() ; k++ ) cout << vnull.GetComponent(k) << " " ;
  cout << endl;

  // construttore con intero : costruisco un OGGETTO di tipo vettore di lunghezza 10

  Vettore v(10);
  cout << "Vettore v : = dimensione = " << v.GetN() << endl;
  for ( unsigned int k = 0 ; k < v.GetN() ; k++ ) cout << v.GetComponent(k) << " " ;
  cout << endl;
  int comp = 3;
  cout << "Componente " << comp << " = " << v.GetComponent(comp) << endl;
  cout << "Componente " << comp << " = " << v[comp] << endl;

  v.SetComponent(comp,-999) ;
  v[comp] = -999 ;

  for ( unsigned int k = 0 ; k < v.GetN() ; k++ ) cout << v.GetComponent(k) << " " ;
  cout << endl;

  // anche come puntatore

  Vettore * vp = new Vettore(10);
  cout << "Vettore vp : = dimensione = " << vp->GetN() << endl;
  for ( unsigned int k = 0 ; k < vp->GetN() ; k++ ) cout << vp->GetComponent(k) << " " ;
  cout << endl;    

  // copy constructor : w viene creato come copia di v

  Vettore w=v;  //  oppure la sintassi equivalente: Vettore w(v);

  cout << "Vettore w : dimensione = " << w.GetN() << endl;
  for ( unsigned int k = 0 ; k < w.GetN() ; k++ ) cout << w.GetComponent(k) << " " ;
  cout << endl;

  v.SetComponent(4,99); // WARNING : senza copy constructor opportuno, un cambio di v cambia anche w !!!!!!

  cout << "Vettore v : dimensione = " << v.GetN() << endl;
  for ( unsigned int k = 0 ; k < v.GetN() ; k++ ) cout << v.GetComponent(k) << " " ;
  cout << endl;

  cout << "Vettore w : dimensione = " << w.GetN() << endl;
  for ( unsigned int k = 0 ; k < w.GetN() ; k++ ) cout << w.GetComponent(k) << " " ;
  cout << endl;

  // operatore di assegnazione : prima creo Z e poi lo eguagli a w

  Vettore z ; 
  z = w ;

  cout << "Vettore z : dimensione = " << z.GetN() << endl;
  for ( unsigned int k = 0 ; k < z.GetN() ; k++ ) cout << z.GetComponent(k) << " " ;
  cout << endl;

  delete vp;

  return 0;

}

ESERCIZIO 2.2 - Codice di analisi dati utilizzando la classe Vettore (da consegnare) :

Proviamo ora a riscrivere il codice della prima lezione utilizzando un contenitore di dati più raffinato: la classe Vettore ci permetterà di riempire il contenitore dati controllando per esempio che non stiamo sforando la dimensione allocata. Il Vettore inoltre si porta dietro anche la sua dimensione: se dobbiamo calcolare la media degli elementi di un Vettore non dobbiamo più passare la dimensione come argomento esterno ! Per svolgere questo esercizio dobbiamo :

  • modificare tutte le funzioni in funzioni.h e funzioni.cpp in modo che lavorino con oggetti di tipo Vettore invece che con semplici array del C.
  • modificare il main in modo che utilizzi la nuova classe Vettore e le nuove funzioni.
  • modificare il Makefile

Se non ci riuscite da soli potete dare un'occhiata ai suggerimenti qui sotto.

Struttura del programma:

#include <iostream>
#include <cstdlib>

// includo la dichiarazione della classe Vettore

#include "Vettore.h"

// include la dichiarazione delle funzioni 

#include "funzioni.h"

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]);    
  char * filename = argv[2];

  Vettore v = Read( ndata , filename );

  Print( v );

  cout << "media    = " << CalcolaMedia( v  ) << endl;
  cout << "varianza = " << CalcolaVarianza( v  ) << endl;
  cout << "mediana  = " << CalcolaMediana( v ) << endl; 

  Print( v );
  Print( v , "data_out.txt" );

  return 0;
}

Le funzioni:

  • L'header file (.h) potrebbe risultare così:
    #include <iostream>
    #include <fstream> 
    
    #include "Vettore.h"
    
    using namespace std;
    
    Vettore Read( int, const char* ) ;
    
    double CalcolaMedia( const Vettore & ) ;
    double CalcolaVarianza( const Vettore & ) ;
    double CalcolaMediana( Vettore ) ; 
    
    void Print( const Vettore & ) ;
    void Print( const Vettore & , const char* ) ;
    
    void selection_sort( Vettore & );
    
  • il file di implementazione (.cpp) potrebbe risultare così per il calcolo della media (aggiungere tutte le funzioni restati):
    #include "funzioni.h"
    
    double CalcolaMedia( const Vettore & v ) {
    
      double accumulo = 0;
      for ( int k = 0 ; k < v.GetN() ; k++ ) { 
        accumulo += v.GetComponent(k) ;
      }
    
      return accumulo / double ( v.GetN() ) ;
    
    }
    
Passaggio dati by reference con qualificatore const

Nella funzione CalcolaMedia il vettore di input viene passato con la sintassi const Vettore & v, quindi il passaggio avviene by reference evitando una inutile e pesante copia dell'oggetto vettore di input. Il passaggio by reference darebbe alla funzione la possibilità di modificare ( per sbaglio ) il contenuto del vettore del main : per questo motivo si aggiunge il qualificatore const che non permette ( pena un errore di compilazione ) operazioni di modifica del contenuto del vettore da dentro la funzione.

Il Makefile: Il makefile va modificato aggiungendo la compilazione della classe Vettore:

esercizio2.2 : esercizio2.2.o Vettore.o funzioni.o
        g++ -o esercizio2.2 esercizio2.2.o Vettore.o funzioni.o
funzioni.o: funzioni.cpp funzioni.h Vettore.h
        g++ -c -o funzioni.o funzioni.cpp
esercizio2.2.o : esercizio2.2.cpp funzioni.h Vettore.h
        g++ -c -o esercizio2.2.o esercizio2.2.cpp
Vettore.o : Vettore.cpp Vettore.h
        g++ -c -o Vettore.o Vettore.cpp

clean:
        rm *.o
cleanall: clean
        rm esercizio2.2

Question

Perchè CalcolaMedia vuole in input un (const Vettore &) mentre CalcolaMediana semplicemente un (Vettore) ? :

  • Nel caso di CalcolaMedia(...) o CalcolaVarianza(...) il passaggio avviene by reference per ottimizzare l'uso della memoria. Con questa modalità di passaggio dati la funzione lavora sul Vettore del main e pertanto una modifica accidentale del Vettore di input all'interno della funzione ha un effetto anche nel main. Il qualificatore const vieta alla funzione di fare qualsiasi operazione di cambiamento del contenuto del vettore di input pena un errore di compilazione.
  • Nel caso invece di CalcolaMediana(...) il passaggio e' effettuato by value e senza il qualificatore const: in questo modo permettiamo che il metodo proceda al riodinamento del Vettore. Dal momento che con il passaggio by value il Vettore nella funzione è una copia del Vettore di input ogni cambiamento effettuato nella funzione non si ripercuote sul main.

Approfondimenti

The move semantic

La move semantic è un nuovo modo (dal C++11) di spostare le risorse in un modo ottimale evitando di creare copie non necessarie di oggetti temporanei ed è basato sulle r-value references. La potenza della move semantic si può capire affrontando il caso in cui si voglia costruire un oggetto della classe Vettore a partire dall'output di una funzione :

Vettore v = Read(ndata, filename);
La funzione Read() restituirà un oggetto temporaneo di tipo `Vettore che poi verrà utilizzato come input del costruttore di copia per la creazione di v. Chiaramente questo riduce notevolmente le performance del nostro codice. Perchè non realizzare un costruttore di copia (e un operatore di assegnazione) che siano in grado di rubare i data membri all'oggetto temporaneo senza dover copiare dati ? Questo è lo spirito del move constructor e move assignment operator:
// overloading del move constructor

Vettore::Vettore( Vettore&& V ) {
  cout << "Calling move constructor" << endl;
  m_N = V.m_N;
  m_v = V.m_v;
  V.m_N = 0;
  V.m_v = nullptr;
  cout << "Move constructor called" << endl;

}

// overloading del move assignment operator

Vettore& Vettore::operator=( Vettore&& V) {
  cout << "Calling move assignment operator " << endl;
  delete [] m_v ;

  m_N = V.m_N;
  m_v = V.m_v;

  V.m_N = 0;
  V.m_v = nullptr;
  cout << "Move assignment operator called" << endl;
  return *this;
}

  • Notare la doppia && al vettore in input, qui stiamo sfruttando la referenza ad un rvalue
  • Il move constructor e move assignment operator semplicemente rubano i dati all'oggetto temporaneo ( non c'è copia elemento per elemento )
  • Il puntatore dell'oggetto di input viene sganciato dai dati
  • Per vedere il move constructor all'opera potrebbe essere necessario aggiungere la flag -fno-elide-constructors per disattivare eventuali ottimizzazioni interne del compilatore che maschererebbero l'uso del move constructor
C++ exceptions

Nei metodi della classe Vettore o nelle funzioni corrispondenti abbiamo spesso utilizzato la funzione exit() per interrompere l'esecuzione del programma in caso si incontri una condizione patologica ( per esempio tentiamo di accedere ad una componenete che non esiste). Questo approccio non è considerto buon conding : in generale non vogliamo che il comportamento di una funzione ( magari scritta da altri ) possa decidere la sorte del programma. Sarebbe meglio che la funzione potesse fornire al main l'informazione su eventuali errori di esecuzione e lasciare al main la possibilità di decidere della sorte del programa. In C++ esiste un meccanismo di gestione delle exceptions. Per capire meglio come utilizzare le exceptions in C++ proviamo a tenere come esempio il metodo di accesso ad un elemento (GetComponent()). Con la modifica seguente

class Vettore {

public:

  // ....

  double GetComponent(unsigned int k ) const {
    if ( k > m_N ) {
      throw 99 ;
    }
    return m_v[k];
  }

private:

  unsigned int m_N;
  double* m_v;

};
il metodo GetComponent() può essere usato nel modo seguente dal main():

#include "Vettore.h"

int main(){


  Vettore v(3);

  v.SetComponent(1,99);

  try{
    v.GetComponent(4) ;
  }catch ( int errcode ) {
    cout << "Error code " << errcode << " exiting " << endl;
    exit (44) ;
  }

  return 0;

}
Come si può notare quando si tenta di leggere la componente 4 (che non esiste ) il metodo GetComponent() solleva un'eccezione che viene propagata al main. A questo punto si può decidere cosa fare direttamente nel main().