Salta al contenuto
Note per Studenti C++ - polimorfismo

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 (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 →) in una sola classificazione, e li confronta.

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:

  1. si individua l'insieme di funzionalità comuni tra classi collegate;
  2. si crea il corrispondente insieme di funzioni virtuali (pure) nella classe base;
  3. 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.

dinamico/dinamico.cppcpp
#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.

statico/statico.cppcpp
#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 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 →.

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:

Sono forme di polimorfismo statico (scelta a tempo di compilazione) e senza gerarchia di classi.

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 un Dog o a un Cat), si fa con dynamic_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).

Il tipo di partenza deve essere polimorfo (avere almeno una funzione virtuale).

cast/cast.cppcpp
#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:

cast/cast_ref.cppcpp
#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

dinamico/dinamico.cppcpp
#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):
statico/statico.cppcpp
#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

Teoria collegata