Polimorfismo e funzioni virtuali in C++
Il polimorfismo è la capacità di trattare oggetti di tipi diversi attraverso un'interfaccia comune, lasciando che ciascuno risponda secondo la propria natura. È uno degli aspetti più potenti della programmazione orientata agli oggetti, perché consente di scrivere codice generico che opera su una gerarchia di classi senza conoscere in anticipo il tipo concreto di ogni oggetto. In C++ il polimorfismo a runtime si realizza attraverso le funzioni virtuali.
Il problema dello slicing e del legame statico
Senza funzioni virtuali, quando si invoca un metodo attraverso un puntatore o un riferimento alla classe base, viene scelta la versione della classe base, indipendentemente dal tipo effettivo dell'oggetto. Questo comportamento, detto legame statico, è deciso in fase di compilazione e impedisce alla classe derivata di personalizzare il comportamento quando vi si accede tramite la base.
La parola chiave virtual
Dichiarando un metodo come virtual nella classe base, si attiva il legame dinamico: la versione del metodo eseguita viene scelta a runtime in base al tipo reale dell'oggetto, non al tipo del puntatore o del riferimento. Nella classe derivata si annota la ridefinizione con la parola chiave override, che documenta l'intento e fa segnalare al compilatore eventuali errori di firma.
#include <iostream>
class Shape {
public:
// Metodo virtuale: puo essere ridefinito dalle derivate
virtual void draw() const {
std::cout << "Disegno una figura generica" << std::endl;
}
};
class Circle : public Shape {
public:
void draw() const override {
std::cout << "Disegno un cerchio" << std::endl;
}
};
class Square : public Shape {
public:
void draw() const override {
std::cout << "Disegno un quadrato" << std::endl;
}
};
Polimorfismo in azione
Il vero vantaggio emerge quando si manipolano oggetti diversi attraverso puntatori o riferimenti alla classe base. Lo stesso identico codice produce comportamenti differenti a seconda del tipo reale dell'oggetto, senza alcun controllo esplicito sul tipo.
#include <vector>
#include <memory>
int main() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>());
shapes.push_back(std::make_unique<Square>());
// Ogni figura risponde secondo il proprio tipo reale
for (const auto& shape : shapes) {
shape->draw();
}
return 0;
}
Pur scorrendo un contenitore di puntatori a Shape, ogni chiamata a draw esegue la versione corretta: quella di Circle per il cerchio e quella di Square per il quadrato. Questo è il cuore del polimorfismo a runtime.
Il distruttore virtuale
Quando una classe è pensata per essere usata come base polimorfica, il suo distruttore deve essere dichiarato virtual. In caso contrario, distruggendo un oggetto derivato attraverso un puntatore alla base, verrebbe invocato solo il distruttore della base, lasciando le risorse della parte derivata non rilasciate. È una regola fondamentale per evitare perdite di memoria.
class Base {
public:
// Distruttore virtuale: indispensabile nelle gerarchie polimorfiche
virtual ~Base() = default;
};
Funzioni virtuali pure e classi astratte
A volte una classe base rappresenta un concetto talmente generico che non ha senso fornire un'implementazione predefinita per alcuni metodi. In questi casi si dichiara una funzione virtuale pura, assegnandole il valore zero. Una classe che contiene almeno una funzione virtuale pura diventa astratta: non può essere istanziata direttamente e funge solo da interfaccia che le classi derivate devono completare.
class Drawable {
public:
// Funzione virtuale pura: rende la classe astratta
virtual void draw() const = 0;
virtual ~Drawable() = default;
};
class Triangle : public Drawable {
public:
// Obbligatorio implementare draw per poter istanziare Triangle
void draw() const override {
std::cout << "Disegno un triangolo" << std::endl;
}
};
Le classi astratte definiscono contratti che le classi concrete devono rispettare, e sono lo strumento principale per progettare interfacce flessibili. Il polimorfismo, combinato con le funzioni virtuali pure, permette di scrivere codice aperto all'estensione: si possono aggiungere nuove classi derivate senza modificare il codice che le utilizza attraverso l'interfaccia comune.