Salta al contenuto
Note per Studenti Esercizio - Ereditarietà, is-a e has-a, slicing (primo appello 2022-23, teoria)

Esercizio - Ereditarietà, is-a e has-a, slicing (primo appello 2022-23, teoria)

Esame
In questa pagina 4

Domanda aperta 1 del primo appello 2022-23 del corso di Laboratorio di Programmazione, Ing. Informatica UniPD. Teoria: 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++ - 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 →, Classi ed ereditarietà in PythonAttributi di istanza e di classe, metodi di istanza, di classe e statici; confronto e ordinamento con eq e lt; proprietà; ereditarietà, super(), override e polimorfismo; classi astratte come interfacce; eccezioni personalizzate; overloading e shadowing in Python.Classi ed ereditarietà in Python → per il confronto con Python.

Testo. Definire il concetto di ereditarietà. Descrivere approfonditamente gli strumenti disponibili in C++ per implementare tale concetto. Descrivere le differenze fra le relazioni "is a" e "has a" fra classi e illustrare tali differenze con un breve esempio di codice. Descrivere il problema dello slicing e le metodologie per evitarlo riportando un esempio di codice.

Il concetto

Ereditarietà. Meccanismo con cui una classe (derivata) acquisisce i dati e le funzioni membro di un'altra (la base) e li estende o specializza. Serve a raggruppare le caratteristiche comuni di più classi in una classe base, così le più specifiche (per esempio Circle, Rectangle) ereditano le funzionalità comuni di Shape (un insieme di punti, il disegno) e aggiungono solo ciò che le distingue. Criterio di progetto abituale: molte classi, ciascuna con poche operazioni.

Gli strumenti del C++

strumento funzione
derivazione pubblica class Circle : public Shape la derivata accede ai membri public e protected della base; modella la relazione is a. Le derivazioni private e protected esistono ma si usano di rado
protected membri accessibili alle classi derivate ma non all'esterno (di solito i costruttori della base)
funzioni virtuali virtual permettono di ridefinire una funzione nella derivata: override; con la chiamata tramite puntatore o reference alla base si esegue la versione della classe effettiva (polimorfismo a run-time, binding dinamico, tramite la virtual table)
override dichiara l'intenzione di fare override: errore di compilazione se la firma non coincide (per esempio manca const)
funzioni virtuali pure = 0 la base è astratta: non si istanzia, impone l'override alle derivate
distruttore virtuale obbligatorio in una base polimorfica: distrugge correttamente un oggetto derivato cancellato tramite Base*
copia vietata nella base (= delete) evita lo slicing
dynamic_cast downcasting con verifica a run-time (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 →)

Regole dell'override: stesso nome, stessi tipi dei parametri e stessa constness; la funzione della base deve essere virtual. Con puntatori: un B* può puntare a un D o a un DD, e un vector<B*> gestisce una collezione di oggetti diversi: per ciascuno si chiama la funzione appropriata.

is a e has a

relazione significato C++ esempio
is a (è un) un oggetto della derivata è un oggetto della base; non biunivoca (una forma non è per forza un cerchio) ereditarietà pubblica Circle è una Shape
has a (ha un) un oggetto di una classe contiene un oggetto di un'altra composizione: un dato membro Car ha delle Wheel

Il test: "un'automobile è un volante?" No: ha dei volanti: composizione, non eredità. Un cerchio è una forma: eredità. Sbagliare la relazione porta a gerarchie assurde (Car : public Wheel), con interfacce che non hanno senso.

ereditarieta.cppcpp
#include <iostream>
#include <memory>
#include <string>
#include <vector>
using namespace std;

// ---------- ereditarieta': Circle "is a" Shape ----------
class Shape {
public:
    Shape() = default;
    virtual ~Shape() = default;                           // distruttore virtuale
    Shape(const Shape&) = delete;                         // base di una gerarchia: copia vietata (contro lo slicing)
    Shape& operator=(const Shape&) = delete;

    virtual double area() const = 0;                      // virtuale pura: Shape e' astratta
    string name() const { return name_; }
protected:
    explicit Shape(string n) : name_{std::move(n)} {}     // visibile alle derivate
private:
    string name_ = "forma";
};

class Circle : public Shape {                             // ereditarieta' pubblica: Circle E' UNA Shape
public:
    explicit Circle(double r) : Shape("cerchio"), r_{r} {}
    double area() const override { return 3.14159 * r_ * r_; }
private:
    double r_;
};

// ---------- composizione: Car "has a" Wheel ----------
class Wheel {
public:
    explicit Wheel(int diametro) : diametro_{diametro} {}
    int diametro() const { return diametro_; }
private:
    int diametro_;
};

class Car {                                               // Car NON e' una Wheel: ne HA quattro
public:
    Car() : ruote_{Wheel(16), Wheel(16), Wheel(16), Wheel(16)} {}
    int n_ruote() const { return static_cast<int>(ruote_.size()); }
    int diametro_ruote() const { return ruote_[0].diametro(); }
private:
    vector<Wheel> ruote_;                                 // l'oggetto Wheel e' CONTENUTO dentro Car
};

