Salta al contenuto
Note per Studenti Esercizio - Polimorfismo dinamico e statico (primo appello 2021-22, teoria)

Esercizio - Polimorfismo dinamico e statico (primo appello 2021-22, teoria)

Esame
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 (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 →. La stessa domanda, con più richieste, è nel secondo appello 2022-23: Esercizio - Polimorfismo statico e dinamico, bounded e unbounded (secondo appello 2022-23, teoria).

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.

poli_dinamico.cppcpp
#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 = 6
  • stampa(const Shape&) è una funzione sola; la chiamata s.area() va a Circle::area o a Rectangle::area secondo 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 per Shape della libreria grafica del corso.
  • Costo: una indirezione in più per chiamata (vptr →\to virtual table →\to 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.

poli_statico.cppcpp
#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 stampa quante sono le forme usate: stampa<Circle>, stampa<Rectangle>. La scelta è del compilatore: nessun costo a run-time, chiamate inlinabili.
  • Circle e Rectangle non 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'istanza stampa<int>).
  • Non si può avere una collezione eterogenea (un vector ha 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:
cpp
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:
cpp
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 o const mancante (nessun override: usare override); distruttore non virtuale; inserire oggetti derivati in un vector<Base> (slicing); credere che i template siano a run-time.

Esercizi su questo argomento

Teoria collegata