C++ - polimorfismo
In questa pagina 5
Polimorfismo significa, letteralmente, "molte forme". Questa nota mette insieme gli strumenti già visti (C++ - ereditarietà, funzioni virtuali e layout degli oggettiL'ereditarietà pubblica (class D : public B) esprime la relazione "is a": D eredita da B i membri public e protected e li estende; la composizione esprime invece "has a". Una classe base di una gerarchia disabilita copia e assegnamento (=delete) perché copiando una derivata in una base si ha lo slicing. Una funzione virtuale può essere ridefinita nelle derivate (override: stesso nome, tipi e constness) e la funzione chiamata si decide a run-time in base al tipo dell'oggetto, non del puntatore o del riferimento; override esplicito con la keyword override; una funzione virtuale pura (= 0) obbliga le derivate a ridefinirla e rende astratta la classe; il distruttore di una classe base polimorfa è virtuale. Il meccanismo è realizzato con una vtbl per classe (tabella di puntatori alle funzioni) e un vptr in ogni oggetto; i dati della derivata seguono quelli della base.C++ - ereditarietà, funzioni virtuali e layout degli oggetti → e C++ - template e programmazione genericaun template permette di usare un tipo (o un intero noto in compilazione) come parametro di una classe o di una funzione; il compilatore genera il codice per ogni tipo per cui il template è specializzato (template instantiation, a tempo di compilazione): il codice è efficiente ma gli errori sono lunghi e oscuri, e il compilatore controlla sia il template sia il suo argomento; la notazione è template
Definizione (polimorfismo). È la capacità di associare comportamenti specifici diversi a un'unica notazione generica. La stessa espressione, per esempio
obj.draw(), produce effetti diversi a seconda del tipo dell'oggetto.
La terminologia varia da autore ad autore. Il corso adotta quella di Vandevoorde e Josuttis, più chiara: si distinguono polimorfismo dinamico e polimorfismo statico.
| polimorfismo dinamico | polimorfismo statico | |
|---|---|---|
| strumento | ereditarietà e funzioni virtuali | template |
| quando si decide | run-time (binding dinamico) | compile-time (binding statico) |
| vincolo sui tipi | vincolato (bounded): l'interfaccia delle derivate è fissata dalla classe base | svincolato (unbounded): non c'è una base, basta che i tipi abbiano la sintassi richiesta |
| funzioni generate | una sola | una per ogni tipo usato |
| collezione di tipi diversi | sì, vector<Base*> |
no, vector<T> contiene un solo T |
Polimorfismo dinamico
Si realizza con ereditarietà e funzioni virtuali. Il progetto:
- si individua l'insieme di funzionalità comuni tra classi collegate;
- si crea il corrispondente insieme di funzioni virtuali (pure) nella classe base;
- ogni classe derivata specializza la funzionalità comune con un override.
Esempio classico: forme geometriche. Stessa interfaccia nella base GeoObj (draw, center_of_gravity), esecuzione diversa per ogni forma.
#include <iostream>
#include <vector>
using namespace std;
// POLIMORFISMO DINAMICO: ereditarieta' + funzioni virtuali (legato, binding a run-time)
class GeoObj {
public:
virtual void draw() const = 0;
virtual double center_of_gravity() const = 0; // ascissa del baricentro (semplificata)
virtual ~GeoObj() {}
};
class Circle : public GeoObj {
public:
Circle(double cx, double r) : cx_{cx}, r_{r} {}
void draw() const override { cout << "cerchio r=" << r_ << '\n'; }
double center_of_gravity() const override { return cx_; }
private:
double cx_, r_;
};
class Line : public GeoObj {
public:
Line(double a, double b) : a_{a}, b_{b} {}
void draw() const override { cout << "linea " << a_ << ".." << b_ << '\n'; }
double center_of_gravity() const override { return (a_ + b_) / 2; }
private:
double a_, b_;
};
void MyDraw(const GeoObj& obj) // UNA sola funzione, compilata una volta
{
obj.draw(); // chiama draw() della classe effettiva dell'oggetto
}
int main()
{
Circle c{0, 2};
Line l{2, 6};
vector<const GeoObj*> scena{&c, &l}; // collezione ETEROGENEA: tipi diversi insieme
for (const GeoObj* g : scena) { MyDraw(*g); cout << " baricentro " << g->center_of_gravity() << '\n'; }
}Uscita: cerchio r=2, baricentro 0, linea 2..6, baricentro 4. Basta una funzione MyDraw per disegnare tutte le forme: la differenziazione è fatta dalle varie draw(). L'interfaccia è vincolata: chi vuole entrare nella scena deve derivare da GeoObj e implementare le sue funzioni. La scelta della funzione avviene a run-time tramite vptr e vtbl, quindi con un piccolo costo per chiamata.
Polimorfismo statico
Presuppone che le varie forme condividano la stessa sintassi: le classi sono definite in modo indipendente, ma devono poter essere usate con le stesse espressioni (draw(), center_of_gravity()). Il polimorfismo si concretizza istanziando un template con tipi diversi.
#include <iostream>
#include <vector>
using namespace std;
// POLIMORFISMO STATICO: template (binding a tempo di compilazione). Nessuna classe base:
// Circle, Line e Rectangle sono indipendenti, condividono solo la SINTASSI draw()/center_of_gravity().
class Circle {
public:
Circle(double cx, double r) : cx_{cx}, r_{r} {}
void draw() const { cout << "cerchio r=" << r_ << '\n'; }
double center_of_gravity() const { return cx_; }
private:
double cx_, r_;
};
class Line {
public:
Line(double a, double b) : a_{a}, b_{b} {}
void draw() const { cout << "linea " << a_ << ".." << b_ << '\n'; }
double center_of_gravity() const { return (a_ + b_) / 2; }
private:
double a_, b_;
};
class Rectangle {
public:
Rectangle(double x, double w) : x_{x}, w_{w} {}
void draw() const { cout << "rettangolo larghezza " << w_ << '\n'; }
double center_of_gravity() const { return x_ + w_ / 2; }
private:
double x_, w_;
};
template <typename GeoObj>
void MyDraw(const GeoObj& obj) // il compilatore genera MyDraw<Circle>, MyDraw<Line>, ...
{
obj.draw(); // risolto a tempo di compilazione, puo' essere inline
}
int main()
{
Circle c{0, 2};
Line l{2, 6};
Rectangle r{1, 4};
MyDraw(c); // MyDraw<Circle>
MyDraw(l); // MyDraw<Line>
MyDraw(r); // MyDraw<Rectangle>
vector<Circle> soli_cerchi{c, Circle{1, 1}}; // un vector<T> contiene un solo T
for (const Circle& x : soli_cerchi) MyDraw(x);
// MyDraw(42); // errore a tempo di compilazione: int non ha draw()
}Uscita: cerchio r=2, linea 2..6, rettangolo larghezza 4, cerchio r=2, cerchio r=1. Qui non esiste una MyDraw: ne esistono molte (MyDraw<Circle>, MyDraw<Line>, MyDraw<Rectangle>), generate dal compilatore. Se si prova MyDraw(42) l'errore appare in compilazione (member reference base type 'const int' is not a structure or union), non a run-time. Circle, Line e Rectangle non hanno una base comune e non si possono mescolare in un solo contenitore.
Programmazione generica
Il polimorfismo statico è legato alla programmazione generica. Una visione semplicistica dice "programmazione generica = usare i template", ma così si confonde un concetto con uno strumento. Più correttamente:
Programmazione generica = scrivere codice che funziona con tipi diversi, forniti successivamente, posto che tali tipi soddisfino determinati requisiti sintattici e semantici. Lo strumento del linguaggio per farlo è il template.
Gli esempi più importanti sono gli algoritmi della libreria standard, che richiedono ai tipi solo le operazioni di cui hanno bisogno (< per sort, == per find, iteratori per le sequenze): vedi STL - algoritmi, predicati e lambdaLa STL ha circa 80 algoritmi generici (header
Quale scegliere
| situazione | polimorfismo preferibile |
|---|---|
| serve una collezione di oggetti di tipo diverso (una scena con cerchi e linee) | dinamico |
| il tipo è noto a compile-time e conta la velocità; tipi senza parentela, anche built-in | statico |
| si vuole un'interfaccia esplicitamente dichiarata e controllata dal compilatore | dinamico (classe astratta) |
| si vuole evitare il costo della chiamata virtuale (vptr, vtbl, niente inline) | statico |
| si vuole evitare la duplicazione di codice generato per ogni tipo | dinamico |
Altre forme di polimorfismo
Altri meccanismi si possono considerare polimorfismo, perché la stessa notazione ha effetti diversi:
- l'overloading di funzioni:
print(int)eprint(string)hanno lo stesso nome; il compilatore sceglie la versione a tempo di compilazione sul tipo degli argomenti; - l'overloading degli operatori (C++ - enumerazioni e overloading degli operatoriun'enumerazione è un tipo definito dall'utente che specifica un insieme di valori simbolici (enumeratori); enum class crea uno scope (Month::jan), non si converte implicitamente in int, e i valori partono da 0 o dal valore del precedente più uno; l'overloading degli operatori permette di definire gli operatori esistenti per tipi definiti dall'utente (almeno un operando UDT, stesso numero di operandi e stessa sintassi) scrivendo una funzione di nome operator+, operator++, operator[], ...; si può fare con una helper function (due argomenti per un operatore binario, il primo può essere di qualunque tipo) o con una funzione membro (un argomento in meno, perché il primo operando è l'oggetto, quindi l'UDT deve stare a sinistra: T + int sì, int + T no); << e >> sono helper function che prendono e restituiscono ostream& e istream&; ++ ha due forme (pre e post, quest'ultima con un int fittizio); gli operatori più utili sono =, ==, !=, <, [], ().C++ - enumerazioni e overloading degli operatori →):
+suinte+suRational.
Sono forme di polimorfismo statico (scelta a tempo di compilazione) e senza gerarchia di classi.
Navigare nella gerarchia: upcasting, downcasting, dynamic_cast
Con una gerarchia si può voler convertire un puntatore o un riferimento da una classe all'altra.
- Upcasting (da derivata a base): è sempre lecito, perché un oggetto di classe derivata è un oggetto di classe base. Non richiede nessun cast:
Animal* a = &dog;. - Downcasting (da base a derivata): non è sempre lecito (un
Animal*può puntare a unDogo a unCat), si fa condynamic_cast. Funziona con puntatori e riferimenti ed è l'unico cast che fa una verifica a run-time. Se il cast non è lecito:- con un puntatore restituisce
nullptr; - con un riferimento lancia l'eccezione
std::bad_cast(non esiste un riferimento nullo).
- con un puntatore restituisce
Il tipo di partenza deve essere polimorfo (avere almeno una funzione virtuale).
#include <iostream>
using namespace std;
class Animal { public: virtual ~Animal() {} }; // base polimorfa (ha una virtuale)
class Dog : public Animal { public: void bark() const { cout << "bau\n"; } };
class Cat : public Animal { public: void meow() const { cout << "miao\n"; } };
void tryBark(Animal* a)
{
Dog* d = dynamic_cast<Dog*>(a); // downcasting con verifica a run-time
if (d) d->bark(); // il puntatore e' valido solo se l'oggetto e' davvero un Dog
else cout << "non e' un cane (nullptr)\n";
}
int main()
{
Dog dog; Cat cat;
Animal* a1 = &dog; // UPCASTING: implicito, sempre lecito
Animal* a2 = &cat;
tryBark(a1); // Dog -> ok
tryBark(a2); // Cat -> nullptr
}Uscita: bau, non e' un cane (nullptr). Con i riferimenti:
#include <iostream>
#include <typeinfo>
using namespace std;
class Animal { public: virtual ~Animal() {} };
class Dog : public Animal { public: void bark() const { cout << "bau\n"; } };
class Cat : public Animal {};
int main()
{
Cat cat;
Animal& a = cat;
try {
Dog& d = dynamic_cast<Dog&>(a); // per i riferimenti non esiste il "riferimento nullo"
d.bark();
} catch (const bad_cast&) { // quindi se il cast non e' lecito lancia bad_cast
cout << "bad_cast: a non e' un Dog\n";
}
}Il programma compila senza avvisi per Linux; non è stato eseguito in WebAssembly (le eccezioni non vi funzionano) e l'uscita prevista è bad_cast: a non e' un Dog, perché l'oggetto è un Cat. Se ci si trova a fare spesso dynamic_cast a catena per sapere "che tipo è questo?", di solito è il segnale che quella funzionalità doveva essere una funzione virtuale: il downcasting si riserva ai casi in cui serve davvero una funzione presente solo nella derivata. Le eccezioni sono trattate in C++ - eccezioni e gestione delle risorse (RAII)Un errore che non si può gestire dove avviene si segnala con throw e si gestisce con try/catch più in alto; la libreria standard ha la gerarchia std::exception (logic_error e runtime_error con le loro derivate) e le eccezioni proprie si derivano pubblicamente da una di esse; si cattura per riferimento. Le eccezioni mettono in crisi il codice che acquisisce risorse (memoria con new, file, lock, socket): se una chiamata lancia prima della delete, la risorsa si perde. La soluzione è RAII (Resource Acquisition Is Initialization): acquisire la risorsa nel costruttore di un oggetto locale e rilasciarla nel distruttore, perché all'uscita da uno scope, anche per eccezione (stack unwinding), i distruttori degli oggetti locali vengono chiamati. Garanzie di una funzione: basic (nessuna perdita, stato valido), strong (commit or rollback: se fallisce lo stato osservabile è quello di prima) e no-throw (non lancia).C++ - eccezioni e gestione delle risorse (RAII) →.
Domande d'esame
1. Descrivere il polimorfismo in C++ e le due forme viste, con le differenze, gli strumenti e un esempio di codice per ciascuna.
Traccia. Stessa notazione, comportamenti diversi. Dinamico: ereditarietà + virtuali, vincolato dalla base, binding a run-time (vptr/vtbl), collezioni eterogenee; esempio GeoObj con draw virtuale pura e vector<GeoObj*>. Statico: template, svincolato (basta la stessa sintassi), binding a compile-time, una funzione generata per tipo, nessun costo a run-time; esempio template<typename T> void MyDraw(const T& o){o.draw();} chiamato con Circle, Line.
2. Che differenza c'è tra upcasting e downcasting? Cosa fa dynamic_cast quando il cast non è lecito?
Traccia. Upcasting implicito e sempre lecito (is a). Downcasting con dynamic_cast, solo su tipi polimorfi, unico cast verificato a run-time: puntatori danno nullptr, riferimenti lanciano bad_cast.
3. Quali altre forme di polimorfismo esistono oltre a ereditarietà e template? Traccia. Overloading di funzioni e di operatori: stesso nome o simbolo, versione scelta a compile-time sui tipi degli argomenti.
Versione ripasso
Polimorfismo = comportamenti diversi associati a una stessa notazione generica. Due forme (terminologia di Vandevoorde e Josuttis): dinamico e statico. Vedi C++ - ereditarietà, funzioni virtuali e layout degli oggettiL'ereditarietà pubblica (class D : public B) esprime la relazione "is a": D eredita da B i membri public e protected e li estende; la composizione esprime invece "has a". Una classe base di una gerarchia disabilita copia e assegnamento (=delete) perché copiando una derivata in una base si ha lo slicing. Una funzione virtuale può essere ridefinita nelle derivate (override: stesso nome, tipi e constness) e la funzione chiamata si decide a run-time in base al tipo dell'oggetto, non del puntatore o del riferimento; override esplicito con la keyword override; una funzione virtuale pura (= 0) obbliga le derivate a ridefinirla e rende astratta la classe; il distruttore di una classe base polimorfa è virtuale. Il meccanismo è realizzato con una vtbl per classe (tabella di puntatori alle funzioni) e un vptr in ogni oggetto; i dati della derivata seguono quelli della base.C++ - ereditarietà, funzioni virtuali e layout degli oggetti → e C++ - template e programmazione genericaun template permette di usare un tipo (o un intero noto in compilazione) come parametro di una classe o di una funzione; il compilatore genera il codice per ogni tipo per cui il template è specializzato (template instantiation, a tempo di compilazione): il codice è efficiente ma gli errori sono lunghi e oscuri, e il compilatore controlla sia il template sia il suo argomento; la notazione è template
(o class T, equivalenti) davanti alla classe o alla funzione, e le funzioni membro fuori dalla classe si scrivono template .tipo Classe ::nome(...); nelle function template il compilatore deduce gli argomenti dai parametri della chiamata (myMax(i, j) invece di myMax (i, j)), ma due argomenti di tipo diverso per lo stesso T sono un errore; una specializzazione esplicita (template<> ...) fornisce il codice per un tipo particolare; i parametri interi (template<typename T, int N> struct array) danno array di dimensione fissa senza free store, più efficienti e che esprimono concetti come la tripletta RGB; poiché il compilatore deve vedere la definizione completa al punto d'uso, le definizioni si mettono nell'header (spesso in un file .hpp incluso in coda al .h).C++ - template e programmazione generica → Dinamico: ereditarietà + funzioni virtuali; vincolato (bounded, l'interfaccia è nella base), binding a run-time (vptr e vtbl). Progetto: funzionalità comuni nella base come virtuali (pure), override nelle derivate. Una sola
MyDraw(const GeoObj&);vector<GeoObj*>eterogeneo.Statico:
template <typename GeoObj> void MyDraw(const GeoObj& obj) { obj.draw(); }; svincolato (unbounded, nessuna base, basta la stessa sintassi), binding a compile-time; esistono molteMyDraw(MyDraw<Circle>,MyDraw<Line>);MyDraw(42)non compila; unvector<T>contiene un soloT.Programmazione generica = codice che funziona con tipi forniti dopo, purché rispettino requisiti sintattici e semantici; lo strumento è il template (≠ "usare i template"). Esempi: algoritmi STL (STL - algoritmi, predicati e lambdaLa STL ha circa 80 algoritmi generici (header
e ).) che lavorano su sequenze [first, last) di qualunque contenitore tramite iteratori: find/find_if, count/count_if, sort, copy/copy_if/unique_copy, merge, equal_range, equal, accumulate, inner_product. find cerca un valore e restituisce l'iteratore alla prima occorrenza o end(); find_if verifica un predicato (funzione che restituisce bool) passato come puntatore a funzione, function object (classe con operator()) o lambda expression. Una variabile globale per passare la soglia al predicato è da evitare; il function object la memorizza in un dato membro. Una lambda cattura -> tipo { corpo } definisce la funzione sul posto e può catturare le variabili locali per valore ([=], [x]) o per riferimento ([&], [&x]). sort(b, e) ordina con < e sort(b, e, cmp) con un criterio, solo su iteratori random access; copy richiede spazio a destinazione.STL - algoritmi, predicati e lambda → Scelta: collezione di tipi diversi o interfaccia esplicita = dinamico; velocità e tipi non imparentati = statico (niente costo virtuale, ma una funzione per tipo).
Altre forme: overloading di funzioni e di operatori (scelta a compile-time).
Casting nella gerarchia: upcasting implicito e sempre lecito (is a). Downcasting con
dynamic_cast, su puntatori e riferimenti, unico cast con verifica a run-time, solo su tipi polimorfi: puntatore →nullptr, riferimento →bad_cast.Errori tipici: confondere i due binding, aspettarsi collezioni eterogenee dai template,
dynamic_castsu classe non polimorfa, dimenticare di controllarenullptr, usare il downcasting al posto di una funzione virtuale.Codice (dinamico/dinamico.cpp):
#include <iostream>
#include <vector>
using namespace std;
// POLIMORFISMO DINAMICO: ereditarieta' + funzioni virtuali (legato, binding a run-time)
class GeoObj {
public:
virtual void draw() const = 0;
virtual double center_of_gravity() const = 0; // ascissa del baricentro (semplificata)
virtual ~GeoObj() {}
};
class Circle : public GeoObj {
public:
Circle(double cx, double r) : cx_{cx}, r_{r} {}
void draw() const override { cout << "cerchio r=" << r_ << '\n'; }
double center_of_gravity() const override { return cx_; }
private:
double cx_, r_;
};
class Line : public GeoObj {
public:
Line(double a, double b) : a_{a}, b_{b} {}
void draw() const override { cout << "linea " << a_ << ".." << b_ << '\n'; }
double center_of_gravity() const override { return (a_ + b_) / 2; }
private:
double a_, b_;
};
void MyDraw(const GeoObj& obj) // UNA sola funzione, compilata una volta
{
obj.draw(); // chiama draw() della classe effettiva dell'oggetto
}
int main()
{
Circle c{0, 2};
Line l{2, 6};
vector<const GeoObj*> scena{&c, &l}; // collezione ETEROGENEA: tipi diversi insieme
for (const GeoObj* g : scena) { MyDraw(*g); cout << " baricentro " << g->center_of_gravity() << '\n'; }
}- Codice (statico/statico.cpp):
#include <iostream>
#include <vector>
using namespace std;
// POLIMORFISMO STATICO: template (binding a tempo di compilazione). Nessuna classe base:
// Circle, Line e Rectangle sono indipendenti, condividono solo la SINTASSI draw()/center_of_gravity().
class Circle {
public:
Circle(double cx, double r) : cx_{cx}, r_{r} {}
void draw() const { cout << "cerchio r=" << r_ << '\n'; }
double center_of_gravity() const { return cx_; }
private:
double cx_, r_;
};
class Line {
public:
Line(double a, double b) : a_{a}, b_{b} {}
void draw() const { cout << "linea " << a_ << ".." << b_ << '\n'; }
double center_of_gravity() const { return (a_ + b_) / 2; }
private:
double a_, b_;
};
class Rectangle {
public:
Rectangle(double x, double w) : x_{x}, w_{w} {}
void draw() const { cout << "rettangolo larghezza " << w_ << '\n'; }
double center_of_gravity() const { return x_ + w_ / 2; }
private:
double x_, w_;
};
template <typename GeoObj>
void MyDraw(const GeoObj& obj) // il compilatore genera MyDraw<Circle>, MyDraw<Line>, ...
{
obj.draw(); // risolto a tempo di compilazione, puo' essere inline
}
int main()
{
Circle c{0, 2};
Line l{2, 6};
Rectangle r{1, 4};
MyDraw(c); // MyDraw<Circle>
MyDraw(l); // MyDraw<Line>
MyDraw(r); // MyDraw<Rectangle>
vector<Circle> soli_cerchi{c, Circle{1, 1}}; // un vector<T> contiene un solo T
for (const Circle& x : soli_cerchi) MyDraw(x);
// MyDraw(42); // errore a tempo di compilazione: int non ha draw()
}Esercizi su questo argomento
- Esercizio - ConvertToArray da un container STL (secondo appello 2023-24, programmazione)
- Esercizio - Design pattern, elementi e classificazione (secondo appello 2022-23, teoria)
- Esercizio - Ereditarietà, is-a e has-a, slicing (primo appello 2022-23, teoria)
- Esercizio - Navigazione di un robot in un labirinto (laboratorio 8)
- Esercizio - Polimorfismo dinamico e statico (primo appello 2021-22, teoria)
- Esercizio - Polimorfismo statico e dinamico, bounded e unbounded (secondo appello 2022-23, teoria)
- Esercizio - Quiz e domande su funzioni virtuali, passaggio degli argomenti, dangling pointer e doppia deallocazione (quarto appello 2023-24, teoria)
- Esercizio - Quiz e domande su polimorfismo, allocazione dinamica e garanzie sulle eccezioni (secondo appello 2023-24, teoria)
- Esercizio - Quiz e domande su smart pointer, function object e aritmetica dei puntatori (terzo appello 2023-24, teoria)
- Esercizio - Quiz su constexpr, ordine di valutazione, dynamic_cast e lambda (primo appello 2022-23, quiz)
- Esercizio - Quiz su conversioni, constexpr, free store e downcasting (terzo appello 2021-22, quiz)
- Esercizio - Quiz su inizializzazione, free store, polimorfismo e passaggio di argomenti (quarto appello 2022-23, quiz)