[ Suggerimenti ] [ Soluzioni degli Esercizi] [ Volume 2 ] [ Newsletter Gratuita ]
[ Seminari ] [ Seminari su CD ROM ] [ Consulenza]
[ Capitolo Precedente ] [ Indice Generale ] [ Indice Analitico ] [ Prossimo Capitolo ]
trad. italiana e adattamento a cura di Umberto Sorbo
Uno delle caratteristiche
più irresistibili del C++ è il riutilizzo del codice. Ma per essere
rivoluzionari, si ha bisogno di essere capaci di fare molto di più
che copiare codice e cambiarlo.
Questo è stato l'approccio del C e non ha funzionato
molto bene. Per la maggior parte del C++, la soluzione gira intorno alla classe.
Si riusa il codice creando classi nuove, ma invece di crearle da zero, si
usano classi esistenti che qualcun altro ha costruito e ha debuggato.
Il trucco è usare classi senza sporcare il codice
esistente. In questo capitolo si vedranno due modi per ottenere ciò.
Il primo è piuttosto facile: si creano semplicemente oggetti della
propria classe esistente nella classe nuova. Ciò è chiamato
composizione perchè la classe nuova è composta
di oggetti di classi esistenti.
Il secondo approccio è più sottile. Si crea una classe nuova come un tipo di una classe esistente. Si prende letteralmente la forma della classe esistente e si aggiunge codice ad essa, senza cambiare la classe esistente. Questo atto magico è chiamato ereditarietà e la maggior parte del lavoro è fatto dal compilatore. L'ereditarietà è una delle pietre angolari della programmazione orientata agli oggetti e ha implicazioni aggiuntive che saranno esplorate in Capitolo 15.
Ne risulta che molto della sintassi e comportamento è simile sia alla
composizione e all'ereditarietà (questo ha senso, sono due modi di
creare tipi nuovi da tipi esistenti). In questo capitolo, s'imparerà
come utilizzare questi meccanismi per il riutilizzo del codice.
A dire il vero si è
sempre usata la composizione per creare classi. Si sono composte classe primariamente
con tipi predefiniti (e qualche volta le stringhe). Risulta facile usare la
composizione con tipi definiti dall'utente.
Si consideri una classe
che è utile per qualche motivo:
//: C14:Useful.h //Una classe da riutilizzare #ifndef USEFUL_H #define USEFUL_H class X { int i; public: X() { i = 0; } void set(int ii) { i = ii; } int read() const { return i; } int permute() { return i = i * 47; } }; #endif // USEFUL_H ///:~
I membri dato sono private in questa classe,
quindi è totalmente sicuro includere un oggetto di tipo X come
un oggetto public in una nuova classe, che rende l'interfaccia semplice:
//: C14:Composition.cpp // riutilizzo del codice con la composizione #include "Useful.h" class Y { int i; public: X x; // oggetto incorporato Y() { i = 0; } void f(int ii) { i = ii; } int g() const { return i; } }; int main() { Y y; y.f(47); y.x.set(37); // accesso all'oggetto incorporato } ///:~
Le funzioni membro dell'oggetto incorporato ( indicato come un suboggetto) semplicemente richiedeno un'altra selezione del membro.
è più comune fare gli oggetti incorporati privati, poichè divengono parte della realizzazione sottostante (che significa che si può cambiare l'implementazione se si vuole). Le funzioni pubbliche dell'interfaccia per la propria classe nuova coinvolgono poi l'uso dell'oggetto incorporato, ma non necessariamente mimano l'interfaccia dell'oggetto:
//: C14:Composition2.cpp // oggetti incorporati privatamente #include "Useful.h" class Y { int i; X x; // oggetto incorporato public: Y() { i = 0; } void f(int ii) { i = ii; x.set(ii); } int g() const { return i * x.read(); } void permute() { x.permute(); } }; int main() { Y y; y.f(47); y.permute(); } ///:~
Qui, la funzione permute( ) è presente nell'interfaccia della classe nuova, ma le altre funzioni del membro di X sono usate fra i membri di Y.
La sintassi per la composizione
è ovvia, ma per usare l'ereditarietà c'è una forma nuova
e diversa.
Quando si eredita, si
sta dicendo,"Questa nuova classe è come quella vecchia classe".
Si afferma questo nel codice dando come al solito il nome della classe, ma
prima della parentesi di apertura del corpo della classe, si mettono due punti
ed il nome della classe base (o classi base, separate da virgole per l'ereditarietà
multipla). Quando si fa questo, si ottengono automaticamente tutti i membri
dato e funzioni membro della classe base. Ecco un esempio:
//: C14:Inheritance.cpp // Semplice ereditarietà #include "Useful.h" #include <iostream> using namespace std; class Y : public X { int i; // diverso da i di X public: Y() { i = 0; } int change() { i = permute(); // diversa chiamata di nome return i; } void set(int ii) { i = ii; X::set(ii); // chiamata a funzione con lo stesso nome } }; int main() { cout << "sizeof(X) = " << sizeof(X) << endl; cout << "sizeof(Y) = " << sizeof(Y) << endl; Y D; D.change(); // utilizzo delle funzioni dell'interfacci di X : D.read(); D.permute(); // le funzioni ridefinite occultano le versioni base: D.set(12); } ///:~
Si può vedere
che Y è ereditato da X, che significa che Y conterrà
tutti gli elementi dato di X e tutte le funzioni membro di X.
Infatti, Y contiene solo un suboggetto di X proprio come se
si avesse creato un oggetto membro di X in Y invece di ereditarlo
da X. Ci si riferisce come suboggetti sia agli oggetti membro che ai
dati della classe base.
Tutti gli elementi privati di X ancora sono privati in Y; ovvero, poichè Y eredita da X, ciò non significa che Y può rompere il meccanismo di protezione. Gli elementi privati di X sono ancora là, prendono spazio, non si può accedere ad essi direttamente.
In main( )
si possono vedere che gli elementi dato di Y sono combinati con quelli
di X, perchè il sizeof(Y) è grande due volte sizeof(X).
Si noti che la classe
base è preceduta da public. Usando l'ereditarietà viene
assunto tutto come private. Se la classe base non fosse preceduta da
public, vorrebbe dire che tutti dei membri pubblici della classe base
sarebbero privati nella classe derivata. Questo non è quasi mai quello
che si vuole[51];
il risultato desiderato è mantenere tutti i membri pubblici della classe
base pubblici nella classe derivata. Si fa questo usando la parola riservata
public durante l'ereditarietà.
In change( ),
il permute() della classe base viene chiamata. La classe derivata ha
accesso diretto a tutte le funzioni pubbliche della classe base.
La funzione set( )
nella classe derivata ridefinisce la funzione set( ) della
classe base. Ovvero, se si chiama il read( ) e permute( )
per un oggetto di tipo Y, si ottengono le versioni della classe base
di quelle funzioni (si può vedere questo accadere in main( )).
Ma se si chiama set( ) per un oggetto di Y, si ottiene
la versione ridefinita. Questo vuole dire che se non piace la versione di
una funzione che si ottiene durante l'ereditarietà, si può cambiare
quello che fa (si possono aggiungere anche funzioni completamente nuove come
change( )).
Comunque, quando si ridefinisce una funzione, si può volere ancora chiamare la versione della classe base. Se, in set( ), si chiama semplicemente set( ) si ottiene la versione locale della funzione, una chiamata ricorsiva di funzione. Si deve chiamare esplicitamente la classe base usando l'operatore di risoluzione dello scope per chiamare la versione della classe base.
Si è visto come
sia importante in C++ garantire un'inizializzazione corretta e non è
diverso durante la composizione e l'ereditarietà. Quando un oggetto
viene creato, il compilatore garantisce che vengano chiamati i costruttori
per tutti i suoi suboggetti. Negli esempi visti finora, tutti i suboggetti
hanno costruttori per default e il compilatore li chiama automaticamente.
Ma cosa accade se i suboggetti non hanno costruttori per default o se si vuole
cambiare un argomento di default in un costruttore? Questo è un problema
perchè il costruttore della nuova classe non hanno il permesso di accedere
agli elementi dato privati del suboggetto, quindi non può inizializzarli
direttamente.
La soluzione è
semplice: chiamare il costruttore per il suboggetto. Il C++ fornisce una sintassi
speciale per questo, la lista di inizializzazione del costruttore.
La forma della lista di inizializzazione del costruttore imita l'atto
di ereditarietà. Con l'ereditarietà, si mette la classe base
dopo un due punti e prima della parentesi di apertura del corpo della classe.
Nella lista di inizializzazione del costruttore, si mettono le chiamate ai
costruttori dei suboggetti dopo lista di inizializzazione del costruttore
e un due punti, ma prima della parentesi di apertura del corpo della funzione.
Per una classe MioTipo, ereditata da Barra ciò appare
come:
MioTipo::MiTipo(int i) : Barra(i) { // ...
se Barra ha un costruttore che prende un solo argomento int.
Si usa questa sintassi molto simile per l'inizializzazione dell' oggetto membro quando si utilizza la composizione. Per la composizione, si danno i nomi degli oggetti invece dei nomi delle classi. Se si ha più di una chiamata di costruttore nella lista di inizializzazione del costruttore, si separano le chiamate con virgole:
MioTipo2::MioTipo2(int i) : Barra(i), m(i+1) { // ...
Questo è l'inizio di un costruttore per la classe MioTipo2 che è ereditata da Barra e contiene un oggetto membro chiamato m. Si faccia attenzione che mentre si può vedere il tipo della classe base nella lista di inizializzazione del costruttore, si vede solamente l'identificativo del oggetto membro.
La lista di inizializzazione
del costruttore permette di chiamare esplicitamente i costruttori per oggetti
membro. Infatti, non c'è nessun altro modo di chiamare quei costruttori.
L'idea è che i costruttori sono tutti chiamati prima che si entra nel
corpo del costruttore delle classi nuove. In questo modo, qualsiasi chiamata
si faccia a funzioni membro di suboggetti andrà sempre ad oggetti inizializzati.
Non c'è modo di andare alla parentesi di apertura del costruttore senza
che alcun costruttore sia chiamato per tutti gli oggetti membro e gli oggetti
della classe base, anche se il compilatore deve fare una chiamata nascosta
ad un costruttore per default. Questo è un ulteriore rafforzamento
del C++ a garanzia che nessuno oggetto (o parte di un oggetto) possa uscire
dalla barriera iniziale senza che il suo costruttore venga chiamato.
Questa idea che tutti
gli oggetti membro siano inizializzati nel momento in cui si raggiunge la
parentesi di apertura del costruttore è un aiuto alla programmazione.
Una volta che si giunge alla parentesi apertura, si può presumere che
tutti i suboggetti sono inizializzati propriamente e ci si concentra su i
compiti specifici che si vuole completare nel costruttore. Comunque, c'è
un intoppo: cosa succede agli oggetti membro dei tipi predefiniti che non
hanno costruttori?
Per rendere la sintassi
coerente, si è permesso di trattare i tipi predefiniti come
se avessero un solo costruttore che prende un solo argomento: una variabile
dello stesso tipo come la variabile che si sta inizializzando. Quindi si può
scrivere:
//: C14:PseudoConstructor.cpp // Pseudo Costruttore class X { int i; float f; char c; char* s; public: X() : i(7), f(1.4), c('x'), s("howdy") {} }; int main() { X x; int i(100); // applicato ad un'ordinaria definizione int* ip = new int(47); } ///:~
L'azione di queste pseudo
chiamate al costruttore è di compiere una semplice assegnazione. È
una tecnica utile ed un buon stile di codifica,
quindi la si vedrà spesso.
È anche possibile
usare la sintassi dello pseudo-costruttore quando si crea una variabile di
un tipo predefinito fuori di una classe:
int i(100); int* ip = new int(47);
Ciò rende i tipi predefiniti un poco più simili agli oggetti. Si ricordi, tuttavia, che questi non sono i veri costruttori. In particolare, se non si fa una chiamata esplicitamente ad uno pseudo-costruttore, nessuna inizializzazione viene compiuta.
Chiaramente, si può
usare la composizione e l'ereditarietà insieme. L'esempio seguente
mostra la creazione di una classe più complessa che le usa entrambe.
//: C14:Combined.cpp // Ereditarietà & composizione class A { int i; public: A(int ii) : i(ii) {} ~A() {} void f() const {} }; class B { int i; public: B(int ii) : i(ii) {} ~B() {} void f() const {} }; class C : public B { A a; public: C(int ii) : B(ii), a(ii) {} ~C() {} // chiama ~A() e ~B() void f() const { // ridefinizione a.f(); B::f(); } }; int main() { C c(47); } ///:~
C eredita da B
e ha un oggetto membro (è composta di ) del tipo A. Si può
vedere che la lista di inizializzazione del costruttore contiene chiamate
ad entrambi i costruttori della classe base e al costruttore del oggetto membro.
La funzione C::f( )
ridefinisce B::f( ), che eredita e chiama anche la versione
della classe base. In aggiunta chiama a.f( ). Si noti che l'unica
volta che si può parlare di ridefinizione di funzioni è con
l'ereditarietà; con un oggetto membro si può manipolare solamente
l'interfaccia pubblica dell'oggetto, non ridefinirla. In aggiunta, chiamando
f( ) per un oggetto della classe C non si chiamerebbe a.f( )
se C::f( ) non fosse stato definito, mentre si chiamerebbe
B::f( ).
Sebbene spesso sia richiesto
di fare chiamate esplicite al costruttore nella lista di inizializzazione,
non si ha mai bisogno di fare chiamate esplicite al distruttore perchè
c'è solamente un distruttore per classe ed esso non prende nessun argomento.
Il compilatore ancora assicura comunque, che tutti i distruttori vengano chiamati
e cioè tutti i distruttori dell'intera gerarchia , cominciando dal
distruttore più derivato e risalendo indietro alla radice.
Vale la pena di sottolineare che costruttori e distruttori sono piuttosto insoliti nel modo in cui vengono chiamati nella gerarchia, laddove con una funzione membro normale viene chiamata solamente quella funzione, ma nessuna delle versioni della classe base. Se si vuole chiamare anche la versione della classe base di una funzione membro normale che si sta sovrascrivendo, lo si deve fare esplicitamente.
È interessante
conoscere l'ordine delle chiamate al costruttore e distruttore quando un oggetto
ha molti suboggetti. L'esempio seguente mostra precisamente come funziona:
//: C14:Order.cpp // ordine costruttore/distruttore #include <fstream> using namespace std; ofstream out("order.out"); #define CLASS(ID) class ID { \ public: \ ID(int) { out << #ID " costruttore\n"; } \ ~ID() { out << #ID " distruttore\n"; } \ }; CLASS(Base1); CLASS(Member1); CLASS(Member2); CLASS(Member3); CLASS(Member4); class Derived1 : public Base1 { Member1 m1; Member2 m2; public: Derived1(int) : m2(1), m1(2), Base1(3) { out << "Derived1 costruttore\n"; } ~Derived1() { out << "Derived1 distruttore\n"; } }; class Derived2 : public Derived1 { Member3 m3; Member4 m4; public: Derived2() : m3(1), Derived1(2), m4(3) { out << "Derived2 costruttore\n"; } ~Derived2() { out << "Derived2 distruttore\n"; } }; int main() { Derived2 d2; } ///:~
Per primo, un oggetto dell'ofstream
viene creato per spedire tutto l'output ad un file. Poi, per risparmiare caratteri
da digitare e dimostrare una tecnica che usa le macro ( sostituita da una
migliore tecnica del Capitolo 16), ne si crea una per costruire alcune delle
classi che sono usate poi con l'ereditarietà e composizione. Ognuno
dei costruttori e distruttori riporta se stesso nel file. Si noti che i costruttori
non sono costruttori per default; ognuno di loro ha un argomento int.
L'argomento stesso non ha identificativo; la sua unica ragione di esistenza
deve costringerlo a chiamare esplicitamente i costruttori nella lista di inizializzazione
(eliminare l'identificatore evita che il compilatore dia messaggi di warning).
L' output del programma è
Base1 costruttore Member1 costruttore Member2 costruttore Derived1 costruttore Member3 costruttore Member4 costruttore Derived2 costruttore Derived2 distruttore Member4 distruttore Member3 distruttore Derived1 distruttore Member2 distruttore Member1 distruttore Base1 distruttore
Si può vedere che la costruzione inizia alla radice della gerarchia della classe e che a ciascun livello il costruttore della classe base viene chiamato prima, seguito dai costruttori dell' oggetto membro. I distruttori sono chiamati precisamente nell'ordine inverso dei costruttori, questo è importante a causa delle dipendenze potenziali (nel costruttore della classe derivata o distruttore, si deve potere presumere che il suboggetto della classe base è ancora disponibile per l'uso ed è già stato costruito o non è stato distrutto ancora).
È anche interessante che l'ordine di chiamata del costruttore per oggetti membro è completamente non soggetto all'ordine delle chiamate nella lista di inizializzazione del costruttore. L'ordine è determinato dall'ordine in cui gli oggetti membro sono dichiarati nella classe. Se si potesse cambiare l'ordine di chiamata dei costruttori con lista di inizializzazione del costruttore, si potrebbero avere due sequenze della chiamata diverse in due costruttori diversi, ma il povero distruttore non saprebbe come invertire propriamente l'ordine delle chiamate per la distruzione e si potrebbe finire con un problema di dipendenza.
Se si eredita una classe
e si fornisce una definizione nuova per una delle sue funzioni membro, ci
sono due possibilità. Il primo è che si fornisce la firma esatta
ed il tipo di ritorno nella definizione della classe derivata come nella definizione
della classe base. Questo viene chiamato ridefinizione di funzioni
membro ordinarie e overriding quando la funzione membro della classe
base è una funzione virtual (virtuale, funzioni virtuali sono
comuni e saranno illustrate in dettaglio nel Capitolo 15). Ma cosa accade
se si cambia la lista degli argomenti della funzione membro o il tipo restituito
dalla classe derivata? Ecco un esempio:
//: C14:NameHiding.cpp // occultamento dei nomi sovraccaricati durante l'ereditarietà #include <iostream> #include <string> using namespace std; class Base { public: int f() const { cout << "Base::f()\n"; return 1; } int f(string) const { return 1; } void g() {} }; class Derived1 : public Base { public: void g() const {} }; class Derived2 : public Base { public: // ridefinizione: int f() const { cout << "Derived2::f()\n"; return 2; } }; class Derived3 : public Base { public: // cambio del tipo restituito: void f() const { cout << "Derived3::f()\n"; } }; class Derived4 : public Base { public: // cambio della lista dei argomenti: int f(int) const { cout << "Derived4::f()\n"; return 4; } }; int main() { string s("ciao"); Derived1 d1; int x = d1.f(); d1.f(s); Derived2 d2; x = d2.f(); //! d2.f(s); // versione stringa occultata Derived3 d3; //! x = d3.f(); // restituisce la versione intera occultata Derived4 d4; //! x = d4.f(); // versione occultata di f() x = d4.f(1); } ///:~
In Base si vede
una f() sovraccaricata, e Derived1 non fa nessun cambiamento
a f( ) ma ridefinisce g( ). In main( ),
si può vedere che entrambe le versioni sovraccaricate di f( )
sono disponibili in Derived1. Comunque, Derived2 ridefinisce
una versione sovraccaricaricata di f( ) ma non l'altra ed il risultato
è che la seconda forma sovraccaricata non è disponibile. In
Derived3, cambiare il tipo del ritorno nasconde entrambe le versioni
della classe base, e Derived4 mostra che cambiare la lista di inizializzazione
del costruttore nasconde entrambe le versioni della classe base. In generale,
possiamo dire che ognivolta che si ridefinisce un nome di funzione sovraccaricata
da una classe base, tutte le altre versioni sono nascoste automaticamente
alla classe nuova. Nel Capitolo 15, si vedrà che l'aggiunta della parola
chiave virtual influenza un pò di più l'overloading.
Se si cambia l'interfaccia della classe base cambiando
la firma e/o il tipo restituito da una funzione membro dalla classe base,
poi si usa la classe in un modo diverso in cui l'ereditarietà normalmente
intende. Non necessariamente significa che si sta sbagliando, è solo
che la meta ultima dell' ereditarietà è sostenere il polimorfismo
e se si cambia poi la firma della funzione o il tipo restitutito si sta cambiando
davvero l'interfaccia della classe base. Se questo è quello che si
è inteso di fare allora si sta usando l'ereditarietà per riusare
il codice e non per mantenere l'interfaccia comune della classe base (che
è un aspetto essenziale del polimorfismo). In generale, quando si usa
l'ereditarietà in questo modo vuol dire che si sta prendendo una classe
per scopo generale e la si sta specializzando per un particolare bisogno,
che di solito è, ma non sempre, considerato il reame della composizione.
Per esempio, si consideri
la classe Stack
del Capitolo 9. Uno dei problemi con quella classe è che si doveva
compiere un cast ogni volta che si otteneva un puntatore dal contenitore.
Questo non solo è tedioso, ma è anche pericoloso, si potrebbe
castare il puntatore a qualsiasi cosa che si vuole.
Un miglior approccio ad un primo sguardo è specializzare
la classe generale Stack usando l'ereditarietà. Ecco un esempio
che utilizza una classe dal Capitolo 9:
//: C14:InheritStack.cpp // Specializzare la classe Stack #include "../C09/Stack4.h" #include "../require.h" #include <iostream> #include <fstream> #include <string> using namespace std; class StringStack : public Stack { public: void push(string* str) { Stack::push(str); } string* peek() const { return (string*)Stack::peek(); } string* pop() { return (string*)Stack::pop(); } ~StringStack() { string* top = pop(); while(top) { delete top; top = pop(); } } }; int main() { ifstream in("InheritStack.cpp"); assure(in, "InheritStack.cpp"); string line; StringStack textlines; while(getline(in, line)) textlines.push(new string(line)); string* s; while((s = textlines.pop()) != 0) { // Nessun cast! cout << *s << endl; delete s; } } ///:~
Poichè tutte delle funzioni del membro in Stack4.h sono inline, nulla ha bisogno di essere linkato.
StringStack specializza Stack in modo
che il push( ) accetterà solamente puntatori String.
Prima, Stack avrebbe accettato puntatori void, quindi l'utente
non aveva nessuno controllo del tipo per essere sicuro fare che venissero
inseriti i puntatori corretti. In aggiunta, peek( ) e pop( )
ora restituiscono puntatori String invece di puntatori void,
quindi nessuno cast è necessario per usare il puntatore.
Straordinariamente, questo extra controllo del tipo
è gratis in push( ), peek( ), e pop( )!
Si stanno dando informazioni del tipo in più al compilatore che esso
usa a tempo di compilazione, ma le funzioni sono inline e nessuno codice addizionale
viene generato.
L'occultamento dei nomi
entra in gioco qui perchè, in particolare, la funzione push( )
ha una firma diversa: la lista degli argomenti è diversa. Se si
avessero due versioni di push( ) nella stessa classe, questo sarebbe
overloading, ma in questo caso sovraccaricare non è quello che noi
vogliamo perchè ancora si permetterebbe di passare qualsiasi
genere di puntatore in push( ) come un void *. Fortunatamente,
il C++ nasconde push(void *) della classe base in favore della versione
nuova definito nella classe derivata e perciò permette solamente di
usare push( ) con puntatori string dentro StringStack.
Poichè noi
ora possiamo garantire di conoscere precisamente che genere di oggetti ci
sono nel contenitore, il distruttore lavora correttamente ed il problema della
proprietà è risolto o almeno si ha un approccio al problema
della proprietà. Qui, se si usa push( ) per un puntatore
alla StringStack, poi (secondo le semantiche del StringStack)
si passa anche la proprietà di quel puntatore al StringStack.
Se si usa pop( ) per ottenere il puntatore, non solo si ottiene
il puntatore, ma si ottiene anche la proprietà di quel puntatore. Qualsiasi
puntatore che viene lasciato in StringStack quando il suo distruttore
viene chiamato è cancellato da quel distruttore. E poichè questi
sono sempre puntatori string e l'istruzione delete lavora su
puntatori string invece di puntatori void, avviene la corretta
distruzione e tutto funziona correttamente.
C'è un inconveniente: questa classe funziona
solamente con puntatori string. Se si vuole un Stack che funziona
con qualche altro genere di oggetto, si deve scrivere una nuova versione della
classe in modo che funziona solamente col il nuovo tipo di oggetto. Questo
diviene rapidamente tedioso ed è risolto finalmente usando i template,
come si vedrà nel Capitolo 16.
Possiamo fare un'osservazione
supplementare circa questo esempio: cambia l'interfaccia della Stack
nel processo di ereditarietà. Se l'interfaccia è diversa, allora
un StringStack realmente non è uno Stack e non si potrà
mai usare correttamente un StringStack come uno Stack. Questo
rende discutibile l'uso dell'ereditarietà; se non si crea un StringStack
che è un tipo di Stack, allora perchè si sta ereditando?
Una versione più adatta di StringStack sarà mostrata
più avanti in questo capitolo.
Non tutte le funzioni sono ereditate automaticamente
dalla classe base nella classe derivata. Costruttori e distruttori trattano
la creazione e distruzione di un oggetto e sanno che fare solamente con gli
aspetti dell'oggetto per la loro particolare classe, quindi tutti i costruttori
e distruttori nella gerarchia sotto di loro devono essere chiamati. Dunque
costruttori e distruttori ereditano e devono essere creati per ogni classe
derivata.
In aggiunta, l'operatore
= non eredita perchè compie un attività come quella
del costruttore. Ovvero, solo perchè si sa come assegnare tutti
i membri di un oggetto sul lato sinistro del = da un oggetto sul lato destro,
non significa che l'assegnazione avrà ancora lo stesso significato
dopo ereditarietà.
Al posto dell'ereditarietà, queste funzioni sono
sintetizzate dal compilatore se non le si crea (con i costruttori, non si
possono creare qualsiasi costruttore in modo che il compilatore sintetizzi
il costruttore di default e il costruttore di copia). Questo è stato
descritto brevemente nel Capitolo 6. I costruttori sintetizzati usano l'inizializzazione
membro a membro e l'operatore sintetizzato = usa l'assegnazione membro
a membro. Ecco un esempio delle funzioni che sono sintetizzate dal compilatore:
//: C14:SynthesizedFunctions.cpp // Funzioni che sono sintetizzati dal compilatore #include <iostream> using namespace std; class GameBoard { public: GameBoard() { cout << "GameBoard()\n"; } GameBoard(const GameBoard&) { cout << "GameBoard(const GameBoard&)\n"; } GameBoard& operator=(const GameBoard&) { cout << "GameBoard::operator=()\n"; return *this; } ~GameBoard() { cout << "~GameBoard()\n"; } }; class Game { GameBoard gb; // Composizione public: // costruttore di GameBoard di default : Game() { cout << "Game()\n"; } // Si deve chiamare il costruttore di copia di GameBoard // oppure il costruttore di default // viene invece chiamato automaticamente: Game(const Game& g) : gb(g.gb) { cout << "Game(const Game&)\n"; } Game(int) { cout << "Game(int)\n"; } Game& operator=(const Game& g) { // Si deve chiamare esplicitamente l'operatore di assegnazione di GameBoard // altrimenti non avviene nessuna assegnazione per gb! gb = g.gb; cout << "Game::operator=()\n"; return *this; } class Other {}; // classe incoporata // conversione automatica del tipo: operator Other() const { cout << "Game::operator Other()\n"; return Other(); } ~Game() { cout << "~Game()\n"; } }; class Chess : public Game {}; void f(Game::Other) {} class Checkers : public Game { public: // costruttore della classe base di default: Checkers() { cout << "Checkers()\n"; } // Si deve chiamare esplicitamente il costruttore di copia // della classe base altrimenti verrà chiamato // il costruttore di default Checkers(const Checkers& c) : Game(c) { cout << "Checkers(const Checkers& c)\n"; } Checkers& operator=(const Checkers& c) { // Si deve chiamare esplicitamente la versione della classe base // dell'operatore=() altrimenti nessuna assegnazione // della classe base avverrà: Game::operator=(c); cout << "Checkers::operator=()\n"; return *this; } }; int main() { Chess d1; // Costruttore di Default Chess d2(d1); // Costruttore di Copia //! Chess d3(1); // Errore: nessun costruttore di int d1 = d2; // Operatore = sintetizzato f(d1); // la conversione di tipo viene ereditata Game::Other go; //! d1 = go; // Operatore = non sintetizzato // per tipi diversi Checkers c1, c2(c1); c1 = c2; } ///:~
I costruttori e l'operatore = per GameBoard e Gioco annunciano loro stessi quindi si può vedere quando sono usati dal compilatore. In aggiunta, l'operatore Other( ) compie conversione del tipo automatica da un oggetto Game a un oggetto incorporato della classe Other. La classe Chess eredita semplicemente da Game e non crea funzioni (per vedere come il compilatore risponde). La funzione f() prende un oggetto Other per esaminare la funzione di conversione di tipo automatica.
In main( ), il costruttore di default sintetizzato
ed il costruttore di copia per la classe derivata Chess vengono chiamati.
Le versioni Game di questi costruttori sono chiamate come parte della
gerarchia delle chiamate del costruttore. Anche se assomiglia all'ereditarietà,
costruttori nuovi vengono sintetizzati davvero dal compilatore. Come ci si
aspetterebbe, nessun costruttore con argomenti viene creato automaticamente,
perchè ciò è troppo per il compilatore.
L'operatore =
è sintetizzato anche come una funzione nuova in Chess usando
l'assegnamento membro a membro (quindi, la versione della classe base viene
chiamata) perchè quella funzione non è stata scritta esplicitamente
nella classe nuova. E chiaramente il distruttore è stato sintetizzato
automaticamente dal compilatore.
A causa di tutti queste regole sul rimaneggiamento delle
funzioni che gestiscono la creazione dell'oggetto, può sembrare un
pò strano a prima vista che l'operatore di conversione di tipo automatico
venga ereditato. Ma non è troppo irragionevole se ci sono abbastanza
pezzi in Game per fare un oggetto Other, quei pezzi sono ancora
là in qualsiasi cosa sia derivata da Game e l'operatore di conversione
di tipo ancora è valido (anche se si può infatti voler ridefinirlo).
L'operatore = è
sintetizzato solamente per assegnare oggetti dello stesso tipo. Se
si vuole assegnare uno tipo ad un altro si deve sempre scrivere il proprio
operatore = .
Se si guarda più da vicino Game, si vede
che il costruttore di copia e gli operatori di assegnazione hanno chiamate
esplicite al costruttore di copia dell'oggetto membro e all'operatore di assegnazione.
Si farà normalmente ciò perchè nel caso del costruttore
di copia, il costruttore dell'oggetto membro di default verrà altrimenti
usato e, nel caso dell'operatore di assegnazione, nessuna assegnazione sarà
fatta per gli oggetti membro!
Infine, si guardi a Checkers dove esplicitamente
è scritto il costruttore di default, costruttore di copia e l'operatore
di assegnazione. Nel caso del costruttore di default, il costruttore della
classe base di default è stato chiamato automaticamente e questo tipicamente
è quello che si vuole. Ma e questo è un importante punto, appena
si decide di scrivere il proprio proprio costruttore di copia e operatore
di assegnazione, il compilatore presume che si sa ciò che si fa e non
chiama automaticamente le versioni della classe base, come fa nelle funzioni
sintetizzate. Se si vuole che le versioni della classe basi siano chiamate
(e tipicamente si vuole) poi li si devono chiamare esplicitamente. Nel costruttore
di copia della Checkers, questa chiamata appare nella lista di inizializzazione
del costruttore.
Checkers(const Checkers& c) : Game(c) {
Nell'operatore assegnazione di Checkers, la classe
base è la prima linea del corpo della funzione:
Game::operator=(c);
Queste chiamate dovrebbero essere parte della forma canonica che si usa ogni volta che si eredita una classe.
Le funzioni membro statiche agiscono allo stesso modo delle funzioni membro non-statiche:
Ereditano nella classe derivata.
Se si ridefinisce un membro statico, tutte le altre funzioni sovraccaricate nella classe base sono occultate.
Se si cambia la firma di una funzione nella classe base, tutte le versioni della classe base con quel nome di funzione sono occultate (questa realmente è una variazione del punto precedente).
Tuttavia funzioni membro statiche non possono essere virtual (un argomento trattato completamente nel Capitolo 15).
Sia la composizione che l'ereditarietà piazzano
suboggetti nella propria classe nuova. Entrambe usano la lista di inizializzazione
del costruttore per costruire questi suboggetti. Ci si può ora star
chiedendo qual è la differenza tra i due e quando scegliere uno o l'altro.
La composizione generalmente si usa quando si vogliono
le caratteristiche di una classe esistente nella propria classe nuova, ma
non la sua interfaccia. Ovvero, si ingloba un oggetto per perfezionare caratteristiche
della classe nuova, ma l'utente della classe nuova vede l'interfaccia definita
piuttosto che l'interfaccia della classe originale. Si segue il percorso tipico
di inglobare oggetti privati di classi esistenti nella propria classe nuova
per fare questo.
Ha comunque, di quando in quando, senso permettere all'utente
della classe di accedere direttamente la composizione della classe nuova,
ovvero, fare i membri oggetto public. I membri oggetto usano essi stessi
il controllo di accesso, così questa è una cosa sicura da fare
e quando l'utente sa che si stanno assemblando un gruppo di parti, rende l'interfaccia
più facile da capire. Una classe Car è un buon esempio:
//: C14:Car.cpp // Composizione public class Engine { public: void start() const {} void rev() const {} void stop() const {} }; class Wheel { public: void inflate(int psi) const {} }; class Window { public: void rollup() const {} void rolldown() const {} }; class Door { public: Window window; void open() const {} void close() const {} }; class Car { public: Engine engine; Wheel wheel[4]; Door left, right; // 2-door }; int main() { Car car; car.left.window.rollup(); car.wheel[0].inflate(72); } ///:~
Poichè la composizione di una Car fa parte dell'analisi del problema (e non semplicemente parte del progetto ), fare i membri public aiuta il programmatore client a capire come usare la classe e richiede meno complessità del codice per il creatore della classe.
Pensandoci un pò, si vedrà anche che non avrebbe senso comporre una Car usando un oggetto "Vehicle" una macchina non contiene un veicolo, è un veicolo. La relazione è-un espressa con l'ereditarietà e la relazione ha-un è espressa con la composizione.
Ora si supponga che si vuole creare un tipo di oggetto
dell'ifstream che non solo apre un file ma anche monitorizza il nome
del file. Si può usare la composizione e inglobare un ifstream
ed una string nella classe nuova:
//: C14:FName1.cpp // Un fstream con un nome di file #include "../require.h" #include <iostream> #include <fstream> #include <string> using namespace std; class FName1 { ifstream file; string fileName; bool named; public: FName1() : named(false) {} FName1(const string& fname) : fileName(fname), file(fname.c_str()) { assure(file, fileName); named = true; } string name() const { return fileName; } void name(const string& newName) { if(named) return; // Non sovrascrive fileName = newName; named = true; } operator ifstream&() { return file; } }; int main() { FName1 file("FName1.cpp"); cout << file.name() << endl; // Errore: close() non è un membro: //! file.close(); } ///:~
C'è un problema qui, tuttavia. Viene fatto un tentativo per permettere dovunque l'uso dell'oggetto FName1, un oggetto dell'ifstream è usato includendo un operatore di conversione di tipo automatico da FName1 a un ifstream&. Ma nel main, la linea
file.close();
non verrà compilata perchè la conversione
di tipo automatica accade solamente nelle chiamate di funzione, non durante
la selezione del membro. Quindi quest'approccio non funziona.
Un secondo approccio è aggiungere la definizione
di close( ) a FName1:
void close() { file.close(); }
Questo funzionerà se ci sono solamente alcune funzioni che si vogliono portare dalla classe ifstream. In quel caso si usa solamente parte della classe ed è adatta la composizione.
E se si vuole passare tutto
nella classe ? Questo è detto subtyping ( sottotipare ) perchè
si fa un tipo nuovo da un tipo esistente e si vuole un tipo nuovo per avere
precisamente la stessa interfaccia del tipo esistente ( più qualsiasi
altra funzione membro che si vuole aggiungere), quindi si può usarlo
dovunque si userebbe il tipo esistente. Ecco dove l'eredità è
essenziale. Si può vedere che sottotipare risolve perfettamente il
problema nell'esempio precedente:
//: C14:FName2.cpp // Sottotipare risolve il problema #include "../require.h" #include <iostream> #include <fstream> #include <string> using namespace std; class FName2 : public ifstream { string fileName; bool named; public: FName2() : named(false) {} FName2(const string& fname) : ifstream(fname.c_str()), fileName(fname) { assure(*this, fileName); named = true; } string name() const { return fileName; } void name(const string& newName) { if(named) return; // Non sovrascrive fileName = newName; named = true; } }; int main() { FName2 file("FName2.cpp"); assure(file, "FName2.cpp"); cout << "nome: " << file.name() << endl; string s; getline(file, s); // Anche questo funziona! file.seekg(-200, ios::end); file.close(); } ///:~
Ora qualsiasi funzione membro disponibile per un oggetto ifstream è disponibile per un oggetto FName2. Si può vedere anche che quelle funzioni non-membro come getline( ) che si aspettano un ifstream possono lavorare anche con un FName2. Questo perchè un FName2 è un tipo di ifstream, non ne contiene semplicemente uno. Questo è un problema molto importante che sarà esplorato alla fine di questo capitolo e nel prossimo.
Si può ereditare
privatamente una classe base tralasciando public nella lista della
classe base o scrivendo esplicitamente private (probabilmente una procedura
migliore perchè è chiaro all'utente cosa si vuole dire).
Quando si eredita privatamente, si sta implementando in termini di, cioè
si crea una classe nuova che ha tutti i dati e le funzionalità della
classe base, ma quella funzionalità è occultata, quindi è
solamente parte della realizzazione sottostante. L'utente della classe non
ha accesso alla funzionalità sottostante ed un oggetto non può
essere trattato come un'istanza della classe base (come era nel FName2.cpp).
Ci si può chiedere qual è lo scopo dell'
ereditarietà privata, perchè l'alternativa di usare la
composizione per creare un oggetto private nella classe nuova sembra
più adatta. L'ereditarietà privata è inclusa nel linguaggio
per completezza, ma se per nessuna altra ragione che ridurre la confusione,
di solito si userà la composizione piuttosto che l'ereditarietà
privata. Ci possono essere di quando in quando comunque, situazioni dove si
vuole produrre parte della stessa interfaccia come quella della classe base
e respingere il trattamento dell'oggetto come se fosse un oggetto della classe
base. L'ereditarietà privata fornisce questa abilità.
Quando si eredita privatamente, tutti i membri pubblici della classe base diventano private. Se si vuole che qualcuno di loro sia visibile, si usa la parola chiave using vicino il loro nome ( senza nessun argomento o tipi di ritorno) nella sezione public della classe derivata:
//: C14:PrivateInheritance.cpp class Pet { public: char eat() const { return 'a'; } int speak() const { return 2; } float sleep() const { return 3.0; } float sleep(int) const { return 4.0; } }; class Goldfish : Pet { // ereditarietà privata public: using Pet::eat; // il nome publicizza il membro using Pet::sleep; // Entrambi i membri sovraccaricati sono esposti }; int main() { Goldfish bob; bob.eat(); bob.sleep(); bob.sleep(1); //! bob.speak();// Errore: funzione membro privata } ///:~
Quindi, l'ereditarietà privata è utile se si vuole occultare parte della funzionalità della classe base.
Si noti che esporre il nome di una funzione sovraccaricata,
espone tutte le versioni della funzione sovraccaricata nella classe base.
Si dovrebbe far attenzione prima di usare l'ereditarietà privata invece della composizione; l'ereditarietà privata ha particolari complicazioni quando combinata con l'identificazione del tipo a runtime (questo è il tema di un capitolo nel Volume 2 di questo libro, scaricabile da www.BruceEckel.com).
Ora che è stata presentata l'ereditarietà,
la parola riservata protected finalmente ha un significato. In un mondo
ideale, membri privati sarebbero sempre private, ma nei progetti veri
a volte si vuole occultare qualcosa e ancora permettere l'accesso ai membri
delle classi derivate. La parola riservata protected è un cenno
al pragmatismo; dice:"Questo è privato per quanto concerne l'utente
della classe, ma disponibile a chiunque eredita da questo classe".
Il migliore approccio è lasciare i membri dato
private, si dovrebbe sempre preservare il proprio diritto di cambiare
l'implementazione sottostante. Si può permettere poi l'accesso controllato
a chi eredita dalla propria classe attraverso funzioni membro protected:
//: C14:Protected.cpp // La parola riservata protected #include <fstream> using namespace std; class Base { int i; protected: int read() const { return i; } void set(int ii) { i = ii; } public: Base(int ii = 0) : i(ii) {} int value(int m) const { return m*i; } }; class Derived : public Base { int j; public: Derived(int jj = 0) : j(jj) {} void change(int x) { set(x); } }; int main() { Derived d; d.change(10); } ///:~
Si troveranno esempi del uso di protected più avanti in questo libro e nel Volume 2.
Quando si eredita, per default la classe base è
private, che significa che tutte le funzioni membro pubbliche sono
private all'utente della classe nuova. Normalmente, si rende l'ereditarietà
pubblica in modo che l'interfaccia della classe base è anche l'interfaccia
della classe derivata. Si può usare anche la parola chiave protected
con l'ereditarietà.
La derivazione protetta vuole dire: "implementata in termini di" altre classi ma "è-un" per classi derivate e friend. Non si usa molto spesso, ma è presente nel linguaggio per completezza.
Tranne per l'operatore assegnamento, gli operatori sono
ereditati automaticamente in una classe derivata. Questo può essere
dimostrato ereditando da C12:Byte.h:
//: C14:OperatorInheritance.cpp // Ereditare operatori sovraccaricati #include "../C12/Byte.h" #include <fstream> using namespace std; ofstream out("ByteTest.out"); class Byte2 : public Byte { public: // I costruttori non ereditano: Byte2(unsigned char bb = 0) : Byte(bb) {} // l'operatore non eredità, ma // viene sintetizzato per assegnazione membro a membro. // Tuttavia, solo l'operatore = per StessoTipo = StessoTipo // viene sintetizzato, quindi si devono // scrivere gli altri : Byte2& operator=(const Byte& right) { Byte::operator=(right); return *this; } Byte2& operator=(int i) { Byte::operator=(i); return *this; } }; // funzione di test come in C12:ByteTest.cpp: void k(Byte2& b1, Byte2& b2) { b1 = b1 * b2 + b2 % b1; #define TRY2(OP) \ out << "b1 = "; b1.print(out); \ out << ", b2 = "; b2.print(out); \ out << "; b1 " #OP " b2 produce"; \ (b1 OP b2).print(out); \ out << endl; b1 = 9; b2 = 47; TRY2(+) TRY2(-) TRY2(*) TRY2(/) TRY2(%) TRY2(^) TRY2(&) TRY2(|) TRY2(<<) TRY2(>>) TRY2(+=) TRY2(-=) TRY2(*=) TRY2(/=) TRY2(%=) TRY2(^=) TRY2(&=) TRY2(|=) TRY2(>>=) TRY2(<<=) TRY2(=) // operator assegnazione // Conditionals: #define TRYC2(OP) \ out << "b1 = "; b1.print(out); \ out << ", b2 = "; b2.print(out); \ out << "; b1 " #OP " b2 produce"; \ out << (b1 OP b2); \ out << endl; b1 = 9; b2 = 47; TRYC2(<) TRYC2(>) TRYC2(==) TRYC2(!=) TRYC2(<=) TRYC2(>=) TRYC2(&&) TRYC2(||) // assegnazione a catena: Byte2 b3 = 92; b1 = b2 = b3; } int main() { out << "funzioni membro:" << endl; Byte2 b1(47), b2(9); k(b1, b2); } ///:~
Il codice di prova è identico a quello nel C12:ByteTest.cpp
tranne che Byte2 è usato al posto di Byte. In questo
modo tutti gli operatori funzionanto con Byte2 mediante l'ereditarietà.
Quando si esamina la classe Byte2, si vede che il costruttore deve essere definito esplicitamente e che solamente l'operatore = che assegna un Byte2 a un Byte2 viene sintetizzato; qualsiasi altro operatore di assegnazione di cui si ha bisogno deve essere scritto per proprio conto.
Si può ereditare
da una classe, quindi sembrerebbe avere senso ereditare da più di una
classe alla volta. Davvero si può, ma se ha senso nella parte di un
progetto è soggetto di continuo dibattito. Su una cosa generalmente
si è d'accordo: non la si dovrebbe provare fino a che non si programma
da un pò e si capisce completamente il linguaggio. Per quel tempo,
si comprenderà che probabilmente dove servirebbe assolutamente l'ereditarietà
multipla, quasi sempre va bene l'ereditarietà singola.
L'ereditarietà multipla sembra inizialmente abbastanza semplice: si aggiungono più classi nella lista della classe base durante l'ereditarietà, separata da virgole. Comunque, l'ereditarietà multipla presenta delle ambiguità, perciò un capitolo nel Volume 2 è dedicato all'argomento.
Uno dei vantaggi dell' ereditarietà e della composizione
è che questi sostengono lo sviluppo incrementale permettendo
di presentare codice nuovo senza causare bachi nel codice esistente. Se appaiono
bachi, sono isolati nel codice nuovo. Ereditando da (o componendo con) una
esistente e funzionale classe, aggiungendo membri dato e funzioni membro (e
ridefinendo funzioni membro esistenti durante l'ereditarietà) si lascia
il codice esistente · che qualcuno altro ancora può star usando
· intatto e non bacato. Se c'è un baco, si sa che è nel
codice nuovo che è molto più corto e più facile leggere
che se si avesse cambiato il corpo del codice esistente.
Stupisce piuttosto come
le classi siano separate. Non si ha nemmeno bisogno del codice sorgente per
le funzioni membro per riusare il codice, solo l'header file che descrive
la classe e il file oggetto o la libreria con le funzioni membro compilate
(questo è vero sia per l'ereditarietà che per la composizione).
È importante rendersi
conto che lo sviluppo del programma è un processo incrementale, proprio
come l'apprendimento umano. Si può fare tanta analisi quanto si vuole,
ma ancora non si conosceranno tutte le risposte quando
si intraprenderà un progetto. Si avrà molto più
successo e responsi più immediati se si incomincia a far crescere il
proprio progetto come una creatura organica, evolutiva, piuttosto che costruendolo
tutto in una volta come un grattacielo [52].
Sebbene l'ereditarietà per sperimentazione
sia una tecnica utile, a un certo punto dopo che le cose si stabilizzano,
si ha bisogno di dare alla propria gerarchia di classe un nuovo look, collassando
tutto in una struttura assennata[53]. Si
ricordi che l'ereditarietà serve a esprimere una relazione che dice:
"Questa nuova classe è un tipo di quella classe vecchia".
Il proprio programma non si dovrebbe preoccupare di gestire bit, ma invece
di crere e manipolare oggetti di vari tipio per esprimere un modello nei termini
dati dallo spazio del problema.
Nei primo capitoli, si
è visto come un oggetto di una classe derivata da ifstream ha
tutte le caratteristiche e comportamenti di un oggetto
dell'ifstream. In FName2.cpp, qualsiasi funzione membro di ifstream
potrebbe essere chiamata per un oggetto FName2.
Il più importante
aspetto dell' ereditarietà non è che fornisce funzioni membro
per la classe nuova, comunque. È la relazione espressa tra la classe
nuova e la classe base. Questa relazione può essere ricapitolata dicendo:
" La classe nuova è un tipo della classe esistente ".
Questa descrizione non
è solo un modo fantastico di spiegare l' ereditarietà, è
sostenuto direttamente dal compilatore. Come esempio, si consideri una classe
base chiamata Instrument
che rappresenta strumenti musicali e una classe derivata chiamata Wind.
Poichè ereditare significa che tutte le funzioni della classe base
sono anche disponibili nella classe derivata, qualsiasi messaggio che si può
spedire alla classe base può essere spedito anche alla classe derivata.
Quindi se la classe Instrument
ha una funzione membro play( ), allo stesso modo l'avrà
Wind.
Questo vuol dire che noi possiamo dire accuratamente che un oggetto Wind
è anche un tipo Instrument.
L'esempio seguente mostra come il compilatore sostiene questa nozione:
//: C14:Instrument.cpp // Ereditarietà & upcasting enum note { middleC, Csharp, Cflat }; // Etc. class Instrument { public: void play(note) const {} }; // oggetti Wind sono Instruments // perchè hannno la stessa interfaccia: class Wind : public Instrument {}; void tune(Instrument& i) { // ... i.play(middleC); } int main() { Wind flute; tune(flute); // Upcasting } ///:~
Ciò che è interessante in questo esempio è la funzione tune( ), che accetta un riferimento Instrument. Tuttavia, in main( ) la funzione tune( ) viene chiamata gestendo un riferimento a un oggetto Wind. Dato che il C++ è molto particolare circa il controllo del tipo, sembra strano che una funzione che accetta un tipo accetterà prontamente un altro tipo, finchè si comprenderà che un oggetto Wind è anche un oggetto Instrument, e non c'è nessuna funzione che tune( ) potrebbe chiamare per un Instrument che non è anche in Wind (ciò è quello che l'ereditarietà garantisce). In tune( ) il codice lavora per Instrument e qualsiasi cosa si deriva da Instrument ; l'atto di convertire un riferimento o puntatore Wind in un riferimento o puntatore Instrument è chiamato upcasting.
La ragione per il termine
è storica ed è basata sul modo in cui i diagrammi delle classi
ereditate sono disegnate tradizionalmente: con la classe base
alla cima della pagina. (Chiaramente, si può disegnare i diagrammi
in qualsiasi modo si trova utile.) Il diagramma dell'eredità per Instrument.cpp
è:
Castando dalla derivata alla base ci si muove in su sul diagramma dell'eredità, quindi ci si riferisce comunemente come upcasting. L'upcasting è sempre sicuro perché si va da un tipo più specifico ad un tipo più generale, l'unica cosa che può accadere all'interfaccia della classe è che può perdere funzioni membro, non guadagnarle. Questo accade perchè il compilatore permette l'upcasting senza qualsiasi cast esplicito o altre notazioni speciale.
Se si permette al compilatore
di sintetizzare un copia di costruttore per una classe derivata, esso chiamerà
automaticamente il costruttore di copia della classe base e poi i costruttori
di copia per tutto gli oggetti membro (o compie una copia bit a bit sui tipi
predefiniti) quindi si ottiene il giusto comportamento :
//: C14:CopyConstructor.cpp // creare correttamente il costruttore di copia #include <iostream> using namespace std; class Parent { int i; public: Parent(int ii) : i(ii) { cout << "Parent(int ii)\n"; } Parent(const Parent& b) : i(b.i) { cout << "Parent(const Parent&)\n"; } Parent() : i(0) { cout << "Parent()\n"; } friend ostream& operator<<(ostream& os, const Parent& b) { return os << "Parent: " << b.i << endl; } }; class Member { int i; public: Member(int ii) : i(ii) { cout << "Member(int ii)\n"; } Member(const Member& m) : i(m.i) { cout << "Member(const Member&)\n"; } friend ostream& operator<<(ostream& os, const Member& m) { return os << "Member: " << m.i << endl; } }; class Child : public Parent { int i; Member m; public: Child(int ii) : Parent(ii), i(ii), m(ii) { cout << "Child(int ii)\n"; } friend ostream& operator<<(ostream& os, const Child& c){ return os << (Parent&)c << c.m << "Child: " << c.i << endl; } }; int main() { Child c(2); cout << "chiamo il costruttore di copia: " << endl; Child c2 = c; // chiama il costruttore di copia cout << "values in c2:\n" << c2; } ///:~
L'operatore <<
per Child è
interessante per il modo in cui chiama l'operatore << per la
parte Parent
in esso: castando l'oggetto Child a un Parent&
(se si casta a un oggetto della classe base invece di un riferimento di solito
si ottengono risultati indesiderati):
return os << (Parent&)c << c.m
Poichè il compilatore poi vede un Parent, chiama la versione Parent dell'operatore <<.
Si vede che un Child non ha un costruttore di copia esplicitamente dichiarato. Il compilatore quindi sintetizza il costruttore di copia (poichè è una delle quattro funzioni che esso sintetizza, insieme al costruttore di default, se non si crea nessun costrutore, l'operatore = ed il distruttore) chiamando il costruttore di copia Parent ed il costruttore di copia di Member. Ecco l'output:
Parent(int ii) Member(int ii) Child(int ii) chiamo il costruttore di copia: Parent(const Parent&) Member(const Member&) values in c2: Parent: 2 Member: 2 Child: 2
Tuttavia, se si prova a scrivere il proprio costruttore
di copia per Child e si fa un errore innocente :
Child(const Child& c) : i(c.i), m(c.m) {}
allora il costruttore di default verrà
chiamato automaticamente per la parte della classe base di Child, poichè
ciò è quello che il compilatore fa quando non nessun altro costruttore
da chiamare ( si ricordi che un costruttore deve essere sempre chiamato per
ogni oggetto, anche se è un suboggetto di un'altra classe). L'output
sarà:
Parent(int ii) Member(int ii) Child(int ii) chiamo il costruttore di copia: Parent() Member(const Member&) values in c2: Parent: 0 Member: 2 Child: 2
Questo non è probabilmente ciò che ci
si aspettava, poichè generalmente si vorrà che la porzione della
classe base sia copiata da un oggetto esistente ad un nuovo oggetto come parte
del costruttore di copia.
Per rimediare al problema si deve ricordare di chiamare
propriamente il costruttore di copia della classe base (come il compilatore
fa) ogni qualvolta si scrive il proprio costruttore di copia . All'inizio
ciò può sembrare un pò strano ma ecco un altro esempio
di upcasting:
Child(const Child& c) : Parent(c), i(c.i), m(c.m) { cout << "Child(Child&)\n"; }
La parte strana è
dove il costruttore di copia di Parent
viene chiamato: Parent(c). Che vuol dire passare un oggetto Child
a un costruttore di Parent?
Ma Child è
ereditato da Parent,
quindi un riferimento a Child
è una riferimento a Parent.
Il costruttore di copia della classe base fa l'upcasting di riferimento a
Child
ad un riferimento a Parent
ed lo usa per eseguire il costruttore di copia. Quando si scrivono i propri
costruttori di copia, si vuole che essi facciano quasi sempre la stessa cosa.
Uno dei modi più chiari di determinare se si
deve usare la composizione o l'ereditarietà è chiedersi se si
avrà mai bisogno di un upcasting dalla propria nuova classe. Precedentemente
in questo capitolo, la classe della Stack è stata specializzata
usando l'ereditarietà. Tuttavia, le gli oggetti di StringStack
saranno usati solamente come contenitori di string e mai con l'upcasingt,
quindi un'alternativa
più adatta è la composizione:
//: C14:InheritStack2.cpp // composizione ed ereditarietà #include "../C09/Stack4.h" #include "../require.h" #include <iostream> #include <fstream> #include <string> using namespace std; class StringStack { Stack stack; // incorporato invece di ereditato public: void push(string* str) { stack.push(str); } string* peek() const { return (string*)stack.peek(); } string* pop() { return (string*)stack.pop(); } }; int main() { ifstream in("InheritStack2.cpp"); assure(in, "InheritStack2.cpp"); string line; StringStack textlines; while(getline(in, line)) textlines.push(new string(line)); string* s; while((s = textlines.pop()) != 0) // Nessun cast! cout << *s << endl; } ///:~
Il file è identico a InheritStack.cpp, tranne che un oggetto Stack è incorporato in StringStack e le funzioni membro vengono chiamate per l'oggetto incorporato. Non c'è ancora overhead di tempo o spazio perché il suboggetto prende lo stesso ammontare di spazio e tutto il controllodi tipo supplementare avviene a tempo di compilazione.
Sebbene può confondere,
si può anche usare l'ereditarietà privata per esprimere "implementato
in termini di". Anche ciò risolverebbe il problema adeguatamente.
Un posto in cui questo diventa importante, tuttavia, è dove l'ereditarietà
multipla dovrebbe essere garantita. In quel caso, se si vede un progetto in
cui la composizione può essere usata invece dell'ereditarietà,
si può eliminare il bisogno dell'ereditarietà multipla.
In Instrument.cpp, l'upcasting avviene durante
la chiamata a funzione, viene preso il riferimento ad un oggetto Wind
esterno alla funzione e diventa un riferimento Instrument dentro la
funzione. L' upcasting avviene anche con una semplice assegnazione ad un puntatore
o riferimento:
Wind w; Instrument* ip = &w; // Upcast Instrument& ir = w; // Upcast
Come la chiamata a funzione, nessuno di questi casi
richiede un cast esplicito.
Naturalmente, con l'upcasting si perde l'informazione
del tipo di un oggetto. Se si scrive:
Wind w; Instrument* ip = &w;
il compilatore può trattare un ip solo
come un puntatore Instrument e nient'altro. Cioè non sa che
ip punta a un oggetto Wind. Quindi quando si chiama la funzione
membro play() scrivendo:
ip->play(middleC);
il compilatore conosce solo che sta chiamando play() per un puntatore Instrument e chiama la versione di Instrument::play() della classe base invece di ciò che dovrebbe fare, che è chiamare Wind::play( ). Quindi non si otterrà il giusto comportamento.
Questo è un problema serio ed è risolto
nel Capitolo 15, dove viene presentata la terza pietra miliare della programmazione
ad oggetti: il polimorfismo ( implementato in C++ con le funzioni virtuali).
Sia l'ereditarietà che la composizione permettono
di creare un tipo nuovo da tipi esistenti ed entrambi inglobano suboggetti
di tipi esistenti nel tipo nuovo. Si usa comunque, tipicamente, la composizione
per riutilizzare tipi esistenti come parte della implementazione sottostante
del tipo nuovo e l'ereditarietà quando si vuole costringere il tipo
nuovo ad essere dello stesso tipo della classe base ( l'equivalenza dei tipi
garantisce l'equivalenza delle interfacce). Poichè la classe derivata
ha l'interfaccia della classe base, si può usare l'upcasting verso
la classe base, che è critico per il polimorfismo come si vedrà
nel Capitolo 15.
Sebbene il riutilizzo del codice attraverso composizione
ed ereditarietà sia molto utile per lo sviluppo rapido dei progetti,
generalmente si vuole ridisegnare la propria gerarchia di classe prima di
permettere agli altri programmatori di divenire dipendenti da essa. La meta
è una gerarchia nella quale ciascuna classe ha un uso specifico e nessuna
è nè troppo grande ( comprendendo così molte funzionalità
che sono difficili da riusare) né molestamente
piccolo ( non la si può usare di per se o senza aggiungere funzionalità).
Le soluzioni agli esercizi
selezionati possono essere trovate nel documento elettronico The Thinking
in C++ Annotated Solution Guide, disponibile in cambio di un piccolo onorario
su www.BruceEckel.com.
[51] In Java,
il compilatore non permetterà di diminuire l'accesso di un membro con
l'ereditarietà.
[52] Per imparare
qualcosa di più circa questa idea, si veda Extreme Programming Explained
di Kent Beck (Addison-Wesley 2000).
[53] Si veda
Refactoring: Improving the Design of Existing Code di Martin Fowler
(Addison-Wesley 1999).
[ Capitolo Precedente ] [ Indice Generale ] [ Indice Analitico ] [ Prossimo Capitolo ]
Aggiornato al : 25/02/2003