Salta al contenuto
Note per Studenti C++ - ereditarietà, funzioni virtuali e layout degli oggetti

C++ - ereditarietà, funzioni virtuali e layout degli oggetti

In questa pagina 7

Una libreria che descrive entità geometriche (punti, linee, cerchi, rettangoli) ha un problema di progetto: ogni forma ha caratteristiche comuni a tutte (è descritta da un insieme di punti, si può disegnare, spostare) e caratteristiche che dipendono dalla forma specifica (come si disegna un cerchio). Le classi permettono di raggruppare le caratteristiche comuni in una classe base e di far ereditare ed estendere quelle caratteristiche alle classi più specifiche. Una scelta di progetto frequente: molte classi, ciascuna con poche operazioni. Questa nota usa l'esempio per introdurre ereditarietà, slicing, funzioni virtuali, classi astratte e layout in memoria; la visione d'insieme sui diversi tipi di polimorfismo è in 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 →.

Classe base e classi derivate

L'entità elementare è il punto, una struct perché ogni coppia di valori è ammissibile:

cpp
struct Point { int x, y; };

La classe Shape rappresenta una forma generica, costruita a partire dai punti. Molte operazioni sono definibili per una forma qualsiasi e Shape fornisce l'interfaccia comune a tutte le forme; l'implementazione effettiva dipende dalla forma. Una classe derivata si dichiara con : public:

cpp
class Circle : public Shape {
    // ...
};

Definizione (ereditarietà pubblica). Con class D : public B, D è la classe derivata e B la classe base. D eredita tutti i membri di B e può accedere a quelli public e protected (non ai private). Esistono anche l'ereditarietà private e protected, usate di rado.

L'accesso ai membri si riassume così:

membro in B utente di B classe derivata D
public sì sì
protected no sì
private no no

I dati membro di D sono aggiunti dopo quelli di B (vedi il layout più avanti). Il costruttore della base viene eseguito prima di quello della derivata, e i distruttori in ordine inverso: prima ~D, poi ~B.

"Is a" e "has a"

relazione significa si realizza con esempio
is a un oggetto di classe derivata è un oggetto di classe base ereditarietà pubblica un cerchio è una forma
has a un oggetto contiene un altro oggetto composizione (un dato membro) un'automobile ha un volante

La relazione is a non è biunivoca: ogni cerchio è una forma, ma non ogni forma è un cerchio. Conseguenza pratica: dove serve una Shape si può fornire un Circle (puntatore o riferimento a Shape possono riferirsi a un Circle).

Il programma completo

La classe Shape del corso (qui senza la libreria grafica: il disegno stampa del testo) con due derivate, Open_polyline e Circle:

forme/forme.cppcpp
#include <initializer_list>
#include <iostream>
#include <vector>
using namespace std;

struct Point { int x, y; };

class Shape {                       // classe base astratta (costruttori protected)
public:
    void draw() const { cout << "disegno " << name() << ": "; draw_lines(); cout << '\n'; }
    virtual void move(int dx, int dy) { for (auto& p : points) { p.x += dx; p.y += dy; } }
    Point point(int i) const { return points[i]; }
    int number_of_points() const { return static_cast<int>(points.size()); }

    Shape(const Shape&) = delete;              // niente copia: evita lo slicing
    Shape& operator=(const Shape&) = delete;
    virtual ~Shape() {}
protected:
    Shape() {}
    Shape(initializer_list<Point> lst) : points{lst} {}
    virtual void draw_lines() const {          // comportamento di default: poligonale
        for (const Point& p : points) cout << '(' << p.x << ',' << p.y << ") ";
    }
    virtual const char* name() const { return "Shape"; }
    void add(Point p) { points.push_back(p); }
private:
    vector<Point> points;
};

class Open_polyline : public Shape {         // non ridefinisce nulla
public:
    Open_polyline(initializer_list<Point> lst) : Shape{lst} {}
protected:
    const char* name() const override { return "Open_polyline"; }
};

