MindView Inc.

[ Suggerimenti ] [ Soluzioni degli Esercizi] [ Volume 2 ] [ Newsletter Gratuita ]
[
Seminari ] [ Seminari su CD ROM ] [ Consulenza]

Pensare in C++, seconda ed. Volume 1

©2000 by Bruce Eckel

[ Capitolo Precedente ] [ Indice Generale ] [ Indice Analitico ] [ Prossimo Capitolo ]

trad. italiana e adattamento a cura di Umberto Sorbo

14: Ereditarietà & Composizione

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.

Sintassi della composizione

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.

sintassi dell'ereditarietà

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.

La lista di inizializzazione del costruttore

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.

Inizializzazione dell'oggetto membro

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.

Tipi predefiniti nella lista di inizializzazione

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.


Combinare composizione & ereditarietà

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( ).

Chiamate automatiche al distruttore

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.

Ordine delle chiamate al costruttore & distruttore

È 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.

Occultamento del nome

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.

Funzioni che non ereditano automaticamente

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.

Ereditarietà e funzioni membro statiche

Le funzioni membro statiche agiscono allo stesso modo delle funzioni membro non-statiche:

  1. Ereditano nella classe derivata.

  2. Se si ridefinisce un membro statico, tutte le altre funzioni sovraccaricate nella classe base sono occultate.

  3. 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).

Scegliere tra composizione ed ereditarietà

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.

Subtyping

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.

Ereditarietà privata

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à.

Pubblicare membri privatamente ereditati

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).

protected

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.


Ereditarietà protetta

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.

Operatore overloading & ereditarietà

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.

Ereditarietà multipla

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.

Sviluppo incrementale

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.

Upcasting ( cast all'insù )

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.

Perchè “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.

Upcasting ed il costruttore di copia

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.

Composizione ed ereditarietà (rivisitata)

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.

Upcasting di puntatori & riferimenti

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.

Un problema

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).

Sommario

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à).

