L'ereditarietà è uno dei presupposti per una delle più grandi innovazioni della programmazione a oggetti rispetto i paradigmi precedenti: il polimorfismo.
Nelle lezioni precedenti abbiamo appreso che in C++, a differenza di altri linguaggi, l'uso delle variabili è fortemente vincolato al loro tipo, la cui definizione contribuisce al determinare la correttezza sintattica delle singole istruzioni a tempo di compilazione.
Ad esempio, se una variabile viene definita di tipo puntatore a double
, essa non potrà essere usata per referenziare un valore intero, né tanto meno una stringa o un valore booleano.
int val = 2;
double* dptr = &val; // errore ptr e &val sono di tipo differente!
Il motivo è semplice: int
e double
sono tipi con caratteristiche differenti. Ad esempio, su molte architetture un intero viene rappresentato con 32 bit mentre un valore floating point a doppia precisione ne richiede 64. Se quindi dereferenziassimo una variabile puntatore a double
che, per assurdo, contiene l'indirizzo di memoria di un intero, con tutta probabilità provocheremmo una violazione di accesso in memoria e la terminazione immediata del programma.
Questo genere di errori, che riguarda il layouting degli oggetti in memoria, è facilmente predicibile dal compilatore, che infatti in questo caso notifica un errore sintattico e interrompe la compilazione.
Il polimorfismo nella programmazione a oggetti altera il meccanismo di controllo dei tipi nel modo seguente: quando due o più tipi sono strutturati secondo una gerarchia, cioè sussistono dei vincoli di ereditarietà tra di essi, un puntatore al tipo della classe base può essere usato per referenziare anche oggetti di classi derivate.
I tipi appartenenti ad una gerarchia sono cioè compatibili, e l'esempio seguente, che fa uso della gerarchia definita nella lezione precedente, si avvale di questo principio:
// Shape è la classe base di Polygon,
// quindi un puntatore al tipo Shape è compatibile con uno al tipo Polygon
Polygon poly;
Shape *shape_ptr = &poly;
In questo caso, quindi, l'istruzione che assegna al puntatore di tipo Shape l'indirizzo di un oggetto di tipo Polygon non genera alcun errore, sebbene Shape e Polygon siano tipi differenti.
Ciò che rende "sicura" l'assegnazione del puntatore shape_ptr è il particolare modo di rappresentare gli oggetti in memoria di C++, che nel caso di classi derivate è basato su una tecnica nota come slicing, illustrata nella figura seguente:
Ad un oggetto di una classe derivata corrisponde in memoria una struttura a strati, uno per ognuna della sue classi base. Poichè la porzione di oggetto che contiene i dati membro della classe base non può eccedere la dimensione della sua derivata, è possibile dereferenziare il puntatore shape_ptr senza incorrere in potenziali violazioni di accesso alla memoria.
Al contrario, tentare di accedere all'istanza di una classe tramite un puntatore ad una sua derivata come mostrato nel frammento seguente, è un'operazione non ammissibile e genera inevitabilmente un errore di compilazione, per gli stessi motivi discussi in precedenza.
Shape shp;
Polygon* poly_ptr = &shp; // errore!!
Tuttavia, lo slicing in sé non esaurisce l'implementazione del polimorfismo in C++. Anzi, esso è semplicemente un altro degli espedienti che consente di rendere compatibile la definizione di struct
propria del linguaggio C con la definizione di classi in C++.
La vera potenza semantica del polimorfismo si cela infatti dietro il meccanismo di associazione dinamica dei metodi alle istanze di oggetti appartenenti a gerarchie di tipi (dynamic binding). Tale meccanismo si estrinseca mediante l'uso della parola riservata virtual
, i cui casi d'impiego saranno oggetto del prossimo paragrafo e delle lezioni successive.
Metodi virtual
L'applicazione della parola chiave virtual
ai metodi di una classe consente di condizionare l'esecuzione del codice secondo il tipo dell'istanza oggetto cui si fa riferimento.
Nell'esempio seguente, riprendiamo in esame la nostra gerarchia di forme per illustrarne l'applicazione mediante l'introduzione di un metodo per il calcolo dell'area, che ovviamente va specializzato in ogni sotto classe. Come illustrato nel listato seguente, supponiamo che un oggetto generico di tipo Shape abbia un'implementazione del metodo area()
che restituisce 0, riservandoci di particolarizzare il calcolo dell'area per gli oggetti più specializzati della gerarchia.
#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;
// metodo virtuale per calcolare l'area di una forma
virtual double area() const { return 0; };
protected:
std::string label;
};
#endif
Osserviamo che alla dichiarazione del metodo area()
è applicato il modificatore virtual
. Inoltre, per brevità, l'implementazione del metodo è fornita contestualmente alla sua dichiarazione, cosa che lo rende implicitamente un metodo inline, e l'uso del qualificatore const
garantische che l'invocazione di tale metodo non modificherà lo stato, cioè il valore dei dati membro, dell'istanza da cui viene invocato.
Nel listato successivo è mostrata la sintassi secondo la quale la classe Ellipse viene dotata di un'implementazione specifica del metodo area()
, la cui dichiarazione viene ripetuta nella dichiarazione della classe:
// 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
// sovraccaricamento del metodo virtuale per calcolare l'area
virtual double area() const;
protected:
Point2D f1;
Point2D f2;
double minorAxis;
double majorAxis;
};
#endif
// ellipse.cpp
#include "ellipse.h"
#include <cmath>
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;
}
double Ellipse::area() const
{
return M_PI * (majorAxis / 2.0) * (minorAxis / 2.0);
}
} // namespace Geometry
Una volta dotata la nostra gerarchia di un metodo virtuale è possibile mettere alla prova il comportamento polimorfico degli oggetti in C++ come illustrato nel listato seguente:
#include "ellipse.h"
#include <iostream>
using namespace Geometry;
using namespace std;
void print_area(Shape* s_ptr)
{
if (s_ptr != nullptr)
{
cout << s_ptr->getLabel() << " has an area of " << s_ptr->area() << " sq." << endl;
}
}
int main()
{
Point2D f1(-1,0);
Point2D f2(1,0);
Shape* shape_ptr = new Shape("My Shape");
Ellipse* ellipse_ptr = new Ellipse(f1, f2, 5, 2.5, "My Ellipse");
print_area(shape_ptr);
print_area(ellipse_ptr); // <--- upcasting del tipo della classe derivata
delete shape_ptr;
delete ellipse_ptr;
return 0;
}
La funzione print_area()
ha come unico parametro di ingresso un puntatore al tipo della classe base Shape ed all'interno del suo corpo di istruzioni invoca l'esecuzione del metodo virtuale area()
.
Per effetto del comportamento polimorfico, l'invocazione di tale metodo sulle istanze puntate da shape_ptr ed ellipse_ptr si traduce in una chiamata alle funzioni implementate rispettivamente nelle due classi.
Pertanto, l'esecuzione di tale programma produrrà come risultato l'output seguente:
My Shape has an area of 0 sq.
My Ellipse has an area of 9.81748 sq.
La compatibilità tra tipi di classi base e derivate fa sì che avvenga una conversione implicita del tipo del puntatore della classe derivata ad quello della della classe base (upcasting), mentre l'uso di metodi virtuali consente di invocare la corretta implementazione del metodo area()
basandosi sull'effettiva natura dell'istanza che viene passata per riferimento.
Per ottenere lo stesso risultato senza l'applicazione del polimorfismo, sarebbe necessario ricorrere al sovraccaricamento della funzione print_area() con argomenti di tipo differente, uno per ogni classe derivata da Shape.
Polimorfismo e puntatori (o reference)
A differenza di altri linguaggi, in C++ per beneficiare del polimorfismo bisogna fare uso di puntatori. La ragione di ciò è puramente implementativa, e ancora una volta è connessa al retaggio di C++, la retro-compatibilità con il linguaggio C.
Per fare un esempio pratico, consideriamo il caso seguente: modifichiamo la firma e l'implementazione della funzione print_area() di modo che essa accetti un'istanza di tipo Shape piuttosto che un puntatore.
void print_area(Shape s_obj)
{
cout << s_obj.getLabel() << " has an area of " << s_obj.area() << " sq." << endl;
}
Ciò implica ovviamente la necessità di modificare la sua invocazione nel main() come illustrato di seguito:
print_area(*shape_ptr);
print_area(*ellipse_ptr);
Il risultato di questa modifica è che l'invocazione del metodo area()
restituisce in entrambi i casi il valore 0, come illustrato di seguito.
My Shape has an area of 0 sq.
My Ellipse has an area of 0 sq.
Perché? La risposta è legata ad un effetto collaterale dello slicing e ai meccanismi di conversione implicita tra tipi derivati.
Poiché in questa versione alternativa della funzione print_area() gli oggetti sono passati per copia e non per riferimento, l'argomento di tipo Ellipse viene prima implicitamente convertito a Shape (ancora una volta upcasting), ed in seguito ne viene fatta la copia per essere passato come argomento della funzione print_area().
Tuttavia, a seguito della conversione di tipo, per la costruzione dell'argomento s_obj
, il compilatore selezionerà il costruttore di copia della classe base Shape, piuttosto che della classe derivata Ellipse, e pertanto solo la porzione di *ellipse_ptr
che corrisponde alla classe base Shape verrà copiata.
Di conseguenza, l'invocazione del metodo area() si traduce nell'invocazione del metodo della classe base, e non della derivata, come ci aspetteremmo.
Il medesimo problema riguarda ovviamente tutte le istruzioni che contemplano una copia o uno spostamento tra oggetti, non solo le chiamate a funzione.
Quando invece passiamo le istanze per indirizzo o riferimento, la conversione esplicita di tipo da Shape* a Ellipse* e la copia riguardano solo l'indirizzo dell'oggetto. La successiva dereferenziazione dell'argomento punta quindi direttamente all'oggetto di tipo Ellipse originale.
Il comportamento polimorfico è alla base di numerosi design pattern e semplifica notevolmente la gestione di oggetti eterogenei che condividono un'interfaccia comune. Nella prossima lezione si analizzeranno alcuni meccanismi implementativi che rendono il polimorfismo possibile da un punto di vista tecnico al fine di approfondire ulteriormente la conoscenza del linguaggio in sé e dei compilatori.