class Circle : public Shape {
public:
    Circle(Point c, int rr) : r{rr} { add(c); }
    void move(int dx, int dy) override { Shape::move(dx, dy); cout << "[Circle::move] "; }
protected:
    void draw_lines() const override {
        cout << "cerchio di centro (" << point(0).x << ',' << point(0).y << ") raggio " << r;
    }
    const char* name() const override { return "Circle"; }
private:
    int r;                                     // dato membro aggiunto dalla derivata
};

int main()
{
    // Shape s;                                // errore: costruttore protected, classe astratta
    Open_polyline poly{{0, 0}, {10, 0}, {10, 5}};
    Circle c{{3, 4}, 7};

    vector<Shape*> v{&poly, &c};               // un vector<B*> contiene derivate diverse
    for (const Shape* s : v) s->draw();        // per ogni oggetto la funzione giusta
    v[1]->move(1, 1);                          // chiamata virtuale: Circle::move
    v[1]->draw();
    // Circle copia = c;                       // errore: costruttore di copia cancellato
}

Uscita:

disegno Open_polyline: (0,0) (10,0) (10,5) 
disegno Circle: cerchio di centro (3,4) raggio 7
[Circle::move] disegno Circle: cerchio di centro (4,5) raggio 7

Cosa è successo, passo per passo.

  1. Shape ha il costruttore di default e quello con initializer_list<Point> protected: solo le derivate possono costruire il sottooggetto Shape, quindi Shape s; non compila. È una classe astratta: serve solo come interfaccia comune.
  2. Open_polyline non ridefinisce nessuna funzione: usa tutte quelle di Shape, compresa draw_lines() (la poligonale). Non ha dati membro aggiuntivi: si appoggia su points.
  3. Circle ridefinisce draw_lines() e move() e aggiunge il dato membro r. move richiama Shape::move (qualificando il nome si sceglie la versione della base) e poi aggiunge il suo messaggio.
  4. Il vector<Shape*> contiene un puntatore a Open_polyline e uno a Circle: un puntatore a Shape può puntare a qualunque derivata. Il ciclo s->draw() chiama sempre Shape::draw(), ma dentro questa draw_lines() e name() sono virtuali, quindi per ciascun oggetto viene eseguita la versione giusta.
  5. v[1]->move(1, 1) ha tipo statico Shape* ma l'oggetto è un Circle: parte Circle::move, che sposta il centro da (3,4) a (4,5).

Slicing

Una conseguenza spiacevole dell'is a: è lecito mettere un Circle in un vector<Shape>. Se Circle ha un dato in più (il raggio) e Shape no, copiare il cerchio in una Shape taglia (slicing) la parte aggiunta: il compilatore lascia solo i membri che trovano posto nella base.

slicing/slicing.cppcpp
#include <iostream>
#include <string>
#include <vector>
using namespace std;

struct Shape2 {                        // gerarchia COPIABILE: serve a mostrare lo slicing
    int id = 1;
    virtual string name() const { return "Shape2"; }
    virtual ~Shape2() {}
};
struct Circle2 : Shape2 {
    int r = 7;                         // membro in più
    string name() const override { return "Circle2"; }
};

int main()
{
    Circle2 c;
    cout << "Circle2 ha piu' dati di Shape2: " << (sizeof(Circle2) > sizeof(Shape2)) << '\n';

    vector<Shape2> v;
    v.push_back(c);                    // compila: costruttore di copia di Shape2(const Shape2&)
    cout << "nel vector: " << v[0].name() << '\n';              // Shape2: la parte Circle2 e' persa

    Shape2& ref = c;                   // per riferimento NON c'e' slicing
    cout << "per riferimento: " << ref.name() << '\n';          // Circle2
    Shape2 copia = c;                  // per valore SI': slicing
    cout << "per valore: " << copia.name() << '\n';             // Shape2
}

