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 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.
override
area()
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
area()
Ellipse
virtual double Area() const override; // Attenzione: la "a" di area è minuscola in Shape
La presenza di override
Area()
Ellipse
Shape
Area()
Shape
Senza lo specificatore override
Area
Ellipse
area()
Shape
L'uso di override
Lo specificatore final
- 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
pattern
Uso di final
Un caso d'uso più ricorrente di final
definizione di classi che non possono essere derivate
// 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
final
ereditarietà privata e protetta
Circle
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.