// ---------- slicing ----------
class Base {
public:
    virtual ~Base() = default;
    virtual string chi() const { return "Base"; }
    int a = 1;
};
class Derivata : public Base {
public:
    string chi() const override { return "Derivata"; }
    int b = 2;                                            // dato membro in piu'
};

int main()
{
    unique_ptr<Shape> s = make_unique<Circle>(2.0);       // un puntatore alla base puo' puntare a una derivata
    cout << s->name() << ": area = " << s->area() << '\n';

    Car c;
    cout << "Car ha " << c.n_ruote() << " ruote da " << c.diametro_ruote() << " pollici\n";

    cout << "sizeof(Base) = " << sizeof(Base) << ", sizeof(Derivata) = " << sizeof(Derivata) << '\n';

    Derivata d;
    vector<Base> v;                                       // vector di OGGETTI Base
    v.push_back(d);                                       // SLICING: si copia solo la parte Base
    cout << "oggetto originale: " << d.chi() << ", copia nel vector<Base>: " << v[0].chi() << '\n';

    vector<unique_ptr<Base>> p;                           // soluzione: puntatori alla base
    p.push_back(make_unique<Derivata>());
    cout << "vector di puntatori: " << p[0]->chi() << '\n';

    const Base& r = d;                                    // soluzione: reference (nessuna copia)
    cout << "tramite reference: " << r.chi() << '\n';
    return 0;
}
cerchio: area = 12.5664
Car ha 4 ruote da 16 pollici
sizeof(Base) = 8, sizeof(Derivata) = 12
oggetto originale: Derivata, copia nel vector<Base>: Base
vector di puntatori: Derivata
tramite reference: Derivata
  • unique_ptr<Shape> s = make_unique<Circle>(2.0): un puntatore alla base punta a una derivata; area() è virtuale pura, quindi la chiamata arriva a Circle::area (3.14159⋅4=12.56643.14159 \cdot 4 = 12.5664). Shape è astratta: Shape x; non compila.
  • Car ha un vector<Wheel>: l'oggetto Wheel sta dentro Car; Car non eredita nulla da Wheel.

Lo slicing

Se Circle ha dati in più (il raggio) e si inserisce un Circle in un vector<Shape>, ogni slot può contenere solo ciò che c'è in Shape: il costruttore di copia copia solo la parte base e taglia il resto: l'oggetto perde i membri aggiunti e perde il tipo derivato, quindi anche l'override non funziona più. Nel programma sopra: sizeof(Base) = 8 e sizeof(Derivata) = 12 (in WebAssembly un puntatore è 4 byte; su un PC a 64 bit i valori sono più grandi); v.push_back(d) in un vector<Base> copia solo i primi 8 byte, e v[0].chi() stampa Base anche se d.chi() è Derivata.

Come evitarlo ("class hierarchies plus pass-by-reference and default copying do not mix"):

  1. disabilitare copia e assegnamento nella base (Shape(const Shape&) = delete; Shape& operator=(const Shape&) = delete;): la riga con lo slicing diventa un errore di compilazione (push_back richiede la copia: static assertion failed ... is_move_insertable);
  2. usare puntatori (meglio smart pointer) alla base nei contenitori: vector<unique_ptr<Base>>: si copia il puntatore, non l'oggetto: nell'esempio p[0]->chi() stampa Derivata;
  3. usare reference nelle funzioni (const Base& r = d;): nessuna copia, r.chi() stampa Derivata;
  4. rendere la base astratta (con virtuali pure o costruttori protetti): così non si può nemmeno creare un oggetto Base da copiare.

Versione ripasso

relazione significato C++ esempio
is a la derivata è una base ereditarietà pubblica Circle è una Shape
has a un oggetto contiene un altro composizione (dato membro) Car ha un vector<Wheel>
cpp
class Circle : public Shape { public: double area() const override { return 3.14159 * r_ * r_; } /*...*/ };  // is a
class Car { vector<Wheel> ruote_; /*...*/ };                                                                   // has a
  • Slicing: inserire un Derivata in un vector<Base> copia solo la parte base: si perdono i dati in più e il tipo derivato (v[0].chi() = Base, mentre d.chi() = Derivata; sizeof(Base) = 8, sizeof(Derivata) = 12 in wasm32).
  • Rimedi: (1) base con copia e assegnamento = delete →\to errore di compilazione; (2) contenitori di puntatori (vector<unique_ptr<Base>>: p[0]->chi() = Derivata); (3) reference (const Base& r = d;); (4) base astratta.
  • Errori tipici: Car : public Wheel (non è una relazione is-a); dimenticare virtual (nessun override) o const (nessun override: usare override); distruttore non virtuale; vector<Shape> con oggetti derivati; derivare per riusare codice quando basta la composizione.

Esercizi su questo argomento

Teoria collegata