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:
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:
class Circle : public Shape {
// ...
};Definizione (ereditarietà pubblica). Con
class D : public B,Dè la classe derivata eBla classe base.Deredita tutti i membri diBe può accedere a quellipubliceprotected(non aiprivate). Esistono anche l'ereditarietàprivateeprotected, 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:
#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 7Cosa è successo, passo per passo.
Shapeha il costruttore di default e quello coninitializer_list<Point>protected: solo le derivate possono costruire il sottooggettoShape, quindiShape s;non compila. È una classe astratta: serve solo come interfaccia comune.Open_polylinenon ridefinisce nessuna funzione: usa tutte quelle diShape, compresadraw_lines()(la poligonale). Non ha dati membro aggiuntivi: si appoggia supoints.Circleridefiniscedraw_lines()emove()e aggiunge il dato membror.moverichiamaShape::move(qualificando il nome si sceglie la versione della base) e poi aggiunge il suo messaggio.- Il
vector<Shape*>contiene un puntatore aOpen_polylinee uno aCircle: un puntatore aShapepuò puntare a qualunque derivata. Il ciclos->draw()chiama sempreShape::draw(), ma dentro questadraw_lines()ename()sono virtuali, quindi per ciascun oggetto viene eseguita la versione giusta. v[1]->move(1, 1)ha tipo staticoShape*ma l'oggetto è unCircle: parteCircle::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.
#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:
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
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:
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).
#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. Concall(d)esceD::f. Concall(dd)esce ancoraD::f, perchéDD::f()non èconste quindi non è un override (ha una firma diversa): è una funzione nuova che nascondeD::fquando si usa unDD, ma per il meccanismo virtuale l'ultimo override èD::f. Il compilatore avverte conhides overloaded virtual function.g()non è virtuale: la scelta è fatta a tempo di compilazione sul tipo del riferimento, quindi sempreB::g, anche perdedd.- 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.
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:
class B {
public:
virtual void f() = 0; // l'override è obbligatorio
virtual void g() = 0;
};
B b; // errore: B è astrattaUna 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.
#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() {}.
#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 aShapeche punta a unCirclevede correttamente la parte iniziale; il resto lo conosce soloCircle. - 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 diOpen_polylinepunta aShape::draw_lines()eShape::move()(non ha ridefinito niente); quella diCirclepunta aCircle::draw_lines()e aShape::move()(ha ridefinito solodraw_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:
#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 7La funzione draw non sa se riceve una forma o un cerchio: lo decide il vptr dell'oggetto. Le stesse dimensioni degli oggetti lo confermano:
#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
Ereditarietà pubblica
class D : public B:Deredita i membripubliceprotecteddiB(mai iprivate) e li estende. Is a = ereditarietà (un cerchio è una forma, non vale il contrario); has a = composizione (un'automobile ha un volante). Costruttori: prima la base, poi la derivata; distruttori all'inverso.Gerarchia di forme:
Point(struct),Shapebase con interfaccia comune, derivateCircle(aggiunger) eOpen_polyline.vector<Shape*>contiene derivate diverse; le chiamate virtuali eseguono la funzione giusta per ciascuna. Vedi 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 →.Slicing: copiare una derivata in una base (
vector<Shape> v; v.push_back(c);) taglia i dati in più e il tipo dinamico. Difesa: nella baseShape(const Shape&) = delete; Shape& operator=(const Shape&) = delete;, e puntatori o riferimenti.Funzione virtuale e override: stesso nome, tipi e
const. La funzione eseguita dipende dall'oggetto (run-time), non dal tipo del puntatore o riferimento. Non virtuale: decide il tipo statico. TestB/D/DD:call(dd)stampaD::f B::gperchéDD::f()senzaconstnon è override.override: dichiara l'intenzione; errore se non è un vero override (only virtual member functions can be marked 'override').Virtuale pura
virtual void f() = 0;: classe astratta, non istanziabile; le derivate devono fare override, altrimenti restano astratte. Strumento per le interfacce. In alternativa costruttoriprotected.Distruttore virtuale nelle basi polimorfe: senza,
delete psuBase*esegue solo~Base(comportamento indefinito). Con esso:~Derivedpoi~Base. Nei costruttori e distruttori le chiamate virtuali non arrivano alla derivata (Base()chiamaBase::who).Layout: dati della derivata dopo quelli della base; per ogni classe una vtbl (puntatori alle versioni delle virtuali), in ogni oggetto un vptr alla vtbl della sua classe. Chiamata virtuale = leggi vptr, prendi il puntatore dalla vtbl, chiama. Costo: un puntatore per oggetto e una indirezione (
sizeofcresce di un puntatore).Errori tipici: dimenticare
constnell'override; distruttore non virtuale nella base; copiare oggetti polimorfi per valore; credere cheg()non virtuale sia scelta dall'oggetto; classe astratta istanziata; funzioni virtuali nei costruttori.Codice (forme/forme.cpp):
#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):
#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):
#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
- Esercizio - Ereditarietà, is-a e has-a, slicing (primo appello 2022-23, teoria)
- Esercizio - Gerarchia MyPointer, MyUniquePointer e MySharedPointer (terzo appello 2021-22, programmazione)
- Esercizio - Navigazione di un robot in un labirinto (laboratorio 8)
- Esercizio - Polimorfismo dinamico e statico (primo appello 2021-22, teoria)
- Esercizio - Polimorfismo statico e dinamico, bounded e unbounded (secondo appello 2022-23, teoria)
- Esercizio - Quiz e domande su funzioni virtuali, passaggio degli argomenti, dangling pointer e doppia deallocazione (quarto appello 2023-24, teoria)
- Esercizio - Quiz e domande su spostamento, passaggio dei parametri e contenitori STL (primo appello 2023-24, teoria)
- Esercizio - Quiz su contenitori STL, override, sizeof, void e virtual table (secondo appello 2021-22, quiz)
- Esercizio - Quiz su include guard, librerie dinamiche, explicit e function object (primo appello 2021-22, quiz)
- Esercizio - Quiz su scope, riferimenti, conversioni e puntatori (secondo appello 2022-23, quiz)