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 connew
. - 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 classeVettore
. Immaginate infatti di voler compilare un codicemain.cpp
insieme ad un filefunzioni.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 classeVettore
. Con il meccanismo indicato, alla prima inclusione diVettore.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 fileVettore.h
. - Si può ottenere lo stesso effetto inserendo la direttiva
#pragma once
all'inizio del fileVettore.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
Vettore a; // costruttore standard senza argomenti
Vettore b(a); // copy constructor
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;
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]
Vettore
dobbiamo come al solito :
* aggiungere la dichiarazione del metodo nell'header file
double& operator[](int);
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
efunzioni.cpp
in modo che lavorino con oggetti di tipoVettore
invece che con semplici array del C. - modificare il
main
in modo che utilizzi la nuova classeVettore
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(...)
oCalcolaVarianza(...)
il passaggio avviene by reference per ottimizzare l'uso della memoria. Con questa modalità di passaggio dati la funzione lavora sulVettore
delmain
e pertanto una modifica accidentale delVettore
di input all'interno della funzione ha un effetto anche nelmain
. Il qualificatoreconst
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 qualificatoreconst
: in questo modo permettiamo che il metodo proceda al riodinamento delVettore
. Dal momento che con il passaggio by value ilVettore
nella funzione è una copia delVettore
di input ogni cambiamento effettuato nella funzione non si ripercuote sulmain
.
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);
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;
};
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;
}
GetComponent()
solleva un'eccezione che viene propagata al main
. A questo punto si può decidere cosa fare direttamente nel main()
.