Reimpiego di codice
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
mostrato, non c'e` alcun limite alla complessita` di un membro dato di un
oggetto; un attributo puo` avere sia tipo elementare che tipo definito
dall'utente, in particolare un attributo puo` a sua volta essere un
oggetto.
|
class Lavoro {
public:
Lavoro(/* Parametri */);
/* ... */
private:
/* ... */
};
class Lavoratore {
public:
Lavoratore(Lavoro* occupazione);
/* ... */
private:
Lavoro* Occupazione;
/* ... */
};
|
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. In questo caso il reimpiego
e` servito per modellare una proprieta` della classe
Lavoratore, ma sono possibili casi ancora piu` 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];
};
|
In questo secondo esempio invece il reimpiego della classe
Complex ci consente anche di definire le operazioni
sulla classe Matrix in termini delle operazioni su
Complex (un approccio matematicamente corretto).
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,
const unsigned code);
void PrintName();
/* ... */
private:
Person Self;
const unsigned int IdCode; // numero di matricola
/* ... */
};
Student::Student(const char* name, unsigned age,
const 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 tecnologie basate su questo approccio).
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 del concetto di ereditarieta` che
sara` esaminato in seguito.
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 (con il contenimento
diretto invece oltre all'interfaccia viene fissato anche il comportamento
ovvero l'implementazione del componente).
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 (e controlli) 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. 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 >
using namespace std;
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;
};
int main(int, char* []) {
BigObj Test(15);
BigObj Test2;
return 0;
}
|
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).
Si poteva pensare di realizzare il costruttore 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, in questo caso
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.
Ora che e` noto il meccanismo che regola l'inizializzazione di un oggetto
composto, resta chiarire come vengono esattamente generati il costruttore di
default e quello di copia.
Sappiamo che il compilatore genera automaticamente un costruttore di default
se il programmatore non ne definisce uno qualsiasi, in questo caso
il costruttore di default fornito automaticamente, come e` facile
immaginare, non fa altro che chiamare i costruttori di default
delle singole componenti, generando un errore se per qualche componente
non esiste un tale costruttore. Analogamente il costruttore di copia che il
compilatore genera (solo se il programmatore non lo definisce esplicitamente)
non fa altro che richiamare i costruttori di copia delle singole componenti.
Pagina precedente - Pagina successiva
C++, una panoramica sul linguaggio - seconda edizione © Copyright 1996-1999, Paolo Marotta
|