La programmazione orientata agli oggetti e` nata con lo scopo di
risolvere il problema di sempre del modo dell'informatica: rendere economicamente
possibile e facile il reimpiego di codice gia` scritto. Due sono sostanzialmente le
tecniche di reimpiego del codice offerte: reimpiego per composizione e reimpiego per
ereditarieta`; il C++ ha poi offerto anche il meccanismo dei Template che puo`
essere utilizzato anche in combinazione con quelli classici della OOP.
Per adesso rimanderemo la trattazione dei template ad un apposito
capitolo, concentrando la nostra attenzione prima sulla composizione di oggetti
e poi sull'ereditarieta` il secondo pilastro (dopo l'incapsulazione di dati e
codice) della programmazione a oggetti.
Reimpiego per composizione
Benche` non sia stato esplicitamente mensionato, non
c'e` alcun limite alla complessita` di un membro dato di un oggetto; un dato
attributo puo` avere sia tipo elementare che tipo definito dall'utente, in
particolare un attributo puo` a sua volta essere un oggetto.
Vediamo un esempio che mostra come definire una matrice 10x10 di numeri complessi:
class Complex {
public:
Complex(float Real=0, float Immag=0);
Complex operator+(Complex &);
Complex operator-(Complex &);
/* ... */
private:
float Re, Im;
};
class Matrix {
public:
Matrix();
Matrix operator+(Matrix &);
/* ... */
private:
Complex Data[10][10];
};
L'esempio mostrato suggerisce un modo di reimpiegare codice gia` pronto quando si e`
di fronte ad una relazione di tipo Has-a, in cui una
entita` piu` piccola e` effettivamente parte di una piu` grossa; tuttavia la
composizione puo` essere utilizzata anche per modellare una relazione di
tipo Is-a, in cui invece una istanza di un certo tipo
puo` essere vista anche come istanza di un tipo piu` "piccolo":
class Person {
public:
Person(const char * name, unsigned age);
void PrintName();
/* ... */
private:
const char * Name;
unsiggned int Age;
};
class Student {
public:
Student(const char * name, unsigned age, unsigned code);
void PrintName();
/* ... */
private:
Person Self;
unsigned int IdCode; // numero di matricola
/* ... */
};
Student::Student(const char * name, unsigned age, unsigned code) :
Self(name, age), IdCode(code) {}
void Student::PrintName() {
Self.PrintName();
}
/* ... */
In sostanza la composizione puo` essere utilizzata anche quando vogliamo
semplicemente estendere le funzionalita` di una classe realizzata in precedenza.
Esistono due tecniche di composizione:
- Contenimento diretto;
- Contenimento tramite puntatori.
Nel primo caso un oggetto viene effettivamente inglobato all'interno di un altro
(come negli esempi visti), nel secondo invece l'oggetto contenitore in realta`
contiene un puntatore. Le due tecniche offrono vantaggi e svantaggi differenti.
Nel caso del contenimento tramite puntatori:
- L'uso di puntatori permette di modellare relazioni
1-n altrimenti non modellabili se non stabilendo
un valore massimo per n;
- Non e` necessario conoscere il modo in cui va costruito una componente nel
momento in cui l'oggetto che la contiene viene istanziato;
- E` possibile che piu` oggetti contenitori condividano la stessa componente;
- Il contenimento tramite puntatori puo` essere utilizzato insieme
all'ereditarieta` e al polimorfismo per realizzare classi di oggetti che non
sono completamente definiti fino al momento in cui il tutto
(compreso le parti accessibili tramite puntatori) non e` totalmente costruito.
L'ultimo punto e` probabilmente il piu` difficile da capire e richiede la conoscenza
dei principi della OOP.
Sostanzialmente possiamo dire che poiche` il contenimento avviene tramite puntatori,
in effetti non possiamo conoscere l'esatto tipo del componente, ma solo una sua
interfaccia generica (classe base) costituita dai messaggi cui l'oggetto puntato
sicuramente risponde. Questo rende il contenimento tramite puntatori piu` flessibile
e potente (espressivo) del contenimento diretto, potendo realizzare oggetti il cui
comportamento puo` cambiare dinamicamente nel corso dell'esecuzione del programma.
Pensate al caso di una classe che modelli un'auto: utilizzando un puntatore per
accedere alla componente motore, se vogliamo testare il comportamento dell'auto con
un nuovo motore non dobbiamo fare altro che fare in modo che il puntatore punti ad
un nuovo motore. Con il contenimento diretto la struttura del motore (corrispondente
ai membri privati della componente) sarebbe stata limitata e non avremmo potuto
testare l'auto con un motore di nuova concezione (ad esempio uno a propulsione
anzicche` a scoppio). Come vedremo invece il polimorfismo consente di superare tale
limite. Tutto cio` sara` comunque piu` chiaro in seguito.
Consideriamo ora i principali vantaggi e svantaggi del contenimento diretto:
- L'accesso ai componenti non deve passare tramite puntatori;
- La struttura di una classe e` nota gia` in fase di compilazione, si conosce
subito l'esatto tipo del componente e il compilatore puo` effettuare molte
ottimizzazioni altrimenti impossibili (tipo espansione delle funzioni
inline dei componenti);
- Non e` necessario eseguire operazioni di allocazione e deallocazione per
costruire le componenti, ma e` necessario conoscere il modo in cui costruirle
gia` quando si istanzia (costruisce) l'oggetto contenitore.
Se da una parte queste caratteristice rendono il contenimento diretto meno
flessibile ed espressivo di quello tramite puntatore e anche vero che lo rendono
piu` efficente, non tanto perche` non e` necessario passare tramite i puntatori,
ma quanto per gli ultimi due punti.
Costruttori per oggetti composti
L'inizializzazione di un ogggetto composto richiede che
siano inizializzate tutte le sue componenti. Implicitamente abbiamo visto che un
attributo non puo` essere inizializzato mentre lo si dichiara (infatti gli attributi
static vanno inizializzati fuori dalla dichiarazione di classe
(vedi capitolo VIII, paragrafo 6); la stessa cosa vale per
gli attributi di tipo oggetto:
class Composed {
public:
/* ... */
private:
unsigned int Attr = 5; // Errore!
Component Elem(10, 5); // Errore!
/* ... */
};
Il motivo e` ovvio, eseguendo l'inizializzazione nel modo appena mostrato il
programmatore sarebbe costretto ad inizializzare la componente sempre nello stesso
modo; nel caso si desiderasse una inizializzazione alternativa, saremmo costretti a
eseguire altre operazioni (e avremmo aggiunto overhead inutile).
La creazione di un oggetto che contiene istanze di altre classi richiede che vengano
prima chiamati i costruttori per le componenti e poi quello per l'oggetto stesso;
analogamente ma in senso contrario, quando l'oggetto viene distrutto, viene prima
chiamato il distruttore per l'oggetto composto, e poi vengono eseguiti i distruttori
per le singole componenti.
Il processo puo` sembrare molto complesso, ma fortunatamente e` il compilatore che
si occupa di tutta la faccenda, il programmatore deve occuparsi solo dell'oggetto
con cui lavora, non delle sue componenti. Al piu` puo` capitare che si voglia avere
il controllo sui costruttori da utilizzare per le componenti, l'operazione puo`
essere eseguita utilizzando la lista di inizializzazione, come mostra l'esempio
seguente:
#include < iostream.h >
class SmallObj {
public:
SmallObj() {
cout << "Costruttore SmallObj()" << endl;
}
SmallObj(int a, int b) : A1(a), A2(b) {
cout << "Costruttore SmallObj(int, int)" << endl;
}
~SmallObj() {
cout << "Distruttore ~SmallObj()" << endl;
}
private:
int A1, A2;
};
class BigObj {
public:
BigObj() {
cout << "Costruttore BigObj()" << endl;
}
BigObj(char c, int a = 0, int b = 1) : Obj(a, b), B(c) {
cout << "Costruttore BigObj(char, int, int)" << endl;
}
~BigObj() {
cout << "Distruttore ~BigObj()" << endl;
}
private:
SmallObj Obj;
char B;
};
void main() {
BigObj Test(15);
BigObj Test2;
}
il cui output sarebbe:
Costruttore SmallObj(int, int)
Costruttore BigObj(char, int, int)
Costruttore SmallObj()
Costruttore BigObj()
Distruttore ~BigObj()
Distruttore ~SmallObj()
Distruttore ~BigObj()
Distruttore ~SmallObj()
L'inizializzazione della variabile Test2 viene eseguita tramite il
costruttore di default, e poiche` questo non chiama esplicitamente un costruttore
per la componente SmallObj automaticamente il compilatore aggiunge una
chiamata a SmallObj::SmallObj(); nel caso in cui invece
desiderassimo utilizzare un particolare costruttore per SmallObj
bisogna chiamarlo esplicitamente come fatto in
BigObj::BigObj(char, int, int) (utilizzato per
inizializzare Test).
Il costruttore poteva anche essere scritto
nel seguente modo:
BigObj::BigObj(char c, int a = 0, int b = 1) {
Obj = SmallObj(a, b);
B = c;
cout << "Costruttore BigObj(char, int, int)" << endl;
}
ma benche` funzionalmente equivalente al precedente, non genera lo stesso codice.
Infatti poiche` un costruttore per SmallObj non e` esplicitamente
chiamato nella lista di inizializzazione e poiche` per costruire un oggetto
complesso bisogna prima costruire le sue componente, il compilatore esegue
una chiamata a SmallObj::SmallObj() e poi passa il controllo a
BigObj::BigObj(char, int, int). Conseguenza di cio` e` un maggiore
overhead dovuto a due chiamate di funzione in piu`: una per
SmallObj::SmallObj() (aggiunta dal compilatore) e l'altra per
SmallObj::operator=(SmallObj&) (dovuta alla prima istruzione del
costruttore).
Il motivo di un tale comportamento potrebbe sembrare piuttosto arbitrario, tuttavia
in realta` una tale scelta e` dovuta alla necessita` di garantire sempre che un
oggetto sia inizializzato prima di essere utilizzato.
Ovviamente poiche` ogni classe possiede un solo distruttore, non esistono problemi
di scelta!
In pratica possiamo riassumere quanto detto dicendo che:
- la costruzione di un oggetto composto richiede prima la costruzione
delle sue componenti, utilizzando le eventuali specifiche presenti nella lista
di inizializzazione del suo costruttore; in caso non venga
specificato il costruttore da utilizzare per una componente, il compilatore
utilizza quello di default. Alla fine viene eseguito il corpo del costruttore
per l'oggetto composto;
- la distruzione di un oggetto composto avviene eseguendo prima il suo distruttore
e poi il distruttore di ciascuna delle sue componenti;
In quanto detto e` sottointeso che se una componete di un oggetto e` a sua volta un
oggetto composto, il procedimento viene iterato fino a che non si giunge a
componenti di tipo primitivo.
Pagina precedente - Pagina successiva