Esercizio - Polimorfismo dinamico e statico (primo appello 2021-22, teoria)
In questa pagina 4
Domanda aperta 1 del primo appello 2021-22 (25 gennaio 2022) del corso di Laboratorio di Programmazione, Ing. Informatica UniPD. Teoria: C++ - polimorfismoPolimorfismo = abilità di associare comportamenti diversi a un'unica notazione generica. In C++ ci sono due forme principali: dinamico (ereditarietà più funzioni virtuali: vincolato dall'interfaccia della classe base, binding a run-time, permette collezioni eterogenee) e statico (template: svincolato, basta che i tipi abbiano la stessa sintassi, binding a tempo di compilazione, nessun costo a run-time ma una funzione per ogni tipo); il polimorfismo statico è lo strumento della programmazione generica. Anche l'overloading di funzioni e di operatori può essere visto come polimorfismo. Nella gerarchia si naviga con l'upcasting (implicito, sempre lecito) e il downcasting con dynamic_cast, l'unico cast con verifica a run-time: su un puntatore dà nullptr se non lecito, su un riferimento lancia bad_cast.C++ - polimorfismo →, 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 →, 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
Testo. Definire il concetto di polimorfismo. Descrivere il polimorfismo dinamico e il polimorfismo statico evidenziandone le differenze e gli strumenti implementativi che il C++ offre a supporto. Mostrare un esempio di codice di classe che sfrutti il polimorfismo dinamico e un esempio di classe che sfrutti il polimorfismo statico.
Definizione
Polimorfismo. Capacità di associare comportamenti specifici diversi a un'unica notazione generica (la definizione di Vandevoorde e Josuttis, usata dal corso). Letteralmente "molte forme": la stessa espressione, come
forma.area(), esegue codice diverso a seconda dell'oggetto.
In C++ si ottiene in due modi principali, che differiscono per quando viene scelto il comportamento:
| polimorfismo dinamico | polimorfismo statico | |
|---|---|---|
| strumenti C++ | ereditarietà + funzioni virtuali (overriding) | template (di classi e di funzioni) |
| binding | a tempo di esecuzione (run-time) | a tempo di compilazione (compile-time) |
| tipo di polimorfismo | vincolato (bounded): l'interfaccia delle classi è fissata dalla classe base | svincolato (unbounded): nessuna classe base, basta che i tipi abbiano la stessa sintassi |
| come si sceglie la funzione | tramite il puntatore alla virtual table (vptr) dell'oggetto |
il compilatore genera una versione per ogni tipo |
| dove serve | collezioni eterogenee di oggetti (un vector<Shape*>), tipo noto solo a run-time |
quando i tipi sono noti in compilazione; programmazione generica, contenitori e algoritmi della STL |
Altri meccanismi che si possono considerare forme di polimorfismo: l'overloading di funzioni e di operatori.
Polimorfismo dinamico
Si identifica un insieme di funzionalità comuni fra classi collegate e si crea il corrispondente insieme di funzioni virtuali nella classe base; le classi derivate le specializzano con l'overriding. Il codice che usa la base (una sola funzione per tutte le forme) chiama la funzione giusta in base al tipo effettivo dell'oggetto: la scelta è fatta a run-time consultando la virtual table. Condizioni: chiamata tramite puntatore o reference alla base, funzione dichiarata virtual nella base, stessa firma e stessa constness nella derivata (con override il compilatore verifica). Con funzioni virtuali pure (= 0) la base è astratta: definisce solo l'interfaccia.
#include <iostream>
#include <memory>
#include <vector>
using namespace std;
// Polimorfismo DINAMICO: ereditarieta' + funzioni virtuali, binding a run-time.
class Shape {
public:
virtual double area() const = 0; // virtuale pura: interfaccia comune, classe astratta
virtual const char* name() const = 0;
virtual ~Shape() = default; // distruttore virtuale: si cancella via puntatore alla base
Shape() = default;
Shape(const Shape&) = delete; // niente copia in una gerarchia (slicing)
Shape& operator=(const Shape&) = delete;
};
class Circle : public Shape {
public:
explicit Circle(double r) : r_{r} {}
double area() const override { return 3.14159 * r_ * r_; }
const char* name() const override { return "Circle"; }
private:
double r_;
};
class Rectangle : public Shape {
public:
Rectangle(double w, double h) : w_{w}, h_{h} {}
double area() const override { return w_ * h_; }
const char* name() const override { return "Rectangle"; }
private:
double w_, h_;
};
// UNA sola funzione serve per tutte le forme: la chiamata area() e' risolta a run-time
void stampa(const Shape& s)
{
cout << s.name() << ": area = " << s.area() << '\n';
}
int main()
{
vector<unique_ptr<Shape>> forme; // collezione eterogenea tramite puntatori alla base
forme.push_back(make_unique<Circle>(1.0));
forme.push_back(make_unique<Rectangle>(2.0, 3.0));
for (const auto& f : forme) stampa(*f); // la scelta di Circle::area o Rectangle::area avviene qui
// Shape s; // errore: Shape e' astratta
return 0;
}Circle: area = 3.14159
Rectangle: area = 6stampa(const Shape&)è una funzione sola; la chiamatas.area()va aCircle::areao aRectangle::areasecondo l'oggetto (binding dinamico).vector<unique_ptr<Shape>>contiene forme di tipi diversi: si usano puntatori alla base per evitare lo slicing.- Il distruttore è
virtual, altrimenti cancellare una forma tramite puntatore alla base non chiamerebbe il distruttore giusto. Copia e assegnamento della base sono vietati (= delete), come perShapedella libreria grafica del corso. - Costo: una indirezione in più per chiamata (
vptrvirtual table funzione), un puntatore in più per oggetto.
Polimorfismo statico
Le classi sono definite in modo indipendente, ma condividono la stessa sintassi (qui name() e area()); il polimorfismo si realizza specificando una classe o una funzione template con tipi diversi.
#include <iostream>
using namespace std;
// Polimorfismo STATICO: template, binding a compile-time. Nessuna classe base comune.
class Circle {
public:
explicit Circle(double r) : r_{r} {}
double area() const { return 3.14159 * r_ * r_; }
const char* name() const { return "Circle"; }
private:
double r_;
};
class Rectangle {
public:
Rectangle(double w, double h) : w_{w}, h_{h} {}
double area() const { return w_ * h_; }
const char* name() const { return "Rectangle"; }
private:
double w_, h_;
};
// Esistono tante stampa<T> quante sono le forme usate: stampa<Circle>, stampa<Rectangle>, ...
template <typename Shape>
void stampa(const Shape& s)
{
cout << s.name() << ": area = " << s.area() << '\n'; // basta che Shape abbia name() e area()
}
int main()
{
Circle c {1.0};
Rectangle r {2.0, 3.0};
stampa(c); // istanzia stampa<Circle>
stampa(r); // istanzia stampa<Rectangle>
// stampa(42); // errore di compilazione: int non ha area()
return 0;
}Circle: area = 3.14159
Rectangle: area = 6- Esistono tante funzioni
stampaquante sono le forme usate:stampa<Circle>,stampa<Rectangle>. La scelta è del compilatore: nessun costo a run-time, chiamate inlinabili. CircleeRectanglenon hanno una base comune; l'unico requisito è che i membri usati dal template esistano. Se non esistono (stampa(42)) l'errore è di compilazione (member reference base type 'const int' is not a structure or union, segnalato nell'istanzastampa<int>).- Non si può avere una collezione eterogenea (un
vectorha un solo tipo di elemento) e il tipo deve essere noto in compilazione.
Quando l'uno, quando l'altro
Il polimorfismo statico è collegato alla programmazione generica: scrivere codice che funziona con tipi diversi forniti in seguito, purché soddisfino requisiti sintattici e semantici; il template ne è lo strumento (non il concetto). Dinamico se i tipi emergono solo a run-time o servono contenitori eterogenei; statico se i tipi sono noti e conta l'efficienza.
Versione ripasso
- Polimorfismo = associare comportamenti diversi a un'unica notazione generica. Si dividono per quando si sceglie il comportamento.
| dinamico | statico | |
|---|---|---|
| strumento | ereditarietà + funzioni virtuali (overriding) | template |
| binding | run-time (virtual table, vptr) |
compile-time |
| vincolo | bounded: interfaccia fissata dalla classe base | unbounded: basta la stessa sintassi |
| tipi | puntatore/reference alla base; collezioni eterogenee | tipi noti in compilazione; nessun costo a run-time |
- Dinamico, codice:
class Shape { public: virtual double area() const = 0; virtual ~Shape() = default; };
class Circle : public Shape { public: double area() const override { return 3.14159 * r_ * r_; } /*...*/ };
void stampa(const Shape& s) { cout << s.area() << '\n'; } // sceglie Circle::area o Rectangle::area a run-time
vector<unique_ptr<Shape>> forme; // puntatori: niente slicing Uscita: Circle: area = 3.14159, Rectangle: area = 6. Serve virtual nella base, stessa firma e constness, chiamata tramite puntatore/reference; funzione virtuale pura = classe astratta; distruttore virtuale; copia vietata nella base.
- Statico, codice:
template <typename Shape>
void stampa(const Shape& s) { cout << s.name() << ": area = " << s.area() << '\n'; }
// Circle e Rectangle senza base comune: basta che abbiano name() e area(); stampa(42) non compila Esistono stampa<Circle>, stampa<Rectangle>: una versione per tipo, scelta dal compilatore.
- Altre forme: overloading di funzioni e di operatori. Programmazione generica = scrivere codice per tipi diversi forniti dopo (il template ne è lo strumento).
- Errori tipici: dimenticare
virtual(chiamata statica del tipo del puntatore); firma diversa oconstmancante (nessun override: usareoverride); distruttore non virtuale; inserire oggetti derivati in unvector<Base>(slicing); credere che i template siano a run-time.