Esercizi

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.

  1. Modificare Car.cpp in modo che erediti anche da una classe chiamata Vehicle, mettendo le appropriate funzioni membro in Vehicle (ovvero, scrivere qualche funzione membro ). Aggiungere un costruttore non di default a Vehicle, che si deve chiamare nel costruttore di Car.
  2. Creare due classi, A e B, con costruttori di default che annunciano loro stessi. Ereditare una classe nuova chiamata C da A e creare un oggetto membro di B in C, ma non creare un costruttore per C. Creare un oggetto di classe C e osservare i risultati.
  3. Creare una gerarchia a tre livelli di classi con costruttori di default,
    insieme ai distruttori, entrambi che annunciano loro stessi su cout. Verificare che per un oggetto del tipo di più derivato, tutti e tre i costruttori e distruttori vengono chiamati automaticamente. Spiegare l'ordine nel quale le chiamate vengono fatte.
  4. Modificare Combined.cpp per aggiungere un altro livello di ereditarietà ed un nuovo oggetto membro. Si aggiunga codice per mostrare quando si stanno chiamando costruttori e distruttori.
  5. In Combined.cpp, creare una classe D che eredità da B ed ha un oggetto membro della C. Si aggiunga codice per mostrare quando si stanno chiamando costruttori e distruttori.
  6. Modificare Order.cpp per aggiungere un altro livello di ereditarietà Derived3 con oggetti membro della classe Member4 eMember5. Tracciare l' output del programma.
  7. In NameHiding.cpp, verificare che in Derived2, Derived3, e Derived4, nessuna delle versioni della classe base di f() è disponibile.
  8. Modificare NameHiding.cpp aggiungendo tre funzioni sovraccaricate chiamate h( ) alla Base e mostrare che ridefinire una di loro in una classe derivata occulta le altre.
  9. Ereditare una classe StringVector da vector<void*> e ridefinire le funzioni membro push_back( ) e operator[] per accettare e produrre string*. Che accade se si usa push_back( ) con void*?
  10. Scrivere una classe contenente un long ed usare la sintassi della chiamata dello psuedo costruttore per inizializzare il long.
  11. Creare una classe chiamata Asteroid. Usare l'ereditarietà per specializzare la classe PStash del Capitolo 13 (PStash.h & PStash.cpp) in modo che accetti e restituisca puntatori Asteroid. Modificare anche PStashTest.cpp per testare le classi. Cambiare la classe in modo che PStash sia un oggetto membro.
  12. Ripetere l'esercizio 11 con un vector invece di un PStash.
  13. In SynthesizedFunctions.cpp, modificare Chess in modo che abbia un costruttore di default, un costruttore di copia un operatore di assegnazione. Dimostrare che ciò che si è scritto è corretto.
  14. Creare due classi chiamate Traveler e Pager senza costruttori di default, ma con costruttori che prendono un argomento di tipo string, che semplicemente copiano in una variabile string interna. Per ogni classe, scrivere il corretto costruttore di copia e operatore di assegnazione. Poi ereditare un classe BusinessTraveler da Traveler e darle un oggetto membro di tipo Pager. Scrivere il corretto costruttore di default, un costruttore che prende un argomento string, un costruttore di copia ed un operatore di assegnazione.
  15. Creare una classe con due funzioni membro static. Ereditare da questa classe e ridefinire una delle funzioni membro. Mostrare che l'altra è occultata nella classe derivata.
  16. Approfondire i metodi di ifstream. In FName2.cpp, provarli nell'oggetto file.
  17. Usare l'ereditarietà private e protected per creare due nuove classe da una classe base. Poi cercare di usare l'upcasting gli oggetti delle classi derivate alla classe base. Spiegare cosa succede.
  18. In Protected.cpp, aggiungere un metodo in Derived che chiama il metodo di read () di protected Base.
  19. Cambiare Protected.cpp in modo che Derived utilizzi l'ereditarietà protected. Verificare si si può chiamare value( ) di un oggetto Derived.
  20. Creare una classe chiamata SpaceShip con un metodo fly( ). Ereditare Shuttle da SpaceShip e aggiungere un metodo land( ). Creare un nuovo Shuttle, usare l'upcast con puntatore o riferimento a SpaceShip, e provare a chiamare il metodo land( ). Spiegare i risultati.
  21. Modificare Instrument.cpp per aggiungere un metodo prepare( ) a Instrument. Chiamare prepare( ) dentro tune( ).
  22. Modificare Instrument.cpp in modo che play( ) stampa un messaggio verso cout, e Wind ridefinisca play( ) per stampare un messaggio diverso su cout. Eseguire il programma e spiegare perchè questo comportamento non è desiderabile. Mettere la parola chiave virtual (che si imparerà nel capitolo 15) davanti la dichiarazione play( ) in Instrument ed osservare come cambia il comportamento.
  23. In CopyConstructor.cpp, ereditare un nuova classe da Child e darle un Member m. Scrivere un costruttore, costruttore di copia, operator=, e operator<< per ostreams, e testare la classe in main( ).
  24. Prendere l'esempio CopyConstructor.cpp e modificarelo aggiungendo il proprio costruttore di copia a Child senza chiamare il costruttore di copia della classe base e vedere cosa accade. Risolvere il problema facendo una chiamata esplicita al costruttore di copa della classe base nella lista di iniziazzazione del costruttore del costruttore di copia di Child.
  25. Modificare InheritStack2.cpp per utilizzare un vector<string> invece di uno Stack.
  26. Creare una classe Rock con un costruttore di default, un costruttore di copia, un operatore di assegnamento ed un distruttore, che scrivono tutti su cout cosa hanno chiamato. In main( ), creare un vector<Rock> (cioè che contiene oggetti Rock per valore ) e aggiungere qualche Rock. Eseguire il programma e spiegare i risultati che si ottengono. Notare se i distruttori vengono chiamati per gli oggetti Rock nel vector. Ripetere ora l'esercizio con un vector<Rock*>. È possibile creare un vector<Rock&>?
  27. Quest' esercizio è il design pattern chiamato proxy. Partire con la classe base Subject e aggiungere tre funzioni: f( ), g( ) e h( ). Ereditare una classe Proxy e due classi Implementation1 e Implementation2 da Subject. Proxy dovrebbe contenere un puntatore a Subject e tutti i metodi di Proxy dovrebberero fare le stesse chiamate attraverso il puntatore Subject. Il costruttore di Proxy accetta un puntatore a Subject che viene istanziato in Proxy (di solito da costruttore). In main( ), creare due oggetti Proxy diversi che usano implementazione differenti. Modificare Proxy in modo che si possono cambiare le implemntazioni dinamicamente.
  28. Modificare ArrayOperatorNew.cpp del Capitolo 13 per mostrare che, se si eredita da Widget, l'allocazione funziona ancora correttamente. Spiegare perchè l'ereditarietà in Framis.cpp del Capitolo 13 non funzionerebbe correttamente.
  29. Modificare Framis.cpp del Capitolo 13 ereditando da Framis e creare nuove versioni di new e delete dalla propria classe derivata. Dimostrare che funzionano correttamente.

[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