Uscita: Circle2 ha piu' dati di Shape2: 1, nel vector: Shape2, per riferimento: Circle2, per valore: Shape2. Il vector<Shape2> contiene davvero oggetti Shape2 (la copia ha perso r e anche il tipo dinamico); un riferimento invece non copia niente e resta legato al cerchio vero.

La difesa è disabilitare la copia nelle classi pensate come base di una gerarchia, come fa Shape:

cpp
Shape(const Shape&) = delete;
Shape& operator=(const Shape&) = delete;

Con la copia cancellata v.push_back(c) diventa un errore di compilazione. Regola dell'autore del linguaggio: classi di una gerarchia, passaggio per riferimento e copia di default non vanno d'accordo. Gli oggetti di una gerarchia si gestiscono con puntatori o riferimenti (e, per la proprietà, con gli smart pointer di C++ - smart pointerGli smart pointer (classi template di ) sono oggetti che detengono un puntatore e lo deallocano automaticamente all'uscita dallo scope (RAII), eliminando memory leak e dangling pointer con un overhead minimo. unique_ptr ha un solo proprietario: non si copia (costruttore e assegnamento di copia cancellati) ma si sposta (std::move, ritorno da funzione), ha reset(), release(), get(), * e ->; due unique_ptr costruiti con lo stesso puntatore grezzo sono un errore (doppia delete) che il compilatore non vede, e si evita con make_unique. shared_ptr ha più proprietari: si copia, tiene un contatore di riferimenti (use_count) e libera l'oggetto quando l'ultimo shared_ptr viene distrutto, a un costo maggiore; weak_ptr osserva senza possedere, per rompere i cicli. auto_ptr è deprecato (rimosso in C++17).C++ - smart pointer →).

Funzioni virtuali

Le gerarchie di classi usano tre meccanismi:

meccanismo cosa permette
ereditarietà una classe riceve funzioni e dati della base
funzioni virtuali la stessa funzione definita nella base e nella derivata; quale esegue si decide a run-time (polimorfismo dinamico)
incapsulamento private e protected nascondono i dettagli e semplificano la manutenzione

Una funzione membro dichiarata virtual nella base può essere ridefinita nelle derivate:

cpp
class Shape {
    virtual void draw_lines() const;
};
class Circle : public Shape {
    void draw_lines() const;       // override (virtual implicito)
};

Definizione (override). Una funzione di una classe derivata fa override di una virtuale della base se ha stesso nome, stessi tipi dei parametri e stessa constness. Allora la chiamata attraverso un puntatore o riferimento alla base esegue la versione della classe effettiva dell'oggetto.

Con i puntatori l'effetto è potente: dato class D : public B e class DD : public D, un puntatore a B può puntare a un B, a un D o a un DD (D è un B; DD è un D e anche un B). Un vector<B*> gestisce quindi una collezione di oggetti diversi, e la stessa chiamata virtuale esegue per ciascuno la funzione appropriata.

Funzione virtuale e non virtuale a confronto

Il programma seguente è il test centrale: B ha f() virtuale e g() non virtuale; D ridefinisce entrambe; in DD la f() ha una svista (manca const).

virt/virt.cppcpp
#include <iostream>
using namespace std;

class B {
public:
    virtual void f() const { cout << "B::f "; }
    void g() const { cout << "B::g "; }             // non virtuale
};
class D : public B {
public:
    void f() const { cout << "D::f "; }             // override (virtual implicito)
    void g() const { cout << "D::g "; }             // nasconde B::g
};
class DD : public D {
public:
    void f() { cout << "DD::f "; }                  // NON e' override: manca const
    void g() const { cout << "DD::g "; }
};

void call(const B& b) { b.f(); b.g(); cout << '\n'; }

int main()
{
    B b;  D d;  DD dd;
    call(b);
    call(d);
    call(dd);
    b.f();  b.g();  d.f();  d.g();  dd.f();  dd.g();
    cout << '\n';
}

Uscita (verificata):

B::f B::g 
D::f B::g 
D::f B::g 
B::f B::g D::f D::g DD::f DD::g 

Si ragiona così. call riceve un const B&, quindi chiama sempre f e g cercandole in B.

  • f() è virtuale: viene scelta la versione dell'oggetto vero. Con call(d) esce D::f. Con call(dd) esce ancora D::f, perché DD::f() non è const e quindi non è un override (ha una firma diversa): è una funzione nuova che nasconde D::f quando si usa un DD, ma per il meccanismo virtuale l'ultimo override è D::f. Il compilatore avverte con hides overloaded virtual function.
  • g() non è virtuale: la scelta è fatta a tempo di compilazione sul tipo del riferimento, quindi sempre B::g, anche per d e dd.
  • Le chiamate dirette (d.g(), dd.f()) usano il tipo dell'oggetto, che qui coincide con quello dichiarato: D::g, DD::f, DD::g.

override esplicito

Nei casi reali le funzioni sono progettate per essere ridefinite. Si può dichiarare l'intenzione con la keyword override: se la funzione non è davvero un override, il compilatore dà errore. È utile per chiarezza e per scoprire svarioni come il const mancante.

override/override_err.cppcpp
class B {
public:
    virtual void f() const {}
    void g() const {}
};
class D : public B {
public:
    void f() const override {}   // ok
    void g() const override {}   // errore: g non e' virtuale in B
};
class DD : public D {
public:
    void f() override {}         // errore: non corrisponde a nessuna virtuale (manca const)
};
int main() {}

I due errori sono only virtual member functions can be marked 'override' (per D::g) e non-virtual member function marked 'override' hides virtual member function (per DD::f): proprio le due righe che il programma precedente lasciava passare in silenzio.

Funzioni virtuali pure e classi astratte

Una funzione virtuale pura si dichiara con = 0 e non può essere implementata nella classe base:

cpp
class B {
public:
    virtual void f() = 0;     // l'override è obbligatorio
    virtual void g() = 0;
};
B b;                          // errore: B è astratta

Una classe con almeno una funzione virtuale pura è astratta: non si possono creare oggetti di quel tipo. Si usa:

  • per impedire di istanziare una classe (effetto voluto);
  • per obbligare le derivate a implementare le funzioni (l'override delle pure è obbligatorio); una derivata che non ne ridefinisce una resta astratta;
  • per definire un'interfaccia comune a più classi derivate. Una classe astratta è lo strumento che realizza un'interfaccia.

Shape ottiene l'astrattezza con i costruttori protected; il modo più corretto e usato sono le funzioni virtuali pure.

astratta/astratta.cppcpp
#include <iostream>
#include <vector>
using namespace std;

class GeoObj {                                   // interfaccia: solo funzioni virtuali pure
public:
    virtual void draw() const = 0;
    virtual double area() const = 0;
    virtual ~GeoObj() {}                         // distruttore virtuale
};
class Rectangle : public GeoObj {
public:
    Rectangle(double w, double h) : w_{w}, h_{h} {}
    void draw() const override { cout << "rettangolo " << w_ << "x" << h_ << '\n'; }
    double area() const override { return w_ * h_; }
private:
    double w_, h_;
};
class Square : public Rectangle {
public:
    explicit Square(double l) : Rectangle{l, l} {}
    void draw() const override { cout << "quadrato (derivato da rettangolo) "; Rectangle::draw(); }
};
class Incomplete : public GeoObj {               // non ridefinisce area(): resta astratta
public:
    void draw() const override {}
};

int main()
{
    // GeoObj g;                                 // errore: classe astratta
    // Incomplete i;                             // errore: area() non ridefinita
    Rectangle r{2, 3};
    Square s{4};
    vector<const GeoObj*> v{&r, &s};
    for (const GeoObj* p : v) { p->draw(); cout << "  area " << p->area() << '\n'; }
}

Uscita: rettangolo 2x3, area 6, quadrato (derivato da rettangolo) rettangolo 4x4, area 16. Incomplete non ridefinisce area() e quindi non si può istanziare; Square eredita area() da Rectangle e ridefinisce solo draw().

Il distruttore virtuale e le chiamate nei costruttori

Se si distrugge un oggetto attraverso un puntatore alla base (delete p con p di tipo Shape*) e il distruttore non è virtuale, viene eseguito solo ~Shape(): il comportamento è indefinito, di fatto si perde la pulizia della derivata. Una classe con funzioni virtuali ha quindi quasi sempre un distruttore virtuale, come virtual ~Shape() {}.

dtor/dtor.cppcpp
#include <iostream>
#include <string>
using namespace std;

class Base {
public:
    Base()  { cout << "Base() "; who(); }                // chiamata virtuale nel costruttore
    virtual ~Base() { cout << "~Base() "; who(); }       // e nel distruttore
    virtual void who() const { cout << "[Base::who]\n"; }
};
class Derived : public Base {
public:
    Derived() : name_{new string("risorsa")} { cout << "Derived() "; who(); }
    ~Derived() override { cout << "~Derived() "; who(); delete name_; }
    void who() const override { cout << "[Derived::who]\n"; }
private:
    string* name_;
};

int main()
{
    Base* p = new Derived;            // costruzione: prima Base, poi Derived
    p->who();                         // chiamata virtuale: Derived::who
    delete p;                         // distruttore virtuale: prima ~Derived, poi ~Base
}

Uscita:

Base() [Base::who]
Derived() [Derived::who]
[Derived::who]
~Derived() [Derived::who]
~Base() [Base::who]

Due fatti da notare. (1) delete p dal tipo Base* esegue prima ~Derived e poi ~Base, ordine inverso della costruzione: è merito del distruttore virtuale. (2) Dentro il costruttore di Base la chiamata a who() esegue Base::who, anche se l'oggetto sarà un Derived: mentre gira il costruttore della base, la parte derivata non esiste ancora, e il C++ non permette di arrivarci. Lo stesso accade nel distruttore di Base (la parte Derived è già distrutta). Per questo non si chiamano funzioni virtuali nei costruttori e nei distruttori aspettandosi il comportamento della derivata.

Layout degli oggetti: vptr e vtbl

Come sa il programma quale funzione eseguire? Si considerino Shape (con vector<Point> points e i dati del colore), Open_polyline (nessun dato aggiuntivo) e Circle (il raggio r).

  • I dati membro di una classe derivata sono messi dopo quelli della base: un Circle è points, lcolor, ls, fcolor, r. Un puntatore a Shape che punta a un Circle vede correttamente la parte iniziale; il resto lo conosce solo Circle.
  • Per ogni classe (non per ogni oggetto) esiste una virtual table (vtbl), una tabella con un puntatore alla versione corretta di ciascuna funzione virtuale. Per l'esempio: la vtbl di Open_polyline punta a Shape::draw_lines() e Shape::move() (non ha ridefinito niente); quella di Circle punta a Circle::draw_lines() e a Shape::move() (ha ridefinito solo draw_lines).
  • Ogni oggetto contiene un puntatore nascosto, il virtual pointer (vptr), che punta alla vtbl della sua classe.
oggetto contenuto vtbl a cui punta il vptr
Open_polyline vptr, points, lcolor, ls, fcolor Shape::draw_lines, Shape::move
Circle vptr, points, lcolor, ls, fcolor, r Circle::draw_lines, Shape::move

Una chiamata virtuale p->draw_lines() si traduce in: leggi il vptr dell'oggetto, prendi dalla vtbl il puntatore alla funzione, chiamala. Il costo è un accesso in più per chiamata e un puntatore in più per oggetto. Il tutto si può costruire a mano in poche righe:

vtbl/vtbl.cppcpp
#include <iostream>
using namespace std;

// Versione "a mano" del meccanismo delle funzioni virtuali: ogni classe ha UNA tabella
// di puntatori a funzione (vtbl), ogni oggetto ha UN puntatore a quella della sua classe (vptr).
struct ShapeV;                                   // tipo "Shape" con vptr
struct VTable {
    void (*draw_lines)(const ShapeV*);
    void (*move)(ShapeV*, int, int);
};
struct ShapeV {
    const VTable* vptr;                          // aggiunto in testa a ogni oggetto
    int x, y;                                    // dati della classe base
};
struct CircleV {
    ShapeV base;                                 // i dati della derivata seguono quelli della base
    int r;
};

void shape_draw_lines(const ShapeV* s) { cout << "Shape::draw_lines (" << s->x << ',' << s->y << ")\n"; }
void shape_move(ShapeV* s, int dx, int dy) { s->x += dx; s->y += dy; }
void circle_draw_lines(const ShapeV* s)
{
    const CircleV* c = reinterpret_cast<const CircleV*>(s);   // la base e' all'inizio
    cout << "Circle::draw_lines centro (" << s->x << ',' << s->y << ") raggio " << c->r << '\n';
}

const VTable shape_vtbl  = { shape_draw_lines, shape_move };    // Shape: tutto suo
const VTable circle_vtbl = { circle_draw_lines, shape_move };   // Circle: ridefinisce solo draw_lines

// la "chiamata virtuale": si passa SEMPRE per il vptr dell'oggetto, qualunque sia il tipo del puntatore
void draw(const ShapeV* s) { s->vptr->draw_lines(s); }

int main()
{
    ShapeV s{&shape_vtbl, 1, 2};
    CircleV c{{&circle_vtbl, 3, 4}, 7};
    ShapeV* p1 = &s;
    ShapeV* p2 = &c.base;                        // puntatore "alla base" che punta a un cerchio
    draw(p1);
    draw(p2);                                    // sceglie circle_draw_lines grazie al vptr
    p2->vptr->move(p2, 10, 10);                  // Circle non ridefinisce move: usa quello di Shape
    draw(p2);
}

Uscita:

Shape::draw_lines (1,2)
Circle::draw_lines centro (3,4) raggio 7
Circle::draw_lines centro (13,14) raggio 7

La funzione draw non sa se riceve una forma o un cerchio: lo decide il vptr dell'oggetto. Le stesse dimensioni degli oggetti lo confermano:

sz/sz.cppcpp
#include <iostream>
using namespace std;

struct Plain   { int a; int b; };                 // nessuna funzione virtuale
struct Virt    { int a; int b; virtual void f() {} };   // + vptr
struct VirtDer : Virt { int c; };                 // dati della derivata dopo quelli della base

int main()
{
    cout << "puntatore: " << sizeof(void*) << " byte\n";
    cout << "Plain:   " << sizeof(Plain) << '\n';
    cout << "Virt:    " << sizeof(Virt) << " (Plain + vptr, con eventuale padding)\n";
    cout << "VirtDer: " << sizeof(VirtDer) << " (Virt + c)\n";
    cout << "Virt - Plain = " << sizeof(Virt) - sizeof(Plain) << '\n';
}

Compilato per WebAssembly a 32 bit stampa 4, 8, 12, 16 e Virt - Plain = 4: la classe con una funzione virtuale è più grande di esattamente un puntatore (4 byte qui, 8 byte su un sistema a 64 bit), la derivata aggiunge solo c. Per la disposizione di stack e heap, e la memoria degli oggetti locali, vedi C++ - chiamate a funzione e layout della memoriauna chiamata a funzione crea un record di attivazione (RA) con una copia dei parametri e le variabili locali, impilato nello stack (LIFO) e tolto all'uscita; la dimensione dipende dalle variabili locali e il tempo per inserirlo è costante; il processo ha cinque regioni di memoria: text (istruzioni, spesso read-only), initialized data (globali e statiche inizializzate), uninitialized data o BSS (globali e statiche non inizializzate, azzerate dal sistema), stack (RA) e heap (allocazione dinamica con new e delete); la durata (storage duration) di una variabile è diversa dallo scope: le globali e le static sono statiche (esistono per tutto il programma, inizializzate una volta), le locali sono automatiche (create all'entrata nel blocco, distrutte in ordine inverso all'uscita); le globali sono inizializzate prima del main ma l'ordine fra file diversi non è definito.C++ - chiamate a funzione e layout della memoria →.

Domande d'esame

1. Cosa sono una funzione virtuale e una funzione virtuale pura? Che caratteristiche ha una classe con almeno una funzione virtuale pura e quali sono le conseguenze sulle derivate? Quando conviene usarle? Traccia. Virtuale: dichiarata virtual nella base, ridefinibile nelle derivate con stessa firma (override), chiamata risolta a run-time sul tipo effettivo. Pura: = 0, non implementata nella base. La classe è astratta, non si istanzia; ogni derivata deve fare override di tutte le pure, altrimenti è astratta a sua volta. Si usano per definire un'interfaccia comune e per il polimorfismo dinamico (es. GeoObj con draw e area, gestite tramite vector<GeoObj*>). Esempio di codice con base astratta e due derivate.

2. Dato il codice con B, D, DD (f virtuale, g no, DD::f senza const) dire cosa stampa call(dd). Traccia. call(const B&): f virtuale ma DD::f non è override (manca const), quindi esce D::f; g non virtuale, tipo statico B: esce B::g. Risposta: D::f B::g.

3. Che cos'è lo slicing e come lo si evita? Traccia. Copiare una derivata in una base (vector<Shape> v; v.push_back(circle);) lascia solo i membri della base: si perdono i dati e il tipo dinamico. Si evita disabilitando copia e assegnamento nella base (= delete) e usando puntatori o riferimenti.

4. Come funzionano vptr e vtbl? Traccia. Una vtbl per classe (puntatori alle versioni corrette delle virtuali), un vptr in ogni oggetto che punta alla vtbl della sua classe; la chiamata virtuale legge il vptr e salta alla funzione. Costo: un puntatore per oggetto e una indirezione per chiamata.

Versione ripasso

forme/forme.cppcpp
#include <initializer_list>
#include <iostream>
#include <vector>
using namespace std;

struct Point { int x, y; };

class Shape {                       // classe base astratta (costruttori protected)
public:
    void draw() const { cout << "disegno " << name() << ": "; draw_lines(); cout << '\n'; }
    virtual void move(int dx, int dy) { for (auto& p : points) { p.x += dx; p.y += dy; } }
    Point point(int i) const { return points[i]; }
    int number_of_points() const { return static_cast<int>(points.size()); }

    Shape(const Shape&) = delete;              // niente copia: evita lo slicing
    Shape& operator=(const Shape&) = delete;
    virtual ~Shape() {}
protected:
    Shape() {}
    Shape(initializer_list<Point> lst) : points{lst} {}
    virtual void draw_lines() const {          // comportamento di default: poligonale
        for (const Point& p : points) cout << '(' << p.x << ',' << p.y << ") ";
    }
    virtual const char* name() const { return "Shape"; }
    void add(Point p) { points.push_back(p); }
private:
    vector<Point> points;
};

class Open_polyline : public Shape {         // non ridefinisce nulla
public:
    Open_polyline(initializer_list<Point> lst) : Shape{lst} {}
protected:
    const char* name() const override { return "Open_polyline"; }
};

class Circle : public Shape {
public:
    Circle(Point c, int rr) : r{rr} { add(c); }
    void move(int dx, int dy) override { Shape::move(dx, dy); cout << "[Circle::move] "; }
protected:
    void draw_lines() const override {
        cout << "cerchio di centro (" << point(0).x << ',' << point(0).y << ") raggio " << r;
    }
    const char* name() const override { return "Circle"; }
private:
    int r;                                     // dato membro aggiunto dalla derivata
};

int main()
{
    // Shape s;                                // errore: costruttore protected, classe astratta
    Open_polyline poly{{0, 0}, {10, 0}, {10, 5}};
    Circle c{{3, 4}, 7};

    vector<Shape*> v{&poly, &c};               // un vector<B*> contiene derivate diverse
    for (const Shape* s : v) s->draw();        // per ogni oggetto la funzione giusta
    v[1]->move(1, 1);                          // chiamata virtuale: Circle::move
    v[1]->draw();
    // Circle copia = c;                       // errore: costruttore di copia cancellato
}
  • Codice (virt/virt.cpp):
virt/virt.cppcpp
#include <iostream>
using namespace std;

class B {
public:
    virtual void f() const { cout << "B::f "; }
    void g() const { cout << "B::g "; }             // non virtuale
};
class D : public B {
public:
    void f() const { cout << "D::f "; }             // override (virtual implicito)
    void g() const { cout << "D::g "; }             // nasconde B::g
};
class DD : public D {
public:
    void f() { cout << "DD::f "; }                  // NON e' override: manca const
    void g() const { cout << "DD::g "; }
};

void call(const B& b) { b.f(); b.g(); cout << '\n'; }

int main()
{
    B b;  D d;  DD dd;
    call(b);
    call(d);
    call(dd);
    b.f();  b.g();  d.f();  d.g();  dd.f();  dd.g();
    cout << '\n';
}
  • Codice (vtbl/vtbl.cpp):
vtbl/vtbl.cppcpp
#include <iostream>
using namespace std;

// Versione "a mano" del meccanismo delle funzioni virtuali: ogni classe ha UNA tabella
// di puntatori a funzione (vtbl), ogni oggetto ha UN puntatore a quella della sua classe (vptr).
struct ShapeV;                                   // tipo "Shape" con vptr
struct VTable {
    void (*draw_lines)(const ShapeV*);
    void (*move)(ShapeV*, int, int);
};
struct ShapeV {
    const VTable* vptr;                          // aggiunto in testa a ogni oggetto
    int x, y;                                    // dati della classe base
};
struct CircleV {
    ShapeV base;                                 // i dati della derivata seguono quelli della base
    int r;
};

void shape_draw_lines(const ShapeV* s) { cout << "Shape::draw_lines (" << s->x << ',' << s->y << ")\n"; }
void shape_move(ShapeV* s, int dx, int dy) { s->x += dx; s->y += dy; }
void circle_draw_lines(const ShapeV* s)
{
    const CircleV* c = reinterpret_cast<const CircleV*>(s);   // la base e' all'inizio
    cout << "Circle::draw_lines centro (" << s->x << ',' << s->y << ") raggio " << c->r << '\n';
}

const VTable shape_vtbl  = { shape_draw_lines, shape_move };    // Shape: tutto suo
const VTable circle_vtbl = { circle_draw_lines, shape_move };   // Circle: ridefinisce solo draw_lines

// la "chiamata virtuale": si passa SEMPRE per il vptr dell'oggetto, qualunque sia il tipo del puntatore
void draw(const ShapeV* s) { s->vptr->draw_lines(s); }

int main()
{
    ShapeV s{&shape_vtbl, 1, 2};
    CircleV c{{&circle_vtbl, 3, 4}, 7};
    ShapeV* p1 = &s;
    ShapeV* p2 = &c.base;                        // puntatore "alla base" che punta a un cerchio
    draw(p1);
    draw(p2);                                    // sceglie circle_draw_lines grazie al vptr
    p2->vptr->move(p2, 10, 10);                  // Circle non ridefinisce move: usa quello di Shape
    draw(p2);
}

Esercizi su questo argomento

Teoria collegata