Ereditarietà e Relazioni Is-A
Impara l'ereditarietà pubblica, il concatenamento dei costruttori, il problema dello slicing, l'ereditarietà multipla e il problema del diamante — e quando l'ereditarietà è la scelta giusta.
L'Ereditarietà Modella la Relazione Is-A
L'ereditarietà pubblica modella una relazione "is-a" (è un): un Dog è un Animal, un SavingsAccount è un BankAccount. Questo è regolato dal Principio di Sostituzione di Liskov (LSP): ovunque ci si aspetti una classe base, una classe derivata deve funzionare correttamente senza sorprese.
L'ereditarietà è una delle caratteristiche più potenti — e più abusate — dell'OOP. Non ogni relazione è "is-a". Uno Stack non è un Vector, anche se ne usa uno internamente. Usare l'ereditarietà dove sarebbe appropriata la composizione porta a codice fragile e difficile da mantenere.
Ereditarietà di Base e Concatenamento dei Costruttori
Una classe derivata eredita tutti i membri della classe base. Il costruttore della classe base viene eseguito per primo, poi quello della classe derivata. La distruzione avviene in ordine inverso.
#include <iostream>
#include <string>
class Animal {
std::string name_;
int age_;
public:
Animal(const std::string& name, int age)
: name_{name}, age_{age}
{
std::cout << "Animal constructed: " << name_ << '\n';
}
virtual ~Animal() {
std::cout << "Animal destroyed: " << name_ << '\n';
}
const std::string& name() const { return name_; }
int age() const { return age_; }
virtual void speak() const {
std::cout << name_ << " makes a sound\n";
}
};
class Dog : public Animal { // public inheritance = is-a
std::string breed_;
public:
Dog(const std::string& name, int age, const std::string& breed)
: Animal{name, age} // base class constructor MUST be called first
, breed_{breed}
{
std::cout << "Dog constructed: " << name() << '\n';
}
~Dog() override {
std::cout << "Dog destroyed: " << name() << '\n';
}
void speak() const override {
std::cout << name() << " barks! (" << breed_ << ")\n";
}
};
int main() {
Dog rex{"Rex", 5, "German Shepherd"};
rex.speak(); // Rex barks! (German Shepherd)
// Destruction order: Dog destroyed, then Animal destroyed
return 0;
}Il Problema dello Slicing
Lo slicing degli oggetti si verifica quando un oggetto derivato viene assegnato o passato per valore a una variabile di tipo classe base. La parte derivata viene "tagliata via" — rimane solo la porzione base. È un bug sottile e frequente.
#include <iostream>
#include <string>
#include <vector>
#include <memory>
class Shape {
public:
virtual ~Shape() = default;
virtual std::string type() const { return "Shape"; }
};
class Circle : public Shape {
double radius_;
public:
explicit Circle(double r) : radius_{r} {}
std::string type() const override { return "Circle"; }
};
// BUG: passing by value slices the object!
void print_type_bad(Shape s) {
std::cout << s.type() << '\n'; // always prints "Shape"!
}
// CORRECT: pass by reference to preserve polymorphism
void print_type_good(const Shape& s) {
std::cout << s.type() << '\n'; // prints actual type
}
int main() {
Circle c{5.0};
print_type_bad(c); // "Shape" — SLICED!
print_type_good(c); // "Circle" — correct
// Slicing also happens with containers of values:
// std::vector<Shape> shapes; // BAD: slicing on insert
// Use pointers instead:
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(3.0));
std::cout << shapes[0]->type() << '\n'; // "Circle"
return 0;
}Ereditarietà Multipla e il Problema del Diamante
Il C++ permette a una classe di ereditare da più basi. Il problema del diamante si verifica quando due basi condividono un antenato comune, facendo sì che la classe derivata contenga due copie dei dati dell'antenato. L'ereditarietà virtuale risolve il problema.
#include <iostream>
// Diamond problem:
// Device
// / \
// Printer Scanner
// \ /
// AllInOne
class Device {
protected:
int id_;
public:
Device(int id) : id_{id} {
std::cout << "Device(" << id_ << ")\n";
}
virtual ~Device() = default;
};
// Virtual inheritance: only ONE copy of Device in AllInOne
class Printer : virtual public Device {
public:
Printer(int id) : Device{id} {
std::cout << "Printer\n";
}
void print_doc() { std::cout << "Printing on device " << id_ << '\n'; }
};
class Scanner : virtual public Device {
public:
Scanner(int id) : Device{id} {
std::cout << "Scanner\n";
}
void scan_doc() { std::cout << "Scanning on device " << id_ << '\n'; }
};
class AllInOne : public Printer, public Scanner {
public:
// With virtual inheritance, the most-derived class
// must initialize the virtual base directly
AllInOne(int id) : Device{id}, Printer{id}, Scanner{id} {
std::cout << "AllInOne\n";
}
};
int main() {
AllInOne aio{42};
aio.print_doc(); // Printing on device 42
aio.scan_doc(); // Scanning on device 42
// Only ONE Device sub-object exists — no ambiguity
return 0;
}Tipi di Ritorno Covarianti e Accesso protected
I tipi di ritorno covarianti permettono a una classe derivata di fare l'override di una funzione virtuale e restituire un tipo puntatore o riferimento più specifico (derivato). Per esempio, se Base::clone() restituisce Base*, Derived::clone() può restituire Derived*.
Lo specificatore di accesso protected si colloca tra public e private: i membri protected sono accessibili alla classe stessa e alle sue classi derivate, ma non al codice esterno. Usa protected per i membri di cui le classi derivate hanno legittimamente bisogno — ma con cautela, perché crea un accoppiamento tra base e derivata.
Un errore comune è usare l'ereditarietà solo per riutilizzare codice. Se uno Stack eredita da std::vector, gli utenti possono chiamare push_back, insert e altre operazioni del vector che violano l'astrazione dello stack. L'ereditarietà dovrebbe modellare una vera relazione is-a che soddisfa il Principio di Sostituzione di Liskov. Per riutilizzare il codice, preferisci la composizione (uno Stack che *contiene* un vector).
Usa sempre l'ereditarietà public — l'ereditarietà private/protected serve raramente ed è fonte di confusione. Dichiara sempre i distruttori delle classi base come virtual. Passa gli oggetti polimorfici per riferimento o puntatore, mai per valore (per evitare lo slicing). Preferisci la composizione all'ereditarietà, a meno che la relazione non sia davvero "is-a". Evita gerarchie di ereditarietà profonde (2-3 livelli sono di solito il limite pratico).
- L'ereditarietà pubblica modella la relazione is-a — la classe derivata deve soddisfare il Principio di Sostituzione di Liskov
- I costruttori base vengono eseguiti per primi, i distruttori per ultimi (ordine inverso rispetto alla costruzione)
- Lo slicing degli oggetti si verifica quando un oggetto derivato viene passato per valore a una base — usa sempre riferimenti o puntatori
- Il problema del diamante si risolve con l'ereditarietà
virtual, ma è meglio preferire la composizione - L'ereditarietà serve alla sostituibilità, non al riuso del codice — per quest'ultimo usa la composizione
Quiz — Metti alla prova le tue conoscenze
(15 XP)1. Che cos'è lo slicing degli oggetti?
2. In che modo l'ereditarietà virtuale risolve il problema del diamante?
3. Perché NON dovresti ereditare da `std::vector` per creare una classe `Stack`?