Nell'introduzione al polimorfismo in C++, abbiamo introdotto lo specificatore virtual
che si applica ai metodi di classe per i quali si vuole garantire un'associazione dinamica a tempo di esecuzione.
Questo meccanismo di base può essere raffinato ulteriormente grazie all'uso di altri specificatori che il linguaggio C++ mette a disposizione per la definizione di metodi virtuali.
L'uso consapevole di tali costrutti ha, come sempre, il duplice scopo di incrementare l'espressività del nostro codice e di prevenire errori concettuali o da distrazione segnalandoli già a tempo di compilazione.
Metodi virtuali puri e classi astratte
Un metodo virtuale puro è un metodo privo di definizione, senza cioè un blocco di istruzioni (anche vuoto) ad esso associato.
Per dicharare un metodo vituale puro è sufficiente apporre lo specificatore = 0
alla fine della firma del metodo.
Il listato seguente mostra l'applicazione di tale specificatore al metodo area()
della classe generica Shape che avevamo introdotto in precedenza:
#ifndef SHAPE_H
#define SHAPE_H
#include
namespace Geometry
{
class Shape;
}
class Geometry::Shape
{
public:
Shape(std::string lab="");
void setLabel(std::string lab);
std::string getLabel() const;
// definizione di un metodo virtuale puro
virtual double area() const = 0;
protected:
std::string label;
};
#endif
L'uso dello specificatore per metodi virtuali puri esonera quindi il programmatore dalla necessità di fornire un'implementazione, eventualmente anche minimale, del metodo in questione.
Nel caso in esame infatti, essendo Shape una classe base del nostro modello dati, è opportuno che essa definisca un metodo per il calcolo dell'area, in quanto essa è una proprietà che caratterizza trasversalmente ogni tipo di forma.
Tuttavia, poichè la classe Shape non ha elementi che rendono possibile il calcolo dell'area, la reale implementazione di tale metodo deve essere demandata alle classi derivate.
Per arginare il problema, in precendenza, avevamo dotato il metodo Shape::area()
di un'implementazione di comodo, il cui unico effetto era quello di restituire il valore 0.
Sebbene non ci sia nulla di sbagliato da un punto di vista sintattico, questa soluzione non è probabilmente la migliore dal punto di vista concettuale, perchè nella nostra intenzione la definizione del metodo area()
in Shape non è certo finalizzata alla restituzione di un valore noto e immutabile, ma è il mezzo che ci consente di trasmettere questa caratteristica a tutte le classi da essa derivate.
In pratica, la definzione di un metodo virtuale puro all'interno di una classe definisce uno schema di comportamento al quale tutte le derivate dovranno adattarsi, senza imporre la necessità di fornire implementazioni incongrue nella classe base.
Si definisce astratta una classe in cui è dichiarato almeno un metodo virtuale puro.
Il termine "astatto" sottolinea un effetto collaterale (e prevedibile) della definizione dei metodi virtuali puri: una classe astratta non può essere istanziata. Le istruzioni seguenti genereranno quindi inevitabilemente degli errori di compilazione:
Shape shape; // errore
Shape* shape_ptr = new Shape(); // errore
Il compilatore previene l'uso di istanze di classi astratte, poichè sono oggetti non completamente definiti. Cosa comporterebbe altrimenti, l'invocazione del metodo area()
su uno di tali oggetti se esso è privo della sua definizione?
È tuttavia possibile utilizzare le classi astratte in applicazione dei principi del polimorfismo, sotto forma di puntatori a oggetti di tutte le classi derivate che forniscono un'implementazione dei metodi virtuali puri ereditati.
Una classe derivata che non implementa anche solo uno dei metodi virtuali puri di una sua classe base è anch'essa astratta.
La definizione di classi astratte, in congiunzione con l'ereditarietà multipla di cui parleremo nelle lezioni successive, è un costrutto proprio di C++ che copre la mancanza di costrutti di più alto livello, come le interfacce, che sono propri di linguaggi in cui il paradigma della programmazione a oggetti è prevalente rispetto agli altri, ad esempio Java o C#.
override e final
Lo standard c++11 ha introdotto due nuovi specificatori accessori per la definizione dei metodi virtuali.
Il primo di essi è override
e serve per specificare che il metodo cui esso si applica sovraccarica un metodo virtuale derivato da una classe base.
Nel listato seguente vediamo l'applicazione di override
al metodo area()
reimplementato nella classe Ellipse.
// 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 con specificatore override
virtual double area() const override;
protected:
Point2D f1;
Point2D f2;
double minorAxis;
double majorAxis;
};
#endif
L'uso di questo specificatore rende possibile prevenire banali errori di digitazione poichè consente al compilatore di notificare eventuali mancate corrispondenze tra la firma del metodo virtuale dichiarato nella classe base e quello ridefinito dalla classe derivata.
Come esempio dell'utilità di override
, si consideri di alterare per errore, ad esempio per una distrazione, la firma del metodo area()
definito nella classe Ellipse come segue:
virtual double Area() const override; // Attenzione: la "a" di area è minuscola in Shape
La presenza di override
informa il compilatore del fatto che il metodo Area()
definito in Ellipse è un metodo derivato, nel nostro caso da Shape. In ragione di ciò il compilatore ricerca il metodo Area()
nella definizione di Shape, e non trovandolo emette un errore ed interrompe la compilazione del nostro programma.
Senza lo specificatore override
, il metodo Area
sarebbe invece stato interpretato come un nuovo metodo virtuale, da trasmettere alle classi derivate da Ellipse insieme al metodo area()
derivato da Shape. È un errore banale, ma ha ripercussioni molto forti sulla consistenza del nostro modello dati.
L'uso di override
, inoltre, rende più evidenti i vincoli di ereditarietà nella definizione di una classe, in quanto distingue quei metodi virtuali che sono propri della classe stessa da quelli che sono invece dovuti al suo "retaggio". In altre parole, oltre ad essere una sentinella per errori da "copia e incolla", è anche una forma di documentazione intrinseca al codice stesso.
Lo specificatore final
ha uno scopo totalmente differente e si applica in due casi:
- la definizione di un metodo virtuale, come
override
; - la definizione di una classe e classi derivate.
Se applicato alla definizione di un metodo virtuale, esso previene che tale metodo possa essere sovraccaricato nelle classi da esso derivate. Ad esempio, può essere utile per preservare la funzionalità di un metodo in una gerarchia di classi, prevendendo la possibilità che esso venga sovraccaricato da classi derivate.
L'uso di final
a tale scopo, tuttavia, si applica a pattern di programmazione o esigenze specifiche, in quanto può rivelarsi limitante e restrittivo se applicato senza criterio.
Uso di final nella dichiarazione di una classe
Un caso d'uso più ricorrente di final
che non riguarda la definizione dei metodi virtuali e che citiamo per completezza, è quello della definizione di classi che non possono essere derivate.
Come esempio pratico, si consideri di alterare la definizione della classe Ellipse come segue:
// ellipse.h
namespace Geometry
{
class Ellipse;
}
// uso di final nella dichiarazione di Ellipse
class Geometry::Ellipse final : public Shape
{
public:
Ellipse(Point2D p1, Point2D p2, double axis1, double axis2, std::string lab="");
// ... metodi getter / setter
virtual double area() const override;
// membri protetti
};
In questo caso, definendo la classe Ellipse come final
, preveniamo la possibilità di derivarla ulteriormente, come abbiamo fatto in precedenza, quando abbiamo parlato di ereditarietà privata e protetta per definire la classe Circle avvalendoci della regola is-implemented-in-terms-of.
Anche in questo caso, l'uso di questo specificatore si rivela utile per imprimere i dettagli del nostro modello concettuale nella codifica senza ambiguità.
Prevenire la possibilità di derivare una classe ha lo scopo di segnalare ai possibili utilizzatori che essa non si presta ad essere estesa nè specializzata, o che un'eventuale derivazione di tale classe violerebbe in qualche modo il nostro modello dati.
Come esempio, si considerino classi di libreria che definiscono metodi di utilità, ad esempio manipolazione di stringhe o funzioni aritmetiche che sono progettate per essere usate mediante composizione, piuttosto che per sfruttare il comportamento polimorfico.