L'ereditarietà è un meccanismo proprio della programmazione C++ orientata agli oggetti che consente di scomporre il modello dati della nostra applicazione in una gerarchia di classi che definiscono strutture dati e comportamenti differenziati.
Da un punto di vista pratico, tale decomposizione consente di trasmettere un insieme di caratteristiche comuni da una classe base ad una derivata senza che ciò comporti una duplicazione del codice, offrendo allo stesso tempo l'opportunità di adattare o estendere il comportamento a casi d'uso specifici.
Riutilizzare porzioni di codice significa aumentare il livello di manutenibilità dello stesso. In generale, è lecito anche dire che, a fronte di uno sforzo iniziale più consistente, ciò consente di minimizzare i tempi necessari per espandere le funzionalità del software nel medio e lungo termine, estendendone quindi il ciclo di vita... a patto che la gerarchia di classi segua alcune regole di una buona progettazione.
La regola is-a
Nel caso ideale, per sfruttare al massimo le potenzialità offerte dal meccanismo di ereditarietà, una gerarchia di classi dovrebbe assumere una forma schematica simile a quella illustrata nella figura seguente:
Attraversare i livelli della gerarchia dall'alto verso il basso significa spostarsi da un livello di astrazione generico ad altri sempre più specifici, in cui i dettagli implementativi diversificano il comportamento delle istanze in maniera più significativa.
Secondo i principi generali della programmazione a oggetti, tale gerarchia soddisfa quindi la regola nota come is-a, cioè ogni classe derivata è un tipo particolare di ciò che rappresenta la sua classe base.
Un esempio classico è quello di una gerarchia di classi atta a rappresentare forme geometriche. Al posto di A potremmo avere una classe di nome Shape, da cui deriva una classe di nome Polygon (B), da cui a sua volta deriva una classe di nome Rect (E) e così via.
Mettendo in pratica la regola is-a, un rettangolo è in effetti un poligono, ed un poligono è una forma. A prescindere dai costrutti sintattici con cui il linguaggio C++ implementa l'ereditarietà, se la nostra gerarchia soddisfa questa semplice regola, siamo già sulla strada della buona progettazione.
Definizione di una classe derivata
Definiamo la classe Shape come base della nostra gerarchia di forme nel namespace Geometry, che abbiamo già usato per la definizione della classe Point2D nelle lezioni precedenti:
// shape.h
#ifndef SHAPE_H
#define SHAPE_H
#include <string>
namespace Geometry
{
class Shape;
}
class Geometry::Shape
{
public:
Shape(std::string lab="");
void setLabel(std::string lab);
std::string getLabel() const;
protected:
std::string label;
};
#endif
// shape.cpp
#include "shape.h"
namespace Geometry {
Shape::Shape(std::string lab)
{
label = lab;
}
void Shape::setLabel(std::string lab)
{
label = lab;
}
std::string Shape::getLabel() const
{
return label;
}
} // namespace Geometry
Per lo scopo di questa lezione, è sufficiente dotare la classe Shape di un solo membro di tipo stringa utile per assegnare un'etichetta alla forma e dei relativi metodi accessori.
Nel listato successivo, la dichiarazione della classe derivata Polygon è seguita dal carattere :, da un qualificatore di accesso, in questo caso public
ed infine dal nome della classe base, Shape.
// polygon.h
#ifndef POLYGON_H
#define POLYGON_H
#include "shape.h"
namespace Geometry
{
class Polygon;
}
class Geometry::Polygon : public Shape
{
public:
Polygon(std::string lab="");
int getVertexCount() const;
protected:
int vertexCount;
};
#endif
// polygon.cpp
#include "polygon.h"
namespace Geometry {
Polygon::Polygon(std::string lab)
: Shape(lab) // <== invocazione del costruttore della classe base
{
vertexCount = 0;
}
int Polygon::getVertexCount() const
{
return vertexCount;
}
} // namespace Geometry
La classe Polygon estende e specializza la classe Shape con un dato membro apposito per la definizione del numero di vertici, che non abbiamo incluso nella classe base in quanto generica (si pensi ad esempio al caso di una circonferenza) ed il vincolo di ereditarietà tra Shape e Polygon fa sì che quest'ultima erediti tutti i membri della prima.
L'implementazione del costruttore della classe derivata Polygon richiama in maniera esplicita il costruttore della classe base Shape per inizializzare il membro label. Tale soluzione previene la necessità di replicare il codice di inizializzazione dei membri ereditati dalla classe base nel costruttore delle sue derivate.
Come esempio si consideri il listato successivo, dove l'inizializzazione e l'invocazione del metodo getLabel
da parte di un oggetto di tipo Polygon in realtà fa riferimento a dati e metodi definiti nella classe base:
#include "polygon.h"
#include <iostream>
using namespace Geometry;
using namespace std;
int main()
{
Polygon p("This is a polygon");
cout << p.getLabel(); << endl;
return 0;
}
Ereditarietà pubblica, privata e protetta
Uno degli aspetti che abbiamo tralasciato finora è il qualificatore di accesso. Qual è infatti la sua utilità? E che cosa succede se al posto di public
usiamo private
o protected
?
La risposta a queste domande consiste nel fatto che C++ prevede diverse forme di ereditarietà, non tutte, come vedremo, necessariamente conformi alla regola is-a.
La tipologia di ereditarietà è dettata unicamente dal qualificatore di accesso usato. Quindi nel caso precedente é sufficiente cambiare public
con private
o protected
. Omettere il qualificatore caratterizza la relazione di ereditarietà come privata.
L'effetto che ha l'uso di un qualificatore rispetto ad un altro è illustrato nella figura seguente:
Il qualificatore usato per definire il vincolo di ereditarietà altera sostanzialmente il livello di accesso di ogni membro della classe base nelle sue derivate. In particolare:
- indipendentemente dal qualificatore scelto, i membri privati della classe base non sono mai accessibili nelle sue derivate, anche se ne fanno parte.
- posto che
public
è il livello massimo di accessibilità,protected
è intermedio eprivate
è il minimo, il qualificatore scelto per il vincolo di ereditarietà è il livello di accesso massimo consentito per i membri della classe base, nel contesto delle sue classe derivate. L'ereditarietà pubblica lascia quindi inalterati i membri pubblici e protetti, mentre quella protetta e privata appiattiscono i livelli di accesso a protetto e privato rispettivamente.
Nelle prossime lezioni, introducendo il concetto di polimorfismo, avremo modo di analizzare altri effetti prodotti dal tipo di ereditarietà scelto. Per adesso ci limitiamo a dire che il tipo di vincolo che lega la classe base alle sue derivate condiziona anche il contesto esterno ad esse.
Applicazioni dell'ereditarietà protetta e privata: la regola is-implemented-in-terms-of
Se applichiamo principi generali di buona programmazione e un po' di buon senso, è altamente probabile / preferibile che la nostra gerarchia sia conforme alla regola is-a, con classi base composte da soli membri pubblici e protetti. In questo modo, essi saranno sempre accessibili nelle classi direttamente o indirettamente derivate, garantendoci una maggiore libertà di azione.
Tuttavia esistono alcuni casi in cui l'ereditarietà protetta o privata è ammissibile, anche se non necessaria.
Per fare un esempio, riprendiamo adesso la modellazione della nostra gerarchia di forme: un altro ramo discendente dalla classe base Shape potrebbe essere quello che definisce forme curve chiuse, ad esempio il cerchio e l'ellisse. In questa sede, supponiamo di aver bisogno di una rappresentazione esatta, che non possiamo ottenere usando una classe derivata da Polygon, che approssimi tali forme con un poligono.
Se analizziamo queste entità in relazione alla regola is-a e facciamo un parallelo con il nostro esempio precedente tra poligono e rettangolo, noteremo una differenza fondamentale.
Il concetto di poligono generalizza quello di rettangolo: un rettangolo è infatti un poligono, ma un poligono non è necessariamente un rettangolo, ad esempio può essere un triangolo. Un cerchio invece è rappresentabile come un'ellisse i cui fuochi coincidono, ma è quanto meno opinabile dire che il concetto di ellisse generalizza quello di circonferenza. Esistono infatti altri tipi di ellisse?
L'esistenza di un unico caso particolare e la generalizzazione sono infatti concetti profondamente diversi; un triangolo può degenerare in un segmento, ma di certo non lo generalizza, e lo stesso può dirsi di ellisse e circonferenza.
Tuttavia, resta il fatto che possiamo applicare la definizione di ellisse per ottenere un cerchio, e supponiamo di voler tradurre questa particolarità nel nostro modello dati evidenziando un forte legame tra le due classi.
È chiaro che l'ereditarietà pubblica non sarebbe una via praticabile, per via della regola is-a che essa incarna: tutti i metodi pubblici di Ellipse farebbero infatti parte anche dell'interfaccia pubblica di Circle e ciò porterebbe a delle ridondanze o forzature (a che servono due fuochi dentro una circonferenza?).
Tuttavia possiamo ricorrere a quella privata o protetta e riutilizzare la classe Ellipse per l'implementazione di Circle, a beneficio del riutilizzo del codice.
Il listato seguente mostra la definizione della classe Ellipse, che deriva pubblicamente da Shape:
// ellipse.h
#ifndef ELLIPSE_H
#define ELLIPSE_H
#include "shape.h"
#include "point2d.h"
namespace Geometry
{
class Ellipse;
}
class Geometry::Ellipse : public Shape
{
public:
Ellipse(Point2D p1, Point2D p2, double axis1, double axis2, std::string lab="");
// ... metodi getter / setter
protected:
Point2D f1;
Point2D f2;
double minorAxis;
double majorAxis;
};
#endif
// ellipse.cpp
#include "ellipse.h"
namespace Geometry {
Ellipse::Ellipse(Point2D p1, Point2D p2, double axis1, double axis2, std::string lab)
: Shape(lab)
{
f1 = p1;
f2 = p2;
minorAxis = axis1;
majorAxis = axis2;
}
} // namespace Geometry
Per brevità, omettiamo la dichiarazione di tutti i membri dell'interfaccia pubblica di Ellipse, e ci limitiamo ad osservare che essa ha una sezione protetta che contiene i due fuochi e i due assi.
La classe Circle, illustrata nel listato successivo, eredita privatamente da Ellipse sia i membri pubblici che protetti, che seguendo le regole discusse nel paragrafo precedente diventano quindi privati per Circle.
Essa è dotata di un'interfaccia pubblica diversa da quella di Ellipse che ci consente di ragionare in termini di centro e raggio, anche se la sua implementazione interna fa uso di fuochi e assi (si noti il corpo del costruttore, in cui il centro è assegnato a entrambi i fuochi ed il diametro ad entrambi gli assi).
In questa forma, il vincolo gerarchico tra Ellipse e Circle non infrange la regola is-a, ma applica la regola is-implemented-in-terms-of. Abbiamo cioè usato l'ereditarietà per mettere in pratica quello che avevamo detto in precedenza riguardo il nostro modello dati: la circonferenza non è un'ellisse, ma possiamo usare un'ellisse come se fosse una circonferenza.
// circle.h
#ifndef CIRCLE_H
#define CIRCLE_H
#include "ellipse.h"
namespace Geometry
{
class Circle;
}
class Geometry::Circle : private Ellipse
{
public:
Circle(Point2D c, float r, std::string lab="");
Point2D getCenter() const;
void setCenter(Point2D f);
double getRadius() const;
void setRadius(double axis);
};
#endif
// circle.cpp
#include "circle.h"
namespace Geometry {
Circle::Circle(Point2D c, double r, std::string lab)
: Ellipse(c, c, 2*r, 2*r, lab) // <== invocazione del costruttore della classe base
{
}
Point2D Circle::getCenter() const
{
return f1; // o f2, indifferentemente
}
void Circle::setCenter(Point2D f)
{
f1 = f;
f2 = f;
}
double Circle::getRadius() const
{
return majorAxis / 2.0; // o minorAxis / 2.0
}
void Circle::setRadius(double axis)
{
minorAxis = axis;
majorAxis = axis;
}
} // namespace Geometry
Composizione o eredità protetta/privata ?
L'ereditarietà privata o protetta sono molto spesso associate alla composizione, cioè la definizione di classi a partire da altri oggetti che ne sono dati membro. Per fare un esempio, avremmo anche potuto definire la classe Circle come diretta derivata di Shape, con l'aggiunta di un dato membro di tipo Ellipse da usare per la sua implementazione.
Ad una prima analisi, i due approcci potrebbero sembrare equivalenti. Quando una classe eredita privatamente da un'altra infatti, la classe base è un'entità inaccessibile dall'esterno, come se fosse un membro privato o protetto. Tuttavia, quando parleremo di polimorfismo, scopriremo che esiste una profonda differenza tra queste due forme di aggregazione, e che l'ereditarietà protetta o privata è più difficile da manutenere ed è meno espandibile, oltre che raramente necessaria.